@friedbotstudio/create-baseline 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +222 -0
- package/bin/cli.js +247 -0
- package/obj/template/.claude/agents/swarm-worker.md +52 -0
- package/obj/template/.claude/bin/LICENSE +201 -0
- package/obj/template/.claude/bin/NOTICE +48 -0
- package/obj/template/.claude/commands/approve-spec.md +29 -0
- package/obj/template/.claude/commands/approve-swarm.md +27 -0
- package/obj/template/.claude/commands/grant-commit.md +19 -0
- package/obj/template/.claude/commands/init-project.md +191 -0
- package/obj/template/.claude/hooks/artifact_template_guard.sh +141 -0
- package/obj/template/.claude/hooks/consent_gate_grant.sh +89 -0
- package/obj/template/.claude/hooks/destructive_cmd_guard.sh +42 -0
- package/obj/template/.claude/hooks/env_guard.sh +36 -0
- package/obj/template/.claude/hooks/git_commit_guard.sh +93 -0
- package/obj/template/.claude/hooks/harness_continuation.sh +121 -0
- package/obj/template/.claude/hooks/lib/__pycache__/resume_writer.cpython-314.pyc +0 -0
- package/obj/template/.claude/hooks/lib/common.sh +328 -0
- package/obj/template/.claude/hooks/lib/resume_writer.py +341 -0
- package/obj/template/.claude/hooks/lint_runner.sh +55 -0
- package/obj/template/.claude/hooks/memory_pre_compact.sh +36 -0
- package/obj/template/.claude/hooks/memory_session_start.sh +244 -0
- package/obj/template/.claude/hooks/memory_stop.sh +173 -0
- package/obj/template/.claude/hooks/plantuml_syntax_guard.sh +161 -0
- package/obj/template/.claude/hooks/process_lifecycle_guard.sh +89 -0
- package/obj/template/.claude/hooks/setup_guard.sh +50 -0
- package/obj/template/.claude/hooks/spec_approval_guard.sh +81 -0
- package/obj/template/.claude/hooks/spec_design_calls_guard.sh +183 -0
- package/obj/template/.claude/hooks/spec_diagram_presence_guard.sh +141 -0
- package/obj/template/.claude/hooks/swarm_approval_guard.sh +39 -0
- package/obj/template/.claude/hooks/swarm_boundary_guard.sh +136 -0
- package/obj/template/.claude/hooks/tdd_order_guard.sh +176 -0
- package/obj/template/.claude/hooks/test_runner.sh +75 -0
- package/obj/template/.claude/hooks/tests/fixtures/ac008_byte_equal_reference.txt +12 -0
- package/obj/template/.claude/hooks/tests/memory_session_start_test.sh +285 -0
- package/obj/template/.claude/hooks/track_guard.sh +127 -0
- package/obj/template/.claude/hooks/verify_pass_guard.sh +88 -0
- package/obj/template/.claude/memory/README.md +108 -0
- package/obj/template/.claude/memory/_pending.md +15 -0
- package/obj/template/.claude/memory/_resume.md +12 -0
- package/obj/template/.claude/memory/conventions.md +26 -0
- package/obj/template/.claude/memory/decisions.md +29 -0
- package/obj/template/.claude/memory/landmarks.md +26 -0
- package/obj/template/.claude/memory/landmines.md +27 -0
- package/obj/template/.claude/memory/libraries.md +27 -0
- package/obj/template/.claude/memory/pending-questions.md +28 -0
- package/obj/template/.claude/project.json +221 -0
- package/obj/template/.claude/settings.json +110 -0
- package/obj/template/.claude/skills/archive/SKILL.md +48 -0
- package/obj/template/.claude/skills/archive/archive.sh +145 -0
- package/obj/template/.claude/skills/audit-baseline/SKILL.md +80 -0
- package/obj/template/.claude/skills/audit-baseline/audit.sh +919 -0
- package/obj/template/.claude/skills/brd/SKILL.md +44 -0
- package/obj/template/.claude/skills/brd/template.md +83 -0
- package/obj/template/.claude/skills/chore/SKILL.md +99 -0
- package/obj/template/.claude/skills/claude-automation-recommender/LICENSE +202 -0
- package/obj/template/.claude/skills/claude-automation-recommender/NOTICE +69 -0
- package/obj/template/.claude/skills/claude-automation-recommender/SKILL.md +358 -0
- package/obj/template/.claude/skills/claude-automation-recommender/references/hooks-patterns.md +226 -0
- package/obj/template/.claude/skills/claude-automation-recommender/references/mcp-servers.md +263 -0
- package/obj/template/.claude/skills/claude-automation-recommender/references/plugins-reference.md +98 -0
- package/obj/template/.claude/skills/claude-automation-recommender/references/skills-reference.md +408 -0
- package/obj/template/.claude/skills/claude-automation-recommender/references/subagent-templates.md +181 -0
- package/obj/template/.claude/skills/code-structure/SKILL.md +204 -0
- package/obj/template/.claude/skills/commit/SKILL.md +21 -0
- package/obj/template/.claude/skills/copywriting/SKILL.md +252 -0
- package/obj/template/.claude/skills/copywriting/evals/evals.json +111 -0
- package/obj/template/.claude/skills/copywriting/references/ai-writing-detection.md +200 -0
- package/obj/template/.claude/skills/copywriting/references/copy-frameworks.md +344 -0
- package/obj/template/.claude/skills/copywriting/references/natural-transitions.md +272 -0
- package/obj/template/.claude/skills/design-ui/SKILL.md +175 -0
- package/obj/template/.claude/skills/design-ui/references/design-vs-development.md +89 -0
- package/obj/template/.claude/skills/design-ui/references/intent-table.md +64 -0
- package/obj/template/.claude/skills/design-ui/references/orchestration.md +121 -0
- package/obj/template/.claude/skills/design-ui/references/state-machine.md +125 -0
- package/obj/template/.claude/skills/document/SKILL.md +66 -0
- package/obj/template/.claude/skills/documentation/SKILL.md +50 -0
- package/obj/template/.claude/skills/harness/SKILL.md +169 -0
- package/obj/template/.claude/skills/humanizer/SKILL.md +489 -0
- package/obj/template/.claude/skills/humanizer/references/ai-writing-detection.md +208 -0
- package/obj/template/.claude/skills/impeccable/PROJECT_NOTES.md +22 -0
- package/obj/template/.claude/skills/impeccable/SKILL.md +153 -0
- package/obj/template/.claude/skills/impeccable/agents/openai.yaml +4 -0
- package/obj/template/.claude/skills/impeccable/reference/adapt.md +190 -0
- package/obj/template/.claude/skills/impeccable/reference/animate.md +173 -0
- package/obj/template/.claude/skills/impeccable/reference/audit.md +134 -0
- package/obj/template/.claude/skills/impeccable/reference/bolder.md +113 -0
- package/obj/template/.claude/skills/impeccable/reference/brand.md +104 -0
- package/obj/template/.claude/skills/impeccable/reference/clarify.md +174 -0
- package/obj/template/.claude/skills/impeccable/reference/cognitive-load.md +106 -0
- package/obj/template/.claude/skills/impeccable/reference/color-and-contrast.md +105 -0
- package/obj/template/.claude/skills/impeccable/reference/colorize.md +154 -0
- package/obj/template/.claude/skills/impeccable/reference/craft.md +138 -0
- package/obj/template/.claude/skills/impeccable/reference/critique.md +213 -0
- package/obj/template/.claude/skills/impeccable/reference/delight.md +302 -0
- package/obj/template/.claude/skills/impeccable/reference/distill.md +111 -0
- package/obj/template/.claude/skills/impeccable/reference/document.md +427 -0
- package/obj/template/.claude/skills/impeccable/reference/extract.md +70 -0
- package/obj/template/.claude/skills/impeccable/reference/harden.md +347 -0
- package/obj/template/.claude/skills/impeccable/reference/heuristics-scoring.md +234 -0
- package/obj/template/.claude/skills/impeccable/reference/interaction-design.md +195 -0
- package/obj/template/.claude/skills/impeccable/reference/layout.md +141 -0
- package/obj/template/.claude/skills/impeccable/reference/live.md +513 -0
- package/obj/template/.claude/skills/impeccable/reference/motion-design.md +99 -0
- package/obj/template/.claude/skills/impeccable/reference/onboard.md +234 -0
- package/obj/template/.claude/skills/impeccable/reference/optimize.md +258 -0
- package/obj/template/.claude/skills/impeccable/reference/overdrive.md +130 -0
- package/obj/template/.claude/skills/impeccable/reference/personas.md +178 -0
- package/obj/template/.claude/skills/impeccable/reference/polish.md +232 -0
- package/obj/template/.claude/skills/impeccable/reference/product.md +62 -0
- package/obj/template/.claude/skills/impeccable/reference/quieter.md +99 -0
- package/obj/template/.claude/skills/impeccable/reference/responsive-design.md +114 -0
- package/obj/template/.claude/skills/impeccable/reference/shape.md +136 -0
- package/obj/template/.claude/skills/impeccable/reference/spatial-design.md +100 -0
- package/obj/template/.claude/skills/impeccable/reference/teach.md +137 -0
- package/obj/template/.claude/skills/impeccable/reference/typeset.md +124 -0
- package/obj/template/.claude/skills/impeccable/reference/typography.md +159 -0
- package/obj/template/.claude/skills/impeccable/reference/ux-writing.md +107 -0
- package/obj/template/.claude/skills/impeccable/scripts/cleanup-deprecated.mjs +284 -0
- package/obj/template/.claude/skills/impeccable/scripts/command-metadata.json +94 -0
- package/obj/template/.claude/skills/impeccable/scripts/design-parser.mjs +820 -0
- package/obj/template/.claude/skills/impeccable/scripts/detect-csp.mjs +198 -0
- package/obj/template/.claude/skills/impeccable/scripts/is-generated.mjs +69 -0
- package/obj/template/.claude/skills/impeccable/scripts/live-accept.mjs +465 -0
- package/obj/template/.claude/skills/impeccable/scripts/live-browser.js +4684 -0
- package/obj/template/.claude/skills/impeccable/scripts/live-inject.mjs +436 -0
- package/obj/template/.claude/skills/impeccable/scripts/live-poll.mjs +187 -0
- package/obj/template/.claude/skills/impeccable/scripts/live-server.mjs +679 -0
- package/obj/template/.claude/skills/impeccable/scripts/live-wrap.mjs +395 -0
- package/obj/template/.claude/skills/impeccable/scripts/live.mjs +247 -0
- package/obj/template/.claude/skills/impeccable/scripts/load-context.mjs +93 -0
- package/obj/template/.claude/skills/impeccable/scripts/modern-screenshot.umd.js +14 -0
- package/obj/template/.claude/skills/impeccable/scripts/pin.mjs +214 -0
- package/obj/template/.claude/skills/implement/SKILL.md +83 -0
- package/obj/template/.claude/skills/intake/SKILL.md +46 -0
- package/obj/template/.claude/skills/intake/template.md +61 -0
- package/obj/template/.claude/skills/integrate/SKILL.md +62 -0
- package/obj/template/.claude/skills/memory-flush/SKILL.md +172 -0
- package/obj/template/.claude/skills/memory-flush/sweep.py +286 -0
- package/obj/template/.claude/skills/memory-flush/tests/run.sh +327 -0
- package/obj/template/.claude/skills/prose/SKILL.md +119 -0
- package/obj/template/.claude/skills/rca/SKILL.md +42 -0
- package/obj/template/.claude/skills/rca/template.md +83 -0
- package/obj/template/.claude/skills/research/SKILL.md +75 -0
- package/obj/template/.claude/skills/scenario/SKILL.md +64 -0
- package/obj/template/.claude/skills/scout/SKILL.md +72 -0
- package/obj/template/.claude/skills/security/SKILL.md +75 -0
- package/obj/template/.claude/skills/simplify/SKILL.md +67 -0
- package/obj/template/.claude/skills/spec/SKILL.md +69 -0
- package/obj/template/.claude/skills/spec/template.md +274 -0
- package/obj/template/.claude/skills/spec-diagram-review/SKILL.md +81 -0
- package/obj/template/.claude/skills/spec-lint/SKILL.md +55 -0
- package/obj/template/.claude/skills/spec-lint/lint.sh +218 -0
- package/obj/template/.claude/skills/spec-render/SKILL.md +45 -0
- package/obj/template/.claude/skills/spec-render/render.sh +109 -0
- package/obj/template/.claude/skills/spec-traceability-review/SKILL.md +72 -0
- package/obj/template/.claude/skills/swarm-dispatch/SKILL.md +212 -0
- package/obj/template/.claude/skills/swarm-dispatch/swarm_merge.sh +154 -0
- package/obj/template/.claude/skills/swarm-plan/SKILL.md +90 -0
- package/obj/template/.claude/skills/swarm-plan/validate.sh +181 -0
- package/obj/template/.claude/skills/tdd/SKILL.md +100 -0
- package/obj/template/.claude/skills/technical-tutorials/SKILL.md +569 -0
- package/obj/template/.claude/skills/technical-tutorials/references/audience-context-README.md +53 -0
- package/obj/template/.claude/skills/technical-tutorials/references/audience-context.md +246 -0
- package/obj/template/.claude/skills/technical-tutorials/references/audience-example.md +175 -0
- package/obj/template/.claude/skills/technical-tutorials/references/audience-template.md +152 -0
- package/obj/template/.claude/skills/triage/SKILL.md +55 -0
- package/obj/template/.claude/skills/verify/SKILL.md +74 -0
- package/obj/template/.mcp.json +24 -0
- package/obj/template/CLAUDE.md +327 -0
- package/obj/template/docs/init/seed.md +585 -0
- package/obj/template/manifest.json +214 -0
- package/package.json +48 -0
- package/src/.mcp.template.json +24 -0
- package/src/.npmrc.template +2 -0
- package/src/CLAUDE.template.md +327 -0
- package/src/agents/swarm-worker.template.md +51 -0
- package/src/cli/conflict.js +31 -0
- package/src/cli/doctor.js +152 -0
- package/src/cli/install.js +93 -0
- package/src/cli/io.js +27 -0
- package/src/cli/manifest.js +38 -0
- package/src/cli/mcp.js +54 -0
- package/src/cli/merge.js +107 -0
- package/src/cli/plantuml.js +121 -0
- package/src/cli/util.js +10 -0
- package/src/memory/_pending.template.md +15 -0
- package/src/memory/_resume.template.md +12 -0
- package/src/memory/conventions.template.md +26 -0
- package/src/memory/decisions.template.md +29 -0
- package/src/memory/landmarks.template.md +26 -0
- package/src/memory/landmines.template.md +27 -0
- package/src/memory/libraries.template.md +27 -0
- package/src/memory/pending-questions.template.md +28 -0
- package/src/project.template.json +221 -0
- package/src/seed.template.md +585 -0
- package/src/settings.template.json +110 -0
|
@@ -0,0 +1,919 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# audit-baseline — drift check between docs/init/seed.md and the implementation.
|
|
3
|
+
#
|
|
4
|
+
# Reports each check as PASS / FAIL / WARN with a short detail. Exits 0 on a
|
|
5
|
+
# clean audit, 1 if any FAIL. Read-only; safe to run any time, in CI, or as
|
|
6
|
+
# the final step of /init-project.
|
|
7
|
+
|
|
8
|
+
set -u
|
|
9
|
+
|
|
10
|
+
ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
|
11
|
+
|
|
12
|
+
ROOT="$ROOT" python3 <<'PY'
|
|
13
|
+
import json, os, re, sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
root = Path(os.environ['ROOT'])
|
|
17
|
+
results = [] # (name, status, detail)
|
|
18
|
+
|
|
19
|
+
def add(name, status, detail=""):
|
|
20
|
+
results.append((name, status, detail))
|
|
21
|
+
|
|
22
|
+
# ---------- expected canonical sets (mirror seed.md §4) ----------
|
|
23
|
+
EXPECTED_HOOKS = {
|
|
24
|
+
# Write/Bash boundary guards (17)
|
|
25
|
+
"setup_guard", "destructive_cmd_guard", "git_commit_guard", "env_guard",
|
|
26
|
+
"spec_approval_guard", "swarm_approval_guard", "verify_pass_guard",
|
|
27
|
+
"track_guard", "artifact_template_guard", "plantuml_syntax_guard",
|
|
28
|
+
"spec_diagram_presence_guard", "spec_design_calls_guard",
|
|
29
|
+
"swarm_boundary_guard", "tdd_order_guard",
|
|
30
|
+
"process_lifecycle_guard",
|
|
31
|
+
"lint_runner", "test_runner",
|
|
32
|
+
# Lifecycle hooks for project memory, cross-session continuity, and
|
|
33
|
+
# workflow auto-continuation (4)
|
|
34
|
+
"memory_session_start", "memory_stop", "memory_pre_compact",
|
|
35
|
+
"harness_continuation",
|
|
36
|
+
# Input-boundary hook for consent-gate marker writes (1)
|
|
37
|
+
"consent_gate_grant",
|
|
38
|
+
}
|
|
39
|
+
EXPECTED_AGENTS = {
|
|
40
|
+
# The only subagent in the baseline. Workers execute pre-decided recipes
|
|
41
|
+
# from main context; they never make decisions.
|
|
42
|
+
"swarm-worker",
|
|
43
|
+
}
|
|
44
|
+
# Skill provenance comes from the shipped manifest at obj/template/manifest.json.
|
|
45
|
+
# The build (scripts/build-manifest.mjs) reads owner: frontmatter from every
|
|
46
|
+
# .claude/skills/<slug>/SKILL.md and emits the canonical baseline-skill set as
|
|
47
|
+
# manifest.owners.skills. See CLAUDE.md Article XI and seed.md §17.
|
|
48
|
+
def load_manifest():
|
|
49
|
+
path = root / "obj/template/manifest.json"
|
|
50
|
+
if not path.exists():
|
|
51
|
+
return None
|
|
52
|
+
try:
|
|
53
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
54
|
+
except Exception:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
def read_skill_owner(slug):
|
|
58
|
+
p = root / f".claude/skills/{slug}/SKILL.md"
|
|
59
|
+
if not p.exists():
|
|
60
|
+
return None
|
|
61
|
+
text = p.read_text(encoding="utf-8", errors="replace")
|
|
62
|
+
fm = re.match(r'^---\n([\s\S]*?)\n---\n', text)
|
|
63
|
+
if not fm:
|
|
64
|
+
return None
|
|
65
|
+
m = re.search(r'^owner:\s*(\S+)\s*$', fm.group(1), re.MULTILINE)
|
|
66
|
+
return m.group(1) if m else None
|
|
67
|
+
|
|
68
|
+
EXPECTED_COMMANDS = {"approve-spec", "approve-swarm", "grant-commit", "init-project"}
|
|
69
|
+
|
|
70
|
+
EXPECTED_MEMORY_FILES = {
|
|
71
|
+
# Canonical files (six)
|
|
72
|
+
"landmarks", "libraries", "decisions", "landmines", "conventions",
|
|
73
|
+
"pending-questions",
|
|
74
|
+
# Auto-extraction inbox (one); body gitignored, file committed
|
|
75
|
+
"_pending",
|
|
76
|
+
# Cross-session continuity snapshot (one); written by memory_stop &
|
|
77
|
+
# memory_pre_compact, read by memory_session_start. Body gitignored.
|
|
78
|
+
"_resume",
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# ---------- helpers ----------
|
|
82
|
+
WORDS = {
|
|
83
|
+
"one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, "seven": 7,
|
|
84
|
+
"eight": 8, "nine": 9, "ten": 10, "eleven": 11, "twelve": 12, "thirteen": 13,
|
|
85
|
+
"fourteen": 14, "fifteen": 15, "sixteen": 16, "seventeen": 17,
|
|
86
|
+
"eighteen": 18, "nineteen": 19, "twenty": 20, "twenty-one": 21,
|
|
87
|
+
"twenty-two": 22, "twenty-three": 23, "twenty-four": 24, "twenty-five": 25,
|
|
88
|
+
"twenty-six": 26, "twenty-seven": 27, "twenty-eight": 28, "twenty-nine": 29,
|
|
89
|
+
"thirty": 30, "thirty-one": 31, "thirty-two": 32, "thirty-three": 33,
|
|
90
|
+
"thirty-four": 34, "thirty-five": 35, "thirty-six": 36, "thirty-seven": 37,
|
|
91
|
+
"thirty-eight": 38, "thirty-nine": 39, "forty": 40,
|
|
92
|
+
}
|
|
93
|
+
def to_int(s):
|
|
94
|
+
s = (s or "").strip().lower()
|
|
95
|
+
if s.isdigit():
|
|
96
|
+
return int(s)
|
|
97
|
+
return WORDS.get(s)
|
|
98
|
+
|
|
99
|
+
def read_text(rel):
|
|
100
|
+
p = root / rel
|
|
101
|
+
return p.read_text(encoding="utf-8") if p.exists() else ""
|
|
102
|
+
|
|
103
|
+
def read_json(rel):
|
|
104
|
+
txt = read_text(rel)
|
|
105
|
+
if not txt: return None
|
|
106
|
+
try:
|
|
107
|
+
return json.loads(txt)
|
|
108
|
+
except Exception:
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
# ---------- project.json additions (load early so headline counts can offset) ----------
|
|
112
|
+
# Headline claims in seed.md / CLAUDE.md / docs.jsx describe the *baseline* shape
|
|
113
|
+
# ("10 subagents", "27 skills"). After /init-project adds variants and stack
|
|
114
|
+
# skills, the disk has more files than baseline; those additions are recorded
|
|
115
|
+
# under project.json → additions and accounted for separately. Without this,
|
|
116
|
+
# every /init-project run would leave the audit FAILing on legitimate adds.
|
|
117
|
+
pj = read_json(".claude/project.json")
|
|
118
|
+
additions = (pj or {}).get("additions", {}) or {}
|
|
119
|
+
add_agents = set(additions.get("agents", []))
|
|
120
|
+
add_skills = set(additions.get("skills", []))
|
|
121
|
+
add_hooks = set(additions.get("hooks", []))
|
|
122
|
+
add_mcp_servers = set(additions.get("mcp_servers", []))
|
|
123
|
+
add_swarm_worker_skills = set(additions.get("swarm_worker_skills", []))
|
|
124
|
+
|
|
125
|
+
# ---------- on-disk inventory ----------
|
|
126
|
+
hooks_dir = root / ".claude/hooks"
|
|
127
|
+
agents_dir = root / ".claude/agents"
|
|
128
|
+
skills_dir = root / ".claude/skills"
|
|
129
|
+
cmds_dir = root / ".claude/commands"
|
|
130
|
+
|
|
131
|
+
disk_hooks = {p.stem for p in hooks_dir.glob("*.sh")} if hooks_dir.exists() else set()
|
|
132
|
+
disk_agents = {p.stem for p in agents_dir.glob("*.md")} if agents_dir.exists() else set()
|
|
133
|
+
disk_skills = {p.name for p in skills_dir.iterdir() if p.is_dir()} if skills_dir.exists() else set()
|
|
134
|
+
disk_commands = {p.stem for p in cmds_dir.glob("*.md")} if cmds_dir.exists() else set()
|
|
135
|
+
|
|
136
|
+
# Baseline subset of disk = total - project additions. Used by every count check
|
|
137
|
+
# below so headline claims still compare cleanly after /init-project runs.
|
|
138
|
+
disk_baseline_hooks = disk_hooks - add_hooks
|
|
139
|
+
disk_baseline_agents = disk_agents - add_agents
|
|
140
|
+
|
|
141
|
+
# Skill provenance: a skill is baseline iff its SKILL.md frontmatter declares
|
|
142
|
+
# owner: baseline. User-added skills (owner: user) are excluded from baseline
|
|
143
|
+
# counts so headline claims and check_names match even after a user adds skills.
|
|
144
|
+
disk_baseline_skills = {s for s in disk_skills if read_skill_owner(s) == "baseline"}
|
|
145
|
+
disk_user_skills = {s for s in disk_skills if read_skill_owner(s) == "user"}
|
|
146
|
+
|
|
147
|
+
# ---------- counts vs seed.md ----------
|
|
148
|
+
seed = read_text("docs/init/seed.md")
|
|
149
|
+
|
|
150
|
+
def find_count(*patterns):
|
|
151
|
+
for pat in patterns:
|
|
152
|
+
m = re.search(pat, seed, re.IGNORECASE)
|
|
153
|
+
if m:
|
|
154
|
+
v = to_int(m.group(1))
|
|
155
|
+
if v is not None: return v
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
# Pull headline counts from seed.md preamble. Hooks now come in two flavours
|
|
159
|
+
# (write/run-boundary guards + lifecycle hooks) so the headline gives the
|
|
160
|
+
# total via "(seventeen .sh scripts total)" or the §4.1 heading "Hooks (17
|
|
161
|
+
# total — …)". Prefer those; fall back to the legacy "<N> guards" form.
|
|
162
|
+
NUM_WORD = r"\d+|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty"
|
|
163
|
+
hooks_claimed = find_count(
|
|
164
|
+
rf"\((\d+|{NUM_WORD})\s+\.sh\s+scripts?\s+total\)", # "(seventeen .sh scripts total)"
|
|
165
|
+
rf"§4\.1\s+Hooks\s+\((\d+)\s+total\b", # "§4.1 Hooks (17 total"
|
|
166
|
+
rf"\b({NUM_WORD})\s+guards?\b", # legacy "fourteen guards"
|
|
167
|
+
)
|
|
168
|
+
agents_claimed = find_count(r"\b(\d+|one|two|three|eight|nine|ten|eleven|twelve)\s+subagents?\b")
|
|
169
|
+
skills_claimed = find_count(
|
|
170
|
+
r"\b(\d+|twenty-(?:four|five|six|seven|eight|nine)|"
|
|
171
|
+
r"thirty|thirty-(?:one|two|three|four|five|six|seven|eight|nine)|forty)\s+skills?\b")
|
|
172
|
+
gates_claimed = find_count(r"\b(\d+|three)\s+consent\s+gates?\b")
|
|
173
|
+
cmds_claimed = 4 if re.search(r"three\s+consent\s+gates?\s*\+\s*one\s+bootstrap", seed, re.IGNORECASE) else None
|
|
174
|
+
|
|
175
|
+
def check_count(label, claimed, actual):
|
|
176
|
+
if claimed is None:
|
|
177
|
+
add(label, "WARN", f"could not extract claimed count; disk has {actual}")
|
|
178
|
+
elif claimed == actual:
|
|
179
|
+
add(label, "PASS", f"{actual}")
|
|
180
|
+
else:
|
|
181
|
+
add(label, "FAIL", f"seed claims {claimed}, disk has {actual}")
|
|
182
|
+
|
|
183
|
+
check_count("hooks count (seed vs baseline)", hooks_claimed, len(disk_baseline_hooks))
|
|
184
|
+
check_count("agents count (seed vs baseline)", agents_claimed, len(disk_baseline_agents))
|
|
185
|
+
check_count("skills count (seed vs baseline)", skills_claimed, len(disk_baseline_skills))
|
|
186
|
+
check_count("commands count (seed vs disk)", cmds_claimed, len(disk_commands))
|
|
187
|
+
|
|
188
|
+
# ---------- names ----------
|
|
189
|
+
def check_names(label, baseline, additions, disk):
|
|
190
|
+
expected = baseline | additions
|
|
191
|
+
missing = sorted(expected - disk)
|
|
192
|
+
unexpected = sorted(disk - expected)
|
|
193
|
+
if not missing and not unexpected:
|
|
194
|
+
if additions:
|
|
195
|
+
detail = f"{len(baseline)} baseline + {len(additions)} project = {len(disk)}"
|
|
196
|
+
else:
|
|
197
|
+
detail = ""
|
|
198
|
+
add(label, "PASS", detail)
|
|
199
|
+
else:
|
|
200
|
+
bits = []
|
|
201
|
+
if missing: bits.append(f"missing: {missing}")
|
|
202
|
+
if unexpected: bits.append(f"unexpected: {unexpected}")
|
|
203
|
+
add(label, "FAIL", "; ".join(bits))
|
|
204
|
+
|
|
205
|
+
check_names("hooks names match seed §4.1", EXPECTED_HOOKS, add_hooks, disk_hooks)
|
|
206
|
+
check_names("agents names match seed §4.2", EXPECTED_AGENTS, add_agents, disk_agents)
|
|
207
|
+
|
|
208
|
+
# Skills canonical set comes from manifest.owners.skills (built by
|
|
209
|
+
# scripts/build-manifest.mjs at release time). Falls back to disk_baseline_skills
|
|
210
|
+
# when the manifest is missing (e.g., first audit before initial build).
|
|
211
|
+
_manifest_for_skills = load_manifest()
|
|
212
|
+
if _manifest_for_skills is None:
|
|
213
|
+
_canonical_skills = disk_baseline_skills
|
|
214
|
+
else:
|
|
215
|
+
_canonical_skills = set((_manifest_for_skills.get("owners") or {}).get("skills", {}).keys()) \
|
|
216
|
+
or disk_baseline_skills
|
|
217
|
+
check_names("skills names match seed §4.3", _canonical_skills, add_skills, disk_baseline_skills)
|
|
218
|
+
check_names("commands names match seed §4.4", EXPECTED_COMMANDS, set(), disk_commands)
|
|
219
|
+
|
|
220
|
+
# ---------- skill ownership (per-file hash drift + frontmatter validation) ----------
|
|
221
|
+
def check_skill_ownership():
|
|
222
|
+
# Frontmatter validation: every on-disk SKILL.md must declare owner: baseline|user.
|
|
223
|
+
for slug in sorted(disk_skills):
|
|
224
|
+
owner = read_skill_owner(slug)
|
|
225
|
+
if owner is None:
|
|
226
|
+
add(f"skill ownership: {slug}", "FAIL", "missing owner frontmatter")
|
|
227
|
+
continue
|
|
228
|
+
if owner not in ("baseline", "user"):
|
|
229
|
+
add(f"skill ownership: {slug}", "FAIL", f"invalid owner={owner}")
|
|
230
|
+
continue
|
|
231
|
+
# Manifest-driven baseline-skill presence + per-file hash check.
|
|
232
|
+
manifest = load_manifest()
|
|
233
|
+
if manifest is None:
|
|
234
|
+
add("skill ownership: manifest", "WARN", "obj/template/manifest.json missing — run npm run build")
|
|
235
|
+
return
|
|
236
|
+
owners_skills = (manifest.get("owners") or {}).get("skills", {}) or {}
|
|
237
|
+
files_map = manifest.get("files") or {}
|
|
238
|
+
for slug in sorted(owners_skills.keys()):
|
|
239
|
+
skill_dir = root / f".claude/skills/{slug}"
|
|
240
|
+
if not skill_dir.is_dir():
|
|
241
|
+
add(f"skill ownership: {slug}", "FAIL", "baseline skill missing")
|
|
242
|
+
continue
|
|
243
|
+
for path, expected_hash in files_map.items():
|
|
244
|
+
if not path.startswith(f".claude/skills/{slug}/"):
|
|
245
|
+
continue
|
|
246
|
+
disk_file = root / path
|
|
247
|
+
if not disk_file.exists():
|
|
248
|
+
add(f"skill ownership: {slug}", "FAIL", f"baseline skill missing: {path}")
|
|
249
|
+
continue
|
|
250
|
+
actual = hashlib.sha256(disk_file.read_bytes()).hexdigest()
|
|
251
|
+
if actual != expected_hash:
|
|
252
|
+
add(f"skill ownership: {slug}", "FAIL", f"hash mismatch at {path}")
|
|
253
|
+
break # one mismatch per slug is enough; surface the first
|
|
254
|
+
|
|
255
|
+
import hashlib # used by check_skill_ownership
|
|
256
|
+
check_skill_ownership()
|
|
257
|
+
|
|
258
|
+
# ---------- constitutional citation (Article XI + §17) ----------
|
|
259
|
+
# The check looks for the section HEADINGS specifically (## Article XI and
|
|
260
|
+
# ## §17), not just the literal strings — body prose can reference the
|
|
261
|
+
# names, but only the actual section heading proves the section exists.
|
|
262
|
+
def check_constitutional_citations():
|
|
263
|
+
claude_text = read_text("CLAUDE.md")
|
|
264
|
+
seed_text = read_text("docs/init/seed.md")
|
|
265
|
+
if "## Article XI" not in claude_text or "manifest" not in claude_text:
|
|
266
|
+
add("CLAUDE.md citation", "FAIL", "CLAUDE.md missing Article XI citation")
|
|
267
|
+
else:
|
|
268
|
+
add("CLAUDE.md citation", "PASS", "Article XI present")
|
|
269
|
+
if "## §17" not in seed_text or "manifest" not in seed_text:
|
|
270
|
+
add("seed.md citation", "FAIL", "seed.md missing §17 citation")
|
|
271
|
+
else:
|
|
272
|
+
add("seed.md citation", "PASS", "§17 present")
|
|
273
|
+
|
|
274
|
+
check_constitutional_citations()
|
|
275
|
+
|
|
276
|
+
# ---------- memory directory ----------
|
|
277
|
+
mem_dir = root / ".claude/memory"
|
|
278
|
+
if not mem_dir.is_dir():
|
|
279
|
+
add("memory directory exists", "FAIL", "missing .claude/memory/")
|
|
280
|
+
else:
|
|
281
|
+
add("memory directory exists", "PASS", "")
|
|
282
|
+
disk_memory = {p.stem for p in mem_dir.glob("*.md") if p.stem != "README"}
|
|
283
|
+
missing = sorted(EXPECTED_MEMORY_FILES - disk_memory)
|
|
284
|
+
unexpected = sorted(disk_memory - EXPECTED_MEMORY_FILES)
|
|
285
|
+
if missing or unexpected:
|
|
286
|
+
bits = []
|
|
287
|
+
if missing: bits.append(f"missing: {missing}")
|
|
288
|
+
if unexpected: bits.append(f"unexpected: {unexpected}")
|
|
289
|
+
add("memory files present", "FAIL", "; ".join(bits))
|
|
290
|
+
else:
|
|
291
|
+
add("memory files present", "PASS", f"{len(disk_memory)} files")
|
|
292
|
+
# Each canonical file should have frontmatter (--- ... ---) and at least one entry.
|
|
293
|
+
for name in sorted(EXPECTED_MEMORY_FILES):
|
|
294
|
+
p = mem_dir / f"{name}.md"
|
|
295
|
+
if not p.is_file():
|
|
296
|
+
continue
|
|
297
|
+
text = p.read_text(encoding="utf-8", errors="replace")
|
|
298
|
+
if not text.startswith("---"):
|
|
299
|
+
add(f"memory shape: {name}.md", "FAIL", "missing frontmatter")
|
|
300
|
+
continue
|
|
301
|
+
# _pending body may be empty; canonical must have at least one entry.
|
|
302
|
+
if name == "_pending":
|
|
303
|
+
add(f"memory shape: {name}.md", "PASS", "")
|
|
304
|
+
continue
|
|
305
|
+
body = text.split("---", 2)[-1] if text.startswith("---") else text
|
|
306
|
+
# Strip fenced code blocks so example "## <stable key>" lines inside
|
|
307
|
+
# ```markdown ... ``` don't count as entries.
|
|
308
|
+
body_no_fence = re.sub(r"(?ms)^```.*?^```\s*$", "", body)
|
|
309
|
+
entry_count = len(re.findall(r'(?m)^##\s+\S', body_no_fence))
|
|
310
|
+
if entry_count == 0:
|
|
311
|
+
add(f"memory shape: {name}.md", "FAIL", "no entries (## headings) in body")
|
|
312
|
+
else:
|
|
313
|
+
add(f"memory shape: {name}.md", "PASS", f"{entry_count} entries")
|
|
314
|
+
# README inside memory/ is a structural expectation
|
|
315
|
+
add("memory README", "PASS" if (mem_dir / "README.md").is_file() else "FAIL",
|
|
316
|
+
"" if (mem_dir / "README.md").is_file() else "missing .claude/memory/README.md")
|
|
317
|
+
|
|
318
|
+
# ---------- src/ templates (pristine pre-init versions) ----------
|
|
319
|
+
# Pristine versions of every file that /init-project modifies. The build
|
|
320
|
+
# script overlays these onto the rsync'd template at pack time so the
|
|
321
|
+
# dogfood project's live state never ships to fresh users. See
|
|
322
|
+
# `docs/create-baseline.md`.
|
|
323
|
+
#
|
|
324
|
+
# The src/ tree mirrors the canonical paths with a `.template` suffix so
|
|
325
|
+
# `npx @friedbotstudio/create-baseline` can discover and overlay deterministically.
|
|
326
|
+
src_dir = root / "src"
|
|
327
|
+
if not src_dir.is_dir():
|
|
328
|
+
add("src templates: directory", "FAIL", "missing src/")
|
|
329
|
+
else:
|
|
330
|
+
add("src templates: directory", "PASS", "")
|
|
331
|
+
|
|
332
|
+
# CLAUDE.template.md — must exist and read as constitution-voice (or, in
|
|
333
|
+
# the pre-Stage-2 transitional shape, at least the user-voice lede). The
|
|
334
|
+
# test below tolerates either: dogfood-leak fails hard; constitutional
|
|
335
|
+
# markers OR the legacy user-voice lede pass.
|
|
336
|
+
src_claude = src_dir / "CLAUDE.template.md"
|
|
337
|
+
if not src_claude.is_file():
|
|
338
|
+
add("src templates: CLAUDE.template.md", "FAIL", "missing")
|
|
339
|
+
else:
|
|
340
|
+
head = src_claude.read_text(encoding="utf-8", errors="replace")[:1200]
|
|
341
|
+
if "is a general-purpose Claude setup" in head:
|
|
342
|
+
add("src templates: CLAUDE.template.md", "FAIL",
|
|
343
|
+
"lede uses dogfood voice ('is a general-purpose Claude setup'); "
|
|
344
|
+
"template must read as ship-to-user constitution")
|
|
345
|
+
elif re.search(r"\bArticle\s+I\b", head) or "in-session constitution" in head.lower():
|
|
346
|
+
add("src templates: CLAUDE.template.md", "PASS", "constitution voice")
|
|
347
|
+
elif "uses the Claude Code baseline" in head:
|
|
348
|
+
add("src templates: CLAUDE.template.md", "PASS", "user-voice lede (pre-constitution)")
|
|
349
|
+
else:
|
|
350
|
+
add("src templates: CLAUDE.template.md", "FAIL",
|
|
351
|
+
"lede missing — expected constitution markers ('Article I', 'in-session constitution') "
|
|
352
|
+
"or transitional user-voice phrase 'uses the Claude Code baseline'")
|
|
353
|
+
|
|
354
|
+
# project.template.json — must parse and be in pristine (unconfigured) state.
|
|
355
|
+
src_pj = src_dir / "project.template.json"
|
|
356
|
+
if not src_pj.is_file():
|
|
357
|
+
add("src templates: project.template.json", "FAIL", "missing")
|
|
358
|
+
else:
|
|
359
|
+
try:
|
|
360
|
+
pj_seed = json.loads(src_pj.read_text(encoding="utf-8"))
|
|
361
|
+
except Exception as e:
|
|
362
|
+
add("src templates: project.template.json", "FAIL", f"invalid JSON: {e}")
|
|
363
|
+
pj_seed = None
|
|
364
|
+
if pj_seed is not None:
|
|
365
|
+
if pj_seed.get("configured") is not False:
|
|
366
|
+
add("src templates: project.template.json", "FAIL",
|
|
367
|
+
"must be pristine — `configured` should be false (got "
|
|
368
|
+
f"{pj_seed.get('configured')!r})")
|
|
369
|
+
else:
|
|
370
|
+
add("src templates: project.template.json", "PASS", "configured=false")
|
|
371
|
+
|
|
372
|
+
# seed.template.md — must exist + carry the §16 reservation (pre-init shape).
|
|
373
|
+
# If `Generated:` appears under §16 the template has been polluted by an
|
|
374
|
+
# /init-project run on the live seed file rather than against the dogfood copy.
|
|
375
|
+
src_seed = src_dir / "seed.template.md"
|
|
376
|
+
if not src_seed.is_file():
|
|
377
|
+
add("src templates: seed.template.md", "FAIL", "missing")
|
|
378
|
+
else:
|
|
379
|
+
seed_text = src_seed.read_text(encoding="utf-8", errors="replace")
|
|
380
|
+
s16 = re.search(r"##\s+§16\s+—\s+Project-specific configuration[\s\S]{0,400}",
|
|
381
|
+
seed_text)
|
|
382
|
+
if not s16:
|
|
383
|
+
add("src templates: seed.template.md", "FAIL", "missing §16 reservation")
|
|
384
|
+
elif "Generated:" in s16.group(0):
|
|
385
|
+
add("src templates: seed.template.md", "FAIL",
|
|
386
|
+
"§16 has been populated (`Generated:` stamp present); template must stay pristine")
|
|
387
|
+
else:
|
|
388
|
+
add("src templates: seed.template.md", "PASS", "§16 reserved (pristine)")
|
|
389
|
+
|
|
390
|
+
# .mcp.template.json — must parse and declare the three baseline servers.
|
|
391
|
+
src_mcp = src_dir / ".mcp.template.json"
|
|
392
|
+
if not src_mcp.is_file():
|
|
393
|
+
add("src templates: .mcp.template.json", "FAIL", "missing")
|
|
394
|
+
else:
|
|
395
|
+
try:
|
|
396
|
+
m = json.loads(src_mcp.read_text(encoding="utf-8"))
|
|
397
|
+
servers = list((m.get("mcpServers") or {}).keys())
|
|
398
|
+
missing = [s for s in ("context7", "plantuml", "playwright") if s not in servers]
|
|
399
|
+
if missing:
|
|
400
|
+
add("src templates: .mcp.template.json", "FAIL",
|
|
401
|
+
f"baseline servers missing: {missing}")
|
|
402
|
+
else:
|
|
403
|
+
add("src templates: .mcp.template.json", "PASS",
|
|
404
|
+
f"baseline servers present ({len(servers)} declared)")
|
|
405
|
+
except Exception as e:
|
|
406
|
+
add("src templates: .mcp.template.json", "FAIL", f"invalid JSON: {e}")
|
|
407
|
+
|
|
408
|
+
# settings.template.json — must parse and wire every baseline hook.
|
|
409
|
+
src_settings = src_dir / "settings.template.json"
|
|
410
|
+
if not src_settings.is_file():
|
|
411
|
+
add("src templates: settings.template.json", "FAIL", "missing")
|
|
412
|
+
else:
|
|
413
|
+
try:
|
|
414
|
+
s_text = src_settings.read_text(encoding="utf-8")
|
|
415
|
+
json.loads(s_text)
|
|
416
|
+
missing_wired = sorted(h for h in EXPECTED_HOOKS if f"{h}.sh" not in s_text)
|
|
417
|
+
if missing_wired:
|
|
418
|
+
head = missing_wired[:3]
|
|
419
|
+
tail = f" + {len(missing_wired) - 3} more" if len(missing_wired) > 3 else ""
|
|
420
|
+
add("src templates: settings.template.json", "FAIL",
|
|
421
|
+
f"baseline hooks not wired: {head}{tail}")
|
|
422
|
+
else:
|
|
423
|
+
add("src templates: settings.template.json", "PASS",
|
|
424
|
+
f"all {len(EXPECTED_HOOKS)} baseline hooks wired")
|
|
425
|
+
except Exception as e:
|
|
426
|
+
add("src templates: settings.template.json", "FAIL", f"invalid JSON: {e}")
|
|
427
|
+
|
|
428
|
+
# agents/swarm-worker.template.md — must carry all four substitution tokens.
|
|
429
|
+
src_worker = src_dir / "agents" / "swarm-worker.template.md"
|
|
430
|
+
if not src_worker.is_file():
|
|
431
|
+
add("src templates: agents/swarm-worker.template.md", "FAIL", "missing")
|
|
432
|
+
else:
|
|
433
|
+
wt = src_worker.read_text(encoding="utf-8", errors="replace")
|
|
434
|
+
tokens = ("{{NAME}}", "{{DESCRIPTION}}", "{{SKILLS}}", "{{ROLE_LINE}}")
|
|
435
|
+
missing_tokens = [t for t in tokens if t not in wt]
|
|
436
|
+
if missing_tokens:
|
|
437
|
+
add("src templates: agents/swarm-worker.template.md", "FAIL",
|
|
438
|
+
f"tokens missing: {missing_tokens}")
|
|
439
|
+
else:
|
|
440
|
+
add("src templates: agents/swarm-worker.template.md", "PASS",
|
|
441
|
+
"all 4 tokens present")
|
|
442
|
+
|
|
443
|
+
# memory/<canonical>.template.md — frontmatter + zero entries (skip
|
|
444
|
+
# _pending / _resume, runtime-only).
|
|
445
|
+
src_mem_dir = src_dir / "memory"
|
|
446
|
+
canonical_memory = EXPECTED_MEMORY_FILES - {"_pending", "_resume"}
|
|
447
|
+
if not src_mem_dir.is_dir():
|
|
448
|
+
add("src templates: memory/", "FAIL", "missing src/memory/")
|
|
449
|
+
else:
|
|
450
|
+
for name in sorted(canonical_memory):
|
|
451
|
+
p = src_mem_dir / f"{name}.template.md"
|
|
452
|
+
if not p.is_file():
|
|
453
|
+
add(f"src templates: memory/{name}.template.md", "FAIL", "missing")
|
|
454
|
+
continue
|
|
455
|
+
text = p.read_text(encoding="utf-8", errors="replace")
|
|
456
|
+
if not text.startswith("---"):
|
|
457
|
+
add(f"src templates: memory/{name}.template.md", "FAIL", "missing frontmatter")
|
|
458
|
+
continue
|
|
459
|
+
body = text.split("---", 2)[-1]
|
|
460
|
+
# Same fenced-block stripping as the live-memory check.
|
|
461
|
+
body_no_fence = re.sub(r"(?ms)^```.*?^```\s*$", "", body)
|
|
462
|
+
entry_count = len(re.findall(r"(?m)^##\s+\S", body_no_fence))
|
|
463
|
+
if entry_count > 0:
|
|
464
|
+
add(f"src templates: memory/{name}.template.md", "FAIL",
|
|
465
|
+
f"template must be pristine; {entry_count} entries found")
|
|
466
|
+
else:
|
|
467
|
+
add(f"src templates: memory/{name}.template.md", "PASS", "pristine")
|
|
468
|
+
|
|
469
|
+
# ---------- helper scripts ----------
|
|
470
|
+
helpers = [
|
|
471
|
+
".claude/skills/swarm-plan/validate.sh",
|
|
472
|
+
".claude/skills/swarm-dispatch/swarm_merge.sh",
|
|
473
|
+
".claude/skills/spec-render/render.sh",
|
|
474
|
+
".claude/skills/spec-lint/lint.sh",
|
|
475
|
+
".claude/skills/archive/archive.sh",
|
|
476
|
+
".claude/skills/audit-baseline/audit.sh",
|
|
477
|
+
]
|
|
478
|
+
for rel in helpers:
|
|
479
|
+
p = root / rel
|
|
480
|
+
label = f"helper {rel.split('/.claude/skills/')[-1]}"
|
|
481
|
+
if not p.exists():
|
|
482
|
+
add(label, "FAIL", "missing")
|
|
483
|
+
elif not os.access(p, os.X_OK):
|
|
484
|
+
add(label, "FAIL", "not executable")
|
|
485
|
+
else:
|
|
486
|
+
add(label, "PASS", "")
|
|
487
|
+
|
|
488
|
+
# ---------- settings.json hook wiring ----------
|
|
489
|
+
settings_text = read_text(".claude/settings.json")
|
|
490
|
+
if not settings_text:
|
|
491
|
+
add("settings.json present", "FAIL", "missing or empty")
|
|
492
|
+
else:
|
|
493
|
+
try:
|
|
494
|
+
json.loads(settings_text)
|
|
495
|
+
add("settings.json parses", "PASS", "")
|
|
496
|
+
except Exception as e:
|
|
497
|
+
add("settings.json parses", "FAIL", str(e))
|
|
498
|
+
for h in sorted(EXPECTED_HOOKS):
|
|
499
|
+
if f"{h}.sh" in settings_text:
|
|
500
|
+
add(f"hook wired: {h}", "PASS", "")
|
|
501
|
+
else:
|
|
502
|
+
add(f"hook wired: {h}", "FAIL", "not in settings.json")
|
|
503
|
+
|
|
504
|
+
# ---------- project.json keys ---------- (pj already loaded earlier for additions)
|
|
505
|
+
if pj is None:
|
|
506
|
+
add("project.json parses", "FAIL", "missing or invalid JSON")
|
|
507
|
+
else:
|
|
508
|
+
add("project.json parses", "PASS", "")
|
|
509
|
+
expected_paths = [
|
|
510
|
+
("configured", ["configured"]),
|
|
511
|
+
("test.cmd", ["test", "cmd"]),
|
|
512
|
+
("lint.cmd", ["lint", "cmd"]),
|
|
513
|
+
("tdd.source_globs", ["tdd", "source_globs"]),
|
|
514
|
+
("tdd.test_globs", ["tdd", "test_globs"]),
|
|
515
|
+
("tdd.exempt_globs", ["tdd", "exempt_globs"]),
|
|
516
|
+
("tdd.ui_globs", ["tdd", "ui_globs"]),
|
|
517
|
+
("destructive.hard_block_patterns", ["destructive", "hard_block_patterns"]),
|
|
518
|
+
("destructive.ask_patterns", ["destructive", "ask_patterns"]),
|
|
519
|
+
("artifacts.required_sections.intake", ["artifacts", "required_sections", "intake"]),
|
|
520
|
+
("artifacts.required_sections.brd", ["artifacts", "required_sections", "brd"]),
|
|
521
|
+
("artifacts.required_sections.spec", ["artifacts", "required_sections", "spec"]),
|
|
522
|
+
("artifacts.required_sections.rca", ["artifacts", "required_sections", "rca"]),
|
|
523
|
+
("artifacts.required_diagrams.spec", ["artifacts", "required_diagrams", "spec"]),
|
|
524
|
+
("swarm.max_parallel", ["swarm", "max_parallel"]),
|
|
525
|
+
("swarm.isolation", ["swarm", "isolation"]),
|
|
526
|
+
("swarm.min_tasks_worth_swarming", ["swarm", "min_tasks_worth_swarming"]),
|
|
527
|
+
("swarm.refuse_dirty_tree", ["swarm", "refuse_dirty_tree"]),
|
|
528
|
+
("swarm.exempt_path_prefixes", ["swarm", "exempt_path_prefixes"]),
|
|
529
|
+
("swarm.enforced_path_prefixes", ["swarm", "enforced_path_prefixes"]),
|
|
530
|
+
("consent.commit_ttl_seconds", ["consent", "commit_ttl_seconds"]),
|
|
531
|
+
("consent.gate_marker_ttl_seconds", ["consent", "gate_marker_ttl_seconds"]),
|
|
532
|
+
("additions.agents", ["additions", "agents"]),
|
|
533
|
+
("additions.skills", ["additions", "skills"]),
|
|
534
|
+
("additions.hooks", ["additions", "hooks"]),
|
|
535
|
+
("additions.mcp_servers", ["additions", "mcp_servers"]),
|
|
536
|
+
("additions.swarm_worker_skills", ["additions", "swarm_worker_skills"]),
|
|
537
|
+
]
|
|
538
|
+
for label, path in expected_paths:
|
|
539
|
+
cur = pj
|
|
540
|
+
ok = True
|
|
541
|
+
for k in path:
|
|
542
|
+
if isinstance(cur, dict) and k in cur:
|
|
543
|
+
cur = cur[k]
|
|
544
|
+
else:
|
|
545
|
+
ok = False; break
|
|
546
|
+
add(f"project.json: {label}", "PASS" if ok else "FAIL", "" if ok else "missing key")
|
|
547
|
+
|
|
548
|
+
# ---------- .mcp.json servers ----------
|
|
549
|
+
mcp = read_json(".mcp.json")
|
|
550
|
+
if mcp is None:
|
|
551
|
+
add(".mcp.json parses", "FAIL", "missing or invalid JSON")
|
|
552
|
+
else:
|
|
553
|
+
add(".mcp.json parses", "PASS", "")
|
|
554
|
+
servers = list((mcp.get("mcpServers") or {}).keys())
|
|
555
|
+
for s in ("context7", "plantuml", "playwright"):
|
|
556
|
+
add(f"mcp server: {s}", "PASS" if s in servers else "FAIL",
|
|
557
|
+
"" if s in servers else "not declared")
|
|
558
|
+
|
|
559
|
+
# ---------- vendored license / notice ----------
|
|
560
|
+
recommender = root / ".claude/skills/claude-automation-recommender"
|
|
561
|
+
if recommender.is_dir():
|
|
562
|
+
for fname in ("LICENSE", "NOTICE", "SKILL.md"):
|
|
563
|
+
p = recommender / fname
|
|
564
|
+
add(f"recommender {fname}",
|
|
565
|
+
"PASS" if p.exists() else "FAIL",
|
|
566
|
+
"" if p.exists() else "missing")
|
|
567
|
+
else:
|
|
568
|
+
add("recommender skill directory", "FAIL", "missing")
|
|
569
|
+
|
|
570
|
+
# .claude/bin/ — vendored Apache-licensed PlantUML jar (deferred-fetch model;
|
|
571
|
+
# only LICENSE + NOTICE ship; the jar itself is fetched at install time and
|
|
572
|
+
# verified against a pinned sha256). The LICENSE + NOTICE are mandatory.
|
|
573
|
+
plantuml_dir = root / ".claude/bin"
|
|
574
|
+
if plantuml_dir.is_dir():
|
|
575
|
+
for fname in ("LICENSE", "NOTICE"):
|
|
576
|
+
p = plantuml_dir / fname
|
|
577
|
+
add(f"plantuml-vendored {fname}",
|
|
578
|
+
"PASS" if p.exists() else "FAIL",
|
|
579
|
+
"" if p.exists() else f"missing — required for Apache 2.0 redistribution of plantuml-asl jar")
|
|
580
|
+
notice_p = plantuml_dir / "NOTICE"
|
|
581
|
+
if notice_p.exists():
|
|
582
|
+
notice_text = notice_p.read_text(encoding="utf-8", errors="replace")
|
|
583
|
+
required_substrings = [
|
|
584
|
+
"plantuml-asl-1.2026.2",
|
|
585
|
+
"c348f6a26d999f81fd05b5d49834bb70df9cf35fab0939c4edecb0909e64022b",
|
|
586
|
+
]
|
|
587
|
+
missing = [s for s in required_substrings if s not in notice_text]
|
|
588
|
+
if missing:
|
|
589
|
+
add("plantuml-vendored NOTICE content", "FAIL",
|
|
590
|
+
f"missing required attribution strings: {missing}")
|
|
591
|
+
else:
|
|
592
|
+
add("plantuml-vendored NOTICE content", "PASS",
|
|
593
|
+
"upstream version + pinned sha256 present")
|
|
594
|
+
else:
|
|
595
|
+
add(".claude/bin directory", "FAIL", "missing — required for vendored PlantUML LICENSE/NOTICE")
|
|
596
|
+
|
|
597
|
+
# ---------- Article X.2 / design-ui orchestrator surface ----------
|
|
598
|
+
claude_md = read_text("CLAUDE.md")
|
|
599
|
+
if "### X.2 Design-task routing" in claude_md:
|
|
600
|
+
add("CLAUDE.md: Article X.2 present", "PASS", "design-task routing rule declared")
|
|
601
|
+
else:
|
|
602
|
+
add("CLAUDE.md: Article X.2 present", "FAIL",
|
|
603
|
+
"missing `### X.2 Design-task routing` heading — Article X.2 is the structural seam between design-ui and impeccable")
|
|
604
|
+
|
|
605
|
+
template_claude = read_text("src/CLAUDE.template.md")
|
|
606
|
+
if "### X.2 Design-task routing" in template_claude:
|
|
607
|
+
add("src/CLAUDE.template.md: Article X.2 mirrors", "PASS", "")
|
|
608
|
+
else:
|
|
609
|
+
add("src/CLAUDE.template.md: Article X.2 mirrors", "FAIL",
|
|
610
|
+
"src template does not contain Article X.2 — template-drift will fail")
|
|
611
|
+
|
|
612
|
+
design_ui_skill = read_text(".claude/skills/design-ui/SKILL.md")
|
|
613
|
+
if re.search(r'^description:.*orchestrat', design_ui_skill, re.MULTILINE | re.IGNORECASE):
|
|
614
|
+
add("design-ui SKILL.md: orchestrator role", "PASS",
|
|
615
|
+
"frontmatter description names orchestrator role")
|
|
616
|
+
else:
|
|
617
|
+
add("design-ui SKILL.md: orchestrator role", "FAIL",
|
|
618
|
+
"frontmatter description must mention 'orchestrat' — the v1 code-writing role is retired")
|
|
619
|
+
|
|
620
|
+
# spec_design_calls_guard — present, executable, and wired in settings.
|
|
621
|
+
hook_path = root / ".claude/hooks/spec_design_calls_guard.sh"
|
|
622
|
+
hook_wired = "spec_design_calls_guard.sh" in (settings_text or "")
|
|
623
|
+
if hook_path.is_file() and os.access(hook_path, os.X_OK) and hook_wired:
|
|
624
|
+
add("spec_design_calls_guard.sh: present + wired", "PASS",
|
|
625
|
+
"file executable and wired in PreToolUse Write|Edit|MultiEdit chain")
|
|
626
|
+
else:
|
|
627
|
+
detail = []
|
|
628
|
+
if not hook_path.is_file():
|
|
629
|
+
detail.append("hook script missing")
|
|
630
|
+
elif not os.access(hook_path, os.X_OK):
|
|
631
|
+
detail.append("hook not executable")
|
|
632
|
+
if not hook_wired:
|
|
633
|
+
detail.append("not wired in .claude/settings.json")
|
|
634
|
+
add("spec_design_calls_guard.sh: present + wired", "FAIL", "; ".join(detail))
|
|
635
|
+
|
|
636
|
+
# ---------- cross-doc count claims ----------
|
|
637
|
+
#
|
|
638
|
+
# Two-layer design:
|
|
639
|
+
#
|
|
640
|
+
# Layer 1 — regex sweep. Find every "<n> <noun>" or "<noun> (<n>)" shape.
|
|
641
|
+
# Cheap, broad, but produces false positives (e.g. "Two subagents
|
|
642
|
+
# review before /approve-spec" is a local count of two specific
|
|
643
|
+
# reviewer agents, not a headline claim about the baseline).
|
|
644
|
+
#
|
|
645
|
+
# Layer 2 — context classifier. Look at what surrounds each match and
|
|
646
|
+
# bucket it into HEADLINE / LOCAL / AMBIGUOUS. Only HEADLINE
|
|
647
|
+
# matches drive PASS/FAIL on the headline count. LOCAL matches
|
|
648
|
+
# are suppressed silently. AMBIGUOUS matches surface a soft
|
|
649
|
+
# note so the user can sanity-check without confusing them
|
|
650
|
+
# into thinking the baseline is drifting.
|
|
651
|
+
#
|
|
652
|
+
# This keeps the sweep's recall (catches drift in unexpected phrasings)
|
|
653
|
+
# without the precision tax of dumping every match as a warning.
|
|
654
|
+
|
|
655
|
+
NUM = (r"(?<![.\d\-])(" # also block hyphen so "four" in "thirty-four" doesn't match
|
|
656
|
+
r"\d+|"
|
|
657
|
+
# Compounds first so "thirty-four" wins over bare "four".
|
|
658
|
+
r"twenty-one|twenty-two|twenty-three|twenty-four|twenty-five|twenty-six|"
|
|
659
|
+
r"twenty-seven|twenty-eight|twenty-nine|"
|
|
660
|
+
r"thirty-one|thirty-two|thirty-three|thirty-four|thirty-five|thirty-six|"
|
|
661
|
+
r"thirty-seven|thirty-eight|thirty-nine|"
|
|
662
|
+
r"twenty|thirty|forty|"
|
|
663
|
+
r"one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|"
|
|
664
|
+
r"thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen)")
|
|
665
|
+
|
|
666
|
+
# Patterns matching the headline form: "<n> <noun>".
|
|
667
|
+
# Headline claims describe the *baseline* shape, so they compare against the
|
|
668
|
+
# baseline subset of disk (project additions are accounted for separately).
|
|
669
|
+
HEAD_PATTERNS = [
|
|
670
|
+
(NUM + r"\s+hooks?\b", len(disk_baseline_hooks), "hooks"),
|
|
671
|
+
(NUM + r"\s+guard\s+(?:hook|script)s?\b", len(disk_baseline_hooks), "guard hooks/scripts"),
|
|
672
|
+
(NUM + r"\s+(?:baseline\s+)?subagents?\b", len(disk_baseline_agents), "subagents"),
|
|
673
|
+
(NUM + r"\s+skills\b", len(disk_baseline_skills), "skills"),
|
|
674
|
+
]
|
|
675
|
+
# Patterns matching the parenthesised form: "<noun> (<n>)" — common in
|
|
676
|
+
# diagram labels like 'subagents (10)' that the headline form misses.
|
|
677
|
+
PAREN_PATTERNS = [
|
|
678
|
+
(r"\b(?:guard\s+hooks?|guards?)\s*\((\d+)\)", len(disk_baseline_hooks), "guard hooks"),
|
|
679
|
+
(r"\bsubagents?\s*\((\d+)\)", len(disk_baseline_agents), "subagents"),
|
|
680
|
+
(r"\bskills?\s*\((\d+)\)", len(disk_baseline_skills), "skills"),
|
|
681
|
+
]
|
|
682
|
+
# Patterns matching the noun-first form: "<noun> <n>" — used in compact
|
|
683
|
+
# typographic lists like 'phases 11 · hooks 14 · skills 27 · agents 10'
|
|
684
|
+
# that the standard "<n> <noun>" headline form misses.
|
|
685
|
+
NOUN_FIRST_PATTERNS = [
|
|
686
|
+
(r"\bhooks?\s+(\d+)\b", len(disk_baseline_hooks), "hooks"),
|
|
687
|
+
(r"\b(?:sub)?agents?\s+(\d+)\b", len(disk_baseline_agents), "agents"),
|
|
688
|
+
(r"\bskills?\s+(\d+)\b", len(disk_baseline_skills), "skills"),
|
|
689
|
+
]
|
|
690
|
+
|
|
691
|
+
# Indicators that a count is LOCAL (sub-paragraph enumeration), not headline.
|
|
692
|
+
# Tested against the ~80 chars immediately following the matched snippet.
|
|
693
|
+
LOCAL_POST_HINTS = (
|
|
694
|
+
"review before", "review of", "iterate safely", "iterate over",
|
|
695
|
+
"+ one command", "+ 1 command", "sit between", "operate on",
|
|
696
|
+
"ship a", "ship `template", "share `code", "review prose",
|
|
697
|
+
"run between", "follow ", "handle ",
|
|
698
|
+
)
|
|
699
|
+
# Indicators that a count IS a headline claim about the baseline as a whole.
|
|
700
|
+
HEADLINE_PRE_HINTS = (
|
|
701
|
+
"ships the claude code baseline (", "drop-in scaffold", "<strong>",
|
|
702
|
+
"ships ", "baseline (", "delivers ", "twenty-", "fourteen ",
|
|
703
|
+
"ten ", "eleven ",
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
def classify_match(text, m):
|
|
707
|
+
"""Return HEADLINE | LOCAL | AMBIGUOUS for a regex match."""
|
|
708
|
+
start, end = m.span()
|
|
709
|
+
pre = text[max(0, start - 80):start].lower()
|
|
710
|
+
post = text[end:end + 80].lower()
|
|
711
|
+
|
|
712
|
+
# LOCAL signals win first — local enumeration is the dominant FP source.
|
|
713
|
+
for hint in LOCAL_POST_HINTS:
|
|
714
|
+
if hint in post:
|
|
715
|
+
return "LOCAL"
|
|
716
|
+
# A colon or bullet introducing a list right after the count is also LOCAL
|
|
717
|
+
# ("Two subagents review before /approve-spec:" then a bulleted list).
|
|
718
|
+
stripped_post = post.lstrip()
|
|
719
|
+
if stripped_post.startswith(":") and "\n" in post[:40]:
|
|
720
|
+
return "LOCAL"
|
|
721
|
+
|
|
722
|
+
# HEADLINE signals: lede position, structural <strong>, declarative cue.
|
|
723
|
+
if start < 1200: # lede / preamble window
|
|
724
|
+
return "HEADLINE"
|
|
725
|
+
for hint in HEADLINE_PRE_HINTS:
|
|
726
|
+
if hint in pre:
|
|
727
|
+
return "HEADLINE"
|
|
728
|
+
|
|
729
|
+
return "AMBIGUOUS"
|
|
730
|
+
|
|
731
|
+
docs_to_check = [
|
|
732
|
+
"CLAUDE.md",
|
|
733
|
+
"README.md",
|
|
734
|
+
"docs/init/seed.md",
|
|
735
|
+
]
|
|
736
|
+
for doc in docs_to_check:
|
|
737
|
+
text = read_text(doc)
|
|
738
|
+
if not text:
|
|
739
|
+
add(f"{doc} count claims", "WARN", "file not present")
|
|
740
|
+
continue
|
|
741
|
+
|
|
742
|
+
headline_drift = [] # confirmed stale headline claims
|
|
743
|
+
headline_ok = 0 # headline claims that match disk
|
|
744
|
+
local_n = 0 # suppressed local counts
|
|
745
|
+
ambiguous = [] # neither clearly headline nor clearly local
|
|
746
|
+
|
|
747
|
+
# Headline-form sweep (with classifier).
|
|
748
|
+
for pat, expected, kind in HEAD_PATTERNS:
|
|
749
|
+
for m in re.finditer(pat, text, re.IGNORECASE):
|
|
750
|
+
claimed = to_int(m.group(1))
|
|
751
|
+
if claimed is None:
|
|
752
|
+
continue
|
|
753
|
+
tier = classify_match(text, m)
|
|
754
|
+
if tier == "LOCAL":
|
|
755
|
+
local_n += 1
|
|
756
|
+
continue
|
|
757
|
+
if claimed == expected:
|
|
758
|
+
if tier == "HEADLINE":
|
|
759
|
+
headline_ok += 1
|
|
760
|
+
# AMBIGUOUS-and-correct: silently fine.
|
|
761
|
+
continue
|
|
762
|
+
# claimed != expected
|
|
763
|
+
snippet = m.group(0).strip()
|
|
764
|
+
if tier == "HEADLINE":
|
|
765
|
+
headline_drift.append(f'"{snippet}" → expected {expected} {kind}')
|
|
766
|
+
else: # AMBIGUOUS and stale — soft surface, may be local
|
|
767
|
+
ambiguous.append(f'"{snippet}" (likely local; otherwise {expected} {kind})')
|
|
768
|
+
|
|
769
|
+
# Parenthesised-form sweep. Mostly diagram labels like 'subagents (10)'.
|
|
770
|
+
# Qualifier-prefixed forms ('phase skills (11)', 'shared globals (7)',
|
|
771
|
+
# 'guard hooks' is itself a qualifier we already handle in the pattern)
|
|
772
|
+
# are local subset counts — drop them.
|
|
773
|
+
QUALIFIER_PREFIXES = ("phase ", "shared ", "local ", "scoped ",
|
|
774
|
+
"swarm ", "ui ", "test ")
|
|
775
|
+
for pat, expected, kind in PAREN_PATTERNS:
|
|
776
|
+
for m in re.finditer(pat, text, re.IGNORECASE):
|
|
777
|
+
claimed = to_int(m.group(1))
|
|
778
|
+
if claimed is None:
|
|
779
|
+
continue
|
|
780
|
+
# Inspect the ~12 chars before the match — if a qualifier word
|
|
781
|
+
# like 'phase' sits there, this is a sub-count, not headline.
|
|
782
|
+
pre_word = text[max(0, m.start() - 12):m.start()].lower()
|
|
783
|
+
if any(pre_word.endswith(q) for q in QUALIFIER_PREFIXES):
|
|
784
|
+
local_n += 1
|
|
785
|
+
continue
|
|
786
|
+
if claimed == expected:
|
|
787
|
+
headline_ok += 1
|
|
788
|
+
else:
|
|
789
|
+
snippet = m.group(0).strip()
|
|
790
|
+
headline_drift.append(f'"{snippet}" → expected {expected} {kind}')
|
|
791
|
+
|
|
792
|
+
# Noun-first sweep — for compact typographic count strips like
|
|
793
|
+
# 'phases 11 · hooks 14 · skills 27 · agents 10 · gates 3'. The form
|
|
794
|
+
# itself is structural (rare in flowing prose), so AMBIGUOUS classifier
|
|
795
|
+
# results here are promoted to HEADLINE. Only an explicit LOCAL signal
|
|
796
|
+
# in the surrounding context demotes a match to a suppressed local count.
|
|
797
|
+
for pat, expected, kind in NOUN_FIRST_PATTERNS:
|
|
798
|
+
for m in re.finditer(pat, text, re.IGNORECASE):
|
|
799
|
+
try:
|
|
800
|
+
claimed = int(m.group(1))
|
|
801
|
+
except (TypeError, ValueError):
|
|
802
|
+
continue
|
|
803
|
+
tier = classify_match(text, m)
|
|
804
|
+
if tier == "LOCAL":
|
|
805
|
+
local_n += 1
|
|
806
|
+
continue
|
|
807
|
+
# Treat AMBIGUOUS as HEADLINE for noun-first — the form is the signal.
|
|
808
|
+
if claimed == expected:
|
|
809
|
+
headline_ok += 1
|
|
810
|
+
continue
|
|
811
|
+
snippet = m.group(0).strip()
|
|
812
|
+
headline_drift.append(f'"{snippet}" → expected {expected} {kind}')
|
|
813
|
+
|
|
814
|
+
if headline_drift:
|
|
815
|
+
# Real drift — promote to FAIL. The classifier has filtered out
|
|
816
|
+
# the false positives that previously kept this at WARN.
|
|
817
|
+
add(f"{doc} count claims", "FAIL", "; ".join(headline_drift[:3]) +
|
|
818
|
+
(f"; +{len(headline_drift) - 3} more" if len(headline_drift) > 3 else ""))
|
|
819
|
+
elif headline_ok:
|
|
820
|
+
suffix = ""
|
|
821
|
+
if local_n:
|
|
822
|
+
suffix = f" ({local_n} local count{'s' if local_n != 1 else ''} suppressed)"
|
|
823
|
+
add(f"{doc} count claims", "PASS", f"{headline_ok} headline claim{'s' if headline_ok != 1 else ''} match{suffix}")
|
|
824
|
+
elif ambiguous:
|
|
825
|
+
add(f"{doc} count claims", "WARN", "; ".join(ambiguous[:2]))
|
|
826
|
+
else:
|
|
827
|
+
add(f"{doc} count claims", "WARN", "no relevant claims found")
|
|
828
|
+
|
|
829
|
+
# ---------- quickfix invariants (5/6/7) ----------
|
|
830
|
+
|
|
831
|
+
# quickfix-5: scoped baseline files SHALL NOT contain the legacy doc-site
|
|
832
|
+
# path prefix. Scope is narrow (per seed.md §16 deviation #5): audit.sh, the
|
|
833
|
+
# audit-baseline SKILL.md, init-project.md, and seed.md §3 (lines 100-136).
|
|
834
|
+
# §16 itself is excluded — its deviation log legitimately records the historical
|
|
835
|
+
# removal and SHALL NOT be edited by a quickfix pass.
|
|
836
|
+
#
|
|
837
|
+
# The needle is built via concatenation so this assertion file is not a
|
|
838
|
+
# self-match — audit.sh is one of the scanned targets, and the needle string
|
|
839
|
+
# is the only thing here that may contain the joined literal.
|
|
840
|
+
_qf5_needle = "docs/" + "site"
|
|
841
|
+
|
|
842
|
+
def _qf5_scan(rel, line_range=None, cached=None):
|
|
843
|
+
text = cached if cached is not None else read_text(rel)
|
|
844
|
+
if not text:
|
|
845
|
+
return []
|
|
846
|
+
lines = text.splitlines()
|
|
847
|
+
if line_range is not None:
|
|
848
|
+
lo, hi = line_range
|
|
849
|
+
sliced = lines[lo - 1:hi]
|
|
850
|
+
return [(rel, lo + i) for i, ln in enumerate(sliced) if _qf5_needle in ln]
|
|
851
|
+
return [(rel, i + 1) for i, ln in enumerate(lines) if _qf5_needle in ln]
|
|
852
|
+
|
|
853
|
+
_qf5_targets = [
|
|
854
|
+
(".claude/skills/audit-baseline/audit.sh", None, None),
|
|
855
|
+
(".claude/skills/audit-baseline/SKILL.md", None, None),
|
|
856
|
+
(".claude/commands/init-project.md", None, None),
|
|
857
|
+
("docs/init/seed.md", (100, 136), seed),
|
|
858
|
+
]
|
|
859
|
+
_qf5_hits = []
|
|
860
|
+
for _p, _r, _cached in _qf5_targets:
|
|
861
|
+
_qf5_hits.extend(_qf5_scan(_p, _r, _cached))
|
|
862
|
+
if _qf5_hits:
|
|
863
|
+
_qf5_detail = "; ".join(f"{p}:{ln}" for p, ln in _qf5_hits[:3])
|
|
864
|
+
if len(_qf5_hits) > 3:
|
|
865
|
+
_qf5_detail += f"; +{len(_qf5_hits) - 3} more"
|
|
866
|
+
add("quickfix-5: no stale doc-site refs in scoped baseline files", "FAIL", _qf5_detail)
|
|
867
|
+
else:
|
|
868
|
+
add("quickfix-5: no stale doc-site refs in scoped baseline files", "PASS", "4 paths clean")
|
|
869
|
+
|
|
870
|
+
# quickfix-6: HEAD_PATTERNS hooks regex SHALL match bare `<n> hooks` (no
|
|
871
|
+
# `guard` qualifier required). The current pattern requires `\s+guard\s+...`
|
|
872
|
+
# so the synthetic string "the harness has 17 hooks total" does not match.
|
|
873
|
+
_qf6_pat = next((pat for pat, _exp, kind in HEAD_PATTERNS if "hooks" in kind), None)
|
|
874
|
+
if _qf6_pat is None:
|
|
875
|
+
add("quickfix-6: hooks count regex accepts bare phrasing", "FAIL",
|
|
876
|
+
"could not locate hooks pattern in HEAD_PATTERNS")
|
|
877
|
+
else:
|
|
878
|
+
_qf6_m = re.search(_qf6_pat, "the harness has 17 hooks total", re.IGNORECASE)
|
|
879
|
+
if _qf6_m and to_int(_qf6_m.group(1)) == 17:
|
|
880
|
+
add("quickfix-6: hooks count regex accepts bare phrasing", "PASS",
|
|
881
|
+
f"matched {_qf6_m.group(0)!r} -> 17")
|
|
882
|
+
else:
|
|
883
|
+
add("quickfix-6: hooks count regex accepts bare phrasing", "FAIL",
|
|
884
|
+
'bare-form regex did not match "17 hooks total"')
|
|
885
|
+
|
|
886
|
+
# quickfix-7: swarm-worker.md frontmatter `description:` SHALL begin with an
|
|
887
|
+
# imperative verb (Article II — "Decisions live in main context. Subagents
|
|
888
|
+
# only execute pre-decided recipes."). The third-person form `Executes` is
|
|
889
|
+
# rejected; the imperative `Execute` is accepted.
|
|
890
|
+
_qf7_text = read_text(".claude/agents/swarm-worker.md")
|
|
891
|
+
_qf7_m = re.search(r"(?m)^description:\s*(\S+)", _qf7_text or "")
|
|
892
|
+
if not _qf7_text:
|
|
893
|
+
add("quickfix-7: swarm-worker description uses imperative voice", "FAIL",
|
|
894
|
+
".claude/agents/swarm-worker.md not present")
|
|
895
|
+
elif _qf7_m is None:
|
|
896
|
+
add("quickfix-7: swarm-worker description uses imperative voice", "FAIL",
|
|
897
|
+
"no `description:` line found in swarm-worker.md frontmatter")
|
|
898
|
+
else:
|
|
899
|
+
_qf7_first = _qf7_m.group(1).rstrip(",.;:")
|
|
900
|
+
if re.match(r"^(Execute|Run|Receive|Perform)\b", _qf7_first):
|
|
901
|
+
add("quickfix-7: swarm-worker description uses imperative voice", "PASS",
|
|
902
|
+
f"imperative voice: {_qf7_first}")
|
|
903
|
+
else:
|
|
904
|
+
add("quickfix-7: swarm-worker description uses imperative voice", "FAIL",
|
|
905
|
+
f'description starts with "{_qf7_first}" — expected imperative verb (Execute|Run|Receive|Perform)')
|
|
906
|
+
|
|
907
|
+
# ---------- output ----------
|
|
908
|
+
name_w = max((len(r[0]) for r in results), default=20)
|
|
909
|
+
fail_n = sum(1 for _, s, _ in results if s == "FAIL")
|
|
910
|
+
warn_n = sum(1 for _, s, _ in results if s == "WARN")
|
|
911
|
+
print(f"{'check'.ljust(name_w)} {'status':<6} detail")
|
|
912
|
+
print(f"{'-' * name_w} {'-' * 6} {'-' * 50}")
|
|
913
|
+
for name, status, detail in results:
|
|
914
|
+
print(f"{name.ljust(name_w)} {status:<6} {detail}")
|
|
915
|
+
print(f"{'-' * name_w} {'-' * 6}")
|
|
916
|
+
overall = "FAIL" if fail_n else "PASS"
|
|
917
|
+
print(f"{'overall'.ljust(name_w)} {overall:<6} fails={fail_n} warns={warn_n}")
|
|
918
|
+
sys.exit(1 if fail_n else 0)
|
|
919
|
+
PY
|