WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m18s
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user