Files
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at 77a82eb.
Code, docs and the public catalogue only: no plant or job knowledge
layers, no client, job or producer names (tools/scrub.py clean against
the widened term list; a second word-level grep clean). The intro deck is
left out because its slide images cannot be checked for job drawings.
2026-09-09 08:35:15 -05:00

249 lines
8.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Watch the QNA folder and report updates, so the agent can act on them.
Why this exists
---------------
QNA/ is where Will drops questions and tasks (`questions.md`, `for-dsh-*.md`,
`task_*.md`). An older attempt (QNA/.watch.register.ps1) watched the folder
but only *logged* events -- nobody read the log. This tool closes the loop:
it runs as a background job of the live agent session, exits the moment
something changes, and the harness delivers its stdout to the agent, who then
reads the update and does whatever it asks, then re-arms the watcher.
Tracked files
-------------
Top-level files in QNA/ ending in .md or .txt, excluding README.md (the
convention doc, effectively static) and anything starting with '.' (logs,
state). Subfolders (wp345_case/, .dsh-home/) are work artifacts, not tasks --
not tracked.
Usage
-----
python tools/qna_watch.py --status # what is tracked, what is pending
python tools/qna_watch.py # diff now against last armed state, exit
python tools/qna_watch.py --wait 21600 # arm: block up to 6 h, exit on first change
python tools/qna_watch.py --interval 2.0 # poll cadence (default 2 s)
Exit codes: 0 on report (change or timeout) -- the caller just reads stdout.
State lives in QNA/.qna-watch.json (gitignored like the rest of QNA).
CPython 3 only -- this sits beside rvt.py/sweep.py, not under the extension.
"""
import argparse
import hashlib
import json
import os
import re
import sys
import time
from datetime import datetime
STATE_NAME = '.qna-watch.json'
TRACKED_EXT = ('.md', '.txt')
EXCLUDED_NAMES = {'readme.md'} # convention doc, not tasks
def qna_dir_default():
return os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'QNA'))
def list_tracked(qna):
"""Names of top-level files worth watching, sorted."""
out = []
try:
entries = os.listdir(qna)
except OSError as exc:
sys.stderr.write('cannot list {0}: {1}\n'.format(qna, exc))
return out
for name in entries:
path = os.path.join(qna, name)
if name.startswith('.'):
continue
if name.lower() in EXCLUDED_NAMES:
continue
if not os.path.isfile(path):
continue
if os.path.splitext(name)[1].lower() not in TRACKED_EXT:
continue
out.append(name)
return sorted(out)
def snapshot(qna):
"""{name: sha256hex} for every tracked file. Missing/unreadable -> None."""
snap = {}
for name in list_tracked(qna):
path = os.path.join(qna, name)
try:
with open(path, 'rb') as fh:
snap[name] = hashlib.sha256(fh.read()).hexdigest()
except OSError:
snap[name] = None
return snap
def load_state(qna):
path = os.path.join(qna, STATE_NAME)
try:
with open(path, 'r', encoding='utf-8') as fh:
data = json.load(fh)
if isinstance(data, dict) and isinstance(data.get('hashes'), dict):
return data['hashes']
except (OSError, ValueError):
pass
return None
def save_state(qna, hashes):
path = os.path.join(qna, STATE_NAME)
tmp = path + '.tmp'
with open(tmp, 'w', encoding='utf-8') as fh:
json.dump({'version': 1, 'saved': datetime.now().isoformat(timespec='seconds'),
'hashes': hashes}, fh, ensure_ascii=False, indent=1)
os.replace(tmp, path)
def diff_hashes(old, new):
"""(changed, added, deleted) as sorted name lists."""
changed = sorted(n for n in set(old) & set(new) if old[n] != new[n])
added = sorted(set(new) - set(old))
deleted = sorted(set(old) - set(new))
return changed, added, deleted
QUESTION_RE = re.compile(r'^##\s+(Q\s*[::])', re.IGNORECASE)
ANSWER_RE = re.compile(r'^\*\*A')
def unanswered_questions(path):
"""First lines of `## Q:` sections that carry no `**A**` reply yet."""
try:
with open(path, 'r', encoding='utf-8', errors='replace') as fh:
lines = fh.read().splitlines()
except OSError:
return []
found, current, in_fence, in_q = [], None, False, False
for raw in lines:
if raw.lstrip().startswith('```'):
in_fence = not in_fence
if current is not None:
current.append(raw)
continue
if not in_fence and raw.startswith('## '):
if in_q and current is not None:
found.append(current)
in_q = bool(QUESTION_RE.match(raw))
current = [raw.strip()] if in_q else None
continue
if in_q and current is not None:
if ANSWER_RE.match(raw.lstrip()):
in_q = False # answered; stop collecting
current = None
else:
current.append(raw)
if in_q and current is not None:
found.append(current)
out = []
for sec in found:
head = sec[0]
body = next((l.strip() for l in sec[1:] if l.strip()), '')
out.append('{0} -- {1}'.format(head, body[:120]))
return out
def report(qna, changed, added, deleted, hashes):
stamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
print('=== QNA-WATCH update detected {0} ==='.format(stamp))
for name in changed:
print(' changed: {0}'.format(name))
for name in added:
print(' added: {0}'.format(name))
for name in deleted:
print(' deleted: {0}'.format(name))
qpath = os.path.join(qna, 'questions.md')
if os.path.isfile(qpath):
pend = unanswered_questions(qpath)
if pend:
print('unanswered questions in questions.md ({0}):'.format(len(pend)))
for one in pend:
print(' - {0}'.format(one.replace('\n', ' ')))
if hashes is not None:
save_state(qna, hashes)
print('AGENT ACTION: read the changed file(s) under QNA/, do what they ask,')
print('then re-arm: python tools/qna_watch.py --wait 21600')
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument('--root', default=qna_dir_default(), help='folder to watch (default: repo QNA)')
ap.add_argument('--wait', type=float, default=0.0,
help='seconds to block waiting for a change (0 = diff once and exit)')
ap.add_argument('--interval', type=float, default=2.0, help='poll seconds while waiting')
ap.add_argument('--status', action='store_true', help='print tracked files + pending questions, exit')
args = ap.parse_args()
try:
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
except Exception:
pass
qna = os.path.normpath(args.root)
if not os.path.isdir(qna):
sys.stderr.write('no such folder: {0}\n'.format(qna))
return 2
if args.status:
names = list_tracked(qna)
print('watching {0} ({1} tracked)'.format(qna, len(names)))
for name in names:
path = os.path.join(qna, name)
mt = datetime.fromtimestamp(os.path.getmtime(path)).strftime('%Y-%m-%d %H:%M:%S')
print(' {0} {1}'.format(mt, name))
qpath = os.path.join(qna, 'questions.md')
if os.path.isfile(qpath):
pend = unanswered_questions(qpath)
print('unanswered questions: {0}'.format(len(pend)))
for one in pend:
print(' - {0}'.format(one))
return 0
prev = load_state(qna)
base = snapshot(qna)
if prev is None:
# first ever run: lay down the baseline quietly, nothing counts as new
save_state(qna, base)
print('(qna-watch) baseline initialized: {0} tracked files'.format(len(base)))
prev, base = base, snapshot(qna)
else:
changed, added, deleted = diff_hashes(prev, base)
if changed or added or deleted:
# something happened while nobody was watching -- backlog first
report(qna, changed, added, deleted, base)
return 0
if args.wait <= 0:
print('(qna-watch) no changes since last arm')
return 0
deadline = time.monotonic() + args.wait
while time.monotonic() < deadline:
time.sleep(max(0.2, args.interval))
now = snapshot(qna)
if now == base:
continue
# debounce: let the writer finish, take the settled snapshot
time.sleep(1.0)
now = snapshot(qna)
changed, added, deleted = diff_hashes(base, now)
report(qna, changed, added, deleted, now)
return 0
print('(qna-watch) no changes in {0:.0f}s; exiting for re-arm'.format(args.wait))
return 0
if __name__ == '__main__':
sys.exit(main())