25592fa6c2
ci / test (push) Successful in 6m14s
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.
249 lines
8.5 KiB
Python
249 lines
8.5 KiB
Python
#!/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())
|