#!/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())