@antoneeo/mkt-agentic-sdlc-skill 0.23.0 → 0.26.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
@@ -396,8 +402,17 @@ def orient_docs():
396
402
  ("Guide router (when-to-consult)", f"{d}/reference/INDEX.md"),
397
403
  ("Last session handoff", f"{d}/audit/handoff.md"),
398
404
  ]
399
- ORIENT_PER_DOC_CHARS = 6000 # per-doc truncation
400
- 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.")
401
416
 
402
417
 
403
418
  # ----------------------------------------------------------------- utilities
@@ -524,6 +539,41 @@ def has_etdd_shadow(root):
524
539
  return any("tdd" in p.name.lower() for p in sol.glob("SHADOW_*.md"))
525
540
 
526
541
 
542
+ def newest_shadow(solutions_dir, doc_key):
543
+ """The newest devPNT shadow of `doc_key` in `solutions_dir`, or None.
544
+ Only `SHADOW_<doc_key>_v<N>[.<N>...].md` counts -- the exact key, so
545
+ `<doc_key>_extra` is another key -- and versions compare number by number
546
+ (v1.10 above v1.9, v1.0 above v1). Names outside that rule are ignored.
547
+ F-081: the Hybrid target of a claim marker; reusable by any consumer."""
548
+ if not Path(solutions_dir).is_dir():
549
+ return None
550
+ best = None
551
+ for p in Path(solutions_dir).iterdir():
552
+ v = _shadow_version(p.name, doc_key)
553
+ if v is not None and p.is_file():
554
+ if best is None or v > best[0]:
555
+ best = (v, p)
556
+ return best[1] if best else None
557
+
558
+
559
+ def _shadow_version(name, doc_key):
560
+ """The version tuple of `SHADOW_<doc_key>_v<N>[.<N>...].md`, else None."""
561
+ m = re.match(r"^SHADOW_%s_v(\d+(?:\.\d+)*)\.md$" % re.escape(doc_key), name)
562
+ return tuple(int(x) for x in m.group(1).split(".")) if m else None
563
+
564
+
565
+ def stray_shadow(solutions_dir, doc_key):
566
+ """A file of the same exact key that `newest_shadow` does not read
567
+ (`SHADOW_<doc_key>.md`, `..._v1.2a.md`), or None -- the name a user needs
568
+ to see when no shadow resolves. A longer key is never a stray."""
569
+ if not Path(solutions_dir).is_dir():
570
+ return None
571
+ rx = re.compile(r"^SHADOW_%s(?:_v[^_]*)?\.md$" % re.escape(doc_key))
572
+ return next((p.name for p in sorted(Path(solutions_dir).iterdir())
573
+ if rx.match(p.name) and _shadow_version(p.name, doc_key) is None),
574
+ None)
575
+
576
+
527
577
  def iter_files(target):
528
578
  if target.is_file():
529
579
  yield target
@@ -870,10 +920,10 @@ def parse_registry_rows(text):
870
920
  """Workstream ids in a registry table, hand-written or generated."""
871
921
  ids = []
872
922
  for line in text.splitlines():
873
- s = line.strip()
874
- if not s.startswith("|") or set(s) <= {"|", "-", " ", ":"}:
923
+ cells = _table_cells(line)
924
+ if not cells:
875
925
  continue
876
- first = s.strip("|").split("|")[0].strip()
926
+ first = cells[0]
877
927
  if first and first.lower() != "workstream":
878
928
  ids.append(first)
879
929
  return ids
@@ -1169,6 +1219,20 @@ def _opener_at(lines, i):
1169
1219
  return None
1170
1220
 
1171
1221
 
1222
+ def malformed_openers(body):
1223
+ """[(line, text)] -- dated openers whose bold does not pair: `**DATE …` with no
1224
+ closing `**` on the line, or a bare `DATE …` that closes one. Either way the
1225
+ chronology shows a label running into the entry's body (field case 2026-10-19:
1226
+ two release notes in project_notes.md)."""
1227
+ lines = _blank_comments(body).splitlines()
1228
+ out = []
1229
+ for i in range(len(lines)):
1230
+ m = _opener_at(lines, i)
1231
+ if m and bool(m.group(3)) != ("**" in m.group(5)):
1232
+ out.append((i + 1, lines[i]))
1233
+ return out
1234
+
1235
+
1172
1236
  def entry_openers(body):
1173
1237
  """[(date, label, line)] -- one per dated entry opener; line is 1-based
1174
1238
  within `body`. An undated paragraph belongs to the entry above it. Comments
@@ -1267,6 +1331,11 @@ def chronology_advisories(root):
1267
1331
  if len(nxt) > NEXT_MAX_CHARS:
1268
1332
  out.append(f"audit/{p.name}: next: is {len(nxt)} chars (> {NEXT_MAX_CHARS}): one "
1269
1333
  "next step here, the state goes to the Diary as a dated entry")
1334
+ notes = ai_path(root, "audit", PROJECT_NOTES)
1335
+ if notes.is_file():
1336
+ for n, t in malformed_openers(read_text(notes)):
1337
+ out.append(f"audit/{PROJECT_NOTES}: line {n}: dated entry '{t[:60]}' has unpaired "
1338
+ "bold -- open entries with **YYYY-MM-DD — label.**")
1270
1339
  return out
1271
1340
 
1272
1341
 
@@ -1454,10 +1523,30 @@ def has_ledger_heading(text):
1454
1523
  opens a comment at the start of a line -- nuking to EOF on an inline
1455
1524
  mention (or an unclosed example inside a fence) made a document that
1456
1525
  HAS its ledger get told it has none."""
1526
+ return bool(re.search(r"^##[ \t]+Capability Ledger[ \t]*$", strip_non_prose(text), re.M))
1527
+
1528
+
1529
+ def strip_non_prose(text):
1530
+ """Text without fenced code blocks and HTML comments (see has_ledger_heading)."""
1457
1531
  stripped = re.sub(r"^(```|~~~).*?^\1", "", text, flags=re.M | re.S)
1458
1532
  stripped = re.sub(r"<!--.*?-->", "", stripped, flags=re.S)
1459
- 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))
1533
+ return re.sub(r"^[ \t]*<!--(?!.*?-->).*\Z", "", stripped, flags=re.M | re.S)
1534
+
1535
+
1536
+ def has_guide_check(text):
1537
+ """True when a real '## Guide check' heading carries an answer: the template's
1538
+ comment alone answers nothing, so a copied-but-unfilled section still owes it."""
1539
+ m = re.search(r"^##[ \t]+Guide check[ \t]*$(.*?)(?=^#{1,2}[ \t]|\Z)",
1540
+ strip_non_prose(text), re.M | re.S)
1541
+ return bool(m and m.group(1).strip())
1542
+
1543
+
1544
+ def guide_check_due(meta, domain):
1545
+ """True when an ANALYSIS owes the Phase-3 guide question (SKILL.md): a code-lens
1546
+ L3 started on/after GUIDE_CHECK_EPOCH. Same grandfathering as ledger_due."""
1547
+ return domain == "code" and ledger_due(meta) and (
1548
+ parse_iso((meta.get("start_date") or "").strip().strip("'\""))
1549
+ >= parse_iso(GUIDE_CHECK_EPOCH))
1461
1550
 
1462
1551
 
1463
1552
  def ledger_due(meta):
@@ -1706,6 +1795,10 @@ def cmd_validate(root, strict=False, hybrid=False):
1706
1795
  if ledger_due(meta) and not has_ledger_heading(text):
1707
1796
  advisories.append(f"{rel}: L3 without '## Capability Ledger' -- the architect pass "
1708
1797
  "left no record (architect.md); run it before the Impact")
1798
+ if guide_check_due(meta, domain) and not has_guide_check(text):
1799
+ advisories.append(f"{rel}: L3 without a filled '## Guide check' -- does a CURRENT "
1800
+ "guide cover what this touches, and did understanding it force "
1801
+ "a model of a complex component no guide covers (guides.md, section 1)?")
1709
1802
 
1710
1803
  # Generated index aligned
1711
1804
  hist = ai / "strategic" / "features_history.md"
@@ -2001,7 +2094,8 @@ def cmd_stale(root, hybrid=False):
2001
2094
  except ValueError: # symlink out of the tree: report absolute, never crash
2002
2095
  name = str(fp)
2003
2096
  changed.append(name)
2004
- 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)]
2005
2099
  if changed:
2006
2100
  stale.append((rel, changed))
2007
2101
 
@@ -2022,9 +2116,54 @@ def cmd_stale(root, hybrid=False):
2022
2116
  return 1 # stale areas dominate: rc already implied
2023
2117
 
2024
2118
 
2025
- def 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):
2026
2162
  if not require_ai_docs(root, "mark"):
2027
2163
  return 1
2164
+ if note is not None and (not note.strip() or "**" in note or "\n" in note):
2165
+ print("[ERROR] --note must be one line of text without '**'. Nothing was written.")
2166
+ return 1
2028
2167
  f, lines, rows = parse_audit_plan(root)
2029
2168
  use_git_ref = git_available(root) and not any(
2030
2169
  git_has_changes(root, raw.replace("\\", "/").rstrip("/")) for raw in paths
@@ -2051,6 +2190,10 @@ def cmd_mark(root, paths):
2051
2190
  "refusing to mark (use a project-relative path, '.' for the root). "
2052
2191
  "Nothing was written.")
2053
2192
  return 1
2193
+ if _in_docs_root(key, docs_root_rel(root)):
2194
+ print(f"[note] {raw}: the docs root is not an area to mark -- `validate` checks it "
2195
+ "directly. Skipped.")
2196
+ continue
2054
2197
  keys.append(key)
2055
2198
 
2056
2199
  appended = []
@@ -2070,6 +2213,13 @@ def cmd_mark(root, paths):
2070
2213
 
2071
2214
  f.parent.mkdir(parents=True, exist_ok=True)
2072
2215
  f.write_text("\n".join(lines) + "\n", encoding="utf-8")
2216
+ if note is not None:
2217
+ print(f"[ok] dated entry added to audit/{PROJECT_NOTES}: {add_dated_note(root, note)}")
2218
+ elif not _open_units(root):
2219
+ # no open unit's Diary covers this work: a release, a fix after closure
2220
+ print(f"[note] no open unit records this work. If it is a release or a change worth "
2221
+ f"finding later, re-run with --note \"<what was done>\": mark writes the "
2222
+ f"dated entry in audit/{PROJECT_NOTES} for you.")
2073
2223
  if use_git_ref:
2074
2224
  # F-056: the reference is born here, so this is where the gestures that
2075
2225
  # orphan it are named. An amend was the observed one.
@@ -2149,15 +2299,56 @@ def _orient_user_settings_path():
2149
2299
 
2150
2300
 
2151
2301
  def _orient_hook_commands(root):
2152
- """Yield (command, at_user_level) across every known client config: the
2153
- three project-relative files, then the machine-global user settings --
2302
+ """Yield (command, at_user_level, place) across every known client config:
2303
+ the three project-relative files, then the machine-global user settings --
2154
2304
  the file the installer wires (F-042), without which a fixed machine's
2155
- projects would read as unwired forever."""
2305
+ projects would read as unwired forever. `place` is a display label, never a
2306
+ home path (F-075 prints it)."""
2156
2307
  for rel in ORIENT_HOOK_CONFIG_FILES:
2157
2308
  for cmd in _orient_entries_in(Path(root) / rel):
2158
- yield cmd, False
2309
+ yield cmd, False, rel
2159
2310
  for cmd in _orient_entries_in(_orient_user_settings_path()):
2160
- yield cmd, True
2311
+ yield cmd, True, "user settings"
2312
+
2313
+
2314
+ def _orient_entry_live(root, cmd, at_user_level):
2315
+ """True unless the entry names a validator that does not resolve.
2316
+
2317
+ A matching entry whose tokens name no validator is "cannot tell" and counts
2318
+ as live (the lib.js contract: never report broken on cannot-tell). Relative
2319
+ tokens resolve against the project root (the vendored-validator
2320
+ convention); a USER-level relative token has no cwd to resolve against and
2321
+ is treated as cannot-tell."""
2322
+ token = None
2323
+ for m in _ORIENT_HOOK_TOKEN_RE.finditer(cmd):
2324
+ tok = m.group(1) if m.group(1) is not None else m.group(2)
2325
+ if any(tok.endswith(n) for n in ORIENT_HOOK_ENTRY_POINTS):
2326
+ token = tok
2327
+ break
2328
+ if token is None:
2329
+ return True
2330
+ tp = Path(token)
2331
+ if not tp.is_absolute():
2332
+ if at_user_level:
2333
+ return True
2334
+ tp = Path(root) / tp
2335
+ return tp.exists()
2336
+
2337
+
2338
+ def orient_wiring(root):
2339
+ """{client: {command: place}} for the LIVE orient entries, per client.
2340
+
2341
+ Per client because two clients never read each other's settings: a Codex
2342
+ entry beside a Claude Code one is two clients oriented once each, not one
2343
+ session oriented twice. Identical command strings count once. A dead entry
2344
+ delivers no copy and is left out (F-075 FS3)."""
2345
+ out = {}
2346
+ for cmd, at_user_level, place in _orient_hook_commands(root):
2347
+ if not _orient_entry_live(root, cmd, at_user_level):
2348
+ continue
2349
+ client = "codex" if place.startswith(".codex") else "claude"
2350
+ out.setdefault(client, {}).setdefault(cmd.strip(), place)
2351
+ return out
2161
2352
 
2162
2353
 
2163
2354
  def orient_hook_state(root):
@@ -2172,22 +2363,9 @@ def orient_hook_state(root):
2172
2363
  the project root (the vendored-validator convention); a USER-level relative
2173
2364
  token has no cwd to resolve against and is treated as cannot-tell."""
2174
2365
  matched = False
2175
- for cmd, at_user_level in _orient_hook_commands(root):
2366
+ for cmd, at_user_level, _place in _orient_hook_commands(root):
2176
2367
  matched = True
2177
- 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():
2368
+ if _orient_entry_live(root, cmd, at_user_level):
2191
2369
  return "wired"
2192
2370
  return "dead" if matched else "unwired"
2193
2371
 
@@ -2269,6 +2447,12 @@ REMIND_FALLBACK = (
2269
2447
  )
2270
2448
 
2271
2449
 
2450
+ # F-075: constant, for every lens -- with sibling lenses installed nothing per
2451
+ # turn said to choose the lens. r19's bound rose to 600 for it (r32, owner).
2452
+ REMIND_LENS_SUFFIX = (" Above L1, with sibling lenses installed: choose the lens with "
2453
+ "routing.md and declare it.")
2454
+
2455
+
2272
2456
  def cmd_remind(args):
2273
2457
  """UserPromptSubmit hook: print the lens's constant per-turn line. Always 0.
2274
2458
 
@@ -2277,21 +2461,31 @@ def cmd_remind(args):
2277
2461
  try:
2278
2462
  prof = profile()
2279
2463
  line = prof.get("remind_line") or REMIND_FALLBACK.format(skill=prof["skill_name"])
2280
- print(f"{prof['skill_name']}: {line}")
2464
+ print(f"{prof['skill_name']}: {line}{REMIND_LENS_SUFFIX}")
2281
2465
  except Exception:
2282
2466
  pass
2283
2467
  return 0
2284
2468
 
2469
+ def read_hook_payload():
2470
+ """The hook event JSON on stdin as a dict, or None when it cannot be read.
2471
+
2472
+ bytes -> utf-8-sig: the payload is UTF-8 JSON regardless of the console code
2473
+ page; '-sig' strips the BOM (PowerShell pipes)."""
2474
+ try:
2475
+ raw = sys.stdin.buffer.read().decode("utf-8-sig", errors="replace")
2476
+ payload = json.loads(raw)
2477
+ except Exception:
2478
+ return None
2479
+ return payload if isinstance(payload, dict) else None
2480
+
2481
+
2285
2482
  def cmd_gate(args):
2286
2483
  file_path = args.file or ""
2287
2484
  if args.hook:
2485
+ payload = read_hook_payload()
2288
2486
  try:
2289
- # bytes -> utf-8-sig: the hook payload is UTF-8 JSON regardless of the
2290
- # console code page; '-sig' strips the BOM (PowerShell pipes)
2291
- raw = sys.stdin.buffer.read().decode("utf-8-sig", errors="replace")
2292
- payload = json.loads(raw)
2293
2487
  file_path = (payload.get("tool_input") or {}).get("file_path") or ""
2294
- except Exception:
2488
+ except AttributeError:
2295
2489
  return 0 # unparseable input: do not block
2296
2490
  if not file_path:
2297
2491
  return 0
@@ -2326,6 +2520,134 @@ def cmd_gate(args):
2326
2520
  return 2
2327
2521
 
2328
2522
 
2523
+ # ------------------------------------------------------------------ verdict
2524
+ # F-076: Rule Zero's router verdict, checked at the edit. A field agent edited
2525
+ # without declaring one and said nothing stopped it. This reads the session
2526
+ # transcript the client hands every hook and refuses the edit (JSON deny, exit
2527
+ # 0) only on certainty; whatever it cannot tell resolves to silence. It never
2528
+ # prints `allow` -- that would skip the user's permission prompt. Owner: block
2529
+ # (REQUEST_guide_verdict_check.md R3), wired by default (R4, rulings.md r34).
2530
+
2531
+ VERDICT_EDIT_TOOLS = ("Edit", "Write", "MultiEdit", "NotebookEdit")
2532
+ VERDICT_MAX_BYTES = 256 * 1024 * 1024 # a larger transcript: cannot afford to tell
2533
+ VERDICT_LINE_MAX = 1024 * 1024 # assistant lines are small; skip the rest unparsed
2534
+ # Markdown emphasis between the word and the colon ("**router**: no match") and
2535
+ # "Router verdict:" are the same declaration: format drift resolves to silence.
2536
+ _VERDICT_ROUTER_RE = re.compile(r"router(?:[\s*_`]+verdict)?[\s*_`]*:(.*)", re.IGNORECASE)
2537
+ _VERDICT_NONE_RE = re.compile(r"[\s`*_\"'(]*(no match|absent)", re.IGNORECASE)
2538
+ _VERDICT_TRIVIAL_RE = re.compile(r"\blevel[\s*_`]*:[\s`*_]*[A-Z]1\b", re.IGNORECASE)
2539
+ _GUIDE_NAME_RE = re.compile(r"GUIDE_[A-Za-z0-9_-]+\.md")
2540
+
2541
+
2542
+ def transcript_facts(path):
2543
+ """What the session transcript shows about the verdict duty, or None when it
2544
+ cannot be read within the bounds.
2545
+
2546
+ Returns {"assistant": entries parsed, "sidechain": a subagent's own transcript
2547
+ (Claude Code marks every entry of one `isSidechain`), "declared": a router verdict seen,
2548
+ "trivial": a `Level: X1` seen, "named": guides the verdicts name,
2549
+ "read": guide names that appeared in a non-edit tool's input}."""
2550
+ facts = {"assistant": 0, "sidechain": False, "declared": False, "trivial": False,
2551
+ "named": set(), "read": set()}
2552
+ try:
2553
+ p = Path(path)
2554
+ if not p.is_file() or p.stat().st_size > VERDICT_MAX_BYTES:
2555
+ return None
2556
+ with p.open("rb") as fh:
2557
+ for raw in fh:
2558
+ if len(raw) > VERDICT_LINE_MAX or b'"assistant"' not in raw:
2559
+ continue
2560
+ try:
2561
+ entry = json.loads(raw.decode("utf-8", errors="replace"))
2562
+ except ValueError:
2563
+ continue
2564
+ if not isinstance(entry, dict) or entry.get("type") != "assistant":
2565
+ continue
2566
+ content = (entry.get("message") or {}).get("content")
2567
+ if not isinstance(content, list):
2568
+ continue
2569
+ facts["assistant"] += 1
2570
+ if entry.get("isSidechain") is True:
2571
+ facts["sidechain"] = True
2572
+ for block in content:
2573
+ if not isinstance(block, dict):
2574
+ continue
2575
+ if block.get("type") == "text":
2576
+ for line in str(block.get("text") or "").splitlines():
2577
+ if _VERDICT_TRIVIAL_RE.search(line):
2578
+ facts["trivial"] = True
2579
+ m = _VERDICT_ROUTER_RE.search(line)
2580
+ if m:
2581
+ facts["declared"] = True
2582
+ if not _VERDICT_NONE_RE.match(m.group(1)):
2583
+ facts["named"].update(_GUIDE_NAME_RE.findall(m.group(1)))
2584
+ elif (block.get("type") == "tool_use"
2585
+ and block.get("name") not in VERDICT_EDIT_TOOLS):
2586
+ facts["read"].update(_GUIDE_NAME_RE.findall(
2587
+ json.dumps(block.get("input"), ensure_ascii=False)))
2588
+ except (OSError, ValueError, TypeError, AttributeError):
2589
+ return None
2590
+ return facts
2591
+
2592
+
2593
+ def verdict_reason(payload, root):
2594
+ """The refusal line for this edit event, or None when the edit may proceed."""
2595
+ if payload.get("tool_name") not in VERDICT_EDIT_TOOLS or payload.get("agent_id"):
2596
+ return None
2597
+ tool_input = payload.get("tool_input") or {}
2598
+ target = tool_input.get("file_path") or tool_input.get("notebook_path")
2599
+ transcript = payload.get("transcript_path")
2600
+ if not target or not transcript:
2601
+ return None
2602
+ try:
2603
+ Path(target).resolve().relative_to(root)
2604
+ except (ValueError, OSError):
2605
+ return None # outside the project: not this check's concern
2606
+ ref = ai_path(root, "reference")
2607
+ if not ref.is_dir():
2608
+ return None
2609
+ guides = {g.name for g in ref.glob("GUIDE_*.md") if g.is_file()}
2610
+ if not guides:
2611
+ return None
2612
+ facts = transcript_facts(transcript)
2613
+ if not facts or not facts["assistant"]:
2614
+ return None # unreadable, oversized or an unknown format: cannot tell
2615
+ if facts["sidechain"]:
2616
+ return None # a subagent works under its orchestrator's verdict
2617
+ if not (facts["declared"] or facts["trivial"]):
2618
+ return ("[sdlc verdict] No guide-router verdict in this session; this project has "
2619
+ f"guides ({docs_dir()}/reference/INDEX.md). Declare it with the level, e.g. "
2620
+ "\"Level: L2 · router: no match\" or \"router: GUIDE_x.md -> read\" "
2621
+ "(L1: \"Level: L1\"), then retry.")
2622
+ unread = sorted((facts["named"] & guides) - facts["read"])
2623
+ if unread:
2624
+ return (f"[sdlc verdict] The router verdict names {', '.join(unread)}, not read in "
2625
+ "this session. Read it, then retry.")
2626
+ return None
2627
+
2628
+
2629
+ def cmd_verdict(args):
2630
+ """PreToolUse hook: print a JSON refusal or nothing. Always 0 -- exit 2 would
2631
+ block blind, and the wired command adds `; exit 0` for validators that cannot
2632
+ run this at all (missing, or older than it)."""
2633
+ if not args.hook:
2634
+ return 0
2635
+ try:
2636
+ payload = read_hook_payload()
2637
+ if payload is None:
2638
+ return 0
2639
+ root = Path(args.root).resolve() if args.root else find_project_root()
2640
+ reason = verdict_reason(payload, root)
2641
+ if reason:
2642
+ print(json.dumps({"hookSpecificOutput": {
2643
+ "hookEventName": "PreToolUse",
2644
+ "permissionDecision": "deny",
2645
+ "permissionDecisionReason": reason}}, ensure_ascii=False))
2646
+ except Exception:
2647
+ pass
2648
+ return 0
2649
+
2650
+
2329
2651
  # --------------------------------------------------------------------- plan
2330
2652
  # Subagent Execution (Feature A). Zero-execution surface: this section and
2331
2653
  # everything it calls MUST NOT spawn a process (no subprocess/os.system/eval/
@@ -2525,15 +2847,23 @@ REVIEW_POSITIONAL = {
2525
2847
  }
2526
2848
 
2527
2849
 
2850
+ _UNESCAPED_PIPE_RE = re.compile(r"(?<!\\)\|")
2851
+
2852
+
2528
2853
  def _table_cells(line):
2529
2854
  """Cells of a Markdown table row, or None when the line is not one.
2530
- Separator rows (---, :---:, any alignment colons) are not data."""
2855
+ Separator rows (---, :---:, any alignment colons) are not data. A cell may
2856
+ carry an escaped pipe (`\\|`, as `index` writes them): it does not split the
2857
+ row, and it reads back as `|` (F-075)."""
2531
2858
  s = line.strip()
2532
2859
  if not s.startswith("|"):
2533
2860
  return None
2534
2861
  if set(s) <= set("|-: "):
2535
2862
  return None
2536
- 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)]
2537
2867
 
2538
2868
 
2539
2869
  def parse_review_log(text):
@@ -2741,48 +3071,205 @@ def cmd_benefit(root):
2741
3071
  return 0
2742
3072
 
2743
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
+
2744
3201
  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."""
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."""
2750
3212
  try:
2751
3213
  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():
3214
+ rels = [rel for _, rel in orient_docs()]
3215
+ docs = {}
3216
+ for rel in rels:
2756
3217
  target = confine_under(root, rel)
2757
3218
  if target is None or not target.is_file():
2758
3219
  continue
2759
3220
  try:
2760
- text = read_text(target)
3221
+ docs[rel] = read_text(target)
2761
3222
  except OSError:
2762
3223
  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:
3224
+ if not docs:
2774
3225
  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.")
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.")
2782
3248
  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))
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))
2786
3273
  return 0
2787
3274
  except Exception:
2788
3275
  return 0
@@ -2999,11 +3486,18 @@ def main(argv=None):
2999
3486
 
3000
3487
  mp = sub.add_parser("mark", parents=[common], help="record paths as ANALYZED")
3001
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)")
3002
3491
  gp = sub.add_parser("gate", parents=[common, hybrid_opt], help="PreToolUse hook (exit 2 = block)")
3003
3492
  gp.add_argument("--hook", action="store_true", help="read the hook JSON payload from stdin")
3004
3493
  gp.add_argument("--file", help="file path to evaluate (alternative to --hook)")
3005
3494
  gp.add_argument("--protected", default="", help="protected prefixes separated by ';' (e.g. \"src/auth;src/crypto\")")
3006
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
+
3007
3501
  sub.add_parser("benefit", parents=[common],
3008
3502
  help="report what the review gates caught (and what the "
3009
3503
  "doctrine costs to read) -- a report, never a gate: "
@@ -3045,6 +3539,10 @@ def main(argv=None):
3045
3539
  # and says so rather than exiting non-zero.
3046
3540
  print(f"[note] {exc}")
3047
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
3048
3546
  else:
3049
3547
  print(f"[ERROR] {exc}")
3050
3548
  return 1
@@ -3052,6 +3550,8 @@ def main(argv=None):
3052
3550
 
3053
3551
  if args.cmd == "gate":
3054
3552
  return cmd_gate(args)
3553
+ if args.cmd == "verdict":
3554
+ return cmd_verdict(args)
3055
3555
  if args.cmd == "orient":
3056
3556
  return cmd_orient(args)
3057
3557
  if args.cmd == "remind":
@@ -3067,7 +3567,7 @@ def main(argv=None):
3067
3567
  if args.cmd == "stale":
3068
3568
  return cmd_stale(root, hybrid=args.hybrid)
3069
3569
  if args.cmd == "mark":
3070
- return cmd_mark(root, args.paths)
3570
+ return cmd_mark(root, args.paths, args.note)
3071
3571
  if args.cmd == "benefit":
3072
3572
  return cmd_benefit(root)
3073
3573
  if args.cmd == "migrate":