@antoneeo/agentic-sdlc-skill 1.43.1 → 1.48.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 +59 -0
- package/README.md +3 -3
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/scripts/lib.js +109 -46
- package/scripts/postinstall.js +11 -1
- package/scripts/preuninstall.js +17 -1
- package/skills/agentic-sdlc-skill/ENFORCEMENT.md +59 -8
- package/skills/agentic-sdlc-skill/SKILL.md +9 -7
- package/skills/agentic-sdlc-skill/docs_site/app.js +9 -3
- package/skills/agentic-sdlc-skill/guides.md +7 -1
- package/skills/agentic-sdlc-skill/memory.md +1 -1
- package/skills/agentic-sdlc-skill/review.md +28 -3
- package/skills/agentic-sdlc-skill/routing.md +4 -0
- package/skills/agentic-sdlc-skill/scripts/docs_site.py +94 -8
- package/skills/agentic-sdlc-skill/scripts/knowledge.py +519 -17
- package/skills/agentic-sdlc-skill/scripts/sdlc_core.py +581 -70
- package/skills/agentic-sdlc-skill/templates.md +13 -2
|
@@ -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
|
|
@@ -396,8 +402,17 @@ def orient_docs():
|
|
|
396
402
|
("Guide router (when-to-consult)", f"{d}/reference/INDEX.md"),
|
|
397
403
|
("Last session handoff", f"{d}/audit/handoff.md"),
|
|
398
404
|
]
|
|
399
|
-
|
|
400
|
-
|
|
405
|
+
# F-075: the WHOLE output -- the knowledge layer's append included -- stays under
|
|
406
|
+
# Claude Code's 10,000-character hook cap. Above it the client saves the output
|
|
407
|
+
# to a file and injects a 2,000-character preview, and the agent never opens the
|
|
408
|
+
# file: the router printed third, after README and INDEX, never reached a session.
|
|
409
|
+
ORIENT_MAX_TOTAL_CHARS = 9500 # whole output (P-TM T2)
|
|
410
|
+
ORIENT_APPEND_RESERVE = 2000 # left for the knowledge layer's append, which cuts itself to it
|
|
411
|
+
ORIENT_ROUTER_CHARS = 3000 # ~20 guides; the full router stays one read away
|
|
412
|
+
ORIENT_REGISTRY_CHARS = 2600 # registry rows + project-wide notes
|
|
413
|
+
ORIENT_TRIAGE_LINE = ("Triage every request (Rule Zero): L1 trivial - L2 small - L3 significant "
|
|
414
|
+
"- Spike. Declare the level with the guide-router verdict (a guide below, "
|
|
415
|
+
"or \"no match\"). When in doubt, pick the higher level.")
|
|
401
416
|
|
|
402
417
|
|
|
403
418
|
# ----------------------------------------------------------------- utilities
|
|
@@ -524,6 +539,41 @@ def has_etdd_shadow(root):
|
|
|
524
539
|
return any("tdd" in p.name.lower() for p in sol.glob("SHADOW_*.md"))
|
|
525
540
|
|
|
526
541
|
|
|
542
|
+
def newest_shadow(solutions_dir, doc_key):
|
|
543
|
+
"""The newest devPNT shadow of `doc_key` in `solutions_dir`, or None.
|
|
544
|
+
Only `SHADOW_<doc_key>_v<N>[.<N>...].md` counts -- the exact key, so
|
|
545
|
+
`<doc_key>_extra` is another key -- and versions compare number by number
|
|
546
|
+
(v1.10 above v1.9, v1.0 above v1). Names outside that rule are ignored.
|
|
547
|
+
F-081: the Hybrid target of a claim marker; reusable by any consumer."""
|
|
548
|
+
if not Path(solutions_dir).is_dir():
|
|
549
|
+
return None
|
|
550
|
+
best = None
|
|
551
|
+
for p in Path(solutions_dir).iterdir():
|
|
552
|
+
v = _shadow_version(p.name, doc_key)
|
|
553
|
+
if v is not None and p.is_file():
|
|
554
|
+
if best is None or v > best[0]:
|
|
555
|
+
best = (v, p)
|
|
556
|
+
return best[1] if best else None
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _shadow_version(name, doc_key):
|
|
560
|
+
"""The version tuple of `SHADOW_<doc_key>_v<N>[.<N>...].md`, else None."""
|
|
561
|
+
m = re.match(r"^SHADOW_%s_v(\d+(?:\.\d+)*)\.md$" % re.escape(doc_key), name)
|
|
562
|
+
return tuple(int(x) for x in m.group(1).split(".")) if m else None
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def stray_shadow(solutions_dir, doc_key):
|
|
566
|
+
"""A file of the same exact key that `newest_shadow` does not read
|
|
567
|
+
(`SHADOW_<doc_key>.md`, `..._v1.2a.md`), or None -- the name a user needs
|
|
568
|
+
to see when no shadow resolves. A longer key is never a stray."""
|
|
569
|
+
if not Path(solutions_dir).is_dir():
|
|
570
|
+
return None
|
|
571
|
+
rx = re.compile(r"^SHADOW_%s(?:_v[^_]*)?\.md$" % re.escape(doc_key))
|
|
572
|
+
return next((p.name for p in sorted(Path(solutions_dir).iterdir())
|
|
573
|
+
if rx.match(p.name) and _shadow_version(p.name, doc_key) is None),
|
|
574
|
+
None)
|
|
575
|
+
|
|
576
|
+
|
|
527
577
|
def iter_files(target):
|
|
528
578
|
if target.is_file():
|
|
529
579
|
yield target
|
|
@@ -870,10 +920,10 @@ def parse_registry_rows(text):
|
|
|
870
920
|
"""Workstream ids in a registry table, hand-written or generated."""
|
|
871
921
|
ids = []
|
|
872
922
|
for line in text.splitlines():
|
|
873
|
-
|
|
874
|
-
if not
|
|
923
|
+
cells = _table_cells(line)
|
|
924
|
+
if not cells:
|
|
875
925
|
continue
|
|
876
|
-
first =
|
|
926
|
+
first = cells[0]
|
|
877
927
|
if first and first.lower() != "workstream":
|
|
878
928
|
ids.append(first)
|
|
879
929
|
return ids
|
|
@@ -1169,6 +1219,20 @@ def _opener_at(lines, i):
|
|
|
1169
1219
|
return None
|
|
1170
1220
|
|
|
1171
1221
|
|
|
1222
|
+
def malformed_openers(body):
|
|
1223
|
+
"""[(line, text)] -- dated openers whose bold does not pair: `**DATE …` with no
|
|
1224
|
+
closing `**` on the line, or a bare `DATE …` that closes one. Either way the
|
|
1225
|
+
chronology shows a label running into the entry's body (field case 2026-10-19:
|
|
1226
|
+
two release notes in project_notes.md)."""
|
|
1227
|
+
lines = _blank_comments(body).splitlines()
|
|
1228
|
+
out = []
|
|
1229
|
+
for i in range(len(lines)):
|
|
1230
|
+
m = _opener_at(lines, i)
|
|
1231
|
+
if m and bool(m.group(3)) != ("**" in m.group(5)):
|
|
1232
|
+
out.append((i + 1, lines[i]))
|
|
1233
|
+
return out
|
|
1234
|
+
|
|
1235
|
+
|
|
1172
1236
|
def entry_openers(body):
|
|
1173
1237
|
"""[(date, label, line)] -- one per dated entry opener; line is 1-based
|
|
1174
1238
|
within `body`. An undated paragraph belongs to the entry above it. Comments
|
|
@@ -1267,6 +1331,11 @@ def chronology_advisories(root):
|
|
|
1267
1331
|
if len(nxt) > NEXT_MAX_CHARS:
|
|
1268
1332
|
out.append(f"audit/{p.name}: next: is {len(nxt)} chars (> {NEXT_MAX_CHARS}): one "
|
|
1269
1333
|
"next step here, the state goes to the Diary as a dated entry")
|
|
1334
|
+
notes = ai_path(root, "audit", PROJECT_NOTES)
|
|
1335
|
+
if notes.is_file():
|
|
1336
|
+
for n, t in malformed_openers(read_text(notes)):
|
|
1337
|
+
out.append(f"audit/{PROJECT_NOTES}: line {n}: dated entry '{t[:60]}' has unpaired "
|
|
1338
|
+
"bold -- open entries with **YYYY-MM-DD — label.**")
|
|
1270
1339
|
return out
|
|
1271
1340
|
|
|
1272
1341
|
|
|
@@ -1454,10 +1523,30 @@ def has_ledger_heading(text):
|
|
|
1454
1523
|
opens a comment at the start of a line -- nuking to EOF on an inline
|
|
1455
1524
|
mention (or an unclosed example inside a fence) made a document that
|
|
1456
1525
|
HAS its ledger get told it has none."""
|
|
1526
|
+
return bool(re.search(r"^##[ \t]+Capability Ledger[ \t]*$", strip_non_prose(text), re.M))
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
def strip_non_prose(text):
|
|
1530
|
+
"""Text without fenced code blocks and HTML comments (see has_ledger_heading)."""
|
|
1457
1531
|
stripped = re.sub(r"^(```|~~~).*?^\1", "", text, flags=re.M | re.S)
|
|
1458
1532
|
stripped = re.sub(r"<!--.*?-->", "", stripped, flags=re.S)
|
|
1459
|
-
|
|
1460
|
-
|
|
1533
|
+
return re.sub(r"^[ \t]*<!--(?!.*?-->).*\Z", "", stripped, flags=re.M | re.S)
|
|
1534
|
+
|
|
1535
|
+
|
|
1536
|
+
def has_guide_check(text):
|
|
1537
|
+
"""True when a real '## Guide check' heading carries an answer: the template's
|
|
1538
|
+
comment alone answers nothing, so a copied-but-unfilled section still owes it."""
|
|
1539
|
+
m = re.search(r"^##[ \t]+Guide check[ \t]*$(.*?)(?=^#{1,2}[ \t]|\Z)",
|
|
1540
|
+
strip_non_prose(text), re.M | re.S)
|
|
1541
|
+
return bool(m and m.group(1).strip())
|
|
1542
|
+
|
|
1543
|
+
|
|
1544
|
+
def guide_check_due(meta, domain):
|
|
1545
|
+
"""True when an ANALYSIS owes the Phase-3 guide question (SKILL.md): a code-lens
|
|
1546
|
+
L3 started on/after GUIDE_CHECK_EPOCH. Same grandfathering as ledger_due."""
|
|
1547
|
+
return domain == "code" and ledger_due(meta) and (
|
|
1548
|
+
parse_iso((meta.get("start_date") or "").strip().strip("'\""))
|
|
1549
|
+
>= parse_iso(GUIDE_CHECK_EPOCH))
|
|
1461
1550
|
|
|
1462
1551
|
|
|
1463
1552
|
def ledger_due(meta):
|
|
@@ -1706,6 +1795,10 @@ def cmd_validate(root, strict=False, hybrid=False):
|
|
|
1706
1795
|
if ledger_due(meta) and not has_ledger_heading(text):
|
|
1707
1796
|
advisories.append(f"{rel}: L3 without '## Capability Ledger' -- the architect pass "
|
|
1708
1797
|
"left no record (architect.md); run it before the Impact")
|
|
1798
|
+
if guide_check_due(meta, domain) and not has_guide_check(text):
|
|
1799
|
+
advisories.append(f"{rel}: L3 without a filled '## Guide check' -- does a CURRENT "
|
|
1800
|
+
"guide cover what this touches, and did understanding it force "
|
|
1801
|
+
"a model of a complex component no guide covers (guides.md, section 1)?")
|
|
1709
1802
|
|
|
1710
1803
|
# Generated index aligned
|
|
1711
1804
|
hist = ai / "strategic" / "features_history.md"
|
|
@@ -2001,7 +2094,8 @@ def cmd_stale(root, hybrid=False):
|
|
|
2001
2094
|
except ValueError: # symlink out of the tree: report absolute, never crash
|
|
2002
2095
|
name = str(fp)
|
|
2003
2096
|
changed.append(name)
|
|
2004
|
-
|
|
2097
|
+
docs_rel = docs_root_rel(root)
|
|
2098
|
+
changed = [c for c in changed if c != plan_rel and not _in_docs_root(c, docs_rel)]
|
|
2005
2099
|
if changed:
|
|
2006
2100
|
stale.append((rel, changed))
|
|
2007
2101
|
|
|
@@ -2022,9 +2116,54 @@ def cmd_stale(root, hybrid=False):
|
|
|
2022
2116
|
return 1 # stale areas dominate: rc already implied
|
|
2023
2117
|
|
|
2024
2118
|
|
|
2025
|
-
def
|
|
2119
|
+
def add_dated_note(root, note, day=None):
|
|
2120
|
+
"""Write `**DAY — note.**` into audit/project_notes.md, above the newest dated
|
|
2121
|
+
entry (below any undated preface), so the note opens with the shared grammar and
|
|
2122
|
+
the chronology reads it. The day defaults to today (local)."""
|
|
2123
|
+
import datetime as _dt
|
|
2124
|
+
day = day or _dt.date.today().isoformat()
|
|
2125
|
+
entry = "**%s — %s.**" % (day, note.strip().rstrip("."))
|
|
2126
|
+
f = ai_path(root, "audit", PROJECT_NOTES)
|
|
2127
|
+
if not f.is_file():
|
|
2128
|
+
f.parent.mkdir(parents=True, exist_ok=True)
|
|
2129
|
+
f.write_text(entry + "\n", encoding="utf-8")
|
|
2130
|
+
return entry
|
|
2131
|
+
raw = f.read_bytes()
|
|
2132
|
+
crlf = b"\r\n" in raw
|
|
2133
|
+
lines = raw.decode("utf-8", errors="replace").replace("\r\n", "\n").split("\n")
|
|
2134
|
+
at = next((i for i in range(len(lines)) if _opener_at(lines, i)), None)
|
|
2135
|
+
if at is None:
|
|
2136
|
+
while lines and not lines[-1].strip():
|
|
2137
|
+
lines.pop()
|
|
2138
|
+
lines += ["", entry, ""] if lines else [entry, ""]
|
|
2139
|
+
else:
|
|
2140
|
+
lines[at:at] = [entry, ""]
|
|
2141
|
+
text = "\n".join(lines)
|
|
2142
|
+
f.write_bytes((text.replace("\n", "\r\n") if crlf else text).encode("utf-8"))
|
|
2143
|
+
return entry
|
|
2144
|
+
|
|
2145
|
+
|
|
2146
|
+
def docs_root_rel(root):
|
|
2147
|
+
"""The docs root (ai_docs/, mkt_docs/ ...) relative to the project root, or None.
|
|
2148
|
+
It is governance, not an analyzable area: `validate` checks it directly, so `stale`
|
|
2149
|
+
never counts its files and `mark` never records it."""
|
|
2150
|
+
try:
|
|
2151
|
+
return ai_path(root).resolve().relative_to(Path(root).resolve()).as_posix()
|
|
2152
|
+
except ValueError:
|
|
2153
|
+
return None
|
|
2154
|
+
|
|
2155
|
+
|
|
2156
|
+
def _in_docs_root(rel, docs_rel):
|
|
2157
|
+
rel = rel.replace("\\", "/").rstrip("/")
|
|
2158
|
+
return bool(docs_rel) and (rel == docs_rel or rel.startswith(docs_rel + "/"))
|
|
2159
|
+
|
|
2160
|
+
|
|
2161
|
+
def cmd_mark(root, paths, note=None):
|
|
2026
2162
|
if not require_ai_docs(root, "mark"):
|
|
2027
2163
|
return 1
|
|
2164
|
+
if note is not None and (not note.strip() or "**" in note or "\n" in note):
|
|
2165
|
+
print("[ERROR] --note must be one line of text without '**'. Nothing was written.")
|
|
2166
|
+
return 1
|
|
2028
2167
|
f, lines, rows = parse_audit_plan(root)
|
|
2029
2168
|
use_git_ref = git_available(root) and not any(
|
|
2030
2169
|
git_has_changes(root, raw.replace("\\", "/").rstrip("/")) for raw in paths
|
|
@@ -2051,6 +2190,10 @@ def cmd_mark(root, paths):
|
|
|
2051
2190
|
"refusing to mark (use a project-relative path, '.' for the root). "
|
|
2052
2191
|
"Nothing was written.")
|
|
2053
2192
|
return 1
|
|
2193
|
+
if _in_docs_root(key, docs_root_rel(root)):
|
|
2194
|
+
print(f"[note] {raw}: the docs root is not an area to mark -- `validate` checks it "
|
|
2195
|
+
"directly. Skipped.")
|
|
2196
|
+
continue
|
|
2054
2197
|
keys.append(key)
|
|
2055
2198
|
|
|
2056
2199
|
appended = []
|
|
@@ -2070,6 +2213,13 @@ def cmd_mark(root, paths):
|
|
|
2070
2213
|
|
|
2071
2214
|
f.parent.mkdir(parents=True, exist_ok=True)
|
|
2072
2215
|
f.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
2216
|
+
if note is not None:
|
|
2217
|
+
print(f"[ok] dated entry added to audit/{PROJECT_NOTES}: {add_dated_note(root, note)}")
|
|
2218
|
+
elif not _open_units(root):
|
|
2219
|
+
# no open unit's Diary covers this work: a release, a fix after closure
|
|
2220
|
+
print(f"[note] no open unit records this work. If it is a release or a change worth "
|
|
2221
|
+
f"finding later, re-run with --note \"<what was done>\": mark writes the "
|
|
2222
|
+
f"dated entry in audit/{PROJECT_NOTES} for you.")
|
|
2073
2223
|
if use_git_ref:
|
|
2074
2224
|
# F-056: the reference is born here, so this is where the gestures that
|
|
2075
2225
|
# orphan it are named. An amend was the observed one.
|
|
@@ -2149,15 +2299,56 @@ def _orient_user_settings_path():
|
|
|
2149
2299
|
|
|
2150
2300
|
|
|
2151
2301
|
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 --
|
|
2302
|
+
"""Yield (command, at_user_level, place) across every known client config:
|
|
2303
|
+
the three project-relative files, then the machine-global user settings --
|
|
2154
2304
|
the file the installer wires (F-042), without which a fixed machine's
|
|
2155
|
-
projects would read as unwired forever.
|
|
2305
|
+
projects would read as unwired forever. `place` is a display label, never a
|
|
2306
|
+
home path (F-075 prints it)."""
|
|
2156
2307
|
for rel in ORIENT_HOOK_CONFIG_FILES:
|
|
2157
2308
|
for cmd in _orient_entries_in(Path(root) / rel):
|
|
2158
|
-
yield cmd, False
|
|
2309
|
+
yield cmd, False, rel
|
|
2159
2310
|
for cmd in _orient_entries_in(_orient_user_settings_path()):
|
|
2160
|
-
yield cmd, True
|
|
2311
|
+
yield cmd, True, "user settings"
|
|
2312
|
+
|
|
2313
|
+
|
|
2314
|
+
def _orient_entry_live(root, cmd, at_user_level):
|
|
2315
|
+
"""True unless the entry names a validator that does not resolve.
|
|
2316
|
+
|
|
2317
|
+
A matching entry whose tokens name no validator is "cannot tell" and counts
|
|
2318
|
+
as live (the lib.js contract: never report broken on cannot-tell). Relative
|
|
2319
|
+
tokens resolve against the project root (the vendored-validator
|
|
2320
|
+
convention); a USER-level relative token has no cwd to resolve against and
|
|
2321
|
+
is treated as cannot-tell."""
|
|
2322
|
+
token = None
|
|
2323
|
+
for m in _ORIENT_HOOK_TOKEN_RE.finditer(cmd):
|
|
2324
|
+
tok = m.group(1) if m.group(1) is not None else m.group(2)
|
|
2325
|
+
if any(tok.endswith(n) for n in ORIENT_HOOK_ENTRY_POINTS):
|
|
2326
|
+
token = tok
|
|
2327
|
+
break
|
|
2328
|
+
if token is None:
|
|
2329
|
+
return True
|
|
2330
|
+
tp = Path(token)
|
|
2331
|
+
if not tp.is_absolute():
|
|
2332
|
+
if at_user_level:
|
|
2333
|
+
return True
|
|
2334
|
+
tp = Path(root) / tp
|
|
2335
|
+
return tp.exists()
|
|
2336
|
+
|
|
2337
|
+
|
|
2338
|
+
def orient_wiring(root):
|
|
2339
|
+
"""{client: {command: place}} for the LIVE orient entries, per client.
|
|
2340
|
+
|
|
2341
|
+
Per client because two clients never read each other's settings: a Codex
|
|
2342
|
+
entry beside a Claude Code one is two clients oriented once each, not one
|
|
2343
|
+
session oriented twice. Identical command strings count once. A dead entry
|
|
2344
|
+
delivers no copy and is left out (F-075 FS3)."""
|
|
2345
|
+
out = {}
|
|
2346
|
+
for cmd, at_user_level, place in _orient_hook_commands(root):
|
|
2347
|
+
if not _orient_entry_live(root, cmd, at_user_level):
|
|
2348
|
+
continue
|
|
2349
|
+
client = "codex" if place.startswith(".codex") else "claude"
|
|
2350
|
+
out.setdefault(client, {}).setdefault(cmd.strip(), place)
|
|
2351
|
+
return out
|
|
2161
2352
|
|
|
2162
2353
|
|
|
2163
2354
|
def orient_hook_state(root):
|
|
@@ -2172,22 +2363,9 @@ def orient_hook_state(root):
|
|
|
2172
2363
|
the project root (the vendored-validator convention); a USER-level relative
|
|
2173
2364
|
token has no cwd to resolve against and is treated as cannot-tell."""
|
|
2174
2365
|
matched = False
|
|
2175
|
-
for cmd, at_user_level in _orient_hook_commands(root):
|
|
2366
|
+
for cmd, at_user_level, _place in _orient_hook_commands(root):
|
|
2176
2367
|
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():
|
|
2368
|
+
if _orient_entry_live(root, cmd, at_user_level):
|
|
2191
2369
|
return "wired"
|
|
2192
2370
|
return "dead" if matched else "unwired"
|
|
2193
2371
|
|
|
@@ -2269,6 +2447,12 @@ REMIND_FALLBACK = (
|
|
|
2269
2447
|
)
|
|
2270
2448
|
|
|
2271
2449
|
|
|
2450
|
+
# F-075: constant, for every lens -- with sibling lenses installed nothing per
|
|
2451
|
+
# turn said to choose the lens. r19's bound rose to 600 for it (r32, owner).
|
|
2452
|
+
REMIND_LENS_SUFFIX = (" Above L1, with sibling lenses installed: choose the lens with "
|
|
2453
|
+
"routing.md and declare it.")
|
|
2454
|
+
|
|
2455
|
+
|
|
2272
2456
|
def cmd_remind(args):
|
|
2273
2457
|
"""UserPromptSubmit hook: print the lens's constant per-turn line. Always 0.
|
|
2274
2458
|
|
|
@@ -2277,21 +2461,31 @@ def cmd_remind(args):
|
|
|
2277
2461
|
try:
|
|
2278
2462
|
prof = profile()
|
|
2279
2463
|
line = prof.get("remind_line") or REMIND_FALLBACK.format(skill=prof["skill_name"])
|
|
2280
|
-
print(f"{prof['skill_name']}: {line}")
|
|
2464
|
+
print(f"{prof['skill_name']}: {line}{REMIND_LENS_SUFFIX}")
|
|
2281
2465
|
except Exception:
|
|
2282
2466
|
pass
|
|
2283
2467
|
return 0
|
|
2284
2468
|
|
|
2469
|
+
def read_hook_payload():
|
|
2470
|
+
"""The hook event JSON on stdin as a dict, or None when it cannot be read.
|
|
2471
|
+
|
|
2472
|
+
bytes -> utf-8-sig: the payload is UTF-8 JSON regardless of the console code
|
|
2473
|
+
page; '-sig' strips the BOM (PowerShell pipes)."""
|
|
2474
|
+
try:
|
|
2475
|
+
raw = sys.stdin.buffer.read().decode("utf-8-sig", errors="replace")
|
|
2476
|
+
payload = json.loads(raw)
|
|
2477
|
+
except Exception:
|
|
2478
|
+
return None
|
|
2479
|
+
return payload if isinstance(payload, dict) else None
|
|
2480
|
+
|
|
2481
|
+
|
|
2285
2482
|
def cmd_gate(args):
|
|
2286
2483
|
file_path = args.file or ""
|
|
2287
2484
|
if args.hook:
|
|
2485
|
+
payload = read_hook_payload()
|
|
2288
2486
|
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
2487
|
file_path = (payload.get("tool_input") or {}).get("file_path") or ""
|
|
2294
|
-
except
|
|
2488
|
+
except AttributeError:
|
|
2295
2489
|
return 0 # unparseable input: do not block
|
|
2296
2490
|
if not file_path:
|
|
2297
2491
|
return 0
|
|
@@ -2326,6 +2520,134 @@ def cmd_gate(args):
|
|
|
2326
2520
|
return 2
|
|
2327
2521
|
|
|
2328
2522
|
|
|
2523
|
+
# ------------------------------------------------------------------ verdict
|
|
2524
|
+
# F-076: Rule Zero's router verdict, checked at the edit. A field agent edited
|
|
2525
|
+
# without declaring one and said nothing stopped it. This reads the session
|
|
2526
|
+
# transcript the client hands every hook and refuses the edit (JSON deny, exit
|
|
2527
|
+
# 0) only on certainty; whatever it cannot tell resolves to silence. It never
|
|
2528
|
+
# prints `allow` -- that would skip the user's permission prompt. Owner: block
|
|
2529
|
+
# (REQUEST_guide_verdict_check.md R3), wired by default (R4, rulings.md r34).
|
|
2530
|
+
|
|
2531
|
+
VERDICT_EDIT_TOOLS = ("Edit", "Write", "MultiEdit", "NotebookEdit")
|
|
2532
|
+
VERDICT_MAX_BYTES = 256 * 1024 * 1024 # a larger transcript: cannot afford to tell
|
|
2533
|
+
VERDICT_LINE_MAX = 1024 * 1024 # assistant lines are small; skip the rest unparsed
|
|
2534
|
+
# Markdown emphasis between the word and the colon ("**router**: no match") and
|
|
2535
|
+
# "Router verdict:" are the same declaration: format drift resolves to silence.
|
|
2536
|
+
_VERDICT_ROUTER_RE = re.compile(r"router(?:[\s*_`]+verdict)?[\s*_`]*:(.*)", re.IGNORECASE)
|
|
2537
|
+
_VERDICT_NONE_RE = re.compile(r"[\s`*_\"'(]*(no match|absent)", re.IGNORECASE)
|
|
2538
|
+
_VERDICT_TRIVIAL_RE = re.compile(r"\blevel[\s*_`]*:[\s`*_]*[A-Z]1\b", re.IGNORECASE)
|
|
2539
|
+
_GUIDE_NAME_RE = re.compile(r"GUIDE_[A-Za-z0-9_-]+\.md")
|
|
2540
|
+
|
|
2541
|
+
|
|
2542
|
+
def transcript_facts(path):
|
|
2543
|
+
"""What the session transcript shows about the verdict duty, or None when it
|
|
2544
|
+
cannot be read within the bounds.
|
|
2545
|
+
|
|
2546
|
+
Returns {"assistant": entries parsed, "sidechain": a subagent's own transcript
|
|
2547
|
+
(Claude Code marks every entry of one `isSidechain`), "declared": a router verdict seen,
|
|
2548
|
+
"trivial": a `Level: X1` seen, "named": guides the verdicts name,
|
|
2549
|
+
"read": guide names that appeared in a non-edit tool's input}."""
|
|
2550
|
+
facts = {"assistant": 0, "sidechain": False, "declared": False, "trivial": False,
|
|
2551
|
+
"named": set(), "read": set()}
|
|
2552
|
+
try:
|
|
2553
|
+
p = Path(path)
|
|
2554
|
+
if not p.is_file() or p.stat().st_size > VERDICT_MAX_BYTES:
|
|
2555
|
+
return None
|
|
2556
|
+
with p.open("rb") as fh:
|
|
2557
|
+
for raw in fh:
|
|
2558
|
+
if len(raw) > VERDICT_LINE_MAX or b'"assistant"' not in raw:
|
|
2559
|
+
continue
|
|
2560
|
+
try:
|
|
2561
|
+
entry = json.loads(raw.decode("utf-8", errors="replace"))
|
|
2562
|
+
except ValueError:
|
|
2563
|
+
continue
|
|
2564
|
+
if not isinstance(entry, dict) or entry.get("type") != "assistant":
|
|
2565
|
+
continue
|
|
2566
|
+
content = (entry.get("message") or {}).get("content")
|
|
2567
|
+
if not isinstance(content, list):
|
|
2568
|
+
continue
|
|
2569
|
+
facts["assistant"] += 1
|
|
2570
|
+
if entry.get("isSidechain") is True:
|
|
2571
|
+
facts["sidechain"] = True
|
|
2572
|
+
for block in content:
|
|
2573
|
+
if not isinstance(block, dict):
|
|
2574
|
+
continue
|
|
2575
|
+
if block.get("type") == "text":
|
|
2576
|
+
for line in str(block.get("text") or "").splitlines():
|
|
2577
|
+
if _VERDICT_TRIVIAL_RE.search(line):
|
|
2578
|
+
facts["trivial"] = True
|
|
2579
|
+
m = _VERDICT_ROUTER_RE.search(line)
|
|
2580
|
+
if m:
|
|
2581
|
+
facts["declared"] = True
|
|
2582
|
+
if not _VERDICT_NONE_RE.match(m.group(1)):
|
|
2583
|
+
facts["named"].update(_GUIDE_NAME_RE.findall(m.group(1)))
|
|
2584
|
+
elif (block.get("type") == "tool_use"
|
|
2585
|
+
and block.get("name") not in VERDICT_EDIT_TOOLS):
|
|
2586
|
+
facts["read"].update(_GUIDE_NAME_RE.findall(
|
|
2587
|
+
json.dumps(block.get("input"), ensure_ascii=False)))
|
|
2588
|
+
except (OSError, ValueError, TypeError, AttributeError):
|
|
2589
|
+
return None
|
|
2590
|
+
return facts
|
|
2591
|
+
|
|
2592
|
+
|
|
2593
|
+
def verdict_reason(payload, root):
|
|
2594
|
+
"""The refusal line for this edit event, or None when the edit may proceed."""
|
|
2595
|
+
if payload.get("tool_name") not in VERDICT_EDIT_TOOLS or payload.get("agent_id"):
|
|
2596
|
+
return None
|
|
2597
|
+
tool_input = payload.get("tool_input") or {}
|
|
2598
|
+
target = tool_input.get("file_path") or tool_input.get("notebook_path")
|
|
2599
|
+
transcript = payload.get("transcript_path")
|
|
2600
|
+
if not target or not transcript:
|
|
2601
|
+
return None
|
|
2602
|
+
try:
|
|
2603
|
+
Path(target).resolve().relative_to(root)
|
|
2604
|
+
except (ValueError, OSError):
|
|
2605
|
+
return None # outside the project: not this check's concern
|
|
2606
|
+
ref = ai_path(root, "reference")
|
|
2607
|
+
if not ref.is_dir():
|
|
2608
|
+
return None
|
|
2609
|
+
guides = {g.name for g in ref.glob("GUIDE_*.md") if g.is_file()}
|
|
2610
|
+
if not guides:
|
|
2611
|
+
return None
|
|
2612
|
+
facts = transcript_facts(transcript)
|
|
2613
|
+
if not facts or not facts["assistant"]:
|
|
2614
|
+
return None # unreadable, oversized or an unknown format: cannot tell
|
|
2615
|
+
if facts["sidechain"]:
|
|
2616
|
+
return None # a subagent works under its orchestrator's verdict
|
|
2617
|
+
if not (facts["declared"] or facts["trivial"]):
|
|
2618
|
+
return ("[sdlc verdict] No guide-router verdict in this session; this project has "
|
|
2619
|
+
f"guides ({docs_dir()}/reference/INDEX.md). Declare it with the level, e.g. "
|
|
2620
|
+
"\"Level: L2 · router: no match\" or \"router: GUIDE_x.md -> read\" "
|
|
2621
|
+
"(L1: \"Level: L1\"), then retry.")
|
|
2622
|
+
unread = sorted((facts["named"] & guides) - facts["read"])
|
|
2623
|
+
if unread:
|
|
2624
|
+
return (f"[sdlc verdict] The router verdict names {', '.join(unread)}, not read in "
|
|
2625
|
+
"this session. Read it, then retry.")
|
|
2626
|
+
return None
|
|
2627
|
+
|
|
2628
|
+
|
|
2629
|
+
def cmd_verdict(args):
|
|
2630
|
+
"""PreToolUse hook: print a JSON refusal or nothing. Always 0 -- exit 2 would
|
|
2631
|
+
block blind, and the wired command adds `; exit 0` for validators that cannot
|
|
2632
|
+
run this at all (missing, or older than it)."""
|
|
2633
|
+
if not args.hook:
|
|
2634
|
+
return 0
|
|
2635
|
+
try:
|
|
2636
|
+
payload = read_hook_payload()
|
|
2637
|
+
if payload is None:
|
|
2638
|
+
return 0
|
|
2639
|
+
root = Path(args.root).resolve() if args.root else find_project_root()
|
|
2640
|
+
reason = verdict_reason(payload, root)
|
|
2641
|
+
if reason:
|
|
2642
|
+
print(json.dumps({"hookSpecificOutput": {
|
|
2643
|
+
"hookEventName": "PreToolUse",
|
|
2644
|
+
"permissionDecision": "deny",
|
|
2645
|
+
"permissionDecisionReason": reason}}, ensure_ascii=False))
|
|
2646
|
+
except Exception:
|
|
2647
|
+
pass
|
|
2648
|
+
return 0
|
|
2649
|
+
|
|
2650
|
+
|
|
2329
2651
|
# --------------------------------------------------------------------- plan
|
|
2330
2652
|
# Subagent Execution (Feature A). Zero-execution surface: this section and
|
|
2331
2653
|
# everything it calls MUST NOT spawn a process (no subprocess/os.system/eval/
|
|
@@ -2525,15 +2847,23 @@ REVIEW_POSITIONAL = {
|
|
|
2525
2847
|
}
|
|
2526
2848
|
|
|
2527
2849
|
|
|
2850
|
+
_UNESCAPED_PIPE_RE = re.compile(r"(?<!\\)\|")
|
|
2851
|
+
|
|
2852
|
+
|
|
2528
2853
|
def _table_cells(line):
|
|
2529
2854
|
"""Cells of a Markdown table row, or None when the line is not one.
|
|
2530
|
-
Separator rows (---, :---:, any alignment colons) are not data.
|
|
2855
|
+
Separator rows (---, :---:, any alignment colons) are not data. A cell may
|
|
2856
|
+
carry an escaped pipe (`\\|`, as `index` writes them): it does not split the
|
|
2857
|
+
row, and it reads back as `|` (F-075)."""
|
|
2531
2858
|
s = line.strip()
|
|
2532
2859
|
if not s.startswith("|"):
|
|
2533
2860
|
return None
|
|
2534
2861
|
if set(s) <= set("|-: "):
|
|
2535
2862
|
return None
|
|
2536
|
-
|
|
2863
|
+
body = s[1:]
|
|
2864
|
+
if body.endswith("|") and not body.endswith("\\|"):
|
|
2865
|
+
body = body[:-1]
|
|
2866
|
+
return [c.strip().replace("\\|", "|") for c in _UNESCAPED_PIPE_RE.split(body)]
|
|
2537
2867
|
|
|
2538
2868
|
|
|
2539
2869
|
def parse_review_log(text):
|
|
@@ -2598,6 +2928,13 @@ def _is_pass(verdict):
|
|
|
2598
2928
|
return re.search(r"\bPASS\b", (verdict or "").upper()) is not None
|
|
2599
2929
|
|
|
2600
2930
|
|
|
2931
|
+
def _is_not_due(verdict):
|
|
2932
|
+
"""A review ruled not due (review.md, a delta that only restates judged
|
|
2933
|
+
work): logged so a skip stays visible, but it is neither a review nor a
|
|
2934
|
+
finding, so it stays out of every count below (F-071)."""
|
|
2935
|
+
return (verdict or "").strip().upper().startswith("NOT DUE")
|
|
2936
|
+
|
|
2937
|
+
|
|
2601
2938
|
def _print_read_cost():
|
|
2602
2939
|
"""The cost side, printed with the benefit and NEVER without it: a catch
|
|
2603
2940
|
rate with no cost term justifies unbounded doctrine, and a cost term with
|
|
@@ -2638,6 +2975,8 @@ def cmd_benefit(root):
|
|
|
2638
2975
|
_print_read_cost()
|
|
2639
2976
|
return 0
|
|
2640
2977
|
rows, unparsed = parse_review_log(read_text(log))
|
|
2978
|
+
not_due = [r for r in rows if _is_not_due(r["verdict"])]
|
|
2979
|
+
rows = [r for r in rows if not _is_not_due(r["verdict"])]
|
|
2641
2980
|
# `tier` carries the MOMENT in Standalone (design / closure) and the
|
|
2642
2981
|
# reviewer WEIGHT in Hybrid (deep / light / code / guide / vision). A
|
|
2643
2982
|
# Hybrid row is a real review whose moment this column does not state, so
|
|
@@ -2662,9 +3001,11 @@ def cmd_benefit(root):
|
|
|
2662
3001
|
unstated_code += n
|
|
2663
3002
|
classified = moments["design"] + moments["closure"]
|
|
2664
3003
|
print("rows parsed: %d rows unparsed: %d%s"
|
|
2665
|
-
% (len(rows), len(unparsed),
|
|
3004
|
+
% (len(rows) + len(not_due), len(unparsed),
|
|
2666
3005
|
(" -> " + "; ".join("line %d: %s" % u for u in unparsed[:5]))
|
|
2667
3006
|
if unparsed else ""))
|
|
3007
|
+
print("reviews ruled not due (logged, counted in no figure below): %d"
|
|
3008
|
+
% len(not_due))
|
|
2668
3009
|
print("reviews with a stated moment: %d moment not stated by `tier`: %d%s"
|
|
2669
3010
|
% (len(classified), sum(unstated.values()),
|
|
2670
3011
|
(" (" + ", ".join("%s x%d" % (t, n) for t, n
|
|
@@ -2730,48 +3071,205 @@ def cmd_benefit(root):
|
|
|
2730
3071
|
return 0
|
|
2731
3072
|
|
|
2732
3073
|
|
|
3074
|
+
# --- orient helpers (F-075) ---------------------------------------------------
|
|
3075
|
+
SKILL_MD_CAP = 64 * 1024 # a SKILL.md is read for its name only
|
|
3076
|
+
|
|
3077
|
+
|
|
3078
|
+
def orient_fit(lines, budget, rel):
|
|
3079
|
+
"""The lines that fit `budget` characters (one newline each), cut at a line
|
|
3080
|
+
boundary and closed by a pointer to the file holding the rest. A single line
|
|
3081
|
+
wider than the room left shows its head. Never raises."""
|
|
3082
|
+
out, used = [], 0
|
|
3083
|
+
for i, line in enumerate(lines):
|
|
3084
|
+
if used + len(line) + 1 <= budget:
|
|
3085
|
+
out.append(line)
|
|
3086
|
+
used += len(line) + 1
|
|
3087
|
+
continue
|
|
3088
|
+
rest = len(lines) - i
|
|
3089
|
+
|
|
3090
|
+
def ptr(n):
|
|
3091
|
+
return f"(+{n} more lines -- read {rel})"
|
|
3092
|
+
room = budget - used - len(ptr(rest)) - 1
|
|
3093
|
+
if room > 40:
|
|
3094
|
+
head = line[:room - 4] + "..."
|
|
3095
|
+
out.append(head)
|
|
3096
|
+
used += len(head) + 1
|
|
3097
|
+
while out and used + len(ptr(rest)) + 1 > budget:
|
|
3098
|
+
used -= len(out.pop()) + 1
|
|
3099
|
+
rest += 1
|
|
3100
|
+
out.append(ptr(rest))
|
|
3101
|
+
return out
|
|
3102
|
+
return out
|
|
3103
|
+
|
|
3104
|
+
|
|
3105
|
+
def lens_table(skill_dir=None):
|
|
3106
|
+
"""{skill_name: lens} from the running skill's own `routing.md` par.0 table.
|
|
3107
|
+
|
|
3108
|
+
The Python twin of lib.js `lensTable()`, same parse: a row whose lens cell is
|
|
3109
|
+
`[a-z]+` and whose skill cell is a backticked `[a-z0-9-]+`. `routing.md` is a
|
|
3110
|
+
byte-identical shared file, so the family list cannot drift between
|
|
3111
|
+
distributions -- a literal list here is how kb and mkt once announced
|
|
3112
|
+
themselves as the code lens. Fail-open: {} on any failure."""
|
|
3113
|
+
try:
|
|
3114
|
+
base = Path(skill_dir) if skill_dir else Path(__file__).resolve().parent.parent
|
|
3115
|
+
table = {}
|
|
3116
|
+
for line in read_text(base / "routing.md").splitlines():
|
|
3117
|
+
cells = [c.strip() for c in line.split("|")]
|
|
3118
|
+
if len(cells) < 4:
|
|
3119
|
+
continue
|
|
3120
|
+
lens, skill = cells[1], cells[2]
|
|
3121
|
+
if re.fullmatch(r"[a-z]+", lens) and re.fullmatch(r"`[a-z0-9-]+`", skill):
|
|
3122
|
+
table.setdefault(skill[1:-1], lens)
|
|
3123
|
+
return table
|
|
3124
|
+
except Exception:
|
|
3125
|
+
return {}
|
|
3126
|
+
|
|
3127
|
+
|
|
3128
|
+
def _skills_dirs():
|
|
3129
|
+
"""The two places a sibling lens is installed: beside the running skill, and
|
|
3130
|
+
the user's Claude skills directory. AGENTIC_SDLC_SKILLS_DIRS (os.pathsep
|
|
3131
|
+
list; empty = none) is the test seam, like AGENTIC_SDLC_USER_SETTINGS."""
|
|
3132
|
+
seam = os.environ.get("AGENTIC_SDLC_SKILLS_DIRS")
|
|
3133
|
+
if seam is not None:
|
|
3134
|
+
return [Path(s) for s in seam.split(os.pathsep) if s]
|
|
3135
|
+
cfg = os.environ.get("CLAUDE_CONFIG_DIR")
|
|
3136
|
+
return [Path(__file__).resolve().parents[2],
|
|
3137
|
+
(Path(cfg) if cfg else Path.home() / ".claude") / "skills"]
|
|
3138
|
+
|
|
3139
|
+
|
|
3140
|
+
def installed_sibling_lenses():
|
|
3141
|
+
"""Names of the family's other lenses installed on this machine (sorted).
|
|
3142
|
+
|
|
3143
|
+
Only a name that appears in `lens_table()` AND equals a `SKILL.md` `name:`
|
|
3144
|
+
exactly can come out -- no text of a sibling's files reaches the output
|
|
3145
|
+
(P-TM T3). Fail-open: [] on any failure."""
|
|
3146
|
+
try:
|
|
3147
|
+
own = profile()["skill_name"]
|
|
3148
|
+
family = lens_table()
|
|
3149
|
+
found = set()
|
|
3150
|
+
for base in _skills_dirs():
|
|
3151
|
+
if not base.is_dir():
|
|
3152
|
+
continue
|
|
3153
|
+
for sub in base.iterdir():
|
|
3154
|
+
f = sub / "SKILL.md"
|
|
3155
|
+
try:
|
|
3156
|
+
if not f.is_file() or f.stat().st_size > SKILL_MD_CAP:
|
|
3157
|
+
continue
|
|
3158
|
+
m = re.search(r"^name:\s*(\S+)", read_text(f), re.M)
|
|
3159
|
+
except OSError:
|
|
3160
|
+
continue
|
|
3161
|
+
if m and m.group(1) in family and m.group(1) != own:
|
|
3162
|
+
found.add(m.group(1))
|
|
3163
|
+
return sorted(found)
|
|
3164
|
+
except Exception:
|
|
3165
|
+
return []
|
|
3166
|
+
|
|
3167
|
+
|
|
3168
|
+
def _orient_router_lines(text):
|
|
3169
|
+
"""One line per guide from the generated router; a hand-written one as written."""
|
|
3170
|
+
if not re.search(r"GENERATED by \S+ index - do not edit by hand", text):
|
|
3171
|
+
return text.splitlines()
|
|
3172
|
+
rows = []
|
|
3173
|
+
for line in text.splitlines():
|
|
3174
|
+
cells = _table_cells(line)
|
|
3175
|
+
if not cells or len(cells) < 3 or cells[0] == "Guide":
|
|
3176
|
+
continue
|
|
3177
|
+
rows.append(f"- {cells[0]} ({cells[1]}) -- {cells[2]}")
|
|
3178
|
+
return rows or ['no guides yet -- the router verdict is "no match"']
|
|
3179
|
+
|
|
3180
|
+
|
|
3181
|
+
def _orient_registry_lines(text):
|
|
3182
|
+
"""One line per workstream plus the project-wide notes from the generated
|
|
3183
|
+
registry; a hand-written handoff as written."""
|
|
3184
|
+
if not is_generated_registry(text):
|
|
3185
|
+
return text.splitlines()
|
|
3186
|
+
table, _, notes = text.partition("\n## Project-wide notes")
|
|
3187
|
+
lines = []
|
|
3188
|
+
for line in table.splitlines():
|
|
3189
|
+
c = _table_cells(line)
|
|
3190
|
+
if not c or len(c) < 6 or c[0].lower() == "workstream":
|
|
3191
|
+
continue
|
|
3192
|
+
lines.append(f"- {c[0]} \u00b7 {c[1]} \u00b7 {c[2]} \u00b7 {c[3]} -- {c[5]}")
|
|
3193
|
+
if not lines:
|
|
3194
|
+
lines.append("no open workstreams")
|
|
3195
|
+
notes = notes.strip()
|
|
3196
|
+
if notes:
|
|
3197
|
+
lines += ["Project-wide notes:"] + notes.splitlines()
|
|
3198
|
+
return lines
|
|
3199
|
+
|
|
3200
|
+
|
|
2733
3201
|
def cmd_orient(args):
|
|
2734
|
-
"""SessionStart hook: emit a bounded, repo-sourced
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
(P-TM T2). No subprocess/eval anywhere in this call graph.
|
|
3202
|
+
"""SessionStart hook: emit a bounded, repo-sourced orientation to stdout and
|
|
3203
|
+
ALWAYS return 0 (fail-open, P-TM T8) -- a session hook must never block the
|
|
3204
|
+
session or surface a traceback. Zero-execution (P-TM T1): reads a fixed
|
|
3205
|
+
hard-coded doc set, confine_under each (P-TM T3), bounds the WHOLE output
|
|
3206
|
+
(P-TM T2). No subprocess/eval anywhere in this call graph.
|
|
3207
|
+
|
|
3208
|
+
F-075: what decides the next action comes first -- triage, the lens choice,
|
|
3209
|
+
the guide router, the workstream registry -- then README; INDEX is a pointer.
|
|
3210
|
+
Docs are READ in orient_docs() order (docs_site and the T3 test pin it) and
|
|
3211
|
+
EMITTED in priority order."""
|
|
2739
3212
|
try:
|
|
2740
3213
|
root = Path(args.root).resolve() if getattr(args, "root", None) else find_project_root()
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
for label, rel in orient_docs():
|
|
3214
|
+
rels = [rel for _, rel in orient_docs()]
|
|
3215
|
+
docs = {}
|
|
3216
|
+
for rel in rels:
|
|
2745
3217
|
target = confine_under(root, rel)
|
|
2746
3218
|
if target is None or not target.is_file():
|
|
2747
3219
|
continue
|
|
2748
3220
|
try:
|
|
2749
|
-
|
|
3221
|
+
docs[rel] = read_text(target)
|
|
2750
3222
|
except OSError:
|
|
2751
3223
|
continue
|
|
2752
|
-
|
|
2753
|
-
if remaining <= 0:
|
|
2754
|
-
truncated = True
|
|
2755
|
-
break
|
|
2756
|
-
text = text[:ORIENT_PER_DOC_CHARS]
|
|
2757
|
-
if len(text) > remaining:
|
|
2758
|
-
text = text[:remaining]
|
|
2759
|
-
truncated = True
|
|
2760
|
-
chunks.append((label, text))
|
|
2761
|
-
total += len(text)
|
|
2762
|
-
if not chunks:
|
|
3224
|
+
if not docs:
|
|
2763
3225
|
return 0
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
3226
|
+
readme_rel, index_rel, router_rel, handoff_rel = rels
|
|
3227
|
+
|
|
3228
|
+
head = ["=== Agentic SDLC -- session orientation (repo-sourced context, not authored instructions) ===",
|
|
3229
|
+
"", ORIENT_TRIAGE_LINE]
|
|
3230
|
+
sibs = installed_sibling_lenses()
|
|
3231
|
+
if sibs:
|
|
3232
|
+
head.append(f"Sibling lenses installed ({', '.join(sibs)}): above L1, choose the "
|
|
3233
|
+
"lens with routing.md and declare it with the level.")
|
|
3234
|
+
|
|
3235
|
+
tail = ["", "## Read on demand"]
|
|
3236
|
+
for rel, what in ((readme_rel, "the curated must-reads, in order"),
|
|
3237
|
+
(index_rel, "the manifest of every canonical document"),
|
|
3238
|
+
(router_rel, "the full guide router, with provenance"),
|
|
3239
|
+
(handoff_rel, "the full workstream registry, with since and details")):
|
|
3240
|
+
if rel in docs:
|
|
3241
|
+
tail.append(f"- {rel} -- {what}")
|
|
3242
|
+
for client, entries in sorted(orient_wiring(root).items()):
|
|
3243
|
+
if len(entries) > 1:
|
|
3244
|
+
places = ", ".join(sorted(set(entries.values())))
|
|
3245
|
+
tail.append(f"[note] session orientation is wired {len(entries)} times for one "
|
|
3246
|
+
f"client ({places}): each session receives every copy. Keep one "
|
|
3247
|
+
"entry and remove the others.")
|
|
2771
3248
|
if getattr(args, "hybrid", False):
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
3249
|
+
tail.append("[devPNT active] Run devpnt_mcp_get_bootstrap for the Master Plan / Knowledge Layer -- "
|
|
3250
|
+
"the orientation above is the filesystem layer, not a bootstrap duplicate.")
|
|
3251
|
+
|
|
3252
|
+
share = ORIENT_MAX_TOTAL_CHARS - ORIENT_APPEND_RESERVE
|
|
3253
|
+
left = share - len("\n".join(head + tail)) - 2
|
|
3254
|
+
body = []
|
|
3255
|
+
for rel, title, builder, cap in (
|
|
3256
|
+
(router_rel, "## Guide router (reference/INDEX.md) -- read the matching guide before acting",
|
|
3257
|
+
_orient_router_lines, ORIENT_ROUTER_CHARS),
|
|
3258
|
+
(handoff_rel, "## Workstream registry (audit/handoff.md)",
|
|
3259
|
+
_orient_registry_lines, ORIENT_REGISTRY_CHARS),
|
|
3260
|
+
(readme_rel, "## Reading guide (README)", str.splitlines, None)):
|
|
3261
|
+
if rel not in docs:
|
|
3262
|
+
continue
|
|
3263
|
+
room = left - len(title) - 2
|
|
3264
|
+
if cap is not None:
|
|
3265
|
+
room = min(room, cap)
|
|
3266
|
+
if room <= 0:
|
|
3267
|
+
continue
|
|
3268
|
+
lines = orient_fit(builder(docs[rel].strip()), room, rel)
|
|
3269
|
+
section = ["", title] + lines
|
|
3270
|
+
body += section
|
|
3271
|
+
left -= len("\n".join(section)) + 1
|
|
3272
|
+
print("\n".join(head + body + tail))
|
|
2775
3273
|
return 0
|
|
2776
3274
|
except Exception:
|
|
2777
3275
|
return 0
|
|
@@ -2988,11 +3486,18 @@ def main(argv=None):
|
|
|
2988
3486
|
|
|
2989
3487
|
mp = sub.add_parser("mark", parents=[common], help="record paths as ANALYZED")
|
|
2990
3488
|
mp.add_argument("paths", nargs="+", help="paths relative to the project root")
|
|
3489
|
+
mp.add_argument("--note", help="also write a dated entry in audit/project_notes.md "
|
|
3490
|
+
"(a release, work outside an open unit)")
|
|
2991
3491
|
gp = sub.add_parser("gate", parents=[common, hybrid_opt], help="PreToolUse hook (exit 2 = block)")
|
|
2992
3492
|
gp.add_argument("--hook", action="store_true", help="read the hook JSON payload from stdin")
|
|
2993
3493
|
gp.add_argument("--file", help="file path to evaluate (alternative to --hook)")
|
|
2994
3494
|
gp.add_argument("--protected", default="", help="protected prefixes separated by ';' (e.g. \"src/auth;src/crypto\")")
|
|
2995
3495
|
|
|
3496
|
+
vp = sub.add_parser("verdict", parents=[common],
|
|
3497
|
+
help="PreToolUse hook: refuse an edit with no router verdict "
|
|
3498
|
+
"or an unread named guide (fail-open, always exit 0)")
|
|
3499
|
+
vp.add_argument("--hook", action="store_true", help="read the hook JSON payload from stdin")
|
|
3500
|
+
|
|
2996
3501
|
sub.add_parser("benefit", parents=[common],
|
|
2997
3502
|
help="report what the review gates caught (and what the "
|
|
2998
3503
|
"doctrine costs to read) -- a report, never a gate: "
|
|
@@ -3034,6 +3539,10 @@ def main(argv=None):
|
|
|
3034
3539
|
# and says so rather than exiting non-zero.
|
|
3035
3540
|
print(f"[note] {exc}")
|
|
3036
3541
|
discovered, name = None, DEFAULT_DOCS_DIR
|
|
3542
|
+
elif args.cmd == "verdict":
|
|
3543
|
+
# F-076: a PreToolUse check that cannot tell which root governs refuses
|
|
3544
|
+
# nothing and says nothing -- its stdout is read as the hook's decision.
|
|
3545
|
+
return 0
|
|
3037
3546
|
else:
|
|
3038
3547
|
print(f"[ERROR] {exc}")
|
|
3039
3548
|
return 1
|
|
@@ -3041,6 +3550,8 @@ def main(argv=None):
|
|
|
3041
3550
|
|
|
3042
3551
|
if args.cmd == "gate":
|
|
3043
3552
|
return cmd_gate(args)
|
|
3553
|
+
if args.cmd == "verdict":
|
|
3554
|
+
return cmd_verdict(args)
|
|
3044
3555
|
if args.cmd == "orient":
|
|
3045
3556
|
return cmd_orient(args)
|
|
3046
3557
|
if args.cmd == "remind":
|
|
@@ -3056,7 +3567,7 @@ def main(argv=None):
|
|
|
3056
3567
|
if args.cmd == "stale":
|
|
3057
3568
|
return cmd_stale(root, hybrid=args.hybrid)
|
|
3058
3569
|
if args.cmd == "mark":
|
|
3059
|
-
return cmd_mark(root, args.paths)
|
|
3570
|
+
return cmd_mark(root, args.paths, args.note)
|
|
3060
3571
|
if args.cmd == "benefit":
|
|
3061
3572
|
return cmd_benefit(root)
|
|
3062
3573
|
if args.cmd == "migrate":
|