mirror of
https://github.com/Druthulu/BFM-decomp
synced 2026-10-02 07:59:52 -04:00
340 lines
12 KiB
Python
340 lines
12 KiB
Python
#!/usr/bin/env python
|
|
"""discussion.py -- owns .run/DISCUSSION and discussions/INDEX.md.
|
|
|
|
`on` : the PreToolUse guard denies edits and non-read-only shell while the flag exists.
|
|
`off` : removes the flag; `--new-record` writes discussions/D<n>.md and indexes it.
|
|
`triage <id> <status>` : writes a deferred item's status into its index row (planner-gen triage).
|
|
Stdlib only; no imports from `pa/`.
|
|
"""
|
|
|
|
import argparse
|
|
import datetime
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
try: # UTF-8 output even when not started with -X utf8
|
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
except Exception:
|
|
pass
|
|
|
|
INDEX_HEADER = ("# Discussions -- one line per record\n"
|
|
"# id | topic | date | status | path\n")
|
|
TEMPLATE = """# {did} — {topic} ({date}, session {kind})
|
|
Status: open
|
|
|
|
## Decisions
|
|
- (one line each)
|
|
|
|
## Plan edits
|
|
- (plan_edit.py commands; executed: yes/no)
|
|
|
|
## Open
|
|
- (unresolved items)
|
|
|
|
## Deferred
|
|
- deferred: <what> → <phase>
|
|
"""
|
|
|
|
VALID_STATUSES = ("executed", "dropped", "failed")
|
|
STATUS_PREFIX = ("deferred:", "planned:") # deferred:<phase>, planned:<phase>
|
|
|
|
|
|
def find_root(start=None):
|
|
env = os.environ.get("PA_PROJECT_ROOT")
|
|
if env:
|
|
return os.path.abspath(env)
|
|
cur = os.path.abspath(start or os.getcwd())
|
|
while True:
|
|
if os.path.isfile(os.path.join(cur, ".claude", "pa.json")):
|
|
return cur
|
|
parent = os.path.dirname(cur)
|
|
if parent == cur:
|
|
break
|
|
cur = parent
|
|
try:
|
|
import subprocess
|
|
out = subprocess.run(["git", "rev-parse", "--show-toplevel"],
|
|
cwd=start or os.getcwd(), capture_output=True, text=True)
|
|
if out.returncode == 0 and out.stdout.strip():
|
|
return os.path.abspath(out.stdout.strip())
|
|
except Exception:
|
|
pass
|
|
return os.path.abspath(start or os.getcwd())
|
|
|
|
|
|
def cfg(root):
|
|
try:
|
|
with open(os.path.join(root, ".claude", "pa.json"), encoding="utf-8") as fh:
|
|
return json.load(fh)
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def phase_dir(root):
|
|
c = cfg(root)
|
|
return os.path.join(root, c.get("phase_ends_dir") or c.get("phase_dir") or "phase-ends")
|
|
|
|
|
|
def read_text(path):
|
|
with open(path, encoding="utf-8", newline="") as fh:
|
|
return fh.read()
|
|
|
|
|
|
def write_text(path, text):
|
|
d = os.path.dirname(path)
|
|
if d and not os.path.isdir(d):
|
|
os.makedirs(d, exist_ok=True)
|
|
with open(path, "w", encoding="utf-8", newline="") as fh:
|
|
fh.write(text)
|
|
|
|
|
|
def _cnote(path, **kw):
|
|
try:
|
|
_d = os.path.dirname(os.path.abspath(__file__))
|
|
if _d not in sys.path:
|
|
sys.path.insert(0, _d)
|
|
import _credit
|
|
_credit.note(path, **kw)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def rel(root, path):
|
|
try:
|
|
return os.path.relpath(path, root).replace("\\", "/")
|
|
except ValueError:
|
|
return path.replace("\\", "/")
|
|
|
|
|
|
def die(msg):
|
|
sys.stdout.write("refused: %s\n" % msg)
|
|
raise SystemExit(1)
|
|
|
|
|
|
def flag_path(root):
|
|
return os.path.join(root, ".run", "DISCUSSION")
|
|
|
|
|
|
def dis_dir(root):
|
|
return os.path.join(phase_dir(root), "current", "discussions")
|
|
|
|
|
|
def next_id(root):
|
|
d = dis_dir(root)
|
|
used = []
|
|
if os.path.isdir(d):
|
|
for name in os.listdir(d):
|
|
m = re.match(r"^D([0-9]+)\.md$", name)
|
|
if m:
|
|
used.append(int(m.group(1)))
|
|
return 1 + max(used or [0])
|
|
|
|
|
|
def cmd_on(a, root):
|
|
path = flag_path(root)
|
|
if os.path.isfile(path):
|
|
sys.stdout.write("discussion already on (since %s)\n"
|
|
% read_text(path).strip().split("\n")[0])
|
|
return 0
|
|
stamp = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
|
write_text(path, "%s\ntopic: %s\n" % (stamp, a.topic or "-"))
|
|
sys.stdout.write("discussion ON -- edits and non-read-only shell are denied "
|
|
"until `discussion.py off`\n")
|
|
return 0
|
|
|
|
|
|
def cmd_off(a, root):
|
|
path = flag_path(root)
|
|
topic = a.topic or "-"
|
|
kind = a.kind or os.environ.get("PA_MODE") or "router"
|
|
if os.path.isfile(path):
|
|
first = read_text(path).split("\n")
|
|
for line in first:
|
|
if line.startswith("topic:") and not a.topic:
|
|
topic = line.split(":", 1)[1].strip() or "-"
|
|
os.remove(path)
|
|
sys.stdout.write("discussion OFF\n")
|
|
else:
|
|
sys.stdout.write("discussion was already off\n")
|
|
if not a.new_record:
|
|
return 0
|
|
did = "D%d" % next_id(root)
|
|
target = os.path.join(dis_dir(root), "%s.md" % did)
|
|
date = datetime.date.today().isoformat()
|
|
write_text(target, TEMPLATE.format(did=did, topic=topic, date=date, kind=kind))
|
|
_cnote(target, script="discussion.py", sub="off", whole=True, root=root)
|
|
index = os.path.join(dis_dir(root), "INDEX.md")
|
|
if not os.path.isfile(index):
|
|
write_text(index, INDEX_HEADER)
|
|
line = "%s | %s | %s | open | %s" % (did, topic, date, rel(root, target))
|
|
with open(index, "a", encoding="utf-8", newline="") as fh:
|
|
fh.write(line + "\n")
|
|
_cnote(index, script="discussion.py", sub="off", added=len(line), root=root)
|
|
sys.stdout.write("%s\n%s\n" % (rel(root, target), line))
|
|
return 0
|
|
|
|
|
|
def cmd_status(a, root):
|
|
# when called with a record id and value: set the record's status
|
|
did = getattr(a, "did", None)
|
|
if did:
|
|
return _set_record_status(a, root)
|
|
path = flag_path(root)
|
|
if os.path.isfile(path):
|
|
body = read_text(path).strip().replace("\n", " | ")
|
|
sys.stdout.write("on | %s\n" % body)
|
|
else:
|
|
sys.stdout.write("off\n")
|
|
return 0
|
|
|
|
|
|
def _set_record_status(a, root):
|
|
"""Set a discussion record's Status: line and rewrite its index line."""
|
|
did = a.did
|
|
value = a.value
|
|
reason = " ".join(a.reason) if a.reason else None
|
|
# validate status value
|
|
if value not in VALID_STATUSES and not any(value.startswith(p) for p in STATUS_PREFIX):
|
|
die("invalid status: %s (expected executed, deferred:<phase>, "
|
|
"planned:<phase>, dropped, failed)" % value)
|
|
# find the record file
|
|
d = dis_dir(root)
|
|
record = os.path.join(d, "%s.md" % did)
|
|
if not os.path.isfile(record):
|
|
die("no record %s.md in %s" % (did, rel(root, d)))
|
|
# rewrite the Status: line in the record
|
|
text = read_text(record)
|
|
lines = text.split("\n")
|
|
found = False
|
|
for i, line in enumerate(lines):
|
|
if line.startswith("Status:"):
|
|
if reason:
|
|
lines[i] = "Status: %s — %s" % (value, reason)
|
|
else:
|
|
lines[i] = "Status: %s" % value
|
|
found = True
|
|
break
|
|
if not found:
|
|
# insert after the first line (the title)
|
|
if reason:
|
|
lines.insert(1, "Status: %s — %s" % (value, reason))
|
|
else:
|
|
lines.insert(1, "Status: %s" % value)
|
|
write_text(record, "\n".join(lines))
|
|
_cnote(record, script="discussion.py", sub="status", root=root)
|
|
# rewrite the index line
|
|
index = os.path.join(d, "INDEX.md")
|
|
if os.path.isfile(index):
|
|
idx_text = read_text(index)
|
|
idx_lines = idx_text.split("\n")
|
|
for i, line in enumerate(idx_lines):
|
|
if line.startswith(did + " |"):
|
|
parts = [c.strip() for c in line.split("|")]
|
|
# grammar: id | topic | date | status | path
|
|
if len(parts) >= 5:
|
|
parts[3] = value
|
|
elif len(parts) == 4:
|
|
# old format without status: id | topic | date | path
|
|
parts.insert(3, value)
|
|
idx_lines[i] = " | ".join(parts)
|
|
break
|
|
write_text(index, "\n".join(idx_lines))
|
|
_cnote(index, script="discussion.py", sub="status", root=root)
|
|
sys.stdout.write("%s | %s\n" % (did, value))
|
|
return 0
|
|
|
|
|
|
TRIAGE_STATUS_RE = re.compile(r"^(deferred:\S+|planned:[^/\s]+/T[0-9]+|dropped)$")
|
|
DISC_CUM_HEADER = "# Discussions -- cumulative\n# phase | id | topic | date | status | path\n"
|
|
|
|
|
|
def _anchor(lines, idx):
|
|
b = len(lines[idx - 1]) if idx > 0 else 0
|
|
a = len(lines[idx + 1]) if idx + 1 < len(lines) else 0
|
|
return b + a
|
|
|
|
|
|
def cmd_triage(a, root):
|
|
"""Write one triage decision: the status cell of the index row holding <id>; a PhaseEnd item
|
|
(P<phase>-<k>, no row yet) gets a new DISCUSSION_INDEX.md row."""
|
|
rid, status = a.id, a.value
|
|
if not TRIAGE_STATUS_RE.match(status):
|
|
die("invalid triage status: %s (expected deferred:<phase>|deferred:gen<N>, "
|
|
"planned:<phase>/T<n>, dropped)" % status)
|
|
_d = os.path.dirname(os.path.abspath(__file__))
|
|
if _d not in sys.path:
|
|
sys.path.insert(0, _d)
|
|
import _genplan
|
|
pe_dir = phase_dir(root)
|
|
for p, i, cells, col in _genplan.index_rows(pe_dir):
|
|
if cells[col] != rid:
|
|
continue
|
|
plines = read_text(p).split("\n")
|
|
raw = plines[i]
|
|
body, eol = (raw[:-1], "\r") if raw.endswith("\r") else (raw, "")
|
|
parts = body.split("|")
|
|
_old = len(raw)
|
|
parts[col + 3] = " %s " % status
|
|
plines[i] = "|".join(parts) + eol
|
|
write_text(p, "\n".join(plines))
|
|
_cnote(p, script="discussion.py", sub="triage",
|
|
removed=_old, added=len(plines[i]), anchor=_anchor(plines, i), root=root)
|
|
sys.stdout.write("triage: %s -> %s\n" % (rid, status))
|
|
return 0
|
|
gp = os.path.join(root, "GENERATION_PLAN.md")
|
|
gtext = read_text(gp) if os.path.isfile(gp) else ""
|
|
for it in _genplan.deferred_items(pe_dir, gtext, None, every=True):
|
|
if it["kind"] != "phaseend" or it["id"] != rid:
|
|
continue
|
|
cum = os.path.join(pe_dir, "DISCUSSION_INDEX.md")
|
|
ctext = read_text(cum) if os.path.isfile(cum) else DISC_CUM_HEADER
|
|
if ctext and not ctext.endswith("\n"):
|
|
ctext += "\n"
|
|
pphase = _genplan.PE_RE.match(os.path.basename(it["pe"])).group(1)
|
|
row = "%s | %s | %s | %s | %s | %s" % (pphase, rid, it["text"].replace("|", "/"),
|
|
datetime.date.today().isoformat(), status,
|
|
rel(root, it["pe"]))
|
|
clines = (ctext + row).split("\n")
|
|
write_text(cum, ctext + row + "\n")
|
|
_cnote(cum, script="discussion.py", sub="triage",
|
|
removed=0, added=len(row), anchor=_anchor(clines, len(clines) - 1), root=root)
|
|
sys.stdout.write("triage: %s -> %s\n" % (rid, status))
|
|
return 0
|
|
die("unknown deferred item: %s (no index row, no newest-PhaseEnd ## Deferred item)" % rid)
|
|
|
|
|
|
def main(argv=None):
|
|
p = argparse.ArgumentParser(
|
|
prog="discussion.py", description="Read-only discussion mode (/discuss, /proceed).")
|
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
s = sub.add_parser("on", help="raise .run/DISCUSSION")
|
|
s.add_argument("--topic")
|
|
s = sub.add_parser("off", help="drop the flag; --new-record writes discussions/D<n>.md")
|
|
s.add_argument("--new-record", dest="new_record", action="store_true")
|
|
s.add_argument("--topic")
|
|
s.add_argument("--kind")
|
|
s = sub.add_parser("status", help="on|off or set a record status")
|
|
s.add_argument("did", nargs="?", help="record id (e.g. D1)")
|
|
s.add_argument("value", nargs="?", help="executed|deferred:<phase>|planned:<phase>|dropped|failed")
|
|
s.add_argument("--reason", nargs="*", help="reason for the status change")
|
|
s = sub.add_parser("triage", help="set a deferred item's index status (planner-gen triage)")
|
|
s.add_argument("id", help="index id (D<n>, ...) or PhaseEnd item P<phase>-<k>")
|
|
s.add_argument("value", help="deferred:<phase>|deferred:gen<N>|planned:<phase>/T<n>|dropped")
|
|
s.add_argument("--reason", nargs="*", help="reason (not written; for the caller's log)")
|
|
a = p.parse_args(argv)
|
|
root = find_root()
|
|
return {"on": cmd_on, "off": cmd_off, "status": cmd_status,
|
|
"triage": cmd_triage}[a.cmd](a, root)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
sys.exit(main())
|
|
except SystemExit:
|
|
raise
|
|
except Exception as exc:
|
|
sys.stdout.write("refused: %s: %s\n" % (type(exc).__name__, exc))
|
|
sys.exit(1)
|