@antoneeo/mkt-agentic-sdlc-skill 0.23.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -0
- package/README.md +3 -3
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/skills/mkt-agentic-sdlc/ENFORCEMENT.md +22 -10
- package/skills/mkt-agentic-sdlc/SKILL.md +3 -2
- package/skills/mkt-agentic-sdlc/docs_site/app.js +7 -1
- package/skills/mkt-agentic-sdlc/docs_site/strings.js +9 -0
- package/skills/mkt-agentic-sdlc/docs_site/style.css +2 -0
- package/skills/mkt-agentic-sdlc/guides.md +15 -1
- package/skills/mkt-agentic-sdlc/memory.md +1 -1
- package/skills/mkt-agentic-sdlc/review.md +9 -1
- package/skills/mkt-agentic-sdlc/routing.md +4 -0
- package/skills/mkt-agentic-sdlc/scripts/docs_site.py +89 -6
- package/skills/mkt-agentic-sdlc/scripts/knowledge.py +1444 -50
- package/skills/mkt-agentic-sdlc/scripts/mkt_check.py +2 -1
- package/skills/mkt-agentic-sdlc/scripts/sdlc_core.py +1115 -75
|
@@ -21,6 +21,8 @@ Commands:
|
|
|
21
21
|
stale list areas modified after the last analysis recorded in audit_plan.md (exit 1 if any)
|
|
22
22
|
mark record paths as ANALYZED with the current reference (git hash, else UTC timestamp)
|
|
23
23
|
gate PreToolUse hook: block writes on protected paths without an IN_PROGRESS ANALYSIS (exit 2)
|
|
24
|
+
verdict PreToolUse hook: refuse an edit when the session declared no guide-router
|
|
25
|
+
verdict, or never read a guide it names (JSON deny on stdout; always exit 0)
|
|
24
26
|
|
|
25
27
|
Hybrid/devPNT mode: pass --hybrid explicitly on check/stale (skips audit-plan
|
|
26
28
|
staleness, delegated to devPNT/KL) and on gate (also unlocks when an approved
|
|
@@ -94,6 +96,10 @@ DESIGN_REVIEW_EPOCH = "2026-07-28"
|
|
|
94
96
|
# like the two gates above: a fixed epoch cannot protect a late upgrader, so a
|
|
95
97
|
# warning here would redden --strict CI on work written before the upgrade.
|
|
96
98
|
CHRONOLOGY_EPOCH = "2026-10-18"
|
|
99
|
+
# Guide check (F-078): an L3 code ANALYSIS started on/after this date owes a
|
|
100
|
+
# filled '## Guide check'. The day AFTER the rule was written, so the analyses
|
|
101
|
+
# opened that day by parallel units (which never saw the template) stay quiet.
|
|
102
|
+
GUIDE_CHECK_EPOCH = "2026-10-24"
|
|
97
103
|
NEXT_MAX_CHARS = 240 # this repository's longest `next:` was 214 at the epoch
|
|
98
104
|
ID_SCAN_MAX_REFS = 300 # A3 is skipped above it (one advisory says so, T9)
|
|
99
105
|
ID_SCAN_REFS_PER_CALL = 50 # keeps one git command line far below 32k chars
|
|
@@ -259,6 +265,7 @@ REQUIRED_CAPABILITIES = frozenset({
|
|
|
259
265
|
OPTIONAL_CAPABILITIES = frozenset({
|
|
260
266
|
"architect_pass", # does the component already exist? (code overlay)
|
|
261
267
|
"interaction_contract", # actor-facing surface spec between use cases and solution (code overlay)
|
|
268
|
+
"preservation_constraints", # the Impact's P table, and validate's note on its absence (code overlay)
|
|
262
269
|
"taxonomy_pass", # do the categories/topics already exist? (knowledge overlay)
|
|
263
270
|
"comprehension_guides", # source_kind: code maps of complex components
|
|
264
271
|
"tdd", # test-first discipline
|
|
@@ -396,8 +403,17 @@ def orient_docs():
|
|
|
396
403
|
("Guide router (when-to-consult)", f"{d}/reference/INDEX.md"),
|
|
397
404
|
("Last session handoff", f"{d}/audit/handoff.md"),
|
|
398
405
|
]
|
|
399
|
-
|
|
400
|
-
|
|
406
|
+
# F-075: the WHOLE output -- the knowledge layer's append included -- stays under
|
|
407
|
+
# Claude Code's 10,000-character hook cap. Above it the client saves the output
|
|
408
|
+
# to a file and injects a 2,000-character preview, and the agent never opens the
|
|
409
|
+
# file: the router printed third, after README and INDEX, never reached a session.
|
|
410
|
+
ORIENT_MAX_TOTAL_CHARS = 9500 # whole output (P-TM T2)
|
|
411
|
+
ORIENT_APPEND_RESERVE = 2000 # left for the knowledge layer's append, which cuts itself to it
|
|
412
|
+
ORIENT_ROUTER_CHARS = 3000 # ~20 guides; the full router stays one read away
|
|
413
|
+
ORIENT_REGISTRY_CHARS = 2600 # registry rows + project-wide notes
|
|
414
|
+
ORIENT_TRIAGE_LINE = ("Triage every request (Rule Zero): L1 trivial - L2 small - L3 significant "
|
|
415
|
+
"- Spike. Declare the level with the guide-router verdict (a guide below, "
|
|
416
|
+
"or \"no match\"). When in doubt, pick the higher level.")
|
|
401
417
|
|
|
402
418
|
|
|
403
419
|
# ----------------------------------------------------------------- utilities
|
|
@@ -524,12 +540,55 @@ def has_etdd_shadow(root):
|
|
|
524
540
|
return any("tdd" in p.name.lower() for p in sol.glob("SHADOW_*.md"))
|
|
525
541
|
|
|
526
542
|
|
|
543
|
+
def newest_shadow(solutions_dir, doc_key):
|
|
544
|
+
"""The newest devPNT shadow of `doc_key` in `solutions_dir`, or None.
|
|
545
|
+
Only `SHADOW_<doc_key>_v<N>[.<N>...].md` counts -- the exact key, so
|
|
546
|
+
`<doc_key>_extra` is another key -- and versions compare number by number
|
|
547
|
+
(v1.10 above v1.9, v1.0 above v1). Names outside that rule are ignored.
|
|
548
|
+
F-081: the Hybrid target of a claim marker; reusable by any consumer."""
|
|
549
|
+
if not Path(solutions_dir).is_dir():
|
|
550
|
+
return None
|
|
551
|
+
best = None
|
|
552
|
+
for p in Path(solutions_dir).iterdir():
|
|
553
|
+
v = _shadow_version(p.name, doc_key)
|
|
554
|
+
if v is not None and p.is_file():
|
|
555
|
+
if best is None or v > best[0]:
|
|
556
|
+
best = (v, p)
|
|
557
|
+
return best[1] if best else None
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _shadow_version(name, doc_key):
|
|
561
|
+
"""The version tuple of `SHADOW_<doc_key>_v<N>[.<N>...].md`, else None."""
|
|
562
|
+
m = re.match(r"^SHADOW_%s_v(\d+(?:\.\d+)*)\.md$" % re.escape(doc_key), name)
|
|
563
|
+
return tuple(int(x) for x in m.group(1).split(".")) if m else None
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def stray_shadow(solutions_dir, doc_key):
|
|
567
|
+
"""A file of the same exact key that `newest_shadow` does not read
|
|
568
|
+
(`SHADOW_<doc_key>.md`, `..._v1.2a.md`), or None -- the name a user needs
|
|
569
|
+
to see when no shadow resolves. A longer key is never a stray."""
|
|
570
|
+
if not Path(solutions_dir).is_dir():
|
|
571
|
+
return None
|
|
572
|
+
rx = re.compile(r"^SHADOW_%s(?:_v[^_]*)?\.md$" % re.escape(doc_key))
|
|
573
|
+
return next((p.name for p in sorted(Path(solutions_dir).iterdir())
|
|
574
|
+
if rx.match(p.name) and _shadow_version(p.name, doc_key) is None),
|
|
575
|
+
None)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def skip_dir(name):
|
|
579
|
+
"""The one definition of a directory that holds no project files: VCS and
|
|
580
|
+
dependency/build directories, every dot-directory, and the docs root (by
|
|
581
|
+
name, at any depth). `iter_files` and the knowledge layer's inventory both
|
|
582
|
+
walk by it, so the two can never disagree about what the project is."""
|
|
583
|
+
return name in SKIP_DIRS or name == docs_dir() or name.startswith(".")
|
|
584
|
+
|
|
585
|
+
|
|
527
586
|
def iter_files(target):
|
|
528
587
|
if target.is_file():
|
|
529
588
|
yield target
|
|
530
589
|
return
|
|
531
590
|
for dirpath, dirnames, filenames in os.walk(target):
|
|
532
|
-
dirnames[:] = [d for d in dirnames if
|
|
591
|
+
dirnames[:] = [d for d in dirnames if not skip_dir(d)]
|
|
533
592
|
for name in filenames:
|
|
534
593
|
yield Path(dirpath) / name
|
|
535
594
|
|
|
@@ -870,10 +929,10 @@ def parse_registry_rows(text):
|
|
|
870
929
|
"""Workstream ids in a registry table, hand-written or generated."""
|
|
871
930
|
ids = []
|
|
872
931
|
for line in text.splitlines():
|
|
873
|
-
|
|
874
|
-
if not
|
|
932
|
+
cells = _table_cells(line)
|
|
933
|
+
if not cells:
|
|
875
934
|
continue
|
|
876
|
-
first =
|
|
935
|
+
first = cells[0]
|
|
877
936
|
if first and first.lower() != "workstream":
|
|
878
937
|
ids.append(first)
|
|
879
938
|
return ids
|
|
@@ -1169,6 +1228,20 @@ def _opener_at(lines, i):
|
|
|
1169
1228
|
return None
|
|
1170
1229
|
|
|
1171
1230
|
|
|
1231
|
+
def malformed_openers(body):
|
|
1232
|
+
"""[(line, text)] -- dated openers whose bold does not pair: `**DATE …` with no
|
|
1233
|
+
closing `**` on the line, or a bare `DATE …` that closes one. Either way the
|
|
1234
|
+
chronology shows a label running into the entry's body (field case 2026-10-19:
|
|
1235
|
+
two release notes in project_notes.md)."""
|
|
1236
|
+
lines = _blank_comments(body).splitlines()
|
|
1237
|
+
out = []
|
|
1238
|
+
for i in range(len(lines)):
|
|
1239
|
+
m = _opener_at(lines, i)
|
|
1240
|
+
if m and bool(m.group(3)) != ("**" in m.group(5)):
|
|
1241
|
+
out.append((i + 1, lines[i]))
|
|
1242
|
+
return out
|
|
1243
|
+
|
|
1244
|
+
|
|
1172
1245
|
def entry_openers(body):
|
|
1173
1246
|
"""[(date, label, line)] -- one per dated entry opener; line is 1-based
|
|
1174
1247
|
within `body`. An undated paragraph belongs to the entry above it. Comments
|
|
@@ -1267,6 +1340,11 @@ def chronology_advisories(root):
|
|
|
1267
1340
|
if len(nxt) > NEXT_MAX_CHARS:
|
|
1268
1341
|
out.append(f"audit/{p.name}: next: is {len(nxt)} chars (> {NEXT_MAX_CHARS}): one "
|
|
1269
1342
|
"next step here, the state goes to the Diary as a dated entry")
|
|
1343
|
+
notes = ai_path(root, "audit", PROJECT_NOTES)
|
|
1344
|
+
if notes.is_file():
|
|
1345
|
+
for n, t in malformed_openers(read_text(notes)):
|
|
1346
|
+
out.append(f"audit/{PROJECT_NOTES}: line {n}: dated entry '{t[:60]}' has unpaired "
|
|
1347
|
+
"bold -- open entries with **YYYY-MM-DD — label.**")
|
|
1270
1348
|
return out
|
|
1271
1349
|
|
|
1272
1350
|
|
|
@@ -1454,10 +1532,30 @@ def has_ledger_heading(text):
|
|
|
1454
1532
|
opens a comment at the start of a line -- nuking to EOF on an inline
|
|
1455
1533
|
mention (or an unclosed example inside a fence) made a document that
|
|
1456
1534
|
HAS its ledger get told it has none."""
|
|
1535
|
+
return bool(re.search(r"^##[ \t]+Capability Ledger[ \t]*$", strip_non_prose(text), re.M))
|
|
1536
|
+
|
|
1537
|
+
|
|
1538
|
+
def strip_non_prose(text):
|
|
1539
|
+
"""Text without fenced code blocks and HTML comments (see has_ledger_heading)."""
|
|
1457
1540
|
stripped = re.sub(r"^(```|~~~).*?^\1", "", text, flags=re.M | re.S)
|
|
1458
1541
|
stripped = re.sub(r"<!--.*?-->", "", stripped, flags=re.S)
|
|
1459
|
-
|
|
1460
|
-
|
|
1542
|
+
return re.sub(r"^[ \t]*<!--(?!.*?-->).*\Z", "", stripped, flags=re.M | re.S)
|
|
1543
|
+
|
|
1544
|
+
|
|
1545
|
+
def has_guide_check(text):
|
|
1546
|
+
"""True when a real '## Guide check' heading carries an answer: the template's
|
|
1547
|
+
comment alone answers nothing, so a copied-but-unfilled section still owes it."""
|
|
1548
|
+
m = re.search(r"^##[ \t]+Guide check[ \t]*$(.*?)(?=^#{1,2}[ \t]|\Z)",
|
|
1549
|
+
strip_non_prose(text), re.M | re.S)
|
|
1550
|
+
return bool(m and m.group(1).strip())
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
def guide_check_due(meta, domain):
|
|
1554
|
+
"""True when an ANALYSIS owes the Phase-3 guide question (SKILL.md): a code-lens
|
|
1555
|
+
L3 started on/after GUIDE_CHECK_EPOCH. Same grandfathering as ledger_due."""
|
|
1556
|
+
return domain == "code" and ledger_due(meta) and (
|
|
1557
|
+
parse_iso((meta.get("start_date") or "").strip().strip("'\""))
|
|
1558
|
+
>= parse_iso(GUIDE_CHECK_EPOCH))
|
|
1461
1559
|
|
|
1462
1560
|
|
|
1463
1561
|
def ledger_due(meta):
|
|
@@ -1706,6 +1804,22 @@ def cmd_validate(root, strict=False, hybrid=False):
|
|
|
1706
1804
|
if ledger_due(meta) and not has_ledger_heading(text):
|
|
1707
1805
|
advisories.append(f"{rel}: L3 without '## Capability Ledger' -- the architect pass "
|
|
1708
1806
|
"left no record (architect.md); run it before the Impact")
|
|
1807
|
+
if guide_check_due(meta, domain) and not has_guide_check(text):
|
|
1808
|
+
advisories.append(f"{rel}: L3 without a filled '## Guide check' -- does a CURRENT "
|
|
1809
|
+
"guide cover what this touches, and did understanding it force "
|
|
1810
|
+
"a model of a complex component no guide covers (guides.md, section 1)?")
|
|
1811
|
+
# F-085 FS7: code lens only (R12: the lens, not merely the domain -- a kb
|
|
1812
|
+
# install validating a code-domain unit sees no change), open L3 only, a
|
|
1813
|
+
# note and never an error, so no `--strict` exit changes (P5).
|
|
1814
|
+
impact = section_body(text, IMPACT_SECTION)
|
|
1815
|
+
if (has_capability("preservation_constraints") and domain == "code"
|
|
1816
|
+
and str(level or "").upper() == "L3"
|
|
1817
|
+
and status in ("PLANNED", "IN_PROGRESS")
|
|
1818
|
+
and impact is not None and not has_preservation_record(impact)):
|
|
1819
|
+
advisories.append(f"{rel}: open L3 whose '## Impact' has neither the "
|
|
1820
|
+
f"'{PRESERVATION_HEADING}' table nor the line "
|
|
1821
|
+
f"'{NO_SYMBOL_LINE}' -- what existing code must keep "
|
|
1822
|
+
"doing is not extractable (templates.md)")
|
|
1709
1823
|
|
|
1710
1824
|
# Generated index aligned
|
|
1711
1825
|
hist = ai / "strategic" / "features_history.md"
|
|
@@ -2001,7 +2115,8 @@ def cmd_stale(root, hybrid=False):
|
|
|
2001
2115
|
except ValueError: # symlink out of the tree: report absolute, never crash
|
|
2002
2116
|
name = str(fp)
|
|
2003
2117
|
changed.append(name)
|
|
2004
|
-
|
|
2118
|
+
docs_rel = docs_root_rel(root)
|
|
2119
|
+
changed = [c for c in changed if c != plan_rel and not _in_docs_root(c, docs_rel)]
|
|
2005
2120
|
if changed:
|
|
2006
2121
|
stale.append((rel, changed))
|
|
2007
2122
|
|
|
@@ -2022,9 +2137,54 @@ def cmd_stale(root, hybrid=False):
|
|
|
2022
2137
|
return 1 # stale areas dominate: rc already implied
|
|
2023
2138
|
|
|
2024
2139
|
|
|
2025
|
-
def
|
|
2140
|
+
def add_dated_note(root, note, day=None):
|
|
2141
|
+
"""Write `**DAY — note.**` into audit/project_notes.md, above the newest dated
|
|
2142
|
+
entry (below any undated preface), so the note opens with the shared grammar and
|
|
2143
|
+
the chronology reads it. The day defaults to today (local)."""
|
|
2144
|
+
import datetime as _dt
|
|
2145
|
+
day = day or _dt.date.today().isoformat()
|
|
2146
|
+
entry = "**%s — %s.**" % (day, note.strip().rstrip("."))
|
|
2147
|
+
f = ai_path(root, "audit", PROJECT_NOTES)
|
|
2148
|
+
if not f.is_file():
|
|
2149
|
+
f.parent.mkdir(parents=True, exist_ok=True)
|
|
2150
|
+
f.write_text(entry + "\n", encoding="utf-8")
|
|
2151
|
+
return entry
|
|
2152
|
+
raw = f.read_bytes()
|
|
2153
|
+
crlf = b"\r\n" in raw
|
|
2154
|
+
lines = raw.decode("utf-8", errors="replace").replace("\r\n", "\n").split("\n")
|
|
2155
|
+
at = next((i for i in range(len(lines)) if _opener_at(lines, i)), None)
|
|
2156
|
+
if at is None:
|
|
2157
|
+
while lines and not lines[-1].strip():
|
|
2158
|
+
lines.pop()
|
|
2159
|
+
lines += ["", entry, ""] if lines else [entry, ""]
|
|
2160
|
+
else:
|
|
2161
|
+
lines[at:at] = [entry, ""]
|
|
2162
|
+
text = "\n".join(lines)
|
|
2163
|
+
f.write_bytes((text.replace("\n", "\r\n") if crlf else text).encode("utf-8"))
|
|
2164
|
+
return entry
|
|
2165
|
+
|
|
2166
|
+
|
|
2167
|
+
def docs_root_rel(root):
|
|
2168
|
+
"""The docs root (ai_docs/, mkt_docs/ ...) relative to the project root, or None.
|
|
2169
|
+
It is governance, not an analyzable area: `validate` checks it directly, so `stale`
|
|
2170
|
+
never counts its files and `mark` never records it."""
|
|
2171
|
+
try:
|
|
2172
|
+
return ai_path(root).resolve().relative_to(Path(root).resolve()).as_posix()
|
|
2173
|
+
except ValueError:
|
|
2174
|
+
return None
|
|
2175
|
+
|
|
2176
|
+
|
|
2177
|
+
def _in_docs_root(rel, docs_rel):
|
|
2178
|
+
rel = rel.replace("\\", "/").rstrip("/")
|
|
2179
|
+
return bool(docs_rel) and (rel == docs_rel or rel.startswith(docs_rel + "/"))
|
|
2180
|
+
|
|
2181
|
+
|
|
2182
|
+
def cmd_mark(root, paths, note=None):
|
|
2026
2183
|
if not require_ai_docs(root, "mark"):
|
|
2027
2184
|
return 1
|
|
2185
|
+
if note is not None and (not note.strip() or "**" in note or "\n" in note):
|
|
2186
|
+
print("[ERROR] --note must be one line of text without '**'. Nothing was written.")
|
|
2187
|
+
return 1
|
|
2028
2188
|
f, lines, rows = parse_audit_plan(root)
|
|
2029
2189
|
use_git_ref = git_available(root) and not any(
|
|
2030
2190
|
git_has_changes(root, raw.replace("\\", "/").rstrip("/")) for raw in paths
|
|
@@ -2051,6 +2211,10 @@ def cmd_mark(root, paths):
|
|
|
2051
2211
|
"refusing to mark (use a project-relative path, '.' for the root). "
|
|
2052
2212
|
"Nothing was written.")
|
|
2053
2213
|
return 1
|
|
2214
|
+
if _in_docs_root(key, docs_root_rel(root)):
|
|
2215
|
+
print(f"[note] {raw}: the docs root is not an area to mark -- `validate` checks it "
|
|
2216
|
+
"directly. Skipped.")
|
|
2217
|
+
continue
|
|
2054
2218
|
keys.append(key)
|
|
2055
2219
|
|
|
2056
2220
|
appended = []
|
|
@@ -2070,6 +2234,13 @@ def cmd_mark(root, paths):
|
|
|
2070
2234
|
|
|
2071
2235
|
f.parent.mkdir(parents=True, exist_ok=True)
|
|
2072
2236
|
f.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
2237
|
+
if note is not None:
|
|
2238
|
+
print(f"[ok] dated entry added to audit/{PROJECT_NOTES}: {add_dated_note(root, note)}")
|
|
2239
|
+
elif not _open_units(root):
|
|
2240
|
+
# no open unit's Diary covers this work: a release, a fix after closure
|
|
2241
|
+
print(f"[note] no open unit records this work. If it is a release or a change worth "
|
|
2242
|
+
f"finding later, re-run with --note \"<what was done>\": mark writes the "
|
|
2243
|
+
f"dated entry in audit/{PROJECT_NOTES} for you.")
|
|
2073
2244
|
if use_git_ref:
|
|
2074
2245
|
# F-056: the reference is born here, so this is where the gestures that
|
|
2075
2246
|
# orphan it are named. An amend was the observed one.
|
|
@@ -2149,15 +2320,56 @@ def _orient_user_settings_path():
|
|
|
2149
2320
|
|
|
2150
2321
|
|
|
2151
2322
|
def _orient_hook_commands(root):
|
|
2152
|
-
"""Yield (command, at_user_level) across every known client config:
|
|
2153
|
-
three project-relative files, then the machine-global user settings --
|
|
2323
|
+
"""Yield (command, at_user_level, place) across every known client config:
|
|
2324
|
+
the three project-relative files, then the machine-global user settings --
|
|
2154
2325
|
the file the installer wires (F-042), without which a fixed machine's
|
|
2155
|
-
projects would read as unwired forever.
|
|
2326
|
+
projects would read as unwired forever. `place` is a display label, never a
|
|
2327
|
+
home path (F-075 prints it)."""
|
|
2156
2328
|
for rel in ORIENT_HOOK_CONFIG_FILES:
|
|
2157
2329
|
for cmd in _orient_entries_in(Path(root) / rel):
|
|
2158
|
-
yield cmd, False
|
|
2330
|
+
yield cmd, False, rel
|
|
2159
2331
|
for cmd in _orient_entries_in(_orient_user_settings_path()):
|
|
2160
|
-
yield cmd, True
|
|
2332
|
+
yield cmd, True, "user settings"
|
|
2333
|
+
|
|
2334
|
+
|
|
2335
|
+
def _orient_entry_live(root, cmd, at_user_level):
|
|
2336
|
+
"""True unless the entry names a validator that does not resolve.
|
|
2337
|
+
|
|
2338
|
+
A matching entry whose tokens name no validator is "cannot tell" and counts
|
|
2339
|
+
as live (the lib.js contract: never report broken on cannot-tell). Relative
|
|
2340
|
+
tokens resolve against the project root (the vendored-validator
|
|
2341
|
+
convention); a USER-level relative token has no cwd to resolve against and
|
|
2342
|
+
is treated as cannot-tell."""
|
|
2343
|
+
token = None
|
|
2344
|
+
for m in _ORIENT_HOOK_TOKEN_RE.finditer(cmd):
|
|
2345
|
+
tok = m.group(1) if m.group(1) is not None else m.group(2)
|
|
2346
|
+
if any(tok.endswith(n) for n in ORIENT_HOOK_ENTRY_POINTS):
|
|
2347
|
+
token = tok
|
|
2348
|
+
break
|
|
2349
|
+
if token is None:
|
|
2350
|
+
return True
|
|
2351
|
+
tp = Path(token)
|
|
2352
|
+
if not tp.is_absolute():
|
|
2353
|
+
if at_user_level:
|
|
2354
|
+
return True
|
|
2355
|
+
tp = Path(root) / tp
|
|
2356
|
+
return tp.exists()
|
|
2357
|
+
|
|
2358
|
+
|
|
2359
|
+
def orient_wiring(root):
|
|
2360
|
+
"""{client: {command: place}} for the LIVE orient entries, per client.
|
|
2361
|
+
|
|
2362
|
+
Per client because two clients never read each other's settings: a Codex
|
|
2363
|
+
entry beside a Claude Code one is two clients oriented once each, not one
|
|
2364
|
+
session oriented twice. Identical command strings count once. A dead entry
|
|
2365
|
+
delivers no copy and is left out (F-075 FS3)."""
|
|
2366
|
+
out = {}
|
|
2367
|
+
for cmd, at_user_level, place in _orient_hook_commands(root):
|
|
2368
|
+
if not _orient_entry_live(root, cmd, at_user_level):
|
|
2369
|
+
continue
|
|
2370
|
+
client = "codex" if place.startswith(".codex") else "claude"
|
|
2371
|
+
out.setdefault(client, {}).setdefault(cmd.strip(), place)
|
|
2372
|
+
return out
|
|
2161
2373
|
|
|
2162
2374
|
|
|
2163
2375
|
def orient_hook_state(root):
|
|
@@ -2172,22 +2384,9 @@ def orient_hook_state(root):
|
|
|
2172
2384
|
the project root (the vendored-validator convention); a USER-level relative
|
|
2173
2385
|
token has no cwd to resolve against and is treated as cannot-tell."""
|
|
2174
2386
|
matched = False
|
|
2175
|
-
for cmd, at_user_level in _orient_hook_commands(root):
|
|
2387
|
+
for cmd, at_user_level, _place in _orient_hook_commands(root):
|
|
2176
2388
|
matched = True
|
|
2177
|
-
|
|
2178
|
-
for m in _ORIENT_HOOK_TOKEN_RE.finditer(cmd):
|
|
2179
|
-
tok = m.group(1) if m.group(1) is not None else m.group(2)
|
|
2180
|
-
if any(tok.endswith(n) for n in ORIENT_HOOK_ENTRY_POINTS):
|
|
2181
|
-
token = tok
|
|
2182
|
-
break
|
|
2183
|
-
if token is None:
|
|
2184
|
-
return "wired"
|
|
2185
|
-
tp = Path(token)
|
|
2186
|
-
if not tp.is_absolute():
|
|
2187
|
-
if at_user_level:
|
|
2188
|
-
return "wired" # cannot tell: no cwd exists at user level
|
|
2189
|
-
tp = Path(root) / tp
|
|
2190
|
-
if tp.exists():
|
|
2389
|
+
if _orient_entry_live(root, cmd, at_user_level):
|
|
2191
2390
|
return "wired"
|
|
2192
2391
|
return "dead" if matched else "unwired"
|
|
2193
2392
|
|
|
@@ -2269,6 +2468,12 @@ REMIND_FALLBACK = (
|
|
|
2269
2468
|
)
|
|
2270
2469
|
|
|
2271
2470
|
|
|
2471
|
+
# F-075: constant, for every lens -- with sibling lenses installed nothing per
|
|
2472
|
+
# turn said to choose the lens. r19's bound rose to 600 for it (r32, owner).
|
|
2473
|
+
REMIND_LENS_SUFFIX = (" Above L1, with sibling lenses installed: choose the lens with "
|
|
2474
|
+
"routing.md and declare it.")
|
|
2475
|
+
|
|
2476
|
+
|
|
2272
2477
|
def cmd_remind(args):
|
|
2273
2478
|
"""UserPromptSubmit hook: print the lens's constant per-turn line. Always 0.
|
|
2274
2479
|
|
|
@@ -2277,25 +2482,60 @@ def cmd_remind(args):
|
|
|
2277
2482
|
try:
|
|
2278
2483
|
prof = profile()
|
|
2279
2484
|
line = prof.get("remind_line") or REMIND_FALLBACK.format(skill=prof["skill_name"])
|
|
2280
|
-
print(f"{prof['skill_name']}: {line}")
|
|
2485
|
+
print(f"{prof['skill_name']}: {line}{REMIND_LENS_SUFFIX}")
|
|
2281
2486
|
except Exception:
|
|
2282
2487
|
pass
|
|
2283
2488
|
return 0
|
|
2284
2489
|
|
|
2490
|
+
def read_hook_payload():
|
|
2491
|
+
"""The hook event JSON on stdin as a dict, or None when it cannot be read.
|
|
2492
|
+
|
|
2493
|
+
bytes -> utf-8-sig: the payload is UTF-8 JSON regardless of the console code
|
|
2494
|
+
page; '-sig' strips the BOM (PowerShell pipes)."""
|
|
2495
|
+
try:
|
|
2496
|
+
raw = sys.stdin.buffer.read().decode("utf-8-sig", errors="replace")
|
|
2497
|
+
payload = json.loads(raw)
|
|
2498
|
+
except Exception:
|
|
2499
|
+
return None
|
|
2500
|
+
return payload if isinstance(payload, dict) else None
|
|
2501
|
+
|
|
2502
|
+
|
|
2503
|
+
def hook_project(target, root=None, docs_name=None):
|
|
2504
|
+
"""The project root an edit hook judges, with its docs root name set, or None
|
|
2505
|
+
when two docs roots sit side by side. The hook runs in the session's current
|
|
2506
|
+
directory, which a `cd` moves (live probe 2026-10-27): the project AND its docs
|
|
2507
|
+
root name are the ones holding the edited file, never the ones around the
|
|
2508
|
+
working directory. An explicit root (--root) is kept as given."""
|
|
2509
|
+
start = Path(root) if root is not None else Path(target).resolve().parent
|
|
2510
|
+
try:
|
|
2511
|
+
found_root, name = resolve_docs_dir(argparse.Namespace(docs_dir=docs_name), start)
|
|
2512
|
+
except AmbiguousDocsRoot:
|
|
2513
|
+
return None
|
|
2514
|
+
set_docs_dir(name)
|
|
2515
|
+
if root is not None:
|
|
2516
|
+
return Path(root)
|
|
2517
|
+
if found_root is not None:
|
|
2518
|
+
return found_root
|
|
2519
|
+
found_root = find_project_root(start) # an explicit name: discovery did not run
|
|
2520
|
+
# No docs root above the edited file: the session's own project, as before the
|
|
2521
|
+
# hook resolved from the file -- the file's folder would unprotect a gated path.
|
|
2522
|
+
return found_root if ai_path(found_root).is_dir() else find_project_root()
|
|
2523
|
+
|
|
2524
|
+
|
|
2285
2525
|
def cmd_gate(args):
|
|
2286
2526
|
file_path = args.file or ""
|
|
2287
2527
|
if args.hook:
|
|
2528
|
+
payload = read_hook_payload()
|
|
2288
2529
|
try:
|
|
2289
|
-
# bytes -> utf-8-sig: the hook payload is UTF-8 JSON regardless of the
|
|
2290
|
-
# console code page; '-sig' strips the BOM (PowerShell pipes)
|
|
2291
|
-
raw = sys.stdin.buffer.read().decode("utf-8-sig", errors="replace")
|
|
2292
|
-
payload = json.loads(raw)
|
|
2293
2530
|
file_path = (payload.get("tool_input") or {}).get("file_path") or ""
|
|
2294
|
-
except
|
|
2531
|
+
except AttributeError:
|
|
2295
2532
|
return 0 # unparseable input: do not block
|
|
2296
|
-
if not file_path:
|
|
2533
|
+
if not isinstance(file_path, str) or not file_path:
|
|
2297
2534
|
return 0
|
|
2298
|
-
root = Path(args.root).resolve() if args.root else
|
|
2535
|
+
root = hook_project(file_path, Path(args.root).resolve() if args.root else None,
|
|
2536
|
+
getattr(args, "docs_dir", None))
|
|
2537
|
+
if root is None:
|
|
2538
|
+
return 0 # two docs roots side by side: cannot tell which governs
|
|
2299
2539
|
try:
|
|
2300
2540
|
rel = str(Path(file_path).resolve().relative_to(root)).replace("\\", "/")
|
|
2301
2541
|
except ValueError:
|
|
@@ -2326,6 +2566,607 @@ def cmd_gate(args):
|
|
|
2326
2566
|
return 2
|
|
2327
2567
|
|
|
2328
2568
|
|
|
2569
|
+
# ------------------------------------------------------------------ verdict
|
|
2570
|
+
# F-076: Rule Zero's router verdict, checked at the edit. A field agent edited
|
|
2571
|
+
# without declaring one and said nothing stopped it. This reads the session
|
|
2572
|
+
# transcript the client hands every hook and refuses the edit (JSON deny, exit
|
|
2573
|
+
# 0) only on certainty; whatever it cannot tell resolves to silence. It never
|
|
2574
|
+
# prints `allow` -- that would skip the user's permission prompt. Owner: block
|
|
2575
|
+
# (REQUEST_guide_verdict_check.md R3), wired by default (R4, rulings.md r34).
|
|
2576
|
+
|
|
2577
|
+
VERDICT_EDIT_TOOLS = ("Edit", "Write", "MultiEdit", "NotebookEdit")
|
|
2578
|
+
VERDICT_MAX_BYTES = 256 * 1024 * 1024 # a larger transcript: cannot afford to tell
|
|
2579
|
+
VERDICT_LINE_MAX = 1024 * 1024 # assistant lines are small; skip the rest unparsed
|
|
2580
|
+
# Markdown emphasis between the word and the colon ("**router**: no match") and
|
|
2581
|
+
# "Router verdict:" are the same declaration.
|
|
2582
|
+
_VERDICT_ROUTER_RE = re.compile(r"router(?:[\s*_`]+verdict)?[\s*_`]*:(.*)", re.IGNORECASE)
|
|
2583
|
+
# A declaration line may open with markdown: emphasis, quote, heading, bullet or a
|
|
2584
|
+
# numbered item ("## Level: L1", "1. Level: L1", "• router: no match").
|
|
2585
|
+
_LINE_OPEN = r"^[ \t>*_`#+•-]*(?:\d+[.)][ \t*_`]*)?"
|
|
2586
|
+
_LEVEL_WORD = r"(?:level|livello|niveau|nivel|stufe)\b[ \t*_`:·—–-]*(?:è[ \t]+)?"
|
|
2587
|
+
# ...and only on a line that opens with `router` or with a level (`L2`, `Spike`): a
|
|
2588
|
+
# verdict quoted inside a sentence is not one.
|
|
2589
|
+
_VERDICT_LINE_RE = re.compile(
|
|
2590
|
+
_LINE_OPEN + r"(?:router\b|" + _LEVEL_WORD + r"(?:(?:research[ \t]+)?spike|[A-Z][123])\b)", re.IGNORECASE)
|
|
2591
|
+
_VERDICT_NONE_RE = re.compile(r"[\s`*_\"'(]*(no match|absent)", re.IGNORECASE)
|
|
2592
|
+
# A level is DECLARED by a line that opens with the level word, in the form Rule
|
|
2593
|
+
# Zero prescribes -- level 1 alone, 2/3 with `router` on the same line -- so a level
|
|
2594
|
+
# quoted inside a sentence is not one. The letter is the lens's own (`L1`, `E1`):
|
|
2595
|
+
# groups are (letter, "1" or "", "2"/"3" or "").
|
|
2596
|
+
_DECLARATION_RE = re.compile(
|
|
2597
|
+
_LINE_OPEN + _LEVEL_WORD +
|
|
2598
|
+
r"([A-Z])(?:(1)\b|([23])\b(?=[^\n]*\brouter\b))", re.IGNORECASE | re.MULTILINE)
|
|
2599
|
+
_GUIDE_NAME_RE = re.compile(r"GUIDE_[A-Za-z0-9_-]+\.md")
|
|
2600
|
+
|
|
2601
|
+
|
|
2602
|
+
def transcript_facts(path):
|
|
2603
|
+
"""What the session transcript shows about the verdict duty, or None when it
|
|
2604
|
+
cannot be read within the bounds.
|
|
2605
|
+
|
|
2606
|
+
Returns {"assistant": entries parsed, "sidechain": a subagent's own transcript
|
|
2607
|
+
(Claude Code marks every entry of one `isSidechain`), "declared": a router verdict seen,
|
|
2608
|
+
"trivial": a level-1 declaration seen (`Level: L1`, `E1`), "named": guides the verdicts name,
|
|
2609
|
+
"read": guide names that appeared in a non-edit tool's input}."""
|
|
2610
|
+
facts = {"assistant": 0, "sidechain": False, "declared": False, "trivial": False,
|
|
2611
|
+
"named": set(), "read": set()}
|
|
2612
|
+
try:
|
|
2613
|
+
p = Path(path)
|
|
2614
|
+
if not p.is_file() or p.stat().st_size > VERDICT_MAX_BYTES:
|
|
2615
|
+
return None
|
|
2616
|
+
with p.open("rb") as fh:
|
|
2617
|
+
for raw in fh:
|
|
2618
|
+
if len(raw) > VERDICT_LINE_MAX or b'"assistant"' not in raw:
|
|
2619
|
+
continue
|
|
2620
|
+
try:
|
|
2621
|
+
entry = json.loads(raw.decode("utf-8", errors="replace"))
|
|
2622
|
+
except ValueError:
|
|
2623
|
+
continue
|
|
2624
|
+
if not isinstance(entry, dict) or entry.get("type") != "assistant":
|
|
2625
|
+
continue
|
|
2626
|
+
content = (entry.get("message") or {}).get("content")
|
|
2627
|
+
if not isinstance(content, list):
|
|
2628
|
+
continue
|
|
2629
|
+
facts["assistant"] += 1
|
|
2630
|
+
if entry.get("isSidechain") is True:
|
|
2631
|
+
facts["sidechain"] = True
|
|
2632
|
+
for block in content:
|
|
2633
|
+
if not isinstance(block, dict):
|
|
2634
|
+
continue
|
|
2635
|
+
if block.get("type") == "text":
|
|
2636
|
+
for line in str(block.get("text") or "").splitlines():
|
|
2637
|
+
d = _DECLARATION_RE.match(line)
|
|
2638
|
+
if d and d.group(2):
|
|
2639
|
+
facts["trivial"] = True
|
|
2640
|
+
m = _VERDICT_ROUTER_RE.search(line)
|
|
2641
|
+
if m and (d or _VERDICT_LINE_RE.match(line)):
|
|
2642
|
+
facts["declared"] = True
|
|
2643
|
+
if not _VERDICT_NONE_RE.match(m.group(1)):
|
|
2644
|
+
facts["named"].update(_GUIDE_NAME_RE.findall(m.group(1)))
|
|
2645
|
+
elif (block.get("type") == "tool_use"
|
|
2646
|
+
and block.get("name") not in VERDICT_EDIT_TOOLS):
|
|
2647
|
+
facts["read"].update(_GUIDE_NAME_RE.findall(
|
|
2648
|
+
json.dumps(block.get("input"), ensure_ascii=False)))
|
|
2649
|
+
except (OSError, ValueError, TypeError, AttributeError):
|
|
2650
|
+
return None
|
|
2651
|
+
return facts
|
|
2652
|
+
|
|
2653
|
+
|
|
2654
|
+
def verdict_reason(payload, root=None, docs_name=None):
|
|
2655
|
+
"""The refusal line for this edit event, or None when the edit may proceed.
|
|
2656
|
+
Without a root, the project is the one holding the edited file (hook_project)."""
|
|
2657
|
+
if payload.get("tool_name") not in VERDICT_EDIT_TOOLS or payload.get("agent_id"):
|
|
2658
|
+
return None
|
|
2659
|
+
tool_input = payload.get("tool_input") or {}
|
|
2660
|
+
target = tool_input.get("file_path") or tool_input.get("notebook_path")
|
|
2661
|
+
transcript = payload.get("transcript_path")
|
|
2662
|
+
if not isinstance(target, str) or not target or not transcript:
|
|
2663
|
+
return None
|
|
2664
|
+
root = hook_project(target, root, docs_name)
|
|
2665
|
+
if root is None:
|
|
2666
|
+
return None # two docs roots side by side: cannot tell which governs
|
|
2667
|
+
try:
|
|
2668
|
+
Path(target).resolve().relative_to(root)
|
|
2669
|
+
except (ValueError, OSError):
|
|
2670
|
+
return None # outside the project: not this check's concern
|
|
2671
|
+
ref = ai_path(root, "reference")
|
|
2672
|
+
if not ref.is_dir():
|
|
2673
|
+
return None
|
|
2674
|
+
guides = {g.name for g in ref.glob("GUIDE_*.md") if g.is_file()}
|
|
2675
|
+
if not guides:
|
|
2676
|
+
return None
|
|
2677
|
+
facts = transcript_facts(transcript)
|
|
2678
|
+
if not facts or not facts["assistant"]:
|
|
2679
|
+
return None # unreadable, oversized or an unknown format: cannot tell
|
|
2680
|
+
if facts["sidechain"]:
|
|
2681
|
+
return None # a subagent works under its orchestrator's verdict
|
|
2682
|
+
if not (facts["declared"] or facts["trivial"]):
|
|
2683
|
+
return ("[sdlc verdict] No guide-router verdict in this session; this project has "
|
|
2684
|
+
f"guides ({docs_dir()}/reference/INDEX.md). Declare it with the level, e.g. "
|
|
2685
|
+
"\"Level: L2 · router: no match\" or \"router: GUIDE_x.md -> read\" "
|
|
2686
|
+
"(L1: \"Level: L1\"), then retry.")
|
|
2687
|
+
unread = sorted((facts["named"] & guides) - facts["read"])
|
|
2688
|
+
if unread:
|
|
2689
|
+
return (f"[sdlc verdict] The router verdict names {', '.join(unread)}, not read in "
|
|
2690
|
+
"this session. Read it, then retry.")
|
|
2691
|
+
return None
|
|
2692
|
+
|
|
2693
|
+
|
|
2694
|
+
def cmd_verdict(args):
|
|
2695
|
+
"""PreToolUse hook: print a JSON refusal or nothing. Always 0 -- exit 2 would
|
|
2696
|
+
block blind, and the wired command adds `; exit 0` for validators that cannot
|
|
2697
|
+
run this at all (missing, or older than it)."""
|
|
2698
|
+
if not args.hook:
|
|
2699
|
+
return 0
|
|
2700
|
+
try:
|
|
2701
|
+
payload = read_hook_payload()
|
|
2702
|
+
if payload is None:
|
|
2703
|
+
return 0
|
|
2704
|
+
root = Path(args.root).resolve() if args.root else None
|
|
2705
|
+
reason = verdict_reason(payload, root, getattr(args, "docs_dir", None))
|
|
2706
|
+
if reason:
|
|
2707
|
+
print(json.dumps({"hookSpecificOutput": {
|
|
2708
|
+
"hookEventName": "PreToolUse",
|
|
2709
|
+
"permissionDecision": "deny",
|
|
2710
|
+
"permissionDecisionReason": reason}}, ensure_ascii=False))
|
|
2711
|
+
except Exception:
|
|
2712
|
+
pass
|
|
2713
|
+
return 0
|
|
2714
|
+
|
|
2715
|
+
|
|
2716
|
+
# -------------------------------------------------------------- constraints
|
|
2717
|
+
# F-085: the constraint reader. It reads what an ANALYSIS already states in
|
|
2718
|
+
# extractable form -- id'd use cases, spec items and threats, and the rows of
|
|
2719
|
+
# the `### Preservation constraints` table -- and binds each to the files it
|
|
2720
|
+
# names. It names no feature and computes nothing: no count, rank or marker
|
|
2721
|
+
# reaches the output, and every view ends with CONSTRAINTS_SENTENCE naming what
|
|
2722
|
+
# it could not extract, so a short view never reads as a complete one.
|
|
2723
|
+
|
|
2724
|
+
CONSTRAINT_SECTIONS = (("## Use Cases",), ("## Functional Spec",), SECURITY_SECTION)
|
|
2725
|
+
INTERFACE_SECTION = ("## Interface Contract",)
|
|
2726
|
+
IMPACT_SECTION = ANALYSIS_SECTIONS[2]
|
|
2727
|
+
PRESERVATION_HEADING = "### Preservation constraints"
|
|
2728
|
+
NO_SYMBOL_LINE = "No existing symbol changed."
|
|
2729
|
+
CONSTRAINTS_SENTENCE = ("This view shows only the constraints written in extractable "
|
|
2730
|
+
"form and never replaces the ANALYSIS")
|
|
2731
|
+
CONSTRAINT_LINE_MAX = 300
|
|
2732
|
+
|
|
2733
|
+
_ITEM_RE = re.compile(r"^\s*([-*+]\s+|#{3,4}\s+|\|\s*)(?:\*\*|__)?(?:UC|FS|T)\d+\b")
|
|
2734
|
+
_P_ROW_RE = re.compile(r"^\|\s*(?:\*\*)?(P\d+)\b")
|
|
2735
|
+
_ITEM_ID_RE = re.compile(r"(UC|FS|T)\d+")
|
|
2736
|
+
_CITE_RE = re.compile(r"\b(UC|FS|T)(\d+)(?:\s*[–-]\s*(?:UC|FS|T)?(\d+))?\b")
|
|
2737
|
+
_BACKTICK_RE = re.compile(r"`([^`\n]+)`")
|
|
2738
|
+
_PATH_TOKEN_RE = re.compile(r"^[A-Za-z0-9_.\-/]+(?:::[A-Za-z0-9_.]+)?$")
|
|
2739
|
+
_PATH_EXT_RE = re.compile(r"[^./]\.[A-Za-z0-9]+$")
|
|
2740
|
+
_SENTENCE_END_RE = re.compile(r"[.!?](?=\s|$)")
|
|
2741
|
+
_CELL_SPLIT_RE = re.compile(r"(?<!\\)\|")
|
|
2742
|
+
_CONTROL_RE = re.compile(r"[\x00-\x08\x0b-\x1f\x7f]")
|
|
2743
|
+
|
|
2744
|
+
|
|
2745
|
+
def _norm_rel(path):
|
|
2746
|
+
"""Repository-relative, forward slashes, case-folded where the filesystem is."""
|
|
2747
|
+
p = path.replace("\\", "/")
|
|
2748
|
+
while p.startswith("./"):
|
|
2749
|
+
p = p[2:]
|
|
2750
|
+
return p.casefold() if os.name == "nt" else p
|
|
2751
|
+
|
|
2752
|
+
|
|
2753
|
+
def _path_tokens(text, need_slash):
|
|
2754
|
+
"""The backticked file paths in `text`; `path::symbol` yields its path. An elided
|
|
2755
|
+
(`...`), globbed, placeholder or parent-relative path binds nothing; in prose
|
|
2756
|
+
(`need_slash`) a bare file name is a mention, not a binding."""
|
|
2757
|
+
out = set()
|
|
2758
|
+
for token in _BACKTICK_RE.findall(text or ""):
|
|
2759
|
+
t = token.strip()
|
|
2760
|
+
if "..." in t or "*" in t or t.startswith("/") or not _PATH_TOKEN_RE.match(t):
|
|
2761
|
+
continue
|
|
2762
|
+
path = t.split("::", 1)[0]
|
|
2763
|
+
while path.startswith("./"):
|
|
2764
|
+
path = path[2:]
|
|
2765
|
+
if ".." in path.split("/") or not _PATH_EXT_RE.search(path):
|
|
2766
|
+
continue
|
|
2767
|
+
if need_slash and "/" not in path:
|
|
2768
|
+
continue
|
|
2769
|
+
out.add(_norm_rel(path))
|
|
2770
|
+
return out
|
|
2771
|
+
|
|
2772
|
+
|
|
2773
|
+
def _first_sentence(text):
|
|
2774
|
+
"""Up to the first sentence end outside a backticked span (so `a.py` never ends
|
|
2775
|
+
one); a bold title such as `**FS1 Ids.**` ends nothing either."""
|
|
2776
|
+
masked = _BACKTICK_RE.sub(lambda m: "`" + "x" * len(m.group(1)) + "`", text)
|
|
2777
|
+
m = _SENTENCE_END_RE.search(masked)
|
|
2778
|
+
return text[:m.end()] if m else text
|
|
2779
|
+
|
|
2780
|
+
|
|
2781
|
+
def _cells(row):
|
|
2782
|
+
return [c.strip() for c in _CELL_SPLIT_RE.split(row.strip().strip("|"))]
|
|
2783
|
+
|
|
2784
|
+
|
|
2785
|
+
def _cited_ids(text):
|
|
2786
|
+
"""Ids cited in `text`, a range such as `FS3–FS7` expanded."""
|
|
2787
|
+
out = set()
|
|
2788
|
+
for prefix, start, end in _CITE_RE.findall(text or ""):
|
|
2789
|
+
a = int(start)
|
|
2790
|
+
b = int(end) if end else a
|
|
2791
|
+
if b < a or b - a > 50:
|
|
2792
|
+
b = a
|
|
2793
|
+
out.update(f"{prefix}{n}" for n in range(a, b + 1))
|
|
2794
|
+
return out
|
|
2795
|
+
|
|
2796
|
+
|
|
2797
|
+
def parse_constraints(text):
|
|
2798
|
+
"""Every constraint `text` states in extractable form, in document order: a dict
|
|
2799
|
+
with `id`, `kind` (`item` or `P`), `section` (its `## ` heading), `line`
|
|
2800
|
+
(1-based), `statement`, `bindings` (normalized paths), `evidence` and
|
|
2801
|
+
`released` (a P row's release reference, or None)."""
|
|
2802
|
+
lines = (text or "").splitlines()
|
|
2803
|
+
items, impact_rows = [], []
|
|
2804
|
+
section, extracted, impact, preservation = None, False, False, False
|
|
2805
|
+
for n, line in enumerate(lines):
|
|
2806
|
+
if line.startswith("## "):
|
|
2807
|
+
section = line.strip()
|
|
2808
|
+
extracted = any(line.startswith(a) for s in CONSTRAINT_SECTIONS for a in s)
|
|
2809
|
+
impact = any(line.startswith(a) for a in IMPACT_SECTION)
|
|
2810
|
+
preservation = False
|
|
2811
|
+
continue
|
|
2812
|
+
if impact:
|
|
2813
|
+
if line.startswith("### "):
|
|
2814
|
+
preservation = line.strip().lower() == PRESERVATION_HEADING.lower()
|
|
2815
|
+
continue
|
|
2816
|
+
if not line.lstrip().startswith("|"):
|
|
2817
|
+
continue
|
|
2818
|
+
cells = _cells(line)
|
|
2819
|
+
if preservation:
|
|
2820
|
+
m = _P_ROW_RE.match(line.strip())
|
|
2821
|
+
if m and len(cells) >= 5:
|
|
2822
|
+
must = cells[3]
|
|
2823
|
+
released = None
|
|
2824
|
+
if must.startswith("RELEASED (") and ")" in must:
|
|
2825
|
+
released = must[len("RELEASED ("):must.index(")")]
|
|
2826
|
+
items.append({"id": m.group(1), "kind": "P", "section": section,
|
|
2827
|
+
"line": n + 1, "statement": must,
|
|
2828
|
+
"bindings": _path_tokens(cells[1], False),
|
|
2829
|
+
"evidence": cells[4], "released": released})
|
|
2830
|
+
elif cells:
|
|
2831
|
+
paths = _path_tokens(cells[0], False)
|
|
2832
|
+
if paths:
|
|
2833
|
+
impact_rows.append((paths, _cited_ids(" | ".join(cells[1:]))))
|
|
2834
|
+
continue
|
|
2835
|
+
if not extracted:
|
|
2836
|
+
continue
|
|
2837
|
+
m = _ITEM_RE.match(line)
|
|
2838
|
+
if not m:
|
|
2839
|
+
continue
|
|
2840
|
+
marker = m.group(1).strip()
|
|
2841
|
+
body = []
|
|
2842
|
+
if not marker.startswith("|"):
|
|
2843
|
+
for nxt in lines[n + 1:]:
|
|
2844
|
+
if nxt.startswith("#") or _ITEM_RE.match(nxt):
|
|
2845
|
+
break
|
|
2846
|
+
if marker.startswith("#"):
|
|
2847
|
+
if nxt.strip():
|
|
2848
|
+
body.append(nxt.strip())
|
|
2849
|
+
elif nxt.strip() and nxt[:1].isspace():
|
|
2850
|
+
body.append(nxt.strip())
|
|
2851
|
+
else:
|
|
2852
|
+
break
|
|
2853
|
+
if marker.startswith("|"):
|
|
2854
|
+
statement = _first_sentence(" | ".join(_cells(line)))
|
|
2855
|
+
whole = line
|
|
2856
|
+
elif marker.startswith("#"):
|
|
2857
|
+
title = line.lstrip("#").strip()
|
|
2858
|
+
rest = " ".join(body)
|
|
2859
|
+
statement = title + (" — " + _first_sentence(rest) if rest else "")
|
|
2860
|
+
whole = " ".join([line] + body)
|
|
2861
|
+
else:
|
|
2862
|
+
joined = " ".join([line.strip()[len(marker):].strip()] + body)
|
|
2863
|
+
statement = _first_sentence(joined)
|
|
2864
|
+
whole = joined
|
|
2865
|
+
items.append({"id": _ITEM_ID_RE.search(line).group(0), "kind": "item",
|
|
2866
|
+
"section": section, "line": n + 1, "statement": statement,
|
|
2867
|
+
"bindings": _path_tokens(whole, True), "evidence": None,
|
|
2868
|
+
"released": None})
|
|
2869
|
+
for paths, ids in impact_rows:
|
|
2870
|
+
for c in items:
|
|
2871
|
+
if c["kind"] == "item" and c["id"] in ids:
|
|
2872
|
+
c["bindings"] |= paths
|
|
2873
|
+
return items
|
|
2874
|
+
|
|
2875
|
+
|
|
2876
|
+
def has_preservation_record(impact_body):
|
|
2877
|
+
"""True when an Impact body holds the preservation table or the no-symbol line."""
|
|
2878
|
+
return any(b.strip().lower() == PRESERVATION_HEADING.lower() or b.strip() == NO_SYMBOL_LINE
|
|
2879
|
+
for b in (impact_body or "").splitlines())
|
|
2880
|
+
|
|
2881
|
+
|
|
2882
|
+
def unread_sections(text, items=None):
|
|
2883
|
+
"""The `## ` headings the view could not extract, in document order: the
|
|
2884
|
+
Interface Contract (its items carry no id), an extracted section holding prose
|
|
2885
|
+
but no id'd item, and an Impact with neither the preservation table nor the
|
|
2886
|
+
no-symbol line."""
|
|
2887
|
+
items = parse_constraints(text) if items is None else items
|
|
2888
|
+
homes = {c["section"] for c in items if c["kind"] == "item"}
|
|
2889
|
+
lines = (text or "").splitlines()
|
|
2890
|
+
names = []
|
|
2891
|
+
for i, line in enumerate(lines):
|
|
2892
|
+
if not line.startswith("## "):
|
|
2893
|
+
continue
|
|
2894
|
+
body = []
|
|
2895
|
+
for nxt in lines[i + 1:]:
|
|
2896
|
+
if nxt.startswith("## "):
|
|
2897
|
+
break
|
|
2898
|
+
body.append(nxt)
|
|
2899
|
+
heading = line.strip()
|
|
2900
|
+
if any(line.startswith(a) for a in INTERFACE_SECTION):
|
|
2901
|
+
names.append(heading)
|
|
2902
|
+
elif any(line.startswith(a) for s in CONSTRAINT_SECTIONS for a in s):
|
|
2903
|
+
if any(b.strip() for b in body) and heading not in homes:
|
|
2904
|
+
names.append(heading)
|
|
2905
|
+
elif any(line.startswith(a) for a in IMPACT_SECTION):
|
|
2906
|
+
if not has_preservation_record("\n".join(body)):
|
|
2907
|
+
names.append(heading)
|
|
2908
|
+
return names
|
|
2909
|
+
|
|
2910
|
+
|
|
2911
|
+
def constraints_sentence(names):
|
|
2912
|
+
if not names:
|
|
2913
|
+
return CONSTRAINTS_SENTENCE + "."
|
|
2914
|
+
return CONSTRAINTS_SENTENCE + "; read in full: " + ", ".join(f"`{n}`" for n in names) + "."
|
|
2915
|
+
|
|
2916
|
+
|
|
2917
|
+
def _clip(line):
|
|
2918
|
+
line = _CONTROL_RE.sub("", line)
|
|
2919
|
+
return line if len(line) <= CONSTRAINT_LINE_MAX else line[:CONSTRAINT_LINE_MAX - 1] + "…"
|
|
2920
|
+
|
|
2921
|
+
|
|
2922
|
+
def constraint_lines(home, items, grouped):
|
|
2923
|
+
"""FS4's lines: `ID — statement — home:line`, a P row followed by its evidence;
|
|
2924
|
+
`grouped` puts each home heading before its first item."""
|
|
2925
|
+
out, current = [], None
|
|
2926
|
+
for c in items:
|
|
2927
|
+
if grouped and c["section"] != current:
|
|
2928
|
+
current = c["section"]
|
|
2929
|
+
out.append(_clip(current))
|
|
2930
|
+
out.append(_clip(f"{c['id']} — {c['statement']} — {home}:{c['line']}"))
|
|
2931
|
+
if c["kind"] == "P" and c["evidence"]:
|
|
2932
|
+
out.append(_clip(f" evidence: {c['evidence']}"))
|
|
2933
|
+
return out
|
|
2934
|
+
|
|
2935
|
+
|
|
2936
|
+
def unit_constraints(root, unit):
|
|
2937
|
+
"""[(path, meta, items, unread)] for the ANALYSIS files of `unit` -- an id such
|
|
2938
|
+
as F-001, or a file name with or without `.md`; [] when none matches."""
|
|
2939
|
+
key = str(unit or "").strip()
|
|
2940
|
+
name = key if key.endswith(".md") else key + ".md"
|
|
2941
|
+
out = []
|
|
2942
|
+
for p, meta, text in list_analyses(root):
|
|
2943
|
+
m = UNIT_ID_RE.search(str(meta.get("id") or ""))
|
|
2944
|
+
if p.name == name or (m and m.group(1) == key):
|
|
2945
|
+
items = parse_constraints(text)
|
|
2946
|
+
out.append((p, meta, items, unread_sections(text, items)))
|
|
2947
|
+
return out
|
|
2948
|
+
|
|
2949
|
+
|
|
2950
|
+
def project_relative(root, path):
|
|
2951
|
+
"""`path` relative to `root`, normalized as bindings are, or None outside it."""
|
|
2952
|
+
raw = str(path or "").replace("\\", "/")
|
|
2953
|
+
if not raw:
|
|
2954
|
+
return None
|
|
2955
|
+
p = Path(raw)
|
|
2956
|
+
if not p.is_absolute():
|
|
2957
|
+
p = Path.cwd() / p
|
|
2958
|
+
try:
|
|
2959
|
+
rel = p.resolve().relative_to(Path(root).resolve())
|
|
2960
|
+
except (ValueError, OSError):
|
|
2961
|
+
return None
|
|
2962
|
+
return _norm_rel(rel.as_posix())
|
|
2963
|
+
|
|
2964
|
+
|
|
2965
|
+
def open_code_units(root):
|
|
2966
|
+
"""[(path, meta, text)] for the tree's open L3 units of the code domain. Open
|
|
2967
|
+
units come from `_open_units`, unchanged (P1)."""
|
|
2968
|
+
if not ai_path(root).is_dir():
|
|
2969
|
+
return []
|
|
2970
|
+
open_names = {n for names in _open_units(root).values() for n in names}
|
|
2971
|
+
if not open_names:
|
|
2972
|
+
return []
|
|
2973
|
+
default = project_default_domain(root)
|
|
2974
|
+
return [(p, meta, text) for p, meta, text in list_analyses(root)
|
|
2975
|
+
if p.name in open_names
|
|
2976
|
+
and str(meta.get("level", "")).upper() == "L3"
|
|
2977
|
+
and meta.get("status") in ("PLANNED", "IN_PROGRESS")
|
|
2978
|
+
and resolve_domain(meta, default)[0] == "code"]
|
|
2979
|
+
|
|
2980
|
+
|
|
2981
|
+
def file_constraints(root, path):
|
|
2982
|
+
"""[(path, meta, bound items, unread)] for each open L3 unit of the code domain
|
|
2983
|
+
with an unreleased constraint bound to `path`."""
|
|
2984
|
+
rel = project_relative(root, path)
|
|
2985
|
+
if rel is None:
|
|
2986
|
+
return []
|
|
2987
|
+
out = []
|
|
2988
|
+
for p, meta, text in open_code_units(root):
|
|
2989
|
+
items = parse_constraints(text)
|
|
2990
|
+
bound = [c for c in items if not c["released"] and rel in c["bindings"]]
|
|
2991
|
+
if bound:
|
|
2992
|
+
out.append((p, meta, bound, unread_sections(text, items)))
|
|
2993
|
+
return out
|
|
2994
|
+
|
|
2995
|
+
|
|
2996
|
+
# The recall hook (FS6). It informs, never decides: its JSON carries no
|
|
2997
|
+
# `permissionDecision`, so the user's permission flow is the one without it
|
|
2998
|
+
# (P6). A level is read with the verdict hook's `_DECLARATION_RE`; only the code
|
|
2999
|
+
# lens's `L` levels count here.
|
|
3000
|
+
CONSTRAINTS_HOOK_LINES = 15
|
|
3001
|
+
_WORK_TOOLS = VERDICT_EDIT_TOOLS + ("Read",)
|
|
3002
|
+
|
|
3003
|
+
|
|
3004
|
+
def transcript_work(path):
|
|
3005
|
+
"""What the session transcript shows about the work, or None when it cannot be
|
|
3006
|
+
read within the verdict hook's bounds: {"assistant": entries parsed,
|
|
3007
|
+
"sidechain": a subagent's transcript, "level": the last declared level ("1",
|
|
3008
|
+
"2", "3") or None, "touched": normalized absolute paths this session read or
|
|
3009
|
+
wrote}."""
|
|
3010
|
+
work = {"assistant": 0, "sidechain": False, "level": None, "touched": set()}
|
|
3011
|
+
try:
|
|
3012
|
+
p = Path(path)
|
|
3013
|
+
if not p.is_file() or p.stat().st_size > VERDICT_MAX_BYTES:
|
|
3014
|
+
return None
|
|
3015
|
+
with p.open("rb") as fh:
|
|
3016
|
+
for raw in fh:
|
|
3017
|
+
if len(raw) > VERDICT_LINE_MAX or b'"assistant"' not in raw:
|
|
3018
|
+
continue
|
|
3019
|
+
try:
|
|
3020
|
+
entry = json.loads(raw.decode("utf-8", errors="replace"))
|
|
3021
|
+
except ValueError:
|
|
3022
|
+
continue
|
|
3023
|
+
if not isinstance(entry, dict) or entry.get("type") != "assistant":
|
|
3024
|
+
continue
|
|
3025
|
+
content = (entry.get("message") or {}).get("content")
|
|
3026
|
+
if not isinstance(content, list):
|
|
3027
|
+
continue
|
|
3028
|
+
work["assistant"] += 1
|
|
3029
|
+
if entry.get("isSidechain") is True:
|
|
3030
|
+
work["sidechain"] = True
|
|
3031
|
+
for block in content:
|
|
3032
|
+
if not isinstance(block, dict):
|
|
3033
|
+
continue
|
|
3034
|
+
if block.get("type") == "text":
|
|
3035
|
+
for letter, one, other in _DECLARATION_RE.findall(
|
|
3036
|
+
str(block.get("text") or "")):
|
|
3037
|
+
if letter.upper() == "L":
|
|
3038
|
+
work["level"] = one or other
|
|
3039
|
+
elif block.get("type") == "tool_use" and block.get("name") in _WORK_TOOLS:
|
|
3040
|
+
inp = block.get("input") or {}
|
|
3041
|
+
target = inp.get("file_path") or inp.get("notebook_path")
|
|
3042
|
+
if isinstance(target, str) and target:
|
|
3043
|
+
try:
|
|
3044
|
+
work["touched"].add(_norm_rel(Path(target).resolve().as_posix()))
|
|
3045
|
+
except (OSError, ValueError):
|
|
3046
|
+
pass
|
|
3047
|
+
except (OSError, ValueError, TypeError, AttributeError):
|
|
3048
|
+
return None
|
|
3049
|
+
return work
|
|
3050
|
+
|
|
3051
|
+
|
|
3052
|
+
def _hook_new_text(tool_input):
|
|
3053
|
+
"""The text an edit is about to write: Edit, Write, MultiEdit, NotebookEdit."""
|
|
3054
|
+
parts = [tool_input.get(k) for k in ("new_string", "content", "new_source")]
|
|
3055
|
+
for e in tool_input.get("edits") or []:
|
|
3056
|
+
if isinstance(e, dict):
|
|
3057
|
+
parts.append(e.get("new_string"))
|
|
3058
|
+
return "\n".join(x for x in parts if isinstance(x, str))
|
|
3059
|
+
|
|
3060
|
+
|
|
3061
|
+
def _unit_documents(root, p):
|
|
3062
|
+
"""Normalized absolute paths of a unit's ANALYSIS, request records and handoff:
|
|
3063
|
+
reading or writing any of them is working on the unit."""
|
|
3064
|
+
slug = p.stem[len("ANALYSIS_"):]
|
|
3065
|
+
docs = {p, ai_path(root, "audit", f"HANDOFF_{slug}.md")}
|
|
3066
|
+
req = ai_path(root, "audit", "requests")
|
|
3067
|
+
if req.is_dir():
|
|
3068
|
+
for r in req.glob("REQUEST_*.md"):
|
|
3069
|
+
meta = load_frontmatter(read_text(r).splitlines())
|
|
3070
|
+
if str(meta.get("analysis") or "").strip().strip("'\"`") == p.name:
|
|
3071
|
+
docs.add(r)
|
|
3072
|
+
docs.add(ai_path(root, "audit", f"HANDOFF_{r.stem[len('REQUEST_'):]}.md"))
|
|
3073
|
+
return {_norm_rel(d.resolve().as_posix()) for d in docs}
|
|
3074
|
+
|
|
3075
|
+
|
|
3076
|
+
def constraints_hook_context(payload, root=None, docs_name=None):
|
|
3077
|
+
"""The context to recall before this edit, or None (FS6): an edit tool, outside
|
|
3078
|
+
a subagent, on a file of a project whose open L3 code unit binds it (or the
|
|
3079
|
+
unit's own ANALYSIS, about to name a path a P row binds -- UC6), by a session
|
|
3080
|
+
that worked on that unit and last declared L3."""
|
|
3081
|
+
if payload.get("tool_name") not in VERDICT_EDIT_TOOLS or payload.get("agent_id"):
|
|
3082
|
+
return None
|
|
3083
|
+
tool_input = payload.get("tool_input") or {}
|
|
3084
|
+
target = tool_input.get("file_path") or tool_input.get("notebook_path")
|
|
3085
|
+
transcript = payload.get("transcript_path")
|
|
3086
|
+
if not isinstance(target, str) or not target or not transcript:
|
|
3087
|
+
return None
|
|
3088
|
+
root = hook_project(target, root, docs_name)
|
|
3089
|
+
if root is None or not ai_path(root, "solutions").is_dir():
|
|
3090
|
+
return None
|
|
3091
|
+
found = file_constraints(root, target)
|
|
3092
|
+
rel = project_relative(root, target)
|
|
3093
|
+
named = _path_tokens(_hook_new_text(tool_input), False)
|
|
3094
|
+
if named and rel and rel.startswith(_norm_rel(f"{docs_dir()}/solutions/ANALYSIS_")):
|
|
3095
|
+
for p, meta, text in open_code_units(root):
|
|
3096
|
+
if _norm_rel(f"{docs_dir()}/solutions/{p.name}") != rel:
|
|
3097
|
+
continue
|
|
3098
|
+
items = parse_constraints(text)
|
|
3099
|
+
bound = [c for c in items if c["kind"] == "P"
|
|
3100
|
+
and not c["released"] and c["bindings"] & named]
|
|
3101
|
+
if bound and not any(f[0] == p for f in found):
|
|
3102
|
+
found.append((p, meta, bound, unread_sections(text, items)))
|
|
3103
|
+
if not found:
|
|
3104
|
+
return None
|
|
3105
|
+
work = transcript_work(transcript)
|
|
3106
|
+
if not work or not work["assistant"] or work["sidechain"] or work["level"] != "3":
|
|
3107
|
+
return None
|
|
3108
|
+
# One budget for the whole recall, whatever the number of units; a P line and
|
|
3109
|
+
# its evidence line are kept together or left out together.
|
|
3110
|
+
out, budget = [], CONSTRAINTS_HOOK_LINES
|
|
3111
|
+
for p, meta, items, unread in found:
|
|
3112
|
+
if not (_unit_documents(root, p) & work["touched"]):
|
|
3113
|
+
continue
|
|
3114
|
+
home = f"solutions/{p.name}"
|
|
3115
|
+
unit = meta.get("id") or p.stem
|
|
3116
|
+
out.append(_clip(f"[sdlc constraints] {unit} — {home}"))
|
|
3117
|
+
shown = 0
|
|
3118
|
+
for c in items:
|
|
3119
|
+
chunk = constraint_lines(home, [c], False)
|
|
3120
|
+
if len(chunk) > budget:
|
|
3121
|
+
break
|
|
3122
|
+
out.extend(chunk)
|
|
3123
|
+
budget -= len(chunk)
|
|
3124
|
+
shown += 1
|
|
3125
|
+
if shown < len(items):
|
|
3126
|
+
out.append(_clip(f"More: sdlc_check.py constraints --unit {unit}"))
|
|
3127
|
+
out.append(_clip(constraints_sentence(unread)))
|
|
3128
|
+
return "\n".join(out) if out else None
|
|
3129
|
+
|
|
3130
|
+
|
|
3131
|
+
def cmd_constraints_hook(args):
|
|
3132
|
+
"""PreToolUse hook: print a context-only JSON or nothing. Always 0 -- exit 2
|
|
3133
|
+
would block the edit, and the wired command adds `; exit 0` for validators
|
|
3134
|
+
that cannot run this at all (T2)."""
|
|
3135
|
+
try:
|
|
3136
|
+
payload = read_hook_payload()
|
|
3137
|
+
if payload is None:
|
|
3138
|
+
return 0
|
|
3139
|
+
root = Path(args.root).resolve() if args.root else None
|
|
3140
|
+
context = constraints_hook_context(payload, root, getattr(args, "docs_dir", None))
|
|
3141
|
+
if context:
|
|
3142
|
+
print(json.dumps({"hookSpecificOutput": {
|
|
3143
|
+
"hookEventName": "PreToolUse",
|
|
3144
|
+
"additionalContext": context}}, ensure_ascii=False))
|
|
3145
|
+
except Exception:
|
|
3146
|
+
pass
|
|
3147
|
+
return 0
|
|
3148
|
+
|
|
3149
|
+
|
|
3150
|
+
def cmd_constraints(root, args):
|
|
3151
|
+
if args.unit:
|
|
3152
|
+
found = unit_constraints(root, args.unit)
|
|
3153
|
+
if not found:
|
|
3154
|
+
print(f"[ERROR] unknown unit '{args.unit}': give an id such as F-001 "
|
|
3155
|
+
"or an ANALYSIS file name")
|
|
3156
|
+
return 1
|
|
3157
|
+
groups = [(p, meta, items, unread, True) for p, meta, items, unread in found]
|
|
3158
|
+
else:
|
|
3159
|
+
groups = [(p, meta, items, unread, False)
|
|
3160
|
+
for p, meta, items, unread in file_constraints(root, args.file)]
|
|
3161
|
+
for p, meta, items, unread, grouped in groups:
|
|
3162
|
+
home = f"solutions/{p.name}"
|
|
3163
|
+
print(_clip(f"{meta.get('id') or p.stem} — {home}"))
|
|
3164
|
+
for line in constraint_lines(home, items, grouped):
|
|
3165
|
+
print(line)
|
|
3166
|
+
print(_clip(constraints_sentence(unread)))
|
|
3167
|
+
return 0
|
|
3168
|
+
|
|
3169
|
+
|
|
2329
3170
|
# --------------------------------------------------------------------- plan
|
|
2330
3171
|
# Subagent Execution (Feature A). Zero-execution surface: this section and
|
|
2331
3172
|
# everything it calls MUST NOT spawn a process (no subprocess/os.system/eval/
|
|
@@ -2525,15 +3366,23 @@ REVIEW_POSITIONAL = {
|
|
|
2525
3366
|
}
|
|
2526
3367
|
|
|
2527
3368
|
|
|
3369
|
+
_UNESCAPED_PIPE_RE = re.compile(r"(?<!\\)\|")
|
|
3370
|
+
|
|
3371
|
+
|
|
2528
3372
|
def _table_cells(line):
|
|
2529
3373
|
"""Cells of a Markdown table row, or None when the line is not one.
|
|
2530
|
-
Separator rows (---, :---:, any alignment colons) are not data.
|
|
3374
|
+
Separator rows (---, :---:, any alignment colons) are not data. A cell may
|
|
3375
|
+
carry an escaped pipe (`\\|`, as `index` writes them): it does not split the
|
|
3376
|
+
row, and it reads back as `|` (F-075)."""
|
|
2531
3377
|
s = line.strip()
|
|
2532
3378
|
if not s.startswith("|"):
|
|
2533
3379
|
return None
|
|
2534
3380
|
if set(s) <= set("|-: "):
|
|
2535
3381
|
return None
|
|
2536
|
-
|
|
3382
|
+
body = s[1:]
|
|
3383
|
+
if body.endswith("|") and not body.endswith("\\|"):
|
|
3384
|
+
body = body[:-1]
|
|
3385
|
+
return [c.strip().replace("\\|", "|") for c in _UNESCAPED_PIPE_RE.split(body)]
|
|
2537
3386
|
|
|
2538
3387
|
|
|
2539
3388
|
def parse_review_log(text):
|
|
@@ -2647,6 +3496,10 @@ def cmd_benefit(root):
|
|
|
2647
3496
|
rows, unparsed = parse_review_log(read_text(log))
|
|
2648
3497
|
not_due = [r for r in rows if _is_not_due(r["verdict"])]
|
|
2649
3498
|
rows = [r for r in rows if not _is_not_due(r["verdict"])]
|
|
3499
|
+
# F-085 FS10: a `post-merge` row is the owner's report of a defect that got
|
|
3500
|
+
# past every review -- not a review, so it stays out of every review figure.
|
|
3501
|
+
post_merge = [r for r in rows if r["tier"].lower().startswith("post-merge")]
|
|
3502
|
+
rows = [r for r in rows if not r["tier"].lower().startswith("post-merge")]
|
|
2650
3503
|
# `tier` carries the MOMENT in Standalone (design / closure) and the
|
|
2651
3504
|
# reviewer WEIGHT in Hybrid (deep / light / code / guide / vision). A
|
|
2652
3505
|
# Hybrid row is a real review whose moment this column does not state, so
|
|
@@ -2671,11 +3524,14 @@ def cmd_benefit(root):
|
|
|
2671
3524
|
unstated_code += n
|
|
2672
3525
|
classified = moments["design"] + moments["closure"]
|
|
2673
3526
|
print("rows parsed: %d rows unparsed: %d%s"
|
|
2674
|
-
% (len(rows) + len(not_due), len(unparsed),
|
|
3527
|
+
% (len(rows) + len(not_due) + len(post_merge), len(unparsed),
|
|
2675
3528
|
(" -> " + "; ".join("line %d: %s" % u for u in unparsed[:5]))
|
|
2676
3529
|
if unparsed else ""))
|
|
2677
3530
|
print("reviews ruled not due (logged, counted in no figure below): %d"
|
|
2678
3531
|
% len(not_due))
|
|
3532
|
+
if post_merge:
|
|
3533
|
+
print("defects reported after merge (tier post-merge, not reviews, "
|
|
3534
|
+
"counted in no figure below): %d" % len(post_merge))
|
|
2679
3535
|
print("reviews with a stated moment: %d moment not stated by `tier`: %d%s"
|
|
2680
3536
|
% (len(classified), sum(unstated.values()),
|
|
2681
3537
|
(" (" + ", ".join("%s x%d" % (t, n) for t, n
|
|
@@ -2741,48 +3597,205 @@ def cmd_benefit(root):
|
|
|
2741
3597
|
return 0
|
|
2742
3598
|
|
|
2743
3599
|
|
|
3600
|
+
# --- orient helpers (F-075) ---------------------------------------------------
|
|
3601
|
+
SKILL_MD_CAP = 64 * 1024 # a SKILL.md is read for its name only
|
|
3602
|
+
|
|
3603
|
+
|
|
3604
|
+
def orient_fit(lines, budget, rel):
|
|
3605
|
+
"""The lines that fit `budget` characters (one newline each), cut at a line
|
|
3606
|
+
boundary and closed by a pointer to the file holding the rest. A single line
|
|
3607
|
+
wider than the room left shows its head. Never raises."""
|
|
3608
|
+
out, used = [], 0
|
|
3609
|
+
for i, line in enumerate(lines):
|
|
3610
|
+
if used + len(line) + 1 <= budget:
|
|
3611
|
+
out.append(line)
|
|
3612
|
+
used += len(line) + 1
|
|
3613
|
+
continue
|
|
3614
|
+
rest = len(lines) - i
|
|
3615
|
+
|
|
3616
|
+
def ptr(n):
|
|
3617
|
+
return f"(+{n} more lines -- read {rel})"
|
|
3618
|
+
room = budget - used - len(ptr(rest)) - 1
|
|
3619
|
+
if room > 40:
|
|
3620
|
+
head = line[:room - 4] + "..."
|
|
3621
|
+
out.append(head)
|
|
3622
|
+
used += len(head) + 1
|
|
3623
|
+
while out and used + len(ptr(rest)) + 1 > budget:
|
|
3624
|
+
used -= len(out.pop()) + 1
|
|
3625
|
+
rest += 1
|
|
3626
|
+
out.append(ptr(rest))
|
|
3627
|
+
return out
|
|
3628
|
+
return out
|
|
3629
|
+
|
|
3630
|
+
|
|
3631
|
+
def lens_table(skill_dir=None):
|
|
3632
|
+
"""{skill_name: lens} from the running skill's own `routing.md` par.0 table.
|
|
3633
|
+
|
|
3634
|
+
The Python twin of lib.js `lensTable()`, same parse: a row whose lens cell is
|
|
3635
|
+
`[a-z]+` and whose skill cell is a backticked `[a-z0-9-]+`. `routing.md` is a
|
|
3636
|
+
byte-identical shared file, so the family list cannot drift between
|
|
3637
|
+
distributions -- a literal list here is how kb and mkt once announced
|
|
3638
|
+
themselves as the code lens. Fail-open: {} on any failure."""
|
|
3639
|
+
try:
|
|
3640
|
+
base = Path(skill_dir) if skill_dir else Path(__file__).resolve().parent.parent
|
|
3641
|
+
table = {}
|
|
3642
|
+
for line in read_text(base / "routing.md").splitlines():
|
|
3643
|
+
cells = [c.strip() for c in line.split("|")]
|
|
3644
|
+
if len(cells) < 4:
|
|
3645
|
+
continue
|
|
3646
|
+
lens, skill = cells[1], cells[2]
|
|
3647
|
+
if re.fullmatch(r"[a-z]+", lens) and re.fullmatch(r"`[a-z0-9-]+`", skill):
|
|
3648
|
+
table.setdefault(skill[1:-1], lens)
|
|
3649
|
+
return table
|
|
3650
|
+
except Exception:
|
|
3651
|
+
return {}
|
|
3652
|
+
|
|
3653
|
+
|
|
3654
|
+
def _skills_dirs():
|
|
3655
|
+
"""The two places a sibling lens is installed: beside the running skill, and
|
|
3656
|
+
the user's Claude skills directory. AGENTIC_SDLC_SKILLS_DIRS (os.pathsep
|
|
3657
|
+
list; empty = none) is the test seam, like AGENTIC_SDLC_USER_SETTINGS."""
|
|
3658
|
+
seam = os.environ.get("AGENTIC_SDLC_SKILLS_DIRS")
|
|
3659
|
+
if seam is not None:
|
|
3660
|
+
return [Path(s) for s in seam.split(os.pathsep) if s]
|
|
3661
|
+
cfg = os.environ.get("CLAUDE_CONFIG_DIR")
|
|
3662
|
+
return [Path(__file__).resolve().parents[2],
|
|
3663
|
+
(Path(cfg) if cfg else Path.home() / ".claude") / "skills"]
|
|
3664
|
+
|
|
3665
|
+
|
|
3666
|
+
def installed_sibling_lenses():
|
|
3667
|
+
"""Names of the family's other lenses installed on this machine (sorted).
|
|
3668
|
+
|
|
3669
|
+
Only a name that appears in `lens_table()` AND equals a `SKILL.md` `name:`
|
|
3670
|
+
exactly can come out -- no text of a sibling's files reaches the output
|
|
3671
|
+
(P-TM T3). Fail-open: [] on any failure."""
|
|
3672
|
+
try:
|
|
3673
|
+
own = profile()["skill_name"]
|
|
3674
|
+
family = lens_table()
|
|
3675
|
+
found = set()
|
|
3676
|
+
for base in _skills_dirs():
|
|
3677
|
+
if not base.is_dir():
|
|
3678
|
+
continue
|
|
3679
|
+
for sub in base.iterdir():
|
|
3680
|
+
f = sub / "SKILL.md"
|
|
3681
|
+
try:
|
|
3682
|
+
if not f.is_file() or f.stat().st_size > SKILL_MD_CAP:
|
|
3683
|
+
continue
|
|
3684
|
+
m = re.search(r"^name:\s*(\S+)", read_text(f), re.M)
|
|
3685
|
+
except OSError:
|
|
3686
|
+
continue
|
|
3687
|
+
if m and m.group(1) in family and m.group(1) != own:
|
|
3688
|
+
found.add(m.group(1))
|
|
3689
|
+
return sorted(found)
|
|
3690
|
+
except Exception:
|
|
3691
|
+
return []
|
|
3692
|
+
|
|
3693
|
+
|
|
3694
|
+
def _orient_router_lines(text):
|
|
3695
|
+
"""One line per guide from the generated router; a hand-written one as written."""
|
|
3696
|
+
if not re.search(r"GENERATED by \S+ index - do not edit by hand", text):
|
|
3697
|
+
return text.splitlines()
|
|
3698
|
+
rows = []
|
|
3699
|
+
for line in text.splitlines():
|
|
3700
|
+
cells = _table_cells(line)
|
|
3701
|
+
if not cells or len(cells) < 3 or cells[0] == "Guide":
|
|
3702
|
+
continue
|
|
3703
|
+
rows.append(f"- {cells[0]} ({cells[1]}) -- {cells[2]}")
|
|
3704
|
+
return rows or ['no guides yet -- the router verdict is "no match"']
|
|
3705
|
+
|
|
3706
|
+
|
|
3707
|
+
def _orient_registry_lines(text):
|
|
3708
|
+
"""One line per workstream plus the project-wide notes from the generated
|
|
3709
|
+
registry; a hand-written handoff as written."""
|
|
3710
|
+
if not is_generated_registry(text):
|
|
3711
|
+
return text.splitlines()
|
|
3712
|
+
table, _, notes = text.partition("\n## Project-wide notes")
|
|
3713
|
+
lines = []
|
|
3714
|
+
for line in table.splitlines():
|
|
3715
|
+
c = _table_cells(line)
|
|
3716
|
+
if not c or len(c) < 6 or c[0].lower() == "workstream":
|
|
3717
|
+
continue
|
|
3718
|
+
lines.append(f"- {c[0]} \u00b7 {c[1]} \u00b7 {c[2]} \u00b7 {c[3]} -- {c[5]}")
|
|
3719
|
+
if not lines:
|
|
3720
|
+
lines.append("no open workstreams")
|
|
3721
|
+
notes = notes.strip()
|
|
3722
|
+
if notes:
|
|
3723
|
+
lines += ["Project-wide notes:"] + notes.splitlines()
|
|
3724
|
+
return lines
|
|
3725
|
+
|
|
3726
|
+
|
|
2744
3727
|
def cmd_orient(args):
|
|
2745
|
-
"""SessionStart hook: emit a bounded, repo-sourced
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
(P-TM T2). No subprocess/eval anywhere in this call graph.
|
|
3728
|
+
"""SessionStart hook: emit a bounded, repo-sourced orientation to stdout and
|
|
3729
|
+
ALWAYS return 0 (fail-open, P-TM T8) -- a session hook must never block the
|
|
3730
|
+
session or surface a traceback. Zero-execution (P-TM T1): reads a fixed
|
|
3731
|
+
hard-coded doc set, confine_under each (P-TM T3), bounds the WHOLE output
|
|
3732
|
+
(P-TM T2). No subprocess/eval anywhere in this call graph.
|
|
3733
|
+
|
|
3734
|
+
F-075: what decides the next action comes first -- triage, the lens choice,
|
|
3735
|
+
the guide router, the workstream registry -- then README; INDEX is a pointer.
|
|
3736
|
+
Docs are READ in orient_docs() order (docs_site and the T3 test pin it) and
|
|
3737
|
+
EMITTED in priority order."""
|
|
2750
3738
|
try:
|
|
2751
3739
|
root = Path(args.root).resolve() if getattr(args, "root", None) else find_project_root()
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
for label, rel in orient_docs():
|
|
3740
|
+
rels = [rel for _, rel in orient_docs()]
|
|
3741
|
+
docs = {}
|
|
3742
|
+
for rel in rels:
|
|
2756
3743
|
target = confine_under(root, rel)
|
|
2757
3744
|
if target is None or not target.is_file():
|
|
2758
3745
|
continue
|
|
2759
3746
|
try:
|
|
2760
|
-
|
|
3747
|
+
docs[rel] = read_text(target)
|
|
2761
3748
|
except OSError:
|
|
2762
3749
|
continue
|
|
2763
|
-
|
|
2764
|
-
if remaining <= 0:
|
|
2765
|
-
truncated = True
|
|
2766
|
-
break
|
|
2767
|
-
text = text[:ORIENT_PER_DOC_CHARS]
|
|
2768
|
-
if len(text) > remaining:
|
|
2769
|
-
text = text[:remaining]
|
|
2770
|
-
truncated = True
|
|
2771
|
-
chunks.append((label, text))
|
|
2772
|
-
total += len(text)
|
|
2773
|
-
if not chunks:
|
|
3750
|
+
if not docs:
|
|
2774
3751
|
return 0
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
3752
|
+
readme_rel, index_rel, router_rel, handoff_rel = rels
|
|
3753
|
+
|
|
3754
|
+
head = ["=== Agentic SDLC -- session orientation (repo-sourced context, not authored instructions) ===",
|
|
3755
|
+
"", ORIENT_TRIAGE_LINE]
|
|
3756
|
+
sibs = installed_sibling_lenses()
|
|
3757
|
+
if sibs:
|
|
3758
|
+
head.append(f"Sibling lenses installed ({', '.join(sibs)}): above L1, choose the "
|
|
3759
|
+
"lens with routing.md and declare it with the level.")
|
|
3760
|
+
|
|
3761
|
+
tail = ["", "## Read on demand"]
|
|
3762
|
+
for rel, what in ((readme_rel, "the curated must-reads, in order"),
|
|
3763
|
+
(index_rel, "the manifest of every canonical document"),
|
|
3764
|
+
(router_rel, "the full guide router, with provenance"),
|
|
3765
|
+
(handoff_rel, "the full workstream registry, with since and details")):
|
|
3766
|
+
if rel in docs:
|
|
3767
|
+
tail.append(f"- {rel} -- {what}")
|
|
3768
|
+
for client, entries in sorted(orient_wiring(root).items()):
|
|
3769
|
+
if len(entries) > 1:
|
|
3770
|
+
places = ", ".join(sorted(set(entries.values())))
|
|
3771
|
+
tail.append(f"[note] session orientation is wired {len(entries)} times for one "
|
|
3772
|
+
f"client ({places}): each session receives every copy. Keep one "
|
|
3773
|
+
"entry and remove the others.")
|
|
2782
3774
|
if getattr(args, "hybrid", False):
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
3775
|
+
tail.append("[devPNT active] Run devpnt_mcp_get_bootstrap for the Master Plan / Knowledge Layer -- "
|
|
3776
|
+
"the orientation above is the filesystem layer, not a bootstrap duplicate.")
|
|
3777
|
+
|
|
3778
|
+
share = ORIENT_MAX_TOTAL_CHARS - ORIENT_APPEND_RESERVE
|
|
3779
|
+
left = share - len("\n".join(head + tail)) - 2
|
|
3780
|
+
body = []
|
|
3781
|
+
for rel, title, builder, cap in (
|
|
3782
|
+
(router_rel, "## Guide router (reference/INDEX.md) -- read the matching guide before acting",
|
|
3783
|
+
_orient_router_lines, ORIENT_ROUTER_CHARS),
|
|
3784
|
+
(handoff_rel, "## Workstream registry (audit/handoff.md)",
|
|
3785
|
+
_orient_registry_lines, ORIENT_REGISTRY_CHARS),
|
|
3786
|
+
(readme_rel, "## Reading guide (README)", str.splitlines, None)):
|
|
3787
|
+
if rel not in docs:
|
|
3788
|
+
continue
|
|
3789
|
+
room = left - len(title) - 2
|
|
3790
|
+
if cap is not None:
|
|
3791
|
+
room = min(room, cap)
|
|
3792
|
+
if room <= 0:
|
|
3793
|
+
continue
|
|
3794
|
+
lines = orient_fit(builder(docs[rel].strip()), room, rel)
|
|
3795
|
+
section = ["", title] + lines
|
|
3796
|
+
body += section
|
|
3797
|
+
left -= len("\n".join(section)) + 1
|
|
3798
|
+
print("\n".join(head + body + tail))
|
|
2786
3799
|
return 0
|
|
2787
3800
|
except Exception:
|
|
2788
3801
|
return 0
|
|
@@ -2999,11 +4012,28 @@ def main(argv=None):
|
|
|
2999
4012
|
|
|
3000
4013
|
mp = sub.add_parser("mark", parents=[common], help="record paths as ANALYZED")
|
|
3001
4014
|
mp.add_argument("paths", nargs="+", help="paths relative to the project root")
|
|
4015
|
+
mp.add_argument("--note", help="also write a dated entry in audit/project_notes.md "
|
|
4016
|
+
"(a release, work outside an open unit)")
|
|
3002
4017
|
gp = sub.add_parser("gate", parents=[common, hybrid_opt], help="PreToolUse hook (exit 2 = block)")
|
|
3003
4018
|
gp.add_argument("--hook", action="store_true", help="read the hook JSON payload from stdin")
|
|
3004
4019
|
gp.add_argument("--file", help="file path to evaluate (alternative to --hook)")
|
|
3005
4020
|
gp.add_argument("--protected", default="", help="protected prefixes separated by ';' (e.g. \"src/auth;src/crypto\")")
|
|
3006
4021
|
|
|
4022
|
+
vp = sub.add_parser("verdict", parents=[common],
|
|
4023
|
+
help="PreToolUse hook: refuse an edit with no router verdict "
|
|
4024
|
+
"or an unread named guide (fail-open, always exit 0)")
|
|
4025
|
+
vp.add_argument("--hook", action="store_true", help="read the hook JSON payload from stdin")
|
|
4026
|
+
|
|
4027
|
+
cp = sub.add_parser("constraints", parents=[common],
|
|
4028
|
+
help="print the constraints an ANALYSIS states in extractable "
|
|
4029
|
+
"form: a unit's view, or those bound to a file")
|
|
4030
|
+
cg = cp.add_mutually_exclusive_group(required=True)
|
|
4031
|
+
cg.add_argument("--unit", help="unit id (e.g. F-001) or ANALYSIS file name")
|
|
4032
|
+
cg.add_argument("--file", help="file path: constraints of the open L3 code units bound to it")
|
|
4033
|
+
cg.add_argument("--hook", action="store_true",
|
|
4034
|
+
help="PreToolUse hook: read the payload from stdin, print context only "
|
|
4035
|
+
"(fail-open, always exit 0)")
|
|
4036
|
+
|
|
3007
4037
|
sub.add_parser("benefit", parents=[common],
|
|
3008
4038
|
help="report what the review gates caught (and what the "
|
|
3009
4039
|
"doctrine costs to read) -- a report, never a gate: "
|
|
@@ -3027,6 +4057,16 @@ def main(argv=None):
|
|
|
3027
4057
|
|
|
3028
4058
|
args = ap.parse_args(argv)
|
|
3029
4059
|
|
|
4060
|
+
# F-085: the edit hooks resolve their root and docs root from the edited file
|
|
4061
|
+
# (the hook runs in whatever directory the session moved to), so they are
|
|
4062
|
+
# dispatched before the working-directory resolution below.
|
|
4063
|
+
if args.cmd == "constraints" and args.hook:
|
|
4064
|
+
return cmd_constraints_hook(args)
|
|
4065
|
+
if args.cmd == "verdict":
|
|
4066
|
+
return cmd_verdict(args)
|
|
4067
|
+
if args.cmd == "gate":
|
|
4068
|
+
return cmd_gate(args)
|
|
4069
|
+
|
|
3030
4070
|
# Resolve the documentation root ONCE, before any command runs, so every
|
|
3031
4071
|
# surface -- paths, messages, generated headers, the gate's exempt prefix --
|
|
3032
4072
|
# names the same thing.
|
|
@@ -3050,8 +4090,6 @@ def main(argv=None):
|
|
|
3050
4090
|
return 1
|
|
3051
4091
|
set_docs_dir(name)
|
|
3052
4092
|
|
|
3053
|
-
if args.cmd == "gate":
|
|
3054
|
-
return cmd_gate(args)
|
|
3055
4093
|
if args.cmd == "orient":
|
|
3056
4094
|
return cmd_orient(args)
|
|
3057
4095
|
if args.cmd == "remind":
|
|
@@ -3067,9 +4105,11 @@ def main(argv=None):
|
|
|
3067
4105
|
if args.cmd == "stale":
|
|
3068
4106
|
return cmd_stale(root, hybrid=args.hybrid)
|
|
3069
4107
|
if args.cmd == "mark":
|
|
3070
|
-
return cmd_mark(root, args.paths)
|
|
4108
|
+
return cmd_mark(root, args.paths, args.note)
|
|
3071
4109
|
if args.cmd == "benefit":
|
|
3072
4110
|
return cmd_benefit(root)
|
|
4111
|
+
if args.cmd == "constraints":
|
|
4112
|
+
return cmd_constraints(root, args)
|
|
3073
4113
|
if args.cmd == "migrate":
|
|
3074
4114
|
return cmd_migrate(root, args)
|
|
3075
4115
|
if args.cmd == "plan":
|