@antoneeo/agentic-sdlc-skill 1.17.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.
- package/CHANGELOG.md +431 -299
- package/README.md +122 -93
- package/gemini-extension.json +6 -6
- package/package.json +4 -1
- package/scripts/init.js +224 -154
- package/scripts/lib.js +177 -168
- package/skills/agentic-sdlc-skill/ENFORCEMENT.md +11 -5
- package/skills/agentic-sdlc-skill/SKILL.md +19 -5
- package/skills/agentic-sdlc-skill/architect.md +215 -0
- package/skills/agentic-sdlc-skill/elicitation.md +124 -8
- package/skills/agentic-sdlc-skill/guides.md +8 -0
- package/skills/agentic-sdlc-skill/review.md +92 -7
- package/skills/agentic-sdlc-skill/routing.md +100 -0
- package/skills/agentic-sdlc-skill/scripts/sdlc_check.py +52 -1100
- package/skills/agentic-sdlc-skill/scripts/sdlc_core.py +1996 -0
- package/skills/agentic-sdlc-skill/templates.md +93 -2
|
@@ -1,1118 +1,70 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
# -*- coding: utf-8 -*-
|
|
3
|
-
"""
|
|
3
|
+
"""Agentic SDLC — the CODE domain entry point.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
# ANALYSIS sections: (canonical English heading, legacy Italian heading).
|
|
71
|
-
SECURITY_SECTION = ("## Security", "## Sicurezza")
|
|
72
|
-
ANALYSIS_SECTIONS = (
|
|
73
|
-
("## Objective", "## Obiettivo"),
|
|
74
|
-
("## Feature Vision", "## Vision della Feature"),
|
|
75
|
-
("## Impact", "## Impatto"),
|
|
76
|
-
("## Action Plan", "## Piano d'Azione"),
|
|
77
|
-
("## Test Strategy", "## Strategia di Test"),
|
|
78
|
-
("## Diary", "## Diario"),
|
|
79
|
-
)
|
|
21
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
80
22
|
|
|
81
23
|
try:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
except Exception:
|
|
85
|
-
pass
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
# --- orient (SessionStart hook) ---
|
|
89
|
-
# Fixed, hard-coded doc set (label, path-relative-to-root). No content- or
|
|
90
|
-
# user-derived paths -> no traversal input (P-TM T3); confine_under is
|
|
91
|
-
# defense-in-depth. Emitted at session start by the orient subcommand.
|
|
92
|
-
ORIENT_DOCS = [
|
|
93
|
-
("Reading guide (README)", "ai_docs/README.md"),
|
|
94
|
-
("Canonical manifest (INDEX)", "ai_docs/INDEX.md"),
|
|
95
|
-
("Guide router (when-to-consult)", "ai_docs/reference/INDEX.md"),
|
|
96
|
-
("Last session handoff", "ai_docs/audit/handoff.md"),
|
|
97
|
-
]
|
|
98
|
-
ORIENT_PER_DOC_CHARS = 6000 # per-doc truncation
|
|
99
|
-
ORIENT_MAX_TOTAL_CHARS = 16000 # total ingestion cap (P-TM T2); tunable
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
# ----------------------------------------------------------------- utilities
|
|
103
|
-
|
|
104
|
-
def utc_now_iso():
|
|
105
|
-
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def find_project_root(start=None):
|
|
109
|
-
cur = Path(start or os.getcwd()).resolve()
|
|
110
|
-
for p in [cur] + list(cur.parents):
|
|
111
|
-
if (p / "ai_docs").is_dir():
|
|
112
|
-
return p
|
|
113
|
-
return cur
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def require_ai_docs(root, command):
|
|
117
|
-
"""Fail fast when ai_docs/ is missing: prevents silently creating a second
|
|
118
|
-
documentation root in the wrong working directory."""
|
|
119
|
-
if not (root / "ai_docs").is_dir():
|
|
120
|
-
print(f"[ERROR] {root / 'ai_docs'} not found: refusing to run '{command}' here. "
|
|
121
|
-
"Run agentic-sdlc-init first, or pass --root <project_root>.")
|
|
122
|
-
return False
|
|
123
|
-
return True
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
def confine_under(base, rel):
|
|
127
|
-
"""Fail-closed path confinement: resolve `rel` under `base` and require the
|
|
128
|
-
result to stay inside `base`. Returns None (reject) if `rel` is absolute,
|
|
129
|
-
contains a '..' part, or resolves outside `base` (including an OSError
|
|
130
|
-
during resolution, e.g. an unresolvable/reparse-point path on Windows).
|
|
131
|
-
Single source for path confinement (T2/T3): reused by check_kb_collisions'
|
|
132
|
-
`overrides:` check and cmd_validate's `distilled_from` check, and by the
|
|
133
|
-
new `plan` command's paths/consumes/produces/guides confinement."""
|
|
134
|
-
p = Path(rel)
|
|
135
|
-
if p.is_absolute() or ".." in p.parts:
|
|
136
|
-
return None
|
|
137
|
-
try:
|
|
138
|
-
t = (base / rel).resolve()
|
|
139
|
-
t.relative_to(base.resolve())
|
|
140
|
-
return t
|
|
141
|
-
except (ValueError, OSError):
|
|
142
|
-
return None
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def read_text(path):
|
|
146
|
-
# utf-8-sig: strips a leading BOM (files authored on Windows) so the
|
|
147
|
-
# frontmatter '---' on line 0 stays recognizable; reads plain utf-8 otherwise.
|
|
148
|
-
return path.read_text(encoding="utf-8-sig", errors="replace")
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
def sha256_file(path):
|
|
152
|
-
# CRLF->LF before hashing: a Windows checkout with core.autocrlf=true
|
|
153
|
-
# rewrites snapshot files, and a raw-byte hash would flag every guide
|
|
154
|
-
# [stale] on a fresh clone. Recorded hashes are LF-based, so normalizing
|
|
155
|
-
# maps CRLF copies back to the same digest.
|
|
156
|
-
h = hashlib.sha256()
|
|
157
|
-
h.update(path.read_bytes().replace(b"\r\n", b"\n"))
|
|
158
|
-
return h.hexdigest()
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
def parse_iso(value):
|
|
162
|
-
if not value:
|
|
163
|
-
return None
|
|
164
|
-
try:
|
|
165
|
-
dt = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
|
166
|
-
if dt.tzinfo is None:
|
|
167
|
-
dt = dt.replace(tzinfo=timezone.utc)
|
|
168
|
-
return dt
|
|
169
|
-
except ValueError:
|
|
170
|
-
return None
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def norm_text(s):
|
|
174
|
-
return "\n".join(line.rstrip() for line in s.strip().splitlines())
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
def load_frontmatter(lines):
|
|
178
|
-
meta = {}
|
|
179
|
-
if not lines or lines[0].strip() != "---":
|
|
180
|
-
return meta
|
|
181
|
-
for line in lines[1:60]:
|
|
182
|
-
if line.strip() == "---":
|
|
183
|
-
break
|
|
184
|
-
m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", line)
|
|
185
|
-
if m:
|
|
186
|
-
meta[m.group(1).strip().lower()] = m.group(2).strip()
|
|
187
|
-
# Legacy Italian keys: accepted, normalized to canonical English (deprecated).
|
|
188
|
-
for legacy, canon in LEGACY_KEYS.items():
|
|
189
|
-
if legacy in meta and canon not in meta:
|
|
190
|
-
meta[canon] = meta[legacy]
|
|
191
|
-
return meta
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
def is_shadow(path, first_line):
|
|
195
|
-
"""A shadow mirror of a devPNT-governed document, not an authoritative ANALYSIS.
|
|
196
|
-
Recognized by filename (SHADOW_*) or by the marker comment on the FIRST line
|
|
197
|
-
(legacy shadows saved under an ANALYSIS_* name)."""
|
|
198
|
-
return path.name.startswith("SHADOW") or first_line.lstrip().startswith("<!-- SHADOW")
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
def list_analyses(root):
|
|
202
|
-
"""Returns [(path, frontmatter, text)] for the ANALYSIS_*.md files (shadows excluded)."""
|
|
203
|
-
sol = root / "ai_docs" / "solutions"
|
|
204
|
-
out = []
|
|
205
|
-
if not sol.is_dir():
|
|
206
|
-
return out
|
|
207
|
-
for p in sorted(sol.glob("ANALYSIS_*.md")):
|
|
208
|
-
text = read_text(p)
|
|
209
|
-
first_line = text.splitlines()[0] if text else ""
|
|
210
|
-
if is_shadow(p, first_line):
|
|
211
|
-
continue
|
|
212
|
-
out.append((p, load_frontmatter(text.splitlines()), text))
|
|
213
|
-
return out
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
def has_etdd_shadow(root):
|
|
217
|
-
"""True if an E-TDD shadow exported from devPNT exists in solutions/.
|
|
218
|
-
In Hybrid mode the approved E-TDD (exported BEFORE implementation) is the
|
|
219
|
-
design authorization that replaces the IN_PROGRESS ANALYSIS."""
|
|
220
|
-
sol = root / "ai_docs" / "solutions"
|
|
221
|
-
if not sol.is_dir():
|
|
222
|
-
return False
|
|
223
|
-
return any("tdd" in p.name.lower() for p in sol.glob("SHADOW_*.md"))
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
def iter_files(target):
|
|
227
|
-
if target.is_file():
|
|
228
|
-
yield target
|
|
229
|
-
return
|
|
230
|
-
for dirpath, dirnames, filenames in os.walk(target):
|
|
231
|
-
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".")]
|
|
232
|
-
for name in filenames:
|
|
233
|
-
yield Path(dirpath) / name
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
# ---------------------------------------------------------------------- git
|
|
237
|
-
|
|
238
|
-
def git_available(root):
|
|
239
|
-
try:
|
|
240
|
-
r = subprocess.run(["git", "rev-parse", "--is-inside-work-tree"],
|
|
241
|
-
cwd=str(root), capture_output=True, text=True, timeout=10)
|
|
242
|
-
return r.returncode == 0 and r.stdout.strip() == "true"
|
|
243
|
-
except Exception:
|
|
244
|
-
return False
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
def git_head(root):
|
|
248
|
-
try:
|
|
249
|
-
r = subprocess.run(["git", "rev-parse", "--short=12", "HEAD"],
|
|
250
|
-
cwd=str(root), capture_output=True, text=True, timeout=10)
|
|
251
|
-
return r.stdout.strip() if r.returncode == 0 else ""
|
|
252
|
-
except Exception:
|
|
253
|
-
return ""
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
def git_has_changes(root, rel_path):
|
|
257
|
-
"""True if there are tracked/untracked changes under rel_path."""
|
|
258
|
-
try:
|
|
259
|
-
rel = rel_path.replace("\\", "/")
|
|
260
|
-
r = subprocess.run(["git", "status", "--porcelain", "--", rel],
|
|
261
|
-
cwd=str(root), capture_output=True, text=True, timeout=30)
|
|
262
|
-
return r.returncode == 0 and bool(r.stdout.strip())
|
|
263
|
-
except Exception:
|
|
264
|
-
return False
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
def git_changed_since(root, ref, rel_path):
|
|
268
|
-
"""Files changed (tracked + untracked) under rel_path since ref. None if ref unresolvable."""
|
|
269
|
-
try:
|
|
270
|
-
r = subprocess.run(["git", "diff", "--name-only", ref, "--", rel_path],
|
|
271
|
-
cwd=str(root), capture_output=True, text=True, timeout=30)
|
|
272
|
-
if r.returncode != 0:
|
|
273
|
-
return None
|
|
274
|
-
changed = [l.strip() for l in r.stdout.splitlines() if l.strip()]
|
|
275
|
-
r2 = subprocess.run(["git", "ls-files", "--others", "--exclude-standard", "--", rel_path],
|
|
276
|
-
cwd=str(root), capture_output=True, text=True, timeout=30)
|
|
277
|
-
if r2.returncode == 0:
|
|
278
|
-
changed += [l.strip() for l in r2.stdout.splitlines() if l.strip()]
|
|
279
|
-
return sorted(set(changed))
|
|
280
|
-
except Exception:
|
|
281
|
-
return None
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
# -------------------------------------------------------------------- index
|
|
285
|
-
|
|
286
|
-
def build_index(root):
|
|
287
|
-
rows = []
|
|
288
|
-
for p, meta, _ in list_analyses(root):
|
|
289
|
-
rows.append((
|
|
290
|
-
meta.get("id", "?"),
|
|
291
|
-
meta.get("feature", p.stem.replace("ANALYSIS_", "")),
|
|
292
|
-
meta.get("level", ""),
|
|
293
|
-
meta.get("status", "?"),
|
|
294
|
-
meta.get("start_date", ""),
|
|
295
|
-
meta.get("end_date", ""),
|
|
296
|
-
"solutions/" + p.name,
|
|
297
|
-
))
|
|
298
|
-
rows.sort(key=lambda r: r[0])
|
|
299
|
-
lines = [INDEX_HEADER,
|
|
300
|
-
"# Feature History (generated)",
|
|
301
|
-
"",
|
|
302
|
-
"| ID | Feature | Level | Status | Started | Finished | Doc |",
|
|
303
|
-
"|---|---|---|---|---|---|---|"]
|
|
304
|
-
for r in rows:
|
|
305
|
-
lines.append("| " + " | ".join(r) + " |")
|
|
306
|
-
return "\n".join(lines) + "\n"
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
# "Status:"/"Stato:" line in the body (with or without ** **), prefix before the description
|
|
310
|
-
_STATUS_LINE = re.compile(r"^\**\s*(?:status|stato)\s*\**\s*:\s*\**\s*([A-Za-z][\w-]*)", re.I)
|
|
311
|
-
# pure metadata lines to skip when picking the fallback description
|
|
312
|
-
_META_LINE = re.compile(r"^\**\s*(date|data|task ref|version|versione|owner|autore|branch|agente|agent|created|creato|updated|aggiornato)\b", re.I)
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
def extract_doc_meta(path):
|
|
316
|
-
"""(title, description, status, supersedes) of a canonical doc.
|
|
317
|
-
|
|
318
|
-
Recognizes TWO header conventions: the YAML-lite frontmatter
|
|
319
|
-
(description/status/supersedes/title) and the in-body `**Status:** X`
|
|
320
|
-
line (used by ADRs and legacy docs). As a fallback it derives the title
|
|
321
|
-
from the first '# H1' and the description from the first prose line,
|
|
322
|
-
skipping metadata lines.
|
|
323
|
-
"""
|
|
324
|
-
text = read_text(path)
|
|
325
|
-
lines = text.splitlines()
|
|
326
|
-
meta = load_frontmatter(lines)
|
|
327
|
-
body = lines
|
|
328
|
-
if lines and lines[0].strip() == "---":
|
|
329
|
-
for i in range(1, min(len(lines), 60)):
|
|
330
|
-
if lines[i].strip() == "---":
|
|
331
|
-
body = lines[i + 1:]
|
|
332
|
-
break
|
|
333
|
-
|
|
334
|
-
title = meta.get("title", "")
|
|
335
|
-
if not title:
|
|
336
|
-
for line in body:
|
|
337
|
-
m = re.match(r"^#\s+(.*)$", line)
|
|
338
|
-
if m:
|
|
339
|
-
title = m.group(1).strip()
|
|
340
|
-
break
|
|
341
|
-
title = title or path.stem
|
|
342
|
-
|
|
343
|
-
status = meta.get("status", "").upper()
|
|
344
|
-
if not status:
|
|
345
|
-
for line in body[:25]:
|
|
346
|
-
m = _STATUS_LINE.match(line.strip())
|
|
347
|
-
if m:
|
|
348
|
-
status = m.group(1).upper()
|
|
349
|
-
break
|
|
350
|
-
|
|
351
|
-
desc = meta.get("description", "")
|
|
352
|
-
if not desc:
|
|
353
|
-
for line in body:
|
|
354
|
-
s = line.strip()
|
|
355
|
-
if not s or s.startswith("#") or s.startswith("<!--") or _META_LINE.match(s):
|
|
356
|
-
continue
|
|
357
|
-
if s.startswith(">"):
|
|
358
|
-
s = s.lstrip(">").strip()
|
|
359
|
-
m = _STATUS_LINE.match(s)
|
|
360
|
-
if m:
|
|
361
|
-
# "Status: X — description": keep the part after the status; if empty, skip
|
|
362
|
-
rest = s[m.end():].strip(" *—–-:.")
|
|
363
|
-
if not rest:
|
|
364
|
-
continue
|
|
365
|
-
s = rest
|
|
366
|
-
if s:
|
|
367
|
-
desc = s
|
|
368
|
-
break
|
|
369
|
-
desc = re.sub(r"\s+", " ", desc).strip()
|
|
370
|
-
if len(desc) > 160:
|
|
371
|
-
desc = desc[:157].rstrip() + "..."
|
|
372
|
-
return title, desc, status, meta.get("supersedes", "").strip()
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
def list_canonical_docs(root):
|
|
376
|
-
"""[(rel_to_ai_docs, path, (title, desc, status, supersedes))] for canonical docs."""
|
|
377
|
-
ai = root / "ai_docs"
|
|
378
|
-
out = []
|
|
379
|
-
for d in MANIFEST_DIRS:
|
|
380
|
-
base = ai / d
|
|
381
|
-
if not base.is_dir():
|
|
382
|
-
continue
|
|
383
|
-
for p in sorted(base.rglob("*.md")):
|
|
384
|
-
rel_parts = p.relative_to(base).parts
|
|
385
|
-
if any(part.startswith(".") for part in rel_parts[:-1]):
|
|
386
|
-
continue # dot-subdirs (e.g. reference/.sources/) are never canonical
|
|
387
|
-
if p.name in GENERATED_DOCS or p.name == "README.md":
|
|
388
|
-
continue
|
|
389
|
-
out.append((p.relative_to(ai).as_posix(), p, extract_doc_meta(p)))
|
|
390
|
-
return out
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
def build_manifest(root):
|
|
394
|
-
docs = list_canonical_docs(root)
|
|
395
|
-
lines = [MANIFEST_HEADER,
|
|
396
|
-
"# `ai_docs/` document index (generated)",
|
|
397
|
-
"",
|
|
398
|
-
"Complete manifest of the canonical documents. For the reading priority",
|
|
399
|
-
"(must-reads) see the hand-curated `README.md`. The ANALYSIS history is in",
|
|
400
|
-
"`strategic/features_history.md`. `audit/` and `solutions/` are discovery-by-grep,",
|
|
401
|
-
"not manifested here."]
|
|
402
|
-
by_dir = {}
|
|
403
|
-
for rel, _, meta in docs:
|
|
404
|
-
by_dir.setdefault(rel.split("/", 1)[0], []).append((rel, meta))
|
|
405
|
-
for top in MANIFEST_DIRS:
|
|
406
|
-
rows = by_dir.get(top)
|
|
407
|
-
if not rows:
|
|
408
|
-
continue
|
|
409
|
-
lines += ["", f"## {top}/", "",
|
|
410
|
-
"| Document | Status | Description |", "|---|---|---|"]
|
|
411
|
-
for rel, (title, desc, status, _sup) in rows:
|
|
412
|
-
d = (desc or title).replace("|", "\\|")
|
|
413
|
-
lines.append(f"| `{rel}` | {status or '-'} | {d} |")
|
|
414
|
-
return "\n".join(lines).rstrip() + "\n"
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
def list_guides(root):
|
|
418
|
-
"""[(rel_to_ai_docs, path, meta, text)] for ai_docs/reference/GUIDE_*.md."""
|
|
419
|
-
ref = root / "ai_docs" / "reference"
|
|
420
|
-
out = []
|
|
421
|
-
if not ref.is_dir():
|
|
422
|
-
return out
|
|
423
|
-
for p in sorted(ref.glob("GUIDE_*.md")):
|
|
424
|
-
text = read_text(p)
|
|
425
|
-
out.append((p.relative_to(root / "ai_docs").as_posix(), p,
|
|
426
|
-
load_frontmatter(text.splitlines()), text))
|
|
427
|
-
return out
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
def check_kb_collisions(root, project_guides, errors, warnings):
|
|
431
|
-
"""Cross-root awareness (unit 2): project-wins precedence, declared via 'overrides:'."""
|
|
432
|
-
kb_root = DEFAULT_KB_ROOT
|
|
433
|
-
kb_ref = (kb_root / "ai_docs" / "reference")
|
|
434
|
-
try:
|
|
435
|
-
if root.resolve() == kb_root.resolve():
|
|
436
|
-
return # validating the KB itself: no self-comparison
|
|
437
|
-
except OSError:
|
|
438
|
-
return
|
|
439
|
-
if not kb_ref.is_dir():
|
|
440
|
-
return # no KB on this machine: zero behavior change
|
|
441
|
-
kb_names = {p.name for _, p, _, _ in list_guides(kb_root)}
|
|
442
|
-
for rel, p, meta, _ in project_guides:
|
|
443
|
-
ov = (meta.get("overrides") or "").strip()
|
|
444
|
-
if ov:
|
|
445
|
-
# T6: untrusted cross-root pointer — distilled_from parity, fail closed
|
|
446
|
-
target = confine_under(kb_ref, ov)
|
|
447
|
-
if target is None:
|
|
448
|
-
errors.append(f"{rel}: overrides '{ov}' is absolute, contains '..', or escapes the KB "
|
|
449
|
-
"reference dir — rejected (fail closed)")
|
|
450
|
-
continue
|
|
451
|
-
if not target.is_file():
|
|
452
|
-
warnings.append(f"{rel}: overrides target '{ov}' not found in KB ({kb_ref})")
|
|
453
|
-
if p.name in kb_names and ov != p.name:
|
|
454
|
-
warnings.append(f"{rel}: undeclared collision with KB guide '{p.name}' (project wins) — declare overrides: {p.name}")
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
def build_guide_index(root):
|
|
458
|
-
lines = [GUIDE_INDEX_HEADER,
|
|
459
|
-
"# Operative guides (generated router)",
|
|
460
|
-
"",
|
|
461
|
-
"One row per guide. `description` is the when-to-consult line; provenance",
|
|
462
|
-
"shows what the guide was distilled from. Freshness: run `sdlc_check.py stale`.",
|
|
463
|
-
"",
|
|
464
|
-
"| Guide | Status | When to consult | Source | Source version |",
|
|
465
|
-
"|---|---|---|---|---|"]
|
|
466
|
-
for rel, p, meta, _ in list_guides(root):
|
|
467
|
-
lines.append("| `{}` | {} | {} | {} | {} |".format(
|
|
468
|
-
p.name, meta.get("status", "-") or "-",
|
|
469
|
-
(meta.get("description", "") or "-").replace("|", "\\|"),
|
|
470
|
-
(meta.get("source", "") or "-").replace("|", "\\|"),
|
|
471
|
-
meta.get("source_version", "") or "-"))
|
|
472
|
-
return "\n".join(lines) + "\n"
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
def cmd_index(root):
|
|
476
|
-
if not require_ai_docs(root, "index"):
|
|
477
|
-
return 1
|
|
478
|
-
hist = root / "ai_docs" / "strategic" / "features_history.md"
|
|
479
|
-
hist.parent.mkdir(parents=True, exist_ok=True)
|
|
480
|
-
hist.write_text(build_index(root), encoding="utf-8")
|
|
481
|
-
print(f"[ok] ANALYSIS index regenerated: {hist}")
|
|
482
|
-
# INDEX.md only if canonical docs exist: no empty manifest on minimal projects
|
|
483
|
-
if list_canonical_docs(root):
|
|
484
|
-
manifest = root / "ai_docs" / "INDEX.md"
|
|
485
|
-
manifest.write_text(build_manifest(root), encoding="utf-8")
|
|
486
|
-
print(f"[ok] document manifest regenerated: {manifest}")
|
|
487
|
-
else:
|
|
488
|
-
print("[info] no canonical documents: INDEX.md not generated")
|
|
489
|
-
guides = list_guides(root)
|
|
490
|
-
gidx = root / "ai_docs" / "reference" / "INDEX.md"
|
|
491
|
-
if guides:
|
|
492
|
-
gidx.write_text(build_guide_index(root), encoding="utf-8")
|
|
493
|
-
print(f"[ok] guide router regenerated: {gidx}")
|
|
494
|
-
elif gidx.is_file():
|
|
495
|
-
print(f"[warn] {gidx} exists but no GUIDE_*.md found: stale router, remove or add guides")
|
|
496
|
-
return 0
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
# ----------------------------------------------------------------- validate
|
|
500
|
-
|
|
501
|
-
def has_section(text, aliases):
|
|
502
|
-
return any(a in text for a in aliases)
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
def cmd_validate(root, strict=False):
|
|
506
|
-
errors, warnings = [], []
|
|
507
|
-
ai = root / "ai_docs"
|
|
508
|
-
if not ai.is_dir():
|
|
509
|
-
if strict:
|
|
510
|
-
print(f"[ERROR] {ai} does not exist: nothing to validate. In --strict mode this "
|
|
511
|
-
"fails so a wrong working directory cannot produce a green pipeline.")
|
|
512
|
-
return 1
|
|
513
|
-
print(f"[info] {ai} does not exist: nothing to validate (project without SDLC docs).")
|
|
514
|
-
return 0
|
|
515
|
-
|
|
516
|
-
# Vision: presence and declared state
|
|
517
|
-
for name in VISION_FILES:
|
|
518
|
-
f = ai / "vision" / name
|
|
519
|
-
if not f.is_file():
|
|
520
|
-
warnings.append(f"vision/{name} missing")
|
|
521
|
-
continue
|
|
522
|
-
head = "\n".join(read_text(f).splitlines()[:12])
|
|
523
|
-
m = re.search(r"(?:Status|Stato):\s*(DRAFT|APPROVED)", head)
|
|
524
|
-
if not m:
|
|
525
|
-
errors.append(f"vision/{name}: missing 'Status: DRAFT|APPROVED' in the first lines")
|
|
526
|
-
elif m.group(1) == "DRAFT":
|
|
527
|
-
warnings.append(f"vision/{name} is DRAFT: not a gating authority, have the user validate it")
|
|
528
|
-
|
|
529
|
-
# ANALYSIS: frontmatter and mandatory sections
|
|
530
|
-
seen_ids = {}
|
|
531
|
-
analyses = list_analyses(root)
|
|
532
|
-
for p, meta, text in analyses:
|
|
533
|
-
rel = "solutions/" + p.name
|
|
534
|
-
if not meta:
|
|
535
|
-
errors.append(f"{rel}: frontmatter missing")
|
|
536
|
-
continue
|
|
537
|
-
fid = meta.get("id")
|
|
538
|
-
if not fid:
|
|
539
|
-
errors.append(f"{rel}: 'id' field missing")
|
|
540
|
-
elif fid in seen_ids:
|
|
541
|
-
errors.append(f"{rel}: id '{fid}' duplicated (already used in {seen_ids[fid]})")
|
|
542
|
-
else:
|
|
543
|
-
seen_ids[fid] = rel
|
|
544
|
-
status = meta.get("status", "")
|
|
545
|
-
if status not in VALID_STATES:
|
|
546
|
-
errors.append(f"{rel}: status '{status}' not valid ({'/'.join(sorted(VALID_STATES))})")
|
|
547
|
-
if not meta.get("start_date"):
|
|
548
|
-
errors.append(f"{rel}: 'start_date' missing")
|
|
549
|
-
if status == "COMPLETED" and not meta.get("end_date"):
|
|
550
|
-
errors.append(f"{rel}: COMPLETED without 'end_date'")
|
|
551
|
-
level = meta.get("level")
|
|
552
|
-
if level and level.upper() not in VALID_LEVELS:
|
|
553
|
-
warnings.append(f"{rel}: level '{level}' not recognized ({'/'.join(sorted(VALID_LEVELS))})")
|
|
554
|
-
if not has_section(text, SECURITY_SECTION):
|
|
555
|
-
errors.append(f"{rel}: section '## Security and Threat Model' missing (mandatory)")
|
|
556
|
-
for en, it in ANALYSIS_SECTIONS:
|
|
557
|
-
if not has_section(text, (en, it)):
|
|
558
|
-
warnings.append(f"{rel}: section '{en}' missing")
|
|
559
|
-
|
|
560
|
-
# Generated index aligned
|
|
561
|
-
hist = ai / "strategic" / "features_history.md"
|
|
562
|
-
if analyses:
|
|
563
|
-
if not hist.is_file():
|
|
564
|
-
errors.append("strategic/features_history.md missing: run 'sdlc_check.py index'")
|
|
565
|
-
elif norm_text(read_text(hist)) != norm_text(build_index(root)):
|
|
566
|
-
errors.append("strategic/features_history.md not aligned with the ANALYSIS files: run 'sdlc_check.py index'")
|
|
567
|
-
|
|
568
|
-
# Canonical document manifest aligned (Poka-Yoke: unindexed file = dirty closure)
|
|
569
|
-
docs = list_canonical_docs(root)
|
|
570
|
-
manifest = ai / "INDEX.md"
|
|
571
|
-
if docs:
|
|
572
|
-
if not manifest.is_file():
|
|
573
|
-
errors.append("ai_docs/INDEX.md missing: run 'sdlc_check.py index'")
|
|
574
|
-
elif norm_text(read_text(manifest)) != norm_text(build_manifest(root)):
|
|
575
|
-
errors.append("ai_docs/INDEX.md not aligned with the canonical documents: run 'sdlc_check.py index'")
|
|
576
|
-
|
|
577
|
-
# Canonical document lifecycle: declared status + supersedes coherence
|
|
578
|
-
canon_status = {rel: meta[2] for rel, _, meta in docs}
|
|
579
|
-
for rel, _, (title, desc, status, supersedes) in docs:
|
|
580
|
-
if not status:
|
|
581
|
-
warnings.append(f"{rel}: missing 'status:' in the header (CURRENT/SUPERSEDED/DRAFT/DEPRECATED)")
|
|
582
|
-
elif status not in CANONICAL_STATES:
|
|
583
|
-
warnings.append(f"{rel}: status '{status}' not recognized ({'/'.join(sorted(CANONICAL_STATES))})")
|
|
584
|
-
if supersedes:
|
|
585
|
-
base = os.path.basename(supersedes)
|
|
586
|
-
for other, ost in canon_status.items():
|
|
587
|
-
if (other == supersedes or other.endswith("/" + supersedes)
|
|
588
|
-
or os.path.basename(other) == base) and ost == "CURRENT":
|
|
589
|
-
warnings.append(f"{other}: still CURRENT but superseded by {rel} (set status: SUPERSEDED)")
|
|
590
|
-
|
|
591
|
-
# Guide checks (ai_docs/reference/GUIDE_*.md): structure only — freshness is stale's job
|
|
592
|
-
guides = list_guides(root)
|
|
593
|
-
for rel, p, meta, text in guides:
|
|
594
|
-
missing = [k for k in GUIDE_PROVENANCE_KEYS if not meta.get(k)]
|
|
595
|
-
if missing:
|
|
596
|
-
warnings.append(f"{rel}: guide missing provenance key(s): {', '.join(missing)}")
|
|
597
|
-
# (b) per-section fidelity markers: every '## ' section body must carry a marker
|
|
598
|
-
body = text.split("---", 2)[-1]
|
|
599
|
-
sections = re.split(r"^##\s+", body, flags=re.M)[1:]
|
|
600
|
-
unmarked = [s.splitlines()[0].strip() for s in sections if not GUIDE_MARKER_RE.search(s)]
|
|
601
|
-
if unmarked:
|
|
602
|
-
warnings.append(f"{rel}: section(s) without [source: ...] / [not covered by source] marker: "
|
|
603
|
-
+ "; ".join(unmarked[:5]))
|
|
604
|
-
# (c) distilled_from confinement — fail closed (P-TM T6, distilled_from vector)
|
|
605
|
-
df = meta.get("distilled_from", "")
|
|
606
|
-
if df and confine_under(root, df) is None:
|
|
607
|
-
errors.append(f"{rel}: distilled_from '{df}' is absolute, contains '..', or resolves "
|
|
608
|
-
"outside the project root: rejected")
|
|
609
|
-
check_kb_collisions(root, guides, errors, warnings)
|
|
610
|
-
# guide-router alignment (mirror of the root-manifest check)
|
|
611
|
-
gidx = root / "ai_docs" / "reference" / "INDEX.md"
|
|
612
|
-
if guides:
|
|
613
|
-
if not gidx.is_file():
|
|
614
|
-
errors.append("ai_docs/reference/INDEX.md missing: run 'sdlc_check.py index'")
|
|
615
|
-
elif norm_text(read_text(gidx)) != norm_text(build_guide_index(root)):
|
|
616
|
-
errors.append("ai_docs/reference/INDEX.md not aligned with the guides: run 'sdlc_check.py index'")
|
|
617
|
-
|
|
618
|
-
# Handoff: header and freshness
|
|
619
|
-
hand = ai / "audit" / "handoff.md"
|
|
620
|
-
if hand.is_file():
|
|
621
|
-
m = re.search(r"(?:Date|Data):\s*(\d{4}-\d{2}-\d{2})", read_text(hand))
|
|
622
|
-
if not m:
|
|
623
|
-
warnings.append("audit/handoff.md without a 'Date: YYYY-MM-DD' header")
|
|
624
|
-
else:
|
|
625
|
-
try:
|
|
626
|
-
stamp = datetime.strptime(m.group(1), "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
|
627
|
-
age = (datetime.now(timezone.utc) - stamp).days
|
|
628
|
-
if age > 14:
|
|
629
|
-
warnings.append(f"audit/handoff.md is {age} days old: treat it as history, not current state")
|
|
630
|
-
except ValueError:
|
|
631
|
-
warnings.append("audit/handoff.md: date not parseable")
|
|
632
|
-
|
|
633
|
-
for w in warnings:
|
|
634
|
-
print(f"[warn] {w}")
|
|
635
|
-
for e in errors:
|
|
636
|
-
print(f"[ERROR] {e}")
|
|
637
|
-
print(f"\nValidation: {len(errors)} errors, {len(warnings)} warnings.")
|
|
638
|
-
if strict and warnings and not errors:
|
|
639
|
-
print("[strict] warnings are failures in --strict mode.")
|
|
640
|
-
return 1 if errors or (strict and warnings) else 0
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
# ------------------------------------------------------------- audit_plan
|
|
644
|
-
|
|
645
|
-
def parse_audit_plan(root):
|
|
646
|
-
f = root / "ai_docs" / "audit" / "audit_plan.md"
|
|
647
|
-
rows, lines = [], []
|
|
648
|
-
if f.is_file():
|
|
649
|
-
lines = read_text(f).splitlines()
|
|
650
|
-
for i, line in enumerate(lines):
|
|
651
|
-
if not line.strip().startswith("|"):
|
|
652
|
-
continue
|
|
653
|
-
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
|
654
|
-
if len(cells) < 2:
|
|
655
|
-
continue
|
|
656
|
-
if cells[0].lower() in ("path", "percorso") or set(cells[0]) <= set("-: "):
|
|
657
|
-
continue
|
|
658
|
-
rows.append({
|
|
659
|
-
"line": i,
|
|
660
|
-
"path": cells[0],
|
|
661
|
-
"status": cells[1].upper(),
|
|
662
|
-
"ref": cells[2] if len(cells) > 2 else "",
|
|
663
|
-
"note": cells[3] if len(cells) > 3 else "",
|
|
664
|
-
})
|
|
665
|
-
return f, lines, rows
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
def cmd_stale(root, hybrid=False):
|
|
669
|
-
rc = 0
|
|
670
|
-
# --- guide freshness (source_hash vs snapshot) — runs in EVERY mode
|
|
671
|
-
drifted = []
|
|
672
|
-
for rel, p, meta, _ in list_guides(root):
|
|
673
|
-
df, rec = meta.get("distilled_from", ""), meta.get("source_hash", "")
|
|
674
|
-
if not df or not rec:
|
|
675
|
-
continue # structure problems are validate's job
|
|
676
|
-
src = root / df
|
|
677
|
-
if not src.is_file():
|
|
678
|
-
print(f"[warn] {rel}: distilled_from '{df}' not found — snapshot missing")
|
|
679
|
-
rc = 1
|
|
680
|
-
continue
|
|
681
|
-
if sha256_file(src) != rec:
|
|
682
|
-
drifted.append((rel, df))
|
|
683
|
-
for rel, df in drifted:
|
|
684
|
-
print(f"[stale] {rel}: source snapshot '{df}' changed since distillation — regenerate the guide")
|
|
685
|
-
if drifted:
|
|
686
|
-
rc = 1
|
|
687
|
-
# --- audit-plan staleness — delegated to devPNT/KL in hybrid
|
|
688
|
-
if hybrid:
|
|
689
|
-
print("[info] hybrid mode: audit-plan staleness is delegated to devPNT/KL, skipping.")
|
|
690
|
-
return rc # was: implicit skip-all; guide rc survives
|
|
691
|
-
f, _, rows = parse_audit_plan(root)
|
|
692
|
-
if not rows:
|
|
693
|
-
print(f"[info] no rows in {f}: nothing to check "
|
|
694
|
-
"(audit not initialized, or Hybrid mode where mapping is delegated to devPNT).")
|
|
695
|
-
return rc # was: return 0 — MUST carry guide rc
|
|
696
|
-
use_git = git_available(root)
|
|
697
|
-
stale = []
|
|
698
|
-
for row in rows:
|
|
699
|
-
if row["status"] != "ANALYZED":
|
|
700
|
-
continue
|
|
701
|
-
rel, ref = row["path"], row["ref"]
|
|
702
|
-
target = root / rel
|
|
703
|
-
if not target.exists():
|
|
704
|
-
print(f"[warn] {rel}: path does not exist")
|
|
705
|
-
continue
|
|
706
|
-
changed = []
|
|
707
|
-
if use_git and re.fullmatch(r"[0-9a-fA-F]{7,40}", ref or ""):
|
|
708
|
-
res = git_changed_since(root, ref, rel.replace("\\", "/"))
|
|
709
|
-
if res is None:
|
|
710
|
-
print(f"[warn] {rel}: git ref '{ref}' unresolvable, cannot evaluate")
|
|
711
|
-
continue
|
|
712
|
-
changed = res
|
|
713
|
-
else:
|
|
714
|
-
ts = parse_iso(ref)
|
|
715
|
-
if ts is None:
|
|
716
|
-
print(f"[warn] {rel}: reference '{ref}' not parseable (neither git hash nor ISO UTC)")
|
|
717
|
-
continue
|
|
718
|
-
for fp in iter_files(target):
|
|
719
|
-
mtime = datetime.fromtimestamp(fp.stat().st_mtime, tz=timezone.utc)
|
|
720
|
-
if mtime > ts + MTIME_GRACE:
|
|
721
|
-
changed.append(str(fp.relative_to(root)).replace("\\", "/"))
|
|
722
|
-
if changed:
|
|
723
|
-
stale.append((rel, changed))
|
|
724
|
-
|
|
725
|
-
if not stale:
|
|
726
|
-
print("[ok] no analyzed area was modified after its last recorded analysis.")
|
|
727
|
-
return rc # was: return 0 — MUST carry guide rc
|
|
728
|
-
print("Areas modified after the last recorded analysis:")
|
|
729
|
-
for rel, changed in stale:
|
|
730
|
-
print(f" {rel} ({len(changed)} files)")
|
|
731
|
-
for c in changed[:10]:
|
|
732
|
-
print(f" - {c}")
|
|
733
|
-
if len(changed) > 10:
|
|
734
|
-
print(f" ... and {len(changed) - 10} more")
|
|
735
|
-
print("\nAfter re-analyzing, record it with: sdlc_check.py mark <path>")
|
|
736
|
-
return 1 # stale areas dominate: rc already implied
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
def cmd_mark(root, paths):
|
|
740
|
-
if not require_ai_docs(root, "mark"):
|
|
741
|
-
return 1
|
|
742
|
-
f, lines, rows = parse_audit_plan(root)
|
|
743
|
-
use_git_ref = git_available(root) and not any(
|
|
744
|
-
git_has_changes(root, raw.replace("\\", "/").rstrip("/")) for raw in paths
|
|
745
|
-
)
|
|
746
|
-
ref = git_head(root) if use_git_ref else utc_now_iso()
|
|
747
|
-
by_path = {r["path"].replace("\\", "/").rstrip("/"): r for r in rows}
|
|
748
|
-
|
|
749
|
-
if not lines:
|
|
750
|
-
lines = ["# Audit Plan", "",
|
|
751
|
-
"| Path | Status | Reference | Notes |",
|
|
752
|
-
"|---|---|---|---|"]
|
|
753
|
-
rows = []
|
|
754
|
-
|
|
755
|
-
def row_text(path, note):
|
|
756
|
-
return f"| {path} | ANALYZED | {ref} | {note} |"
|
|
757
|
-
|
|
758
|
-
appended = []
|
|
759
|
-
for raw in paths:
|
|
760
|
-
key = raw.replace("\\", "/").rstrip("/")
|
|
761
|
-
display = key + ("/" if (root / key).is_dir() else "")
|
|
762
|
-
existing = by_path.get(key)
|
|
763
|
-
if existing:
|
|
764
|
-
lines[existing["line"]] = row_text(existing["path"], existing["note"])
|
|
765
|
-
print(f"[ok] {existing['path']} -> ANALYZED ({ref})")
|
|
766
|
-
else:
|
|
767
|
-
appended.append(row_text(display, ""))
|
|
768
|
-
print(f"[ok] {display} added as ANALYZED ({ref})")
|
|
769
|
-
|
|
770
|
-
if appended:
|
|
771
|
-
insert_at = (max(r["line"] for r in rows) + 1) if rows else len(lines)
|
|
772
|
-
lines[insert_at:insert_at] = appended
|
|
773
|
-
|
|
774
|
-
f.parent.mkdir(parents=True, exist_ok=True)
|
|
775
|
-
f.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
776
|
-
return 0
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
def cmd_check(root, strict=False, hybrid=False):
|
|
780
|
-
print("===== validate =====")
|
|
781
|
-
rc_v = cmd_validate(root, strict=strict)
|
|
782
|
-
print("\n===== stale =====")
|
|
783
|
-
rc_s = cmd_stale(root, hybrid=hybrid)
|
|
784
|
-
print(f"\ncheck: {'CLEAN' if not (rc_v or rc_s) else 'NOT CLEAN'} "
|
|
785
|
-
f"(validate rc={rc_v}, stale rc={rc_s})")
|
|
786
|
-
return 1 if (rc_v or rc_s) else 0
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
# --------------------------------------------------------------------- gate
|
|
790
|
-
|
|
791
|
-
def cmd_gate(args):
|
|
792
|
-
file_path = args.file or ""
|
|
793
|
-
if args.hook:
|
|
794
|
-
try:
|
|
795
|
-
# bytes -> utf-8-sig: the hook payload is UTF-8 JSON regardless of the
|
|
796
|
-
# console code page; '-sig' strips the BOM (PowerShell pipes)
|
|
797
|
-
raw = sys.stdin.buffer.read().decode("utf-8-sig", errors="replace")
|
|
798
|
-
payload = json.loads(raw)
|
|
799
|
-
file_path = (payload.get("tool_input") or {}).get("file_path") or ""
|
|
800
|
-
except Exception:
|
|
801
|
-
return 0 # unparseable input: do not block
|
|
802
|
-
if not file_path:
|
|
803
|
-
return 0
|
|
804
|
-
root = Path(args.root).resolve() if args.root else find_project_root()
|
|
805
|
-
try:
|
|
806
|
-
rel = str(Path(file_path).resolve().relative_to(root)).replace("\\", "/")
|
|
807
|
-
except ValueError:
|
|
808
|
-
return 0 # outside the project: not this gate's concern
|
|
809
|
-
if rel.startswith(("ai_docs/", "tests/", "test/")):
|
|
810
|
-
return 0
|
|
811
|
-
protected = [p.strip().replace("\\", "/").rstrip("/")
|
|
812
|
-
for p in (args.protected or "").split(";") if p.strip()]
|
|
813
|
-
if not protected:
|
|
814
|
-
return 0
|
|
815
|
-
if not any(rel == p or rel.startswith(p + "/") for p in protected):
|
|
816
|
-
return 0
|
|
817
|
-
for _, meta, _ in list_analyses(root):
|
|
818
|
-
if meta.get("status") == "IN_PROGRESS":
|
|
819
|
-
return 0
|
|
820
|
-
if args.hybrid and has_etdd_shadow(root):
|
|
821
|
-
return 0 # Hybrid design gate: an approved E-TDD shadow authorizes the change
|
|
822
|
-
if args.hybrid:
|
|
823
|
-
sys.stderr.write(
|
|
824
|
-
f"[sdlc gate] '{rel}' is on a protected path but no E-TDD shadow "
|
|
825
|
-
"(solutions/SHADOW_*tdd*.md) exists and no ANALYSIS_*.md is IN_PROGRESS. "
|
|
826
|
-
"In Hybrid mode, export the approved E-TDD shadow from devPNT before implementing.\n")
|
|
827
|
-
return 2
|
|
24
|
+
import sdlc_core
|
|
25
|
+
except ImportError as exc: # pragma: no cover - exercised by TS12, not by unit tests
|
|
828
26
|
sys.stderr.write(
|
|
829
|
-
|
|
830
|
-
"
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
#
|
|
835
|
-
#
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
raw = read_text(path)
|
|
867
|
-
data = json.loads(raw)
|
|
868
|
-
except (ValueError, TypeError, OSError) as e:
|
|
869
|
-
return {}, f"ledger '{path}' unreadable/malformed, treating as empty: {e}"
|
|
870
|
-
if not isinstance(data, dict):
|
|
871
|
-
return {}, f"ledger '{path}' is not a JSON object, treating as empty"
|
|
872
|
-
return data, ""
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
def _confine_or_reject(base, rel, label, rel_label, errors):
|
|
876
|
-
t = confine_under(base, rel)
|
|
877
|
-
if t is None:
|
|
878
|
-
errors.append(f"{rel_label}: {label} '{rel}' is absolute, contains '..', or escapes "
|
|
879
|
-
f"'{base}' — rejected (fail closed)")
|
|
880
|
-
return t
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
def _validate_plan_tasks(root, data, rel_label, errors, warnings):
|
|
884
|
-
"""Shared core of `plan validate`/`plan brief`: schema + confinement checks.
|
|
885
|
-
Returns the task list (possibly empty) on success; errors/warnings are
|
|
886
|
-
appended in place. Callers decide the exit code."""
|
|
887
|
-
tasks = data.get("tasks")
|
|
888
|
-
if not isinstance(tasks, list) or not tasks:
|
|
889
|
-
errors.append(f"{rel_label}: 'tasks' must be a non-empty JSON array")
|
|
890
|
-
return []
|
|
891
|
-
ref_dir = root / "ai_docs" / "reference"
|
|
892
|
-
kb_ref = DEFAULT_KB_ROOT / "ai_docs" / "reference"
|
|
893
|
-
seen_ids = set()
|
|
894
|
-
for i, task in enumerate(tasks):
|
|
895
|
-
loc = f"{rel_label}: task[{i}]"
|
|
896
|
-
if not isinstance(task, dict):
|
|
897
|
-
errors.append(f"{loc}: not a JSON object")
|
|
898
|
-
continue
|
|
899
|
-
missing = [k for k in PLAN_TASK_REQUIRED if not task.get(k)]
|
|
900
|
-
if missing:
|
|
901
|
-
errors.append(f"{loc}: missing required field(s): {', '.join(missing)}")
|
|
902
|
-
if not task.get("paths") and not task.get("produces"):
|
|
903
|
-
errors.append(f"{loc}: must declare at least one of 'paths'/'produces'")
|
|
904
|
-
tid = task.get("id")
|
|
905
|
-
if tid:
|
|
906
|
-
if tid in seen_ids:
|
|
907
|
-
errors.append(f"{loc}: duplicate task id '{tid}'")
|
|
908
|
-
seen_ids.add(tid)
|
|
909
|
-
for key in ("paths", "consumes", "produces"):
|
|
910
|
-
for p in (task.get(key) or []):
|
|
911
|
-
_confine_or_reject(root, p, key, loc, errors)
|
|
912
|
-
for g in (task.get("guides") or []):
|
|
913
|
-
in_project = confine_under(ref_dir, g)
|
|
914
|
-
in_kb = confine_under(kb_ref, g)
|
|
915
|
-
if in_project is None and in_kb is None:
|
|
916
|
-
errors.append(f"{loc}: guide '{g}' is not confined under the project reference "
|
|
917
|
-
f"dir ({ref_dir}) or the agent KB reference dir ({kb_ref}) — rejected")
|
|
918
|
-
return tasks
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
def cmd_plan(root, args):
|
|
922
|
-
"""Zero-execution: validates/briefs a PLAN_[feature].md. Never spawns a
|
|
923
|
-
process, never calls a git_* helper, never runs the opaque `verify` text —
|
|
924
|
-
it is printed, not executed."""
|
|
925
|
-
plan_path = Path(args.file)
|
|
926
|
-
if not plan_path.is_absolute():
|
|
927
|
-
plan_path = root / plan_path
|
|
928
|
-
if not plan_path.is_file():
|
|
929
|
-
sys.stderr.write(f"[plan] plan file not found: {plan_path}\n")
|
|
930
|
-
return 2
|
|
931
|
-
rel_label = str(plan_path)
|
|
932
|
-
data, reason = extract_plan_json(read_text(plan_path))
|
|
933
|
-
if data is None:
|
|
934
|
-
sys.stderr.write(f"[plan] {rel_label}: {reason}\n")
|
|
935
|
-
return 2
|
|
936
|
-
|
|
937
|
-
errors, warnings = [], []
|
|
938
|
-
tasks = _validate_plan_tasks(root, data, rel_label, errors, warnings)
|
|
939
|
-
|
|
940
|
-
ledger_path = plan_path.with_name(plan_path.stem + ".ledger.json")
|
|
941
|
-
ledger, ledger_reason = load_ledger(ledger_path)
|
|
942
|
-
if ledger_reason:
|
|
943
|
-
warnings.append(ledger_reason)
|
|
944
|
-
if not errors:
|
|
945
|
-
task_ids = {t.get("id") for t in tasks if isinstance(t, dict)}
|
|
946
|
-
for lid in ledger:
|
|
947
|
-
if lid not in task_ids:
|
|
948
|
-
warnings.append(f"ledger id '{lid}' not found in {rel_label}: orphaned entry (not fatal)")
|
|
949
|
-
|
|
950
|
-
for w in warnings:
|
|
951
|
-
sys.stderr.write(f"[warn] {w}\n")
|
|
952
|
-
for e in errors:
|
|
953
|
-
sys.stderr.write(f"[ERROR] {e}\n")
|
|
954
|
-
|
|
955
|
-
if args.plan_cmd == "validate":
|
|
956
|
-
if errors:
|
|
957
|
-
sys.stderr.write(f"\n[plan] validate: {len(errors)} errors, {len(warnings)} warnings.\n")
|
|
958
|
-
return 2
|
|
959
|
-
print(f"[ok] {rel_label}: plan valid ({len(tasks)} task(s), {len(warnings)} warning(s)).")
|
|
960
|
-
return 0
|
|
961
|
-
|
|
962
|
-
# brief
|
|
963
|
-
if errors:
|
|
964
|
-
sys.stderr.write(f"\n[plan] brief: plan is invalid, refusing to brief ({len(errors)} errors).\n")
|
|
965
|
-
return 2
|
|
966
|
-
target = None
|
|
967
|
-
for t in tasks:
|
|
968
|
-
if isinstance(t, dict) and t.get("id") == args.task:
|
|
969
|
-
target = t
|
|
970
|
-
break
|
|
971
|
-
if target is None:
|
|
972
|
-
sys.stderr.write(f"[plan] brief: task id '{args.task}' not found in {rel_label}\n")
|
|
973
|
-
return 2
|
|
974
|
-
|
|
975
|
-
print(f"# Task: {target.get('id')} — {target.get('title', '')}")
|
|
976
|
-
print()
|
|
977
|
-
print("## Task block")
|
|
978
|
-
print(json.dumps(target, indent=2))
|
|
979
|
-
print()
|
|
980
|
-
print("## Produces of prior-order tasks (interfaces)")
|
|
981
|
-
prior_produces = []
|
|
982
|
-
for t in tasks:
|
|
983
|
-
if not isinstance(t, dict):
|
|
984
|
-
continue
|
|
985
|
-
if t.get("id") == target.get("id"):
|
|
986
|
-
break
|
|
987
|
-
prior_produces.extend(t.get("produces") or [])
|
|
988
|
-
if prior_produces:
|
|
989
|
-
for p in prior_produces:
|
|
990
|
-
print(f"- {p}")
|
|
991
|
-
else:
|
|
992
|
-
print("(none)")
|
|
993
|
-
print()
|
|
994
|
-
print("## Guide pointers (paths, not content)")
|
|
995
|
-
guides = target.get("guides") or []
|
|
996
|
-
if guides:
|
|
997
|
-
for g in guides:
|
|
998
|
-
print(f"- {g}")
|
|
999
|
-
else:
|
|
1000
|
-
print("(none)")
|
|
1001
|
-
print()
|
|
1002
|
-
print("## Verify (opaque text — orchestrator runs this out of band, NOT executed here)")
|
|
1003
|
-
print(target.get("verify", ""))
|
|
1004
|
-
return 0
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
def cmd_orient(args):
|
|
1008
|
-
"""SessionStart hook: emit a bounded, repo-sourced ai_docs/ orientation to
|
|
1009
|
-
stdout and ALWAYS return 0 (fail-open, P-TM T8) -- a session hook must never
|
|
1010
|
-
block the session or surface a traceback. Zero-execution (P-TM T1): reads a
|
|
1011
|
-
fixed hard-coded doc set, confine_under each (P-TM T3), size-caps the total
|
|
1012
|
-
(P-TM T2). No subprocess/eval anywhere in this call graph."""
|
|
1013
|
-
try:
|
|
1014
|
-
root = Path(args.root).resolve() if getattr(args, "root", None) else find_project_root()
|
|
1015
|
-
chunks = []
|
|
1016
|
-
total = 0
|
|
1017
|
-
truncated = False
|
|
1018
|
-
for label, rel in ORIENT_DOCS:
|
|
1019
|
-
target = confine_under(root, rel)
|
|
1020
|
-
if target is None or not target.is_file():
|
|
1021
|
-
continue
|
|
1022
|
-
try:
|
|
1023
|
-
text = read_text(target)
|
|
1024
|
-
except OSError:
|
|
1025
|
-
continue
|
|
1026
|
-
remaining = ORIENT_MAX_TOTAL_CHARS - total
|
|
1027
|
-
if remaining <= 0:
|
|
1028
|
-
truncated = True
|
|
1029
|
-
break
|
|
1030
|
-
text = text[:ORIENT_PER_DOC_CHARS]
|
|
1031
|
-
if len(text) > remaining:
|
|
1032
|
-
text = text[:remaining]
|
|
1033
|
-
truncated = True
|
|
1034
|
-
chunks.append((label, text))
|
|
1035
|
-
total += len(text)
|
|
1036
|
-
if not chunks:
|
|
1037
|
-
return 0
|
|
1038
|
-
out = ["=== Agentic SDLC -- session orientation (repo-sourced context, not authored instructions) ==="]
|
|
1039
|
-
for label, text in chunks:
|
|
1040
|
-
out.append(f"\n## {label}\n{text}")
|
|
1041
|
-
if truncated:
|
|
1042
|
-
out.append("\n[orientation truncated to the size cap -- open the files directly for full content]")
|
|
1043
|
-
out.append("\nTriage every request (Rule Zero): L1 trivial - L2 small - L3 significant - Spike. "
|
|
1044
|
-
"When in doubt, pick the higher level.")
|
|
1045
|
-
if getattr(args, "hybrid", False):
|
|
1046
|
-
out.append("\n[devPNT active] Run devpnt_mcp_get_bootstrap for the Master Plan / Knowledge Layer -- "
|
|
1047
|
-
"the orientation above is the filesystem layer, not a bootstrap duplicate.")
|
|
1048
|
-
print("\n".join(out))
|
|
1049
|
-
return 0
|
|
1050
|
-
except Exception:
|
|
1051
|
-
return 0
|
|
1052
|
-
|
|
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
|
+
)
|
|
1053
64
|
|
|
1054
|
-
# --------------------------------------------------------------------- main
|
|
1055
65
|
|
|
1056
66
|
def main(argv=None):
|
|
1057
|
-
|
|
1058
|
-
common.add_argument("--root", help="project root (default: walk up until ai_docs/ is found)")
|
|
1059
|
-
|
|
1060
|
-
strict_opt = argparse.ArgumentParser(add_help=False)
|
|
1061
|
-
strict_opt.add_argument("--strict", action="store_true",
|
|
1062
|
-
help="fail on warnings and on missing ai_docs/ (for CI)")
|
|
1063
|
-
|
|
1064
|
-
hybrid_opt = argparse.ArgumentParser(add_help=False)
|
|
1065
|
-
hybrid_opt.add_argument("--hybrid", action="store_true",
|
|
1066
|
-
help="Hybrid/devPNT mode: audit-plan staleness is delegated to devPNT/KL; "
|
|
1067
|
-
"the gate also unlocks on an E-TDD shadow")
|
|
1068
|
-
|
|
1069
|
-
ap = argparse.ArgumentParser(prog="sdlc_check.py",
|
|
1070
|
-
description="Mechanical validator for Agentic SDLC")
|
|
1071
|
-
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
1072
|
-
sub.add_parser("check", parents=[common, strict_opt, hybrid_opt],
|
|
1073
|
-
help="closure gate: validate + stale in one command")
|
|
1074
|
-
sub.add_parser("validate", parents=[common, strict_opt], help="verify ai_docs/ coherence")
|
|
1075
|
-
sub.add_parser("index", parents=[common], help="regenerate features_history.md + ai_docs/INDEX.md")
|
|
1076
|
-
sub.add_parser("stale", parents=[common, hybrid_opt], help="areas modified after the last analysis")
|
|
1077
|
-
mp = sub.add_parser("mark", parents=[common], help="record paths as ANALYZED")
|
|
1078
|
-
mp.add_argument("paths", nargs="+", help="paths relative to the project root")
|
|
1079
|
-
gp = sub.add_parser("gate", parents=[common, hybrid_opt], help="PreToolUse hook (exit 2 = block)")
|
|
1080
|
-
gp.add_argument("--hook", action="store_true", help="read the hook JSON payload from stdin")
|
|
1081
|
-
gp.add_argument("--file", help="file path to evaluate (alternative to --hook)")
|
|
1082
|
-
gp.add_argument("--protected", default="", help="protected prefixes separated by ';' (e.g. \"src/auth;src/crypto\")")
|
|
1083
|
-
|
|
1084
|
-
sub.add_parser("orient", parents=[common, hybrid_opt],
|
|
1085
|
-
help="SessionStart hook: emit ai_docs/ orientation to stdout (fail-open, zero-execution)")
|
|
1086
|
-
|
|
1087
|
-
pp = sub.add_parser("plan", parents=[common],
|
|
1088
|
-
help="Subagent Execution: validate/brief a PLAN_[feature].md (zero-execution)")
|
|
1089
|
-
pp_sub = pp.add_subparsers(dest="plan_cmd", required=True)
|
|
1090
|
-
pv = pp_sub.add_parser("validate", help="schema + confinement + ledger cross-check (exit 2 on error)")
|
|
1091
|
-
pv.add_argument("file", help="path to the PLAN_[feature].md file")
|
|
1092
|
-
pb = pp_sub.add_parser("brief", help="print a task's brief to stdout (verify text is NOT executed)")
|
|
1093
|
-
pb.add_argument("file", help="path to the PLAN_[feature].md file")
|
|
1094
|
-
pb.add_argument("--task", required=True, help="task id to brief")
|
|
1095
|
-
|
|
1096
|
-
args = ap.parse_args(argv)
|
|
1097
|
-
if args.cmd == "gate":
|
|
1098
|
-
return cmd_gate(args)
|
|
1099
|
-
if args.cmd == "orient":
|
|
1100
|
-
return cmd_orient(args)
|
|
1101
|
-
|
|
1102
|
-
root = Path(args.root).resolve() if args.root else find_project_root()
|
|
1103
|
-
if args.cmd == "check":
|
|
1104
|
-
return cmd_check(root, strict=args.strict, hybrid=args.hybrid)
|
|
1105
|
-
if args.cmd == "validate":
|
|
1106
|
-
return cmd_validate(root, strict=args.strict)
|
|
1107
|
-
if args.cmd == "index":
|
|
1108
|
-
return cmd_index(root)
|
|
1109
|
-
if args.cmd == "stale":
|
|
1110
|
-
return cmd_stale(root, hybrid=args.hybrid)
|
|
1111
|
-
if args.cmd == "mark":
|
|
1112
|
-
return cmd_mark(root, args.paths)
|
|
1113
|
-
if args.cmd == "plan":
|
|
1114
|
-
return cmd_plan(root, args)
|
|
1115
|
-
return 0
|
|
67
|
+
return sdlc_core.main(argv)
|
|
1116
68
|
|
|
1117
69
|
|
|
1118
70
|
if __name__ == "__main__":
|