@luizsantiago/spec-guardrails 3.1.8 → 3.1.10
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 +3 -0
- package/lib/archive.js +6 -2
- package/lib/brownfield.js +2 -13
- package/lib/doctor.js +3 -3
- package/lib/gates.js +16 -3
- package/lib/slug-utils.js +40 -0
- package/lib/specs-utils.js +46 -8
- package/package.json +2 -2
- package/scripts/_common.py +45 -4
- package/scripts/validate_quick.py +43 -16
- package/skills/agent-architecture.md +3 -3
- package/templates/GETTING_STARTED.md +3 -2
package/README.md
CHANGED
|
@@ -91,11 +91,14 @@ Install Python when you want gates to fire; run `doctor` to confirm Brakes are a
|
|
|
91
91
|
| --- | --- | --- | --- |
|
|
92
92
|
| Intent exists before code | `validate-spec` | Brakes | Hard gate |
|
|
93
93
|
| Tasks derive from requirements | `analyze-artifacts` | Brakes | Hard gate |
|
|
94
|
+
| Task shape and graph when needed | `validate-tasks` | Brakes | Hard gate |
|
|
94
95
|
| Requirements stay traceable | `validate-traceability` | Brakes | Hard gate |
|
|
96
|
+
| Quick evidence is complete | `validate-quick` | Brakes | Hard gate |
|
|
95
97
|
| Dependencies respected in Execute | `loop-plan` | Brakes | Hard gate |
|
|
96
98
|
| Parallel work is file-safe | `task-graph.md` + `validate-tasks` | Process + Brakes | Artifact + gate |
|
|
97
99
|
| Completion cites evidence | `validate-state` | Brakes | Hard gate |
|
|
98
100
|
| Commits follow policy | `check-commit` | Brakes | Hard gate |
|
|
101
|
+
| Lessons grounded after FAIL | `lessons` | Brakes | Hard gate |
|
|
99
102
|
| Verification is independent | `/verify` + `validate.md` | Process | Phase skill |
|
|
100
103
|
| Knowledge survives chats | `.specs/` + `archive-feature` | Process | Install + CLI |
|
|
101
104
|
|
package/lib/archive.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "./delta-merge.js";
|
|
9
9
|
import { ensureDir, readFileSafe, writeFileSafe } from "./fs-utils.js";
|
|
10
10
|
import { runGate } from "./gates.js";
|
|
11
|
+
import { assertSafeDomainSlug } from "./slug-utils.js";
|
|
11
12
|
import {
|
|
12
13
|
featureDir,
|
|
13
14
|
readFeatureArtifact,
|
|
@@ -112,7 +113,8 @@ async function resetState(cwd) {
|
|
|
112
113
|
*/
|
|
113
114
|
export function inferDomainFromFeature(featureId) {
|
|
114
115
|
const match = /^(\d{3})-(.+)$/.exec(featureId);
|
|
115
|
-
|
|
116
|
+
const slug = match ? match[2] : featureId;
|
|
117
|
+
return assertSafeDomainSlug(slug);
|
|
116
118
|
}
|
|
117
119
|
|
|
118
120
|
/**
|
|
@@ -178,7 +180,9 @@ export async function archiveFeature(featureArg, options = {}) {
|
|
|
178
180
|
let mergeSummary = [];
|
|
179
181
|
|
|
180
182
|
if (!options.skipDomainMerge) {
|
|
181
|
-
const domain = options.domain
|
|
183
|
+
const domain = options.domain
|
|
184
|
+
? assertSafeDomainSlug(options.domain)
|
|
185
|
+
: inferDomainFromFeature(featureId);
|
|
182
186
|
const domainDir = path.join(cwd, ".specs/domains", domain);
|
|
183
187
|
const domainSpecPath = path.join(domainDir, "spec.md");
|
|
184
188
|
domainRelPath = `.specs/domains/${domain}/spec.md`;
|
package/lib/brownfield.js
CHANGED
|
@@ -5,6 +5,7 @@ import { domainSpecStub } from "./delta-merge.js";
|
|
|
5
5
|
import { ensureDir, readFileSafe, writeFileIfMissing, writeFileSafe } from "./fs-utils.js";
|
|
6
6
|
import { initGuardrailsMemory } from "./memory.js";
|
|
7
7
|
import { initProjectConfig } from "./presets.js";
|
|
8
|
+
import { assertSafeDomainSlug, slugifyDomain } from "./slug-utils.js";
|
|
8
9
|
|
|
9
10
|
const SKIP_DIRS = new Set([
|
|
10
11
|
".git",
|
|
@@ -49,18 +50,6 @@ Track milestones and archived features.
|
|
|
49
50
|
|
|
50
51
|
`;
|
|
51
52
|
|
|
52
|
-
/**
|
|
53
|
-
* @param {string} name
|
|
54
|
-
* @returns {string}
|
|
55
|
-
*/
|
|
56
|
-
function slugifyDomain(name) {
|
|
57
|
-
return name
|
|
58
|
-
.toLowerCase()
|
|
59
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
60
|
-
.replace(/^-+|-+$/g, "")
|
|
61
|
-
.slice(0, 48);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
53
|
/**
|
|
65
54
|
* @param {string} cwd
|
|
66
55
|
* @returns {Promise<string>}
|
|
@@ -345,7 +334,7 @@ export async function projectInit(options = {}) {
|
|
|
345
334
|
const repoName = await readRepoName(cwd);
|
|
346
335
|
|
|
347
336
|
let domains = options.domains?.map((d) => ({
|
|
348
|
-
domain:
|
|
337
|
+
domain: assertSafeDomainSlug(d),
|
|
349
338
|
hint: `(manual: ${d})`,
|
|
350
339
|
}));
|
|
351
340
|
|
package/lib/doctor.js
CHANGED
|
@@ -18,8 +18,6 @@ const execFileAsync = promisify(execFile);
|
|
|
18
18
|
export const DOCTOR_PROCESS_CHECK_IDS = [
|
|
19
19
|
"skills-hub",
|
|
20
20
|
"specs-scaffold",
|
|
21
|
-
"config",
|
|
22
|
-
"baseline-rule",
|
|
23
21
|
"state-feature",
|
|
24
22
|
"platform-adapters",
|
|
25
23
|
];
|
|
@@ -170,14 +168,16 @@ export async function runDoctorChecks(cwd) {
|
|
|
170
168
|
weight: 10,
|
|
171
169
|
pass: await pathExists(cwd, ".specs/config.yaml"),
|
|
172
170
|
suggest: NPX("init-config --preset default"),
|
|
171
|
+
optional: true,
|
|
173
172
|
});
|
|
174
173
|
|
|
175
174
|
checks.push({
|
|
176
175
|
id: "baseline-rule",
|
|
177
|
-
label: "engineering-baseline.mdc always-on rule",
|
|
176
|
+
label: "engineering-baseline.mdc always-on rule (Cursor)",
|
|
178
177
|
weight: 8,
|
|
179
178
|
pass: await pathExists(cwd, ".cursor/rules/engineering-baseline.mdc"),
|
|
180
179
|
suggest: NPX("install"),
|
|
180
|
+
optional: true,
|
|
181
181
|
});
|
|
182
182
|
|
|
183
183
|
checks.push({
|
package/lib/gates.js
CHANGED
|
@@ -7,12 +7,25 @@ import { NPX, GUARDRAILS_SCRIPTS_DIR } from "./constants.js";
|
|
|
7
7
|
|
|
8
8
|
/** @typedef {{ command: string, args: string[] }} PythonInterpreter */
|
|
9
9
|
|
|
10
|
-
const
|
|
10
|
+
const PYTHON_CANDIDATES_UNIX = [
|
|
11
11
|
{ command: "python3", args: [] },
|
|
12
12
|
{ command: "python", args: [] },
|
|
13
13
|
{ command: "py", args: ["-3"] },
|
|
14
14
|
];
|
|
15
15
|
|
|
16
|
+
const PYTHON_CANDIDATES_WINDOWS = [
|
|
17
|
+
{ command: "py", args: ["-3"] },
|
|
18
|
+
{ command: "python", args: [] },
|
|
19
|
+
{ command: "python3", args: [] },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @returns {PythonInterpreter[]}
|
|
24
|
+
*/
|
|
25
|
+
export function getPythonCandidates() {
|
|
26
|
+
return process.platform === "win32" ? PYTHON_CANDIDATES_WINDOWS : PYTHON_CANDIDATES_UNIX;
|
|
27
|
+
}
|
|
28
|
+
|
|
16
29
|
const MIN_PYTHON = [3, 10];
|
|
17
30
|
|
|
18
31
|
const GATE_SCRIPTS = {
|
|
@@ -57,7 +70,7 @@ function run(command, args, options = {}) {
|
|
|
57
70
|
* @returns {[number, number] | null}
|
|
58
71
|
*/
|
|
59
72
|
export function parsePythonVersion(output) {
|
|
60
|
-
const match = output.match(
|
|
73
|
+
const match = output.match(/^Python\s+(\d+)\.(\d+)/im);
|
|
61
74
|
if (!match) {
|
|
62
75
|
return null;
|
|
63
76
|
}
|
|
@@ -123,7 +136,7 @@ export async function resolveScriptsDir(_cwd) {
|
|
|
123
136
|
* @returns {Promise<PythonInterpreter | null>}
|
|
124
137
|
*/
|
|
125
138
|
export async function resolvePython() {
|
|
126
|
-
for (const candidate of
|
|
139
|
+
for (const candidate of getPythonCandidates()) {
|
|
127
140
|
try {
|
|
128
141
|
const output = await readPythonVersion(candidate);
|
|
129
142
|
const version = parsePythonVersion(output);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** @typedef {string} DomainSlug */
|
|
2
|
+
|
|
3
|
+
export const FEATURE_ID_PATTERN = /^\d{3}-[a-z0-9][a-z0-9-]*$/;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {string} id
|
|
7
|
+
* @returns {boolean}
|
|
8
|
+
*/
|
|
9
|
+
export function isValidFeatureId(id) {
|
|
10
|
+
return FEATURE_ID_PATTERN.test(id);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} name
|
|
15
|
+
* @returns {DomainSlug}
|
|
16
|
+
*/
|
|
17
|
+
export function slugifyDomain(name) {
|
|
18
|
+
return name
|
|
19
|
+
.toLowerCase()
|
|
20
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
21
|
+
.replace(/^-+|-+$/g, "")
|
|
22
|
+
.slice(0, 48);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DOMAIN_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} raw
|
|
29
|
+
* @returns {DomainSlug}
|
|
30
|
+
*/
|
|
31
|
+
export function assertSafeDomainSlug(raw) {
|
|
32
|
+
if (/[/\\]|\.\./.test(raw)) {
|
|
33
|
+
throw new Error(`Invalid domain slug: ${raw}`);
|
|
34
|
+
}
|
|
35
|
+
const slug = slugifyDomain(raw);
|
|
36
|
+
if (!slug || !DOMAIN_SLUG_PATTERN.test(slug)) {
|
|
37
|
+
throw new Error(`Invalid domain slug: ${raw}`);
|
|
38
|
+
}
|
|
39
|
+
return slug;
|
|
40
|
+
}
|
package/lib/specs-utils.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
4
|
import { readFileSafe } from "./fs-utils.js";
|
|
5
|
+
import { isValidFeatureId } from "./slug-utils.js";
|
|
5
6
|
|
|
6
7
|
const FEATURES_DIR = ".specs/features";
|
|
7
8
|
|
|
@@ -15,7 +16,7 @@ export async function listFeatureIds(cwd) {
|
|
|
15
16
|
try {
|
|
16
17
|
const entries = await fs.readdir(featuresRoot, { withFileTypes: true });
|
|
17
18
|
return entries
|
|
18
|
-
.filter((entry) => entry.isDirectory())
|
|
19
|
+
.filter((entry) => entry.isDirectory() && isValidFeatureId(entry.name))
|
|
19
20
|
.map((entry) => entry.name)
|
|
20
21
|
.sort();
|
|
21
22
|
} catch (err) {
|
|
@@ -26,6 +27,38 @@ export async function listFeatureIds(cwd) {
|
|
|
26
27
|
}
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
/**
|
|
31
|
+
* @param {string} trimmed
|
|
32
|
+
* @param {string} cwd
|
|
33
|
+
* @returns {Promise<string>}
|
|
34
|
+
*/
|
|
35
|
+
async function resolveFeatureIdFromPath(trimmed, cwd) {
|
|
36
|
+
const asPath = path.resolve(cwd, trimmed);
|
|
37
|
+
const featuresRoot = path.resolve(cwd, FEATURES_DIR);
|
|
38
|
+
const relative = path.relative(featuresRoot, asPath);
|
|
39
|
+
|
|
40
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
41
|
+
throw new Error(`No such feature or path: ${trimmed}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const featureId = relative.split(path.sep)[0];
|
|
45
|
+
if (!isValidFeatureId(featureId)) {
|
|
46
|
+
throw new Error(`No such feature or path: ${trimmed}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const named = path.join(featuresRoot, featureId);
|
|
50
|
+
try {
|
|
51
|
+
const stat = await fs.stat(named);
|
|
52
|
+
if (stat.isDirectory()) {
|
|
53
|
+
return featureId;
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// fall through
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
throw new Error(`No such feature or path: ${trimmed}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
29
62
|
/**
|
|
30
63
|
* @param {string | undefined} raw
|
|
31
64
|
* @param {string} cwd
|
|
@@ -34,13 +67,13 @@ export async function listFeatureIds(cwd) {
|
|
|
34
67
|
export async function resolveFeatureId(raw, cwd) {
|
|
35
68
|
if (raw?.trim()) {
|
|
36
69
|
const trimmed = raw.trim();
|
|
37
|
-
const asPath = path.resolve(cwd, trimmed);
|
|
38
70
|
|
|
39
71
|
if (trimmed.endsWith(".md") || trimmed.includes("/") || trimmed.includes("\\")) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
72
|
+
return resolveFeatureIdFromPath(trimmed, cwd);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!isValidFeatureId(trimmed)) {
|
|
76
|
+
throw new Error(`Invalid feature id: ${trimmed} (expected NNN-slug)`);
|
|
44
77
|
}
|
|
45
78
|
|
|
46
79
|
const named = path.join(cwd, FEATURES_DIR, trimmed);
|
|
@@ -58,6 +91,9 @@ export async function resolveFeatureId(raw, cwd) {
|
|
|
58
91
|
|
|
59
92
|
const active = await readActiveFeatureFromState(cwd);
|
|
60
93
|
if (active && active !== "—") {
|
|
94
|
+
if (!isValidFeatureId(active)) {
|
|
95
|
+
throw new Error(`Invalid active feature in STATE.md: ${active}`);
|
|
96
|
+
}
|
|
61
97
|
return active;
|
|
62
98
|
}
|
|
63
99
|
|
|
@@ -108,6 +144,9 @@ export async function readActiveFeatureFromState(cwd) {
|
|
|
108
144
|
* @returns {string}
|
|
109
145
|
*/
|
|
110
146
|
export function featureDir(featureId, cwd) {
|
|
147
|
+
if (!isValidFeatureId(featureId)) {
|
|
148
|
+
throw new Error(`Invalid feature id: ${featureId} (expected NNN-slug)`);
|
|
149
|
+
}
|
|
111
150
|
return path.join(cwd, FEATURES_DIR, featureId);
|
|
112
151
|
}
|
|
113
152
|
|
|
@@ -118,6 +157,5 @@ export function featureDir(featureId, cwd) {
|
|
|
118
157
|
* @returns {Promise<string>}
|
|
119
158
|
*/
|
|
120
159
|
export async function readFeatureArtifact(featureId, cwd, filename) {
|
|
121
|
-
|
|
122
|
-
return readFileSafe(filePath);
|
|
160
|
+
return readFileSafe(path.join(featureDir(featureId, cwd), filename));
|
|
123
161
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luizsantiago/spec-guardrails",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.10",
|
|
4
4
|
"description": "Keep AI coding agents honest — specify the work, prove each step, verify independently. Process mode (Node) for flexibility; Brakes mode (Node + Python) for structural gates and a Guarantees matrix. Progressive loading, independent verify — any AI agent.",
|
|
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 test/test_classify_change.test.js test/test_feature_status.test.js test/test_agent_contract.test.js test/test_gates_python.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 test/test_agent_contract.test.js test/test_gates_python.test.js test/test_specs_utils.test.js",
|
|
16
16
|
"test:gates": "node test/run-gate-tests.mjs",
|
|
17
17
|
"prepublishOnly": "npm test"
|
|
18
18
|
},
|
package/scripts/_common.py
CHANGED
|
@@ -96,6 +96,8 @@ class Report:
|
|
|
96
96
|
|
|
97
97
|
FEATURES_DIR = Path(".specs/features")
|
|
98
98
|
|
|
99
|
+
FEATURE_ID_PATTERN = re.compile(r"^\d{3}-[a-z0-9][a-z0-9-]*$")
|
|
100
|
+
|
|
99
101
|
REQUIREMENT_ID = re.compile(
|
|
100
102
|
r"^(?P<level>#{2,6})\s*(?P<id>[A-Z][A-Z0-9]{1,9}-\d{2,4})\b",
|
|
101
103
|
re.MULTILINE,
|
|
@@ -215,6 +217,22 @@ def _fail_usage(gate: str, target: str, message: str) -> None:
|
|
|
215
217
|
sys.exit(EXIT_USAGE)
|
|
216
218
|
|
|
217
219
|
|
|
220
|
+
def is_valid_feature_id(feature_id: str) -> bool:
|
|
221
|
+
return bool(FEATURE_ID_PATTERN.match(feature_id))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _feature_dir_under_root(feature_dir: Path, root: Path) -> bool:
|
|
225
|
+
features_root = (root / FEATURES_DIR).resolve()
|
|
226
|
+
try:
|
|
227
|
+
resolved = feature_dir.resolve()
|
|
228
|
+
relative = resolved.relative_to(features_root)
|
|
229
|
+
except ValueError:
|
|
230
|
+
return False
|
|
231
|
+
if not relative.parts:
|
|
232
|
+
return False
|
|
233
|
+
return is_valid_feature_id(relative.parts[0])
|
|
234
|
+
|
|
235
|
+
|
|
218
236
|
def list_features(root: Path = Path(".")) -> list[Path]:
|
|
219
237
|
"""Return every feature directory under `.specs/features`, sorted by name."""
|
|
220
238
|
|
|
@@ -223,7 +241,11 @@ def list_features(root: Path = Path(".")) -> list[Path]:
|
|
|
223
241
|
if not base.is_dir():
|
|
224
242
|
return []
|
|
225
243
|
|
|
226
|
-
return sorted(
|
|
244
|
+
return sorted(
|
|
245
|
+
path
|
|
246
|
+
for path in base.iterdir()
|
|
247
|
+
if path.is_dir() and is_valid_feature_id(path.name)
|
|
248
|
+
)
|
|
227
249
|
|
|
228
250
|
|
|
229
251
|
def resolve_feature_dir(
|
|
@@ -231,19 +253,38 @@ def resolve_feature_dir(
|
|
|
231
253
|
) -> Path:
|
|
232
254
|
"""Resolve a feature directory from a path, a bare feature name, or context.
|
|
233
255
|
|
|
234
|
-
Accepts `.specs/features/auth/spec.md`, `.specs/features/auth`,
|
|
235
|
-
nothing at all when the project has exactly one feature.
|
|
256
|
+
Accepts `.specs/features/001-auth/spec.md`, `.specs/features/001-auth`,
|
|
257
|
+
`001-auth`, or nothing at all when the project has exactly one feature.
|
|
236
258
|
"""
|
|
237
259
|
|
|
238
260
|
if raw:
|
|
261
|
+
if raw in (".", "..") or ".." in Path(raw).parts:
|
|
262
|
+
_fail_usage(
|
|
263
|
+
gate,
|
|
264
|
+
raw,
|
|
265
|
+
f"invalid feature id: {raw} (expected NNN-slug)",
|
|
266
|
+
)
|
|
267
|
+
|
|
239
268
|
candidate = Path(raw).expanduser()
|
|
240
269
|
|
|
241
270
|
if candidate.is_file():
|
|
242
|
-
|
|
271
|
+
feature_dir = candidate.parent
|
|
272
|
+
if not _feature_dir_under_root(feature_dir, root):
|
|
273
|
+
_fail_usage(gate, raw, f"no such feature or path: {raw}")
|
|
274
|
+
return feature_dir
|
|
243
275
|
|
|
244
276
|
if candidate.is_dir():
|
|
277
|
+
if not _feature_dir_under_root(candidate, root):
|
|
278
|
+
_fail_usage(gate, raw, f"no such feature or path: {raw}")
|
|
245
279
|
return candidate
|
|
246
280
|
|
|
281
|
+
if not is_valid_feature_id(raw):
|
|
282
|
+
_fail_usage(
|
|
283
|
+
gate,
|
|
284
|
+
raw,
|
|
285
|
+
f"invalid feature id: {raw} (expected NNN-slug)",
|
|
286
|
+
)
|
|
287
|
+
|
|
247
288
|
named = root / FEATURES_DIR / raw
|
|
248
289
|
if named.is_dir():
|
|
249
290
|
return named
|
|
@@ -22,7 +22,7 @@ import re
|
|
|
22
22
|
import sys
|
|
23
23
|
from pathlib import Path
|
|
24
24
|
|
|
25
|
-
from _common import Report, visible_markdown
|
|
25
|
+
from _common import Report, is_valid_feature_id, visible_markdown
|
|
26
26
|
|
|
27
27
|
GATE = "validate-quick"
|
|
28
28
|
QUICK_DIR = Path(".specs/quick")
|
|
@@ -43,38 +43,65 @@ EVIDENCE_LINE = re.compile(
|
|
|
43
43
|
)
|
|
44
44
|
|
|
45
45
|
|
|
46
|
+
def _fail_usage(message: str, target: str = ".") -> None:
|
|
47
|
+
print(f"[{GATE}] USAGE - {message}", file=sys.stderr)
|
|
48
|
+
raise SystemExit(2)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _quick_dir_under_root(quick_dir: Path, root: Path) -> bool:
|
|
52
|
+
quick_root = (root / QUICK_DIR).resolve()
|
|
53
|
+
try:
|
|
54
|
+
resolved = quick_dir.resolve()
|
|
55
|
+
relative = resolved.relative_to(quick_root)
|
|
56
|
+
except ValueError:
|
|
57
|
+
return False
|
|
58
|
+
if not relative.parts:
|
|
59
|
+
return False
|
|
60
|
+
return is_valid_feature_id(relative.parts[0])
|
|
61
|
+
|
|
62
|
+
|
|
46
63
|
def resolve_quick_dir(raw: str | None, root: Path = Path(".")) -> Path:
|
|
47
64
|
if raw:
|
|
65
|
+
if raw in (".", "..") or ".." in Path(raw).parts:
|
|
66
|
+
_fail_usage(f"invalid quick id: {raw} (expected NNN-slug)", raw)
|
|
67
|
+
|
|
48
68
|
candidate = Path(raw).expanduser()
|
|
69
|
+
|
|
70
|
+
if candidate.is_file():
|
|
71
|
+
quick_dir = candidate.parent
|
|
72
|
+
if not _quick_dir_under_root(quick_dir, root):
|
|
73
|
+
_fail_usage(f"no such quick folder: {raw}", raw)
|
|
74
|
+
return quick_dir
|
|
75
|
+
|
|
49
76
|
if candidate.is_dir():
|
|
77
|
+
if not _quick_dir_under_root(candidate, root):
|
|
78
|
+
_fail_usage(f"no such quick folder: {raw}", raw)
|
|
50
79
|
return candidate
|
|
80
|
+
|
|
81
|
+
if not is_valid_feature_id(raw):
|
|
82
|
+
_fail_usage(f"invalid quick id: {raw} (expected NNN-slug)", raw)
|
|
83
|
+
|
|
51
84
|
named = root / QUICK_DIR / raw
|
|
52
85
|
if named.is_dir():
|
|
53
86
|
return named
|
|
54
|
-
|
|
55
|
-
|
|
87
|
+
|
|
88
|
+
_fail_usage(f"no such quick folder: {raw}", raw)
|
|
56
89
|
|
|
57
90
|
base = root / QUICK_DIR
|
|
58
91
|
if not base.is_dir():
|
|
59
|
-
|
|
60
|
-
f"[{GATE}] USAGE - {base} missing — create .specs/quick/NNN-slug/ first",
|
|
61
|
-
file=sys.stderr,
|
|
62
|
-
)
|
|
63
|
-
raise SystemExit(2)
|
|
92
|
+
_fail_usage(f"{base} missing — create .specs/quick/NNN-slug/ first", str(base))
|
|
64
93
|
|
|
65
|
-
folders = sorted(
|
|
94
|
+
folders = sorted(
|
|
95
|
+
p for p in base.iterdir() if p.is_dir() and is_valid_feature_id(p.name)
|
|
96
|
+
)
|
|
66
97
|
if len(folders) == 1:
|
|
67
98
|
return folders[0]
|
|
68
99
|
if not folders:
|
|
69
|
-
|
|
70
|
-
raise SystemExit(2)
|
|
100
|
+
_fail_usage(f"no quick folders under {base}", str(base))
|
|
71
101
|
|
|
72
102
|
listed = "\n".join(f" {p.name}" for p in folders)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
file=sys.stderr,
|
|
76
|
-
)
|
|
77
|
-
raise SystemExit(2)
|
|
103
|
+
_fail_usage(f"{len(folders)} quick folders — name one:\n{listed}", str(base))
|
|
104
|
+
raise AssertionError("unreachable")
|
|
78
105
|
|
|
79
106
|
|
|
80
107
|
def field_map(text: str) -> dict[str, str]:
|
|
@@ -80,7 +80,7 @@ EXPLORE (optional) → SPECIFY → DISCUSS (conditional) → DESIGN (optional)
|
|
|
80
80
|
| **Archive** | After Verify PASS | `references/archive.md` | `git-handoff.md` | `archive-feature` |
|
|
81
81
|
| **Converge** | On drift | `references/converge.md` | — | `analyze_artifacts.py` |
|
|
82
82
|
| **Handoff** | Yes | `references/memory.md` | `git-handoff.md` | — |
|
|
83
|
-
| **Quick** | Alternative | `references/quick-mode.md` | — | `check_commit.py` |
|
|
83
|
+
| **Quick** | Alternative | `references/quick-mode.md` | — | `check_commit.py`, `validate_quick.py` |
|
|
84
84
|
| **Context** | Always | `references/context-limits.md` | — | — |
|
|
85
85
|
| **Sub-agents** | When batched | `references/sub-agents.md` | `task-graph-engineering.md` | — |
|
|
86
86
|
| **Lessons** | On FAIL | `references/lessons.md` | — | `lessons.py` |
|
|
@@ -104,7 +104,7 @@ Complexity determines depth. Do not run every phase on every change.
|
|
|
104
104
|
|
|
105
105
|
| Tier | Scope | Path |
|
|
106
106
|
| --- | --- | --- |
|
|
107
|
-
| **Quick** | ≤3 files, no design decisions, no new dependencies | `references/quick-mode.md` — describe, implement, verify, commit;
|
|
107
|
+
| **Quick** | ≤3 files, no design decisions, no new dependencies | `references/quick-mode.md` — describe, implement, verify, commit; gates: `check_commit.py` + `validate-quick` |
|
|
108
108
|
| **Simple** | 2–5 files, localized change | Specify → Execute → Verify |
|
|
109
109
|
| **Medium** | New feature, <10 tasks | Specify → Tasks → Execute → Verify |
|
|
110
110
|
| **Complex** | New architecture, API surface, infra | Specify → Discuss → Design → Tasks → Execute → Verify |
|
|
@@ -114,7 +114,7 @@ Complexity determines depth. Do not run every phase on every change.
|
|
|
114
114
|
|
|
115
115
|
**Rules**
|
|
116
116
|
|
|
117
|
-
- **Specify and Verify are always required on the full pipeline** — you must know WHAT was asked and prove it was delivered. **Quick** is the exception: the express lane in `references/quick-mode.md` (describe → implement → verify → commit) with
|
|
117
|
+
- **Specify and Verify are always required on the full pipeline** — you must know WHAT was asked and prove it was delivered. **Quick** is the exception: the express lane in `references/quick-mode.md` (describe → implement → verify → commit) with `check_commit.py` on each commit and `validate-quick` as the close/evidence gate.
|
|
118
118
|
- **Design is skipped** when there are no architectural decisions and no new patterns.
|
|
119
119
|
- **Tasks is skipped** when there are ≤3 obvious steps.
|
|
120
120
|
- **Discuss is triggered inside Specify** when the feature touches persistence, external calls, auth, payments, concurrency, or state transitions, or when the owner's intent is ambiguous.
|
|
@@ -20,7 +20,7 @@ You installed the **Spec Guardrails**. You do **not** need to memorize CLI comma
|
|
|
20
20
|
|
|
21
21
|
## Agent commands (chat — not terminal)
|
|
22
22
|
|
|
23
|
-
Type these in **chat** in your agent environment. They load phase procedures from the installed skills tree (e.g. `.cursor/skills/references/`). In **Brakes mode**, the agent runs Python gates for you.
|
|
23
|
+
Type these in **chat** in your agent environment. They load phase procedures from the installed skills tree (e.g. `.cursor/skills/references/`, `.github/skills/references/`, `.codex/skills/references/` — use the tree your agent loads). In **Brakes mode**, the agent runs Python gates for you.
|
|
24
24
|
|
|
25
25
|
| Command | When |
|
|
26
26
|
| --- | --- |
|
|
@@ -55,7 +55,8 @@ Everything else (`loop-plan`, `validate-tasks`, `check-commit`, …) is normally
|
|
|
55
55
|
|
|
56
56
|
| Path | Purpose |
|
|
57
57
|
| --- | --- |
|
|
58
|
-
|
|
|
58
|
+
| `*/skills/agent-architecture.md` | Hub — phase map (`.cursor/`, `.claude/`, `.github/`, `.codex/`) |
|
|
59
|
+
| Root `AGENTS.md` | Open-standard entry |
|
|
59
60
|
| `.specs/STATE.md` | Where you left off |
|
|
60
61
|
| `.specs/features/` | One folder per feature |
|
|
61
62
|
| `.specs/guardrails/scripts/` | Automatic gates |
|