@antoneeo/agentic-sdlc-skill 1.19.0 → 1.20.2

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,1488 +1,70 @@
1
1
  #!/usr/bin/env python3
2
2
  # -*- coding: utf-8 -*-
3
- """Mechanical validator for the Agentic SDLC skill.
3
+ """Agentic SDLC the CODE domain entry point.
4
4
 
5
- Commands:
6
- check single closure gate: validate + stale in one command (exit 1 if either fails)
7
- validate verify the structural coherence of ai_docs/ (exit 1 on errors; --strict also
8
- fails on warnings or on a missing ai_docs/, for CI)
9
- index regenerate the generated indexes: strategic/features_history.md (from the
10
- frontmatter of ANALYSIS_*.md files) and ai_docs/INDEX.md (manifest of canonical docs)
11
- stale list areas modified after the last analysis recorded in audit_plan.md (exit 1 if any)
12
- mark record paths as ANALYZED with the current reference (git hash, else UTC timestamp)
13
- gate PreToolUse hook: block writes on protected paths without an IN_PROGRESS ANALYSIS (exit 2)
5
+ Thin by design. Every behaviour lives in `sdlc_core.py`, the spine shipped
6
+ verbatim in every distribution of the family; this file only says which domain
7
+ this distribution implements and which portable checks it exposes. Command
8
+ names, flags, output and exit codes are unchanged an existing project sees the
9
+ same tool it always had.
14
10
 
15
- Hybrid/devPNT mode: pass --hybrid explicitly on check/stale (skips audit-plan
16
- staleness, delegated to devPNT/KL) and on gate (also unlocks when an approved
17
- E-TDD shadow, solutions/SHADOW_*tdd*.md, exists).
11
+ Both files must sit in the same directory. If you copy the validator into a CI
12
+ image, copy BOTH (`ENFORCEMENT.md` §2 has the recipe); copying this one alone
13
+ fails at import, loudly and immediately, which is the intended failure.
18
14
 
19
- Canonical language is English. Legacy Italian frontmatter keys (stato, livello,
20
- data_inizio, data_fine) and section headings are still accepted for existing projects,
21
- but are deprecated: new documents should use the English forms.
22
-
23
- Standard library only (Python >= 3.8). Windows and POSIX compatible.
15
+ Usage is `sdlc_core.py`'s: check / validate / index / stale / mark / gate /
16
+ orient / plan.
24
17
  """
25
- import argparse
26
- import hashlib
27
- import json
28
- import os
29
- import re
30
- import subprocess
31
18
  import sys
32
- from datetime import datetime, timedelta, timezone
33
19
  from pathlib import Path
34
20
 
35
- VALID_STATES = {"PLANNED", "IN_PROGRESS", "COMPLETED", "CANCELLED"}
36
- VALID_LEVELS = {"L1", "L2", "L3", "SPIKE"}
37
- VISION_FILES = ("project_vision.md", "roadmap.md", "principles.md")
38
- SKIP_DIRS = {".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv",
39
- "dist", "build", ".idea", ".vs", "ai_docs"}
40
- INDEX_HEADER = ("<!-- GENERATED by sdlc_check.py index - do not edit by hand. "
41
- "Source of truth: frontmatter of the ANALYSIS_*.md files -->")
42
- MANIFEST_HEADER = ("<!-- GENERATED by sdlc_check.py index - do not edit by hand. "
43
- "Source of truth: the headers of the canonical documents in ai_docs/. -->")
44
- # Directories whose .md files are durable canonical documents: manifested in INDEX.md.
45
- # audit/ and solutions/ stay discovery-by-grep (session / process artifacts), not manifested.
46
- MANIFEST_DIRS = ("vision", "reference", "architecture", "functional", "strategic")
47
- # Recognized states: canonical docs (CURRENT/SUPERSEDED/...), vision (DRAFT/APPROVED),
48
- # ADR (Accepted/Proposed/Rejected). Union, to avoid false warnings on conventions in use.
49
- CANONICAL_STATES = {"CURRENT", "SUPERSEDED", "DRAFT", "DEPRECATED",
50
- "APPROVED", "ACCEPTED", "PROPOSED", "REJECTED"}
51
- GENERATED_DOCS = {"features_history.md", "INDEX.md"} # generated: never manifest entries
52
- MTIME_GRACE = timedelta(seconds=2)
53
- GUIDE_INDEX_HEADER = ("<!-- GENERATED by sdlc_check.py index - do not edit by hand. "
54
- "Source of truth: the headers of the GUIDE_*.md files in ai_docs/reference/. -->")
55
- GUIDE_PROVENANCE_KEYS = ("source", "distilled_from", "source_hash") # source_version optional
56
- # a guide section is "covered" when it carries a source marker or an explicit gap marker
57
- GUIDE_MARKER_RE = re.compile(r"\[(?:source:[^\]]+|not covered by source)\]")
58
- # Agent-global KB (Feature B unit 2): ONE client-agnostic root under home.
59
- # AGENTIC_SDLC_KB_ROOT env var is a TEST/CI seam only (scenario battery must
60
- # not touch the real user KB); the documented product path is fixed.
61
- DEFAULT_KB_ROOT = Path(os.environ.get("AGENTIC_SDLC_KB_ROOT", "")) if os.environ.get("AGENTIC_SDLC_KB_ROOT") else Path.home() / ".agentic-sdlc"
62
- # Subagent Execution (Feature A): a PLAN_[feature].md task must carry these keys,
63
- # plus at least one of paths/produces (checked separately in cmd_plan).
64
- PLAN_TASK_REQUIRED = ("id", "title", "verify")
65
-
66
- # Deprecated Italian frontmatter keys, mapped to the canonical English ones.
67
- LEGACY_KEYS = {"stato": "status", "livello": "level",
68
- "data_inizio": "start_date", "data_fine": "end_date"}
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
-
88
- # ANALYSIS sections: (canonical English heading, legacy Italian heading).
89
- SECURITY_SECTION = ("## Security", "## Sicurezza")
90
- ANALYSIS_SECTIONS = (
91
- ("## Objective", "## Obiettivo"),
92
- ("## Feature Vision", "## Vision della Feature"),
93
- ("## Impact", "## Impatto"),
94
- ("## Action Plan", "## Piano d'Azione"),
95
- ("## Test Strategy", "## Strategia di Test"),
96
- ("## Diary", "## Diario"),
97
- )
21
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
98
22
 
99
23
  try:
100
- sys.stdout.reconfigure(encoding="utf-8", errors="replace")
101
- sys.stderr.reconfigure(encoding="utf-8", errors="replace")
102
- except Exception:
103
- pass
104
-
105
-
106
- # --- orient (SessionStart hook) ---
107
- # Fixed, hard-coded doc set (label, path-relative-to-root). No content- or
108
- # user-derived paths -> no traversal input (P-TM T3); confine_under is
109
- # defense-in-depth. Emitted at session start by the orient subcommand.
110
- ORIENT_DOCS = [
111
- ("Reading guide (README)", "ai_docs/README.md"),
112
- ("Canonical manifest (INDEX)", "ai_docs/INDEX.md"),
113
- ("Guide router (when-to-consult)", "ai_docs/reference/INDEX.md"),
114
- ("Last session handoff", "ai_docs/audit/handoff.md"),
115
- ]
116
- ORIENT_PER_DOC_CHARS = 6000 # per-doc truncation
117
- ORIENT_MAX_TOTAL_CHARS = 16000 # total ingestion cap (P-TM T2); tunable
118
-
119
-
120
- # ----------------------------------------------------------------- utilities
121
-
122
- def utc_now_iso():
123
- return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
124
-
125
-
126
- def find_project_root(start=None):
127
- cur = Path(start or os.getcwd()).resolve()
128
- for p in [cur] + list(cur.parents):
129
- if (p / "ai_docs").is_dir():
130
- return p
131
- return cur
132
-
133
-
134
- def require_ai_docs(root, command):
135
- """Fail fast when ai_docs/ is missing: prevents silently creating a second
136
- documentation root in the wrong working directory."""
137
- if not (root / "ai_docs").is_dir():
138
- print(f"[ERROR] {root / 'ai_docs'} not found: refusing to run '{command}' here. "
139
- "Run agentic-sdlc-init first, or pass --root <project_root>.")
140
- return False
141
- return True
142
-
143
-
144
- def confine_under(base, rel):
145
- """Fail-closed path confinement: resolve `rel` under `base` and require the
146
- result to stay inside `base`. Returns None (reject) if `rel` is absolute,
147
- contains a '..' part, or resolves outside `base` (including an OSError
148
- during resolution, e.g. an unresolvable/reparse-point path on Windows).
149
- Single source for path confinement (T2/T3): reused by check_kb_collisions'
150
- `overrides:` check and cmd_validate's `distilled_from` check, and by the
151
- new `plan` command's paths/consumes/produces/guides confinement."""
152
- p = Path(rel)
153
- if p.is_absolute() or ".." in p.parts:
154
- return None
155
- try:
156
- t = (base / rel).resolve()
157
- t.relative_to(base.resolve())
158
- return t
159
- except (ValueError, OSError):
160
- return None
161
-
162
-
163
- def read_text(path):
164
- # utf-8-sig: strips a leading BOM (files authored on Windows) so the
165
- # frontmatter '---' on line 0 stays recognizable; reads plain utf-8 otherwise.
166
- return path.read_text(encoding="utf-8-sig", errors="replace")
167
-
168
-
169
- def sha256_file(path):
170
- # CRLF->LF before hashing: a Windows checkout with core.autocrlf=true
171
- # rewrites snapshot files, and a raw-byte hash would flag every guide
172
- # [stale] on a fresh clone. Recorded hashes are LF-based, so normalizing
173
- # maps CRLF copies back to the same digest.
174
- h = hashlib.sha256()
175
- h.update(path.read_bytes().replace(b"\r\n", b"\n"))
176
- return h.hexdigest()
177
-
178
-
179
- def parse_iso(value):
180
- if not value:
181
- return None
182
- try:
183
- dt = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
184
- if dt.tzinfo is None:
185
- dt = dt.replace(tzinfo=timezone.utc)
186
- return dt
187
- except ValueError:
188
- return None
189
-
190
-
191
- def norm_text(s):
192
- return "\n".join(line.rstrip() for line in s.strip().splitlines())
193
-
194
-
195
- def load_frontmatter(lines):
196
- meta = {}
197
- if not lines or lines[0].strip() != "---":
198
- return meta
199
- for line in lines[1:60]:
200
- if line.strip() == "---":
201
- break
202
- m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", line)
203
- if m:
204
- meta[m.group(1).strip().lower()] = m.group(2).strip()
205
- # Legacy Italian keys: accepted, normalized to canonical English (deprecated).
206
- for legacy, canon in LEGACY_KEYS.items():
207
- if legacy in meta and canon not in meta:
208
- meta[canon] = meta[legacy]
209
- return meta
210
-
211
-
212
- def is_shadow(path, first_line):
213
- """A shadow mirror of a devPNT-governed document, not an authoritative ANALYSIS.
214
- Recognized by filename (SHADOW_*) or by the marker comment on the FIRST line
215
- (legacy shadows saved under an ANALYSIS_* name)."""
216
- return path.name.startswith("SHADOW") or first_line.lstrip().startswith("<!-- SHADOW")
217
-
218
-
219
- def list_analyses(root):
220
- """Returns [(path, frontmatter, text)] for the ANALYSIS_*.md files (shadows excluded)."""
221
- sol = root / "ai_docs" / "solutions"
222
- out = []
223
- if not sol.is_dir():
224
- return out
225
- for p in sorted(sol.glob("ANALYSIS_*.md")):
226
- text = read_text(p)
227
- first_line = text.splitlines()[0] if text else ""
228
- if is_shadow(p, first_line):
229
- continue
230
- out.append((p, load_frontmatter(text.splitlines()), text))
231
- return out
232
-
233
-
234
- def has_etdd_shadow(root):
235
- """True if an E-TDD shadow exported from devPNT exists in solutions/.
236
- In Hybrid mode the approved E-TDD (exported BEFORE implementation) is the
237
- design authorization that replaces the IN_PROGRESS ANALYSIS."""
238
- sol = root / "ai_docs" / "solutions"
239
- if not sol.is_dir():
240
- return False
241
- return any("tdd" in p.name.lower() for p in sol.glob("SHADOW_*.md"))
242
-
243
-
244
- def iter_files(target):
245
- if target.is_file():
246
- yield target
247
- return
248
- for dirpath, dirnames, filenames in os.walk(target):
249
- dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".")]
250
- for name in filenames:
251
- yield Path(dirpath) / name
252
-
253
-
254
- # ---------------------------------------------------------------------- git
255
-
256
- def git_available(root):
257
- try:
258
- r = subprocess.run(["git", "rev-parse", "--is-inside-work-tree"],
259
- cwd=str(root), capture_output=True, text=True, timeout=10)
260
- return r.returncode == 0 and r.stdout.strip() == "true"
261
- except Exception:
262
- return False
263
-
264
-
265
- def git_head(root):
266
- try:
267
- r = subprocess.run(["git", "rev-parse", "--short=12", "HEAD"],
268
- cwd=str(root), capture_output=True, text=True, timeout=10)
269
- return r.stdout.strip() if r.returncode == 0 else ""
270
- except Exception:
271
- return ""
272
-
273
-
274
- def git_has_changes(root, rel_path):
275
- """True if there are tracked/untracked changes under rel_path."""
276
- try:
277
- rel = rel_path.replace("\\", "/")
278
- r = subprocess.run(["git", "status", "--porcelain", "--", rel],
279
- cwd=str(root), capture_output=True, text=True, timeout=30)
280
- return r.returncode == 0 and bool(r.stdout.strip())
281
- except Exception:
282
- return False
283
-
284
-
285
- def git_changed_since(root, ref, rel_path):
286
- """Files changed (tracked + untracked) under rel_path since ref. None if ref unresolvable."""
287
- try:
288
- r = subprocess.run(["git", "diff", "--name-only", ref, "--", rel_path],
289
- cwd=str(root), capture_output=True, text=True, timeout=30)
290
- if r.returncode != 0:
291
- return None
292
- changed = [l.strip() for l in r.stdout.splitlines() if l.strip()]
293
- r2 = subprocess.run(["git", "ls-files", "--others", "--exclude-standard", "--", rel_path],
294
- cwd=str(root), capture_output=True, text=True, timeout=30)
295
- if r2.returncode == 0:
296
- changed += [l.strip() for l in r2.stdout.splitlines() if l.strip()]
297
- return sorted(set(changed))
298
- except Exception:
299
- return None
300
-
301
-
302
- # -------------------------------------------------------------------- index
303
-
304
- def build_index(root):
305
- rows = []
306
- for p, meta, _ in list_analyses(root):
307
- rows.append((
308
- meta.get("id", "?"),
309
- meta.get("feature", p.stem.replace("ANALYSIS_", "")),
310
- meta.get("level", ""),
311
- meta.get("status", "?"),
312
- meta.get("start_date", ""),
313
- meta.get("end_date", ""),
314
- "solutions/" + p.name,
315
- ))
316
- rows.sort(key=lambda r: r[0])
317
- lines = [INDEX_HEADER,
318
- "# Feature History (generated)",
319
- "",
320
- "| ID | Feature | Level | Status | Started | Finished | Doc |",
321
- "|---|---|---|---|---|---|---|"]
322
- for r in rows:
323
- lines.append("| " + " | ".join(r) + " |")
324
- return "\n".join(lines) + "\n"
325
-
326
-
327
- # "Status:"/"Stato:" line in the body (with or without ** **), prefix before the description
328
- _STATUS_LINE = re.compile(r"^\**\s*(?:status|stato)\s*\**\s*:\s*\**\s*([A-Za-z][\w-]*)", re.I)
329
- # pure metadata lines to skip when picking the fallback description
330
- _META_LINE = re.compile(r"^\**\s*(date|data|task ref|version|versione|owner|autore|branch|agente|agent|created|creato|updated|aggiornato)\b", re.I)
331
-
332
-
333
- def extract_doc_meta(path):
334
- """(title, description, status, supersedes) of a canonical doc.
335
-
336
- Recognizes TWO header conventions: the YAML-lite frontmatter
337
- (description/status/supersedes/title) and the in-body `**Status:** X`
338
- line (used by ADRs and legacy docs). As a fallback it derives the title
339
- from the first '# H1' and the description from the first prose line,
340
- skipping metadata lines.
341
- """
342
- text = read_text(path)
343
- lines = text.splitlines()
344
- meta = load_frontmatter(lines)
345
- body = lines
346
- if lines and lines[0].strip() == "---":
347
- for i in range(1, min(len(lines), 60)):
348
- if lines[i].strip() == "---":
349
- body = lines[i + 1:]
350
- break
351
-
352
- title = meta.get("title", "")
353
- if not title:
354
- for line in body:
355
- m = re.match(r"^#\s+(.*)$", line)
356
- if m:
357
- title = m.group(1).strip()
358
- break
359
- title = title or path.stem
360
-
361
- status = meta.get("status", "").upper()
362
- if not status:
363
- for line in body[:25]:
364
- m = _STATUS_LINE.match(line.strip())
365
- if m:
366
- status = m.group(1).upper()
367
- break
368
-
369
- desc = meta.get("description", "")
370
- if not desc:
371
- in_comment = False
372
- for line in body:
373
- s = line.strip()
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)):
398
- continue
399
- if s.startswith(">"):
400
- s = s.lstrip(">").strip()
401
- m = _STATUS_LINE.match(s)
402
- if m:
403
- # "Status: X — description": keep the part after the status; if empty, skip
404
- rest = s[m.end():].strip(" *—–-:.")
405
- if not rest:
406
- continue
407
- s = rest
408
- if s:
409
- desc = s
410
- break
411
- desc = re.sub(r"\s+", " ", desc).strip()
412
- if len(desc) > 160:
413
- desc = desc[:157].rstrip() + "..."
414
- return title, desc, status, meta.get("supersedes", "").strip()
415
-
416
-
417
- def list_canonical_docs(root):
418
- """[(rel_to_ai_docs, path, (title, desc, status, supersedes))] for canonical docs."""
419
- ai = root / "ai_docs"
420
- out = []
421
- for d in MANIFEST_DIRS:
422
- base = ai / d
423
- if not base.is_dir():
424
- continue
425
- for p in sorted(base.rglob("*.md")):
426
- rel_parts = p.relative_to(base).parts
427
- if any(part.startswith(".") for part in rel_parts[:-1]):
428
- continue # dot-subdirs (e.g. reference/.sources/) are never canonical
429
- if p.name in GENERATED_DOCS or p.name == "README.md":
430
- continue
431
- out.append((p.relative_to(ai).as_posix(), p, extract_doc_meta(p)))
432
- return out
433
-
434
-
435
- def build_manifest(root):
436
- docs = list_canonical_docs(root)
437
- lines = [MANIFEST_HEADER,
438
- "# `ai_docs/` document index (generated)",
439
- "",
440
- "Complete manifest of the canonical documents. For the reading priority",
441
- "(must-reads) see the hand-curated `README.md`. The ANALYSIS history is in",
442
- "`strategic/features_history.md`. `audit/` and `solutions/` are discovery-by-grep,",
443
- "not manifested here."]
444
- by_dir = {}
445
- for rel, _, meta in docs:
446
- by_dir.setdefault(rel.split("/", 1)[0], []).append((rel, meta))
447
- for top in MANIFEST_DIRS:
448
- rows = by_dir.get(top)
449
- if not rows:
450
- continue
451
- lines += ["", f"## {top}/", "",
452
- "| Document | Status | Description |", "|---|---|---|"]
453
- for rel, (title, desc, status, _sup) in rows:
454
- d = (desc or title).replace("|", "\\|")
455
- lines.append(f"| `{rel}` | {status or '-'} | {d} |")
456
- return "\n".join(lines).rstrip() + "\n"
457
-
458
-
459
- def list_guides(root):
460
- """[(rel_to_ai_docs, path, meta, text)] for ai_docs/reference/GUIDE_*.md."""
461
- ref = root / "ai_docs" / "reference"
462
- out = []
463
- if not ref.is_dir():
464
- return out
465
- for p in sorted(ref.glob("GUIDE_*.md")):
466
- text = read_text(p)
467
- out.append((p.relative_to(root / "ai_docs").as_posix(), p,
468
- load_frontmatter(text.splitlines()), text))
469
- return out
470
-
471
-
472
- def check_kb_collisions(root, project_guides, errors, warnings):
473
- """Cross-root awareness (unit 2): project-wins precedence, declared via 'overrides:'."""
474
- kb_root = DEFAULT_KB_ROOT
475
- kb_ref = (kb_root / "ai_docs" / "reference")
476
- try:
477
- if root.resolve() == kb_root.resolve():
478
- return # validating the KB itself: no self-comparison
479
- except OSError:
480
- return
481
- if not kb_ref.is_dir():
482
- return # no KB on this machine: zero behavior change
483
- kb_names = {p.name for _, p, _, _ in list_guides(kb_root)}
484
- for rel, p, meta, _ in project_guides:
485
- ov = (meta.get("overrides") or "").strip()
486
- if ov:
487
- # T6: untrusted cross-root pointer — distilled_from parity, fail closed
488
- target = confine_under(kb_ref, ov)
489
- if target is None:
490
- errors.append(f"{rel}: overrides '{ov}' is absolute, contains '..', or escapes the KB "
491
- "reference dir — rejected (fail closed)")
492
- continue
493
- if not target.is_file():
494
- warnings.append(f"{rel}: overrides target '{ov}' not found in KB ({kb_ref})")
495
- if p.name in kb_names and ov != p.name:
496
- warnings.append(f"{rel}: undeclared collision with KB guide '{p.name}' (project wins) — declare overrides: {p.name}")
497
-
498
-
499
- def build_guide_index(root):
500
- lines = [GUIDE_INDEX_HEADER,
501
- "# Operative guides (generated router)",
502
- "",
503
- "One row per guide. `description` is the when-to-consult line; provenance",
504
- "shows what the guide was distilled from. Freshness: run `sdlc_check.py stale`.",
505
- "",
506
- "| Guide | Status | When to consult | Source | Source version |",
507
- "|---|---|---|---|---|"]
508
- for rel, p, meta, _ in list_guides(root):
509
- lines.append("| `{}` | {} | {} | {} | {} |".format(
510
- p.name, meta.get("status", "-") or "-",
511
- (meta.get("description", "") or "-").replace("|", "\\|"),
512
- (meta.get("source", "") or "-").replace("|", "\\|"),
513
- meta.get("source_version", "") or "-"))
514
- return "\n".join(lines) + "\n"
515
-
516
-
517
- def cmd_index(root):
518
- if not require_ai_docs(root, "index"):
519
- return 1
520
- hist = root / "ai_docs" / "strategic" / "features_history.md"
521
- hist.parent.mkdir(parents=True, exist_ok=True)
522
- hist.write_text(build_index(root), encoding="utf-8")
523
- print(f"[ok] ANALYSIS index regenerated: {hist}")
524
- # INDEX.md only if canonical docs exist: no empty manifest on minimal projects
525
- if list_canonical_docs(root):
526
- manifest = root / "ai_docs" / "INDEX.md"
527
- manifest.write_text(build_manifest(root), encoding="utf-8")
528
- print(f"[ok] document manifest regenerated: {manifest}")
529
- else:
530
- print("[info] no canonical documents: INDEX.md not generated")
531
- guides = list_guides(root)
532
- gidx = root / "ai_docs" / "reference" / "INDEX.md"
533
- if guides:
534
- gidx.write_text(build_guide_index(root), encoding="utf-8")
535
- print(f"[ok] guide router regenerated: {gidx}")
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}")
551
- return 0
552
-
553
-
554
- # ----------------------------------------------------------------- validate
555
-
556
- def has_section(text, aliases):
557
- return any(a in text for a in aliases)
558
-
559
-
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 = [], [], []
775
- ai = root / "ai_docs"
776
- if not ai.is_dir():
777
- if strict:
778
- print(f"[ERROR] {ai} does not exist: nothing to validate. In --strict mode this "
779
- "fails so a wrong working directory cannot produce a green pipeline.")
780
- return 1
781
- print(f"[info] {ai} does not exist: nothing to validate (project without SDLC docs).")
782
- return 0
783
-
784
- # Vision: presence and declared state
785
- for name in VISION_FILES:
786
- f = ai / "vision" / name
787
- if not f.is_file():
788
- warnings.append(f"vision/{name} missing")
789
- continue
790
- head = "\n".join(read_text(f).splitlines()[:12])
791
- m = re.search(r"(?:Status|Stato):\s*(DRAFT|APPROVED)", head)
792
- if not m:
793
- errors.append(f"vision/{name}: missing 'Status: DRAFT|APPROVED' in the first lines")
794
- elif m.group(1) == "DRAFT":
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")
801
-
802
- # ANALYSIS: frontmatter and mandatory sections
803
- seen_ids = {}
804
- analyses = list_analyses(root)
805
- for p, meta, text in analyses:
806
- rel = "solutions/" + p.name
807
- if not meta:
808
- errors.append(f"{rel}: frontmatter missing")
809
- continue
810
- fid = meta.get("id")
811
- if not fid:
812
- errors.append(f"{rel}: 'id' field missing")
813
- elif fid in seen_ids:
814
- errors.append(f"{rel}: id '{fid}' duplicated (already used in {seen_ids[fid]})")
815
- else:
816
- seen_ids[fid] = rel
817
- status = meta.get("status", "")
818
- if status not in VALID_STATES:
819
- errors.append(f"{rel}: status '{status}' not valid ({'/'.join(sorted(VALID_STATES))})")
820
- if not meta.get("start_date"):
821
- errors.append(f"{rel}: 'start_date' missing")
822
- if status == "COMPLETED" and not meta.get("end_date"):
823
- errors.append(f"{rel}: COMPLETED without 'end_date'")
824
- level = meta.get("level")
825
- if level and level.upper() not in VALID_LEVELS:
826
- warnings.append(f"{rel}: level '{level}' not recognized ({'/'.join(sorted(VALID_LEVELS))})")
827
- if not has_section(text, SECURITY_SECTION):
828
- errors.append(f"{rel}: section '## Security and Threat Model' missing (mandatory)")
829
- for en, it in ANALYSIS_SECTIONS:
830
- if not has_section(text, (en, it)):
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")
854
-
855
- # Generated index aligned
856
- hist = ai / "strategic" / "features_history.md"
857
- if analyses:
858
- if not hist.is_file():
859
- errors.append("strategic/features_history.md missing: run 'sdlc_check.py index'")
860
- elif norm_text(read_text(hist)) != norm_text(build_index(root)):
861
- errors.append("strategic/features_history.md not aligned with the ANALYSIS files: run 'sdlc_check.py index'")
862
-
863
- # Canonical document manifest aligned (Poka-Yoke: unindexed file = dirty closure)
864
- docs = list_canonical_docs(root)
865
- manifest = ai / "INDEX.md"
866
- if docs:
867
- if not manifest.is_file():
868
- errors.append("ai_docs/INDEX.md missing: run 'sdlc_check.py index'")
869
- elif norm_text(read_text(manifest)) != norm_text(build_manifest(root)):
870
- errors.append("ai_docs/INDEX.md not aligned with the canonical documents: run 'sdlc_check.py index'")
871
-
872
- # Canonical document lifecycle: declared status + supersedes coherence
873
- canon_status = {rel: meta[2] for rel, _, meta in docs}
874
- for rel, _, (title, desc, status, supersedes) in docs:
875
- if not status:
876
- warnings.append(f"{rel}: missing 'status:' in the header (CURRENT/SUPERSEDED/DRAFT/DEPRECATED)")
877
- elif status not in CANONICAL_STATES:
878
- warnings.append(f"{rel}: status '{status}' not recognized ({'/'.join(sorted(CANONICAL_STATES))})")
879
- if supersedes:
880
- base = os.path.basename(supersedes)
881
- for other, ost in canon_status.items():
882
- if (other == supersedes or other.endswith("/" + supersedes)
883
- or os.path.basename(other) == base) and ost == "CURRENT":
884
- warnings.append(f"{other}: still CURRENT but superseded by {rel} (set status: SUPERSEDED)")
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
-
920
- # Guide checks (ai_docs/reference/GUIDE_*.md): structure only — freshness is stale's job
921
- guides = list_guides(root)
922
- for rel, p, meta, text in guides:
923
- missing = [k for k in GUIDE_PROVENANCE_KEYS if not meta.get(k)]
924
- if missing:
925
- warnings.append(f"{rel}: guide missing provenance key(s): {', '.join(missing)}")
926
- # (b) per-section fidelity markers: every '## ' section body must carry a marker
927
- body = text.split("---", 2)[-1]
928
- sections = re.split(r"^##\s+", body, flags=re.M)[1:]
929
- unmarked = [s.splitlines()[0].strip() for s in sections if not GUIDE_MARKER_RE.search(s)]
930
- if unmarked:
931
- warnings.append(f"{rel}: section(s) without [source: ...] / [not covered by source] marker: "
932
- + "; ".join(unmarked[:5]))
933
- # (c) distilled_from confinement — fail closed (P-TM T6, distilled_from vector)
934
- df = meta.get("distilled_from", "")
935
- if df and confine_under(root, df) is None:
936
- errors.append(f"{rel}: distilled_from '{df}' is absolute, contains '..', or resolves "
937
- "outside the project root: rejected")
938
- check_kb_collisions(root, guides, errors, warnings)
939
- # guide-router alignment (mirror of the root-manifest check)
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'")
946
- if guides:
947
- if not gidx.is_file():
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'")
955
- elif norm_text(read_text(gidx)) != norm_text(build_guide_index(root)):
956
- errors.append("ai_docs/reference/INDEX.md not aligned with the guides: run 'sdlc_check.py index'")
957
-
958
- # Handoff: header and freshness
959
- hand = ai / "audit" / "handoff.md"
960
- if hand.is_file():
961
- m = re.search(r"(?:Date|Data):\s*(\d{4}-\d{2}-\d{2})", read_text(hand))
962
- if not m:
963
- warnings.append("audit/handoff.md without a 'Date: YYYY-MM-DD' header")
964
- else:
965
- try:
966
- stamp = datetime.strptime(m.group(1), "%Y-%m-%d").replace(tzinfo=timezone.utc)
967
- age = (datetime.now(timezone.utc) - stamp).days
968
- if age > 14:
969
- warnings.append(f"audit/handoff.md is {age} days old: treat it as history, not current state")
970
- except ValueError:
971
- warnings.append("audit/handoff.md: date not parseable")
972
-
973
- for a in advisories:
974
- print(f"[note] {a}")
975
- for w in warnings:
976
- print(f"[warn] {w}")
977
- for e in errors:
978
- print(f"[ERROR] {e}")
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.")
983
- if strict and warnings and not errors:
984
- print("[strict] warnings are failures in --strict mode.")
985
- return 1 if errors or (strict and warnings) else 0
986
-
987
-
988
- # ------------------------------------------------------------- audit_plan
989
-
990
- def parse_audit_plan(root):
991
- f = root / "ai_docs" / "audit" / "audit_plan.md"
992
- rows, lines = [], []
993
- if f.is_file():
994
- lines = read_text(f).splitlines()
995
- for i, line in enumerate(lines):
996
- if not line.strip().startswith("|"):
997
- continue
998
- cells = [c.strip() for c in line.strip().strip("|").split("|")]
999
- if len(cells) < 2:
1000
- continue
1001
- if cells[0].lower() in ("path", "percorso") or set(cells[0]) <= set("-: "):
1002
- continue
1003
- rows.append({
1004
- "line": i,
1005
- "path": cells[0],
1006
- "status": cells[1].upper(),
1007
- "ref": cells[2] if len(cells) > 2 else "",
1008
- "note": cells[3] if len(cells) > 3 else "",
1009
- })
1010
- return f, lines, rows
1011
-
1012
-
1013
- def cmd_stale(root, hybrid=False):
1014
- rc = 0
1015
- # --- guide freshness (source_hash vs snapshot) — runs in EVERY mode
1016
- drifted = []
1017
- for rel, p, meta, _ in list_guides(root):
1018
- df, rec = meta.get("distilled_from", ""), meta.get("source_hash", "")
1019
- if not df or not rec:
1020
- continue # structure problems are validate's job
1021
- src = root / df
1022
- if not src.is_file():
1023
- print(f"[warn] {rel}: distilled_from '{df}' not found — snapshot missing")
1024
- rc = 1
1025
- continue
1026
- if sha256_file(src) != rec:
1027
- drifted.append((rel, df))
1028
- for rel, df in drifted:
1029
- print(f"[stale] {rel}: source snapshot '{df}' changed since distillation — regenerate the guide")
1030
- if drifted:
1031
- rc = 1
1032
- # --- audit-plan staleness — delegated to devPNT/KL in hybrid
1033
- if hybrid:
1034
- print("[info] hybrid mode: audit-plan staleness is delegated to devPNT/KL, skipping.")
1035
- return rc # was: implicit skip-all; guide rc survives
1036
- f, _, rows = parse_audit_plan(root)
1037
- if not rows:
1038
- print(f"[info] no rows in {f}: nothing to check "
1039
- "(audit not initialized, or Hybrid mode where mapping is delegated to devPNT).")
1040
- return rc # was: return 0 — MUST carry guide rc
1041
- use_git = git_available(root)
1042
- stale = []
1043
- for row in rows:
1044
- if row["status"] != "ANALYZED":
1045
- continue
1046
- rel, ref = row["path"], row["ref"]
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
1056
- if not target.exists():
1057
- print(f"[warn] {rel}: path does not exist")
1058
- continue
1059
- changed = []
1060
- if use_git and re.fullmatch(r"[0-9a-fA-F]{7,40}", ref or ""):
1061
- res = git_changed_since(root, ref, rel.replace("\\", "/"))
1062
- if res is None:
1063
- print(f"[warn] {rel}: git ref '{ref}' unresolvable, cannot evaluate")
1064
- continue
1065
- changed = res
1066
- else:
1067
- ts = parse_iso(ref)
1068
- if ts is None:
1069
- print(f"[warn] {rel}: reference '{ref}' not parseable (neither git hash nor ISO UTC)")
1070
- continue
1071
- for fp in iter_files(target):
1072
- mtime = datetime.fromtimestamp(fp.stat().st_mtime, tz=timezone.utc)
1073
- if mtime > ts + MTIME_GRACE:
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)
1079
- if changed:
1080
- stale.append((rel, changed))
1081
-
1082
- if not stale:
1083
- print("[ok] no analyzed area was modified after its last recorded analysis.")
1084
- return rc # was: return 0 — MUST carry guide rc
1085
- print("Areas modified after the last recorded analysis:")
1086
- for rel, changed in stale:
1087
- print(f" {rel} ({len(changed)} files)")
1088
- for c in changed[:10]:
1089
- print(f" - {c}")
1090
- if len(changed) > 10:
1091
- print(f" ... and {len(changed) - 10} more")
1092
- print("\nAfter re-analyzing, record it with: sdlc_check.py mark <path>")
1093
- return 1 # stale areas dominate: rc already implied
1094
-
1095
-
1096
- def cmd_mark(root, paths):
1097
- if not require_ai_docs(root, "mark"):
1098
- return 1
1099
- f, lines, rows = parse_audit_plan(root)
1100
- use_git_ref = git_available(root) and not any(
1101
- git_has_changes(root, raw.replace("\\", "/").rstrip("/")) for raw in paths
1102
- )
1103
- ref = git_head(root) if use_git_ref else utc_now_iso()
1104
- by_path = {r["path"].replace("\\", "/").rstrip("/"): r for r in rows}
1105
-
1106
- if not lines:
1107
- lines = ["# Audit Plan", "",
1108
- "| Path | Status | Reference | Notes |",
1109
- "|---|---|---|---|"]
1110
- rows = []
1111
-
1112
- def row_text(path, note):
1113
- return f"| {path} | ANALYZED | {ref} | {note} |"
1114
-
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 = []
1118
- for raw in paths:
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 "")
1130
- existing = by_path.get(key)
1131
- if existing:
1132
- lines[existing["line"]] = row_text(existing["path"], existing["note"])
1133
- print(f"[ok] {existing['path']} -> ANALYZED ({ref})")
1134
- else:
1135
- appended.append(row_text(display, ""))
1136
- print(f"[ok] {display} added as ANALYZED ({ref})")
1137
-
1138
- if appended:
1139
- insert_at = (max(r["line"] for r in rows) + 1) if rows else len(lines)
1140
- lines[insert_at:insert_at] = appended
1141
-
1142
- f.parent.mkdir(parents=True, exist_ok=True)
1143
- f.write_text("\n".join(lines) + "\n", encoding="utf-8")
1144
- return 0
1145
-
1146
-
1147
- def cmd_check(root, strict=False, hybrid=False):
1148
- print("===== validate =====")
1149
- rc_v = cmd_validate(root, strict=strict, hybrid=hybrid)
1150
- print("\n===== stale =====")
1151
- rc_s = cmd_stale(root, hybrid=hybrid)
1152
- print(f"\ncheck: {'CLEAN' if not (rc_v or rc_s) else 'NOT CLEAN'} "
1153
- f"(validate rc={rc_v}, stale rc={rc_s})")
1154
- return 1 if (rc_v or rc_s) else 0
1155
-
1156
-
1157
- # --------------------------------------------------------------------- gate
1158
-
1159
- def cmd_gate(args):
1160
- file_path = args.file or ""
1161
- if args.hook:
1162
- try:
1163
- # bytes -> utf-8-sig: the hook payload is UTF-8 JSON regardless of the
1164
- # console code page; '-sig' strips the BOM (PowerShell pipes)
1165
- raw = sys.stdin.buffer.read().decode("utf-8-sig", errors="replace")
1166
- payload = json.loads(raw)
1167
- file_path = (payload.get("tool_input") or {}).get("file_path") or ""
1168
- except Exception:
1169
- return 0 # unparseable input: do not block
1170
- if not file_path:
1171
- return 0
1172
- root = Path(args.root).resolve() if args.root else find_project_root()
1173
- try:
1174
- rel = str(Path(file_path).resolve().relative_to(root)).replace("\\", "/")
1175
- except ValueError:
1176
- return 0 # outside the project: not this gate's concern
1177
- if rel.startswith(("ai_docs/", "tests/", "test/")):
1178
- return 0
1179
- protected = [p.strip().replace("\\", "/").rstrip("/")
1180
- for p in (args.protected or "").split(";") if p.strip()]
1181
- if not protected:
1182
- return 0
1183
- if not any(rel == p or rel.startswith(p + "/") for p in protected):
1184
- return 0
1185
- for _, meta, _ in list_analyses(root):
1186
- if meta.get("status") == "IN_PROGRESS":
1187
- return 0
1188
- if args.hybrid and has_etdd_shadow(root):
1189
- return 0 # Hybrid design gate: an approved E-TDD shadow authorizes the change
1190
- if args.hybrid:
1191
- sys.stderr.write(
1192
- f"[sdlc gate] '{rel}' is on a protected path but no E-TDD shadow "
1193
- "(solutions/SHADOW_*tdd*.md) exists and no ANALYSIS_*.md is IN_PROGRESS. "
1194
- "In Hybrid mode, export the approved E-TDD shadow from devPNT before implementing.\n")
1195
- return 2
24
+ import sdlc_core
25
+ except ImportError as exc: # pragma: no cover - exercised by TS12, not by unit tests
1196
26
  sys.stderr.write(
1197
- f"[sdlc gate] '{rel}' is on a protected path but no ANALYSIS_*.md is IN_PROGRESS. "
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")
1200
- return 2
1201
-
1202
-
1203
- # --------------------------------------------------------------------- plan
1204
- # Subagent Execution (Feature A). Zero-execution surface: this section and
1205
- # everything it calls MUST NOT spawn a process (no subprocess/os.system/eval/
1206
- # exec, no git_* helper). It validates a PLAN_[feature].md and prints a task
1207
- # brief as text; the orchestrator (dispatch.md) is the sole executor.
1208
-
1209
- _PLAN_JSON_RE = re.compile(r"```json\s*\n(.*?)```", re.DOTALL)
1210
-
1211
-
1212
- def extract_plan_json(text):
1213
- """Extract the first fenced ```json block from a PLAN_[feature].md body.
1214
- Returns (data, "") on success, or (None, reason) on any failure. Never
1215
- raises: a malformed or missing block is a validation failure, not a crash."""
1216
- m = _PLAN_JSON_RE.search(text or "")
1217
- if not m:
1218
- return None, "no fenced ```json block found in the plan file"
1219
- try:
1220
- data = json.loads(m.group(1))
1221
- except (ValueError, TypeError) as e:
1222
- return None, f"malformed JSON in the plan block: {e}"
1223
- if not isinstance(data, dict):
1224
- return None, "plan JSON block must be a JSON object"
1225
- return data, ""
1226
-
1227
-
1228
- def load_ledger(path):
1229
- """Read the sidecar ledger {"<task_id>": {"status", "verify_result",
1230
- "timestamp"}}. Absent file -> ({}, ""). Malformed/unreadable -> ({}, reason).
1231
- Never raises, never hangs: the ledger is untrusted state read on every call."""
1232
- if not path.is_file():
1233
- return {}, ""
1234
- try:
1235
- raw = read_text(path)
1236
- data = json.loads(raw)
1237
- except (ValueError, TypeError, OSError) as e:
1238
- return {}, f"ledger '{path}' unreadable/malformed, treating as empty: {e}"
1239
- if not isinstance(data, dict):
1240
- return {}, f"ledger '{path}' is not a JSON object, treating as empty"
1241
- return data, ""
1242
-
1243
-
1244
- def _confine_or_reject(base, rel, label, rel_label, errors):
1245
- t = confine_under(base, rel)
1246
- if t is None:
1247
- errors.append(f"{rel_label}: {label} '{rel}' is absolute, contains '..', or escapes "
1248
- f"'{base}' — rejected (fail closed)")
1249
- return t
1250
-
1251
-
1252
- def _validate_plan_tasks(root, data, rel_label, errors, warnings):
1253
- """Shared core of `plan validate`/`plan brief`: schema + confinement checks.
1254
- Returns the task list (possibly empty) on success; errors/warnings are
1255
- appended in place. Callers decide the exit code."""
1256
- tasks = data.get("tasks")
1257
- if not isinstance(tasks, list) or not tasks:
1258
- errors.append(f"{rel_label}: 'tasks' must be a non-empty JSON array")
1259
- return []
1260
- ref_dir = root / "ai_docs" / "reference"
1261
- kb_ref = DEFAULT_KB_ROOT / "ai_docs" / "reference"
1262
- seen_ids = set()
1263
- for i, task in enumerate(tasks):
1264
- loc = f"{rel_label}: task[{i}]"
1265
- if not isinstance(task, dict):
1266
- errors.append(f"{loc}: not a JSON object")
1267
- continue
1268
- missing = [k for k in PLAN_TASK_REQUIRED if not task.get(k)]
1269
- if missing:
1270
- errors.append(f"{loc}: missing required field(s): {', '.join(missing)}")
1271
- if not task.get("paths") and not task.get("produces"):
1272
- errors.append(f"{loc}: must declare at least one of 'paths'/'produces'")
1273
- tid = task.get("id")
1274
- if tid:
1275
- if tid in seen_ids:
1276
- errors.append(f"{loc}: duplicate task id '{tid}'")
1277
- seen_ids.add(tid)
1278
- for key in ("paths", "consumes", "produces"):
1279
- for p in (task.get(key) or []):
1280
- _confine_or_reject(root, p, key, loc, errors)
1281
- for g in (task.get("guides") or []):
1282
- in_project = confine_under(ref_dir, g)
1283
- in_kb = confine_under(kb_ref, g)
1284
- if in_project is None and in_kb is None:
1285
- errors.append(f"{loc}: guide '{g}' is not confined under the project reference "
1286
- f"dir ({ref_dir}) or the agent KB reference dir ({kb_ref}) — rejected")
1287
- return tasks
1288
-
1289
-
1290
- def cmd_plan(root, args):
1291
- """Zero-execution: validates/briefs a PLAN_[feature].md. Never spawns a
1292
- process, never calls a git_* helper, never runs the opaque `verify` text —
1293
- it is printed, not executed."""
1294
- plan_path = Path(args.file)
1295
- if not plan_path.is_absolute():
1296
- plan_path = root / plan_path
1297
- if not plan_path.is_file():
1298
- sys.stderr.write(f"[plan] plan file not found: {plan_path}\n")
1299
- return 2
1300
- rel_label = str(plan_path)
1301
- data, reason = extract_plan_json(read_text(plan_path))
1302
- if data is None:
1303
- sys.stderr.write(f"[plan] {rel_label}: {reason}\n")
1304
- return 2
1305
-
1306
- errors, warnings = [], []
1307
- tasks = _validate_plan_tasks(root, data, rel_label, errors, warnings)
1308
-
1309
- ledger_path = plan_path.with_name(plan_path.stem + ".ledger.json")
1310
- ledger, ledger_reason = load_ledger(ledger_path)
1311
- if ledger_reason:
1312
- warnings.append(ledger_reason)
1313
- if not errors:
1314
- task_ids = {t.get("id") for t in tasks if isinstance(t, dict)}
1315
- for lid in ledger:
1316
- if lid not in task_ids:
1317
- warnings.append(f"ledger id '{lid}' not found in {rel_label}: orphaned entry (not fatal)")
1318
-
1319
- for w in warnings:
1320
- sys.stderr.write(f"[warn] {w}\n")
1321
- for e in errors:
1322
- sys.stderr.write(f"[ERROR] {e}\n")
1323
-
1324
- if args.plan_cmd == "validate":
1325
- if errors:
1326
- sys.stderr.write(f"\n[plan] validate: {len(errors)} errors, {len(warnings)} warnings.\n")
1327
- return 2
1328
- print(f"[ok] {rel_label}: plan valid ({len(tasks)} task(s), {len(warnings)} warning(s)).")
1329
- return 0
1330
-
1331
- # brief
1332
- if errors:
1333
- sys.stderr.write(f"\n[plan] brief: plan is invalid, refusing to brief ({len(errors)} errors).\n")
1334
- return 2
1335
- target = None
1336
- for t in tasks:
1337
- if isinstance(t, dict) and t.get("id") == args.task:
1338
- target = t
1339
- break
1340
- if target is None:
1341
- sys.stderr.write(f"[plan] brief: task id '{args.task}' not found in {rel_label}\n")
1342
- return 2
1343
-
1344
- print(f"# Task: {target.get('id')} — {target.get('title', '')}")
1345
- print()
1346
- print("## Task block")
1347
- print(json.dumps(target, indent=2))
1348
- print()
1349
- print("## Produces of prior-order tasks (interfaces)")
1350
- prior_produces = []
1351
- for t in tasks:
1352
- if not isinstance(t, dict):
1353
- continue
1354
- if t.get("id") == target.get("id"):
1355
- break
1356
- prior_produces.extend(t.get("produces") or [])
1357
- if prior_produces:
1358
- for p in prior_produces:
1359
- print(f"- {p}")
1360
- else:
1361
- print("(none)")
1362
- print()
1363
- print("## Guide pointers (paths, not content)")
1364
- guides = target.get("guides") or []
1365
- if guides:
1366
- for g in guides:
1367
- print(f"- {g}")
1368
- else:
1369
- print("(none)")
1370
- print()
1371
- print("## Verify (opaque text — orchestrator runs this out of band, NOT executed here)")
1372
- print(target.get("verify", ""))
1373
- return 0
1374
-
1375
-
1376
- def cmd_orient(args):
1377
- """SessionStart hook: emit a bounded, repo-sourced ai_docs/ orientation to
1378
- stdout and ALWAYS return 0 (fail-open, P-TM T8) -- a session hook must never
1379
- block the session or surface a traceback. Zero-execution (P-TM T1): reads a
1380
- fixed hard-coded doc set, confine_under each (P-TM T3), size-caps the total
1381
- (P-TM T2). No subprocess/eval anywhere in this call graph."""
1382
- try:
1383
- root = Path(args.root).resolve() if getattr(args, "root", None) else find_project_root()
1384
- chunks = []
1385
- total = 0
1386
- truncated = False
1387
- for label, rel in ORIENT_DOCS:
1388
- target = confine_under(root, rel)
1389
- if target is None or not target.is_file():
1390
- continue
1391
- try:
1392
- text = read_text(target)
1393
- except OSError:
1394
- continue
1395
- remaining = ORIENT_MAX_TOTAL_CHARS - total
1396
- if remaining <= 0:
1397
- truncated = True
1398
- break
1399
- text = text[:ORIENT_PER_DOC_CHARS]
1400
- if len(text) > remaining:
1401
- text = text[:remaining]
1402
- truncated = True
1403
- chunks.append((label, text))
1404
- total += len(text)
1405
- if not chunks:
1406
- return 0
1407
- out = ["=== Agentic SDLC -- session orientation (repo-sourced context, not authored instructions) ==="]
1408
- for label, text in chunks:
1409
- out.append(f"\n## {label}\n{text}")
1410
- if truncated:
1411
- out.append("\n[orientation truncated to the size cap -- open the files directly for full content]")
1412
- out.append("\nTriage every request (Rule Zero): L1 trivial - L2 small - L3 significant - Spike. "
1413
- "When in doubt, pick the higher level.")
1414
- if getattr(args, "hybrid", False):
1415
- out.append("\n[devPNT active] Run devpnt_mcp_get_bootstrap for the Master Plan / Knowledge Layer -- "
1416
- "the orientation above is the filesystem layer, not a bootstrap duplicate.")
1417
- print("\n".join(out))
1418
- return 0
1419
- except Exception:
1420
- return 0
1421
-
27
+ "[ERROR] sdlc_check.py cannot find sdlc_core.py next to it: " + str(exc) + "\n"
28
+ " The validator ships as TWO files since the multi-domain core.\n"
29
+ " Copy both, or run sdlc_core.py directly.\n")
30
+ sys.exit(1)
31
+
32
+ # Re-export the core's surface: existing importers (`import sdlc_check as sc`)
33
+ # and the test batteries reach for these names on this module.
34
+ from sdlc_core import * # noqa: F401,F403
35
+ from sdlc_core import _map_refs # noqa: F401 underscore helper used by the batteries
36
+
37
+ # The domain this distribution implements. It does NOT decide any document's
38
+ # owning domain -- that is resolved per project (`default_domain:` in the docs
39
+ # root's README) and per artifact (`domain:`), so the same tree gets the same
40
+ # verdict from every installed distribution. What it decides is which portable
41
+ # checks a document may import here by name; the rest warn as unavailable.
42
+ DOMAIN = "code"
43
+
44
+ sdlc_core.set_entry_point(DOMAIN, provides=("code", "knowledge"))
45
+
46
+ # What this distribution carries. The shared battery reads it; the spine
47
+ # capabilities are not optional, and a shared test refuses a profile that drops one.
48
+ sdlc_core.set_profile(
49
+ skill_name="agentic-sdlc",
50
+ unit_noun="feature",
51
+ support_files=("templates.md", "architect.md", "guides.md", "vision.md", "tdd.md",
52
+ "debugging.md", "elicitation.md", "review.md", "dispatch.md",
53
+ "routing.md", "ENFORCEMENT.md"),
54
+ capabilities=(
55
+ # spine
56
+ "triage", "write_triggers", "workstream_registry", "vision_gate",
57
+ "design_review_gate", "guide_router", "worktree_hygiene",
58
+ # code overlay
59
+ "architect_pass", "comprehension_guides", "tdd", "subagent_dispatch",
60
+ "legacy_narrative_handoff", "question_discipline",
61
+ ),
62
+ design_gate_between=("### 3. Request Analysis", "### 4. Development and Testing"),
63
+ )
1422
64
 
1423
- # --------------------------------------------------------------------- main
1424
65
 
1425
66
  def main(argv=None):
1426
- common = argparse.ArgumentParser(add_help=False)
1427
- common.add_argument("--root", help="project root (default: walk up until ai_docs/ is found)")
1428
-
1429
- strict_opt = argparse.ArgumentParser(add_help=False)
1430
- strict_opt.add_argument("--strict", action="store_true",
1431
- help="fail on warnings and on missing ai_docs/ (for CI)")
1432
-
1433
- hybrid_opt = argparse.ArgumentParser(add_help=False)
1434
- hybrid_opt.add_argument("--hybrid", action="store_true",
1435
- help="Hybrid/devPNT mode: audit-plan staleness is delegated to devPNT/KL; "
1436
- "the gate also unlocks on an E-TDD shadow")
1437
-
1438
- ap = argparse.ArgumentParser(prog="sdlc_check.py",
1439
- description="Mechanical validator for Agentic SDLC")
1440
- sub = ap.add_subparsers(dest="cmd", required=True)
1441
- sub.add_parser("check", parents=[common, strict_opt, hybrid_opt],
1442
- help="closure gate: validate + stale in one command")
1443
- sub.add_parser("validate", parents=[common, strict_opt, hybrid_opt],
1444
- help="verify ai_docs/ coherence")
1445
- sub.add_parser("index", parents=[common], help="regenerate features_history.md + ai_docs/INDEX.md")
1446
- sub.add_parser("stale", parents=[common, hybrid_opt], help="areas modified after the last analysis")
1447
- mp = sub.add_parser("mark", parents=[common], help="record paths as ANALYZED")
1448
- mp.add_argument("paths", nargs="+", help="paths relative to the project root")
1449
- gp = sub.add_parser("gate", parents=[common, hybrid_opt], help="PreToolUse hook (exit 2 = block)")
1450
- gp.add_argument("--hook", action="store_true", help="read the hook JSON payload from stdin")
1451
- gp.add_argument("--file", help="file path to evaluate (alternative to --hook)")
1452
- gp.add_argument("--protected", default="", help="protected prefixes separated by ';' (e.g. \"src/auth;src/crypto\")")
1453
-
1454
- sub.add_parser("orient", parents=[common, hybrid_opt],
1455
- help="SessionStart hook: emit ai_docs/ orientation to stdout (fail-open, zero-execution)")
1456
-
1457
- pp = sub.add_parser("plan", parents=[common],
1458
- help="Subagent Execution: validate/brief a PLAN_[feature].md (zero-execution)")
1459
- pp_sub = pp.add_subparsers(dest="plan_cmd", required=True)
1460
- pv = pp_sub.add_parser("validate", help="schema + confinement + ledger cross-check (exit 2 on error)")
1461
- pv.add_argument("file", help="path to the PLAN_[feature].md file")
1462
- pb = pp_sub.add_parser("brief", help="print a task's brief to stdout (verify text is NOT executed)")
1463
- pb.add_argument("file", help="path to the PLAN_[feature].md file")
1464
- pb.add_argument("--task", required=True, help="task id to brief")
1465
-
1466
- args = ap.parse_args(argv)
1467
- if args.cmd == "gate":
1468
- return cmd_gate(args)
1469
- if args.cmd == "orient":
1470
- return cmd_orient(args)
1471
-
1472
- root = Path(args.root).resolve() if args.root else find_project_root()
1473
- if args.cmd == "check":
1474
- return cmd_check(root, strict=args.strict, hybrid=args.hybrid)
1475
- if args.cmd == "validate":
1476
- return cmd_validate(root, strict=args.strict, hybrid=args.hybrid)
1477
- if args.cmd == "index":
1478
- return cmd_index(root)
1479
- if args.cmd == "stale":
1480
- return cmd_stale(root, hybrid=args.hybrid)
1481
- if args.cmd == "mark":
1482
- return cmd_mark(root, args.paths)
1483
- if args.cmd == "plan":
1484
- return cmd_plan(root, args)
1485
- return 0
67
+ return sdlc_core.main(argv)
1486
68
 
1487
69
 
1488
70
  if __name__ == "__main__":