@luizsantiago/spec-guardrails 3.2.0 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -9,10 +9,12 @@ import { PACKAGE_VERSION, CLI_NAME } from "./lib/constants.js";
9
9
  import { phaseContext } from "./lib/config.js";
10
10
  import { doctor } from "./lib/doctor.js";
11
11
  import {
12
- checkPathScope,
13
12
  formatPolicyStatus,
14
13
  loadExecutionPolicy,
15
14
  loadPolicyState,
15
+ recordAgentRun,
16
+ recordTaskRetry,
17
+ resolvePathCheck,
16
18
  savePolicyState,
17
19
  } from "./lib/execution-policy.js";
18
20
  import { featureInit } from "./lib/feature.js";
@@ -21,7 +23,9 @@ import { GATE_COMMANDS, AUX_COMMANDS, runGate, runGuardrailsScript } from "./lib
21
23
  import { install } from "./lib/install.js";
22
24
  import {
23
25
  cleanupWorkspaces,
26
+ formatWorkspaceList,
24
27
  formatWorkspaceResults,
28
+ listWorkspaces,
25
29
  prepareWorkspaces,
26
30
  } from "./lib/workspace-isolation.js";
27
31
  import {
@@ -70,13 +74,20 @@ Commands:
70
74
  [--json] Machine-readable output
71
75
  workspace-cleanup <feature> Remove isolated worktrees for a feature
72
76
  [--tasks T1,T2] Limit cleanup to specific tasks
73
- [--force] Force-remove dirty worktrees
77
+ [--force] Force-remove dirty worktrees (recovery after worker FAIL)
78
+ [--json] Machine-readable output
79
+ workspace-list <feature> List isolated worktrees for a feature
74
80
  [--json] Machine-readable output
75
81
  execution-policy status Show configured budgets, scope, and runtime counters
76
82
  [--json] Machine-readable output
77
83
  execution-policy check-path <path> Check whether a relative path is allowed by scope policy
78
84
  [--json] Machine-readable output
79
- execution-policy record-retry <task> Increment retry counter for a task id
85
+ execution-policy record-retry <task> Increment retry counter for a task id (blocks at limit)
86
+ execution-policy record-run Increment agent-run counter (blocks at budget)
87
+ memory-index rebuild Rebuild SQLite memory index from .specs/ artifacts
88
+ memory-query --from <id> Bounded context package from the knowledge graph
89
+ [--depth N] Traversal depth (default 2)
90
+ [--json] Machine-readable output
80
91
  validate-spec [spec.md|feature] Closure gate for a feature spec
81
92
  analyze-artifacts [feature] Cross-artifact consistency before task approval
82
93
  validate-tasks [tasks.md|feature] Granularity gate for a task breakdown
@@ -86,7 +97,7 @@ Commands:
86
97
  validate-quick [quick-folder] Quick-mode TASK.md / SUMMARY.md structural gate
87
98
  validate-state [feature] Completion gate before declaring a feature done
88
99
  check-commit --message "<msg>" Conventional Commits gate
89
- lessons <add|list|penalize|prune|status> Lessons engine
100
+ lessons <add|list|penalize|prune|promote|graduate|status> Lessons engine
90
101
  --help Show this message
91
102
  --version Print the package version
92
103
  `;
@@ -426,6 +437,30 @@ if (command === "--version" || command === "-v" || command === "version") {
426
437
  console.error(`❌ ${err.message}`);
427
438
  process.exit(1);
428
439
  }
440
+ } else if (command === "workspace-list") {
441
+ try {
442
+ let json = false;
443
+ const positional = [];
444
+
445
+ for (const arg of args) {
446
+ if (arg === "--json") {
447
+ json = true;
448
+ } else {
449
+ positional.push(arg);
450
+ }
451
+ }
452
+
453
+ const featureId = positional[0];
454
+ if (!featureId) {
455
+ throw new Error("Usage: workspace-list <feature> [--json]");
456
+ }
457
+
458
+ const workspaces = await listWorkspaces(process.cwd(), featureId);
459
+ process.stdout.write(formatWorkspaceList(workspaces, { json, featureId }));
460
+ } catch (err) {
461
+ console.error(`❌ ${err.message}`);
462
+ process.exit(1);
463
+ }
429
464
  } else if (command === "execution-policy") {
430
465
  try {
431
466
  const sub = args[0];
@@ -451,33 +486,57 @@ if (command === "--version" || command === "-v" || command === "version") {
451
486
  if (!relativePath) {
452
487
  throw new Error("Usage: execution-policy check-path <relative-path>");
453
488
  }
454
- const result = checkPathScope(relativePath, policy);
489
+ const result = resolvePathCheck(relativePath, policy);
455
490
  if (json) {
456
491
  console.log(JSON.stringify({ path: relativePath, ...result }, null, 2));
457
492
  } else {
458
- console.log(
459
- `${relativePath}: ${result.allowed ? "allowed" : "blocked"} (${result.reason})`,
460
- );
493
+ const label = result.allowed
494
+ ? "allowed"
495
+ : result.severity === "warning"
496
+ ? "blocked (warn)"
497
+ : "blocked";
498
+ console.log(`${relativePath}: ${label} (${result.reason})`);
461
499
  }
462
- if (!result.allowed) {
463
- process.exit(1);
500
+ if (result.exitCode !== 0) {
501
+ process.exit(result.exitCode);
464
502
  }
465
503
  } else if (sub === "record-retry") {
466
504
  const taskId = rest[0];
467
505
  if (!taskId) {
468
506
  throw new Error("Usage: execution-policy record-retry <task-id>");
469
507
  }
470
- state.retries[taskId] = (state.retries[taskId] ?? 0) + 1;
471
- state.iterations += 1;
472
- await savePolicyState(cwd, state);
508
+ const recorded = recordTaskRetry(state, taskId, policy);
509
+ if (!recorded.ok) {
510
+ console.error(`❌ ${recorded.message}`);
511
+ process.exit(1);
512
+ }
513
+ await savePolicyState(cwd, recorded.state);
473
514
  if (json) {
474
- console.log(JSON.stringify({ taskId, retries: state.retries[taskId], state }, null, 2));
515
+ console.log(
516
+ JSON.stringify({ taskId, retries: recorded.retries, state: recorded.state }, null, 2),
517
+ );
475
518
  } else {
476
- console.log(`Recorded retry for ${taskId}: ${state.retries[taskId]}`);
519
+ console.log(`Recorded retry for ${taskId}: ${recorded.retries}`);
520
+ }
521
+ } else if (sub === "record-run") {
522
+ const recorded = recordAgentRun(state, policy);
523
+ if (!recorded.ok) {
524
+ console.error(`❌ ${recorded.message}`);
525
+ process.exit(1);
526
+ }
527
+ await savePolicyState(cwd, recorded.state);
528
+ if (json) {
529
+ console.log(
530
+ JSON.stringify({ agent_runs: recorded.state.agent_runs, state: recorded.state }, null, 2),
531
+ );
532
+ } else {
533
+ console.log(
534
+ `Recorded agent run: ${recorded.state.agent_runs}/${policy.budget.max_agent_runs}`,
535
+ );
477
536
  }
478
537
  } else {
479
538
  throw new Error(
480
- "Usage: execution-policy status | check-path <path> | record-retry <task>",
539
+ "Usage: execution-policy status | check-path <path> | record-retry <task> | record-run",
481
540
  );
482
541
  }
483
542
  } catch (err) {
@@ -536,7 +595,8 @@ if (command === "--version" || command === "-v" || command === "version") {
536
595
  console.error(`❌ ${err.message}`);
537
596
  process.exit(1);
538
597
  }
539
- } else if (AUX_COMMANDS.includes(command)) { try {
598
+ } else if (AUX_COMMANDS.includes(command)) {
599
+ try {
540
600
  const code = await runGuardrailsScript(command, args);
541
601
  process.exit(code);
542
602
  } catch (err) {
@@ -0,0 +1,128 @@
1
+ import { injectAgentsMd } from "./agents-md.js";
2
+ import { injectCodexAgents } from "./codex-agents.js";
3
+ import { injectCopilotInstructions } from "./copilot-instructions.js";
4
+ import { injectClaudeMd } from "./claude-md.js";
5
+ import { injectCursorRules } from "./cursorrules.js";
6
+
7
+ /**
8
+ * @typedef {{
9
+ * id: string,
10
+ * label: string,
11
+ * skillsDir: string | null,
12
+ * entryFiles: string[],
13
+ * capabilities: {
14
+ * supports_hooks: boolean,
15
+ * supports_commands: boolean,
16
+ * supports_rules: boolean,
17
+ * supports_skills: boolean,
18
+ * },
19
+ * install: (cwd: string) => Promise<void>,
20
+ * }} PlatformAdapter
21
+ */
22
+
23
+ /** @type {PlatformAdapter[]} */
24
+ export const ADAPTER_REGISTRY = [
25
+ {
26
+ id: "cursor",
27
+ label: "Cursor",
28
+ skillsDir: ".cursor/skills",
29
+ entryFiles: [".cursorrules", ".cursor/rules/engineering-baseline.mdc"],
30
+ capabilities: {
31
+ supports_hooks: true,
32
+ supports_commands: true,
33
+ supports_rules: true,
34
+ supports_skills: true,
35
+ },
36
+ install: injectCursorRules,
37
+ },
38
+ {
39
+ id: "claude",
40
+ label: "Claude Code",
41
+ skillsDir: ".claude/skills",
42
+ entryFiles: [".claude/CLAUDE.md"],
43
+ capabilities: {
44
+ supports_hooks: false,
45
+ supports_commands: true,
46
+ supports_rules: false,
47
+ supports_skills: true,
48
+ },
49
+ install: injectClaudeMd,
50
+ },
51
+ {
52
+ id: "copilot",
53
+ label: "GitHub Copilot",
54
+ skillsDir: ".github/skills",
55
+ entryFiles: [".github/copilot-instructions.md"],
56
+ capabilities: {
57
+ supports_hooks: false,
58
+ supports_commands: false,
59
+ supports_rules: false,
60
+ supports_skills: true,
61
+ },
62
+ install: injectCopilotInstructions,
63
+ },
64
+ {
65
+ id: "codex",
66
+ label: "OpenAI Codex",
67
+ skillsDir: ".codex/skills",
68
+ entryFiles: [".codex/AGENTS.md"],
69
+ capabilities: {
70
+ supports_hooks: false,
71
+ supports_commands: false,
72
+ supports_rules: false,
73
+ supports_skills: true,
74
+ },
75
+ install: injectCodexAgents,
76
+ },
77
+ {
78
+ id: "agents-md",
79
+ label: "AGENTS.md (open standard)",
80
+ skillsDir: null,
81
+ entryFiles: ["AGENTS.md"],
82
+ capabilities: {
83
+ supports_hooks: false,
84
+ supports_commands: false,
85
+ supports_rules: false,
86
+ supports_skills: false,
87
+ },
88
+ install: injectAgentsMd,
89
+ },
90
+ ];
91
+
92
+ /**
93
+ * @param {string} id
94
+ * @returns {PlatformAdapter | undefined}
95
+ */
96
+ export function getAdapter(id) {
97
+ return ADAPTER_REGISTRY.find((adapter) => adapter.id === id);
98
+ }
99
+
100
+ /** @returns {PlatformAdapter[]} */
101
+ export function listAdapters() {
102
+ return [...ADAPTER_REGISTRY];
103
+ }
104
+
105
+ /**
106
+ * @param {keyof PlatformAdapter["capabilities"]} capability
107
+ * @returns {PlatformAdapter[]}
108
+ */
109
+ export function getAdaptersWithCapability(capability) {
110
+ return ADAPTER_REGISTRY.filter((adapter) => adapter.capabilities[capability]);
111
+ }
112
+
113
+ /**
114
+ * @param {string} cwd
115
+ * @param {PlatformAdapter} [adapter]
116
+ */
117
+ export async function installAdapter(cwd, adapter) {
118
+ await adapter.install(cwd);
119
+ }
120
+
121
+ /**
122
+ * Install all registered platform adapters.
123
+ *
124
+ * @param {string} cwd
125
+ */
126
+ export async function installAllAdapters(cwd) {
127
+ await Promise.all(ADAPTER_REGISTRY.map((adapter) => installAdapter(cwd, adapter)));
128
+ }
package/lib/adapters.js CHANGED
@@ -1,17 +1,11 @@
1
- import { injectAgentsMd } from "./agents-md.js";
2
- import { injectCodexAgents } from "./codex-agents.js";
3
- import { injectCopilotInstructions } from "./copilot-instructions.js";
1
+ import { installAllAdapters } from "./adapter-registry.js";
4
2
 
5
3
  /**
6
4
  * Install shipped platform adapter entry files (Copilot, Codex, AGENTS.md).
7
- * Cursor and Claude adapters are injected separately in install.js.
5
+ * Cursor and Claude adapters are injected via the same registry during install.
8
6
  *
9
7
  * @param {string} cwd
10
8
  */
11
9
  export async function installPlatformAdapters(cwd) {
12
- return Promise.all([
13
- injectCopilotInstructions(cwd),
14
- injectAgentsMd(cwd),
15
- injectCodexAgents(cwd),
16
- ]);
10
+ return installAllAdapters(cwd);
17
11
  }
package/lib/constants.js CHANGED
@@ -108,6 +108,8 @@ export const SCRIPT_ASSETS = [
108
108
  { file: "check_commit.py", remotePath: "scripts/check_commit.py" },
109
109
  { file: "lessons.py", remotePath: "scripts/lessons.py" },
110
110
  { file: "loop_plan.py", remotePath: "scripts/loop_plan.py" },
111
+ { file: "memory_index.py", remotePath: "scripts/memory_index.py" },
112
+ { file: "memory_query.py", remotePath: "scripts/memory_query.py" },
111
113
  ];
112
114
 
113
115
  /** @type {{ file: string, remotePath: string }[]} */
@@ -235,13 +235,32 @@ export function checkBudget(state, policy) {
235
235
  return { ok: issues.length === 0, issues };
236
236
  }
237
237
 
238
+ /**
239
+ * @param {string} relativePath
240
+ * @param {ExecutionPolicy} policy
241
+ * @returns {{ allowed: boolean, exitCode: number, severity: "blocking" | "warning" | "info", reason: string }}
242
+ */
243
+ export function resolvePathCheck(relativePath, policy) {
244
+ const result = checkPathScope(relativePath, policy);
245
+ if (result.allowed) {
246
+ return { ...result, exitCode: 0 };
247
+ }
248
+
249
+ const mode = policy.escalation.on_policy_violation ?? "block";
250
+ if (mode === "warn") {
251
+ return { ...result, severity: "warning", exitCode: 0 };
252
+ }
253
+
254
+ return { ...result, exitCode: 1 };
255
+ }
256
+
238
257
  /**
239
258
  * @param {string} taskId
240
259
  * @param {ExecutionPolicyState} state
241
260
  * @param {ExecutionPolicy} policy
242
261
  * @returns {{ ok: boolean, severity?: "blocking", message?: string, retries: number }}
243
262
  */
244
- export function checkTaskRetries(taskId, state, policy) {
263
+ export function previewTaskRetry(taskId, state, policy) {
245
264
  const retries = state.retries[taskId] ?? 0;
246
265
  if (retries >= policy.budget.max_retries_per_task) {
247
266
  return {
@@ -254,6 +273,70 @@ export function checkTaskRetries(taskId, state, policy) {
254
273
  return { ok: true, retries };
255
274
  }
256
275
 
276
+ /**
277
+ * @param {string} taskId
278
+ * @param {ExecutionPolicyState} state
279
+ * @param {ExecutionPolicy} policy
280
+ * @returns {{ ok: boolean, severity?: "blocking", message?: string, retries: number }}
281
+ */
282
+ export function checkTaskRetries(taskId, state, policy) {
283
+ const preview = previewTaskRetry(taskId, state, policy);
284
+ return preview.ok
285
+ ? { ok: true, retries: preview.retries }
286
+ : {
287
+ ok: false,
288
+ severity: preview.severity,
289
+ message: preview.message,
290
+ retries: preview.retries,
291
+ };
292
+ }
293
+
294
+ /**
295
+ * @param {ExecutionPolicyState} state
296
+ * @param {ExecutionPolicy} policy
297
+ * @returns {{ ok: boolean, message?: string }}
298
+ */
299
+ export function previewAgentRun(state, policy) {
300
+ if (state.agent_runs >= policy.budget.max_agent_runs) {
301
+ return { ok: false, message: "max_agent_runs budget exhausted" };
302
+ }
303
+ return { ok: true };
304
+ }
305
+
306
+ /**
307
+ * @param {ExecutionPolicyState} state
308
+ * @param {string} taskId
309
+ * @param {ExecutionPolicy} policy
310
+ * @returns {{ ok: boolean, state: ExecutionPolicyState, retries: number, message?: string }}
311
+ */
312
+ export function recordTaskRetry(state, taskId, policy) {
313
+ const preview = previewTaskRetry(taskId, state, policy);
314
+ if (!preview.ok) {
315
+ return { ok: false, state, retries: preview.retries, message: preview.message };
316
+ }
317
+
318
+ const next = structuredClone(state);
319
+ next.retries[taskId] = (next.retries[taskId] ?? 0) + 1;
320
+ next.iterations += 1;
321
+ return { ok: true, state: next, retries: next.retries[taskId] };
322
+ }
323
+
324
+ /**
325
+ * @param {ExecutionPolicyState} state
326
+ * @param {ExecutionPolicy} policy
327
+ * @returns {{ ok: boolean, state: ExecutionPolicyState, message?: string }}
328
+ */
329
+ export function recordAgentRun(state, policy) {
330
+ const preview = previewAgentRun(state, policy);
331
+ if (!preview.ok) {
332
+ return { ok: false, state, message: preview.message };
333
+ }
334
+
335
+ const next = structuredClone(state);
336
+ next.agent_runs += 1;
337
+ return { ok: true, state: next };
338
+ }
339
+
257
340
  /**
258
341
  * @param {ExecutionPolicy} policy
259
342
  * @param {ExecutionPolicyState} state
package/lib/gates.js CHANGED
@@ -41,6 +41,8 @@ const GATE_SCRIPTS = {
41
41
 
42
42
  const AUX_SCRIPTS = {
43
43
  "loop-plan": "loop_plan.py",
44
+ "memory-index": "memory_index.py",
45
+ "memory-query": "memory_query.py",
44
46
  };
45
47
 
46
48
  const GUARDRAILS_SCRIPTS = { ...GATE_SCRIPTS, ...AUX_SCRIPTS };
package/lib/install.js CHANGED
@@ -13,8 +13,6 @@ import {
13
13
  resolveAssetOverride,
14
14
  } from "./constants.js";
15
15
  import { installPlatformAdapters } from "./adapters.js";
16
- import { injectClaudeMd } from "./claude-md.js";
17
- import { injectCursorRules } from "./cursorrules.js";
18
16
  import { ensureDir, readFileSafe, writeFileIfMissing } from "./fs-utils.js";
19
17
  import { hasPython } from "./gates.js";
20
18
  import { initGuardrailsMemory } from "./memory.js";
@@ -117,8 +115,6 @@ export async function install(options = {}) {
117
115
  }
118
116
 
119
117
  log("🔗 Installing platform adapters...");
120
- await injectCursorRules(cwd);
121
- await injectClaudeMd(cwd);
122
118
  await installPlatformAdapters(cwd);
123
119
  log("✅ Adapters → .cursorrules, CLAUDE.md, copilot-instructions.md, AGENTS.md, .codex/AGENTS.md");
124
120
 
@@ -0,0 +1,59 @@
1
+ import path from "node:path";
2
+
3
+ import { GUARDRAILS_SCRIPTS_DIR } from "./constants.js";
4
+ import { runGuardrailsScript } from "./gates.js";
5
+
6
+ export const MEMORY_DB_PATH = ".specs/memory/memory.db";
7
+
8
+ /**
9
+ * Rebuild the SQLite memory index from `.specs/` markdown artifacts.
10
+ *
11
+ * @param {{ cwd?: string }} [options]
12
+ * @returns {Promise<number>} exit code
13
+ */
14
+ export async function rebuildMemoryIndex(options = {}) {
15
+ const cwd = options.cwd ?? process.cwd();
16
+ return runGuardrailsScript("memory-index", ["rebuild"], { cwd });
17
+ }
18
+
19
+ /**
20
+ * Query the knowledge graph for a bounded context package.
21
+ *
22
+ * @param {{ from: string, depth?: number, json?: boolean, cwd?: string }} options
23
+ * @returns {Promise<number>} exit code
24
+ */
25
+ export async function queryMemory(options) {
26
+ const cwd = options.cwd ?? process.cwd();
27
+ const args = ["--from", options.from];
28
+ if (options.depth !== undefined) {
29
+ args.push("--depth", String(options.depth));
30
+ }
31
+ if (options.json) {
32
+ args.push("--json");
33
+ }
34
+ return runGuardrailsScript("memory-query", args, { cwd });
35
+ }
36
+
37
+ /**
38
+ * @param {string} [cwd]
39
+ * @returns {string}
40
+ */
41
+ export function memoryDbPath(cwd = process.cwd()) {
42
+ return path.join(cwd, MEMORY_DB_PATH);
43
+ }
44
+
45
+ /**
46
+ * @returns {string}
47
+ */
48
+ export function memoryDbRelativePath() {
49
+ return MEMORY_DB_PATH;
50
+ }
51
+
52
+ /**
53
+ * Relative scripts directory for memory tooling diagnostics.
54
+ *
55
+ * @returns {string}
56
+ */
57
+ export function memoryScriptsDir() {
58
+ return GUARDRAILS_SCRIPTS_DIR;
59
+ }
@@ -137,6 +137,39 @@ export async function cleanupWorkspaces(cwd, { featureId, taskIds, force = false
137
137
  return results;
138
138
  }
139
139
 
140
+ /**
141
+ * @param {string} cwd
142
+ * @param {string} featureId
143
+ * @returns {Promise<Array<{ taskId: string, path: string }>>}
144
+ */
145
+ export async function listWorkspaces(cwd, featureId) {
146
+ const paths = await listWorkspacePaths(cwd, featureId);
147
+ return paths.map((wtPath) => ({
148
+ taskId: path.basename(wtPath),
149
+ path: wtPath,
150
+ }));
151
+ }
152
+
153
+ /**
154
+ * @param {Awaited<ReturnType<typeof listWorkspaces>>} workspaces
155
+ * @param {{ json?: boolean, featureId?: string }} [options]
156
+ */
157
+ export function formatWorkspaceList(workspaces, options = {}) {
158
+ if (options.json) {
159
+ return JSON.stringify({ feature: options.featureId, workspaces }, null, 2);
160
+ }
161
+
162
+ const lines = ["Workspace list:"];
163
+ if (!workspaces.length) {
164
+ lines.push(" (none)");
165
+ } else {
166
+ for (const item of workspaces) {
167
+ lines.push(` ${item.taskId}: ${item.path}`);
168
+ }
169
+ }
170
+ return `${lines.join("\n")}\n`;
171
+ }
172
+
140
173
  /**
141
174
  * @param {ReturnType<typeof prepareWorkspaces> extends Promise<infer T> ? T : never} results
142
175
  * @param {{ json?: boolean }} [options]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luizsantiago/spec-guardrails",
3
- "version": "3.2.0",
3
+ "version": "3.2.1",
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 test/test_specs_utils.test.js test/test_validation_verdict.test.js test/test_execution_policy.test.js test/test_workspace_isolation.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 test/test_validation_verdict.test.js test/test_execution_policy.test.js test/test_workspace_isolation.test.js test/test_adapter_registry.test.js",
16
16
  "test:gates": "node test/run-gate-tests.mjs",
17
17
  "prepublishOnly": "npm test"
18
18
  },
@@ -47,8 +47,10 @@ Gates live in `.specs/guardrails/scripts/` (Python 3). **The agent runs them** a
47
47
  | `feature-init "…"` | Optional — or ask the agent to `/specify` |
48
48
  | `project-init` | Optional — brownfield repo with existing code |
49
49
  | `doctor` | Install looks broken |
50
+ | `workspace-list <feature>` | Inspect parallel worktrees before cleanup |
51
+ | `execution-policy status` | Check budgets and scope before expanding Execute |
50
52
 
51
- Everything else (`validate-spec`, `validate-tasks`, `check-commit`, …) is normally run **by the agent**, not memorized from this file. Full list: `npx @luizsantiago/spec-guardrails --help` or `.specs/GETTING_STARTED.md`.
53
+ Everything else (`validate-spec`, `validate-tasks`, `check-commit`, `workspace-prepare`, `execution-policy check-path`, …) is normally run **by the agent**, not memorized from this file. Full list: `npx @luizsantiago/spec-guardrails --help` or `.specs/GETTING_STARTED.md`.
52
54
 
53
55
  Non-zero gate exit = STOP. Without Python, the agent performs the same checks manually.
54
56
 
@@ -46,6 +46,15 @@ STATE_FEATURE = re.compile(
46
46
  r"^\s*-\s*Feature:\s*(.+)$",
47
47
  re.IGNORECASE | re.MULTILINE,
48
48
  )
49
+ TASK_HEADING = re.compile(
50
+ r"^#{2,6}\s*(?P<id>T\d{1,6})\s*[:\-–]?\s*(?P<title>.*)$",
51
+ re.MULTILINE | re.IGNORECASE,
52
+ )
53
+ VERIFY_HINT = re.compile(
54
+ r"\b(test strategy|verification|validate\.md|/verify|acceptance test)\b",
55
+ re.IGNORECASE,
56
+ )
57
+ MEDIUM_TASK_FLOOR = 5
49
58
 
50
59
 
51
60
  def git_branch(root: Path) -> str | None:
@@ -121,6 +130,20 @@ def build_report(feature_dir: Path, root: Path) -> Report:
121
130
  else:
122
131
  report.warn("tasks.md missing or empty — task coverage checks skipped")
123
132
 
133
+ task_count = len(TASK_HEADING.findall(tasks_text or ""))
134
+ medium_plus = task_count >= MEDIUM_TASK_FLOOR
135
+
136
+ if medium_plus and design_text is None:
137
+ report.warn(
138
+ f"{task_count} tasks — design.md recommended for Medium+ features"
139
+ )
140
+
141
+ plan_text = "\n".join(filter(None, [spec_text, design_text]))
142
+ if medium_plus and plan_text and not VERIFY_HINT.search(plan_text):
143
+ report.warn(
144
+ "no verification or test strategy mention in spec/design — add verify plan before Execute"
145
+ )
146
+
124
147
  if design_text is not None and tasks_text:
125
148
  visible_design = visible_markdown(design_text).strip()
126
149
  if len(visible_design.splitlines()) < 5: