@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 +5 -1
- package/index.js +60 -1
- package/lib/archive.js +10 -0
- package/lib/classify-change.js +127 -0
- package/lib/claude-md.js +103 -0
- package/lib/constants.js +2 -0
- package/lib/doctor.js +32 -42
- package/lib/feature-status.js +160 -0
- package/lib/gates.js +2 -0
- package/lib/install.js +2 -0
- package/lib/specs-utils.js +9 -3
- package/package.json +2 -2
- package/scripts/validate_quick.py +159 -0
- package/scripts/validate_traceability.py +167 -0
- package/skills/agent-architecture.md +1 -1
- package/skills/references/quick-mode.md +5 -1
- package/templates/GETTING_STARTED.md +3 -1
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.
|
|
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 (
|
|
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
|
+
}
|
package/lib/claude-md.js
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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(
|
|
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"),
|
package/lib/specs-utils.js
CHANGED
|
@@ -85,12 +85,18 @@ export async function readActiveFeatureFromState(cwd) {
|
|
|
85
85
|
|
|
86
86
|
try {
|
|
87
87
|
const content = await readFileSafe(statePath);
|
|
88
|
-
|
|
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
|
-
|
|
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.
|
|
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).
|
|
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
|
-
| `
|
|
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
|
|