@luizsantiago/spec-guardrails 3.0.1 → 3.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  **Token-efficient by design:** the agent loads **one phase guide per turn** (~9k est. tokens on Specify) instead of dumping the full skill library (~31k). Measured savings: **~72%** on planning, **~86%** on Execute vs a naive full reload ([details below](#token-cost)).
9
9
 
10
- npm: [`@luizsantiago/spec-guardrails`](https://www.npmjs.com/package/@luizsantiago/spec-guardrails) **3.0.x**
10
+ npm: [`@luizsantiago/spec-guardrails`](https://www.npmjs.com/package/@luizsantiago/spec-guardrails) **3.1.x**
11
11
 
12
12
  ---
13
13
 
@@ -122,6 +122,8 @@ Scripts in `.specs/guardrails/scripts/`. **Exit ≠ 0 → stop and fix.**
122
122
  | Before approving spec | `validate-spec` | Incomplete or untestable spec |
123
123
  | Before approving tasks | `analyze-artifacts` | Spec ↔ tasks drift |
124
124
  | Before approving tasks | `validate-tasks` | Bad tasks; missing graph when 3+ tasks |
125
+ | After tasks / with validation | `validate-traceability` | REQ missing from tasks or coverage lines |
126
+ | End of `/quick` | `validate-quick` | Incomplete Quick TASK/SUMMARY; >3 files; sensitive paths |
125
127
  | Each `/loop` wave | `loop-plan` | Blocked dependencies; shows parallel groups |
126
128
  | Each commit | `check-commit` | Non-Conventional commit message |
127
129
  | Before “done” | `validate-state` | Fake PASS without test evidence |
@@ -141,6 +143,7 @@ Full reference: **[Gates](docs/guide/gates.md)** · [Gates and guarantees](docs/
141
143
  | [Concepts](docs/guide/concepts.md) | Spec-driven + guardrails + loop + graph |
142
144
  | [Skills and hub](docs/guide/skills-and-hub.md) | What each skill file does |
143
145
  | [Gates](docs/guide/gates.md) | How each gate works |
146
+ | [Platform parity](docs/guide/Platform-parity.md) | Cursor vs Claude Code |
144
147
  | [FAQ](docs/guide/FAQ.md) | Common questions |
145
148
  | [Changelog](docs/CHANGELOG.md) | Full version history |
146
149
 
@@ -156,6 +159,7 @@ npx @luizsantiago/spec-guardrails install
156
159
 
157
160
  | Version | What you gain |
158
161
  | --- | --- |
162
+ | **3.1.x** | Doctor STATE fix; `validate-traceability` / `validate-quick`; `classify-change` / `feature-status`; Claude `CLAUDE.md` |
159
163
  | **3.0.x** | Final name Spec Guardrails; `.specs/guardrails/`; no dual-path ([Migration](docs/guide/Migration.md)) |
160
164
  | **2.2.x** | Seatbelt-era paths & markers; `doctor` Execute hints; docs split from README |
161
165
  | **2.1.x** | `loop-plan` + parallel `/loop` waves |
package/index.js CHANGED
@@ -4,10 +4,12 @@ import path from "node:path";
4
4
 
5
5
  import { archiveFeature } from "./lib/archive.js";
6
6
  import { projectInit } from "./lib/brownfield.js";
7
+ import { classifyChange, formatClassifyChange } from "./lib/classify-change.js";
7
8
  import { PACKAGE_VERSION, CLI_NAME } from "./lib/constants.js";
8
9
  import { phaseContext } from "./lib/config.js";
9
10
  import { doctor } from "./lib/doctor.js";
10
11
  import { featureInit } from "./lib/feature.js";
12
+ import { featureStatus, formatFeatureStatus } from "./lib/feature-status.js";
11
13
  import { GATE_COMMANDS, AUX_COMMANDS, runGate, runGuardrailsScript } from "./lib/gates.js";
12
14
  import { install } from "./lib/install.js";
13
15
  import {
@@ -42,6 +44,10 @@ Commands:
42
44
  [--no-roadmap] Skip ROADMAP update
43
45
  [--no-domain] Skip domain spec merge
44
46
  [--no-state] Skip STATE reset
47
+ classify-change [desc] [files...] Heuristic complexity tier (quick/simple/medium/complex)
48
+ [--json] Machine-readable output
49
+ feature-status [feature] Artifact checklist + next step for a feature
50
+ [--json] Machine-readable output
45
51
  phase-context <phase> Print .specs/config.yaml context + rules for a phase
46
52
  doctor [path] Audit guardrails readiness (score + next actions)
47
53
  [--json] Machine-readable output
@@ -51,6 +57,8 @@ Commands:
51
57
  validate-tasks [tasks.md|feature] Granularity gate for a task breakdown
52
58
  loop-plan [tasks.md|feature] Next Execute wave — parallel groups + sub-agent hints
53
59
  [--json] Machine-readable plan for agents
60
+ validate-traceability [feature] REQ → tasks → validation coverage chain
61
+ validate-quick [quick-folder] Quick-mode TASK.md / SUMMARY.md structural gate
54
62
  validate-state [feature] Completion gate before declaring a feature done
55
63
  check-commit --message "<msg>" Conventional Commits gate
56
64
  lessons <add|list|penalize|prune|status> Lessons engine
@@ -313,8 +321,59 @@ if (command === "--version" || command === "-v" || command === "version") {
313
321
  console.error(`❌ ${err.message}`);
314
322
  process.exit(1);
315
323
  }
316
- } else if (AUX_COMMANDS.includes(command)) {
324
+ } else if (command === "classify-change") {
325
+ try {
326
+ let json = false;
327
+ const positional = [];
328
+ for (const arg of args) {
329
+ if (arg === "--json") {
330
+ json = true;
331
+ } else {
332
+ positional.push(arg);
333
+ }
334
+ }
335
+ if (positional.length === 0) {
336
+ throw new Error(
337
+ 'Description or files required. Example: classify-change "fix theme toggle" src/hooks/useTheme.ts',
338
+ );
339
+ }
340
+ const files = positional.filter((item) => /[\\/]|\.[a-z0-9]+$/i.test(item));
341
+ const descriptionParts = positional.filter((item) => !files.includes(item));
342
+ const result = classifyChange({
343
+ description: descriptionParts.join(" "),
344
+ files,
345
+ });
346
+ if (json) {
347
+ console.log(JSON.stringify(result, null, 2));
348
+ } else {
349
+ process.stdout.write(formatClassifyChange(result));
350
+ }
351
+ } catch (err) {
352
+ console.error(`❌ ${err.message}`);
353
+ process.exit(1);
354
+ }
355
+ } else if (command === "feature-status") {
317
356
  try {
357
+ let json = false;
358
+ const positional = [];
359
+ for (const arg of args) {
360
+ if (arg === "--json") {
361
+ json = true;
362
+ } else {
363
+ positional.push(arg);
364
+ }
365
+ }
366
+ const status = await featureStatus(positional[0]);
367
+ if (json) {
368
+ console.log(JSON.stringify(status, null, 2));
369
+ } else {
370
+ process.stdout.write(formatFeatureStatus(status));
371
+ }
372
+ } catch (err) {
373
+ console.error(`❌ ${err.message}`);
374
+ process.exit(1);
375
+ }
376
+ } else if (AUX_COMMANDS.includes(command)) { try {
318
377
  const code = await runGuardrailsScript(command, args);
319
378
  process.exit(code);
320
379
  } catch (err) {
package/lib/archive.js CHANGED
@@ -154,6 +154,16 @@ export async function archiveFeature(featureArg, options = {}) {
154
154
  );
155
155
  }
156
156
 
157
+ const traceCode = await runGate("validate-traceability", [featureId], {
158
+ cwd,
159
+ stdio: "pipe",
160
+ });
161
+ if (traceCode !== 0) {
162
+ throw new Error(
163
+ `validate-traceability failed for ${featureId}. Fix REQ coverage before archive.`,
164
+ );
165
+ }
166
+
157
167
  const gateCode = await runGate("validate-state", [featureId], {
158
168
  cwd,
159
169
  stdio: "pipe",
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Heuristic complexity classifier (no LLM) — mirrors the hub Complexity Router.
3
+ */
4
+
5
+ const COMPLEX_SIGNALS = [
6
+ /\bauth(entication|orization)?\b/i,
7
+ /\bpayment|billing|checkout|stripe\b/i,
8
+ /\bmigration|schema\b/i,
9
+ /\binfra(structure)?|kubernetes|terraform\b/i,
10
+ /\barchitecture|redesign\b/i,
11
+ /\bssrf|upload|pii|secret|oauth\b/i,
12
+ /\bapi\s+gateway|multi-tenant\b/i,
13
+ ];
14
+
15
+ const MEDIUM_SIGNALS = [
16
+ /\bfeature\b/i,
17
+ /\bnew\s+(endpoint|page|screen|module|service)\b/i,
18
+ /\brefactor\b/i,
19
+ /\bintegration\b/i,
20
+ ];
21
+
22
+ const DEPENDENCY_SIGNALS = [
23
+ /\bnew\s+dependenc/i,
24
+ /\bnpm\s+install\b/i,
25
+ /\bpip\s+install\b/i,
26
+ /\badd\s+package\b/i,
27
+ ];
28
+
29
+ /**
30
+ * @param {{ description?: string, files?: string[] }} input
31
+ * @returns {{
32
+ * tier: "quick" | "simple" | "medium" | "complex",
33
+ * reasons: string[],
34
+ * next: string,
35
+ * fileCount: number,
36
+ * }}
37
+ */
38
+ export function classifyChange(input = {}) {
39
+ const description = (input.description ?? "").trim();
40
+ const files = (input.files ?? []).map((f) => f.trim()).filter(Boolean);
41
+ const haystack = [description, ...files].join("\n");
42
+ const reasons = [];
43
+ const fileCount = files.length;
44
+
45
+ const hasComplex = COMPLEX_SIGNALS.some((re) => re.test(haystack));
46
+ const hasMedium = MEDIUM_SIGNALS.some((re) => re.test(haystack));
47
+ const hasNewDep = DEPENDENCY_SIGNALS.some((re) => re.test(haystack));
48
+
49
+ if (hasComplex) {
50
+ reasons.push("sensitive surface or architecture signal in description/paths");
51
+ }
52
+ if (hasNewDep) {
53
+ reasons.push("new dependency signal");
54
+ }
55
+ if (fileCount > 3) {
56
+ reasons.push(`${fileCount} files listed (Quick max is 3)`);
57
+ }
58
+ if (fileCount > 0 && fileCount <= 3) {
59
+ reasons.push(`${fileCount} file(s) listed`);
60
+ }
61
+ if (!description && fileCount === 0) {
62
+ reasons.push("no description or files — defaulting to medium");
63
+ }
64
+
65
+ /** @type {"quick" | "simple" | "medium" | "complex"} */
66
+ let tier = "medium";
67
+
68
+ if (hasComplex) {
69
+ tier = "complex";
70
+ } else if (
71
+ !hasNewDep &&
72
+ fileCount > 0 &&
73
+ fileCount <= 3 &&
74
+ description.length > 0 &&
75
+ !hasMedium
76
+ ) {
77
+ tier = "quick";
78
+ reasons.push("≤3 files, no sensitive/architecture signals");
79
+ } else if (
80
+ !hasNewDep &&
81
+ (fileCount === 0 || fileCount <= 5) &&
82
+ !hasMedium &&
83
+ description.length > 0
84
+ ) {
85
+ tier = "simple";
86
+ reasons.push("localized change without feature/architecture signals");
87
+ } else if (hasMedium || hasNewDep || fileCount > 5) {
88
+ tier = "medium";
89
+ if (hasMedium) {
90
+ reasons.push("feature / module signal");
91
+ }
92
+ }
93
+
94
+ if (reasons.length === 0) {
95
+ reasons.push("default medium tier");
96
+ }
97
+
98
+ const nextByTier = {
99
+ quick: 'Use /quick (references/quick-mode.md), then validate-quick',
100
+ simple: 'feature-init → /specify → /loop → /verify',
101
+ medium: 'feature-init → /specify → /tasks → /loop → /verify → /archive',
102
+ complex:
103
+ 'feature-init → full pipeline (+ /discuss, /plan; optional AppSec/QA on verify)',
104
+ };
105
+
106
+ return {
107
+ tier,
108
+ reasons,
109
+ next: nextByTier[tier],
110
+ fileCount,
111
+ };
112
+ }
113
+
114
+ /**
115
+ * @param {{ tier: string, reasons: string[], next: string, fileCount: number }} result
116
+ * @returns {string}
117
+ */
118
+ export function formatClassifyChange(result) {
119
+ const lines = [
120
+ `Tier: ${result.tier}`,
121
+ `Files considered: ${result.fileCount}`,
122
+ "Reasons:",
123
+ ...result.reasons.map((r) => ` - ${r}`),
124
+ `Next: ${result.next}`,
125
+ ];
126
+ return `${lines.join("\n")}\n`;
127
+ }
@@ -0,0 +1,103 @@
1
+ import path from "node:path";
2
+
3
+ import {
4
+ CURSORRULES_MARKER_BEGIN,
5
+ CURSORRULES_MARKER_END,
6
+ LEGACY_CURSORRULES_MARKER_PAIRS,
7
+ } from "./constants.js";
8
+ import {
9
+ appendFileSafe,
10
+ readFileSafe,
11
+ writeFileSafe,
12
+ } from "./fs-utils.js";
13
+
14
+ export const CLAUDE_MD_MARKER_BEGIN = CURSORRULES_MARKER_BEGIN;
15
+ export const CLAUDE_MD_MARKER_END = CURSORRULES_MARKER_END;
16
+
17
+ export const CLAUDE_MD_BLOCK = `${CLAUDE_MD_MARKER_BEGIN}
18
+ # Execution Contract (Spec Guardrails)
19
+
20
+ When planning architecture, specs, or multi-step features, read the hub first:
21
+
22
+ - \`.claude/skills/agent-architecture.md\` — SDD hub: contract, phases, gates, complexity router
23
+ - \`.claude/skills/references/\` — phase procedures (explore, project-init, constitution, specify, discuss, design, tasks, analyze, implement, validate, converge, archive, memory, quick-mode, context-limits, lessons, sub-agents)
24
+ - \`.claude/skills/task-graph-engineering.md\` — task DAG, parallelism, verify topology
25
+ - \`.claude/skills/engineering-standards.md\` — secure coding, code quality, artifact language
26
+ - \`.claude/skills/security-review.md\` — security checklist for /verify
27
+ - Sister skills (\`appsec\`, \`qa-strategy\`, \`code-simplify\`, \`ship-ready\`, \`git-handoff\`) — load **one conditional** at a time
28
+
29
+ Deterministic gates (\`python3\`, non-zero exit means STOP):
30
+
31
+ - Scripts in \`.specs/guardrails/scripts/\` — the **agent** runs them at phase boundaries (see hub).
32
+ - Humans: \`install\` once; optional \`feature-init\`, \`project-init\`, \`doctor\`, \`classify-change\`, \`feature-status\`.
33
+ - Full CLI: \`npx @luizsantiago/spec-guardrails --help\`
34
+ - Onboarding: \`.specs/GETTING_STARTED.md\`
35
+
36
+ All project artifacts are written in English.
37
+ Persistent state: \`.specs/STATE.md\`, \`.specs/lessons.json\`, \`.specs/LESSONS.md\`.
38
+
39
+ Cursor users also get \`.cursorrules\` + \`.cursor/rules/engineering-baseline.mdc\` — same contract, different entrypoint. See \`docs/guide/Platform-parity.md\` in the package repo.
40
+ ${CLAUDE_MD_MARKER_END}
41
+ `;
42
+
43
+ const MARKER_PAIRS = [
44
+ [CLAUDE_MD_MARKER_BEGIN, CLAUDE_MD_MARKER_END],
45
+ ...LEGACY_CURSORRULES_MARKER_PAIRS,
46
+ ];
47
+
48
+ /**
49
+ * @param {string} content
50
+ */
51
+ function locateBlock(content) {
52
+ for (const [begin, endMarker] of MARKER_PAIRS) {
53
+ const start = content.indexOf(begin);
54
+ const end = content.indexOf(endMarker);
55
+ if (start !== -1 && end !== -1 && end >= start) {
56
+ return { start, end, endMarker };
57
+ }
58
+ }
59
+ return null;
60
+ }
61
+
62
+ /**
63
+ * Install or refresh `.claude/CLAUDE.md` with the Spec Guardrails contract.
64
+ *
65
+ * @param {string} cwd
66
+ */
67
+ export async function injectClaudeMd(cwd) {
68
+ const target = path.join(cwd, ".claude", "CLAUDE.md");
69
+ const expected = CLAUDE_MD_BLOCK.trim();
70
+
71
+ try {
72
+ const existing = await readFileSafe(target);
73
+ const located = locateBlock(existing);
74
+ if (located) {
75
+ const current = existing.slice(
76
+ located.start,
77
+ located.end + located.endMarker.length,
78
+ );
79
+ if (current.trim() === expected) {
80
+ return { created: false, updated: false };
81
+ }
82
+ const before = existing.slice(0, located.start);
83
+ const after = existing.slice(located.end + located.endMarker.length);
84
+ const replaced = `${before}${expected}\n${after.replace(/^\n+/, "")}`;
85
+ await writeFileSafe(
86
+ target,
87
+ replaced.endsWith("\n") ? replaced : `${replaced}\n`,
88
+ );
89
+ return { created: false, updated: true };
90
+ }
91
+
92
+ const separator = existing.endsWith("\n") ? "\n" : "\n\n";
93
+ await appendFileSafe(target, `${separator}${CLAUDE_MD_BLOCK}\n`);
94
+ return { created: false, updated: true };
95
+ } catch (err) {
96
+ if (err.code !== "ENOENT") {
97
+ throw err;
98
+ }
99
+ }
100
+
101
+ await writeFileSafe(target, `${CLAUDE_MD_BLOCK}\n`);
102
+ return { created: true, updated: false };
103
+ }
package/lib/constants.js CHANGED
@@ -97,6 +97,8 @@ export const SCRIPT_ASSETS = [
97
97
  { file: "validate_spec.py", remotePath: "scripts/validate_spec.py" },
98
98
  { file: "validate_tasks.py", remotePath: "scripts/validate_tasks.py" },
99
99
  { file: "validate_state.py", remotePath: "scripts/validate_state.py" },
100
+ { file: "validate_traceability.py", remotePath: "scripts/validate_traceability.py" },
101
+ { file: "validate_quick.py", remotePath: "scripts/validate_quick.py" },
100
102
  { file: "analyze_artifacts.py", remotePath: "scripts/analyze_artifacts.py" },
101
103
  { file: "check_commit.py", remotePath: "scripts/check_commit.py" },
102
104
  { file: "lessons.py", remotePath: "scripts/lessons.py" },
package/lib/doctor.js CHANGED
@@ -4,9 +4,9 @@ import path from "node:path";
4
4
  import { promisify } from "node:util";
5
5
 
6
6
  import { NPX, SKILL_DIRS } from "./constants.js";
7
- import { resolveScriptsDir } from "./gates.js";
7
+ import { resolvePython, resolveScriptsDir } from "./gates.js";
8
8
  import { readFileSafe } from "./fs-utils.js";
9
- import { listFeatureIds } from "./specs-utils.js";
9
+ import { listFeatureIds, readActiveFeatureFromState } from "./specs-utils.js";
10
10
 
11
11
  const execFileAsync = promisify(execFile);
12
12
 
@@ -40,44 +40,17 @@ async function pathExists(cwd, relativePath) {
40
40
  * @returns {Promise<boolean>}
41
41
  */
42
42
  async function pythonAvailable() {
43
- for (const bin of ["python3", "python"]) {
44
- try {
45
- const { stdout } = await execFileAsync(bin, ["--version"]);
46
- const match = stdout.match(/(\d+)\.(\d+)/);
47
- if (!match) {
48
- continue;
49
- }
50
- const major = Number(match[1]);
51
- const minor = Number(match[2]);
52
- if (major > 3 || (major === 3 && minor >= 10)) {
53
- return true;
54
- }
55
- } catch {
56
- // try next binary
57
- }
58
- }
59
- return false;
43
+ return (await resolvePython()) !== null;
60
44
  }
61
45
 
62
46
  /**
47
+ * Prefer canonical STATE `- Feature:`; also accept legacy `- **Active feature**:`.
48
+ *
63
49
  * @param {string} cwd
64
50
  * @returns {Promise<string | null>}
65
51
  */
66
52
  async function readActiveFeature(cwd) {
67
- try {
68
- const state = await readFileSafe(path.join(cwd, ".specs/STATE.md"));
69
- const match = state.match(/^\s*[-*]\s*\*\*Active feature\*\*:\s*(.+)$/im);
70
- if (!match) {
71
- return null;
72
- }
73
- const value = match[1].trim();
74
- if (!value || /^none|idle|—|-$/i.test(value)) {
75
- return null;
76
- }
77
- return value.replace(/^`|`$/g, "");
78
- } catch {
79
- return null;
80
- }
53
+ return readActiveFeatureFromState(cwd);
81
54
  }
82
55
 
83
56
  /**
@@ -192,12 +165,15 @@ export async function runDoctorChecks(cwd) {
192
165
  if (gatesPresent && pythonOk) {
193
166
  try {
194
167
  const script = path.join(cwd, scriptsDir, "check_commit.py");
195
- await execFileAsync("python3", [
196
- script,
197
- "--message",
198
- "chore(guardrails): doctor smoke test",
199
- ], { cwd });
200
- gateSmoke = true;
168
+ const python = await resolvePython();
169
+ if (python) {
170
+ await execFileAsync(python, [
171
+ script,
172
+ "--message",
173
+ "chore(guardrails): doctor smoke test",
174
+ ], { cwd });
175
+ gateSmoke = true;
176
+ }
201
177
  } catch {
202
178
  gateSmoke = false;
203
179
  }
@@ -309,12 +285,26 @@ export async function doctor(cwd, options = {}) {
309
285
  const suggestions = topDoctorSuggestions(checks);
310
286
  const activeFeature = await readActiveFeature(cwd);
311
287
  const executeHint = await resolveExecuteHint(cwd, activeFeature);
288
+ const pythonCheck = checks.find((check) => check.id === "python");
289
+ const pythonMissing = pythonCheck ? !pythonCheck.pass : false;
312
290
 
313
291
  if (options.json) {
314
292
  console.log(
315
- JSON.stringify({ score, checks, suggestions, executeHint }, null, 2),
293
+ JSON.stringify(
294
+ { score, checks, suggestions, executeHint, pythonMissing },
295
+ null,
296
+ 2,
297
+ ),
298
+ );
299
+ return { score, checks, suggestions, executeHint, pythonMissing };
300
+ }
301
+
302
+ if (pythonMissing) {
303
+ console.log(
304
+ "⚠ PYTHON MISSING — structural gates will not run automatically.\n" +
305
+ " Install Python 3.10+ (python3 or python on PATH), then re-run doctor.\n" +
306
+ " Until then the agent must perform the same checklists manually from skills/references/.\n",
316
307
  );
317
- return { score, checks, suggestions, executeHint };
318
308
  }
319
309
 
320
310
  console.log(`Guardrails Ready: ${score}/100\n`);
@@ -339,5 +329,5 @@ export async function doctor(cwd, options = {}) {
339
329
  console.log(`\nExecute hint:\n → ${executeHint}`);
340
330
  }
341
331
 
342
- return { score, checks, suggestions, executeHint };
332
+ return { score, checks, suggestions, executeHint, pythonMissing };
343
333
  }
@@ -0,0 +1,160 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { NPX } from "./constants.js";
5
+ import { resolveExecuteHint } from "./doctor.js";
6
+ import { readFileSafe } from "./fs-utils.js";
7
+ import {
8
+ featureDir,
9
+ readActiveFeatureFromState,
10
+ resolveFeatureId,
11
+ } from "./specs-utils.js";
12
+
13
+ /**
14
+ * @param {string} cwd
15
+ * @param {string} featureId
16
+ * @param {string} filename
17
+ * @returns {Promise<boolean>}
18
+ */
19
+ async function artifactExists(cwd, featureId, filename) {
20
+ try {
21
+ await fs.access(path.join(featureDir(featureId, cwd), filename));
22
+ return true;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * @param {string} tasksText
30
+ * @returns {{ total: number, complete: number, open: number }}
31
+ */
32
+ function summarizeTasks(tasksText) {
33
+ const total = (tasksText.match(/^#{2,6}\s*T\d+/gim) ?? []).length;
34
+ const complete = (tasksText.match(/-\s*\[x\]\s*complete\b/gi) ?? []).length;
35
+ return {
36
+ total,
37
+ complete,
38
+ open: Math.max(0, total - complete),
39
+ };
40
+ }
41
+
42
+ /**
43
+ * @param {string} validationText
44
+ * @returns {string | null}
45
+ */
46
+ function readVerdict(validationText) {
47
+ const match = validationText.match(
48
+ /^\s*[-*]?\s*\*{0,2}(?:verdict|result|status)\*{0,2}\s*:\s*\*{0,2}([A-Za-z ]+)/im,
49
+ );
50
+ return match ? match[1].trim().toUpperCase() : null;
51
+ }
52
+
53
+ /**
54
+ * @param {string} [featureArg]
55
+ * @param {{ cwd?: string }} [options]
56
+ */
57
+ export async function featureStatus(featureArg, options = {}) {
58
+ const cwd = options.cwd ?? process.cwd();
59
+ const featureId = await resolveFeatureId(
60
+ featureArg ?? (await readActiveFeatureFromState(cwd)) ?? undefined,
61
+ cwd,
62
+ );
63
+
64
+ const dir = featureDir(featureId, cwd);
65
+ try {
66
+ await fs.access(dir);
67
+ } catch {
68
+ throw new Error(`Feature directory not found: ${dir}`);
69
+ }
70
+
71
+ let phase = null;
72
+ let branch = null;
73
+ try {
74
+ const state = await readFileSafe(path.join(cwd, ".specs/STATE.md"));
75
+ phase = state.match(/^-\s*Phase:\s*(.+)$/m)?.[1]?.trim() ?? null;
76
+ branch = state.match(/^-\s*Branch:\s*(.+)$/m)?.[1]?.trim() ?? null;
77
+ } catch {
78
+ // STATE optional for status
79
+ }
80
+
81
+ const artifacts = {
82
+ spec: await artifactExists(cwd, featureId, "spec.md"),
83
+ tasks: await artifactExists(cwd, featureId, "tasks.md"),
84
+ design: await artifactExists(cwd, featureId, "design.md"),
85
+ validation: await artifactExists(cwd, featureId, "validation.md"),
86
+ taskGraph: await artifactExists(cwd, featureId, "task-graph.md"),
87
+ };
88
+
89
+ /** @type {{ total: number, complete: number, open: number } | null} */
90
+ let tasks = null;
91
+ if (artifacts.tasks) {
92
+ const text = await readFileSafe(path.join(dir, "tasks.md"));
93
+ tasks = summarizeTasks(text);
94
+ }
95
+
96
+ /** @type {string | null} */
97
+ let verdict = null;
98
+ if (artifacts.validation) {
99
+ const text = await readFileSafe(path.join(dir, "validation.md"));
100
+ verdict = readVerdict(text);
101
+ }
102
+
103
+ const executeHint = await resolveExecuteHint(cwd, featureId);
104
+
105
+ /** @type {string} */
106
+ let next;
107
+ if (!artifacts.spec) {
108
+ next = `${NPX(`validate-spec ${featureId}`)} — draft/approve spec.md first`;
109
+ } else if (!artifacts.tasks) {
110
+ next = `${NPX(`validate-spec ${featureId}`)} then /tasks`;
111
+ } else if (tasks && tasks.open > 0) {
112
+ next = executeHint ?? `${NPX(`loop-plan ${featureId}`)} — next Execute wave`;
113
+ } else if (!artifacts.validation || !verdict || !/^PASS/.test(verdict)) {
114
+ next =
115
+ executeHint ??
116
+ `${NPX(`validate-traceability ${featureId}`)} then ${NPX(`validate-state ${featureId}`)}`;
117
+ } else {
118
+ next = `${NPX(`archive-feature ${featureId}`)} — fold into domain memory`;
119
+ }
120
+
121
+ return {
122
+ featureId,
123
+ phase,
124
+ branch,
125
+ artifacts,
126
+ tasks,
127
+ verdict,
128
+ next,
129
+ executeHint,
130
+ };
131
+ }
132
+
133
+ /**
134
+ * @param {Awaited<ReturnType<typeof featureStatus>>} status
135
+ * @returns {string}
136
+ */
137
+ export function formatFeatureStatus(status) {
138
+ const artifactLine = Object.entries(status.artifacts)
139
+ .map(([name, present]) => `${present ? "✓" : "✗"} ${name}`)
140
+ .join(" ");
141
+
142
+ const lines = [
143
+ `Feature: ${status.featureId}`,
144
+ `Phase: ${status.phase ?? "—"}`,
145
+ `Branch: ${status.branch ?? "—"}`,
146
+ `Artifacts: ${artifactLine}`,
147
+ ];
148
+
149
+ if (status.tasks) {
150
+ lines.push(
151
+ `Tasks: ${status.tasks.complete}/${status.tasks.total} complete (${status.tasks.open} open)`,
152
+ );
153
+ } else {
154
+ lines.push("Tasks: —");
155
+ }
156
+
157
+ lines.push(`Validation verdict: ${status.verdict ?? "—"}`);
158
+ lines.push(`Next: ${status.next}`);
159
+ return `${lines.join("\n")}\n`;
160
+ }
package/lib/gates.js CHANGED
@@ -11,6 +11,8 @@ const GATE_SCRIPTS = {
11
11
  "validate-spec": "validate_spec.py",
12
12
  "validate-tasks": "validate_tasks.py",
13
13
  "validate-state": "validate_state.py",
14
+ "validate-traceability": "validate_traceability.py",
15
+ "validate-quick": "validate_quick.py",
14
16
  "analyze-artifacts": "analyze_artifacts.py",
15
17
  "check-commit": "check_commit.py",
16
18
  lessons: "lessons.py",
package/lib/install.js CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  DISPLAY_NAME,
13
13
  resolveAssetOverride,
14
14
  } from "./constants.js";
15
+ import { injectClaudeMd } from "./claude-md.js";
15
16
  import { injectCursorRules } from "./cursorrules.js";
16
17
  import { ensureDir, readFileSafe, writeFileIfMissing } from "./fs-utils.js";
17
18
  import { hasPython } from "./gates.js";
@@ -115,6 +116,7 @@ export async function install(options = {}) {
115
116
  }
116
117
 
117
118
  await injectCursorRules(cwd);
119
+ await injectClaudeMd(cwd);
118
120
 
119
121
  const gettingStartedCreated = await writeFileIfMissing(
120
122
  path.join(cwd, ".specs/GETTING_STARTED.md"),
@@ -85,12 +85,18 @@ export async function readActiveFeatureFromState(cwd) {
85
85
 
86
86
  try {
87
87
  const content = await readFileSafe(statePath);
88
- const match = content.match(/^-\s*Feature:\s*(.+)$/m);
88
+ // Canonical STATE_HEADER uses "- Feature:". Older fixtures used "- **Active feature**:".
89
+ const match =
90
+ content.match(/^-\s*Feature:\s*(.+)$/m) ??
91
+ content.match(/^\s*[-*]\s*\*\*Active feature\*\*:\s*(.+)$/im);
89
92
  if (!match) {
90
93
  return null;
91
94
  }
92
- const value = match[1].trim();
93
- return value === "—" ? null : value;
95
+ const value = match[1].trim().replace(/^`|`$/g, "");
96
+ if (!value || /^none|idle|—|-$/i.test(value)) {
97
+ return null;
98
+ }
99
+ return value;
94
100
  } catch {
95
101
  return null;
96
102
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luizsantiago/spec-guardrails",
3
- "version": "3.0.1",
3
+ "version": "3.1.2",
4
4
  "description": "Guardrails for AI coding agents — spec-driven phases, automatic gates, progressive skill loading (~70% fewer tokens per turn). Works in Cursor and Claude.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,7 @@
12
12
  "scripts": {
13
13
  "guardrails": "node index.js",
14
14
  "test": "npm run test:node && npm run test:gates",
15
- "test:node": "node --test test/install.test.js test/test_feature_init.test.js test/test_config.test.js test/test_archive.test.js test/test_delta_merge.test.js test/test_presets.test.js test/test_brownfield.test.js test/test_doctor.test.js test/test_token_cost.test.js test/test_next_steps.test.js",
15
+ "test:node": "node --test test/install.test.js test/test_feature_init.test.js test/test_config.test.js test/test_archive.test.js test/test_delta_merge.test.js test/test_presets.test.js test/test_brownfield.test.js test/test_doctor.test.js test/test_token_cost.test.js test/test_next_steps.test.js test/test_classify_change.test.js test/test_feature_status.test.js",
16
16
  "test:gates": "node test/run-gate-tests.mjs",
17
17
  "prepublishOnly": "npm test"
18
18
  },
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env python3
2
+ """Structural gate for Quick-mode artifacts under `.specs/quick/`.
3
+
4
+ python3 validate_quick.py .specs/quick/001-theme-persist
5
+ python3 validate_quick.py 001-theme-persist
6
+
7
+ Checks (markdown structure only):
8
+ * TASK.md present with Files / Approach / Verify fields
9
+ * Files list has 1–3 paths
10
+ * Sensitive paths (auth/payment/migration) → promote to specify
11
+ * SUMMARY.md present with Changed + Evidence (file:line or manual steps)
12
+
13
+ Does not run validate-state / discrimination sensor / REQ coverage.
14
+
15
+ Exit codes: 0 pass, 1 blocking issues, 2 usage error.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import re
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ from _common import Report, visible_markdown
26
+
27
+ GATE = "validate-quick"
28
+ QUICK_DIR = Path(".specs/quick")
29
+
30
+ FIELD = re.compile(
31
+ r"^\s*[-*]?\s*\*{0,2}(?P<key>Files|Approach|Verify|Changed|Commit|Evidence)"
32
+ r"\*{0,2}\s*:\s*(?P<value>.+?)\s*$",
33
+ re.MULTILINE | re.IGNORECASE,
34
+ )
35
+ SENSITIVE = re.compile(
36
+ r"(?:^|/)(?:auth|oauth|payment|billing|migration|migrate)(?:/|$)|"
37
+ r"(?:^|/)(?:schema|secrets?)(?:/|$)",
38
+ re.IGNORECASE,
39
+ )
40
+ EVIDENCE_LINE = re.compile(
41
+ r"[\w./\\-]+\.[A-Za-z][A-Za-z0-9]{0,9}:\d{1,6}\b|manual\b|reload\b|steps?\b",
42
+ re.IGNORECASE,
43
+ )
44
+
45
+
46
+ def resolve_quick_dir(raw: str | None, root: Path = Path(".")) -> Path:
47
+ if raw:
48
+ candidate = Path(raw).expanduser()
49
+ if candidate.is_dir():
50
+ return candidate
51
+ named = root / QUICK_DIR / raw
52
+ if named.is_dir():
53
+ return named
54
+ print(f"[{GATE}] USAGE - no such quick folder: {raw}", file=sys.stderr)
55
+ raise SystemExit(2)
56
+
57
+ base = root / QUICK_DIR
58
+ if not base.is_dir():
59
+ print(
60
+ f"[{GATE}] USAGE - {base} missing — create .specs/quick/NNN-slug/ first",
61
+ file=sys.stderr,
62
+ )
63
+ raise SystemExit(2)
64
+
65
+ folders = sorted(p for p in base.iterdir() if p.is_dir())
66
+ if len(folders) == 1:
67
+ return folders[0]
68
+ if not folders:
69
+ print(f"[{GATE}] USAGE - no quick folders under {base}", file=sys.stderr)
70
+ raise SystemExit(2)
71
+
72
+ listed = "\n".join(f" {p.name}" for p in folders)
73
+ print(
74
+ f"[{GATE}] USAGE - {len(folders)} quick folders — name one:\n{listed}",
75
+ file=sys.stderr,
76
+ )
77
+ raise SystemExit(2)
78
+
79
+
80
+ def field_map(text: str) -> dict[str, str]:
81
+ found: dict[str, str] = {}
82
+ for match in FIELD.finditer(visible_markdown(text)):
83
+ key = match.group("key").strip().lower()
84
+ found[key] = match.group("value").strip()
85
+ return found
86
+
87
+
88
+ def split_files(value: str) -> list[str]:
89
+ parts = re.split(r"[,;\n]+", value)
90
+ return [p.strip().strip("`") for p in parts if p.strip()]
91
+
92
+
93
+ def build_report(quick_dir: Path) -> Report:
94
+ report = Report(gate=GATE, target=str(quick_dir))
95
+
96
+ task_path = quick_dir / "TASK.md"
97
+ summary_path = quick_dir / "SUMMARY.md"
98
+
99
+ if not task_path.is_file() or not task_path.read_text(encoding="utf-8").strip():
100
+ report.error("TASK.md missing or empty")
101
+ return report
102
+
103
+ task_fields = field_map(task_path.read_text(encoding="utf-8"))
104
+ for required in ("files", "approach", "verify"):
105
+ if required not in task_fields or not task_fields[required]:
106
+ report.error(f"TASK.md missing required field: {required.title()}")
107
+
108
+ files = split_files(task_fields.get("files", ""))
109
+ if files:
110
+ report.ok(f"{len(files)} file(s) listed")
111
+ if len(files) > 3:
112
+ report.error(
113
+ f"{len(files)} files listed — Quick max is 3; promote to /specify"
114
+ )
115
+ for path in files:
116
+ if SENSITIVE.search(path.replace("\\", "/")):
117
+ report.error(
118
+ f"sensitive path '{path}' — promote to full pipeline + security-review"
119
+ )
120
+ elif "files" in task_fields:
121
+ report.error("Files field is empty")
122
+
123
+ if not summary_path.is_file() or not summary_path.read_text(encoding="utf-8").strip():
124
+ report.error("SUMMARY.md missing or empty")
125
+ return report
126
+
127
+ summary_fields = field_map(summary_path.read_text(encoding="utf-8"))
128
+ if "changed" not in summary_fields or not summary_fields["changed"]:
129
+ report.error("SUMMARY.md missing Changed field")
130
+ else:
131
+ report.ok("Changed field present")
132
+
133
+ evidence = summary_fields.get("evidence", "")
134
+ if not evidence:
135
+ report.error("SUMMARY.md missing Evidence field")
136
+ elif not EVIDENCE_LINE.search(evidence):
137
+ report.error(
138
+ "Evidence must cite file:line or explicit manual verification steps"
139
+ )
140
+ else:
141
+ report.ok("Evidence recorded")
142
+
143
+ return report
144
+
145
+
146
+ def main(argv: list[str] | None = None) -> int:
147
+ parser = argparse.ArgumentParser(description="Validate Quick-mode artifacts")
148
+ parser.add_argument(
149
+ "target",
150
+ nargs="?",
151
+ help="quick folder name or path under .specs/quick/",
152
+ )
153
+ args = parser.parse_args(argv)
154
+ quick_dir = resolve_quick_dir(args.target)
155
+ return build_report(quick_dir).emit()
156
+
157
+
158
+ if __name__ == "__main__":
159
+ sys.exit(main())
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env python3
2
+ """REQ → tasks → validation coverage chain (structural).
3
+
4
+ Run after Tasks, and again when validation.md exists:
5
+
6
+ python3 validate_traceability.py auth
7
+ python3 validate_traceability.py .specs/features/003-chat-system
8
+
9
+ Checks (markdown structure only — not that tests assert criteria):
10
+ * every spec requirement ID appears in at least one task Requirement field
11
+ * every task Requirement references a known spec requirement ID
12
+ * when validation.md exists: every spec REQ has test file:line on the same
13
+ coverage line (evidence pattern only)
14
+
15
+ Does not require Verdict PASS, discrimination sensor, or open-gap checks —
16
+ use validate-state for completion.
17
+
18
+ Exit codes: 0 pass, 1 blocking issues, 2 usage error.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import re
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ from _common import (
29
+ Report,
30
+ requirement_ids,
31
+ resolve_feature_dir,
32
+ visible_markdown,
33
+ )
34
+
35
+ GATE = "validate-traceability"
36
+
37
+ TASK_FIELD = re.compile(
38
+ r"^\s*[-*]?\s*\*{0,2}(?P<key>[A-Za-z][A-Za-z ]+?)\*{0,2}\s*:\s*(?P<value>.+?)\s*$",
39
+ re.MULTILINE,
40
+ )
41
+ REQUIREMENT_REF = re.compile(r"\b[A-Z][A-Z0-9]{1,9}-\d{2,4}\b")
42
+ EVIDENCE = re.compile(r"[\w./\\-]+\.[A-Za-z][A-Za-z0-9]{0,9}:\d{1,6}\b")
43
+ URL = re.compile(r"\b[a-z][a-z0-9+.-]*://\S+", re.IGNORECASE)
44
+ TEST_EVIDENCE = re.compile(
45
+ r"(?:^|/)(?:tests?|__tests__|spec)(?:/|$)|[._-](?:test|spec)\.|test_[^/]+\.",
46
+ re.IGNORECASE,
47
+ )
48
+
49
+
50
+ def task_requirement_ids(tasks_text: str) -> set[str]:
51
+ ids: set[str] = set()
52
+ for match in TASK_FIELD.finditer(tasks_text):
53
+ if match.group("key").strip().lower() != "requirement":
54
+ continue
55
+ ids.update(REQUIREMENT_REF.findall(match.group("value")))
56
+ return ids
57
+
58
+
59
+ def find_test_evidence(text: str) -> list[str]:
60
+ visible = visible_markdown(text)
61
+ hits = EVIDENCE.findall(URL.sub(" ", visible))
62
+ return [hit for hit in hits if TEST_EVIDENCE.search(hit.replace("\\", "/"))]
63
+
64
+
65
+ def requirement_evidence_gaps(spec_ids: list[str], validation: str) -> list[str]:
66
+ missing: list[str] = []
67
+ visible = visible_markdown(validation)
68
+ for requirement_id in spec_ids:
69
+ covered = False
70
+ for line in visible.splitlines():
71
+ if requirement_id not in line:
72
+ continue
73
+ if find_test_evidence(line):
74
+ covered = True
75
+ break
76
+ if not covered:
77
+ missing.append(requirement_id)
78
+ return missing
79
+
80
+
81
+ def read_optional(feature_dir: Path, filename: str) -> str | None:
82
+ path = feature_dir / filename
83
+ if not path.is_file():
84
+ return None
85
+ text = path.read_text(encoding="utf-8")
86
+ return text if text.strip() else None
87
+
88
+
89
+ def build_report(feature_dir: Path) -> Report:
90
+ report = Report(gate=GATE, target=str(feature_dir))
91
+
92
+ spec_text = read_optional(feature_dir, "spec.md")
93
+ tasks_text = read_optional(feature_dir, "tasks.md")
94
+ validation = read_optional(feature_dir, "validation.md")
95
+
96
+ if not spec_text:
97
+ report.error("spec.md missing or empty — cannot check REQ traceability")
98
+ return report
99
+
100
+ spec_ids = requirement_ids(spec_text)
101
+ if not spec_ids:
102
+ report.error(
103
+ "no requirement headings found - use '### REQ-001: Title' (prefix-NNN)"
104
+ )
105
+ return report
106
+
107
+ report.ok(f"{len(spec_ids)} requirement ID(s) in spec.md")
108
+
109
+ if not tasks_text:
110
+ report.error("tasks.md missing or empty — every REQ needs a task Requirement")
111
+ return report
112
+
113
+ covered = task_requirement_ids(tasks_text)
114
+ report.ok(f"{len(covered)} requirement ID(s) referenced in tasks.md")
115
+
116
+ missing_tasks = [req for req in spec_ids if req not in covered]
117
+ if missing_tasks:
118
+ report.error(
119
+ "requirements without task coverage: " + ", ".join(missing_tasks)
120
+ )
121
+ else:
122
+ report.ok("every spec requirement is referenced by a task")
123
+
124
+ orphan_tasks = sorted(covered - set(spec_ids))
125
+ if orphan_tasks:
126
+ report.error(
127
+ "tasks reference unknown requirement IDs: " + ", ".join(orphan_tasks)
128
+ )
129
+ else:
130
+ report.ok("every task Requirement maps to a spec requirement")
131
+
132
+ if validation is None:
133
+ report.warn(
134
+ "validation.md missing — coverage evidence check skipped "
135
+ "(run again after /verify drafts validation.md)"
136
+ )
137
+ return report
138
+
139
+ gaps = requirement_evidence_gaps(spec_ids, validation)
140
+ if gaps:
141
+ for requirement_id in gaps:
142
+ report.error(
143
+ f"{requirement_id} has no test file:line on the same coverage line"
144
+ )
145
+ else:
146
+ report.ok("every spec requirement has test evidence on a coverage line")
147
+
148
+ return report
149
+
150
+
151
+ def main(argv: list[str] | None = None) -> int:
152
+ parser = argparse.ArgumentParser(
153
+ description="Validate REQ → tasks → validation coverage chain"
154
+ )
155
+ parser.add_argument(
156
+ "feature",
157
+ nargs="?",
158
+ help="feature name, feature directory, or path to spec.md",
159
+ )
160
+ args = parser.parse_args(argv)
161
+
162
+ feature_dir = resolve_feature_dir(args.feature, GATE)
163
+ return build_report(feature_dir).emit()
164
+
165
+
166
+ if __name__ == "__main__":
167
+ sys.exit(main())
@@ -101,7 +101,7 @@ Complexity determines depth. Do not run every phase on every change.
101
101
 
102
102
  | Tier | Scope | Path |
103
103
  | --- | --- | --- |
104
- | **Quick** | ≤3 files, no design decisions, no new dependencies | `references/quick-mode.md` — describe, implement, verify, commit |
104
+ | **Quick** | ≤3 files, no design decisions, no new dependencies | `references/quick-mode.md` — describe, implement, verify, commit; gate: `validate-quick` |
105
105
  | **Simple** | 2–5 files, localized change | Specify → Execute → Verify |
106
106
  | **Medium** | New feature, <10 tasks | Specify → Tasks → Execute → Verify |
107
107
  | **Complex** | New architecture, API surface, infra | Specify → Discuss → Design → Tasks → Execute → Verify |
@@ -57,7 +57,11 @@ Announce the switch — do not silently expand a quick task into a feature.
57
57
  Quick mode compresses ceremony, not carelessness — but it is **not** the full Verify gate:
58
58
 
59
59
  - Prove the change with a test or an explicit, documented manual check.
60
- - Record evidence in `SUMMARY.md` (`file:line` or the exact manual steps). That is a **process** requirement; `validate_state.py` does not run on Quick artifacts.
60
+ - Record evidence in `SUMMARY.md` (`file:line` or the exact manual steps). Run the Quick structural gate:
61
+ ```bash
62
+ npx @luizsantiago/spec-guardrails validate-quick 001-theme-persist
63
+ ```
64
+ `validate_state.py` does **not** run on Quick artifacts — that gate is for full-pipeline features.
61
65
  - Blast radius still applies — local commit only; `git push` and deploy need an explicit go-ahead.
62
66
  - If any Quick guardrail is exceeded, stop and route to `specify.md` (see When NOT to Use).
63
67
 
@@ -43,7 +43,9 @@ Type these in **Cursor or Claude Code**. They load phase procedures from `.curso
43
43
  | `install` | First time or upgrade |
44
44
  | `project-init` | Brownfield repo (optional) |
45
45
  | `doctor` | Install looks broken |
46
- | `validate-spec` / `validate-state` | Double-check gates manually |
46
+ | `classify-change` / `feature-status` | Pick a tier or see next step |
47
+ | `validate-spec` / `validate-traceability` / `validate-state` | Double-check feature gates |
48
+ | `validate-quick` | Double-check a Quick TASK/SUMMARY |
47
49
 
48
50
  Everything else (`loop-plan`, `validate-tasks`, `check-commit`, …) is normally run **by the agent**.
49
51