@antoneeo/agentic-sdlc-skill 1.17.0 → 1.19.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.
@@ -1,10 +1,62 @@
1
- # Code Review Discipline
1
+ # Review Discipline
2
2
 
3
- Applies at closure of L2/L3 work, and to any independent review slot in this
4
- skill or in a connected governance layer devPNT's §4.5/§4.6 review gates,
5
- and any future review step added to the workflow. This is the single
6
- definition of how to request, receive, and perform a review; other places
7
- that need review behavior point here instead of restating it (DRY).
3
+ The single definition of how to request, receive, and perform a review. Other
4
+ places that need review behavior point here instead of restating it (DRY) —
5
+ including devPNT's §4.5/§4.6 gates and any future review step.
6
+
7
+ ## When a review is due
8
+
9
+ Two moments, and they review different things:
10
+
11
+ | # | Moment | Object | Level |
12
+ |---|---|---|---|
13
+ | **1. Design review** | End of Phase 3 — **before any implementation** | the ANALYSIS (Standalone) / the `E-ISP`+`E-TDD` (Hybrid) | L3 |
14
+ | **1b. Late arrival** | Work that became L3 *after* code existed — an L1/L2 reclassified mid-flight, or a design increment on a feature already implemented — runs moment 1 **now**, before any further implementation, logged `design (late)` | same | L3 |
15
+ | **2. Closure review** | Phase 5, before DONE | the actual diff, against that approved design | L2 / L3 — the L2 row is optional |
16
+
17
+ **Why the design review is its own moment, and not a nicety.** The closure review
18
+ can only tell you the code matches the design; it cannot tell you the design was
19
+ wrong. An omission in the design — an impacted file nobody listed, a threat with no
20
+ answering requirement, a capability ruled EXISTS on an assumption — is *cheapest*
21
+ to fix before code exists and most expensive after. And the author cannot catch it:
22
+ a self-review runs in the context that produced the omission and is structurally
23
+ blind to it, which is why independence, not effort, is what this gate buys.
24
+
25
+ **Independence, best realization the client supports** — declare which one you used:
26
+
27
+ 1. **A fresh subagent** (Claude Code's Task tool, or the equivalent facility) with
28
+ its own context, given the artifacts below and nothing from this conversation.
29
+ 2. **A one-shot run of the client itself** (`gemini -p "…"`, `codex exec "…"`) with
30
+ a SELF-CONTAINED prompt — the reviewer session has no other context, which is
31
+ exactly what makes it independent.
32
+ 3. **A declared self-pass** — a separate, explicitly adversarial pass by you,
33
+ against the same checklist. **Rung 3 is illegitimate wherever rung 1 or 2
34
+ exists**: on a client with a subagent facility or a one-shot CLI, descending to
35
+ it is choosing zero independence, which is the one thing this gate buys. When
36
+ you do use it, the log row must carry *why* — `self-pass (declared; no subagent
37
+ facility on this client)` — not merely that you did. A rung named without its
38
+ reason is indistinguishable from a rung chosen for convenience.
39
+
40
+ Rung 3 stays in the ladder deliberately: it is what keeps the methodology
41
+ completable with no network, no account and no subagent facility. It is a floor,
42
+ never a default.
43
+
44
+ Use a different model from the author's where the client allows it.
45
+
46
+ **Rounds are capped at 3.** FAIL → revise → re-review. If findings still stand
47
+ after the third, stop and surface them to the user with the artifact — a gate that
48
+ can block forever gets removed. **Log one row per completed review, PASS or FAIL**
49
+ — a FAIL surfaced to the user is the highest-value outcome the gate produces, and
50
+ logging only passes would erase exactly that evidence. The row goes in
51
+ `ai_docs/audit/reviews/REVIEW_LOG.md` (create it if absent — `templates.md`):
52
+ `| date | doc_key | tier | reviewer | findings_raised | findings_real | verdict | revise_rounds |`,
53
+ with `tier` = `design` or `closure` in Standalone. One schema for both modes: a
54
+ Hybrid project's devPNT gates write to the same file.
55
+ The log is how the gate's value is measured over time; skipping it makes the gate
56
+ unfalsifiable, the same defect as an unnamed EXISTS or a faked router verdict.
57
+
58
+ **The reviewer is read-only and advisory.** It never edits, never commits, never
59
+ marks anything DONE, and a PASS is not an approval to merge — the human owns that.
8
60
 
9
61
  ## Requesting
10
62
 
@@ -13,7 +65,9 @@ When you hand work to a reviewer (human or agent), give them:
13
65
  - **Scope**: what changed and why, in one or two lines.
14
66
  - **The authoritative design artifact**: the ANALYSIS, E-TDD, or equivalent
15
67
  the change was built against — not a paraphrase of it.
16
- - **The actual diff**: the real changed files, not a description of them.
68
+ - **The actual diff**: the real changed files, not a description of them. (For a
69
+ **design** review there is no diff yet — that is the point; hand the artifact
70
+ plus the constraints below, and say the object under review is the design.)
17
71
  - **For an impact/solution-analysis review, the constraints it derives from**:
18
72
  the **Vision**, including its `## Actors` (Hybrid: the `M-VISION`; Standalone:
19
73
  `project_vision.md`/`roadmap.md` + the ANALYSIS Vision-Alignment), the
@@ -71,6 +125,27 @@ When you are the reviewer:
71
125
  statement is the proof the check ran; an unfalsifiable "I checked" is the review
72
126
  theater this discipline exists to prevent (the reviewer-side twin of §Receiving's
73
127
  silent-drop rule). Plain code reviews stay findings-only.
128
+ - **Capability Ledger (same reviews).** **An L3 impact/solution analysis or design
129
+ that carries NO Capability Ledger is itself a finding** — the architect pass left
130
+ no record, and "the artifact does not have one" is what a skipped pass looks like,
131
+ not a reason to skip the check. (This half is load-bearing in Hybrid, where the
132
+ validator backstop reads Standalone ANALYSIS files only and this clause is the
133
+ sole check that the pass ran.) When the ledger is there, map each
134
+ ledger row to where the design or diff realizes it. Three findings live here and
135
+ nowhere else (`architect.md`): a capability ruled MISSING but implemented inside
136
+ the feature's code path, with no component owning it; a component whose contract
137
+ names the feature (a second consumer would force it open); and a capability ruled
138
+ MISSING, not built, and absorbed by quietly reshaping the feature — that is a
139
+ scope change owed to the user, not a design detail. An EXISTS row with no named
140
+ path or symbol is itself a finding. A capability built in this change and absent
141
+ from the `## Component Map` (`strategic/architecture.md`) is a finding too — and
142
+ so is **a component the pass merely DISCOVERED and did not write**, especially
143
+ when the change marks that area ANALYZED: the area now looks read, the map is
144
+ still silent, and the next feature may lawfully rule the capability MISSING and
145
+ build it twice. And a **MISSING row in an area `audit/audit_plan.md` does not
146
+ mark ANALYZED, with no searches named**, is the finding that matters most on a
147
+ project the methodology arrived in recently — an unread map reported as an empty
148
+ one is how a duplicate of the existing codebase gets designed.
74
149
 
75
150
  ## Anti-patterns
76
151
 
@@ -67,6 +67,24 @@ PLAN_TASK_REQUIRED = ("id", "title", "verify")
67
67
  LEGACY_KEYS = {"stato": "status", "livello": "level",
68
68
  "data_inizio": "start_date", "data_fine": "end_date"}
69
69
 
70
+ # Architect pass (F-020): the Capability Ledger is due for ACTIVE L3 analyses
71
+ # born on/after the day the pass shipped. Grandfathering by start_date -- an
72
+ # in-flight analysis from before the pass existed never nags (same lazy-convert
73
+ # doctrine as the pre-1.17 narrative handoff).
74
+ ARCHITECT_PASS_EPOCH = "2026-07-28"
75
+ # Design-review gate (F-021): an L3 started on/after this date owes a REVIEW_LOG
76
+ # row. Same grandfathering discipline as the pass above -- never nag work that
77
+ # predates the rule.
78
+ DESIGN_REVIEW_EPOCH = "2026-07-28"
79
+ REVIEW_LOG_REL = "ai_docs/audit/reviews/REVIEW_LOG.md"
80
+ # Component Map 'Where' refs: a dotted token counts as a path only with one of
81
+ # these suffixes. Deliberately a closed list -- a generic ".\w{1,5}$" turns
82
+ # `app.core`, `OrderStore.save` and `1.18.0` into "the map is rotting".
83
+ FILE_SUFFIXES = ("md", "py", "js", "mjs", "cjs", "ts", "tsx", "jsx", "json", "yaml",
84
+ "yml", "toml", "ini", "cfg", "sh", "bat", "ps1", "go", "rs", "java",
85
+ "kt", "rb", "php", "cs", "swift", "c", "h", "cpp", "hpp", "sql",
86
+ "css", "scss", "html", "vue", "svelte", "tf", "proto", "txt")
87
+
70
88
  # ANALYSIS sections: (canonical English heading, legacy Italian heading).
71
89
  SECURITY_SECTION = ("## Security", "## Sicurezza")
72
90
  ANALYSIS_SECTIONS = (
@@ -350,9 +368,33 @@ def extract_doc_meta(path):
350
368
 
351
369
  desc = meta.get("description", "")
352
370
  if not desc:
371
+ in_comment = False
353
372
  for line in body:
354
373
  s = line.strip()
355
- if not s or s.startswith("#") or s.startswith("<!--") or _META_LINE.match(s):
374
+ # track HTML-comment state across lines: skipping only the OPENING
375
+ # line made line 2 of a multi-line comment the manifest description
376
+ # (the shipped vision template opens with a 3-line comment, so the
377
+ # most-read row of the manifest read '... -->')
378
+ if in_comment:
379
+ if "-->" in s:
380
+ in_comment = False
381
+ s = s.split("-->", 1)[1].strip()
382
+ if not s:
383
+ continue
384
+ else:
385
+ continue
386
+ elif s.startswith("<!--"):
387
+ if "-->" not in s:
388
+ in_comment = True
389
+ continue
390
+ s = s.split("-->", 1)[1].strip()
391
+ if not s:
392
+ continue
393
+ # a table row or a bare bullet is not a description: the manifest is
394
+ # the first thing an agent reads to orient, and '| Milestone | ... |'
395
+ # in that column is a row carrying no information
396
+ if (not s or s.startswith("#") or s.startswith("|") or s.startswith("---")
397
+ or re.match(r"^[-*+]\s", s) or _META_LINE.match(s)):
356
398
  continue
357
399
  if s.startswith(">"):
358
400
  s = s.lstrip(">").strip()
@@ -491,8 +533,21 @@ def cmd_index(root):
491
533
  if guides:
492
534
  gidx.write_text(build_guide_index(root), encoding="utf-8")
493
535
  print(f"[ok] guide router regenerated: {gidx}")
494
- elif gidx.is_file():
495
- print(f"[warn] {gidx} exists but no GUIDE_*.md found: stale router, remove or add guides")
536
+ else:
537
+ # An EMPTY router still gets written: Rule Zero makes reading it a
538
+ # mandatory, declared step, and `no match` may not be faked. Without the
539
+ # stub, the required verdict is unsatisfiable on every new project --
540
+ # and a rule that cannot be obeyed on first contact gets discarded.
541
+ gidx.parent.mkdir(parents=True, exist_ok=True)
542
+ gidx.write_text(GUIDE_INDEX_HEADER + "\n# Operative guides (generated router)\n\n"
543
+ "No guides in this project yet. This file exists so the Rule Zero "
544
+ "router lookup has something to read: the honest verdict here is "
545
+ "`router: no match`.\n\n"
546
+ "A guide is written when the user hands over indications to follow "
547
+ "(`source_kind: document`), or when a high-complexity component needs "
548
+ "a comprehension map (`source_kind: code`) -- see `guides.md`.\n",
549
+ encoding="utf-8")
550
+ print(f"[ok] guide router regenerated (empty stub): {gidx}")
496
551
  return 0
497
552
 
498
553
 
@@ -502,8 +557,221 @@ def has_section(text, aliases):
502
557
  return any(a in text for a in aliases)
503
558
 
504
559
 
505
- def cmd_validate(root, strict=False):
506
- errors, warnings = [], []
560
+ def design_review_due(meta):
561
+ """True when an L3 ANALYSIS owes a design-review row (review.md moment 1):
562
+ implementation has started or finished, and it began on/after the gate
563
+ shipped. PLANNED is exempt -- the review is due at the END of Phase 3, so an
564
+ analysis still being drafted is not late."""
565
+ if meta.get("level", "").upper() != "L3":
566
+ return False
567
+ if meta.get("status") not in ("IN_PROGRESS", "COMPLETED"):
568
+ return False
569
+ started = parse_iso((meta.get("start_date") or "").strip().strip("'\""))
570
+ return started is not None and started >= parse_iso(DESIGN_REVIEW_EPOCH)
571
+
572
+
573
+ def review_logged(root, analysis_name):
574
+ """True when REVIEW_LOG.md carries a design-moment row naming this ANALYSIS.
575
+ The filename matches anywhere in the row (loose on purpose: a freshness
576
+ signal must not turn a formatting slip into a false 'you skipped the
577
+ review'), but the moment is read from the `tier` COLUMN -- the schema
578
+ reserves it for exactly this, and matching 'design' anywhere in the row let
579
+ a CLOSURE row saying 'conformance to the design' satisfy the check."""
580
+ log = root / REVIEW_LOG_REL
581
+ if not log.is_file():
582
+ return False
583
+ stem = analysis_name[:-3] if analysis_name.endswith(".md") else analysis_name
584
+ # match the filename on a word boundary: a plain substring lets a longer
585
+ # sibling (ANALYSIS_vision_clarity) satisfy a shorter one (ANALYSIS_vision)
586
+ name_re = re.compile(r"(?<![\w-])" + re.escape(stem) + r"(?![\w-])")
587
+ tier_idx = None
588
+ for line in read_text(log).splitlines():
589
+ line = line.strip()
590
+ if not line.startswith("|"):
591
+ continue
592
+ cells = [c.strip() for c in line.strip("|").split("|")]
593
+ if tier_idx is None:
594
+ lowered = [c.lower() for c in cells]
595
+ if "tier" in lowered: # header found: trust it over position
596
+ tier_idx = lowered.index("tier")
597
+ continue
598
+ if not name_re.search(line):
599
+ continue
600
+ # schema: | date | doc_key | tier | reviewer | raised | real | verdict | rounds |
601
+ idx = tier_idx if tier_idx is not None else 2
602
+ if len(cells) > idx and re.match(r"design\b", cells[idx], re.I):
603
+ return True
604
+ return False
605
+
606
+
607
+ def has_ledger_heading(text):
608
+ """True when a REAL '## Capability Ledger' heading exists: fenced code
609
+ blocks are removed first, then HTML comments. An unterminated '<!--' only
610
+ opens a comment at the start of a line -- nuking to EOF on an inline
611
+ mention (or an unclosed example inside a fence) made a document that
612
+ HAS its ledger get told it has none."""
613
+ stripped = re.sub(r"^(```|~~~).*?^\1", "", text, flags=re.M | re.S)
614
+ stripped = re.sub(r"<!--.*?-->", "", stripped, flags=re.S)
615
+ stripped = re.sub(r"^[ \t]*<!--(?!.*?-->).*\Z", "", stripped, flags=re.M | re.S)
616
+ return bool(re.search(r"^##[ \t]+Capability Ledger[ \t]*$", stripped, re.M))
617
+
618
+
619
+ def ledger_due(meta):
620
+ """True when an ANALYSIS owes a '## Capability Ledger' (architect.md):
621
+ an L3 started on/after the day the pass shipped. Grandfathered by
622
+ start_date ALONE -- deliberately NOT by status: closure flips the ANALYSIS
623
+ to COMPLETED before `check` runs (SKILL.md phase 5), so a status filter
624
+ would silence the backstop at the only moment the process mandates the
625
+ validator. A malformed/absent start_date is not due (fail-open: cmd_validate
626
+ already errors on a missing one, and guessing an epoch from garbage would
627
+ nag projects the pass never reached)."""
628
+ if meta.get("level", "").upper() != "L3":
629
+ return False
630
+ if meta.get("status") == "CANCELLED":
631
+ return False # abandoned work has no legitimate way to satisfy this
632
+ started = parse_iso((meta.get("start_date") or "").strip().strip("'\""))
633
+ return started is not None and started >= parse_iso(ARCHITECT_PASS_EPOCH)
634
+
635
+
636
+ MAP_SECTION_RE = re.compile(r"^#{2,3}[ \t]+Component Map\b.*?$(.*?)(?=^#{1,3}[ \t]+\S|\Z)",
637
+ re.M | re.S | re.I)
638
+
639
+
640
+ def map_where_refs(arch_text):
641
+ """Normalized, symbol-stripped paths from the Component Map's 'Where' column
642
+ ONLY -- never from the whole document. Harvesting the whole file let the
643
+ canonical template's own '## Directory Structure' backticks satisfy the check
644
+ and silently disable it on every project that fills that section in.
645
+ Returns None when the document has no Component Map at all."""
646
+ m = MAP_SECTION_RE.search(arch_text)
647
+ if not m:
648
+ return None
649
+ refs, where_idx = [], None
650
+ for ln in m.group(1).splitlines():
651
+ ln = ln.strip()
652
+ if not ln.startswith("|"):
653
+ continue
654
+ cells = [c.strip() for c in ln.strip("|").split("|")]
655
+ if len(cells) < 2 or not cells[0] or set(cells[0]) <= {"-", ":"}:
656
+ continue
657
+ if where_idx is None:
658
+ lowered = [c.lower() for c in cells]
659
+ if "where" not in lowered:
660
+ return [] # no Where column: nothing is mapped
661
+ where_idx = lowered.index("where")
662
+ continue
663
+ if where_idx < len(cells):
664
+ for _ref, path_part, _sym in _map_refs(cells[where_idx]):
665
+ refs.append(path_part.lstrip("./").strip("/"))
666
+ return refs
667
+
668
+
669
+ def _map_refs(where):
670
+ """Backticked refs in a 'Where' cell that are file paths: they contain a
671
+ separator, or end in a KNOWN source-file suffix. Windows separators are
672
+ normalized. Everything else in that cell is prose and must stay silent --
673
+ a false 'the map is rotting' teaches readers to ignore the output, which is
674
+ worse than the rot. `app.core`, `OrderStore.save` and `1.18.0` are prose."""
675
+ out = []
676
+ for ref in re.findall(r"`([^`]+)`", where):
677
+ path_part, _, symbol = ref.partition("#")
678
+ path_part = path_part.replace("\\", "/").strip()
679
+ if not path_part or "://" in path_part:
680
+ continue # a URL is not a repo path
681
+ # A slash-less token counts only if it looks like a FILENAME. The one
682
+ # real false-positive class is `Next.js` / `Node.js` / `Vue.js`: a
683
+ # CamelCase stem with a `.js` tail is a framework name, not a file.
684
+ # The exclusion is scoped to that suffix ON PURPOSE -- a blanket
685
+ # CamelCase rule would silence `App.tsx`, `Program.cs`, `Main.java`,
686
+ # which are exactly what React/C#/Java projects put in a Where cell.
687
+ stem, _, suffix = path_part.rsplit("/", 1)[-1].rpartition(".")
688
+ framework_name = (suffix.lower() == "js"
689
+ and bool(re.fullmatch(r"[A-Z][a-z0-9]+(?:[A-Z][a-z0-9]*)*", stem)))
690
+ looks_like_path = "/" in path_part or (
691
+ bool(re.search(r"\.(" + "|".join(FILE_SUFFIXES) + r")$", path_part, re.I))
692
+ and not framework_name)
693
+ if looks_like_path:
694
+ out.append((ref, path_part, symbol.strip()))
695
+ return out
696
+
697
+
698
+ def check_component_map(root, text, advisories):
699
+ """Anti-rot for the '## Component Map' of strategic/architecture.md
700
+ (architect.md): every path-shaped backticked ref in the 'Where' column must
701
+ still resolve on disk, and its '#symbol' must still appear as a whole word
702
+ in a matched file. This is the map's equivalent of the guides' source_hash.
703
+ ADVISORY: a freshness signal, never a gate -- not even under --strict (the
704
+ accepted ceremony budget was a warning, not a blocked pipeline)."""
705
+ m = MAP_SECTION_RE.search(text) # one regex for both checks: they cannot drift
706
+ if not m:
707
+ return
708
+ rows = [ln.strip() for ln in m.group(1).splitlines() if ln.strip().startswith("|")]
709
+ where_idx, header_cells, checked, data_rows, ragged = None, 0, 0, 0, 0
710
+ for row in rows:
711
+ cells = [c.strip() for c in row.strip("|").split("|")]
712
+ if len(cells) < 2 or not cells[0] or set(cells[0]) <= {"-", ":"}:
713
+ continue
714
+ if where_idx is None: # the first non-separator row is the header
715
+ lowered = [c.lower() for c in cells]
716
+ if "where" not in lowered:
717
+ advisories.append("strategic/architecture.md: Component Map has no 'Where' "
718
+ "column in its header -- the anti-rot check cannot run; "
719
+ "give the table a Where column of `path/to/file#Symbol` refs")
720
+ return
721
+ where_idx, header_cells = lowered.index("where"), len(cells)
722
+ continue
723
+ if all(c in ("", "...", "…") for c in cells):
724
+ continue # untouched template placeholder row
725
+ data_rows += 1
726
+ if len(cells) != header_cells: # ragged: never silently unchecked
727
+ ragged += 1
728
+ continue
729
+ component, where = cells[0], cells[where_idx]
730
+ for ref, path_part, symbol in _map_refs(where):
731
+ checked += 1
732
+ if confine_under(root, re.sub(r"[*?\[\]]", "x", path_part)) is None:
733
+ advisories.append(f"strategic/architecture.md: Component Map row '{component}': "
734
+ f"ref '{ref}' escapes the project root: rejected")
735
+ continue
736
+ target = root / path_part
737
+ try:
738
+ if target.exists(): # literal first: `app/[id]/page.tsx` is a real path
739
+ matches = [target]
740
+ elif any(c in path_part for c in "*?["):
741
+ matches = list(root.glob(path_part))
742
+ else:
743
+ matches = []
744
+ except (ValueError, OSError):
745
+ matches = []
746
+ if not matches:
747
+ advisories.append(f"strategic/architecture.md: Component Map row '{component}': "
748
+ f"'{path_part}' no longer exists -- the map is rotting, "
749
+ "update the row or drop it")
750
+ continue
751
+ files = [f for f in matches if f.is_file()]
752
+ if symbol and files:
753
+ word = re.compile(r"(?<![A-Za-z0-9_])" + re.escape(symbol) + r"(?![A-Za-z0-9_])")
754
+ if not any(word.search(read_text(f)) for f in files):
755
+ advisories.append(f"strategic/architecture.md: Component Map row '{component}': "
756
+ f"symbol '{symbol}' not found in '{path_part}' -- renamed or "
757
+ "removed, update the row")
758
+ if ragged:
759
+ advisories.append(f"strategic/architecture.md: Component Map has {ragged} row(s) whose "
760
+ "column count differs from the header -- unchecked; an escaped '|' in "
761
+ "a cell shifts the columns")
762
+ if data_rows and not checked and not ragged:
763
+ # only once the map claims real components: a freshly seeded project
764
+ # carries the template placeholder and must NOT be nagged on day zero
765
+ advisories.append("strategic/architecture.md: Component Map has rows but no checkable "
766
+ "path in its 'Where' column -- the anti-rot check is inert; write refs "
767
+ "as `path/to/file#Symbol`")
768
+
769
+
770
+ def cmd_validate(root, strict=False, hybrid=False):
771
+ # advisories: architect-pass freshness signals. Reported, never escalated by
772
+ # --strict -- the accepted ceremony budget (project_vision.md "no ceremony
773
+ # ratchet") was a warning, and a warning that reddens CI is a gate.
774
+ errors, warnings, advisories = [], [], []
507
775
  ai = root / "ai_docs"
508
776
  if not ai.is_dir():
509
777
  if strict:
@@ -524,7 +792,12 @@ def cmd_validate(root, strict=False):
524
792
  if not m:
525
793
  errors.append(f"vision/{name}: missing 'Status: DRAFT|APPROVED' in the first lines")
526
794
  elif m.group(1) == "DRAFT":
527
- warnings.append(f"vision/{name} is DRAFT: not a gating authority, have the user validate it")
795
+ # advisory, not a warning: bootstrap MANDATES DRAFT, so a warning here
796
+ # makes `validate --strict` red on every freshly bootstrapped project
797
+ # until a human runs the blind check -- and teams delete the CI step
798
+ # rather than block on it. DRAFT is a state, not a defect.
799
+ advisories.append(f"vision/{name} is DRAFT: not a gating authority, "
800
+ "have the user validate it")
528
801
 
529
802
  # ANALYSIS: frontmatter and mandatory sections
530
803
  seen_ids = {}
@@ -556,6 +829,28 @@ def cmd_validate(root, strict=False):
556
829
  for en, it in ANALYSIS_SECTIONS:
557
830
  if not has_section(text, (en, it)):
558
831
  warnings.append(f"{rel}: section '{en}' missing")
832
+ if not level and (parse_iso((meta.get("start_date") or "").strip().strip("'\"")) or
833
+ parse_iso("1970-01-01")) >= parse_iso(ARCHITECT_PASS_EPOCH):
834
+ # advisory + epoch-gated, exactly like the check it guards: a warning
835
+ # here would redden --strict CI on every pre-1.18 analysis that never
836
+ # carried the optional field. (Same defect the advisories bucket was
837
+ # invented to prevent -- reintroduced once, caught in review.)
838
+ advisories.append(f"{rel}: 'level' missing (L1/L2/L3/Spike) -- risk-proportional "
839
+ "checks cannot apply, and dropping the line is cheaper than "
840
+ "doing the work it triggers")
841
+ # comment-stripped, anchored: a '<!-- TODO: the ## Capability Ledger -->'
842
+ # must not read as the section being present
843
+ # Hybrid: the design lives in devPNT and its §4.5 gate owns this slot
844
+ # (SKILL.md ownership matrix: "run ONE of them, never both"), and its log
845
+ # rows are keyed on e_isp_/e_tdd_ doc_keys, not on this filename -- so
846
+ # firing here would be a permanent, unfixable false positive.
847
+ if not hybrid and design_review_due(meta) and not review_logged(root, p.name):
848
+ advisories.append(f"{rel}: L3 in implementation with no design-review row in "
849
+ f"{REVIEW_LOG_REL} -- the design was reviewed by nobody but its "
850
+ "author before code was written (review.md moment 1)")
851
+ if ledger_due(meta) and not has_ledger_heading(text):
852
+ advisories.append(f"{rel}: L3 without '## Capability Ledger' -- the architect pass "
853
+ "left no record (architect.md); run it before the Impact")
559
854
 
560
855
  # Generated index aligned
561
856
  hist = ai / "strategic" / "features_history.md"
@@ -588,6 +883,40 @@ def cmd_validate(root, strict=False):
588
883
  or os.path.basename(other) == base) and ost == "CURRENT":
589
884
  warnings.append(f"{other}: still CURRENT but superseded by {rel} (set status: SUPERSEDED)")
590
885
 
886
+ # Component Map anti-rot (architect.md): rows must still resolve on disk
887
+ arch = ai / "strategic" / "architecture.md"
888
+ if arch.is_file():
889
+ arch_text = read_text(arch)
890
+ check_component_map(root, arch_text, advisories)
891
+ # the missing half of the loop: `mark` asserts an area was read closely
892
+ # enough to name its capability owners, and nothing verified that claim.
893
+ # An ANALYZED area with no map row is how the brownfield guard is
894
+ # disarmed -- the area looks read, so the map's silence becomes groundable.
895
+ _, _, plan_rows = parse_audit_plan(root)
896
+ mapped = map_where_refs(arch_text)
897
+ if plan_rows and mapped is not None:
898
+ for prow in plan_rows:
899
+ if prow["status"] != "ANALYZED":
900
+ continue
901
+ if confine_under(root, prow["path"]) is None:
902
+ continue # stale already rejects these
903
+ if re.search(r"owns no component", prow.get("note", ""), re.I):
904
+ continue # declared, not forgotten: the opt-out
905
+ area = prow["path"].replace("\\", "/").strip("/")
906
+ if area.startswith("./"):
907
+ area = area[2:]
908
+ if area in ("", "."):
909
+ continue # the whole root: every row is inside it
910
+ if not (root / area).exists():
911
+ continue # gone from disk: not a mapping gap
912
+ if not any(mp == area or mp.startswith(area + "/") for mp in mapped):
913
+ advisories.append(
914
+ f"strategic/architecture.md: '{prow['path']}' is ANALYZED in the audit "
915
+ "plan but owns no Component Map row -- marking asserts the area was read "
916
+ "closely enough to name what it owns, and the map's silence there is now "
917
+ "groundable for a MISSING verdict (architect.md). If it genuinely owns no "
918
+ "component, say so in the audit plan's Notes column: 'owns no component'")
919
+
591
920
  # Guide checks (ai_docs/reference/GUIDE_*.md): structure only — freshness is stale's job
592
921
  guides = list_guides(root)
593
922
  for rel, p, meta, text in guides:
@@ -609,9 +938,20 @@ def cmd_validate(root, strict=False):
609
938
  check_kb_collisions(root, guides, errors, warnings)
610
939
  # guide-router alignment (mirror of the root-manifest check)
611
940
  gidx = root / "ai_docs" / "reference" / "INDEX.md"
941
+ if not gidx.is_file() and not guides:
942
+ # zero guides: the stub is a convenience for the mandatory Rule Zero read
943
+ advisories.append("ai_docs/reference/INDEX.md missing: Rule Zero requires reading the "
944
+ "guide router and forbids faking its verdict, so the router exists "
945
+ "even with zero guides -- run 'sdlc_check.py index'")
612
946
  if guides:
613
947
  if not gidx.is_file():
614
- errors.append("ai_docs/reference/INDEX.md missing: run 'sdlc_check.py index'")
948
+ # guides EXIST and the router does not: the agent's mandatory lookup
949
+ # finds nothing and legally declares 'absent', so the guide that
950
+ # governs the work is never consulted. An absent router must not be
951
+ # graded below a merely stale one.
952
+ errors.append("ai_docs/reference/INDEX.md missing while GUIDE_*.md files exist: "
953
+ "the router is the only thing that routes work to them -- "
954
+ "run 'sdlc_check.py index'")
615
955
  elif norm_text(read_text(gidx)) != norm_text(build_guide_index(root)):
616
956
  errors.append("ai_docs/reference/INDEX.md not aligned with the guides: run 'sdlc_check.py index'")
617
957
 
@@ -630,11 +970,16 @@ def cmd_validate(root, strict=False):
630
970
  except ValueError:
631
971
  warnings.append("audit/handoff.md: date not parseable")
632
972
 
973
+ for a in advisories:
974
+ print(f"[note] {a}")
633
975
  for w in warnings:
634
976
  print(f"[warn] {w}")
635
977
  for e in errors:
636
978
  print(f"[ERROR] {e}")
637
- print(f"\nValidation: {len(errors)} errors, {len(warnings)} warnings.")
979
+ print(f"\nValidation: {len(errors)} errors, {len(warnings)} warnings, "
980
+ f"{len(advisories)} advisories.")
981
+ if advisories:
982
+ print("[note] advisories are freshness signals: never fail a build, not even --strict.")
638
983
  if strict and warnings and not errors:
639
984
  print("[strict] warnings are failures in --strict mode.")
640
985
  return 1 if errors or (strict and warnings) else 0
@@ -699,7 +1044,15 @@ def cmd_stale(root, hybrid=False):
699
1044
  if row["status"] != "ANALYZED":
700
1045
  continue
701
1046
  rel, ref = row["path"], row["ref"]
702
- target = root / rel
1047
+ # Confine BEFORE touching the filesystem: audit_plan.md is document
1048
+ # content, so an absolute row ('/'), a drive-relative one or a '..'
1049
+ # escape would otherwise walk outside the project (P-TM T2/T3). A bare
1050
+ # '/' is the row init.js used to seed, and `root / "/"` is the drive.
1051
+ target = confine_under(root, rel)
1052
+ if target is None:
1053
+ print(f"[warn] {rel}: path is absolute, contains '..', or resolves outside "
1054
+ "the project root: rejected (use a project-relative path, '.' for the root)")
1055
+ continue
703
1056
  if not target.exists():
704
1057
  print(f"[warn] {rel}: path does not exist")
705
1058
  continue
@@ -718,7 +1071,11 @@ def cmd_stale(root, hybrid=False):
718
1071
  for fp in iter_files(target):
719
1072
  mtime = datetime.fromtimestamp(fp.stat().st_mtime, tz=timezone.utc)
720
1073
  if mtime > ts + MTIME_GRACE:
721
- changed.append(str(fp.relative_to(root)).replace("\\", "/"))
1074
+ try:
1075
+ name = str(fp.relative_to(root)).replace("\\", "/")
1076
+ except ValueError: # symlink out of the tree: report absolute, never crash
1077
+ name = str(fp)
1078
+ changed.append(name)
722
1079
  if changed:
723
1080
  stale.append((rel, changed))
724
1081
 
@@ -755,10 +1112,21 @@ def cmd_mark(root, paths):
755
1112
  def row_text(path, note):
756
1113
  return f"| {path} | ANALYZED | {ref} | {note} |"
757
1114
 
758
- appended = []
1115
+ # validate EVERY path before printing or mutating anything: a rejection
1116
+ # after an '[ok] ... added as ANALYZED' line is a lie the agent will act on
1117
+ keys = []
759
1118
  for raw in paths:
760
- key = raw.replace("\\", "/").rstrip("/")
761
- display = key + ("/" if (root / key).is_dir() else "")
1119
+ key = raw.replace("\\", "/").rstrip("/") or "."
1120
+ if confine_under(root, key) is None:
1121
+ print(f"[ERROR] {raw}: absolute, '..'-escaping, or outside the project root: "
1122
+ "refusing to mark (use a project-relative path, '.' for the root). "
1123
+ "Nothing was written.")
1124
+ return 1
1125
+ keys.append(key)
1126
+
1127
+ appended = []
1128
+ for key in keys:
1129
+ display = key + ("/" if (root / key).is_dir() and key != "." else "")
762
1130
  existing = by_path.get(key)
763
1131
  if existing:
764
1132
  lines[existing["line"]] = row_text(existing["path"], existing["note"])
@@ -778,7 +1146,7 @@ def cmd_mark(root, paths):
778
1146
 
779
1147
  def cmd_check(root, strict=False, hybrid=False):
780
1148
  print("===== validate =====")
781
- rc_v = cmd_validate(root, strict=strict)
1149
+ rc_v = cmd_validate(root, strict=strict, hybrid=hybrid)
782
1150
  print("\n===== stale =====")
783
1151
  rc_s = cmd_stale(root, hybrid=hybrid)
784
1152
  print(f"\ncheck: {'CLEAN' if not (rc_v or rc_s) else 'NOT CLEAN'} "
@@ -827,7 +1195,8 @@ def cmd_gate(args):
827
1195
  return 2
828
1196
  sys.stderr.write(
829
1197
  f"[sdlc gate] '{rel}' is on a protected path but no ANALYSIS_*.md is IN_PROGRESS. "
830
- "Create or reactivate the analysis (agentic-sdlc Phase 3) before modifying this file.\n")
1198
+ "If your analysis already exists, set its frontmatter to 'status: IN_PROGRESS' "
1199
+ "(that flip is what opens the gate); otherwise write it first (Phase 3).\n")
831
1200
  return 2
832
1201
 
833
1202
 
@@ -1071,7 +1440,8 @@ def main(argv=None):
1071
1440
  sub = ap.add_subparsers(dest="cmd", required=True)
1072
1441
  sub.add_parser("check", parents=[common, strict_opt, hybrid_opt],
1073
1442
  help="closure gate: validate + stale in one command")
1074
- sub.add_parser("validate", parents=[common, strict_opt], help="verify ai_docs/ coherence")
1443
+ sub.add_parser("validate", parents=[common, strict_opt, hybrid_opt],
1444
+ help="verify ai_docs/ coherence")
1075
1445
  sub.add_parser("index", parents=[common], help="regenerate features_history.md + ai_docs/INDEX.md")
1076
1446
  sub.add_parser("stale", parents=[common, hybrid_opt], help="areas modified after the last analysis")
1077
1447
  mp = sub.add_parser("mark", parents=[common], help="record paths as ANALYZED")
@@ -1103,7 +1473,7 @@ def main(argv=None):
1103
1473
  if args.cmd == "check":
1104
1474
  return cmd_check(root, strict=args.strict, hybrid=args.hybrid)
1105
1475
  if args.cmd == "validate":
1106
- return cmd_validate(root, strict=args.strict)
1476
+ return cmd_validate(root, strict=args.strict, hybrid=args.hybrid)
1107
1477
  if args.cmd == "index":
1108
1478
  return cmd_index(root)
1109
1479
  if args.cmd == "stale":