@antoneeo/course-creator 0.1.0-beta.1 → 0.1.0-beta.13

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.
@@ -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
@@ -89,6 +91,18 @@ ARCHITECT_PASS_EPOCH = "2026-07-28"
89
91
  # row. Same grandfathering discipline as the pass above -- never nag work that
90
92
  # predates the rule.
91
93
  DESIGN_REVIEW_EPOCH = "2026-07-28"
94
+ # Chronology (F-073): an L3 ANALYSIS started, or an L3 HANDOFF updated, on/after
95
+ # this date is read for undated Diary text and a multi-step `next:`. Advisories,
96
+ # like the two gates above: a fixed epoch cannot protect a late upgrader, so a
97
+ # warning here would redden --strict CI on work written before the upgrade.
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"
103
+ NEXT_MAX_CHARS = 240 # this repository's longest `next:` was 214 at the epoch
104
+ ID_SCAN_MAX_REFS = 300 # A3 is skipped above it (one advisory says so, T9)
105
+ ID_SCAN_REFS_PER_CALL = 50 # keeps one git command line far below 32k chars
92
106
  def review_log_rel():
93
107
  return f"{docs_dir()}/audit/reviews/REVIEW_LOG.md"
94
108
  # Component Map 'Where' refs: a dotted token counts as a path only with one of
@@ -388,8 +402,17 @@ def orient_docs():
388
402
  ("Guide router (when-to-consult)", f"{d}/reference/INDEX.md"),
389
403
  ("Last session handoff", f"{d}/audit/handoff.md"),
390
404
  ]
391
- ORIENT_PER_DOC_CHARS = 6000 # per-doc truncation
392
- ORIENT_MAX_TOTAL_CHARS = 16000 # total ingestion cap (P-TM T2); tunable
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.")
393
416
 
394
417
 
395
418
  # ----------------------------------------------------------------- utilities
@@ -516,6 +539,41 @@ def has_etdd_shadow(root):
516
539
  return any("tdd" in p.name.lower() for p in sol.glob("SHADOW_*.md"))
517
540
 
518
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
+
519
577
  def iter_files(target):
520
578
  if target.is_file():
521
579
  yield target
@@ -834,6 +892,10 @@ def build_registry(root):
834
892
  return ""
835
893
  stamp = max(str(m.get("updated") or m.get("since") or "").strip() or "0000-00-00"
836
894
  for _p, m in rows)
895
+ return _render_registry(root, rows, stamp)
896
+
897
+
898
+ def _render_registry(root, rows, stamp):
837
899
  lines = ["# Handoff — workstream registry",
838
900
  f"Date: {stamp} (UTC)",
839
901
  "",
@@ -858,15 +920,44 @@ def parse_registry_rows(text):
858
920
  """Workstream ids in a registry table, hand-written or generated."""
859
921
  ids = []
860
922
  for line in text.splitlines():
861
- s = line.strip()
862
- if not s.startswith("|") or set(s) <= {"|", "-", " ", ":"}:
923
+ cells = _table_cells(line)
924
+ if not cells:
863
925
  continue
864
- first = s.strip("|").split("|")[0].strip()
926
+ first = cells[0]
865
927
  if first and first.lower() != "workstream":
866
928
  ids.append(first)
867
929
  return ids
868
930
 
869
931
 
932
+ def is_generated_registry(text):
933
+ # "Ours" = written by ANY family entry point: the header is WRITTEN with
934
+ # entry_script(), so recognition must not hard-code one distribution's
935
+ # script name (a registry generated by mkt_check.py is just as generated).
936
+ return bool(re.search(r"GENERATED by \S+ index - do not edit by hand", text))
937
+
938
+
939
+ def generated_registry_rows(text):
940
+ """Workstream rows of a generated registry, ignoring the project notes
941
+ (free text that may carry tables of its own)."""
942
+ return parse_registry_rows(text.split("\n## Project-wide notes", 1)[0])
943
+
944
+
945
+ def build_empty_registry(root, existing):
946
+ """The generated registry once the last source is gone (F1, found by F-068).
947
+
948
+ Only for a registry that is already ours: a hand-written handoff with no
949
+ sources is a project that never converted, and stays untouched. The Date is
950
+ kept while the view is already empty -- re-running index on a clean tree
951
+ must not change it -- and becomes today's when rows are being dropped:
952
+ there is no source left whose `updated:` could date the closure."""
953
+ m = re.search(r"Date:\s*(\d{4}-\d{2}-\d{2})", existing)
954
+ if m and not generated_registry_rows(existing):
955
+ stamp = m.group(1)
956
+ else:
957
+ stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d")
958
+ return _render_registry(root, [], stamp)
959
+
960
+
870
961
  def registry_conversion_blockers(root):
871
962
  """What stops `index` from writing over a hand-written handoff.md.
872
963
 
@@ -879,10 +970,7 @@ def registry_conversion_blockers(root):
879
970
  if not hand.is_file():
880
971
  return []
881
972
  text = read_text(hand)
882
- # "Already ours" = written by ANY family entry point: the header is WRITTEN
883
- # with entry_script(), so recognition must not hard-code one distribution's
884
- # script name (a registry generated by mkt_check.py is just as generated).
885
- if re.search(r"GENERATED by \S+ index - do not edit by hand", text):
973
+ if is_generated_registry(text):
886
974
  return []
887
975
  blockers = []
888
976
  known = {str(m.get("workstream")).strip() for _p, m in list_workstreams(root)}
@@ -1015,10 +1103,21 @@ def cmd_index(root):
1015
1103
  def rc_registry(root):
1016
1104
  """Write the generated workstream registry, or refuse and say why (F-028)."""
1017
1105
  ws = list_workstreams(root)
1106
+ hand = ai_path(root, "audit", "handoff.md")
1018
1107
  if not ws:
1019
- return 0 # no sources: a hand-written handoff is untouched
1108
+ # No sources: a hand-written handoff is untouched; a generated one is
1109
+ # emptied, or the last closed workstream would stay listed forever.
1110
+ if not hand.is_file():
1111
+ return 0
1112
+ existing = read_text(hand)
1113
+ if not is_generated_registry(existing):
1114
+ return 0
1115
+ empty = build_empty_registry(root, existing)
1116
+ if norm_text(existing) != norm_text(empty):
1117
+ hand.write_text(empty, encoding="utf-8")
1118
+ print(f"[ok] workstream registry emptied (no open workstreams): {hand}")
1119
+ return 0
1020
1120
  blockers = registry_conversion_blockers(root)
1021
- hand = ai_path(root, "audit", "handoff.md")
1022
1121
  if blockers:
1023
1122
  print(f"[ERROR] {docs_dir()}/audit/handoff.md NOT regenerated -- it still holds "
1024
1123
  "state no source accounts for:")
@@ -1060,6 +1159,317 @@ def section_body(text, aliases):
1060
1159
  return None
1061
1160
 
1062
1161
 
1162
+ def section_lines(text, aliases):
1163
+ """(heading_line, body) for the first matching `## ` section, or None: the
1164
+ body NOT stripped, so body line n is file line heading_line + n, and an
1165
+ indented first line keeps its indentation. The readers below use it; a
1166
+ stripped body would shift every line number and move ` - DATE` to column 0."""
1167
+ lines = (text or "").splitlines()
1168
+ for i, line in enumerate(lines):
1169
+ if any(line.startswith(a) for a in aliases):
1170
+ body = []
1171
+ for nxt in lines[i + 1:]:
1172
+ if nxt.startswith("## "):
1173
+ break
1174
+ body.append(nxt)
1175
+ return i + 1, "\n".join(body)
1176
+ return None
1177
+
1178
+
1179
+ # ---------------------------------------------------------------- chronology
1180
+ # One grammar for a dated entry (F-073), read by the validator and by any
1181
+ # consumer that lists events (F-072's chronology): both call these readers, so
1182
+ # they cannot disagree on what an event is. The readers return openers only --
1183
+ # date, label, line -- and format nothing for display.
1184
+
1185
+ DIARY_SECTION = next(s for s in ANALYSIS_SECTIONS if s[0] == "## Diary")
1186
+ _ISO = r"\d{4}-\d{2}-\d{2}"
1187
+ # At column 0: optional list marker, optional `###`/`####`, optional bold, then
1188
+ # the ISO date and a word boundary. The date FIRST is the rule; markup is
1189
+ # tolerated so the dated forms already written stay readable with no migration.
1190
+ ENTRY_OPENER = re.compile(r"^(?:([-*]) |(#{3,4}) )?(\*\*)?(" + _ISO + r")\b(.*)$")
1191
+ _LABEL_SEP = re.compile(r"^(?:\*\*)?\s*(?:[—–:-])?\s*")
1192
+ _REQUEST_HEADING = re.compile(r"^### (R\d+) [—–-] (" + _ISO + r") [—–-] (.+)$")
1193
+ _ADR_BODY_DATE = re.compile(r"^\**Date:\**\s*(" + _ISO + r")\b", re.M)
1194
+ _ADR_NAME_DATE = re.compile(r"^ADR_(" + _ISO + r")_", re.I)
1195
+ UNIT_ID_RE = re.compile(r"\b([A-Z]-\d{3,})\b")
1196
+
1197
+
1198
+ def _entry_label(bold, rest):
1199
+ rest = _LABEL_SEP.sub("", rest, count=1)
1200
+ if bold and "**" in rest:
1201
+ rest = rest.split("**", 1)[0]
1202
+ else:
1203
+ rest = re.split(r"(?<=[.!?])\s", rest, maxsplit=1)[0]
1204
+ return rest.strip(" .:—–-*")[:120]
1205
+
1206
+
1207
+ def _opener_at(lines, i):
1208
+ """The match when lines[i] opens an entry. It must also START A BLOCK -- the
1209
+ line above is blank, absent or a heading, or the line is itself a list item
1210
+ or a heading -- so a hard-wrapped prose line that happens to begin with a
1211
+ date is never an event. (A separator after the date is NOT required: real
1212
+ openers read `DATE (qualifier)` and `DATE word`.)"""
1213
+ m = ENTRY_OPENER.match(lines[i])
1214
+ if not m:
1215
+ return None
1216
+ prev = lines[i - 1] if i else ""
1217
+ if not prev.strip() or prev.startswith("#") or m.group(1) or m.group(2):
1218
+ return m
1219
+ return None
1220
+
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
+
1236
+ def entry_openers(body):
1237
+ """[(date, label, line)] -- one per dated entry opener; line is 1-based
1238
+ within `body`. An undated paragraph belongs to the entry above it. Comments
1239
+ are blanked first, exactly as `undated_diary_lines` does, so an entry
1240
+ written under the template's comment is an event to both."""
1241
+ lines = _blank_comments(body).splitlines()
1242
+ out = []
1243
+ for i in range(len(lines)):
1244
+ m = _opener_at(lines, i)
1245
+ if m:
1246
+ out.append((m.group(4), _entry_label(m.group(3), m.group(5)), i + 1))
1247
+ return out
1248
+
1249
+
1250
+ def _blank_comments(text):
1251
+ # comments become blank lines, so line numbers still point at the file's lines
1252
+ return re.sub(r"<!--.*?-->", lambda m: "\n" * m.group(0).count("\n"), text or "",
1253
+ flags=re.S)
1254
+
1255
+
1256
+ def undated_diary_lines(body):
1257
+ """[(line, text)] a Diary body cannot date: text before the first opener,
1258
+ and any `### ` heading that is not an opener. `####` and deeper headings are
1259
+ sub-structure of an entry and stay legal. Comments are ignored."""
1260
+ lines = _blank_comments(body).splitlines()
1261
+ out, seen_opener = [], False
1262
+ for i, line in enumerate(lines):
1263
+ if _opener_at(lines, i):
1264
+ seen_opener = True
1265
+ elif line.startswith("### "):
1266
+ out.append((i + 1, line.strip()))
1267
+ elif not seen_opener and line.strip():
1268
+ out.append((i + 1, line.strip()))
1269
+ return out
1270
+
1271
+
1272
+ def request_entries(text):
1273
+ """(entries, unparsed) for a request record: entries are
1274
+ [(rid, date, label, line)]; a `### ` heading that does not match
1275
+ `### R<n> — YYYY-MM-DD — <label>` is returned in unparsed as (line, text),
1276
+ never dropped."""
1277
+ entries, unparsed = [], []
1278
+ for i, line in enumerate((text or "").splitlines()):
1279
+ if not line.startswith("### "):
1280
+ continue
1281
+ m = _REQUEST_HEADING.match(line.rstrip())
1282
+ if m:
1283
+ entries.append((m.group(1), m.group(2), m.group(3).strip(), i + 1))
1284
+ else:
1285
+ unparsed.append((i + 1, line.rstrip()))
1286
+ return entries, unparsed
1287
+
1288
+
1289
+ def adr_date(name, text):
1290
+ """An ADR's date: `**Date:** YYYY-MM-DD` in the body, else the
1291
+ `ADR_YYYY-MM-DD_*` file name, else None."""
1292
+ m = _ADR_BODY_DATE.search(text or "") or _ADR_NAME_DATE.match(name or "")
1293
+ return m.group(1) if m else None
1294
+
1295
+
1296
+ def _meta_date(meta, key):
1297
+ return parse_iso(str(meta.get(key) or "").strip().strip("'\"")[:10])
1298
+
1299
+
1300
+ def chronology_advisories(root):
1301
+ """A1 (undated Diary text) and A2 (`next:` is not one step), as advisory
1302
+ strings. Gated on `level: L3` and on a date the file carries -- never an
1303
+ mtime -- so L1/L2 work and anything written before the epoch see nothing."""
1304
+ epoch = parse_iso(CHRONOLOGY_EPOCH)
1305
+ out = []
1306
+ for p, meta, text in list_analyses(root):
1307
+ started = _meta_date(meta, "start_date")
1308
+ if str(meta.get("level", "")).upper() != "L3" or not started or started < epoch:
1309
+ continue
1310
+ section = section_lines(text, DIARY_SECTION)
1311
+ if section is None:
1312
+ continue
1313
+ head, body = section
1314
+ rel = "solutions/" + p.name
1315
+ lines = undated_diary_lines(body)
1316
+ preface = [(n, t) for n, t in lines if not t.startswith("### ")]
1317
+ if preface:
1318
+ out.append(f"{rel}: line {head + preface[0][0]}: Diary text '{preface[0][1][:60]}' "
1319
+ "comes before the first dated entry -- open entries with "
1320
+ "**YYYY-MM-DD — label.**, and write the current state as the latest "
1321
+ "dated entry")
1322
+ for n, t in lines:
1323
+ if t.startswith("### "):
1324
+ out.append(f"{rel}: line {head + n}: Diary heading '{t[:60]}' is not a dated "
1325
+ "entry -- open entries with **YYYY-MM-DD — label.**")
1326
+ for p, meta in list_workstreams(root):
1327
+ updated = _meta_date(meta, "updated")
1328
+ if str(meta.get("level", "")).upper() != "L3" or not updated or updated < epoch:
1329
+ continue
1330
+ nxt = str(meta.get("next") or "").strip()
1331
+ if len(nxt) > NEXT_MAX_CHARS:
1332
+ out.append(f"audit/{p.name}: next: is {len(nxt)} chars (> {NEXT_MAX_CHARS}): one "
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.**")
1339
+ return out
1340
+
1341
+
1342
+ def _open_units(root):
1343
+ """{id: {analysis name}} for the tree's open L3 units: an L3 ANALYSIS that is
1344
+ PLANNED or IN_PROGRESS, or a request record (L3 only, r26) whose `analysis:`
1345
+ is absent from the tree or names such an open ANALYSIS. A closed unit's
1346
+ record, kept forever, does not count."""
1347
+ units, present, open_names = {}, set(), set()
1348
+ for p, meta, _ in list_analyses(root):
1349
+ present.add(p.name)
1350
+ if (str(meta.get("level", "")).upper() == "L3"
1351
+ and meta.get("status") in ("PLANNED", "IN_PROGRESS")):
1352
+ m = UNIT_ID_RE.search(str(meta.get("id") or ""))
1353
+ if m:
1354
+ open_names.add(p.name)
1355
+ units.setdefault(m.group(1), set()).add(p.name)
1356
+ req = ai_path(root, "audit", "requests")
1357
+ if req.is_dir():
1358
+ for p in sorted(req.glob("REQUEST_*.md")):
1359
+ meta = load_frontmatter(read_text(p).splitlines())
1360
+ name = str(meta.get("analysis") or "").strip().strip("'\"`")
1361
+ m = UNIT_ID_RE.search(str(meta.get("unit") or meta.get("id") or ""))
1362
+ if m and name and (name not in present or name in open_names):
1363
+ units.setdefault(m.group(1), set()).add(name)
1364
+ return units
1365
+
1366
+
1367
+ def _git_out(root, *args, timeout=30):
1368
+ r = subprocess.run(["git", *args], cwd=str(root), capture_output=True, text=True,
1369
+ encoding="utf-8", errors="replace", timeout=timeout)
1370
+ return r.stdout if r.returncode == 0 else None
1371
+
1372
+
1373
+ def ids_on_other_refs(root, max_refs=ID_SCAN_MAX_REFS):
1374
+ """[(id, mine, theirs, [refs])] -- an open unit's id that another ref (heads,
1375
+ remotes, tags) uses for a DIFFERENT analysis. Identity is the ANALYSIS file
1376
+ name, declared by the ANALYSIS or announced by a request record's
1377
+ `analysis:`. One `git grep` over local refs, fixed arguments only (T8);
1378
+ as fresh as the last fetch. [] with no open L3 unit or no git; None when
1379
+ skipped -- above `max_refs` refs, or git failed or timed out -- so the
1380
+ caller can say the check did not run instead of reporting it clean."""
1381
+ mine = _open_units(root)
1382
+ if not mine or not git_available(root):
1383
+ return []
1384
+ try:
1385
+ head = (_git_out(root, "rev-parse", "HEAD") or "").strip()
1386
+ listing = _git_out(root, "for-each-ref",
1387
+ "--format=%(objectname) %(objecttype) %(*objecttype) %(refname)",
1388
+ "refs/heads", "refs/remotes", "refs/tags")
1389
+ if listing is None:
1390
+ return None
1391
+ refs = []
1392
+ for line in listing.split("\n"):
1393
+ parts = line.split(" ")
1394
+ if len(parts) == 3: # %(*objecttype) empty: not a tag
1395
+ parts.insert(2, "")
1396
+ if len(parts) != 4:
1397
+ continue
1398
+ sha, kind, peeled, ref = parts
1399
+ # only refs that resolve to a commit can be grepped (a tag on a blob
1400
+ # would fail the whole batch); the tree's own commit is not "another
1401
+ # ref"; symbolic HEADs repeat a branch
1402
+ if "commit" not in (kind, peeled) or ref.endswith("/HEAD") or sha == head:
1403
+ continue
1404
+ refs.append(ref)
1405
+ if not refs:
1406
+ return []
1407
+ if len(refs) > max_refs:
1408
+ return None
1409
+ # Pathspecs are relative to cwd=root, so a docs root inside a monorepo
1410
+ # subdirectory is found. -z keeps a path with ':' or non-ASCII intact
1411
+ # (quotePath off); -n plus the fence lines confine hits to frontmatter.
1412
+ docs = docs_dir()
1413
+ chunks = []
1414
+ for i in range(0, len(refs), ID_SCAN_REFS_PER_CALL): # command-line length
1415
+ r = subprocess.run(
1416
+ ["git", "-c", "core.quotePath=false", "grep", "-z", "-n", "-I", "-E",
1417
+ r"^(id|unit|analysis):|^---\s*$", *refs[i:i + ID_SCAN_REFS_PER_CALL],
1418
+ "--", f"{docs}/solutions/ANALYSIS_*.md", f"{docs}/audit/requests/REQUEST_*.md"],
1419
+ cwd=str(root), capture_output=True, text=True, encoding="utf-8",
1420
+ errors="replace", timeout=60)
1421
+ if r.returncode not in (0, 1): # 1 = no match
1422
+ return None
1423
+ chunks.append(r.stdout)
1424
+ except Exception:
1425
+ return None
1426
+ per = {}
1427
+ for line in "".join(chunks).split("\n"): # not splitlines: \x1c.. are content
1428
+ parts = line.split("\0")
1429
+ if len(parts) != 3 or ":" not in parts[0] or not parts[1].isdigit():
1430
+ continue
1431
+ ref, path = parts[0].split(":", 1) # a ref name cannot hold ':'
1432
+ content = parts[2].rstrip("\r")
1433
+ st = per.setdefault((ref, path), {"fences": 0, "kv": {}})
1434
+ if int(parts[1]) == 1 and content.strip() == "---":
1435
+ st["fences"] = 1 # frontmatter opens on line 1
1436
+ elif st["fences"] == 1 and content.strip() == "---":
1437
+ st["fences"] = 2 # ...and closes here
1438
+ elif st["fences"] == 1:
1439
+ key, _, val = content.partition(":")
1440
+ st["kv"].setdefault(key, val.strip())
1441
+ found = {}
1442
+ for (ref, path), st in per.items():
1443
+ kv = st["kv"]
1444
+ fname = path.replace("\\", "/").rsplit("/", 1)[-1]
1445
+ name = fname if fname.startswith("ANALYSIS_") else kv.get("analysis", "").strip("'\"`")
1446
+ m = UNIT_ID_RE.search(kv.get("id") or kv.get("unit") or "")
1447
+ if not (m and name) or m.group(1) not in mine or name in mine[m.group(1)]:
1448
+ continue
1449
+ short = re.sub(r"^refs/(heads|remotes|tags)/", "", ref)
1450
+ found.setdefault((m.group(1), name), [])
1451
+ if short not in found[(m.group(1), name)]:
1452
+ found[(m.group(1), name)].append(short)
1453
+ return sorted((fid, ", ".join(sorted(mine[fid])), theirs, sorted(refs_))
1454
+ for (fid, theirs), refs_ in found.items())
1455
+
1456
+
1457
+ def id_advisories(root):
1458
+ """A3 as advisory strings, for every entry point's `check`."""
1459
+ hits = ids_on_other_refs(root)
1460
+ if hits is None:
1461
+ return [f"id check skipped (more than {ID_SCAN_MAX_REFS} refs, or git grep failed "
1462
+ "or timed out) -- choose a new unit's id after fetching, as the first id "
1463
+ "free on every ref"]
1464
+ out = []
1465
+ for fid, mine, theirs, refs in hits:
1466
+ more = f" (+{len(refs) - 1} more)" if len(refs) > 1 else ""
1467
+ out.append(f"id {fid} ({mine}) is also used by {theirs} on {refs[0]}{more}: choose "
1468
+ "the first id free on every ref -- if this is the same unit under its "
1469
+ "old name, ignore it")
1470
+ return out
1471
+
1472
+
1063
1473
  def design_review_due(meta):
1064
1474
  """True when an L3 ANALYSIS owes a design-review row (review.md moment 1):
1065
1475
  implementation has started or finished, and it began on/after the gate
@@ -1113,10 +1523,30 @@ def has_ledger_heading(text):
1113
1523
  opens a comment at the start of a line -- nuking to EOF on an inline
1114
1524
  mention (or an unclosed example inside a fence) made a document that
1115
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)."""
1116
1531
  stripped = re.sub(r"^(```|~~~).*?^\1", "", text, flags=re.M | re.S)
1117
1532
  stripped = re.sub(r"<!--.*?-->", "", stripped, flags=re.S)
1118
- stripped = re.sub(r"^[ \t]*<!--(?!.*?-->).*\Z", "", stripped, flags=re.M | re.S)
1119
- return bool(re.search(r"^##[ \t]+Capability Ledger[ \t]*$", stripped, re.M))
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))
1120
1550
 
1121
1551
 
1122
1552
  def ledger_due(meta):
@@ -1354,10 +1784,10 @@ def cmd_validate(root, strict=False, hybrid=False):
1354
1784
  "doing the work it triggers")
1355
1785
  # comment-stripped, anchored: a '<!-- TODO: the ## Capability Ledger -->'
1356
1786
  # must not read as the section being present
1357
- # Hybrid: the design lives in devPNT and its §4.5 gate owns this slot
1358
- # (the Hybrid ownership matrix: "run ONE of them, never both"), and its log
1359
- # rows are keyed on e_isp_/e_tdd_ doc_keys, not on this filename -- so
1360
- # firing here would be a permanent, unfixable false positive.
1787
+ # Hybrid: the design lives in devPNT, and the agent review of it (the
1788
+ # same review.md review, run before the proposal) logs a row keyed on
1789
+ # its e_isp_/e_tdd_ doc_key, not on this filename -- so firing here
1790
+ # would be a permanent, unfixable false positive.
1361
1791
  if not hybrid and design_review_due(meta) and not review_logged(root, p.name):
1362
1792
  advisories.append(f"{rel}: L3 in implementation with no design-review row in "
1363
1793
  f"{review_log_rel()} -- the design was reviewed by nobody but its "
@@ -1365,6 +1795,10 @@ def cmd_validate(root, strict=False, hybrid=False):
1365
1795
  if ledger_due(meta) and not has_ledger_heading(text):
1366
1796
  advisories.append(f"{rel}: L3 without '## Capability Ledger' -- the architect pass "
1367
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)?")
1368
1802
 
1369
1803
  # Generated index aligned
1370
1804
  hist = ai / "strategic" / "features_history.md"
@@ -1496,6 +1930,12 @@ def cmd_validate(root, strict=False, hybrid=False):
1496
1930
  "workstream — decide which file owns it")
1497
1931
  else:
1498
1932
  seen[wid] = p.name
1933
+ elif hand.is_file():
1934
+ text = read_text(hand)
1935
+ if is_generated_registry(text) and generated_registry_rows(text):
1936
+ errors.append(f"{docs_dir()}/audit/handoff.md lists workstreams no HANDOFF_*.md "
1937
+ "source exists for any more: run 'sdlc_check.py index' to drop "
1938
+ "the closed rows")
1499
1939
  if hand.is_file():
1500
1940
  m = re.search(r"(?:Date|Data):\s*(\d{4}-\d{2}-\d{2})", read_text(hand))
1501
1941
  if not m:
@@ -1509,6 +1949,7 @@ def cmd_validate(root, strict=False, hybrid=False):
1509
1949
  except ValueError:
1510
1950
  warnings.append("audit/handoff.md: date not parseable")
1511
1951
 
1952
+ advisories.extend(chronology_advisories(root)) # A1, A2 (F-073)
1512
1953
  for a in advisories:
1513
1954
  print(f"[note] {a}")
1514
1955
  for w in warnings:
@@ -1653,7 +2094,8 @@ def cmd_stale(root, hybrid=False):
1653
2094
  except ValueError: # symlink out of the tree: report absolute, never crash
1654
2095
  name = str(fp)
1655
2096
  changed.append(name)
1656
- changed = [c for c in changed if c != plan_rel]
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)]
1657
2099
  if changed:
1658
2100
  stale.append((rel, changed))
1659
2101
 
@@ -1674,9 +2116,54 @@ def cmd_stale(root, hybrid=False):
1674
2116
  return 1 # stale areas dominate: rc already implied
1675
2117
 
1676
2118
 
1677
- def cmd_mark(root, paths):
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):
1678
2162
  if not require_ai_docs(root, "mark"):
1679
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
1680
2167
  f, lines, rows = parse_audit_plan(root)
1681
2168
  use_git_ref = git_available(root) and not any(
1682
2169
  git_has_changes(root, raw.replace("\\", "/").rstrip("/")) for raw in paths
@@ -1703,6 +2190,10 @@ def cmd_mark(root, paths):
1703
2190
  "refusing to mark (use a project-relative path, '.' for the root). "
1704
2191
  "Nothing was written.")
1705
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
1706
2197
  keys.append(key)
1707
2198
 
1708
2199
  appended = []
@@ -1722,6 +2213,13 @@ def cmd_mark(root, paths):
1722
2213
 
1723
2214
  f.parent.mkdir(parents=True, exist_ok=True)
1724
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.")
1725
2223
  if use_git_ref:
1726
2224
  # F-056: the reference is born here, so this is where the gestures that
1727
2225
  # orphan it are named. An amend was the observed one.
@@ -1801,15 +2299,56 @@ def _orient_user_settings_path():
1801
2299
 
1802
2300
 
1803
2301
  def _orient_hook_commands(root):
1804
- """Yield (command, at_user_level) across every known client config: the
1805
- 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 --
1806
2304
  the file the installer wires (F-042), without which a fixed machine's
1807
- 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)."""
1808
2307
  for rel in ORIENT_HOOK_CONFIG_FILES:
1809
2308
  for cmd in _orient_entries_in(Path(root) / rel):
1810
- yield cmd, False
2309
+ yield cmd, False, rel
1811
2310
  for cmd in _orient_entries_in(_orient_user_settings_path()):
1812
- 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
1813
2352
 
1814
2353
 
1815
2354
  def orient_hook_state(root):
@@ -1824,22 +2363,9 @@ def orient_hook_state(root):
1824
2363
  the project root (the vendored-validator convention); a USER-level relative
1825
2364
  token has no cwd to resolve against and is treated as cannot-tell."""
1826
2365
  matched = False
1827
- for cmd, at_user_level in _orient_hook_commands(root):
2366
+ for cmd, at_user_level, _place in _orient_hook_commands(root):
1828
2367
  matched = True
1829
- token = None
1830
- for m in _ORIENT_HOOK_TOKEN_RE.finditer(cmd):
1831
- tok = m.group(1) if m.group(1) is not None else m.group(2)
1832
- if any(tok.endswith(n) for n in ORIENT_HOOK_ENTRY_POINTS):
1833
- token = tok
1834
- break
1835
- if token is None:
1836
- return "wired"
1837
- tp = Path(token)
1838
- if not tp.is_absolute():
1839
- if at_user_level:
1840
- return "wired" # cannot tell: no cwd exists at user level
1841
- tp = Path(root) / tp
1842
- if tp.exists():
2368
+ if _orient_entry_live(root, cmd, at_user_level):
1843
2369
  return "wired"
1844
2370
  return "dead" if matched else "unwired"
1845
2371
 
@@ -1869,15 +2395,30 @@ def print_orient_hook_note(root):
1869
2395
  pass # detection must never break check
1870
2396
 
1871
2397
 
1872
- def cmd_check(root, strict=False, hybrid=False):
2398
+ def cmd_check(root, strict=False, hybrid=False, extra_validate=None, extra_sections=()):
2399
+ """An overlay adds its checks through the two hooks, never after the call:
2400
+ the summary line is the verdict, so every check that can fail runs before
2401
+ it. `extra_validate()` folds into the validate stage; each
2402
+ (title, fn) in `extra_sections` gets its own banner and summary rc."""
1873
2403
  print("===== validate =====")
1874
2404
  rc_v = cmd_validate(root, strict=strict, hybrid=hybrid)
2405
+ if extra_validate is not None:
2406
+ rc_v = max(rc_v, extra_validate())
2407
+ # A3 (F-073): check only -- it reads git refs, and validate stays file-local.
2408
+ # Advisory: printed, never part of any rc.
2409
+ for a in id_advisories(root):
2410
+ print(f"[note] {a}")
1875
2411
  print("\n===== stale =====")
1876
2412
  rc_s = cmd_stale(root, hybrid=hybrid)
1877
- print(f"\ncheck: {'CLEAN' if not (rc_v or rc_s) else 'NOT CLEAN'} "
1878
- f"(validate rc={rc_v}, stale rc={rc_s})")
2413
+ rcs = [("validate", rc_v), ("stale", rc_s)]
2414
+ for title, fn in extra_sections:
2415
+ print(f"\n===== {title} =====")
2416
+ rcs.append((title, fn()))
2417
+ failed = any(rc for _, rc in rcs)
2418
+ print(f"\ncheck: {'NOT CLEAN' if failed else 'CLEAN'} "
2419
+ f"({', '.join(f'{title} rc={rc}' for title, rc in rcs)})")
1879
2420
  print_orient_hook_note(root)
1880
- return 1 if (rc_v or rc_s) else 0
2421
+ return 1 if failed else 0
1881
2422
 
1882
2423
 
1883
2424
  # --------------------------------------------------------------------- gate
@@ -1906,6 +2447,12 @@ REMIND_FALLBACK = (
1906
2447
  )
1907
2448
 
1908
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
+
1909
2456
  def cmd_remind(args):
1910
2457
  """UserPromptSubmit hook: print the lens's constant per-turn line. Always 0.
1911
2458
 
@@ -1914,21 +2461,31 @@ def cmd_remind(args):
1914
2461
  try:
1915
2462
  prof = profile()
1916
2463
  line = prof.get("remind_line") or REMIND_FALLBACK.format(skill=prof["skill_name"])
1917
- print(f"{prof['skill_name']}: {line}")
2464
+ print(f"{prof['skill_name']}: {line}{REMIND_LENS_SUFFIX}")
1918
2465
  except Exception:
1919
2466
  pass
1920
2467
  return 0
1921
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
+
1922
2482
  def cmd_gate(args):
1923
2483
  file_path = args.file or ""
1924
2484
  if args.hook:
2485
+ payload = read_hook_payload()
1925
2486
  try:
1926
- # bytes -> utf-8-sig: the hook payload is UTF-8 JSON regardless of the
1927
- # console code page; '-sig' strips the BOM (PowerShell pipes)
1928
- raw = sys.stdin.buffer.read().decode("utf-8-sig", errors="replace")
1929
- payload = json.loads(raw)
1930
2487
  file_path = (payload.get("tool_input") or {}).get("file_path") or ""
1931
- except Exception:
2488
+ except AttributeError:
1932
2489
  return 0 # unparseable input: do not block
1933
2490
  if not file_path:
1934
2491
  return 0
@@ -1963,6 +2520,134 @@ def cmd_gate(args):
1963
2520
  return 2
1964
2521
 
1965
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
+
1966
2651
  # --------------------------------------------------------------------- plan
1967
2652
  # Subagent Execution (Feature A). Zero-execution surface: this section and
1968
2653
  # everything it calls MUST NOT spawn a process (no subprocess/os.system/eval/
@@ -2162,15 +2847,23 @@ REVIEW_POSITIONAL = {
2162
2847
  }
2163
2848
 
2164
2849
 
2850
+ _UNESCAPED_PIPE_RE = re.compile(r"(?<!\\)\|")
2851
+
2852
+
2165
2853
  def _table_cells(line):
2166
2854
  """Cells of a Markdown table row, or None when the line is not one.
2167
- 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)."""
2168
2858
  s = line.strip()
2169
2859
  if not s.startswith("|"):
2170
2860
  return None
2171
2861
  if set(s) <= set("|-: "):
2172
2862
  return None
2173
- return [c.strip() for c in s.strip("|").split("|")]
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)]
2174
2867
 
2175
2868
 
2176
2869
  def parse_review_log(text):
@@ -2235,6 +2928,13 @@ def _is_pass(verdict):
2235
2928
  return re.search(r"\bPASS\b", (verdict or "").upper()) is not None
2236
2929
 
2237
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
+
2238
2938
  def _print_read_cost():
2239
2939
  """The cost side, printed with the benefit and NEVER without it: a catch
2240
2940
  rate with no cost term justifies unbounded doctrine, and a cost term with
@@ -2275,6 +2975,8 @@ def cmd_benefit(root):
2275
2975
  _print_read_cost()
2276
2976
  return 0
2277
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"])]
2278
2980
  # `tier` carries the MOMENT in Standalone (design / closure) and the
2279
2981
  # reviewer WEIGHT in Hybrid (deep / light / code / guide / vision). A
2280
2982
  # Hybrid row is a real review whose moment this column does not state, so
@@ -2299,9 +3001,11 @@ def cmd_benefit(root):
2299
3001
  unstated_code += n
2300
3002
  classified = moments["design"] + moments["closure"]
2301
3003
  print("rows parsed: %d rows unparsed: %d%s"
2302
- % (len(rows), len(unparsed),
3004
+ % (len(rows) + len(not_due), len(unparsed),
2303
3005
  (" -> " + "; ".join("line %d: %s" % u for u in unparsed[:5]))
2304
3006
  if unparsed else ""))
3007
+ print("reviews ruled not due (logged, counted in no figure below): %d"
3008
+ % len(not_due))
2305
3009
  print("reviews with a stated moment: %d moment not stated by `tier`: %d%s"
2306
3010
  % (len(classified), sum(unstated.values()),
2307
3011
  (" (" + ", ".join("%s x%d" % (t, n) for t, n
@@ -2367,48 +3071,205 @@ def cmd_benefit(root):
2367
3071
  return 0
2368
3072
 
2369
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
+
2370
3201
  def cmd_orient(args):
2371
- """SessionStart hook: emit a bounded, repo-sourced ai_docs/ orientation to
2372
- stdout and ALWAYS return 0 (fail-open, P-TM T8) -- a session hook must never
2373
- block the session or surface a traceback. Zero-execution (P-TM T1): reads a
2374
- fixed hard-coded doc set, confine_under each (P-TM T3), size-caps the total
2375
- (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."""
2376
3212
  try:
2377
3213
  root = Path(args.root).resolve() if getattr(args, "root", None) else find_project_root()
2378
- chunks = []
2379
- total = 0
2380
- truncated = False
2381
- for label, rel in orient_docs():
3214
+ rels = [rel for _, rel in orient_docs()]
3215
+ docs = {}
3216
+ for rel in rels:
2382
3217
  target = confine_under(root, rel)
2383
3218
  if target is None or not target.is_file():
2384
3219
  continue
2385
3220
  try:
2386
- text = read_text(target)
3221
+ docs[rel] = read_text(target)
2387
3222
  except OSError:
2388
3223
  continue
2389
- remaining = ORIENT_MAX_TOTAL_CHARS - total
2390
- if remaining <= 0:
2391
- truncated = True
2392
- break
2393
- text = text[:ORIENT_PER_DOC_CHARS]
2394
- if len(text) > remaining:
2395
- text = text[:remaining]
2396
- truncated = True
2397
- chunks.append((label, text))
2398
- total += len(text)
2399
- if not chunks:
3224
+ if not docs:
2400
3225
  return 0
2401
- out = ["=== Agentic SDLC -- session orientation (repo-sourced context, not authored instructions) ==="]
2402
- for label, text in chunks:
2403
- out.append(f"\n## {label}\n{text}")
2404
- if truncated:
2405
- out.append("\n[orientation truncated to the size cap -- open the files directly for full content]")
2406
- out.append("\nTriage every request (Rule Zero): L1 trivial - L2 small - L3 significant - Spike. "
2407
- "When in doubt, pick the higher level.")
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.")
2408
3248
  if getattr(args, "hybrid", False):
2409
- out.append("\n[devPNT active] Run devpnt_mcp_get_bootstrap for the Master Plan / Knowledge Layer -- "
2410
- "the orientation above is the filesystem layer, not a bootstrap duplicate.")
2411
- print("\n".join(out))
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))
2412
3273
  return 0
2413
3274
  except Exception:
2414
3275
  return 0
@@ -2625,11 +3486,18 @@ def main(argv=None):
2625
3486
 
2626
3487
  mp = sub.add_parser("mark", parents=[common], help="record paths as ANALYZED")
2627
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)")
2628
3491
  gp = sub.add_parser("gate", parents=[common, hybrid_opt], help="PreToolUse hook (exit 2 = block)")
2629
3492
  gp.add_argument("--hook", action="store_true", help="read the hook JSON payload from stdin")
2630
3493
  gp.add_argument("--file", help="file path to evaluate (alternative to --hook)")
2631
3494
  gp.add_argument("--protected", default="", help="protected prefixes separated by ';' (e.g. \"src/auth;src/crypto\")")
2632
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
+
2633
3501
  sub.add_parser("benefit", parents=[common],
2634
3502
  help="report what the review gates caught (and what the "
2635
3503
  "doctrine costs to read) -- a report, never a gate: "
@@ -2671,6 +3539,10 @@ def main(argv=None):
2671
3539
  # and says so rather than exiting non-zero.
2672
3540
  print(f"[note] {exc}")
2673
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
2674
3546
  else:
2675
3547
  print(f"[ERROR] {exc}")
2676
3548
  return 1
@@ -2678,6 +3550,8 @@ def main(argv=None):
2678
3550
 
2679
3551
  if args.cmd == "gate":
2680
3552
  return cmd_gate(args)
3553
+ if args.cmd == "verdict":
3554
+ return cmd_verdict(args)
2681
3555
  if args.cmd == "orient":
2682
3556
  return cmd_orient(args)
2683
3557
  if args.cmd == "remind":
@@ -2693,7 +3567,7 @@ def main(argv=None):
2693
3567
  if args.cmd == "stale":
2694
3568
  return cmd_stale(root, hybrid=args.hybrid)
2695
3569
  if args.cmd == "mark":
2696
- return cmd_mark(root, args.paths)
3570
+ return cmd_mark(root, args.paths, args.note)
2697
3571
  if args.cmd == "benefit":
2698
3572
  return cmd_benefit(root)
2699
3573
  if args.cmd == "migrate":