@antoneeo/mkt-agentic-sdlc-skill 0.23.0 → 0.27.0

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