@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.
- package/CHANGELOG.md +33 -0
- package/README.md +3 -1
- package/gemini-extension.json +6 -6
- package/package.json +48 -47
- package/scripts/init.js +3 -1
- package/skills/agentic-sdlc-skill/ENFORCEMENT.md +2 -0
- package/skills/agentic-sdlc-skill/SKILL.md +16 -5
- package/skills/agentic-sdlc-skill/architect.md +215 -0
- package/skills/agentic-sdlc-skill/elicitation.md +7 -0
- package/skills/agentic-sdlc-skill/guides.md +8 -0
- package/skills/agentic-sdlc-skill/review.md +82 -7
- package/skills/agentic-sdlc-skill/scripts/sdlc_check.py +387 -17
- package/skills/agentic-sdlc-skill/templates.md +81 -2
|
@@ -1,10 +1,62 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Review Discipline
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
and any future review step
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
495
|
-
|
|
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
|
|
506
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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],
|
|
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":
|