@antoneeo/course-creator 0.1.0-beta.1
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 +15 -0
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.md +25 -0
- package/gemini-extension.json +6 -0
- package/package.json +52 -0
- package/scripts/init.js +62 -0
- package/scripts/lib.js +734 -0
- package/scripts/postinstall.js +16 -0
- package/scripts/preuninstall.js +12 -0
- package/skills/course-creator/ENFORCEMENT.md +272 -0
- package/skills/course-creator/LICENSE +202 -0
- package/skills/course-creator/NOTICE +5 -0
- package/skills/course-creator/SKILL.md +95 -0
- package/skills/course-creator/dispatch.md +140 -0
- package/skills/course-creator/elicitation.md +196 -0
- package/skills/course-creator/feedback.md +7 -0
- package/skills/course-creator/guides.md +291 -0
- package/skills/course-creator/learning_design.md +79 -0
- package/skills/course-creator/memory.md +88 -0
- package/skills/course-creator/review.md +450 -0
- package/skills/course-creator/routing.md +118 -0
- package/skills/course-creator/scripts/course_check.py +479 -0
- package/skills/course-creator/scripts/entry_point.py +25 -0
- package/skills/course-creator/scripts/knowledge.py +2089 -0
- package/skills/course-creator/scripts/sdlc_check.py +73 -0
- package/skills/course-creator/scripts/sdlc_core.py +2707 -0
- package/skills/course-creator/scripts/shared_files.py +215 -0
- package/skills/course-creator/scripts/shared_manifest.json +27 -0
- package/skills/course-creator/simulation.md +47 -0
- package/skills/course-creator/slide_content.md +78 -0
- package/skills/course-creator/source_check.md +28 -0
- package/skills/course-creator/templates.md +222 -0
- package/skills/course-creator/vision.md +245 -0
|
@@ -0,0 +1,2707 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Mechanical validator for the Agentic SDLC family — the shared core.
|
|
4
|
+
|
|
5
|
+
This module is the SPINE: it is authored once here and shipped verbatim in every
|
|
6
|
+
distribution of the family (code, knowledge, marketing). It carries nothing that
|
|
7
|
+
belongs to a single domain. Each distribution ships a thin entry point beside it —
|
|
8
|
+
`sdlc_check.py` for code, `mkt_check.py` for marketing — which names the domain it
|
|
9
|
+
implements and delegates everything else here. A drift guard fails CI when the
|
|
10
|
+
copies diverge, so do not fork this file: fix it once and copy it.
|
|
11
|
+
|
|
12
|
+
It is also runnable on its own (`python sdlc_core.py check`) for the same reason
|
|
13
|
+
the entry points are thin: the behaviour is here, not in them.
|
|
14
|
+
|
|
15
|
+
Commands:
|
|
16
|
+
check single closure gate: validate + stale in one command (exit 1 if either fails)
|
|
17
|
+
validate verify the structural coherence of the docs root (default ai_docs/; exit 1 on
|
|
18
|
+
errors; --strict also fails on warnings or on a missing docs root, for CI)
|
|
19
|
+
index regenerate the generated indexes: strategic/features_history.md (from the
|
|
20
|
+
frontmatter of ANALYSIS_*.md files) and ai_docs/INDEX.md (manifest of canonical docs)
|
|
21
|
+
stale list areas modified after the last analysis recorded in audit_plan.md (exit 1 if any)
|
|
22
|
+
mark record paths as ANALYZED with the current reference (git hash, else UTC timestamp)
|
|
23
|
+
gate PreToolUse hook: block writes on protected paths without an IN_PROGRESS ANALYSIS (exit 2)
|
|
24
|
+
|
|
25
|
+
Hybrid/devPNT mode: pass --hybrid explicitly on check/stale (skips audit-plan
|
|
26
|
+
staleness, delegated to devPNT/KL) and on gate (also unlocks when an approved
|
|
27
|
+
E-TDD shadow, solutions/SHADOW_*tdd*.md, exists).
|
|
28
|
+
|
|
29
|
+
Canonical language is English. Legacy Italian frontmatter keys (stato, livello,
|
|
30
|
+
data_inizio, data_fine) and section headings are still accepted for existing projects,
|
|
31
|
+
but are deprecated: new documents should use the English forms.
|
|
32
|
+
|
|
33
|
+
Standard library only (Python >= 3.8). Windows and POSIX compatible.
|
|
34
|
+
"""
|
|
35
|
+
import argparse
|
|
36
|
+
import hashlib
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
import re
|
|
40
|
+
import subprocess
|
|
41
|
+
import sys
|
|
42
|
+
from datetime import datetime, timedelta, timezone
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
VALID_STATES = {"PLANNED", "IN_PROGRESS", "COMPLETED", "CANCELLED"}
|
|
46
|
+
VALID_LEVELS = {"L1", "L2", "L3", "SPIKE"}
|
|
47
|
+
VISION_FILES = ("project_vision.md", "roadmap.md", "principles.md")
|
|
48
|
+
SKIP_DIRS = {".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv",
|
|
49
|
+
"dist", "build", ".idea", ".vs"} # the docs root is excluded separately: its
|
|
50
|
+
# name is resolved per invocation, so it cannot live in a frozen set (see docs_dir()).
|
|
51
|
+
INDEX_HEADER = ("<!-- GENERATED by sdlc_check.py index - do not edit by hand. "
|
|
52
|
+
"Source of truth: frontmatter of the ANALYSIS_*.md files -->")
|
|
53
|
+
def manifest_header():
|
|
54
|
+
return ("<!-- GENERATED by sdlc_check.py index - do not edit by hand. "
|
|
55
|
+
f"Source of truth: the headers of the canonical documents in {docs_dir()}/. -->")
|
|
56
|
+
# Directories whose .md files are durable canonical documents: manifested in INDEX.md.
|
|
57
|
+
# audit/ and solutions/ stay discovery-by-grep (session / process artifacts), not manifested.
|
|
58
|
+
MANIFEST_DIRS = ("vision", "reference", "architecture", "functional", "strategic")
|
|
59
|
+
# Recognized states: canonical docs (CURRENT/SUPERSEDED/...), vision (DRAFT/APPROVED),
|
|
60
|
+
# ADR (Accepted/Proposed/Rejected). Union, to avoid false warnings on conventions in use.
|
|
61
|
+
CANONICAL_STATES = {"CURRENT", "SUPERSEDED", "DRAFT", "DEPRECATED",
|
|
62
|
+
"APPROVED", "ACCEPTED", "PROPOSED", "REJECTED"}
|
|
63
|
+
GENERATED_DOCS = {"features_history.md", "INDEX.md"} # generated: never manifest entries
|
|
64
|
+
MTIME_GRACE = timedelta(seconds=2)
|
|
65
|
+
def guide_index_header():
|
|
66
|
+
return ("<!-- GENERATED by sdlc_check.py index - do not edit by hand. "
|
|
67
|
+
f"Source of truth: the headers of the GUIDE_*.md files in {docs_dir()}/reference/. -->")
|
|
68
|
+
GUIDE_PROVENANCE_KEYS = ("source", "distilled_from", "source_hash") # source_version optional
|
|
69
|
+
# a guide section is "covered" when it carries a source marker or an explicit gap marker
|
|
70
|
+
GUIDE_MARKER_RE = re.compile(r"\[(?:source:[^\]]+|not covered by source)\]")
|
|
71
|
+
# Agent-global KB (Feature B unit 2): ONE client-agnostic root under home.
|
|
72
|
+
# AGENTIC_SDLC_KB_ROOT env var is a TEST/CI seam only (scenario battery must
|
|
73
|
+
# not touch the real user KB); the documented product path is fixed.
|
|
74
|
+
DEFAULT_KB_ROOT = Path(os.environ.get("AGENTIC_SDLC_KB_ROOT", "")) if os.environ.get("AGENTIC_SDLC_KB_ROOT") else Path.home() / ".agentic-sdlc"
|
|
75
|
+
# Subagent Execution (Feature A): a PLAN_[feature].md task must carry these keys,
|
|
76
|
+
# plus at least one of paths/produces (checked separately in cmd_plan).
|
|
77
|
+
PLAN_TASK_REQUIRED = ("id", "title", "verify")
|
|
78
|
+
|
|
79
|
+
# Deprecated Italian frontmatter keys, mapped to the canonical English ones.
|
|
80
|
+
LEGACY_KEYS = {"stato": "status", "livello": "level",
|
|
81
|
+
"data_inizio": "start_date", "data_fine": "end_date"}
|
|
82
|
+
|
|
83
|
+
# Architect pass (F-020): the Capability Ledger is due for ACTIVE L3 analyses
|
|
84
|
+
# born on/after the day the pass shipped. Grandfathering by start_date -- an
|
|
85
|
+
# in-flight analysis from before the pass existed never nags (same lazy-convert
|
|
86
|
+
# doctrine as the pre-1.17 narrative handoff).
|
|
87
|
+
ARCHITECT_PASS_EPOCH = "2026-07-28"
|
|
88
|
+
# Design-review gate (F-021): an L3 started on/after this date owes a REVIEW_LOG
|
|
89
|
+
# row. Same grandfathering discipline as the pass above -- never nag work that
|
|
90
|
+
# predates the rule.
|
|
91
|
+
DESIGN_REVIEW_EPOCH = "2026-07-28"
|
|
92
|
+
def review_log_rel():
|
|
93
|
+
return f"{docs_dir()}/audit/reviews/REVIEW_LOG.md"
|
|
94
|
+
# Component Map 'Where' refs: a dotted token counts as a path only with one of
|
|
95
|
+
# these suffixes. Deliberately a closed list -- a generic ".\w{1,5}$" turns
|
|
96
|
+
# `app.core`, `OrderStore.save` and `1.18.0` into "the map is rotting".
|
|
97
|
+
FILE_SUFFIXES = ("md", "py", "js", "mjs", "cjs", "ts", "tsx", "jsx", "json", "yaml",
|
|
98
|
+
"yml", "toml", "ini", "cfg", "sh", "bat", "ps1", "go", "rs", "java",
|
|
99
|
+
"kt", "rb", "php", "cs", "swift", "c", "h", "cpp", "hpp", "sql",
|
|
100
|
+
"css", "scss", "html", "vue", "svelte", "tf", "proto", "txt")
|
|
101
|
+
|
|
102
|
+
# ANALYSIS sections: (canonical English heading, legacy Italian heading).
|
|
103
|
+
SECURITY_SECTION = ("## Security", "## Sicurezza")
|
|
104
|
+
ANALYSIS_SECTIONS = (
|
|
105
|
+
("## Objective", "## Obiettivo"),
|
|
106
|
+
("## Feature Vision", "## Vision della Feature"),
|
|
107
|
+
("## Impact", "## Impatto"),
|
|
108
|
+
("## Action Plan", "## Piano d'Azione"),
|
|
109
|
+
("## Test Strategy", "## Strategia di Test"),
|
|
110
|
+
("## Diary", "## Diario"),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# ------------------------------------------------------------------- docs root
|
|
114
|
+
# `ai_docs/` is the ONE surviving documentation root and the default everywhere.
|
|
115
|
+
# The name is a parameter for exactly two reasons, both temporary by nature: a
|
|
116
|
+
# project that predates the convention has to be READ before it can be migrated,
|
|
117
|
+
# and the migration tool has to see both sides. It is not an invitation to keep a
|
|
118
|
+
# second permanent root -- which is why `init.js` has no rename knob.
|
|
119
|
+
DEFAULT_DOCS_DIR = "ai_docs"
|
|
120
|
+
DOCS_DIR_CANDIDATES = ("ai_docs", "mkt_docs")
|
|
121
|
+
DOCS_DIR_ENV = "AGENTIC_SDLC_DOCS_DIR" # test/CI seam, read per invocation
|
|
122
|
+
_DOCS_DIR = {"name": DEFAULT_DOCS_DIR}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def docs_dir():
|
|
126
|
+
"""The resolved documentation root NAME for this invocation."""
|
|
127
|
+
return _DOCS_DIR["name"]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def set_docs_dir(name):
|
|
131
|
+
_DOCS_DIR["name"] = name or DEFAULT_DOCS_DIR
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def ai_path(root, *parts):
|
|
135
|
+
"""A path inside the resolved documentation root."""
|
|
136
|
+
return Path(root).joinpath(docs_dir(), *parts)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def resolve_docs_dir(args=None, start=None):
|
|
140
|
+
"""Explicit beats guessed, exactly like --hybrid.
|
|
141
|
+
|
|
142
|
+
Order: --docs-dir, then the env seam (read now, not at import, or it could not
|
|
143
|
+
be varied by a test), then discovery, then the default. Returns (root, name);
|
|
144
|
+
the root is None when discovery did not run.
|
|
145
|
+
"""
|
|
146
|
+
explicit = getattr(args, "docs_dir", None) or os.environ.get(DOCS_DIR_ENV)
|
|
147
|
+
if explicit:
|
|
148
|
+
return None, explicit
|
|
149
|
+
return discover_docs_root(start)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def discover_docs_root(start=None):
|
|
153
|
+
"""Walk up looking for a candidate root. PER LEVEL: the nearest one wins.
|
|
154
|
+
|
|
155
|
+
Two candidates at the SAME level is the shape of a half-migrated project.
|
|
156
|
+
Returning either would validate half of it and print a verdict, so it refuses
|
|
157
|
+
-- naming both -- and the caller exits without a verdict.
|
|
158
|
+
"""
|
|
159
|
+
cur = Path(start or os.getcwd()).resolve()
|
|
160
|
+
for p in [cur] + list(cur.parents):
|
|
161
|
+
found = [c for c in DOCS_DIR_CANDIDATES if (p / c).is_dir()]
|
|
162
|
+
if len(found) > 1:
|
|
163
|
+
raise AmbiguousDocsRoot(p, found)
|
|
164
|
+
if found:
|
|
165
|
+
return p, found[0]
|
|
166
|
+
return None, DEFAULT_DOCS_DIR
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class AmbiguousDocsRoot(Exception):
|
|
170
|
+
"""Two documentation roots side by side: a verdict here would be a guess."""
|
|
171
|
+
|
|
172
|
+
def __init__(self, where, found):
|
|
173
|
+
self.where = where
|
|
174
|
+
self.found = found
|
|
175
|
+
super().__init__(
|
|
176
|
+
f"{where} contains more than one documentation root ({', '.join(found)}): "
|
|
177
|
+
"refusing to guess which one this project uses. Pass --docs-dir <name> "
|
|
178
|
+
"to say which, or finish the migration so only one remains.")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# --------------------------------------------------------------------- domains
|
|
182
|
+
# One entry per lens of the family. The DATA lives here, in the shared core, so a
|
|
183
|
+
# mixed tree is validated identically from every installed distribution: an entry
|
|
184
|
+
# point never picks rules, it only declares which portable checks it can run.
|
|
185
|
+
#
|
|
186
|
+
# The exclusive part of a rule set -- template, mandatory sections, the risk slot
|
|
187
|
+
# -- has exactly one owner per document. It is the part that never composes:
|
|
188
|
+
# unioning it would demand every domain's ceremony of every document, intersecting
|
|
189
|
+
# it would demand nothing. The risk slot is translated per domain, never dropped.
|
|
190
|
+
DOMAINS = {
|
|
191
|
+
"code": {
|
|
192
|
+
"risk_section": SECURITY_SECTION,
|
|
193
|
+
"risk_label": "## Security and Threat Model",
|
|
194
|
+
"id_prefix": "F-",
|
|
195
|
+
},
|
|
196
|
+
"knowledge": {
|
|
197
|
+
"risk_section": ("## Sources and Verification",),
|
|
198
|
+
"risk_label": "## Sources and Verification",
|
|
199
|
+
"id_prefix": "K-",
|
|
200
|
+
},
|
|
201
|
+
"marketing": {
|
|
202
|
+
"risk_section": ("## Threat Map / Plan Risks", "## Threat Map"),
|
|
203
|
+
"risk_label": "## Threat Map / Plan Risks",
|
|
204
|
+
"id_prefix": "M-",
|
|
205
|
+
},
|
|
206
|
+
"course": {
|
|
207
|
+
"risk_section": ("## Learning and Content Risks",),
|
|
208
|
+
"risk_label": "## Learning and Content Risks",
|
|
209
|
+
"id_prefix": "C-",
|
|
210
|
+
},
|
|
211
|
+
}
|
|
212
|
+
# Absent everything -- no `default_domain:` line, no `domain:` field -- a project is
|
|
213
|
+
# `code`. That is what every project created before this field existed already is,
|
|
214
|
+
# so the default is chosen to leave them untouched, not because code is special.
|
|
215
|
+
DEFAULT_DOMAIN = "code"
|
|
216
|
+
|
|
217
|
+
# Portable checks: composable, opt-in per document via `checks:`. An imported check
|
|
218
|
+
# may only ADD findings, never relax what the owning domain requires -- monotonic, so
|
|
219
|
+
# importing one is safe by construction. Registered by name `<domain>.<check>`.
|
|
220
|
+
PORTABLE_CHECKS = {}
|
|
221
|
+
# Which check namespaces this distribution actually carries. Set by the entry point;
|
|
222
|
+
# a `checks:` entry outside it WARNS visibly rather than passing silently.
|
|
223
|
+
_ENTRY_POINT = {"domain": DEFAULT_DOMAIN, "provides": (), "script": "sdlc_check.py"}
|
|
224
|
+
# "script" is what generated headers tell the reader to RUN. Declared, never
|
|
225
|
+
# derived from sys.argv: the generated bytes must not depend on how the command
|
|
226
|
+
# was invoked, or the alignment check would fail on the invocation instead of on
|
|
227
|
+
# the content.
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# --- distribution profile ----------------------------------------------------
|
|
231
|
+
# Each distribution declares what it carries. The battery reads this instead of
|
|
232
|
+
# assuming the code overlay, which is what lets one shared battery run in three
|
|
233
|
+
# distributions without either failing on files a domain legitimately does not have
|
|
234
|
+
# or quietly excusing a domain from doctrine it owes.
|
|
235
|
+
#
|
|
236
|
+
# REQUIRED_CAPABILITIES is the spine: process discipline that is domain-neutral, so
|
|
237
|
+
# no distribution may drop it. A profile missing one fails a shared test -- editing
|
|
238
|
+
# your own profile is therefore NOT a way out of the doctrine, only a way to declare
|
|
239
|
+
# an overlay you genuinely do not have.
|
|
240
|
+
REQUIRED_CAPABILITIES = frozenset({
|
|
241
|
+
"triage", # Rule Zero, with the router verdict as a declared output
|
|
242
|
+
"write_triggers", # one event, one destination
|
|
243
|
+
"workstream_registry", # audit/handoff.md as a parallel-safe registry
|
|
244
|
+
"vision_gate", # DRAFT informs, APPROVED binds, blind check before promotion
|
|
245
|
+
"design_review_gate", # a design reviewed by somebody other than its author
|
|
246
|
+
"guide_router", # the mandatory pre-work lookup
|
|
247
|
+
"worktree_hygiene", # isolate the work
|
|
248
|
+
})
|
|
249
|
+
# Optional overlays: real capabilities that a domain may legitimately not have.
|
|
250
|
+
# Listed here so "this distribution does not claim it" is a visible decision.
|
|
251
|
+
OPTIONAL_CAPABILITIES = frozenset({
|
|
252
|
+
"architect_pass", # does the component already exist? (code overlay)
|
|
253
|
+
"interaction_contract", # actor-facing surface spec between use cases and solution (code overlay)
|
|
254
|
+
"taxonomy_pass", # do the categories/topics already exist? (knowledge overlay)
|
|
255
|
+
"comprehension_guides", # source_kind: code maps of complex components
|
|
256
|
+
"tdd", # test-first discipline
|
|
257
|
+
"subagent_dispatch", # opt-in PLAN_[feature].md execution
|
|
258
|
+
"legacy_narrative_handoff", # published before the registry format: owes a migration clause
|
|
259
|
+
"question_discipline", # when a question to the user is legal (elicitation.md);
|
|
260
|
+
# spine candidate once every sibling's elicitation carries it
|
|
261
|
+
})
|
|
262
|
+
# `unit_noun` is vocabulary, not structure: the code domain works on a "feature",
|
|
263
|
+
# the knowledge domain on a "topic". The shared battery asserts the SHAPE
|
|
264
|
+
# (HANDOFF_[<unit>].md) and reads the word from here, so a domain keeps its own
|
|
265
|
+
# language without either weakening the assertion or forking the test.
|
|
266
|
+
# `design_gate_between` is the pair of SKILL.md headings the design review must sit
|
|
267
|
+
# between: after the design exists, before the work is executed. The headings are each
|
|
268
|
+
# domain's own wording -- the code overlay runs five phases, the marketing overlay nine
|
|
269
|
+
# -- so the battery asserts the ORDER, never a shared phase name.
|
|
270
|
+
_PROFILE = {
|
|
271
|
+
"skill_name": "agentic-sdlc",
|
|
272
|
+
"unit_noun": "feature",
|
|
273
|
+
"support_files": (),
|
|
274
|
+
"capabilities": frozenset(),
|
|
275
|
+
"design_gate_between": (),
|
|
276
|
+
"remind_line": "",
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def set_profile(skill_name, support_files=(), capabilities=(), unit_noun="feature",
|
|
281
|
+
design_gate_between=(), remind_line=""):
|
|
282
|
+
_PROFILE.update(skill_name=skill_name,
|
|
283
|
+
unit_noun=unit_noun,
|
|
284
|
+
support_files=tuple(support_files),
|
|
285
|
+
capabilities=frozenset(capabilities),
|
|
286
|
+
design_gate_between=tuple(design_gate_between),
|
|
287
|
+
remind_line=remind_line)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def profile():
|
|
291
|
+
return dict(_PROFILE)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def has_capability(name):
|
|
295
|
+
return name in _PROFILE["capabilities"]
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def portable_check(name):
|
|
299
|
+
"""Register a portable check. The callable takes (rel, meta, text) and returns
|
|
300
|
+
a list of (severity, message) with severity in {'error', 'warning', 'advisory'}."""
|
|
301
|
+
def register(fn):
|
|
302
|
+
PORTABLE_CHECKS[name] = fn
|
|
303
|
+
return fn
|
|
304
|
+
return register
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def set_entry_point(domain, provides=(), script=None):
|
|
308
|
+
"""Declare which domain this distribution is and which check namespaces it ships."""
|
|
309
|
+
_ENTRY_POINT["domain"] = domain
|
|
310
|
+
_ENTRY_POINT["provides"] = tuple(provides)
|
|
311
|
+
if script:
|
|
312
|
+
_ENTRY_POINT["script"] = script
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def entry_script():
|
|
316
|
+
"""The command name a generated header tells the reader to run."""
|
|
317
|
+
return _ENTRY_POINT["script"]
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def project_default_domain(root):
|
|
321
|
+
"""The project's answer for every artifact that declares no `domain:`.
|
|
322
|
+
|
|
323
|
+
Read once from `ai_docs/README.md`'s frontmatter. Project-level ON PURPOSE: a
|
|
324
|
+
per-distribution default would give the same tree two different verdicts
|
|
325
|
+
depending on which lens the agent happened to load."""
|
|
326
|
+
readme = ai_path(root, "README.md")
|
|
327
|
+
if not readme.is_file():
|
|
328
|
+
return DEFAULT_DOMAIN
|
|
329
|
+
meta = load_frontmatter(read_text(readme).splitlines())
|
|
330
|
+
declared = (meta.get("default_domain") or "").strip().strip("'\"").lower()
|
|
331
|
+
return declared if declared in DOMAINS else DEFAULT_DOMAIN
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def resolve_domain(meta, default):
|
|
335
|
+
"""(domain, declared_but_unknown) for one artifact. The field RECORDS the answer;
|
|
336
|
+
it never invents one, and an unrecognized value is reported, not obeyed."""
|
|
337
|
+
declared = (meta.get("domain") or "").strip().strip("'\"").lower()
|
|
338
|
+
if not declared:
|
|
339
|
+
return default, None
|
|
340
|
+
if declared not in DOMAINS:
|
|
341
|
+
return default, declared
|
|
342
|
+
return declared, None
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def declared_checks(meta):
|
|
346
|
+
"""The `checks:` list, accepted as `[a, b]` or as a comma-separated string."""
|
|
347
|
+
raw = (meta.get("checks") or "").strip()
|
|
348
|
+
if not raw:
|
|
349
|
+
return []
|
|
350
|
+
return [c.strip().strip("'\"") for c in raw.strip("[]").split(",") if c.strip()]
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def run_portable_checks(rel, meta, text, errors, warnings, advisories):
|
|
354
|
+
"""Run the checks a document imported. Findings are ADDED to the owning domain's;
|
|
355
|
+
an unavailable check is a visible warning -- never a silent pass."""
|
|
356
|
+
for name in declared_checks(meta):
|
|
357
|
+
namespace = name.split(".", 1)[0]
|
|
358
|
+
if name not in PORTABLE_CHECKS:
|
|
359
|
+
if namespace in _ENTRY_POINT["provides"]:
|
|
360
|
+
warnings.append(f"{rel}: check '{name}' is unknown (no such portable check)")
|
|
361
|
+
else:
|
|
362
|
+
warnings.append(
|
|
363
|
+
f"{rel}: check '{name}' is not available in this distribution "
|
|
364
|
+
f"(it ships {', '.join(_ENTRY_POINT['provides']) or 'no checks'}): "
|
|
365
|
+
"the document was NOT checked against it")
|
|
366
|
+
continue
|
|
367
|
+
for severity, message in PORTABLE_CHECKS[name](rel, meta, text) or []:
|
|
368
|
+
{"error": errors, "warning": warnings}.get(severity, advisories).append(
|
|
369
|
+
f"{rel}: [{name}] {message}")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
try:
|
|
373
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
374
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
375
|
+
except Exception:
|
|
376
|
+
pass
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# --- orient (SessionStart hook) ---
|
|
380
|
+
# Fixed, hard-coded doc set (label, path-relative-to-root). No content- or
|
|
381
|
+
# user-derived paths -> no traversal input (P-TM T3); confine_under is
|
|
382
|
+
# defense-in-depth. Emitted at session start by the orient subcommand.
|
|
383
|
+
def orient_docs():
|
|
384
|
+
d = docs_dir()
|
|
385
|
+
return [
|
|
386
|
+
("Reading guide (README)", f"{d}/README.md"),
|
|
387
|
+
("Canonical manifest (INDEX)", f"{d}/INDEX.md"),
|
|
388
|
+
("Guide router (when-to-consult)", f"{d}/reference/INDEX.md"),
|
|
389
|
+
("Last session handoff", f"{d}/audit/handoff.md"),
|
|
390
|
+
]
|
|
391
|
+
ORIENT_PER_DOC_CHARS = 6000 # per-doc truncation
|
|
392
|
+
ORIENT_MAX_TOTAL_CHARS = 16000 # total ingestion cap (P-TM T2); tunable
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# ----------------------------------------------------------------- utilities
|
|
396
|
+
|
|
397
|
+
def utc_now_iso():
|
|
398
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def find_project_root(start=None):
|
|
402
|
+
cur = Path(start or os.getcwd()).resolve()
|
|
403
|
+
for p in [cur] + list(cur.parents):
|
|
404
|
+
if (p / docs_dir()).is_dir():
|
|
405
|
+
return p
|
|
406
|
+
return cur
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def require_ai_docs(root, command):
|
|
410
|
+
"""Fail fast when the docs root is missing: prevents silently creating a second
|
|
411
|
+
documentation root in the wrong working directory."""
|
|
412
|
+
if not ai_path(root).is_dir():
|
|
413
|
+
print(f"[ERROR] {ai_path(root)} not found: refusing to run '{command}' here. "
|
|
414
|
+
"Run agentic-sdlc-init first, or pass --root <project_root>.")
|
|
415
|
+
return False
|
|
416
|
+
return True
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def confine_under(base, rel):
|
|
420
|
+
"""Fail-closed path confinement: resolve `rel` under `base` and require the
|
|
421
|
+
result to stay inside `base`. Returns None (reject) if `rel` is absolute,
|
|
422
|
+
contains a '..' part, or resolves outside `base` (including an OSError
|
|
423
|
+
during resolution, e.g. an unresolvable/reparse-point path on Windows).
|
|
424
|
+
Single source for path confinement (T2/T3): reused by check_kb_collisions'
|
|
425
|
+
`overrides:` check and cmd_validate's `distilled_from` check, and by the
|
|
426
|
+
new `plan` command's paths/consumes/produces/guides confinement."""
|
|
427
|
+
p = Path(rel)
|
|
428
|
+
if p.is_absolute() or ".." in p.parts:
|
|
429
|
+
return None
|
|
430
|
+
try:
|
|
431
|
+
t = (base / rel).resolve()
|
|
432
|
+
t.relative_to(base.resolve())
|
|
433
|
+
return t
|
|
434
|
+
except (ValueError, OSError):
|
|
435
|
+
return None
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def read_text(path):
|
|
439
|
+
# utf-8-sig: strips a leading BOM (files authored on Windows) so the
|
|
440
|
+
# frontmatter '---' on line 0 stays recognizable; reads plain utf-8 otherwise.
|
|
441
|
+
return path.read_text(encoding="utf-8-sig", errors="replace")
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def sha256_file(path):
|
|
445
|
+
# CRLF->LF before hashing: a Windows checkout with core.autocrlf=true
|
|
446
|
+
# rewrites snapshot files, and a raw-byte hash would flag every guide
|
|
447
|
+
# [stale] on a fresh clone. Recorded hashes are LF-based, so normalizing
|
|
448
|
+
# maps CRLF copies back to the same digest.
|
|
449
|
+
h = hashlib.sha256()
|
|
450
|
+
h.update(path.read_bytes().replace(b"\r\n", b"\n"))
|
|
451
|
+
return h.hexdigest()
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def parse_iso(value):
|
|
455
|
+
if not value:
|
|
456
|
+
return None
|
|
457
|
+
try:
|
|
458
|
+
dt = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
|
459
|
+
if dt.tzinfo is None:
|
|
460
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
461
|
+
return dt
|
|
462
|
+
except ValueError:
|
|
463
|
+
return None
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def norm_text(s):
|
|
467
|
+
return "\n".join(line.rstrip() for line in s.strip().splitlines())
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def load_frontmatter(lines):
|
|
471
|
+
meta = {}
|
|
472
|
+
if not lines or lines[0].strip() != "---":
|
|
473
|
+
return meta
|
|
474
|
+
for line in lines[1:60]:
|
|
475
|
+
if line.strip() == "---":
|
|
476
|
+
break
|
|
477
|
+
m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", line)
|
|
478
|
+
if m:
|
|
479
|
+
meta[m.group(1).strip().lower()] = m.group(2).strip()
|
|
480
|
+
# Legacy Italian keys: accepted, normalized to canonical English (deprecated).
|
|
481
|
+
for legacy, canon in LEGACY_KEYS.items():
|
|
482
|
+
if legacy in meta and canon not in meta:
|
|
483
|
+
meta[canon] = meta[legacy]
|
|
484
|
+
return meta
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def is_shadow(path, first_line):
|
|
488
|
+
"""A shadow mirror of a devPNT-governed document, not an authoritative ANALYSIS.
|
|
489
|
+
Recognized by filename (SHADOW_*) or by the marker comment on the FIRST line
|
|
490
|
+
(legacy shadows saved under an ANALYSIS_* name)."""
|
|
491
|
+
return path.name.startswith("SHADOW") or first_line.lstrip().startswith("<!-- SHADOW")
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def list_analyses(root):
|
|
495
|
+
"""Returns [(path, frontmatter, text)] for the ANALYSIS_*.md files (shadows excluded)."""
|
|
496
|
+
sol = ai_path(root, "solutions")
|
|
497
|
+
out = []
|
|
498
|
+
if not sol.is_dir():
|
|
499
|
+
return out
|
|
500
|
+
for p in sorted(sol.glob("ANALYSIS_*.md")):
|
|
501
|
+
text = read_text(p)
|
|
502
|
+
first_line = text.splitlines()[0] if text else ""
|
|
503
|
+
if is_shadow(p, first_line):
|
|
504
|
+
continue
|
|
505
|
+
out.append((p, load_frontmatter(text.splitlines()), text))
|
|
506
|
+
return out
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def has_etdd_shadow(root):
|
|
510
|
+
"""True if an E-TDD shadow exported from devPNT exists in solutions/.
|
|
511
|
+
In Hybrid mode the approved E-TDD (exported BEFORE implementation) is the
|
|
512
|
+
design authorization that replaces the IN_PROGRESS ANALYSIS."""
|
|
513
|
+
sol = ai_path(root, "solutions")
|
|
514
|
+
if not sol.is_dir():
|
|
515
|
+
return False
|
|
516
|
+
return any("tdd" in p.name.lower() for p in sol.glob("SHADOW_*.md"))
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def iter_files(target):
|
|
520
|
+
if target.is_file():
|
|
521
|
+
yield target
|
|
522
|
+
return
|
|
523
|
+
for dirpath, dirnames, filenames in os.walk(target):
|
|
524
|
+
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and d != docs_dir() and not d.startswith(".")]
|
|
525
|
+
for name in filenames:
|
|
526
|
+
yield Path(dirpath) / name
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
# ---------------------------------------------------------------------- git
|
|
530
|
+
|
|
531
|
+
def git_available(root):
|
|
532
|
+
try:
|
|
533
|
+
r = subprocess.run(["git", "rev-parse", "--is-inside-work-tree"],
|
|
534
|
+
cwd=str(root), capture_output=True, text=True, timeout=10)
|
|
535
|
+
return r.returncode == 0 and r.stdout.strip() == "true"
|
|
536
|
+
except Exception:
|
|
537
|
+
return False
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def git_head(root):
|
|
541
|
+
try:
|
|
542
|
+
r = subprocess.run(["git", "rev-parse", "--short=12", "HEAD"],
|
|
543
|
+
cwd=str(root), capture_output=True, text=True, timeout=10)
|
|
544
|
+
return r.stdout.strip() if r.returncode == 0 else ""
|
|
545
|
+
except Exception:
|
|
546
|
+
return ""
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def git_has_changes(root, rel_path):
|
|
550
|
+
"""True if there are tracked/untracked changes under rel_path."""
|
|
551
|
+
try:
|
|
552
|
+
rel = rel_path.replace("\\", "/")
|
|
553
|
+
r = subprocess.run(["git", "status", "--porcelain", "--", rel],
|
|
554
|
+
cwd=str(root), capture_output=True, text=True, timeout=30)
|
|
555
|
+
return r.returncode == 0 and bool(r.stdout.strip())
|
|
556
|
+
except Exception:
|
|
557
|
+
return False
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def git_changed_since(root, ref, rel_path):
|
|
561
|
+
"""Files changed (tracked + untracked) under rel_path since ref. None if ref unresolvable."""
|
|
562
|
+
try:
|
|
563
|
+
r = subprocess.run(["git", "diff", "--name-only", ref, "--", rel_path],
|
|
564
|
+
cwd=str(root), capture_output=True, text=True, timeout=30)
|
|
565
|
+
if r.returncode != 0:
|
|
566
|
+
return None
|
|
567
|
+
changed = [l.strip() for l in r.stdout.splitlines() if l.strip()]
|
|
568
|
+
r2 = subprocess.run(["git", "ls-files", "--others", "--exclude-standard", "--", rel_path],
|
|
569
|
+
cwd=str(root), capture_output=True, text=True, timeout=30)
|
|
570
|
+
if r2.returncode == 0:
|
|
571
|
+
changed += [l.strip() for l in r2.stdout.splitlines() if l.strip()]
|
|
572
|
+
return sorted(set(changed))
|
|
573
|
+
except Exception:
|
|
574
|
+
return None
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def git_ref_state(root, ref):
|
|
578
|
+
"""Where a recorded commit stands against HEAD (F-056).
|
|
579
|
+
|
|
580
|
+
'ancestor' -- in HEAD's history; 'orphan' -- a commit HEAD's history does not
|
|
581
|
+
contain (amended, rebased or squash-merged away, or marked on another branch:
|
|
582
|
+
it stops resolving once pruned, and on any fresh clone); 'missing' -- not a
|
|
583
|
+
commit in this repository; 'unknown' -- git gave no yes/no answer.
|
|
584
|
+
Callers pass a hash-shaped ref only: that check is what keeps document
|
|
585
|
+
content from reaching git's argv as an option."""
|
|
586
|
+
try:
|
|
587
|
+
r = subprocess.run(["git", "cat-file", "-e", ref + "^{commit}"],
|
|
588
|
+
cwd=str(root), capture_output=True, text=True, timeout=30)
|
|
589
|
+
if r.returncode != 0:
|
|
590
|
+
return "missing"
|
|
591
|
+
r = subprocess.run(["git", "merge-base", "--is-ancestor", ref, "HEAD"],
|
|
592
|
+
cwd=str(root), capture_output=True, text=True, timeout=30)
|
|
593
|
+
except Exception:
|
|
594
|
+
return "unknown"
|
|
595
|
+
return {0: "ancestor", 1: "orphan"}.get(r.returncode, "unknown")
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def git_is_shallow(root):
|
|
599
|
+
try:
|
|
600
|
+
r = subprocess.run(["git", "rev-parse", "--is-shallow-repository"],
|
|
601
|
+
cwd=str(root), capture_output=True, text=True, timeout=10)
|
|
602
|
+
return r.returncode == 0 and r.stdout.strip() == "true"
|
|
603
|
+
except Exception:
|
|
604
|
+
return False
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
# -------------------------------------------------------------------- index
|
|
608
|
+
|
|
609
|
+
def build_index(root):
|
|
610
|
+
analyses = list_analyses(root)
|
|
611
|
+
# SYNTACTIC predicate, on purpose: the column appears when some analysis WRITES
|
|
612
|
+
# a `domain:` field, not when one resolves to a domain (every analysis does).
|
|
613
|
+
# So the generated file is a function of the tree alone -- identical from every
|
|
614
|
+
# entry point, and byte-identical on every tree that predates the field.
|
|
615
|
+
tagged = any((meta.get("domain") or "").strip() for _, meta, _ in analyses)
|
|
616
|
+
default_domain = project_default_domain(root) if tagged else None
|
|
617
|
+
rows = []
|
|
618
|
+
for p, meta, _ in analyses:
|
|
619
|
+
row = [
|
|
620
|
+
meta.get("id", "?"),
|
|
621
|
+
meta.get("feature", p.stem.replace("ANALYSIS_", "")),
|
|
622
|
+
meta.get("level", ""),
|
|
623
|
+
meta.get("status", "?"),
|
|
624
|
+
meta.get("start_date", ""),
|
|
625
|
+
meta.get("end_date", ""),
|
|
626
|
+
"solutions/" + p.name,
|
|
627
|
+
]
|
|
628
|
+
if tagged:
|
|
629
|
+
row.insert(2, resolve_domain(meta, default_domain)[0])
|
|
630
|
+
rows.append(tuple(row))
|
|
631
|
+
rows.sort(key=lambda r: r[0])
|
|
632
|
+
header = "| ID | Feature | Level | Status | Started | Finished | Doc |"
|
|
633
|
+
sep = "|---|---|---|---|---|---|---|"
|
|
634
|
+
if tagged:
|
|
635
|
+
header = "| ID | Feature | Domain | Level | Status | Started | Finished | Doc |"
|
|
636
|
+
sep = "|---|---|---|---|---|---|---|---|"
|
|
637
|
+
lines = [INDEX_HEADER,
|
|
638
|
+
"# Feature History (generated)",
|
|
639
|
+
"",
|
|
640
|
+
header,
|
|
641
|
+
sep]
|
|
642
|
+
for r in rows:
|
|
643
|
+
lines.append("| " + " | ".join(r) + " |")
|
|
644
|
+
return "\n".join(lines) + "\n"
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
# "Status:"/"Stato:" line in the body (with or without ** **), prefix before the description
|
|
648
|
+
_STATUS_LINE = re.compile(r"^\**\s*(?:status|stato)\s*\**\s*:\s*\**\s*([A-Za-z][\w-]*)", re.I)
|
|
649
|
+
# pure metadata lines to skip when picking the fallback description
|
|
650
|
+
_META_LINE = re.compile(r"^\**\s*(date|data|task ref|version|versione|owner|autore|branch|agente|agent|created|creato|updated|aggiornato)\b", re.I)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def extract_doc_meta(path):
|
|
654
|
+
"""(title, description, status, supersedes) of a canonical doc.
|
|
655
|
+
|
|
656
|
+
Recognizes TWO header conventions: the YAML-lite frontmatter
|
|
657
|
+
(description/status/supersedes/title) and the in-body `**Status:** X`
|
|
658
|
+
line (used by ADRs and legacy docs). As a fallback it derives the title
|
|
659
|
+
from the first '# H1' and the description from the first prose line,
|
|
660
|
+
skipping metadata lines.
|
|
661
|
+
"""
|
|
662
|
+
text = read_text(path)
|
|
663
|
+
lines = text.splitlines()
|
|
664
|
+
meta = load_frontmatter(lines)
|
|
665
|
+
body = lines
|
|
666
|
+
if lines and lines[0].strip() == "---":
|
|
667
|
+
for i in range(1, min(len(lines), 60)):
|
|
668
|
+
if lines[i].strip() == "---":
|
|
669
|
+
body = lines[i + 1:]
|
|
670
|
+
break
|
|
671
|
+
|
|
672
|
+
title = meta.get("title", "")
|
|
673
|
+
if not title:
|
|
674
|
+
for line in body:
|
|
675
|
+
m = re.match(r"^#\s+(.*)$", line)
|
|
676
|
+
if m:
|
|
677
|
+
title = m.group(1).strip()
|
|
678
|
+
break
|
|
679
|
+
title = title or path.stem
|
|
680
|
+
|
|
681
|
+
status = meta.get("status", "").upper()
|
|
682
|
+
if not status:
|
|
683
|
+
for line in body[:25]:
|
|
684
|
+
m = _STATUS_LINE.match(line.strip())
|
|
685
|
+
if m:
|
|
686
|
+
status = m.group(1).upper()
|
|
687
|
+
break
|
|
688
|
+
|
|
689
|
+
desc = meta.get("description", "")
|
|
690
|
+
if not desc:
|
|
691
|
+
in_comment = False
|
|
692
|
+
for line in body:
|
|
693
|
+
s = line.strip()
|
|
694
|
+
# track HTML-comment state across lines: skipping only the OPENING
|
|
695
|
+
# line made line 2 of a multi-line comment the manifest description
|
|
696
|
+
# (the shipped vision template opens with a 3-line comment, so the
|
|
697
|
+
# most-read row of the manifest read '... -->')
|
|
698
|
+
if in_comment:
|
|
699
|
+
if "-->" in s:
|
|
700
|
+
in_comment = False
|
|
701
|
+
s = s.split("-->", 1)[1].strip()
|
|
702
|
+
if not s:
|
|
703
|
+
continue
|
|
704
|
+
else:
|
|
705
|
+
continue
|
|
706
|
+
elif s.startswith("<!--"):
|
|
707
|
+
if "-->" not in s:
|
|
708
|
+
in_comment = True
|
|
709
|
+
continue
|
|
710
|
+
s = s.split("-->", 1)[1].strip()
|
|
711
|
+
if not s:
|
|
712
|
+
continue
|
|
713
|
+
# a table row or a bare bullet is not a description: the manifest is
|
|
714
|
+
# the first thing an agent reads to orient, and '| Milestone | ... |'
|
|
715
|
+
# in that column is a row carrying no information
|
|
716
|
+
if (not s or s.startswith("#") or s.startswith("|") or s.startswith("---")
|
|
717
|
+
or re.match(r"^[-*+]\s", s) or _META_LINE.match(s)):
|
|
718
|
+
continue
|
|
719
|
+
if s.startswith(">"):
|
|
720
|
+
s = s.lstrip(">").strip()
|
|
721
|
+
m = _STATUS_LINE.match(s)
|
|
722
|
+
if m:
|
|
723
|
+
# "Status: X — description": keep the part after the status; if empty, skip
|
|
724
|
+
rest = s[m.end():].strip(" *—–-:.")
|
|
725
|
+
if not rest:
|
|
726
|
+
continue
|
|
727
|
+
s = rest
|
|
728
|
+
if s:
|
|
729
|
+
desc = s
|
|
730
|
+
break
|
|
731
|
+
desc = re.sub(r"\s+", " ", desc).strip()
|
|
732
|
+
if len(desc) > 160:
|
|
733
|
+
desc = desc[:157].rstrip() + "..."
|
|
734
|
+
return title, desc, status, meta.get("supersedes", "").strip()
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def list_canonical_docs(root):
|
|
738
|
+
"""[(rel_to_ai_docs, path, (title, desc, status, supersedes))] for canonical docs."""
|
|
739
|
+
ai = ai_path(root)
|
|
740
|
+
out = []
|
|
741
|
+
for d in MANIFEST_DIRS:
|
|
742
|
+
base = ai / d
|
|
743
|
+
if not base.is_dir():
|
|
744
|
+
continue
|
|
745
|
+
for p in sorted(base.rglob("*.md")):
|
|
746
|
+
rel_parts = p.relative_to(base).parts
|
|
747
|
+
if any(part.startswith(".") for part in rel_parts[:-1]):
|
|
748
|
+
continue # dot-subdirs (e.g. reference/.sources/) are never canonical
|
|
749
|
+
if p.name in GENERATED_DOCS or p.name == "README.md":
|
|
750
|
+
continue
|
|
751
|
+
out.append((p.relative_to(ai).as_posix(), p, extract_doc_meta(p)))
|
|
752
|
+
return out
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def build_manifest(root):
|
|
756
|
+
docs = list_canonical_docs(root)
|
|
757
|
+
lines = [manifest_header(),
|
|
758
|
+
f"# `{docs_dir()}/` document index (generated)",
|
|
759
|
+
"",
|
|
760
|
+
"Complete manifest of the canonical documents. For the reading priority",
|
|
761
|
+
"(must-reads) see the hand-curated `README.md`. The ANALYSIS history is in",
|
|
762
|
+
"`strategic/features_history.md`. `audit/` and `solutions/` are discovery-by-grep,",
|
|
763
|
+
"not manifested here."]
|
|
764
|
+
by_dir = {}
|
|
765
|
+
for rel, _, meta in docs:
|
|
766
|
+
by_dir.setdefault(rel.split("/", 1)[0], []).append((rel, meta))
|
|
767
|
+
for top in MANIFEST_DIRS:
|
|
768
|
+
rows = by_dir.get(top)
|
|
769
|
+
if not rows:
|
|
770
|
+
continue
|
|
771
|
+
lines += ["", f"## {top}/", "",
|
|
772
|
+
"| Document | Status | Description |", "|---|---|---|"]
|
|
773
|
+
for rel, (title, desc, status, _sup) in rows:
|
|
774
|
+
d = (desc or title).replace("|", "\\|")
|
|
775
|
+
lines.append(f"| `{rel}` | {status or '-'} | {d} |")
|
|
776
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
# ------------------------------------------------- workstream registry (F-028)
|
|
780
|
+
# audit/handoff.md is GENERATED from one source file per open workstream, so two
|
|
781
|
+
# writers working two workstreams touch two different files. Row-per-workstream
|
|
782
|
+
# alone was not enough -- F-019 had that and the file still conflicted twice,
|
|
783
|
+
# because a file-global `Date:` header defeats row-level ownership. The header is
|
|
784
|
+
# now DERIVED, so no writer touches it.
|
|
785
|
+
|
|
786
|
+
REGISTRY_COLUMNS = ("Workstream", "Level", "Branch", "Status", "Since",
|
|
787
|
+
"Next step", "Details")
|
|
788
|
+
REGISTRY_KEYS = ("workstream", "level", "branch", "status", "since", "next")
|
|
789
|
+
REGISTRY_CAP = 20 # a signal, never a truncation (see cmd_index)
|
|
790
|
+
PROJECT_NOTES = "project_notes.md" # NOT handoff_notes.md: the HANDOFF_*.md
|
|
791
|
+
# glob is case-insensitive on Windows, and that name would be collected as a
|
|
792
|
+
# source. The trap is real; the name is the fix.
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def registry_header():
|
|
796
|
+
return (f"<!-- GENERATED by {entry_script()} index - do not edit by hand. "
|
|
797
|
+
f"Source of truth: the HANDOFF_*.md files in {docs_dir()}/audit/. -->")
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def list_workstreams(root):
|
|
801
|
+
"""[(path, meta)] for audit/HANDOFF_*.md carrying registry frontmatter.
|
|
802
|
+
|
|
803
|
+
Opt-in by the presence of `workstream:` -- a project whose handoff is still
|
|
804
|
+
hand-written has no sources, so nothing generates and nothing errors (the
|
|
805
|
+
F-019 migration lesson). Sorted by workstream id: the alignment check is a
|
|
806
|
+
byte comparison, and glob order differs across filesystems."""
|
|
807
|
+
aud = ai_path(root, "audit")
|
|
808
|
+
out = []
|
|
809
|
+
if not aud.is_dir():
|
|
810
|
+
return out
|
|
811
|
+
for p in sorted(aud.glob("HANDOFF_*.md")):
|
|
812
|
+
meta = load_frontmatter(read_text(p).splitlines())
|
|
813
|
+
if str(meta.get("workstream") or "").strip():
|
|
814
|
+
out.append((p, meta))
|
|
815
|
+
out.sort(key=lambda pm: (str(pm[1].get("workstream")).strip().lower(), pm[0].name))
|
|
816
|
+
return out
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _registry_cell(value):
|
|
820
|
+
text = str(value if value is not None else "").strip()
|
|
821
|
+
return text.replace("|", "\\|") or "-"
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def build_registry(root):
|
|
825
|
+
"""The generated registry, or "" when there is no source to build it from.
|
|
826
|
+
|
|
827
|
+
Deterministic by construction: sorted rows, and a `Date:` taken from the
|
|
828
|
+
newest `updated:` VALUE written inside the sources -- never a filesystem
|
|
829
|
+
timestamp. Git does not preserve mtimes, so an mtime-derived header would
|
|
830
|
+
regenerate differently in every fresh clone and the alignment check would
|
|
831
|
+
fire on a tree nobody touched."""
|
|
832
|
+
rows = list_workstreams(root)
|
|
833
|
+
if not rows:
|
|
834
|
+
return ""
|
|
835
|
+
stamp = max(str(m.get("updated") or m.get("since") or "").strip() or "0000-00-00"
|
|
836
|
+
for _p, m in rows)
|
|
837
|
+
lines = ["# Handoff — workstream registry",
|
|
838
|
+
f"Date: {stamp} (UTC)",
|
|
839
|
+
"",
|
|
840
|
+
registry_header(),
|
|
841
|
+
"",
|
|
842
|
+
"| " + " | ".join(REGISTRY_COLUMNS) + " |",
|
|
843
|
+
"|" + "---|" * len(REGISTRY_COLUMNS)]
|
|
844
|
+
for p, meta in rows:
|
|
845
|
+
extra = str(meta.get("details") or "").strip()
|
|
846
|
+
details = f"{p.name} · {extra}" if extra else p.name
|
|
847
|
+
cells = [_registry_cell(meta.get(k)) for k in REGISTRY_KEYS]
|
|
848
|
+
lines.append("| " + " | ".join(cells) + f" | {_registry_cell(details)} |")
|
|
849
|
+
notes = ai_path(root, "audit", PROJECT_NOTES)
|
|
850
|
+
if notes.is_file():
|
|
851
|
+
body = read_text(notes).strip()
|
|
852
|
+
if body:
|
|
853
|
+
lines += ["", "## Project-wide notes", "", body]
|
|
854
|
+
return "\n".join(lines) + "\n"
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def parse_registry_rows(text):
|
|
858
|
+
"""Workstream ids in a registry table, hand-written or generated."""
|
|
859
|
+
ids = []
|
|
860
|
+
for line in text.splitlines():
|
|
861
|
+
s = line.strip()
|
|
862
|
+
if not s.startswith("|") or set(s) <= {"|", "-", " ", ":"}:
|
|
863
|
+
continue
|
|
864
|
+
first = s.strip("|").split("|")[0].strip()
|
|
865
|
+
if first and first.lower() != "workstream":
|
|
866
|
+
ids.append(first)
|
|
867
|
+
return ids
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def registry_conversion_blockers(root):
|
|
871
|
+
"""What stops `index` from writing over a hand-written handoff.md.
|
|
872
|
+
|
|
873
|
+
The mixed state is the trap this exists for: converting one row at a time
|
|
874
|
+
leaves a project at one source file and five hand-written rows, and
|
|
875
|
+
regenerating from the one source DELETES the other five -- silently, in the
|
|
876
|
+
file whose whole purpose is not losing them. So conversion is per project.
|
|
877
|
+
Empty list = writing is safe."""
|
|
878
|
+
hand = ai_path(root, "audit", "handoff.md")
|
|
879
|
+
if not hand.is_file():
|
|
880
|
+
return []
|
|
881
|
+
text = read_text(hand)
|
|
882
|
+
# "Already ours" = written by ANY family entry point: the header is WRITTEN
|
|
883
|
+
# with entry_script(), so recognition must not hard-code one distribution's
|
|
884
|
+
# script name (a registry generated by mkt_check.py is just as generated).
|
|
885
|
+
if re.search(r"GENERATED by \S+ index - do not edit by hand", text):
|
|
886
|
+
return []
|
|
887
|
+
blockers = []
|
|
888
|
+
known = {str(m.get("workstream")).strip() for _p, m in list_workstreams(root)}
|
|
889
|
+
orphans = [r for r in parse_registry_rows(text) if r not in known]
|
|
890
|
+
if orphans:
|
|
891
|
+
blockers.append("rows no HANDOFF_*.md accounts for: " + ", ".join(orphans))
|
|
892
|
+
# Everything else in the file must have a home too, or it is lost on write:
|
|
893
|
+
# a pre-1.17 narrative handoff carries no table at all, so orphan rows alone
|
|
894
|
+
# would not notice it.
|
|
895
|
+
notes_ok = ai_path(root, "audit", PROJECT_NOTES).is_file()
|
|
896
|
+
leftovers, in_notes = [], False
|
|
897
|
+
for line in text.splitlines():
|
|
898
|
+
s = line.strip()
|
|
899
|
+
if not s or s.startswith("|") or s.startswith("<!--"):
|
|
900
|
+
continue
|
|
901
|
+
if s.startswith("# ") or re.match(r"^(?:Date|Data):", s):
|
|
902
|
+
continue
|
|
903
|
+
if re.match(r"^##\s+Project-wide notes\s*$", s):
|
|
904
|
+
in_notes = True
|
|
905
|
+
continue
|
|
906
|
+
if s.startswith("## "):
|
|
907
|
+
in_notes = False
|
|
908
|
+
if in_notes and notes_ok:
|
|
909
|
+
continue
|
|
910
|
+
leftovers.append(s)
|
|
911
|
+
if leftovers:
|
|
912
|
+
blockers.append("content outside the table with nowhere to go (%d line(s), first: %r) "
|
|
913
|
+
"-- project-wide notes belong in audit/%s"
|
|
914
|
+
% (len(leftovers), leftovers[0][:60], PROJECT_NOTES))
|
|
915
|
+
return blockers
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def list_guides(root):
|
|
919
|
+
"""[(rel_to_ai_docs, path, meta, text)] for ai_docs/reference/GUIDE_*.md."""
|
|
920
|
+
ref = ai_path(root, "reference")
|
|
921
|
+
out = []
|
|
922
|
+
if not ref.is_dir():
|
|
923
|
+
return out
|
|
924
|
+
for p in sorted(ref.glob("GUIDE_*.md")):
|
|
925
|
+
text = read_text(p)
|
|
926
|
+
out.append((p.relative_to(ai_path(root)).as_posix(), p,
|
|
927
|
+
load_frontmatter(text.splitlines()), text))
|
|
928
|
+
return out
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def check_kb_collisions(root, project_guides, errors, warnings):
|
|
932
|
+
"""Cross-root awareness (unit 2): project-wins precedence, declared via 'overrides:'."""
|
|
933
|
+
kb_root = DEFAULT_KB_ROOT
|
|
934
|
+
# The agent-global KB is client-agnostic and shared across lenses: it keeps its
|
|
935
|
+
# own fixed layout and NEVER follows a project's docs-root name (TS16).
|
|
936
|
+
kb_ref = (kb_root / "ai_docs" / "reference")
|
|
937
|
+
try:
|
|
938
|
+
if root.resolve() == kb_root.resolve():
|
|
939
|
+
return # validating the KB itself: no self-comparison
|
|
940
|
+
except OSError:
|
|
941
|
+
return
|
|
942
|
+
if not kb_ref.is_dir():
|
|
943
|
+
return # no KB on this machine: zero behavior change
|
|
944
|
+
kb_names = {p.name for _, p, _, _ in list_guides(kb_root)}
|
|
945
|
+
for rel, p, meta, _ in project_guides:
|
|
946
|
+
ov = (meta.get("overrides") or "").strip()
|
|
947
|
+
if ov:
|
|
948
|
+
# T6: untrusted cross-root pointer — distilled_from parity, fail closed
|
|
949
|
+
target = confine_under(kb_ref, ov)
|
|
950
|
+
if target is None:
|
|
951
|
+
errors.append(f"{rel}: overrides '{ov}' is absolute, contains '..', or escapes the KB "
|
|
952
|
+
"reference dir — rejected (fail closed)")
|
|
953
|
+
continue
|
|
954
|
+
if not target.is_file():
|
|
955
|
+
warnings.append(f"{rel}: overrides target '{ov}' not found in KB ({kb_ref})")
|
|
956
|
+
if p.name in kb_names and ov != p.name:
|
|
957
|
+
warnings.append(f"{rel}: undeclared collision with KB guide '{p.name}' (project wins) — declare overrides: {p.name}")
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def build_guide_index(root):
|
|
961
|
+
lines = [guide_index_header(),
|
|
962
|
+
"# Operative guides (generated router)",
|
|
963
|
+
"",
|
|
964
|
+
"One row per guide. `description` is the when-to-consult line; provenance",
|
|
965
|
+
"shows what the guide was distilled from. Freshness: run `sdlc_check.py stale`.",
|
|
966
|
+
"",
|
|
967
|
+
"| Guide | Status | When to consult | Source | Source version |",
|
|
968
|
+
"|---|---|---|---|---|"]
|
|
969
|
+
for rel, p, meta, _ in list_guides(root):
|
|
970
|
+
lines.append("| `{}` | {} | {} | {} | {} |".format(
|
|
971
|
+
p.name, meta.get("status", "-") or "-",
|
|
972
|
+
(meta.get("description", "") or "-").replace("|", "\\|"),
|
|
973
|
+
(meta.get("source", "") or "-").replace("|", "\\|"),
|
|
974
|
+
meta.get("source_version", "") or "-"))
|
|
975
|
+
return "\n".join(lines) + "\n"
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def cmd_index(root):
|
|
979
|
+
if not require_ai_docs(root, "index"):
|
|
980
|
+
return 1
|
|
981
|
+
hist = ai_path(root, "strategic", "features_history.md")
|
|
982
|
+
hist.parent.mkdir(parents=True, exist_ok=True)
|
|
983
|
+
hist.write_text(build_index(root), encoding="utf-8")
|
|
984
|
+
print(f"[ok] ANALYSIS index regenerated: {hist}")
|
|
985
|
+
# INDEX.md only if canonical docs exist: no empty manifest on minimal projects
|
|
986
|
+
if list_canonical_docs(root):
|
|
987
|
+
manifest = ai_path(root, "INDEX.md")
|
|
988
|
+
manifest.write_text(build_manifest(root), encoding="utf-8")
|
|
989
|
+
print(f"[ok] document manifest regenerated: {manifest}")
|
|
990
|
+
else:
|
|
991
|
+
print("[info] no canonical documents: INDEX.md not generated")
|
|
992
|
+
guides = list_guides(root)
|
|
993
|
+
gidx = ai_path(root, "reference", "INDEX.md")
|
|
994
|
+
if guides:
|
|
995
|
+
gidx.write_text(build_guide_index(root), encoding="utf-8")
|
|
996
|
+
print(f"[ok] guide router regenerated: {gidx}")
|
|
997
|
+
else:
|
|
998
|
+
# An EMPTY router still gets written: Rule Zero makes reading it a
|
|
999
|
+
# mandatory, declared step, and `no match` may not be faked. Without the
|
|
1000
|
+
# stub, the required verdict is unsatisfiable on every new project --
|
|
1001
|
+
# and a rule that cannot be obeyed on first contact gets discarded.
|
|
1002
|
+
gidx.parent.mkdir(parents=True, exist_ok=True)
|
|
1003
|
+
gidx.write_text(guide_index_header() + "\n# Operative guides (generated router)\n\n"
|
|
1004
|
+
"No guides in this project yet. This file exists so the Rule Zero "
|
|
1005
|
+
"router lookup has something to read: the honest verdict here is "
|
|
1006
|
+
"`router: no match`.\n\n"
|
|
1007
|
+
"A guide is written when the user hands over indications to follow "
|
|
1008
|
+
"(`source_kind: document`), or when a high-complexity component needs "
|
|
1009
|
+
"a comprehension map (`source_kind: code`) -- see `guides.md`.\n",
|
|
1010
|
+
encoding="utf-8")
|
|
1011
|
+
print(f"[ok] guide router regenerated (empty stub): {gidx}")
|
|
1012
|
+
return max(rc_registry(root), 0)
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
def rc_registry(root):
|
|
1016
|
+
"""Write the generated workstream registry, or refuse and say why (F-028)."""
|
|
1017
|
+
ws = list_workstreams(root)
|
|
1018
|
+
if not ws:
|
|
1019
|
+
return 0 # no sources: a hand-written handoff is untouched
|
|
1020
|
+
blockers = registry_conversion_blockers(root)
|
|
1021
|
+
hand = ai_path(root, "audit", "handoff.md")
|
|
1022
|
+
if blockers:
|
|
1023
|
+
print(f"[ERROR] {docs_dir()}/audit/handoff.md NOT regenerated -- it still holds "
|
|
1024
|
+
"state no source accounts for:")
|
|
1025
|
+
for b in blockers:
|
|
1026
|
+
print(f" - {b}")
|
|
1027
|
+
print(" Convert the whole registry at once (templates.md): converting "
|
|
1028
|
+
"one row at a time is the state that loses the others.")
|
|
1029
|
+
return 1
|
|
1030
|
+
hand.parent.mkdir(parents=True, exist_ok=True)
|
|
1031
|
+
hand.write_text(build_registry(root), encoding="utf-8")
|
|
1032
|
+
print(f"[ok] workstream registry regenerated: {hand}")
|
|
1033
|
+
if len(ws) > REGISTRY_CAP:
|
|
1034
|
+
print(f"[warn] {len(ws)} open workstreams: the registry is meant to stay under "
|
|
1035
|
+
f"{REGISTRY_CAP}. Nothing was truncated -- closing one is the fix.")
|
|
1036
|
+
return 0
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
# ----------------------------------------------------------------- validate
|
|
1040
|
+
|
|
1041
|
+
def has_section(text, aliases):
|
|
1042
|
+
return any(a in text for a in aliases)
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def section_body(text, aliases):
|
|
1046
|
+
"""The body of the first matching `## ` section, or None if no alias is present.
|
|
1047
|
+
|
|
1048
|
+
Portable checks read a section rather than the whole document, so a phrase that
|
|
1049
|
+
happens to appear elsewhere cannot satisfy a check about this section."""
|
|
1050
|
+
lines = text.splitlines()
|
|
1051
|
+
for i, line in enumerate(lines):
|
|
1052
|
+
if not any(line.startswith(a) for a in aliases):
|
|
1053
|
+
continue
|
|
1054
|
+
body = []
|
|
1055
|
+
for nxt in lines[i + 1:]:
|
|
1056
|
+
if nxt.startswith("## "):
|
|
1057
|
+
break
|
|
1058
|
+
body.append(nxt)
|
|
1059
|
+
return "\n".join(body).strip()
|
|
1060
|
+
return None
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def design_review_due(meta):
|
|
1064
|
+
"""True when an L3 ANALYSIS owes a design-review row (review.md moment 1):
|
|
1065
|
+
implementation has started or finished, and it began on/after the gate
|
|
1066
|
+
shipped. PLANNED is exempt -- the review is due at the END of Phase 3, so an
|
|
1067
|
+
analysis still being drafted is not late."""
|
|
1068
|
+
if meta.get("level", "").upper() != "L3":
|
|
1069
|
+
return False
|
|
1070
|
+
if meta.get("status") not in ("IN_PROGRESS", "COMPLETED"):
|
|
1071
|
+
return False
|
|
1072
|
+
started = parse_iso((meta.get("start_date") or "").strip().strip("'\""))
|
|
1073
|
+
return started is not None and started >= parse_iso(DESIGN_REVIEW_EPOCH)
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def review_logged(root, analysis_name):
|
|
1077
|
+
"""True when REVIEW_LOG.md carries a design-moment row naming this ANALYSIS.
|
|
1078
|
+
The filename matches anywhere in the row (loose on purpose: a freshness
|
|
1079
|
+
signal must not turn a formatting slip into a false 'you skipped the
|
|
1080
|
+
review'), but the moment is read from the `tier` COLUMN -- the schema
|
|
1081
|
+
reserves it for exactly this, and matching 'design' anywhere in the row let
|
|
1082
|
+
a CLOSURE row saying 'conformance to the design' satisfy the check."""
|
|
1083
|
+
log = root / review_log_rel()
|
|
1084
|
+
if not log.is_file():
|
|
1085
|
+
return False
|
|
1086
|
+
stem = analysis_name[:-3] if analysis_name.endswith(".md") else analysis_name
|
|
1087
|
+
# match the filename on a word boundary: a plain substring lets a longer
|
|
1088
|
+
# sibling (ANALYSIS_vision_clarity) satisfy a shorter one (ANALYSIS_vision)
|
|
1089
|
+
name_re = re.compile(r"(?<![\w-])" + re.escape(stem) + r"(?![\w-])")
|
|
1090
|
+
tier_idx = None
|
|
1091
|
+
for line in read_text(log).splitlines():
|
|
1092
|
+
line = line.strip()
|
|
1093
|
+
if not line.startswith("|"):
|
|
1094
|
+
continue
|
|
1095
|
+
cells = [c.strip() for c in line.strip("|").split("|")]
|
|
1096
|
+
if tier_idx is None:
|
|
1097
|
+
lowered = [c.lower() for c in cells]
|
|
1098
|
+
if "tier" in lowered: # header found: trust it over position
|
|
1099
|
+
tier_idx = lowered.index("tier")
|
|
1100
|
+
continue
|
|
1101
|
+
if not name_re.search(line):
|
|
1102
|
+
continue
|
|
1103
|
+
# schema: | date | doc_key | tier | reviewer | raised | real | verdict | rounds |
|
|
1104
|
+
idx = tier_idx if tier_idx is not None else 2
|
|
1105
|
+
if len(cells) > idx and re.match(r"design\b", cells[idx], re.I):
|
|
1106
|
+
return True
|
|
1107
|
+
return False
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def has_ledger_heading(text):
|
|
1111
|
+
"""True when a REAL '## Capability Ledger' heading exists: fenced code
|
|
1112
|
+
blocks are removed first, then HTML comments. An unterminated '<!--' only
|
|
1113
|
+
opens a comment at the start of a line -- nuking to EOF on an inline
|
|
1114
|
+
mention (or an unclosed example inside a fence) made a document that
|
|
1115
|
+
HAS its ledger get told it has none."""
|
|
1116
|
+
stripped = re.sub(r"^(```|~~~).*?^\1", "", text, flags=re.M | re.S)
|
|
1117
|
+
stripped = re.sub(r"<!--.*?-->", "", stripped, flags=re.S)
|
|
1118
|
+
stripped = re.sub(r"^[ \t]*<!--(?!.*?-->).*\Z", "", stripped, flags=re.M | re.S)
|
|
1119
|
+
return bool(re.search(r"^##[ \t]+Capability Ledger[ \t]*$", stripped, re.M))
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def ledger_due(meta):
|
|
1123
|
+
"""True when an ANALYSIS owes a '## Capability Ledger' (architect.md):
|
|
1124
|
+
an L3 started on/after the day the pass shipped. Grandfathered by
|
|
1125
|
+
start_date ALONE -- deliberately NOT by status: closure flips the ANALYSIS
|
|
1126
|
+
to COMPLETED before `check` runs (SKILL.md phase 5), so a status filter
|
|
1127
|
+
would silence the backstop at the only moment the process mandates the
|
|
1128
|
+
validator. A malformed/absent start_date is not due (fail-open: cmd_validate
|
|
1129
|
+
already errors on a missing one, and guessing an epoch from garbage would
|
|
1130
|
+
nag projects the pass never reached)."""
|
|
1131
|
+
if meta.get("level", "").upper() != "L3":
|
|
1132
|
+
return False
|
|
1133
|
+
if meta.get("status") == "CANCELLED":
|
|
1134
|
+
return False # abandoned work has no legitimate way to satisfy this
|
|
1135
|
+
started = parse_iso((meta.get("start_date") or "").strip().strip("'\""))
|
|
1136
|
+
return started is not None and started >= parse_iso(ARCHITECT_PASS_EPOCH)
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
MAP_SECTION_RE = re.compile(r"^#{2,3}[ \t]+Component Map\b.*?$(.*?)(?=^#{1,3}[ \t]+\S|\Z)",
|
|
1140
|
+
re.M | re.S | re.I)
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def map_where_refs(arch_text):
|
|
1144
|
+
"""Normalized, symbol-stripped paths from the Component Map's 'Where' column
|
|
1145
|
+
ONLY -- never from the whole document. Harvesting the whole file let the
|
|
1146
|
+
canonical template's own '## Directory Structure' backticks satisfy the check
|
|
1147
|
+
and silently disable it on every project that fills that section in.
|
|
1148
|
+
Returns None when the document has no Component Map at all."""
|
|
1149
|
+
m = MAP_SECTION_RE.search(arch_text)
|
|
1150
|
+
if not m:
|
|
1151
|
+
return None
|
|
1152
|
+
refs, where_idx = [], None
|
|
1153
|
+
for ln in m.group(1).splitlines():
|
|
1154
|
+
ln = ln.strip()
|
|
1155
|
+
if not ln.startswith("|"):
|
|
1156
|
+
continue
|
|
1157
|
+
cells = [c.strip() for c in ln.strip("|").split("|")]
|
|
1158
|
+
if len(cells) < 2 or not cells[0] or set(cells[0]) <= {"-", ":"}:
|
|
1159
|
+
continue
|
|
1160
|
+
if where_idx is None:
|
|
1161
|
+
lowered = [c.lower() for c in cells]
|
|
1162
|
+
if "where" not in lowered:
|
|
1163
|
+
return [] # no Where column: nothing is mapped
|
|
1164
|
+
where_idx = lowered.index("where")
|
|
1165
|
+
continue
|
|
1166
|
+
if where_idx < len(cells):
|
|
1167
|
+
for _ref, path_part, _sym in _map_refs(cells[where_idx]):
|
|
1168
|
+
refs.append(path_part.lstrip("./").strip("/"))
|
|
1169
|
+
return refs
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def _map_refs(where):
|
|
1173
|
+
"""Backticked refs in a 'Where' cell that are file paths: they contain a
|
|
1174
|
+
separator, or end in a KNOWN source-file suffix. Windows separators are
|
|
1175
|
+
normalized. Everything else in that cell is prose and must stay silent --
|
|
1176
|
+
a false 'the map is rotting' teaches readers to ignore the output, which is
|
|
1177
|
+
worse than the rot. `app.core`, `OrderStore.save` and `1.18.0` are prose."""
|
|
1178
|
+
out = []
|
|
1179
|
+
for ref in re.findall(r"`([^`]+)`", where):
|
|
1180
|
+
path_part, _, symbol = ref.partition("#")
|
|
1181
|
+
path_part = path_part.replace("\\", "/").strip()
|
|
1182
|
+
if not path_part or "://" in path_part:
|
|
1183
|
+
continue # a URL is not a repo path
|
|
1184
|
+
# A slash-less token counts only if it looks like a FILENAME. The one
|
|
1185
|
+
# real false-positive class is `Next.js` / `Node.js` / `Vue.js`: a
|
|
1186
|
+
# CamelCase stem with a `.js` tail is a framework name, not a file.
|
|
1187
|
+
# The exclusion is scoped to that suffix ON PURPOSE -- a blanket
|
|
1188
|
+
# CamelCase rule would silence `App.tsx`, `Program.cs`, `Main.java`,
|
|
1189
|
+
# which are exactly what React/C#/Java projects put in a Where cell.
|
|
1190
|
+
stem, _, suffix = path_part.rsplit("/", 1)[-1].rpartition(".")
|
|
1191
|
+
framework_name = (suffix.lower() == "js"
|
|
1192
|
+
and bool(re.fullmatch(r"[A-Z][a-z0-9]+(?:[A-Z][a-z0-9]*)*", stem)))
|
|
1193
|
+
looks_like_path = "/" in path_part or (
|
|
1194
|
+
bool(re.search(r"\.(" + "|".join(FILE_SUFFIXES) + r")$", path_part, re.I))
|
|
1195
|
+
and not framework_name)
|
|
1196
|
+
if looks_like_path:
|
|
1197
|
+
out.append((ref, path_part, symbol.strip()))
|
|
1198
|
+
return out
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
def check_component_map(root, text, advisories):
|
|
1202
|
+
"""Anti-rot for the '## Component Map' of strategic/architecture.md
|
|
1203
|
+
(architect.md): every path-shaped backticked ref in the 'Where' column must
|
|
1204
|
+
still resolve on disk, and its '#symbol' must still appear as a whole word
|
|
1205
|
+
in a matched file. This is the map's equivalent of the guides' source_hash.
|
|
1206
|
+
ADVISORY: a freshness signal, never a gate -- not even under --strict (the
|
|
1207
|
+
accepted ceremony budget was a warning, not a blocked pipeline)."""
|
|
1208
|
+
m = MAP_SECTION_RE.search(text) # one regex for both checks: they cannot drift
|
|
1209
|
+
if not m:
|
|
1210
|
+
return
|
|
1211
|
+
rows = [ln.strip() for ln in m.group(1).splitlines() if ln.strip().startswith("|")]
|
|
1212
|
+
where_idx, header_cells, checked, data_rows, ragged = None, 0, 0, 0, 0
|
|
1213
|
+
for row in rows:
|
|
1214
|
+
cells = [c.strip() for c in row.strip("|").split("|")]
|
|
1215
|
+
if len(cells) < 2 or not cells[0] or set(cells[0]) <= {"-", ":"}:
|
|
1216
|
+
continue
|
|
1217
|
+
if where_idx is None: # the first non-separator row is the header
|
|
1218
|
+
lowered = [c.lower() for c in cells]
|
|
1219
|
+
if "where" not in lowered:
|
|
1220
|
+
advisories.append("strategic/architecture.md: Component Map has no 'Where' "
|
|
1221
|
+
"column in its header -- the anti-rot check cannot run; "
|
|
1222
|
+
"give the table a Where column of `path/to/file#Symbol` refs")
|
|
1223
|
+
return
|
|
1224
|
+
where_idx, header_cells = lowered.index("where"), len(cells)
|
|
1225
|
+
continue
|
|
1226
|
+
if all(c in ("", "...", "…") for c in cells):
|
|
1227
|
+
continue # untouched template placeholder row
|
|
1228
|
+
data_rows += 1
|
|
1229
|
+
if len(cells) != header_cells: # ragged: never silently unchecked
|
|
1230
|
+
ragged += 1
|
|
1231
|
+
continue
|
|
1232
|
+
component, where = cells[0], cells[where_idx]
|
|
1233
|
+
for ref, path_part, symbol in _map_refs(where):
|
|
1234
|
+
checked += 1
|
|
1235
|
+
if confine_under(root, re.sub(r"[*?\[\]]", "x", path_part)) is None:
|
|
1236
|
+
advisories.append(f"strategic/architecture.md: Component Map row '{component}': "
|
|
1237
|
+
f"ref '{ref}' escapes the project root: rejected")
|
|
1238
|
+
continue
|
|
1239
|
+
target = root / path_part
|
|
1240
|
+
try:
|
|
1241
|
+
if target.exists(): # literal first: `app/[id]/page.tsx` is a real path
|
|
1242
|
+
matches = [target]
|
|
1243
|
+
elif any(c in path_part for c in "*?["):
|
|
1244
|
+
matches = list(root.glob(path_part))
|
|
1245
|
+
else:
|
|
1246
|
+
matches = []
|
|
1247
|
+
except (ValueError, OSError):
|
|
1248
|
+
matches = []
|
|
1249
|
+
if not matches:
|
|
1250
|
+
advisories.append(f"strategic/architecture.md: Component Map row '{component}': "
|
|
1251
|
+
f"'{path_part}' no longer exists -- the map is rotting, "
|
|
1252
|
+
"update the row or drop it")
|
|
1253
|
+
continue
|
|
1254
|
+
files = [f for f in matches if f.is_file()]
|
|
1255
|
+
if symbol and files:
|
|
1256
|
+
word = re.compile(r"(?<![A-Za-z0-9_])" + re.escape(symbol) + r"(?![A-Za-z0-9_])")
|
|
1257
|
+
if not any(word.search(read_text(f)) for f in files):
|
|
1258
|
+
advisories.append(f"strategic/architecture.md: Component Map row '{component}': "
|
|
1259
|
+
f"symbol '{symbol}' not found in '{path_part}' -- renamed or "
|
|
1260
|
+
"removed, update the row")
|
|
1261
|
+
if ragged:
|
|
1262
|
+
advisories.append(f"strategic/architecture.md: Component Map has {ragged} row(s) whose "
|
|
1263
|
+
"column count differs from the header -- unchecked; an escaped '|' in "
|
|
1264
|
+
"a cell shifts the columns")
|
|
1265
|
+
if data_rows and not checked and not ragged:
|
|
1266
|
+
# only once the map claims real components: a freshly seeded project
|
|
1267
|
+
# carries the template placeholder and must NOT be nagged on day zero
|
|
1268
|
+
advisories.append("strategic/architecture.md: Component Map has rows but no checkable "
|
|
1269
|
+
"path in its 'Where' column -- the anti-rot check is inert; write refs "
|
|
1270
|
+
"as `path/to/file#Symbol`")
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
def cmd_validate(root, strict=False, hybrid=False):
|
|
1274
|
+
# advisories: architect-pass freshness signals. Reported, never escalated by
|
|
1275
|
+
# --strict -- the accepted ceremony budget (project_vision.md "no ceremony
|
|
1276
|
+
# ratchet") was a warning, and a warning that reddens CI is a gate.
|
|
1277
|
+
errors, warnings, advisories = [], [], []
|
|
1278
|
+
ai = ai_path(root)
|
|
1279
|
+
if not ai.is_dir():
|
|
1280
|
+
if strict:
|
|
1281
|
+
print(f"[ERROR] {ai} does not exist: nothing to validate. In --strict mode this "
|
|
1282
|
+
"fails so a wrong working directory cannot produce a green pipeline.")
|
|
1283
|
+
return 1
|
|
1284
|
+
print(f"[info] {ai} does not exist: nothing to validate (project without SDLC docs).")
|
|
1285
|
+
return 0
|
|
1286
|
+
|
|
1287
|
+
# Vision: presence and declared state
|
|
1288
|
+
for name in VISION_FILES:
|
|
1289
|
+
f = ai / "vision" / name
|
|
1290
|
+
if not f.is_file():
|
|
1291
|
+
warnings.append(f"vision/{name} missing")
|
|
1292
|
+
continue
|
|
1293
|
+
head = "\n".join(read_text(f).splitlines()[:12])
|
|
1294
|
+
m = re.search(r"(?:Status|Stato):\s*(DRAFT|APPROVED)", head)
|
|
1295
|
+
if not m:
|
|
1296
|
+
errors.append(f"vision/{name}: missing 'Status: DRAFT|APPROVED' in the first lines")
|
|
1297
|
+
elif m.group(1) == "DRAFT":
|
|
1298
|
+
# advisory, not a warning: bootstrap MANDATES DRAFT, so a warning here
|
|
1299
|
+
# makes `validate --strict` red on every freshly bootstrapped project
|
|
1300
|
+
# until a human runs the blind check -- and teams delete the CI step
|
|
1301
|
+
# rather than block on it. DRAFT is a state, not a defect.
|
|
1302
|
+
advisories.append(f"vision/{name} is DRAFT: not a gating authority, "
|
|
1303
|
+
"have the user validate it")
|
|
1304
|
+
|
|
1305
|
+
# ANALYSIS: frontmatter and mandatory sections
|
|
1306
|
+
# Ids are unique WITHIN a domain (prefixes F-/K-/M-/C- keep them apart in practice):
|
|
1307
|
+
# two lenses over one tree must not collide on a number neither of them chose.
|
|
1308
|
+
seen_ids = {}
|
|
1309
|
+
default_domain = project_default_domain(root)
|
|
1310
|
+
analyses = list_analyses(root)
|
|
1311
|
+
for p, meta, text in analyses:
|
|
1312
|
+
rel = "solutions/" + p.name
|
|
1313
|
+
if not meta:
|
|
1314
|
+
errors.append(f"{rel}: frontmatter missing")
|
|
1315
|
+
continue
|
|
1316
|
+
domain, unknown_domain = resolve_domain(meta, default_domain)
|
|
1317
|
+
if unknown_domain:
|
|
1318
|
+
warnings.append(f"{rel}: domain '{unknown_domain}' not recognized "
|
|
1319
|
+
f"({'/'.join(sorted(DOMAINS))}): validated as '{domain}'")
|
|
1320
|
+
fid = meta.get("id")
|
|
1321
|
+
if not fid:
|
|
1322
|
+
errors.append(f"{rel}: 'id' field missing")
|
|
1323
|
+
elif (domain, fid) in seen_ids:
|
|
1324
|
+
errors.append(f"{rel}: id '{fid}' duplicated (already used in {seen_ids[(domain, fid)]})")
|
|
1325
|
+
else:
|
|
1326
|
+
seen_ids[(domain, fid)] = rel
|
|
1327
|
+
status = meta.get("status", "")
|
|
1328
|
+
if status not in VALID_STATES:
|
|
1329
|
+
errors.append(f"{rel}: status '{status}' not valid ({'/'.join(sorted(VALID_STATES))})")
|
|
1330
|
+
if not meta.get("start_date"):
|
|
1331
|
+
errors.append(f"{rel}: 'start_date' missing")
|
|
1332
|
+
if status == "COMPLETED" and not meta.get("end_date"):
|
|
1333
|
+
errors.append(f"{rel}: COMPLETED without 'end_date'")
|
|
1334
|
+
level = meta.get("level")
|
|
1335
|
+
if level and level.upper() not in VALID_LEVELS:
|
|
1336
|
+
warnings.append(f"{rel}: level '{level}' not recognized ({'/'.join(sorted(VALID_LEVELS))})")
|
|
1337
|
+
# The risk slot is translated per domain, never dropped: whichever lens owns
|
|
1338
|
+
# the document, it owes ITS account of what could go wrong.
|
|
1339
|
+
rules = DOMAINS[domain]
|
|
1340
|
+
if not has_section(text, rules["risk_section"]):
|
|
1341
|
+
errors.append(f"{rel}: section '{rules['risk_label']}' missing (mandatory)")
|
|
1342
|
+
for en, it in ANALYSIS_SECTIONS:
|
|
1343
|
+
if not has_section(text, (en, it)):
|
|
1344
|
+
warnings.append(f"{rel}: section '{en}' missing")
|
|
1345
|
+
run_portable_checks(rel, meta, text, errors, warnings, advisories)
|
|
1346
|
+
if not level and (parse_iso((meta.get("start_date") or "").strip().strip("'\"")) or
|
|
1347
|
+
parse_iso("1970-01-01")) >= parse_iso(ARCHITECT_PASS_EPOCH):
|
|
1348
|
+
# advisory + epoch-gated, exactly like the check it guards: a warning
|
|
1349
|
+
# here would redden --strict CI on every pre-1.18 analysis that never
|
|
1350
|
+
# carried the optional field. (Same defect the advisories bucket was
|
|
1351
|
+
# invented to prevent -- reintroduced once, caught in review.)
|
|
1352
|
+
advisories.append(f"{rel}: 'level' missing (L1/L2/L3/Spike) -- risk-proportional "
|
|
1353
|
+
"checks cannot apply, and dropping the line is cheaper than "
|
|
1354
|
+
"doing the work it triggers")
|
|
1355
|
+
# comment-stripped, anchored: a '<!-- TODO: the ## Capability Ledger -->'
|
|
1356
|
+
# must not read as the section being present
|
|
1357
|
+
# Hybrid: the design lives in devPNT and its §4.5 gate owns this slot
|
|
1358
|
+
# (the Hybrid ownership matrix: "run ONE of them, never both"), and its log
|
|
1359
|
+
# rows are keyed on e_isp_/e_tdd_ doc_keys, not on this filename -- so
|
|
1360
|
+
# firing here would be a permanent, unfixable false positive.
|
|
1361
|
+
if not hybrid and design_review_due(meta) and not review_logged(root, p.name):
|
|
1362
|
+
advisories.append(f"{rel}: L3 in implementation with no design-review row in "
|
|
1363
|
+
f"{review_log_rel()} -- the design was reviewed by nobody but its "
|
|
1364
|
+
"author before code was written (review.md moment 1)")
|
|
1365
|
+
if ledger_due(meta) and not has_ledger_heading(text):
|
|
1366
|
+
advisories.append(f"{rel}: L3 without '## Capability Ledger' -- the architect pass "
|
|
1367
|
+
"left no record (architect.md); run it before the Impact")
|
|
1368
|
+
|
|
1369
|
+
# Generated index aligned
|
|
1370
|
+
hist = ai / "strategic" / "features_history.md"
|
|
1371
|
+
if analyses:
|
|
1372
|
+
if not hist.is_file():
|
|
1373
|
+
errors.append("strategic/features_history.md missing: run 'sdlc_check.py index'")
|
|
1374
|
+
elif norm_text(read_text(hist)) != norm_text(build_index(root)):
|
|
1375
|
+
errors.append("strategic/features_history.md not aligned with the ANALYSIS files: run 'sdlc_check.py index'")
|
|
1376
|
+
|
|
1377
|
+
# Canonical document manifest aligned (Poka-Yoke: unindexed file = dirty closure)
|
|
1378
|
+
docs = list_canonical_docs(root)
|
|
1379
|
+
manifest = ai / "INDEX.md"
|
|
1380
|
+
if docs:
|
|
1381
|
+
if not manifest.is_file():
|
|
1382
|
+
errors.append(f"{docs_dir()}/INDEX.md missing: run 'sdlc_check.py index'")
|
|
1383
|
+
elif norm_text(read_text(manifest)) != norm_text(build_manifest(root)):
|
|
1384
|
+
errors.append(f"{docs_dir()}/INDEX.md not aligned with the canonical documents: run 'sdlc_check.py index'")
|
|
1385
|
+
|
|
1386
|
+
# Canonical document lifecycle: declared status + supersedes coherence
|
|
1387
|
+
canon_status = {rel: meta[2] for rel, _, meta in docs}
|
|
1388
|
+
for rel, _, (title, desc, status, supersedes) in docs:
|
|
1389
|
+
if not status:
|
|
1390
|
+
warnings.append(f"{rel}: missing 'status:' in the header (CURRENT/SUPERSEDED/DRAFT/DEPRECATED)")
|
|
1391
|
+
elif status not in CANONICAL_STATES:
|
|
1392
|
+
warnings.append(f"{rel}: status '{status}' not recognized ({'/'.join(sorted(CANONICAL_STATES))})")
|
|
1393
|
+
if supersedes:
|
|
1394
|
+
base = os.path.basename(supersedes)
|
|
1395
|
+
for other, ost in canon_status.items():
|
|
1396
|
+
if (other == supersedes or other.endswith("/" + supersedes)
|
|
1397
|
+
or os.path.basename(other) == base) and ost == "CURRENT":
|
|
1398
|
+
warnings.append(f"{other}: still CURRENT but superseded by {rel} (set status: SUPERSEDED)")
|
|
1399
|
+
|
|
1400
|
+
# Component Map anti-rot (architect.md): rows must still resolve on disk
|
|
1401
|
+
arch = ai / "strategic" / "architecture.md"
|
|
1402
|
+
if arch.is_file():
|
|
1403
|
+
arch_text = read_text(arch)
|
|
1404
|
+
check_component_map(root, arch_text, advisories)
|
|
1405
|
+
# the missing half of the loop: `mark` asserts an area was read closely
|
|
1406
|
+
# enough to name its capability owners, and nothing verified that claim.
|
|
1407
|
+
# An ANALYZED area with no map row is how the brownfield guard is
|
|
1408
|
+
# disarmed -- the area looks read, so the map's silence becomes groundable.
|
|
1409
|
+
_, _, plan_rows = parse_audit_plan(root)
|
|
1410
|
+
mapped = map_where_refs(arch_text)
|
|
1411
|
+
if plan_rows and mapped is not None:
|
|
1412
|
+
for prow in plan_rows:
|
|
1413
|
+
if prow["status"] != "ANALYZED":
|
|
1414
|
+
continue
|
|
1415
|
+
if confine_under(root, prow["path"]) is None:
|
|
1416
|
+
continue # stale already rejects these
|
|
1417
|
+
if re.search(r"owns no component", prow.get("note", ""), re.I):
|
|
1418
|
+
continue # declared, not forgotten: the opt-out
|
|
1419
|
+
area = prow["path"].replace("\\", "/").strip("/")
|
|
1420
|
+
if area.startswith("./"):
|
|
1421
|
+
area = area[2:]
|
|
1422
|
+
if area in ("", "."):
|
|
1423
|
+
continue # the whole root: every row is inside it
|
|
1424
|
+
if not (root / area).exists():
|
|
1425
|
+
continue # gone from disk: not a mapping gap
|
|
1426
|
+
if not any(mp == area or mp.startswith(area + "/") for mp in mapped):
|
|
1427
|
+
advisories.append(
|
|
1428
|
+
f"strategic/architecture.md: '{prow['path']}' is ANALYZED in the audit "
|
|
1429
|
+
"plan but owns no Component Map row -- marking asserts the area was read "
|
|
1430
|
+
"closely enough to name what it owns, and the map's silence there is now "
|
|
1431
|
+
"groundable for a MISSING verdict (architect.md). If it genuinely owns no "
|
|
1432
|
+
"component, say so in the audit plan's Notes column: 'owns no component'")
|
|
1433
|
+
|
|
1434
|
+
# Guide checks (ai_docs/reference/GUIDE_*.md): structure only — freshness is stale's job
|
|
1435
|
+
guides = list_guides(root)
|
|
1436
|
+
for rel, p, meta, text in guides:
|
|
1437
|
+
missing = [k for k in GUIDE_PROVENANCE_KEYS if not meta.get(k)]
|
|
1438
|
+
if missing:
|
|
1439
|
+
warnings.append(f"{rel}: guide missing provenance key(s): {', '.join(missing)}")
|
|
1440
|
+
# (b) per-section fidelity markers: every '## ' section body must carry a marker
|
|
1441
|
+
body = text.split("---", 2)[-1]
|
|
1442
|
+
sections = re.split(r"^##\s+", body, flags=re.M)[1:]
|
|
1443
|
+
unmarked = [s.splitlines()[0].strip() for s in sections if not GUIDE_MARKER_RE.search(s)]
|
|
1444
|
+
if unmarked:
|
|
1445
|
+
warnings.append(f"{rel}: section(s) without [source: ...] / [not covered by source] marker: "
|
|
1446
|
+
+ "; ".join(unmarked[:5]))
|
|
1447
|
+
# (c) distilled_from confinement — fail closed (P-TM T6, distilled_from vector)
|
|
1448
|
+
df = meta.get("distilled_from", "")
|
|
1449
|
+
if df and confine_under(root, df) is None:
|
|
1450
|
+
errors.append(f"{rel}: distilled_from '{df}' is absolute, contains '..', or resolves "
|
|
1451
|
+
"outside the project root: rejected")
|
|
1452
|
+
check_kb_collisions(root, guides, errors, warnings)
|
|
1453
|
+
# guide-router alignment (mirror of the root-manifest check)
|
|
1454
|
+
gidx = ai_path(root, "reference", "INDEX.md")
|
|
1455
|
+
if not gidx.is_file() and not guides:
|
|
1456
|
+
# zero guides: the stub is a convenience for the mandatory Rule Zero read
|
|
1457
|
+
advisories.append(f"{docs_dir()}/reference/INDEX.md missing: Rule Zero requires reading the "
|
|
1458
|
+
"guide router and forbids faking its verdict, so the router exists "
|
|
1459
|
+
"even with zero guides -- run 'sdlc_check.py index'")
|
|
1460
|
+
if guides:
|
|
1461
|
+
if not gidx.is_file():
|
|
1462
|
+
# guides EXIST and the router does not: the agent's mandatory lookup
|
|
1463
|
+
# finds nothing and legally declares 'absent', so the guide that
|
|
1464
|
+
# governs the work is never consulted. An absent router must not be
|
|
1465
|
+
# graded below a merely stale one.
|
|
1466
|
+
errors.append(f"{docs_dir()}/reference/INDEX.md missing while GUIDE_*.md files exist: "
|
|
1467
|
+
"the router is the only thing that routes work to them -- "
|
|
1468
|
+
"run 'sdlc_check.py index'")
|
|
1469
|
+
elif norm_text(read_text(gidx)) != norm_text(build_guide_index(root)):
|
|
1470
|
+
errors.append(f"{docs_dir()}/reference/INDEX.md not aligned with the guides: run 'sdlc_check.py index'")
|
|
1471
|
+
|
|
1472
|
+
# Handoff: alignment with its sources (F-028), then header and freshness
|
|
1473
|
+
hand = ai / "audit" / "handoff.md"
|
|
1474
|
+
workstreams = list_workstreams(root)
|
|
1475
|
+
if workstreams:
|
|
1476
|
+
if not hand.is_file():
|
|
1477
|
+
errors.append(f"{docs_dir()}/audit/handoff.md missing while HANDOFF_*.md sources "
|
|
1478
|
+
"exist: the registry is the only place a cold agent sees the open "
|
|
1479
|
+
"workstreams -- run 'sdlc_check.py index'")
|
|
1480
|
+
elif norm_text(read_text(hand)) != norm_text(build_registry(root)):
|
|
1481
|
+
errors.append(f"{docs_dir()}/audit/handoff.md not aligned with its HANDOFF_*.md "
|
|
1482
|
+
"sources: run 'sdlc_check.py index'. A merge resolved by hand is "
|
|
1483
|
+
"exactly what this catches")
|
|
1484
|
+
if len(workstreams) > REGISTRY_CAP:
|
|
1485
|
+
warnings.append(f"{len(workstreams)} open workstreams: the registry is meant to "
|
|
1486
|
+
f"stay under {REGISTRY_CAP}")
|
|
1487
|
+
# Two files claiming one workstream is the collision this design does NOT
|
|
1488
|
+
# fix (two people opening the same work under different file names). It
|
|
1489
|
+
# would otherwise show up as two identical-looking rows and nothing else.
|
|
1490
|
+
seen = {}
|
|
1491
|
+
for p, meta in workstreams:
|
|
1492
|
+
wid = str(meta.get("workstream")).strip()
|
|
1493
|
+
if wid in seen:
|
|
1494
|
+
warnings.append(f"{docs_dir()}/audit/{p.name} and {seen[wid]} both claim "
|
|
1495
|
+
f"workstream '{wid}': the registry shows two rows for one "
|
|
1496
|
+
"workstream — decide which file owns it")
|
|
1497
|
+
else:
|
|
1498
|
+
seen[wid] = p.name
|
|
1499
|
+
if hand.is_file():
|
|
1500
|
+
m = re.search(r"(?:Date|Data):\s*(\d{4}-\d{2}-\d{2})", read_text(hand))
|
|
1501
|
+
if not m:
|
|
1502
|
+
warnings.append("audit/handoff.md without a 'Date: YYYY-MM-DD' header")
|
|
1503
|
+
else:
|
|
1504
|
+
try:
|
|
1505
|
+
stamp = datetime.strptime(m.group(1), "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
|
1506
|
+
age = (datetime.now(timezone.utc) - stamp).days
|
|
1507
|
+
if age > 14:
|
|
1508
|
+
warnings.append(f"audit/handoff.md is {age} days old: treat it as history, not current state")
|
|
1509
|
+
except ValueError:
|
|
1510
|
+
warnings.append("audit/handoff.md: date not parseable")
|
|
1511
|
+
|
|
1512
|
+
for a in advisories:
|
|
1513
|
+
print(f"[note] {a}")
|
|
1514
|
+
for w in warnings:
|
|
1515
|
+
print(f"[warn] {w}")
|
|
1516
|
+
for e in errors:
|
|
1517
|
+
print(f"[ERROR] {e}")
|
|
1518
|
+
print(f"\nValidation: {len(errors)} errors, {len(warnings)} warnings, "
|
|
1519
|
+
f"{len(advisories)} advisories.")
|
|
1520
|
+
if advisories:
|
|
1521
|
+
print("[note] advisories are freshness signals: never fail a build, not even --strict.")
|
|
1522
|
+
if strict and warnings and not errors:
|
|
1523
|
+
print("[strict] warnings are failures in --strict mode.")
|
|
1524
|
+
return 1 if errors or (strict and warnings) else 0
|
|
1525
|
+
|
|
1526
|
+
|
|
1527
|
+
# ------------------------------------------------------------- audit_plan
|
|
1528
|
+
|
|
1529
|
+
def parse_audit_plan(root):
|
|
1530
|
+
f = ai_path(root, "audit", "audit_plan.md")
|
|
1531
|
+
rows, lines = [], []
|
|
1532
|
+
if f.is_file():
|
|
1533
|
+
lines = read_text(f).splitlines()
|
|
1534
|
+
for i, line in enumerate(lines):
|
|
1535
|
+
if not line.strip().startswith("|"):
|
|
1536
|
+
continue
|
|
1537
|
+
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
|
1538
|
+
if len(cells) < 2:
|
|
1539
|
+
continue
|
|
1540
|
+
if cells[0].lower() in ("path", "percorso") or set(cells[0]) <= set("-: "):
|
|
1541
|
+
continue
|
|
1542
|
+
rows.append({
|
|
1543
|
+
"line": i,
|
|
1544
|
+
"path": cells[0],
|
|
1545
|
+
"status": cells[1].upper(),
|
|
1546
|
+
"ref": cells[2] if len(cells) > 2 else "",
|
|
1547
|
+
"note": cells[3] if len(cells) > 3 else "",
|
|
1548
|
+
})
|
|
1549
|
+
return f, lines, rows
|
|
1550
|
+
|
|
1551
|
+
|
|
1552
|
+
def cmd_stale(root, hybrid=False):
|
|
1553
|
+
rc = 0
|
|
1554
|
+
# --- guide freshness (source_hash vs snapshot) — runs in EVERY mode
|
|
1555
|
+
drifted = []
|
|
1556
|
+
for rel, p, meta, _ in list_guides(root):
|
|
1557
|
+
df, rec = meta.get("distilled_from", ""), meta.get("source_hash", "")
|
|
1558
|
+
if not df or not rec:
|
|
1559
|
+
continue # structure problems are validate's job
|
|
1560
|
+
src = root / df
|
|
1561
|
+
if not src.is_file():
|
|
1562
|
+
print(f"[warn] {rel}: distilled_from '{df}' not found — snapshot missing")
|
|
1563
|
+
rc = 1
|
|
1564
|
+
continue
|
|
1565
|
+
if sha256_file(src) != rec:
|
|
1566
|
+
drifted.append((rel, df))
|
|
1567
|
+
for rel, df in drifted:
|
|
1568
|
+
print(f"[stale] {rel}: source snapshot '{df}' changed since distillation — regenerate the guide")
|
|
1569
|
+
if drifted:
|
|
1570
|
+
rc = 1
|
|
1571
|
+
# --- audit-plan staleness — delegated to devPNT/KL in hybrid
|
|
1572
|
+
if hybrid:
|
|
1573
|
+
print("[info] hybrid mode: audit-plan staleness is delegated to devPNT/KL, skipping.")
|
|
1574
|
+
return rc # was: implicit skip-all; guide rc survives
|
|
1575
|
+
f, _, rows = parse_audit_plan(root)
|
|
1576
|
+
if not rows:
|
|
1577
|
+
print(f"[info] no rows in {f}: nothing to check "
|
|
1578
|
+
"(audit not initialized, or Hybrid mode where mapping is delegated to devPNT).")
|
|
1579
|
+
return rc # was: return 0 — MUST carry guide rc
|
|
1580
|
+
use_git = git_available(root)
|
|
1581
|
+
entry = entry_script()
|
|
1582
|
+
# The audit plan records the marks: its own edits are not a change to the area
|
|
1583
|
+
# that contains it. Counting them made a committed hash mark of that area
|
|
1584
|
+
# stale again, and every re-mark repeated the loop.
|
|
1585
|
+
try:
|
|
1586
|
+
plan_rel = f.resolve().relative_to(Path(root).resolve()).as_posix()
|
|
1587
|
+
except ValueError:
|
|
1588
|
+
plan_rel = None
|
|
1589
|
+
# F-056: an ANALYZED row whose reference cannot be evaluated is UNVERIFIED,
|
|
1590
|
+
# never fresh. Each `continue` below that skips such a row records it here;
|
|
1591
|
+
# skipping silently is how a pruned reference printed `[ok]` over three areas.
|
|
1592
|
+
unverifiable = []
|
|
1593
|
+
stale = []
|
|
1594
|
+
for row in rows:
|
|
1595
|
+
if row["status"] != "ANALYZED":
|
|
1596
|
+
continue
|
|
1597
|
+
rel, ref = row["path"], row["ref"]
|
|
1598
|
+
# Confine BEFORE touching the filesystem: audit_plan.md is document
|
|
1599
|
+
# content, so an absolute row ('/'), a drive-relative one or a '..'
|
|
1600
|
+
# escape would otherwise walk outside the project (P-TM T2/T3). A bare
|
|
1601
|
+
# '/' is the row init.js used to seed, and `root / "/"` is the drive.
|
|
1602
|
+
target = confine_under(root, rel)
|
|
1603
|
+
if target is None:
|
|
1604
|
+
print(f"[warn] {rel}: path is absolute, contains '..', or resolves outside "
|
|
1605
|
+
"the project root: rejected (use a project-relative path, '.' for the root)")
|
|
1606
|
+
continue
|
|
1607
|
+
if not target.exists():
|
|
1608
|
+
print(f"[warn] {rel}: path does not exist")
|
|
1609
|
+
continue
|
|
1610
|
+
changed = []
|
|
1611
|
+
remark = f"re-analyze the area, then run: {entry} mark {rel}"
|
|
1612
|
+
if re.fullmatch(r"[0-9a-fA-F]{7,40}", ref or ""):
|
|
1613
|
+
if not use_git:
|
|
1614
|
+
print(f"[stale] {rel}: reference '{ref}' is a git commit, but git is not usable "
|
|
1615
|
+
"here (not a git work tree, git missing, or the repository refused): its "
|
|
1616
|
+
"freshness cannot be evaluated. Run stale inside the git work tree, or "
|
|
1617
|
+
+ remark)
|
|
1618
|
+
unverifiable.append(rel)
|
|
1619
|
+
continue
|
|
1620
|
+
state = git_ref_state(root, ref)
|
|
1621
|
+
if state == "missing":
|
|
1622
|
+
shallow = (" This clone is shallow: full history may hold it "
|
|
1623
|
+
"(git fetch --unshallow)." if git_is_shallow(root) else "")
|
|
1624
|
+
print(f"[stale] {rel}: git ref '{ref}' is not a commit in this repository "
|
|
1625
|
+
"(amended, rebased or squash-merged away and pruned, or never fetched "
|
|
1626
|
+
f"here): its freshness cannot be evaluated.{shallow} To fix, " + remark)
|
|
1627
|
+
unverifiable.append(rel)
|
|
1628
|
+
continue
|
|
1629
|
+
if state == "orphan":
|
|
1630
|
+
print(f"[warn] {rel}: git ref '{ref}' is not an ancestor of HEAD (amended, "
|
|
1631
|
+
"rebased, squash-merged, or marked on another branch): it stops "
|
|
1632
|
+
"resolving once git prunes it, and on any fresh clone. Re-mark now: "
|
|
1633
|
+
f"{entry} mark {rel}")
|
|
1634
|
+
res = git_changed_since(root, ref, rel.replace("\\", "/"))
|
|
1635
|
+
if res is None:
|
|
1636
|
+
print(f"[stale] {rel}: git could not compare against ref '{ref}': its "
|
|
1637
|
+
"freshness cannot be evaluated. To fix, " + remark)
|
|
1638
|
+
unverifiable.append(rel)
|
|
1639
|
+
continue
|
|
1640
|
+
changed = res
|
|
1641
|
+
else:
|
|
1642
|
+
ts = parse_iso(ref)
|
|
1643
|
+
if ts is None:
|
|
1644
|
+
print(f"[stale] {rel}: reference '{ref}' is neither a git commit nor an ISO "
|
|
1645
|
+
"UTC timestamp: its freshness cannot be evaluated. To fix, " + remark)
|
|
1646
|
+
unverifiable.append(rel)
|
|
1647
|
+
continue
|
|
1648
|
+
for fp in iter_files(target):
|
|
1649
|
+
mtime = datetime.fromtimestamp(fp.stat().st_mtime, tz=timezone.utc)
|
|
1650
|
+
if mtime > ts + MTIME_GRACE:
|
|
1651
|
+
try:
|
|
1652
|
+
name = str(fp.relative_to(root)).replace("\\", "/")
|
|
1653
|
+
except ValueError: # symlink out of the tree: report absolute, never crash
|
|
1654
|
+
name = str(fp)
|
|
1655
|
+
changed.append(name)
|
|
1656
|
+
changed = [c for c in changed if c != plan_rel]
|
|
1657
|
+
if changed:
|
|
1658
|
+
stale.append((rel, changed))
|
|
1659
|
+
|
|
1660
|
+
if unverifiable:
|
|
1661
|
+
rc = 1 # unverified is not fresh (F-056)
|
|
1662
|
+
if not stale:
|
|
1663
|
+
if not unverifiable:
|
|
1664
|
+
print("[ok] no analyzed area was modified after its last recorded analysis.")
|
|
1665
|
+
return rc # was: return 0 — MUST carry guide rc
|
|
1666
|
+
print("Areas modified after the last recorded analysis:")
|
|
1667
|
+
for rel, changed in stale:
|
|
1668
|
+
print(f" {rel} ({len(changed)} files)")
|
|
1669
|
+
for c in changed[:10]:
|
|
1670
|
+
print(f" - {c}")
|
|
1671
|
+
if len(changed) > 10:
|
|
1672
|
+
print(f" ... and {len(changed) - 10} more")
|
|
1673
|
+
print(f"\nAfter re-analyzing, record it with: {entry} mark <path>")
|
|
1674
|
+
return 1 # stale areas dominate: rc already implied
|
|
1675
|
+
|
|
1676
|
+
|
|
1677
|
+
def cmd_mark(root, paths):
|
|
1678
|
+
if not require_ai_docs(root, "mark"):
|
|
1679
|
+
return 1
|
|
1680
|
+
f, lines, rows = parse_audit_plan(root)
|
|
1681
|
+
use_git_ref = git_available(root) and not any(
|
|
1682
|
+
git_has_changes(root, raw.replace("\\", "/").rstrip("/")) for raw in paths
|
|
1683
|
+
)
|
|
1684
|
+
ref = git_head(root) if use_git_ref else utc_now_iso()
|
|
1685
|
+
by_path = {r["path"].replace("\\", "/").rstrip("/"): r for r in rows}
|
|
1686
|
+
|
|
1687
|
+
if not lines:
|
|
1688
|
+
lines = ["# Audit Plan", "",
|
|
1689
|
+
"| Path | Status | Reference | Notes |",
|
|
1690
|
+
"|---|---|---|---|"]
|
|
1691
|
+
rows = []
|
|
1692
|
+
|
|
1693
|
+
def row_text(path, note):
|
|
1694
|
+
return f"| {path} | ANALYZED | {ref} | {note} |"
|
|
1695
|
+
|
|
1696
|
+
# validate EVERY path before printing or mutating anything: a rejection
|
|
1697
|
+
# after an '[ok] ... added as ANALYZED' line is a lie the agent will act on
|
|
1698
|
+
keys = []
|
|
1699
|
+
for raw in paths:
|
|
1700
|
+
key = raw.replace("\\", "/").rstrip("/") or "."
|
|
1701
|
+
if confine_under(root, key) is None:
|
|
1702
|
+
print(f"[ERROR] {raw}: absolute, '..'-escaping, or outside the project root: "
|
|
1703
|
+
"refusing to mark (use a project-relative path, '.' for the root). "
|
|
1704
|
+
"Nothing was written.")
|
|
1705
|
+
return 1
|
|
1706
|
+
keys.append(key)
|
|
1707
|
+
|
|
1708
|
+
appended = []
|
|
1709
|
+
for key in keys:
|
|
1710
|
+
display = key + ("/" if (root / key).is_dir() and key != "." else "")
|
|
1711
|
+
existing = by_path.get(key)
|
|
1712
|
+
if existing:
|
|
1713
|
+
lines[existing["line"]] = row_text(existing["path"], existing["note"])
|
|
1714
|
+
print(f"[ok] {existing['path']} -> ANALYZED ({ref})")
|
|
1715
|
+
else:
|
|
1716
|
+
appended.append(row_text(display, ""))
|
|
1717
|
+
print(f"[ok] {display} added as ANALYZED ({ref})")
|
|
1718
|
+
|
|
1719
|
+
if appended:
|
|
1720
|
+
insert_at = (max(r["line"] for r in rows) + 1) if rows else len(lines)
|
|
1721
|
+
lines[insert_at:insert_at] = appended
|
|
1722
|
+
|
|
1723
|
+
f.parent.mkdir(parents=True, exist_ok=True)
|
|
1724
|
+
f.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
1725
|
+
if use_git_ref:
|
|
1726
|
+
# F-056: the reference is born here, so this is where the gestures that
|
|
1727
|
+
# orphan it are named. An amend was the observed one.
|
|
1728
|
+
print(f"[info] {ref} is the current HEAD. Commit this audit plan change as a new "
|
|
1729
|
+
"commit: amending, rebasing or squash-merging that commit orphans the "
|
|
1730
|
+
f"reference, and `{entry_script()} stale` fails once it no longer resolves. "
|
|
1731
|
+
"After a squash or rebase merge, re-mark on the integration branch.")
|
|
1732
|
+
return 0
|
|
1733
|
+
|
|
1734
|
+
|
|
1735
|
+
# ------------------------------------------------- orientation-hook wiring
|
|
1736
|
+
# F-041: read-only mirror of the installer-side detection in lib.js
|
|
1737
|
+
# (findOrientHook + orientHookValidator). The JS owns WRITING hooks, with its
|
|
1738
|
+
# injection guards; this side only DETECTS, at `check`, so a project whose
|
|
1739
|
+
# sessions start unoriented hears about it in-project (ENFORCEMENT par.4: the
|
|
1740
|
+
# hook is wired at init only -- pre-F-036 and agent-bootstrapped projects never
|
|
1741
|
+
# got it, and a wired-but-dead hook is "the worst of the three states").
|
|
1742
|
+
# The file list is a SUPERSET of the JS one: .codex/hooks.json is documented in
|
|
1743
|
+
# ENFORCEMENT par.4 but never written by init. Pinned equal on both sides:
|
|
1744
|
+
# the entry-point names, and (F-042) the user-settings path derivation --
|
|
1745
|
+
# CLAUDE_CONFIG_DIR when set, else <home>/.claude -- empty-string env falsy
|
|
1746
|
+
# on both sides.
|
|
1747
|
+
ORIENT_HOOK_ENTRY_POINTS = ("sdlc_check.py", "mkt_check.py")
|
|
1748
|
+
ORIENT_HOOK_CONFIG_FILES = (".claude/settings.json",
|
|
1749
|
+
".claude/settings.local.json",
|
|
1750
|
+
".codex/hooks.json")
|
|
1751
|
+
ORIENT_HOOK_CONFIG_CAP = 1024 * 1024 # settings are tiny; cap pathological files
|
|
1752
|
+
_ORIENT_HOOK_CMD_RE = re.compile(r" orient(\s|$)")
|
|
1753
|
+
_ORIENT_HOOK_TOKEN_RE = re.compile(r'"([^"]*)"|(\S+)')
|
|
1754
|
+
|
|
1755
|
+
|
|
1756
|
+
def _orient_entries_in(p):
|
|
1757
|
+
"""Yield the SessionStart commands invoking an orient entry point from ONE
|
|
1758
|
+
client config file. Fail-open: an unreadable, oversized, malformed or
|
|
1759
|
+
directory-shaped file contributes nothing. Reads are utf-8-sig -- a BOM'd
|
|
1760
|
+
settings.json (PowerShell/Notepad) must not read as unwired (declared
|
|
1761
|
+
divergence from Node's JSON.parse)."""
|
|
1762
|
+
try:
|
|
1763
|
+
if not p.is_file() or p.stat().st_size > ORIENT_HOOK_CONFIG_CAP:
|
|
1764
|
+
return
|
|
1765
|
+
data = json.loads(p.read_text(encoding="utf-8-sig"))
|
|
1766
|
+
except Exception:
|
|
1767
|
+
return
|
|
1768
|
+
if not isinstance(data, dict):
|
|
1769
|
+
return
|
|
1770
|
+
hooks = data.get("hooks")
|
|
1771
|
+
groups = hooks.get("SessionStart") if isinstance(hooks, dict) else None
|
|
1772
|
+
if groups is None:
|
|
1773
|
+
groups = data.get("SessionStart") # the .codex/hooks.json shape
|
|
1774
|
+
if not isinstance(groups, list):
|
|
1775
|
+
return
|
|
1776
|
+
for group in groups:
|
|
1777
|
+
if not isinstance(group, dict):
|
|
1778
|
+
continue
|
|
1779
|
+
inner = group.get("hooks")
|
|
1780
|
+
for h in inner if isinstance(inner, list) else [group]:
|
|
1781
|
+
cmd = h.get("command") if isinstance(h, dict) else None
|
|
1782
|
+
if (isinstance(cmd, str)
|
|
1783
|
+
and any(n in cmd for n in ORIENT_HOOK_ENTRY_POINTS)
|
|
1784
|
+
and _ORIENT_HOOK_CMD_RE.search(cmd)):
|
|
1785
|
+
yield cmd
|
|
1786
|
+
|
|
1787
|
+
|
|
1788
|
+
def _orient_user_settings_path():
|
|
1789
|
+
"""The machine-global settings file (F-042 wires it at install time).
|
|
1790
|
+
|
|
1791
|
+
Read at CALL time -- a DECLARED divergence from AGENTIC_SDLC_KB_ROOT's
|
|
1792
|
+
import-time read: unittest imports this module once before any test runs,
|
|
1793
|
+
so an import-time seam would be frozen before the matrix could set it.
|
|
1794
|
+
AGENTIC_SDLC_USER_SETTINGS is the test-isolation seam (python-side only);
|
|
1795
|
+
the production derivation mirrors lib.js: CLAUDE_CONFIG_DIR || ~/.claude."""
|
|
1796
|
+
seam = os.environ.get("AGENTIC_SDLC_USER_SETTINGS")
|
|
1797
|
+
if seam:
|
|
1798
|
+
return Path(seam)
|
|
1799
|
+
cfg = os.environ.get("CLAUDE_CONFIG_DIR")
|
|
1800
|
+
return (Path(cfg) if cfg else Path.home() / ".claude") / "settings.json"
|
|
1801
|
+
|
|
1802
|
+
|
|
1803
|
+
def _orient_hook_commands(root):
|
|
1804
|
+
"""Yield (command, at_user_level) across every known client config: the
|
|
1805
|
+
three project-relative files, then the machine-global user settings --
|
|
1806
|
+
the file the installer wires (F-042), without which a fixed machine's
|
|
1807
|
+
projects would read as unwired forever."""
|
|
1808
|
+
for rel in ORIENT_HOOK_CONFIG_FILES:
|
|
1809
|
+
for cmd in _orient_entries_in(Path(root) / rel):
|
|
1810
|
+
yield cmd, False
|
|
1811
|
+
for cmd in _orient_entries_in(_orient_user_settings_path()):
|
|
1812
|
+
yield cmd, True
|
|
1813
|
+
|
|
1814
|
+
|
|
1815
|
+
def orient_hook_state(root):
|
|
1816
|
+
"""'wired' | 'dead' | 'unwired'.
|
|
1817
|
+
|
|
1818
|
+
A matching entry whose tokens name no validator is "cannot tell" and counts
|
|
1819
|
+
as wired (the lib.js contract: never report broken on cannot-tell). 'dead'
|
|
1820
|
+
requires every matching entry to name a validator that does not resolve:
|
|
1821
|
+
the client runs ALL SessionStart hooks -- project-level AND user-level
|
|
1822
|
+
merge -- so one live entry at either level still orients the session
|
|
1823
|
+
(any-resolves aggregation across levels). Relative tokens resolve against
|
|
1824
|
+
the project root (the vendored-validator convention); a USER-level relative
|
|
1825
|
+
token has no cwd to resolve against and is treated as cannot-tell."""
|
|
1826
|
+
matched = False
|
|
1827
|
+
for cmd, at_user_level in _orient_hook_commands(root):
|
|
1828
|
+
matched = True
|
|
1829
|
+
token = None
|
|
1830
|
+
for m in _ORIENT_HOOK_TOKEN_RE.finditer(cmd):
|
|
1831
|
+
tok = m.group(1) if m.group(1) is not None else m.group(2)
|
|
1832
|
+
if any(tok.endswith(n) for n in ORIENT_HOOK_ENTRY_POINTS):
|
|
1833
|
+
token = tok
|
|
1834
|
+
break
|
|
1835
|
+
if token is None:
|
|
1836
|
+
return "wired"
|
|
1837
|
+
tp = Path(token)
|
|
1838
|
+
if not tp.is_absolute():
|
|
1839
|
+
if at_user_level:
|
|
1840
|
+
return "wired" # cannot tell: no cwd exists at user level
|
|
1841
|
+
tp = Path(root) / tp
|
|
1842
|
+
if tp.exists():
|
|
1843
|
+
return "wired"
|
|
1844
|
+
return "dead" if matched else "unwired"
|
|
1845
|
+
|
|
1846
|
+
|
|
1847
|
+
def print_orient_hook_note(root):
|
|
1848
|
+
"""Print the FS-A wiring note (unwired / dead), or nothing when wired.
|
|
1849
|
+
|
|
1850
|
+
Informational only, by contract: never an exit code, never a validate
|
|
1851
|
+
warning (--strict CI must not fail on a hook that legitimately lives in
|
|
1852
|
+
git-ignored settings.local.json). Called by the spine's cmd_check AND by
|
|
1853
|
+
an overlay that replaces check with its own pipeline (the marketing lens
|
|
1854
|
+
does): the note is check-layer behaviour, whoever owns the check."""
|
|
1855
|
+
try:
|
|
1856
|
+
state = orient_hook_state(root)
|
|
1857
|
+
if state == "unwired":
|
|
1858
|
+
print("[note] agent sessions start without automatic orientation on this "
|
|
1859
|
+
"machine (no session-orientation hook found at project or user level). "
|
|
1860
|
+
"Fix once: update or reinstall the skill npm package (on Claude Code "
|
|
1861
|
+
"with Python it wires the hook machine-wide), or wire it per "
|
|
1862
|
+
"ENFORCEMENT.md par.4")
|
|
1863
|
+
elif state == "dead":
|
|
1864
|
+
print("[note] a session-orientation hook is configured but points at a "
|
|
1865
|
+
"validator that no longer exists, so sessions start unoriented. Fix: "
|
|
1866
|
+
"update or reinstall the skill npm package, or correct the command. "
|
|
1867
|
+
"Details: ENFORCEMENT.md par.4")
|
|
1868
|
+
except Exception:
|
|
1869
|
+
pass # detection must never break check
|
|
1870
|
+
|
|
1871
|
+
|
|
1872
|
+
def cmd_check(root, strict=False, hybrid=False):
|
|
1873
|
+
print("===== validate =====")
|
|
1874
|
+
rc_v = cmd_validate(root, strict=strict, hybrid=hybrid)
|
|
1875
|
+
print("\n===== stale =====")
|
|
1876
|
+
rc_s = cmd_stale(root, hybrid=hybrid)
|
|
1877
|
+
print(f"\ncheck: {'CLEAN' if not (rc_v or rc_s) else 'NOT CLEAN'} "
|
|
1878
|
+
f"(validate rc={rc_v}, stale rc={rc_s})")
|
|
1879
|
+
print_orient_hook_note(root)
|
|
1880
|
+
return 1 if (rc_v or rc_s) else 0
|
|
1881
|
+
|
|
1882
|
+
|
|
1883
|
+
# --------------------------------------------------------------------- gate
|
|
1884
|
+
|
|
1885
|
+
# --- UserPromptSubmit reminder hook (F-046) --------------------------------
|
|
1886
|
+
# `orient` fires once per session and decays; a session that never invokes the
|
|
1887
|
+
# skill never had the protocol at all. Measured 2026-09-06: a release ran in
|
|
1888
|
+
# this repo against a `GUIDE_release.md` the session never consulted, because
|
|
1889
|
+
# SKILL.md -- where the duty is written -- had not been read.
|
|
1890
|
+
#
|
|
1891
|
+
# The carrier is kb's (F-041 item B), lifted here so all three lenses share one
|
|
1892
|
+
# machine instead of two that do the same thing. Its contract is kept: the line
|
|
1893
|
+
# is CONSTANT and reads NOTHING -- nothing repo- or session-controlled may ride
|
|
1894
|
+
# text injected every turn, and zero reads is what keeps the per-turn cost flat.
|
|
1895
|
+
# What changed is the payload's first duty and the wiring. First duty: decide
|
|
1896
|
+
# whether the skill governs this work and, if it does, read it once -- an
|
|
1897
|
+
# unread protocol cannot be applied, which is the failure above. Wiring: a
|
|
1898
|
+
# default instead of opt-in, on the owner's explicit acceptance of the per-turn
|
|
1899
|
+
# cost (`vision/rulings.md` r19; the "no ceremony ratchet" Non-Goal admits a
|
|
1900
|
+
# never-zero cost by that acceptance, and a trivial turn pays a yes/no
|
|
1901
|
+
# judgement, never a load).
|
|
1902
|
+
|
|
1903
|
+
REMIND_FALLBACK = (
|
|
1904
|
+
"decide whether the {skill} skill governs this work; if it does, load it "
|
|
1905
|
+
"once before acting -- an unread protocol cannot be applied."
|
|
1906
|
+
)
|
|
1907
|
+
|
|
1908
|
+
|
|
1909
|
+
def cmd_remind(args):
|
|
1910
|
+
"""UserPromptSubmit hook: print the lens's constant per-turn line. Always 0.
|
|
1911
|
+
|
|
1912
|
+
argv is ignored whole and nothing is read: a hook surface must not fail, and
|
|
1913
|
+
a flag typo must not cost a turn's context."""
|
|
1914
|
+
try:
|
|
1915
|
+
prof = profile()
|
|
1916
|
+
line = prof.get("remind_line") or REMIND_FALLBACK.format(skill=prof["skill_name"])
|
|
1917
|
+
print(f"{prof['skill_name']}: {line}")
|
|
1918
|
+
except Exception:
|
|
1919
|
+
pass
|
|
1920
|
+
return 0
|
|
1921
|
+
|
|
1922
|
+
def cmd_gate(args):
|
|
1923
|
+
file_path = args.file or ""
|
|
1924
|
+
if args.hook:
|
|
1925
|
+
try:
|
|
1926
|
+
# bytes -> utf-8-sig: the hook payload is UTF-8 JSON regardless of the
|
|
1927
|
+
# console code page; '-sig' strips the BOM (PowerShell pipes)
|
|
1928
|
+
raw = sys.stdin.buffer.read().decode("utf-8-sig", errors="replace")
|
|
1929
|
+
payload = json.loads(raw)
|
|
1930
|
+
file_path = (payload.get("tool_input") or {}).get("file_path") or ""
|
|
1931
|
+
except Exception:
|
|
1932
|
+
return 0 # unparseable input: do not block
|
|
1933
|
+
if not file_path:
|
|
1934
|
+
return 0
|
|
1935
|
+
root = Path(args.root).resolve() if args.root else find_project_root()
|
|
1936
|
+
try:
|
|
1937
|
+
rel = str(Path(file_path).resolve().relative_to(root)).replace("\\", "/")
|
|
1938
|
+
except ValueError:
|
|
1939
|
+
return 0 # outside the project: not this gate's concern
|
|
1940
|
+
if rel.startswith((docs_dir() + "/", "tests/", "test/")):
|
|
1941
|
+
return 0
|
|
1942
|
+
protected = [p.strip().replace("\\", "/").rstrip("/")
|
|
1943
|
+
for p in (args.protected or "").split(";") if p.strip()]
|
|
1944
|
+
if not protected:
|
|
1945
|
+
return 0
|
|
1946
|
+
if not any(rel == p or rel.startswith(p + "/") for p in protected):
|
|
1947
|
+
return 0
|
|
1948
|
+
for _, meta, _ in list_analyses(root):
|
|
1949
|
+
if meta.get("status") == "IN_PROGRESS":
|
|
1950
|
+
return 0
|
|
1951
|
+
if args.hybrid and has_etdd_shadow(root):
|
|
1952
|
+
return 0 # Hybrid design gate: an approved E-TDD shadow authorizes the change
|
|
1953
|
+
if args.hybrid:
|
|
1954
|
+
sys.stderr.write(
|
|
1955
|
+
f"[sdlc gate] '{rel}' is on a protected path but no E-TDD shadow "
|
|
1956
|
+
"(solutions/SHADOW_*tdd*.md) exists and no ANALYSIS_*.md is IN_PROGRESS. "
|
|
1957
|
+
"In Hybrid mode, export the approved E-TDD shadow from devPNT before implementing.\n")
|
|
1958
|
+
return 2
|
|
1959
|
+
sys.stderr.write(
|
|
1960
|
+
f"[sdlc gate] '{rel}' is on a protected path but no ANALYSIS_*.md is IN_PROGRESS. "
|
|
1961
|
+
"If your analysis already exists, set its frontmatter to 'status: IN_PROGRESS' "
|
|
1962
|
+
"(that flip is what opens the gate); otherwise write it first (Phase 3).\n")
|
|
1963
|
+
return 2
|
|
1964
|
+
|
|
1965
|
+
|
|
1966
|
+
# --------------------------------------------------------------------- plan
|
|
1967
|
+
# Subagent Execution (Feature A). Zero-execution surface: this section and
|
|
1968
|
+
# everything it calls MUST NOT spawn a process (no subprocess/os.system/eval/
|
|
1969
|
+
# exec, no git_* helper). It validates a PLAN_[feature].md and prints a task
|
|
1970
|
+
# brief as text; the orchestrator (dispatch.md) is the sole executor.
|
|
1971
|
+
|
|
1972
|
+
_PLAN_JSON_RE = re.compile(r"```json\s*\n(.*?)```", re.DOTALL)
|
|
1973
|
+
|
|
1974
|
+
|
|
1975
|
+
def extract_plan_json(text):
|
|
1976
|
+
"""Extract the first fenced ```json block from a PLAN_[feature].md body.
|
|
1977
|
+
Returns (data, "") on success, or (None, reason) on any failure. Never
|
|
1978
|
+
raises: a malformed or missing block is a validation failure, not a crash."""
|
|
1979
|
+
m = _PLAN_JSON_RE.search(text or "")
|
|
1980
|
+
if not m:
|
|
1981
|
+
return None, "no fenced ```json block found in the plan file"
|
|
1982
|
+
try:
|
|
1983
|
+
data = json.loads(m.group(1))
|
|
1984
|
+
except (ValueError, TypeError) as e:
|
|
1985
|
+
return None, f"malformed JSON in the plan block: {e}"
|
|
1986
|
+
if not isinstance(data, dict):
|
|
1987
|
+
return None, "plan JSON block must be a JSON object"
|
|
1988
|
+
return data, ""
|
|
1989
|
+
|
|
1990
|
+
|
|
1991
|
+
def load_ledger(path):
|
|
1992
|
+
"""Read the sidecar ledger {"<task_id>": {"status", "verify_result",
|
|
1993
|
+
"timestamp"}}. Absent file -> ({}, ""). Malformed/unreadable -> ({}, reason).
|
|
1994
|
+
Never raises, never hangs: the ledger is untrusted state read on every call."""
|
|
1995
|
+
if not path.is_file():
|
|
1996
|
+
return {}, ""
|
|
1997
|
+
try:
|
|
1998
|
+
raw = read_text(path)
|
|
1999
|
+
data = json.loads(raw)
|
|
2000
|
+
except (ValueError, TypeError, OSError) as e:
|
|
2001
|
+
return {}, f"ledger '{path}' unreadable/malformed, treating as empty: {e}"
|
|
2002
|
+
if not isinstance(data, dict):
|
|
2003
|
+
return {}, f"ledger '{path}' is not a JSON object, treating as empty"
|
|
2004
|
+
return data, ""
|
|
2005
|
+
|
|
2006
|
+
|
|
2007
|
+
def _confine_or_reject(base, rel, label, rel_label, errors):
|
|
2008
|
+
t = confine_under(base, rel)
|
|
2009
|
+
if t is None:
|
|
2010
|
+
errors.append(f"{rel_label}: {label} '{rel}' is absolute, contains '..', or escapes "
|
|
2011
|
+
f"'{base}' — rejected (fail closed)")
|
|
2012
|
+
return t
|
|
2013
|
+
|
|
2014
|
+
|
|
2015
|
+
def _validate_plan_tasks(root, data, rel_label, errors, warnings):
|
|
2016
|
+
"""Shared core of `plan validate`/`plan brief`: schema + confinement checks.
|
|
2017
|
+
Returns the task list (possibly empty) on success; errors/warnings are
|
|
2018
|
+
appended in place. Callers decide the exit code."""
|
|
2019
|
+
tasks = data.get("tasks")
|
|
2020
|
+
if not isinstance(tasks, list) or not tasks:
|
|
2021
|
+
errors.append(f"{rel_label}: 'tasks' must be a non-empty JSON array")
|
|
2022
|
+
return []
|
|
2023
|
+
ref_dir = ai_path(root, "reference")
|
|
2024
|
+
kb_ref = DEFAULT_KB_ROOT / "ai_docs" / "reference"
|
|
2025
|
+
seen_ids = set()
|
|
2026
|
+
for i, task in enumerate(tasks):
|
|
2027
|
+
loc = f"{rel_label}: task[{i}]"
|
|
2028
|
+
if not isinstance(task, dict):
|
|
2029
|
+
errors.append(f"{loc}: not a JSON object")
|
|
2030
|
+
continue
|
|
2031
|
+
missing = [k for k in PLAN_TASK_REQUIRED if not task.get(k)]
|
|
2032
|
+
if missing:
|
|
2033
|
+
errors.append(f"{loc}: missing required field(s): {', '.join(missing)}")
|
|
2034
|
+
if not task.get("paths") and not task.get("produces"):
|
|
2035
|
+
errors.append(f"{loc}: must declare at least one of 'paths'/'produces'")
|
|
2036
|
+
tid = task.get("id")
|
|
2037
|
+
if tid:
|
|
2038
|
+
if tid in seen_ids:
|
|
2039
|
+
errors.append(f"{loc}: duplicate task id '{tid}'")
|
|
2040
|
+
seen_ids.add(tid)
|
|
2041
|
+
for key in ("paths", "consumes", "produces"):
|
|
2042
|
+
for p in (task.get(key) or []):
|
|
2043
|
+
_confine_or_reject(root, p, key, loc, errors)
|
|
2044
|
+
for g in (task.get("guides") or []):
|
|
2045
|
+
in_project = confine_under(ref_dir, g)
|
|
2046
|
+
in_kb = confine_under(kb_ref, g)
|
|
2047
|
+
if in_project is None and in_kb is None:
|
|
2048
|
+
errors.append(f"{loc}: guide '{g}' is not confined under the project reference "
|
|
2049
|
+
f"dir ({ref_dir}) or the agent KB reference dir ({kb_ref}) — rejected")
|
|
2050
|
+
return tasks
|
|
2051
|
+
|
|
2052
|
+
|
|
2053
|
+
def cmd_plan(root, args):
|
|
2054
|
+
"""Zero-execution: validates/briefs a PLAN_[feature].md. Never spawns a
|
|
2055
|
+
process, never calls a git_* helper, never runs the opaque `verify` text —
|
|
2056
|
+
it is printed, not executed."""
|
|
2057
|
+
plan_path = Path(args.file)
|
|
2058
|
+
if not plan_path.is_absolute():
|
|
2059
|
+
plan_path = root / plan_path
|
|
2060
|
+
if not plan_path.is_file():
|
|
2061
|
+
sys.stderr.write(f"[plan] plan file not found: {plan_path}\n")
|
|
2062
|
+
return 2
|
|
2063
|
+
rel_label = str(plan_path)
|
|
2064
|
+
data, reason = extract_plan_json(read_text(plan_path))
|
|
2065
|
+
if data is None:
|
|
2066
|
+
sys.stderr.write(f"[plan] {rel_label}: {reason}\n")
|
|
2067
|
+
return 2
|
|
2068
|
+
|
|
2069
|
+
errors, warnings = [], []
|
|
2070
|
+
tasks = _validate_plan_tasks(root, data, rel_label, errors, warnings)
|
|
2071
|
+
|
|
2072
|
+
ledger_path = plan_path.with_name(plan_path.stem + ".ledger.json")
|
|
2073
|
+
ledger, ledger_reason = load_ledger(ledger_path)
|
|
2074
|
+
if ledger_reason:
|
|
2075
|
+
warnings.append(ledger_reason)
|
|
2076
|
+
if not errors:
|
|
2077
|
+
task_ids = {t.get("id") for t in tasks if isinstance(t, dict)}
|
|
2078
|
+
for lid in ledger:
|
|
2079
|
+
if lid not in task_ids:
|
|
2080
|
+
warnings.append(f"ledger id '{lid}' not found in {rel_label}: orphaned entry (not fatal)")
|
|
2081
|
+
|
|
2082
|
+
for w in warnings:
|
|
2083
|
+
sys.stderr.write(f"[warn] {w}\n")
|
|
2084
|
+
for e in errors:
|
|
2085
|
+
sys.stderr.write(f"[ERROR] {e}\n")
|
|
2086
|
+
|
|
2087
|
+
if args.plan_cmd == "validate":
|
|
2088
|
+
if errors:
|
|
2089
|
+
sys.stderr.write(f"\n[plan] validate: {len(errors)} errors, {len(warnings)} warnings.\n")
|
|
2090
|
+
return 2
|
|
2091
|
+
print(f"[ok] {rel_label}: plan valid ({len(tasks)} task(s), {len(warnings)} warning(s)).")
|
|
2092
|
+
return 0
|
|
2093
|
+
|
|
2094
|
+
# brief
|
|
2095
|
+
if errors:
|
|
2096
|
+
sys.stderr.write(f"\n[plan] brief: plan is invalid, refusing to brief ({len(errors)} errors).\n")
|
|
2097
|
+
return 2
|
|
2098
|
+
target = None
|
|
2099
|
+
for t in tasks:
|
|
2100
|
+
if isinstance(t, dict) and t.get("id") == args.task:
|
|
2101
|
+
target = t
|
|
2102
|
+
break
|
|
2103
|
+
if target is None:
|
|
2104
|
+
sys.stderr.write(f"[plan] brief: task id '{args.task}' not found in {rel_label}\n")
|
|
2105
|
+
return 2
|
|
2106
|
+
|
|
2107
|
+
print(f"# Task: {target.get('id')} — {target.get('title', '')}")
|
|
2108
|
+
print()
|
|
2109
|
+
print("## Task block")
|
|
2110
|
+
print(json.dumps(target, indent=2))
|
|
2111
|
+
print()
|
|
2112
|
+
print("## Produces of prior-order tasks (interfaces)")
|
|
2113
|
+
prior_produces = []
|
|
2114
|
+
for t in tasks:
|
|
2115
|
+
if not isinstance(t, dict):
|
|
2116
|
+
continue
|
|
2117
|
+
if t.get("id") == target.get("id"):
|
|
2118
|
+
break
|
|
2119
|
+
prior_produces.extend(t.get("produces") or [])
|
|
2120
|
+
if prior_produces:
|
|
2121
|
+
for p in prior_produces:
|
|
2122
|
+
print(f"- {p}")
|
|
2123
|
+
else:
|
|
2124
|
+
print("(none)")
|
|
2125
|
+
print()
|
|
2126
|
+
print("## Guide pointers (paths, not content)")
|
|
2127
|
+
guides = target.get("guides") or []
|
|
2128
|
+
if guides:
|
|
2129
|
+
for g in guides:
|
|
2130
|
+
print(f"- {g}")
|
|
2131
|
+
else:
|
|
2132
|
+
print("(none)")
|
|
2133
|
+
print()
|
|
2134
|
+
print("## Verify (opaque text — orchestrator runs this out of band, NOT executed here)")
|
|
2135
|
+
print(target.get("verify", ""))
|
|
2136
|
+
return 0
|
|
2137
|
+
|
|
2138
|
+
|
|
2139
|
+
# --- F-050: the benefit report -------------------------------------------
|
|
2140
|
+
# The process's central claim is that it makes divergence visible BEFORE
|
|
2141
|
+
# implementation. That claim lived in prose for four months while the evidence
|
|
2142
|
+
# for it sat unread in REVIEW_LOG. This reads it.
|
|
2143
|
+
#
|
|
2144
|
+
# Columns are resolved BY HEADER NAME, never by width. `templates.md` states
|
|
2145
|
+
# that contract ("extra or reordered columns are fine -- but the header must
|
|
2146
|
+
# say `tier`") and three widths are legal today: the original 8, Standalone's 9
|
|
2147
|
+
# (F-048 added `model`), and Hybrid's 10 (`instrument` + `notes`, and no
|
|
2148
|
+
# `reviewer` at all). A width whitelist reads none of the last, and silently
|
|
2149
|
+
# reports a devPNT-governed project as having no data at all.
|
|
2150
|
+
#
|
|
2151
|
+
# Two honesty rules, both learned from this unit's own review: whatever cannot
|
|
2152
|
+
# be read is COUNTED and NAMED, at the row level AND at the cell level. A ratio
|
|
2153
|
+
# over a silently shrunken denominator is worse than no ratio.
|
|
2154
|
+
REVIEW_CORE = ("date", "doc_key", "tier", "findings_real", "verdict")
|
|
2155
|
+
REVIEW_POSITIONAL = {
|
|
2156
|
+
8: ("date", "doc_key", "tier", "reviewer", "findings_raised",
|
|
2157
|
+
"findings_real", "verdict", "revise_rounds"),
|
|
2158
|
+
9: ("date", "doc_key", "tier", "model", "reviewer", "findings_raised",
|
|
2159
|
+
"findings_real", "verdict", "revise_rounds"),
|
|
2160
|
+
10: ("date", "doc_key", "tier", "model", "instrument", "findings_raised",
|
|
2161
|
+
"findings_real", "verdict", "revise_rounds", "notes"),
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
|
|
2165
|
+
def _table_cells(line):
|
|
2166
|
+
"""Cells of a Markdown table row, or None when the line is not one.
|
|
2167
|
+
Separator rows (---, :---:, any alignment colons) are not data."""
|
|
2168
|
+
s = line.strip()
|
|
2169
|
+
if not s.startswith("|"):
|
|
2170
|
+
return None
|
|
2171
|
+
if set(s) <= set("|-: "):
|
|
2172
|
+
return None
|
|
2173
|
+
return [c.strip() for c in s.strip("|").split("|")]
|
|
2174
|
+
|
|
2175
|
+
|
|
2176
|
+
def parse_review_log(text):
|
|
2177
|
+
"""-> (rows, unparsed). Columns come from the header when there is one.
|
|
2178
|
+
|
|
2179
|
+
Falls back to the positional maps only for a table with no header, so a
|
|
2180
|
+
hand-kept log still reads. A row whose cell count does not match the
|
|
2181
|
+
header's is unparsed and named -- never dropped."""
|
|
2182
|
+
rows, unparsed, names = [], [], None
|
|
2183
|
+
for n, line in enumerate(text.splitlines(), 1):
|
|
2184
|
+
cells = _table_cells(line)
|
|
2185
|
+
if cells is None:
|
|
2186
|
+
continue
|
|
2187
|
+
lowered = [c.lower() for c in cells]
|
|
2188
|
+
if names is None and "tier" in lowered and "verdict" in lowered:
|
|
2189
|
+
names = lowered # the header IS the column map
|
|
2190
|
+
continue
|
|
2191
|
+
# The header supplies the map -- that is what makes reordered and extra
|
|
2192
|
+
# columns readable. But a log legitimately carries MIXED widths (F-048
|
|
2193
|
+
# widened the schema and did not rewrite history), so a row that does
|
|
2194
|
+
# not match the header is read by the positional map for its own width
|
|
2195
|
+
# before it is given up on. Only a width no map knows is unparsed.
|
|
2196
|
+
if names is not None and len(cells) == len(names):
|
|
2197
|
+
row = dict(zip(names, cells))
|
|
2198
|
+
else:
|
|
2199
|
+
positional = REVIEW_POSITIONAL.get(len(cells))
|
|
2200
|
+
if positional is None:
|
|
2201
|
+
unparsed.append((n, "%d cells: %s" % (
|
|
2202
|
+
len(cells),
|
|
2203
|
+
"header declares %d and no positional map fits"
|
|
2204
|
+
% len(names) if names else "no header, and no map fits")))
|
|
2205
|
+
continue
|
|
2206
|
+
row = dict(zip(positional, cells))
|
|
2207
|
+
missing = [c for c in REVIEW_CORE if c not in row]
|
|
2208
|
+
if missing:
|
|
2209
|
+
unparsed.append((n, "no %s column" % "/".join(missing)))
|
|
2210
|
+
continue
|
|
2211
|
+
if not re.match(r"^\d{4}-\d{2}-\d{2}$", row["date"]):
|
|
2212
|
+
unparsed.append((n, "date cell %r is not a date" % row["date"][:24]))
|
|
2213
|
+
continue
|
|
2214
|
+
rows.append(row)
|
|
2215
|
+
return rows, unparsed
|
|
2216
|
+
|
|
2217
|
+
|
|
2218
|
+
def review_findings(value):
|
|
2219
|
+
"""The count in a `findings_real` cell, or None when it states no number.
|
|
2220
|
+
|
|
2221
|
+
None is NOT zero. `all` and `VOID -- instrument error` appear in real logs,
|
|
2222
|
+
and counting them as zero findings silently deflates the one ratio this
|
|
2223
|
+
report exists to state. Anchored at the START of the cell so a prose cell
|
|
2224
|
+
like `R1 4 BLOCK + 8 WARN` does not yield a confident 4."""
|
|
2225
|
+
m = re.match(r"^(\d+)\b", (value or "").strip())
|
|
2226
|
+
return int(m.group(1)) if m else None
|
|
2227
|
+
|
|
2228
|
+
|
|
2229
|
+
def _is_fail(verdict):
|
|
2230
|
+
"""FAIL as a WORD, so `PASS (no failures)` is not counted as a failure."""
|
|
2231
|
+
return re.search(r"\bFAIL\b", (verdict or "").upper()) is not None
|
|
2232
|
+
|
|
2233
|
+
|
|
2234
|
+
def _is_pass(verdict):
|
|
2235
|
+
return re.search(r"\bPASS\b", (verdict or "").upper()) is not None
|
|
2236
|
+
|
|
2237
|
+
|
|
2238
|
+
def _print_read_cost():
|
|
2239
|
+
"""The cost side, printed with the benefit and NEVER without it: a catch
|
|
2240
|
+
rate with no cost term justifies unbounded doctrine, and a cost term with
|
|
2241
|
+
no catch rate justifies deleting the process. When it cannot be measured
|
|
2242
|
+
the line still prints, saying so -- silence here IS the catch rate quoted
|
|
2243
|
+
alone, which is the thing this report exists to prevent."""
|
|
2244
|
+
skill_dir = Path(__file__).resolve().parent.parent
|
|
2245
|
+
contract = skill_dir / "SKILL.md"
|
|
2246
|
+
print()
|
|
2247
|
+
if not contract.is_file():
|
|
2248
|
+
print("Read cost: unavailable -- SKILL.md not found beside this "
|
|
2249
|
+
"validator (%s)" % skill_dir)
|
|
2250
|
+
return
|
|
2251
|
+
files = [p for p in sorted(skill_dir.glob("*.md")) if p.name != "SKILL.md"]
|
|
2252
|
+
support = sum(p.stat().st_size for p in files)
|
|
2253
|
+
head = contract.stat().st_size
|
|
2254
|
+
# The TOTAL, and the files itemised. Printing only the two terms lets the
|
|
2255
|
+
# one that fell be quoted while the one that grew stays lumped -- which is
|
|
2256
|
+
# exactly how a -7.8% headline coexisted with a read surface that grew.
|
|
2257
|
+
print("Read cost of %s: SKILL.md %d bytes (every session) + %d bytes "
|
|
2258
|
+
"across %d support files (read on trigger) = %d bytes total"
|
|
2259
|
+
% (skill_dir.name, head, support, len(files), head + support))
|
|
2260
|
+
for p in files:
|
|
2261
|
+
print(" %-24s %8d" % (p.name, p.stat().st_size))
|
|
2262
|
+
|
|
2263
|
+
|
|
2264
|
+
def cmd_benefit(root):
|
|
2265
|
+
"""Report what the process caught, beside what it costs to read.
|
|
2266
|
+
|
|
2267
|
+
ALWAYS exits 0 and emits no warning or advisory. This number is the
|
|
2268
|
+
criterion future units are judged by; a measurement that can fail a build
|
|
2269
|
+
becomes a target, and a targeted measurement stops measuring."""
|
|
2270
|
+
log = root / review_log_rel()
|
|
2271
|
+
print("=== benefit: what the process caught, and what it costs to read ===")
|
|
2272
|
+
if not log.is_file():
|
|
2273
|
+
print("[info] no %s yet: nothing to report. The number appears once "
|
|
2274
|
+
"reviews are logged." % review_log_rel())
|
|
2275
|
+
_print_read_cost()
|
|
2276
|
+
return 0
|
|
2277
|
+
rows, unparsed = parse_review_log(read_text(log))
|
|
2278
|
+
# `tier` carries the MOMENT in Standalone (design / closure) and the
|
|
2279
|
+
# reviewer WEIGHT in Hybrid (deep / light / code / guide / vision). A
|
|
2280
|
+
# Hybrid row is a real review whose moment this column does not state, so
|
|
2281
|
+
# it is counted and named, never folded into a moment by guesswork.
|
|
2282
|
+
moments = {"design": [], "closure": []}
|
|
2283
|
+
unstated = {}
|
|
2284
|
+
unstated_found = unstated_code = unstated_uncounted = 0
|
|
2285
|
+
for r in rows:
|
|
2286
|
+
tier = r["tier"].lower()
|
|
2287
|
+
if tier.startswith("design"):
|
|
2288
|
+
moments["design"].append(r)
|
|
2289
|
+
elif tier.startswith("closure"):
|
|
2290
|
+
moments["closure"].append(r)
|
|
2291
|
+
else:
|
|
2292
|
+
unstated[tier] = unstated.get(tier, 0) + 1
|
|
2293
|
+
n = review_findings(r["findings_real"])
|
|
2294
|
+
if n is None:
|
|
2295
|
+
unstated_uncounted += 1
|
|
2296
|
+
else:
|
|
2297
|
+
unstated_found += n
|
|
2298
|
+
if tier.startswith("code"):
|
|
2299
|
+
unstated_code += n
|
|
2300
|
+
classified = moments["design"] + moments["closure"]
|
|
2301
|
+
print("rows parsed: %d rows unparsed: %d%s"
|
|
2302
|
+
% (len(rows), len(unparsed),
|
|
2303
|
+
(" -> " + "; ".join("line %d: %s" % u for u in unparsed[:5]))
|
|
2304
|
+
if unparsed else ""))
|
|
2305
|
+
print("reviews with a stated moment: %d moment not stated by `tier`: %d%s"
|
|
2306
|
+
% (len(classified), sum(unstated.values()),
|
|
2307
|
+
(" (" + ", ".join("%s x%d" % (t, n) for t, n
|
|
2308
|
+
in sorted(unstated.items())) + ")")
|
|
2309
|
+
if unstated else ""))
|
|
2310
|
+
if not classified:
|
|
2311
|
+
_print_read_cost()
|
|
2312
|
+
return 0
|
|
2313
|
+
dates = sorted(r["date"] for r in classified)
|
|
2314
|
+
print("span of the reviews counted below: %s -> %s" % (dates[0], dates[-1]))
|
|
2315
|
+
|
|
2316
|
+
print()
|
|
2317
|
+
print("%-9s %6s %10s %11s %10s" % ("moment", "rows", "findings",
|
|
2318
|
+
"FAIL verdict", "per review"))
|
|
2319
|
+
totals, uncounted = {}, 0
|
|
2320
|
+
for m in ("design", "closure"):
|
|
2321
|
+
rs = moments[m]
|
|
2322
|
+
counts = [review_findings(r["findings_real"]) for r in rs]
|
|
2323
|
+
uncounted += sum(1 for c in counts if c is None)
|
|
2324
|
+
found = sum(c for c in counts if c is not None)
|
|
2325
|
+
failed = sum(1 for r in rs if _is_fail(r["verdict"]))
|
|
2326
|
+
totals[m] = found
|
|
2327
|
+
print("%-9s %6d %10d %11d %10.1f"
|
|
2328
|
+
% (m, len(rs), found, failed, (found / len(rs)) if rs else 0.0))
|
|
2329
|
+
total = sum(totals.values())
|
|
2330
|
+
if uncounted:
|
|
2331
|
+
print("findings cells stating no number: %d (counted as unknown, "
|
|
2332
|
+
"never as zero)" % uncounted)
|
|
2333
|
+
if total:
|
|
2334
|
+
print()
|
|
2335
|
+
print("Caught BEFORE the code existed: %d / %d findings = %.0f%%"
|
|
2336
|
+
% (totals["design"], total, 100.0 * totals["design"] / total))
|
|
2337
|
+
# The residue, stated as a BOUND rather than as a direction. The
|
|
2338
|
+
# sentence this replaces claimed the share was "a floor, not a
|
|
2339
|
+
# ceiling" because `code` rows are post-implementation -- which is
|
|
2340
|
+
# backwards, since folding them LOWERS the design share. But the fact
|
|
2341
|
+
# was right and only the direction wrong, so `code` findings sit on
|
|
2342
|
+
# the closure side of the upper bound instead of being discarded with
|
|
2343
|
+
# it. No direction is printed at all: the unstated set pushes both
|
|
2344
|
+
# ways, and only an interval is true of all of it.
|
|
2345
|
+
if unstated_found:
|
|
2346
|
+
denom = total + unstated_found
|
|
2347
|
+
print("Bounded by the %d findings in rows whose moment `tier` "
|
|
2348
|
+
"does not state: %.0f%%-%.0f%% (`code` rows are "
|
|
2349
|
+
"post-implementation by definition, so their findings sit "
|
|
2350
|
+
"on the closure side of the upper bound)"
|
|
2351
|
+
% (unstated_found, 100.0 * totals["design"] / denom,
|
|
2352
|
+
100.0 * (totals["design"] + unstated_found - unstated_code)
|
|
2353
|
+
/ denom))
|
|
2354
|
+
if unstated_uncounted:
|
|
2355
|
+
print("%d unstated rows state no findings count (`all`, `VOID`): "
|
|
2356
|
+
"neither zero nor countable, so they lie outside both "
|
|
2357
|
+
"bounds" % unstated_uncounted)
|
|
2358
|
+
failed_all = sum(1 for r in classified if _is_fail(r["verdict"]))
|
|
2359
|
+
inconclusive = sum(1 for r in classified if not _is_fail(r["verdict"])
|
|
2360
|
+
and not _is_pass(r["verdict"]))
|
|
2361
|
+
print("Reviews whose verdict contains FAIL: %d / %d = %.0f%%%s"
|
|
2362
|
+
% (failed_all, len(classified), 100.0 * failed_all / len(classified),
|
|
2363
|
+
(" (+%d inconclusive)" % inconclusive) if inconclusive else ""))
|
|
2364
|
+
print("(both figures are over the %d reviews whose moment `tier` states)"
|
|
2365
|
+
% len(classified))
|
|
2366
|
+
_print_read_cost()
|
|
2367
|
+
return 0
|
|
2368
|
+
|
|
2369
|
+
|
|
2370
|
+
def cmd_orient(args):
|
|
2371
|
+
"""SessionStart hook: emit a bounded, repo-sourced ai_docs/ orientation to
|
|
2372
|
+
stdout and ALWAYS return 0 (fail-open, P-TM T8) -- a session hook must never
|
|
2373
|
+
block the session or surface a traceback. Zero-execution (P-TM T1): reads a
|
|
2374
|
+
fixed hard-coded doc set, confine_under each (P-TM T3), size-caps the total
|
|
2375
|
+
(P-TM T2). No subprocess/eval anywhere in this call graph."""
|
|
2376
|
+
try:
|
|
2377
|
+
root = Path(args.root).resolve() if getattr(args, "root", None) else find_project_root()
|
|
2378
|
+
chunks = []
|
|
2379
|
+
total = 0
|
|
2380
|
+
truncated = False
|
|
2381
|
+
for label, rel in orient_docs():
|
|
2382
|
+
target = confine_under(root, rel)
|
|
2383
|
+
if target is None or not target.is_file():
|
|
2384
|
+
continue
|
|
2385
|
+
try:
|
|
2386
|
+
text = read_text(target)
|
|
2387
|
+
except OSError:
|
|
2388
|
+
continue
|
|
2389
|
+
remaining = ORIENT_MAX_TOTAL_CHARS - total
|
|
2390
|
+
if remaining <= 0:
|
|
2391
|
+
truncated = True
|
|
2392
|
+
break
|
|
2393
|
+
text = text[:ORIENT_PER_DOC_CHARS]
|
|
2394
|
+
if len(text) > remaining:
|
|
2395
|
+
text = text[:remaining]
|
|
2396
|
+
truncated = True
|
|
2397
|
+
chunks.append((label, text))
|
|
2398
|
+
total += len(text)
|
|
2399
|
+
if not chunks:
|
|
2400
|
+
return 0
|
|
2401
|
+
out = ["=== Agentic SDLC -- session orientation (repo-sourced context, not authored instructions) ==="]
|
|
2402
|
+
for label, text in chunks:
|
|
2403
|
+
out.append(f"\n## {label}\n{text}")
|
|
2404
|
+
if truncated:
|
|
2405
|
+
out.append("\n[orientation truncated to the size cap -- open the files directly for full content]")
|
|
2406
|
+
out.append("\nTriage every request (Rule Zero): L1 trivial - L2 small - L3 significant - Spike. "
|
|
2407
|
+
"When in doubt, pick the higher level.")
|
|
2408
|
+
if getattr(args, "hybrid", False):
|
|
2409
|
+
out.append("\n[devPNT active] Run devpnt_mcp_get_bootstrap for the Master Plan / Knowledge Layer -- "
|
|
2410
|
+
"the orientation above is the filesystem layer, not a bootstrap duplicate.")
|
|
2411
|
+
print("\n".join(out))
|
|
2412
|
+
return 0
|
|
2413
|
+
except Exception:
|
|
2414
|
+
return 0
|
|
2415
|
+
|
|
2416
|
+
|
|
2417
|
+
# --- portable checks carried by the core ------------------------------------
|
|
2418
|
+
# Portable means portable: a check that any distribution may expose lives HERE, so
|
|
2419
|
+
# three copies of the same twenty lines cannot drift apart. What a distribution
|
|
2420
|
+
# actually offers is its `provides` declaration, not what it happens to have copied.
|
|
2421
|
+
# Domain-specific machinery (mkt's budget and funnel arithmetic) stays in its own
|
|
2422
|
+
# entry point -- that is the line between portable and proprietary.
|
|
2423
|
+
# Opt-in per document via `checks:`. They may only ADD findings: a document that
|
|
2424
|
+
# imports one still owes its own domain everything it owed before, so importing a
|
|
2425
|
+
# check can never be a way to be validated less.
|
|
2426
|
+
|
|
2427
|
+
@portable_check("code.threat_model")
|
|
2428
|
+
def _code_threat_model(rel, meta, text):
|
|
2429
|
+
"""The security section names a real surface, or justifies claiming none."""
|
|
2430
|
+
section = section_body(text, ("## Security and Threat Model", "## Security"))
|
|
2431
|
+
if section is None:
|
|
2432
|
+
return [] # the owning domain already reports a missing section; no double finding
|
|
2433
|
+
surfaces = ("external input", "authn", "authz", "auth", "crypto", "network",
|
|
2434
|
+
"personal data", "filesystem", "supply chain")
|
|
2435
|
+
low = section.lower()
|
|
2436
|
+
if any(s in low for s in surfaces):
|
|
2437
|
+
return []
|
|
2438
|
+
if "no security impact" in low or "no new security" in low:
|
|
2439
|
+
if len(section.split()) < 15:
|
|
2440
|
+
return [("warning", "'no security impact' is declared, not justified: "
|
|
2441
|
+
"say which surfaces you checked and why none is touched")]
|
|
2442
|
+
return []
|
|
2443
|
+
return [("warning", "no security surface named (external input, authN/authZ, crypto, "
|
|
2444
|
+
"network, personal data, filesystem, supply chain) and no justified "
|
|
2445
|
+
"claim that none is touched")]
|
|
2446
|
+
|
|
2447
|
+
|
|
2448
|
+
@portable_check("knowledge.sources")
|
|
2449
|
+
def _knowledge_sources(rel, meta, text):
|
|
2450
|
+
"""A knowledge artifact says what it was written from AND how that was verified."""
|
|
2451
|
+
section = section_body(text, ("## Sources and Verification",))
|
|
2452
|
+
if section is None:
|
|
2453
|
+
return []
|
|
2454
|
+
findings = []
|
|
2455
|
+
if not re.search(r"(?m)^\s*[-*|]|\bhttps?://|\.(?:md|pdf|docx?|csv|xlsx?)\b", section):
|
|
2456
|
+
findings.append(("warning", "no source is named: a distillation whose origin cannot "
|
|
2457
|
+
"be reopened is model knowledge, not knowledge work"))
|
|
2458
|
+
if not re.search(r"verif|cross-check|checked against|confirmed", section, re.I):
|
|
2459
|
+
findings.append(("warning", "sources are listed but not verified: say how each was "
|
|
2460
|
+
"confirmed, or mark explicitly what could not be"))
|
|
2461
|
+
return findings
|
|
2462
|
+
|
|
2463
|
+
|
|
2464
|
+
# ------------------------------------------------------------------- migrate
|
|
2465
|
+
|
|
2466
|
+
def _migration_plan(root, src_name, dst_name):
|
|
2467
|
+
"""(files, refs, conflicts) for a docs-root move. Reads only."""
|
|
2468
|
+
src = root / src_name
|
|
2469
|
+
dst = root / dst_name
|
|
2470
|
+
files, refs, conflicts = [], [], []
|
|
2471
|
+
if not src.is_dir():
|
|
2472
|
+
return None, None, None
|
|
2473
|
+
for p in sorted(src.rglob("*")):
|
|
2474
|
+
if not p.is_file():
|
|
2475
|
+
continue
|
|
2476
|
+
rel = p.relative_to(src)
|
|
2477
|
+
target = dst / rel
|
|
2478
|
+
if target.exists():
|
|
2479
|
+
conflicts.append(rel.as_posix())
|
|
2480
|
+
files.append(rel.as_posix())
|
|
2481
|
+
if p.suffix.lower() in (".md", ".txt", ".json", ".yml", ".yaml"):
|
|
2482
|
+
try:
|
|
2483
|
+
if f"{src_name}/" in p.read_text(encoding="utf-8", errors="replace"):
|
|
2484
|
+
refs.append(rel.as_posix())
|
|
2485
|
+
except OSError:
|
|
2486
|
+
pass
|
|
2487
|
+
return files, refs, conflicts
|
|
2488
|
+
|
|
2489
|
+
|
|
2490
|
+
def _external_references(root, src_name):
|
|
2491
|
+
"""Files OUTSIDE both roots that mention the old root.
|
|
2492
|
+
|
|
2493
|
+
Reported, never edited: the protocol pointers (CLAUDE.md, AGENTS.md,
|
|
2494
|
+
.cursorrules) are user-authored, and a tool that rewrites them is the one thing
|
|
2495
|
+
`init` has refused to do since day one."""
|
|
2496
|
+
out = []
|
|
2497
|
+
for p in sorted(root.rglob("*")):
|
|
2498
|
+
if not p.is_file() or p.suffix.lower() not in (".md", ".txt", ".json", ".yml", ".yaml"):
|
|
2499
|
+
continue
|
|
2500
|
+
rel = p.relative_to(root)
|
|
2501
|
+
if rel.parts and rel.parts[0] in (src_name, ".git"):
|
|
2502
|
+
continue
|
|
2503
|
+
if any(part in SKIP_DIRS for part in rel.parts):
|
|
2504
|
+
continue
|
|
2505
|
+
try:
|
|
2506
|
+
if f"{src_name}/" in p.read_text(encoding="utf-8", errors="replace"):
|
|
2507
|
+
out.append(rel.as_posix())
|
|
2508
|
+
except OSError:
|
|
2509
|
+
pass
|
|
2510
|
+
return out
|
|
2511
|
+
|
|
2512
|
+
|
|
2513
|
+
def cmd_migrate(root, args):
|
|
2514
|
+
"""Relocate the documentation root. Dry-run by default; never deletes.
|
|
2515
|
+
|
|
2516
|
+
Reversibility is structural, not promised: the old root is COPIED, never moved,
|
|
2517
|
+
so undoing the migration is deleting the new directory. Both roots stay
|
|
2518
|
+
readable throughout, which is what lets a team switch over one session at a
|
|
2519
|
+
time instead of in one jump.
|
|
2520
|
+
"""
|
|
2521
|
+
src_name = args.from_dir
|
|
2522
|
+
dst_name = args.to_dir
|
|
2523
|
+
if src_name == dst_name:
|
|
2524
|
+
print(f"[ERROR] --from and --to are both '{src_name}': nothing to do.")
|
|
2525
|
+
return 1
|
|
2526
|
+
src = root / src_name
|
|
2527
|
+
if not src.is_dir():
|
|
2528
|
+
print(f"[ERROR] {src} not found: nothing to migrate.")
|
|
2529
|
+
return 1
|
|
2530
|
+
|
|
2531
|
+
files, refs, conflicts = _migration_plan(root, src_name, dst_name)
|
|
2532
|
+
external = _external_references(root, src_name)
|
|
2533
|
+
|
|
2534
|
+
print(f"=== migrate {src_name}/ -> {dst_name}/ ===")
|
|
2535
|
+
print(f" {len(files)} file(s) to copy, {len(refs)} carrying '{src_name}/' references")
|
|
2536
|
+
for rel in files[:20]:
|
|
2537
|
+
print(f" {src_name}/{rel} -> {dst_name}/{rel}")
|
|
2538
|
+
if len(files) > 20:
|
|
2539
|
+
print(f" ... and {len(files) - 20} more")
|
|
2540
|
+
if conflicts:
|
|
2541
|
+
print(f"\n[ERROR] {len(conflicts)} file(s) already exist under {dst_name}/:")
|
|
2542
|
+
for rel in conflicts[:10]:
|
|
2543
|
+
print(f" {dst_name}/{rel}")
|
|
2544
|
+
print(" Refusing to overwrite. Move them aside, or migrate into an empty root.")
|
|
2545
|
+
return 1
|
|
2546
|
+
if external:
|
|
2547
|
+
print(f"\n[note] {len(external)} file(s) OUTSIDE the docs roots mention "
|
|
2548
|
+
f"'{src_name}/'. They are NOT touched -- protocol pointers and READMEs are "
|
|
2549
|
+
"yours to edit:")
|
|
2550
|
+
for rel in external[:10]:
|
|
2551
|
+
print(f" {rel}")
|
|
2552
|
+
|
|
2553
|
+
if not args.apply:
|
|
2554
|
+
print("\n[dry-run] Nothing was written. Re-run with --apply to perform the copy.")
|
|
2555
|
+
print(" The old root is kept: this migration is undone by deleting "
|
|
2556
|
+
f"{dst_name}/.")
|
|
2557
|
+
return 0
|
|
2558
|
+
|
|
2559
|
+
if git_available(root) and git_has_changes(root, "."):
|
|
2560
|
+
print("\n[ERROR] the working tree has uncommitted changes. Commit or stash first: "
|
|
2561
|
+
"a migration you cannot diff is a migration you cannot review.")
|
|
2562
|
+
return 1
|
|
2563
|
+
|
|
2564
|
+
written = 0
|
|
2565
|
+
for rel in files:
|
|
2566
|
+
source = src / rel
|
|
2567
|
+
target = root / dst_name / rel
|
|
2568
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
2569
|
+
if rel in refs:
|
|
2570
|
+
text = source.read_text(encoding="utf-8", errors="replace")
|
|
2571
|
+
target.write_text(text.replace(f"{src_name}/", f"{dst_name}/"), encoding="utf-8")
|
|
2572
|
+
else:
|
|
2573
|
+
target.write_bytes(source.read_bytes())
|
|
2574
|
+
written += 1
|
|
2575
|
+
print(f"\n[ok] copied {written} file(s) into {dst_name}/. "
|
|
2576
|
+
f"{src_name}/ is untouched -- delete it yourself once you are satisfied.")
|
|
2577
|
+
print(f" Next: run `validate --docs-dir {dst_name}` and compare it with "
|
|
2578
|
+
f"`validate --docs-dir {src_name}`. They should say the same thing.")
|
|
2579
|
+
return 0
|
|
2580
|
+
|
|
2581
|
+
|
|
2582
|
+
|
|
2583
|
+
# --------------------------------------------------------------------- main
|
|
2584
|
+
|
|
2585
|
+
def main(argv=None):
|
|
2586
|
+
# `remind` is intercepted BEFORE argparse (kb's F-041 rule, kept): it is a
|
|
2587
|
+
# hook surface fired on every prompt, so a stray flag must cost a usage
|
|
2588
|
+
# message and a non-zero exit on a turn, not the turn itself.
|
|
2589
|
+
if (argv if argv is not None else sys.argv[1:])[:1] == ["remind"]:
|
|
2590
|
+
return cmd_remind(None)
|
|
2591
|
+
|
|
2592
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
2593
|
+
common.add_argument("--root", help="project root (default: walk up until a docs root is found)")
|
|
2594
|
+
common.add_argument("--docs-dir", dest="docs_dir",
|
|
2595
|
+
help="name of the documentation root (default: ai_docs; "
|
|
2596
|
+
"use it to reach a legacy root such as mkt_docs, "
|
|
2597
|
+
"or to disambiguate a half-migrated tree)")
|
|
2598
|
+
|
|
2599
|
+
strict_opt = argparse.ArgumentParser(add_help=False)
|
|
2600
|
+
strict_opt.add_argument("--strict", action="store_true",
|
|
2601
|
+
help="fail on warnings and on a missing docs root (for CI)")
|
|
2602
|
+
|
|
2603
|
+
hybrid_opt = argparse.ArgumentParser(add_help=False)
|
|
2604
|
+
hybrid_opt.add_argument("--hybrid", action="store_true",
|
|
2605
|
+
help="Hybrid/devPNT mode: audit-plan staleness is delegated to devPNT/KL; "
|
|
2606
|
+
"the gate also unlocks on an E-TDD shadow")
|
|
2607
|
+
|
|
2608
|
+
ap = argparse.ArgumentParser(prog="sdlc_check.py",
|
|
2609
|
+
description="Mechanical validator for Agentic SDLC")
|
|
2610
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
2611
|
+
sub.add_parser("check", parents=[common, strict_opt, hybrid_opt],
|
|
2612
|
+
help="closure gate: validate + stale in one command")
|
|
2613
|
+
sub.add_parser("validate", parents=[common, strict_opt, hybrid_opt],
|
|
2614
|
+
help="verify docs-root coherence")
|
|
2615
|
+
sub.add_parser("index", parents=[common], help="regenerate features_history.md + ai_docs/INDEX.md")
|
|
2616
|
+
sub.add_parser("stale", parents=[common, hybrid_opt], help="areas modified after the last analysis")
|
|
2617
|
+
gp_mig = sub.add_parser("migrate", parents=[common],
|
|
2618
|
+
help="relocate the documentation root (dry-run by default)")
|
|
2619
|
+
gp_mig.add_argument("--from", dest="from_dir", required=True,
|
|
2620
|
+
help="current docs root name, e.g. mkt_docs")
|
|
2621
|
+
gp_mig.add_argument("--to", dest="to_dir", default=DEFAULT_DOCS_DIR,
|
|
2622
|
+
help=f"target docs root name (default: {DEFAULT_DOCS_DIR})")
|
|
2623
|
+
gp_mig.add_argument("--apply", action="store_true",
|
|
2624
|
+
help="actually copy (default is a dry run; never deletes)")
|
|
2625
|
+
|
|
2626
|
+
mp = sub.add_parser("mark", parents=[common], help="record paths as ANALYZED")
|
|
2627
|
+
mp.add_argument("paths", nargs="+", help="paths relative to the project root")
|
|
2628
|
+
gp = sub.add_parser("gate", parents=[common, hybrid_opt], help="PreToolUse hook (exit 2 = block)")
|
|
2629
|
+
gp.add_argument("--hook", action="store_true", help="read the hook JSON payload from stdin")
|
|
2630
|
+
gp.add_argument("--file", help="file path to evaluate (alternative to --hook)")
|
|
2631
|
+
gp.add_argument("--protected", default="", help="protected prefixes separated by ';' (e.g. \"src/auth;src/crypto\")")
|
|
2632
|
+
|
|
2633
|
+
sub.add_parser("benefit", parents=[common],
|
|
2634
|
+
help="report what the review gates caught (and what the "
|
|
2635
|
+
"doctrine costs to read) -- a report, never a gate: "
|
|
2636
|
+
"always exits 0")
|
|
2637
|
+
|
|
2638
|
+
sub.add_parser("orient", parents=[common, hybrid_opt],
|
|
2639
|
+
help="SessionStart hook: emit docs-root orientation to stdout (fail-open, zero-execution)")
|
|
2640
|
+
|
|
2641
|
+
sub.add_parser("remind", parents=[common],
|
|
2642
|
+
help="UserPromptSubmit hook: print the constant per-turn line "
|
|
2643
|
+
"(fail-open, zero-read, zero-execution)")
|
|
2644
|
+
|
|
2645
|
+
pp = sub.add_parser("plan", parents=[common],
|
|
2646
|
+
help="Subagent Execution: validate/brief a PLAN_[feature].md (zero-execution)")
|
|
2647
|
+
pp_sub = pp.add_subparsers(dest="plan_cmd", required=True)
|
|
2648
|
+
pv = pp_sub.add_parser("validate", help="schema + confinement + ledger cross-check (exit 2 on error)")
|
|
2649
|
+
pv.add_argument("file", help="path to the PLAN_[feature].md file")
|
|
2650
|
+
pb = pp_sub.add_parser("brief", help="print a task's brief to stdout (verify text is NOT executed)")
|
|
2651
|
+
pb.add_argument("file", help="path to the PLAN_[feature].md file")
|
|
2652
|
+
pb.add_argument("--task", required=True, help="task id to brief")
|
|
2653
|
+
|
|
2654
|
+
args = ap.parse_args(argv)
|
|
2655
|
+
|
|
2656
|
+
# Resolve the documentation root ONCE, before any command runs, so every
|
|
2657
|
+
# surface -- paths, messages, generated headers, the gate's exempt prefix --
|
|
2658
|
+
# names the same thing.
|
|
2659
|
+
try:
|
|
2660
|
+
discovered, name = resolve_docs_dir(args, getattr(args, "root", None))
|
|
2661
|
+
except AmbiguousDocsRoot as exc:
|
|
2662
|
+
if args.cmd == "migrate":
|
|
2663
|
+
# A tree with two roots is not an obstacle to `migrate`: it is the state
|
|
2664
|
+
# `migrate` exists to resolve, and both names come from --from/--to, so
|
|
2665
|
+
# nothing is being guessed. Refusing here would make the guard block the
|
|
2666
|
+
# one command that ends the ambiguity.
|
|
2667
|
+
discovered, name = None, args.from_dir
|
|
2668
|
+
elif args.cmd in ("orient", "remind"):
|
|
2669
|
+
# The SessionStart hook is fail-open by contract: it must never block a
|
|
2670
|
+
# session, not even on a half-migrated tree. It orients on the default
|
|
2671
|
+
# and says so rather than exiting non-zero.
|
|
2672
|
+
print(f"[note] {exc}")
|
|
2673
|
+
discovered, name = None, DEFAULT_DOCS_DIR
|
|
2674
|
+
else:
|
|
2675
|
+
print(f"[ERROR] {exc}")
|
|
2676
|
+
return 1
|
|
2677
|
+
set_docs_dir(name)
|
|
2678
|
+
|
|
2679
|
+
if args.cmd == "gate":
|
|
2680
|
+
return cmd_gate(args)
|
|
2681
|
+
if args.cmd == "orient":
|
|
2682
|
+
return cmd_orient(args)
|
|
2683
|
+
if args.cmd == "remind":
|
|
2684
|
+
return cmd_remind(args)
|
|
2685
|
+
|
|
2686
|
+
root = Path(args.root).resolve() if args.root else (discovered or find_project_root())
|
|
2687
|
+
if args.cmd == "check":
|
|
2688
|
+
return cmd_check(root, strict=args.strict, hybrid=args.hybrid)
|
|
2689
|
+
if args.cmd == "validate":
|
|
2690
|
+
return cmd_validate(root, strict=args.strict, hybrid=args.hybrid)
|
|
2691
|
+
if args.cmd == "index":
|
|
2692
|
+
return cmd_index(root)
|
|
2693
|
+
if args.cmd == "stale":
|
|
2694
|
+
return cmd_stale(root, hybrid=args.hybrid)
|
|
2695
|
+
if args.cmd == "mark":
|
|
2696
|
+
return cmd_mark(root, args.paths)
|
|
2697
|
+
if args.cmd == "benefit":
|
|
2698
|
+
return cmd_benefit(root)
|
|
2699
|
+
if args.cmd == "migrate":
|
|
2700
|
+
return cmd_migrate(root, args)
|
|
2701
|
+
if args.cmd == "plan":
|
|
2702
|
+
return cmd_plan(root, args)
|
|
2703
|
+
return 0
|
|
2704
|
+
|
|
2705
|
+
|
|
2706
|
+
if __name__ == "__main__":
|
|
2707
|
+
sys.exit(main())
|