@luizsantiago/spec-guardrails 3.1.10 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  | **Solution** | One kit, two deliberate modes: **Process** (Node only) for a flexible spec-driven workflow; **Brakes** (Node + Python) for the **full product** — structural gates that exit non-zero when paperwork or evidence is missing. You approve specs/tasks in both. |
12
12
  | **Result** | Traceable `.specs/` memory, fewer fake finishes, cheaper turns (~70% less skill text on planning). Choose Process for light ceremony; add Python when you want the [Guarantees matrix](#guarantees-matrix) enforced automatically. |
13
13
 
14
- npm: [`@luizsantiago/spec-guardrails`](https://www.npmjs.com/package/@luizsantiago/spec-guardrails) **3.1.x**
14
+ npm: [`@luizsantiago/spec-guardrails`](https://www.npmjs.com/package/@luizsantiago/spec-guardrails) **3.2.x**
15
15
 
16
16
  ---
17
17
 
@@ -41,6 +41,17 @@ npx @luizsantiago/spec-guardrails install
41
41
 
42
42
  Re-run `install` anytime to refresh skills; your `.specs/` decisions and `STATE.md` are kept.
43
43
 
44
+ ### Governance focus (3.2+)
45
+
46
+ Spec Guardrails is a **single product**: governance, evidence, verification, and controlled execution for agentic software development — not a bundle of companion packages.
47
+
48
+ | Capability | What it does |
49
+ | --- | --- |
50
+ | **Artifact gates** | Structural quality for spec, tasks, and cross-artifact consistency — with **blocking / warning / info** severity |
51
+ | **Parallel waves** | `loop-plan` computes safe parallel groups; `workspace-prepare` isolates tasks in git worktrees |
52
+ | **Execution policy** | Budgets, path scope, and escalation rules in `.specs/config.yaml` — consult via `execution-policy` |
53
+ | **Independent verify** | Fresh-context verification with evidence-or-zero (`validate-state`) |
54
+
44
55
  | Need | Command |
45
56
  | --- | --- |
46
57
  | First time / upgrade | `install` |
@@ -190,6 +201,7 @@ Full reference: **[Gates](docs/guide/gates.md)** · [Guarantees matrix](docs/gui
190
201
  | [Skills and hub](docs/guide/skills-and-hub.md) | What each skill file does |
191
202
  | [Gates](docs/guide/gates.md) | How each gate works |
192
203
  | [Platform parity](docs/guide/Platform-parity.md) | Shipped adapters (Cursor, Claude, Copilot, Codex) — core works with any agent |
204
+ | [Restart PRD seed](docs/guide/Restart-prd-seed.md) | Clean-project PRD template for a single-package restart |
193
205
  | [FAQ](docs/guide/FAQ.md) | Common questions |
194
206
  | [Changelog](docs/CHANGELOG.md) | Full version history |
195
207
 
@@ -205,7 +217,8 @@ npx @luizsantiago/spec-guardrails install
205
217
 
206
218
  | Version | What you gain |
207
219
  | --- | --- |
208
- | **3.1.x** | Copilot/Codex/AGENTS.md adapters; doctor Process + Brakes scores; unified execution contracts; `validate-traceability` / `validate-quick`; `classify-change` / `feature-status` |
220
+ | **3.2.x** | Single-package focus; artifact gate severity labels; git worktree isolation CLI; execution policy (budget/scope/escalation) |
221
+ | **3.1.x** | Copilot/Codex/AGENTS.md adapters; doctor Process + Brakes scores; `validate-traceability` / `validate-quick`; `classify-change` / `feature-status` |
209
222
  | **3.0.x** | Final name Spec Guardrails; `.specs/guardrails/`; no dual-path ([Migration](docs/guide/Migration.md)) |
210
223
  | **2.2.x** | Seatbelt-era paths & markers; `doctor` Execute hints; docs split from README |
211
224
  | **2.1.x** | `loop-plan` + parallel `/loop` waves |
package/index.js CHANGED
@@ -8,10 +8,22 @@ import { classifyChange, formatClassifyChange } from "./lib/classify-change.js";
8
8
  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
+ import {
12
+ checkPathScope,
13
+ formatPolicyStatus,
14
+ loadExecutionPolicy,
15
+ loadPolicyState,
16
+ savePolicyState,
17
+ } from "./lib/execution-policy.js";
11
18
  import { featureInit } from "./lib/feature.js";
12
19
  import { featureStatus, formatFeatureStatus } from "./lib/feature-status.js";
13
20
  import { GATE_COMMANDS, AUX_COMMANDS, runGate, runGuardrailsScript } from "./lib/gates.js";
14
21
  import { install } from "./lib/install.js";
22
+ import {
23
+ cleanupWorkspaces,
24
+ formatWorkspaceResults,
25
+ prepareWorkspaces,
26
+ } from "./lib/workspace-isolation.js";
15
27
  import {
16
28
  initProjectConfig,
17
29
  listPresets,
@@ -52,6 +64,19 @@ Commands:
52
64
  doctor [path] Audit guardrails readiness (score + next actions)
53
65
  [--json] Machine-readable output
54
66
  [--no-suggest] Hide per-check remediation hints
67
+ workspace-prepare <feature> Create isolated git worktrees for parallel tasks
68
+ --tasks T1,T2 Task ids to isolate (required)
69
+ [--base-ref HEAD] Base ref for new worktrees
70
+ [--json] Machine-readable output
71
+ workspace-cleanup <feature> Remove isolated worktrees for a feature
72
+ [--tasks T1,T2] Limit cleanup to specific tasks
73
+ [--force] Force-remove dirty worktrees
74
+ [--json] Machine-readable output
75
+ execution-policy status Show configured budgets, scope, and runtime counters
76
+ [--json] Machine-readable output
77
+ execution-policy check-path <path> Check whether a relative path is allowed by scope policy
78
+ [--json] Machine-readable output
79
+ execution-policy record-retry <task> Increment retry counter for a task id
55
80
  validate-spec [spec.md|feature] Closure gate for a feature spec
56
81
  analyze-artifacts [feature] Cross-artifact consistency before task approval
57
82
  validate-tasks [tasks.md|feature] Granularity gate for a task breakdown
@@ -321,6 +346,144 @@ if (command === "--version" || command === "-v" || command === "version") {
321
346
  console.error(`❌ ${err.message}`);
322
347
  process.exit(1);
323
348
  }
349
+ } else if (command === "workspace-prepare") {
350
+ try {
351
+ let json = false;
352
+ let baseRef = "HEAD";
353
+ let tasksRaw = "";
354
+ const positional = [];
355
+
356
+ for (let i = 0; i < args.length; i++) {
357
+ const arg = args[i];
358
+ if (arg === "--json") {
359
+ json = true;
360
+ } else if (arg === "--base-ref") {
361
+ baseRef = args[++i];
362
+ if (!baseRef) {
363
+ throw new Error("--base-ref requires a git ref");
364
+ }
365
+ } else if (arg === "--tasks") {
366
+ tasksRaw = args[++i] ?? "";
367
+ if (!tasksRaw) {
368
+ throw new Error("--tasks requires a comma-separated list (e.g. T1,T2)");
369
+ }
370
+ } else {
371
+ positional.push(arg);
372
+ }
373
+ }
374
+
375
+ const featureId = positional[0];
376
+ if (!featureId) {
377
+ throw new Error("Usage: workspace-prepare <feature> --tasks T1,T2");
378
+ }
379
+
380
+ const taskIds = tasksRaw.split(",").map((item) => item.trim()).filter(Boolean);
381
+ const results = await prepareWorkspaces(process.cwd(), { featureId, taskIds, baseRef });
382
+ process.stdout.write(formatWorkspaceResults(results, { json }));
383
+
384
+ if (results.some((item) => item.status === "failed")) {
385
+ process.exit(1);
386
+ }
387
+ } catch (err) {
388
+ console.error(`❌ ${err.message}`);
389
+ process.exit(1);
390
+ }
391
+ } else if (command === "workspace-cleanup") {
392
+ try {
393
+ let json = false;
394
+ let force = false;
395
+ let tasksRaw = "";
396
+ const positional = [];
397
+
398
+ for (let i = 0; i < args.length; i++) {
399
+ const arg = args[i];
400
+ if (arg === "--json") {
401
+ json = true;
402
+ } else if (arg === "--force") {
403
+ force = true;
404
+ } else if (arg === "--tasks") {
405
+ tasksRaw = args[++i] ?? "";
406
+ } else {
407
+ positional.push(arg);
408
+ }
409
+ }
410
+
411
+ const featureId = positional[0];
412
+ if (!featureId) {
413
+ throw new Error("Usage: workspace-cleanup <feature> [--tasks T1,T2] [--force]");
414
+ }
415
+
416
+ const taskIds = tasksRaw
417
+ ? tasksRaw.split(",").map((item) => item.trim()).filter(Boolean)
418
+ : undefined;
419
+ const results = await cleanupWorkspaces(process.cwd(), { featureId, taskIds, force });
420
+ process.stdout.write(formatWorkspaceResults(results, { json }));
421
+
422
+ if (results.some((item) => item.status === "failed")) {
423
+ process.exit(1);
424
+ }
425
+ } catch (err) {
426
+ console.error(`❌ ${err.message}`);
427
+ process.exit(1);
428
+ }
429
+ } else if (command === "execution-policy") {
430
+ try {
431
+ const sub = args[0];
432
+ let json = false;
433
+ const rest = [];
434
+
435
+ for (let i = 1; i < args.length; i++) {
436
+ if (args[i] === "--json") {
437
+ json = true;
438
+ } else {
439
+ rest.push(args[i]);
440
+ }
441
+ }
442
+
443
+ const cwd = process.cwd();
444
+ const policy = await loadExecutionPolicy(cwd);
445
+ const state = await loadPolicyState(cwd);
446
+
447
+ if (sub === "status") {
448
+ process.stdout.write(formatPolicyStatus(policy, state, { json }));
449
+ } else if (sub === "check-path") {
450
+ const relativePath = rest[0];
451
+ if (!relativePath) {
452
+ throw new Error("Usage: execution-policy check-path <relative-path>");
453
+ }
454
+ const result = checkPathScope(relativePath, policy);
455
+ if (json) {
456
+ console.log(JSON.stringify({ path: relativePath, ...result }, null, 2));
457
+ } else {
458
+ console.log(
459
+ `${relativePath}: ${result.allowed ? "allowed" : "blocked"} (${result.reason})`,
460
+ );
461
+ }
462
+ if (!result.allowed) {
463
+ process.exit(1);
464
+ }
465
+ } else if (sub === "record-retry") {
466
+ const taskId = rest[0];
467
+ if (!taskId) {
468
+ throw new Error("Usage: execution-policy record-retry <task-id>");
469
+ }
470
+ state.retries[taskId] = (state.retries[taskId] ?? 0) + 1;
471
+ state.iterations += 1;
472
+ await savePolicyState(cwd, state);
473
+ if (json) {
474
+ console.log(JSON.stringify({ taskId, retries: state.retries[taskId], state }, null, 2));
475
+ } else {
476
+ console.log(`Recorded retry for ${taskId}: ${state.retries[taskId]}`);
477
+ }
478
+ } else {
479
+ throw new Error(
480
+ "Usage: execution-policy status | check-path <path> | record-retry <task>",
481
+ );
482
+ }
483
+ } catch (err) {
484
+ console.error(`❌ ${err.message}`);
485
+ process.exit(1);
486
+ }
324
487
  } else if (command === "classify-change") {
325
488
  try {
326
489
  let json = false;
package/lib/archive.js CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  import { ensureDir, readFileSafe, writeFileSafe } from "./fs-utils.js";
10
10
  import { runGate } from "./gates.js";
11
11
  import { assertSafeDomainSlug } from "./slug-utils.js";
12
+ import { isPassVerdict } from "./validation-verdict.js";
12
13
  import {
13
14
  featureDir,
14
15
  readFeatureArtifact,
@@ -23,15 +24,6 @@ Track milestones and archived features.
23
24
 
24
25
  `;
25
26
 
26
- /**
27
- * @param {string} text
28
- * @returns {boolean}
29
- */
30
- function validationPassed(text) {
31
- const visible = text.replace(/<!--[\s\S]*?-->/g, "");
32
- return /\b(?:PASS|PASSED)\b/.test(visible);
33
- }
34
-
35
27
  /**
36
28
  * @param {string} cwd
37
29
  * @param {string} featureId
@@ -150,7 +142,7 @@ export async function archiveFeature(featureArg, options = {}) {
150
142
  },
151
143
  );
152
144
 
153
- if (!validationPassed(validationText)) {
145
+ if (!isPassVerdict(validationText)) {
154
146
  throw new Error(
155
147
  `validation.md for ${featureId} does not contain PASS/PASSED. Run validate-state first.`,
156
148
  );
package/lib/assets.js CHANGED
@@ -55,8 +55,9 @@ export function resolveInstallSource(repoUrl) {
55
55
  /**
56
56
  * @param {string} remotePath
57
57
  * @param {string} destPath
58
+ * @param {{ boundary?: string }} [options]
58
59
  */
59
- export async function copyPackagedAsset(remotePath, destPath) {
60
+ export async function copyPackagedAsset(remotePath, destPath, options = {}) {
60
61
  const source = packagedAssetPath(remotePath);
61
62
 
62
63
  try {
@@ -71,7 +72,7 @@ export async function copyPackagedAsset(remotePath, destPath) {
71
72
  throw err;
72
73
  }
73
74
 
74
- await assertSafeWriteTarget(destPath);
75
+ await assertSafeWriteTarget(destPath, options);
75
76
 
76
77
  try {
77
78
  await fs.copyFile(source, destPath);
@@ -91,7 +92,7 @@ export async function copyPackagedAsset(remotePath, destPath) {
91
92
  * repo with no override (kept for the remote path; the default install no
92
93
  * longer uses it).
93
94
  *
94
- * @param {{ remotePath: string, destPath: string, repoUrl?: string, state: { warned: boolean }, log: (msg: string) => void }} params
95
+ * @param {{ remotePath: string, destPath: string, repoUrl?: string, state: { warned: boolean }, log: (msg: string) => void, boundary?: string }} params
95
96
  */
96
97
  export async function downloadRemoteAsset({
97
98
  remotePath,
@@ -99,9 +100,10 @@ export async function downloadRemoteAsset({
99
100
  repoUrl,
100
101
  state,
101
102
  log,
103
+ boundary,
102
104
  }) {
103
105
  try {
104
- await downloadToFile(resolveAssetUrl(remotePath, repoUrl), destPath);
106
+ await downloadToFile(resolveAssetUrl(remotePath, repoUrl), destPath, { boundary });
105
107
  return;
106
108
  } catch (err) {
107
109
  const missingPinnedAsset =
@@ -123,15 +125,16 @@ export async function downloadRemoteAsset({
123
125
  await downloadToFile(
124
126
  resolveAssetUrl(remotePath, FALLBACK_REPO_URL),
125
127
  destPath,
128
+ { boundary },
126
129
  );
127
130
  }
128
131
 
129
132
  /**
130
- * @param {{ remotePath: string, destPath: string, source: ReturnType<typeof resolveInstallSource>, state: { warned: boolean }, log: (msg: string) => void }} params
133
+ * @param {{ remotePath: string, destPath: string, source: ReturnType<typeof resolveInstallSource>, state: { warned: boolean }, log: (msg: string) => void, boundary?: string }} params
131
134
  */
132
- export async function installAsset({ remotePath, destPath, source, state, log }) {
135
+ export async function installAsset({ remotePath, destPath, source, state, log, boundary }) {
133
136
  if (source.mode === "package") {
134
- await copyPackagedAsset(remotePath, destPath);
137
+ await copyPackagedAsset(remotePath, destPath, { boundary });
135
138
  return;
136
139
  }
137
140
 
@@ -141,5 +144,6 @@ export async function installAsset({ remotePath, destPath, source, state, log })
141
144
  repoUrl: source.repoUrl,
142
145
  state,
143
146
  log,
147
+ boundary,
144
148
  });
145
149
  }
package/lib/download.js CHANGED
@@ -98,8 +98,8 @@ async function fetchWithSafeRedirects(startUrl) {
98
98
  );
99
99
  }
100
100
 
101
- export async function downloadToFile(url, destPath) {
102
- await assertSafeWriteTarget(destPath);
101
+ export async function downloadToFile(url, destPath, options = {}) {
102
+ await assertSafeWriteTarget(destPath, options);
103
103
 
104
104
  const response = await fetchWithSafeRedirects(url);
105
105
 
@@ -0,0 +1,291 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { readFileSafe, writeFileSafe } from "./fs-utils.js";
5
+
6
+ export const POLICY_STATE_PATH = ".specs/state/execution-policy.json";
7
+
8
+ /** @type {import("./execution-policy.js").ExecutionPolicy} */
9
+ export const DEFAULT_POLICY = {
10
+ budget: {
11
+ max_iterations: 5,
12
+ max_agent_runs: 12,
13
+ max_retries_per_task: 3,
14
+ },
15
+ scope: {
16
+ allowed_paths: [],
17
+ denied_paths: ["**/.env", "**/secrets/**", "**/production/**"],
18
+ },
19
+ escalation: {
20
+ on_scope_expansion: "human",
21
+ on_budget_exhaustion: "stop",
22
+ on_policy_violation: "block",
23
+ },
24
+ };
25
+
26
+ /**
27
+ * @typedef {{
28
+ * budget: { max_iterations: number, max_agent_runs: number, max_retries_per_task: number },
29
+ * scope: { allowed_paths: string[], denied_paths: string[] },
30
+ * escalation: { on_scope_expansion: string, on_budget_exhaustion: string, on_policy_violation: string },
31
+ * }} ExecutionPolicy
32
+ */
33
+
34
+ /**
35
+ * @typedef {{
36
+ * iterations: number,
37
+ * agent_runs: number,
38
+ * retries: Record<string, number>,
39
+ * }} ExecutionPolicyState
40
+ */
41
+
42
+ /**
43
+ * Parse budget/scope/escalation blocks from `.specs/config.yaml` text.
44
+ *
45
+ * @param {string} text
46
+ * @returns {Partial<ExecutionPolicy>}
47
+ */
48
+ export function parseExecutionPolicySections(text) {
49
+ /** @type {Partial<ExecutionPolicy>} */
50
+ const result = {};
51
+ /** @type {"budget" | "scope" | "escalation" | null} */
52
+ let section = null;
53
+ /** @type {string | null} */
54
+ let listKey = null;
55
+
56
+ for (const line of text.split("\n")) {
57
+ const trimmed = line.trim();
58
+ if (!trimmed || trimmed.startsWith("#")) {
59
+ continue;
60
+ }
61
+
62
+ const sectionMatch = trimmed.match(/^(budget|scope|escalation):\s*$/);
63
+ if (sectionMatch) {
64
+ section = /** @type {"budget" | "scope" | "escalation"} */ (sectionMatch[1]);
65
+ result[section] = result[section] ?? {};
66
+ listKey = null;
67
+ continue;
68
+ }
69
+
70
+ const kv = trimmed.match(/^([a-z_]+):\s*(.*)$/);
71
+ if (kv && section) {
72
+ const key = kv[1];
73
+ const rawValue = kv[2].replace(/^['"]|['"]$/g, "");
74
+ const bucket = /** @type {Record<string, unknown>} */ (result[section]);
75
+
76
+ if (rawValue === "") {
77
+ listKey = key;
78
+ bucket[listKey] = [];
79
+ continue;
80
+ }
81
+
82
+ if (/^\d+$/.test(rawValue)) {
83
+ bucket[key] = Number(rawValue);
84
+ } else {
85
+ bucket[key] = rawValue;
86
+ }
87
+ listKey = null;
88
+ continue;
89
+ }
90
+
91
+ const listItem = trimmed.match(/^-\s+(.+)$/);
92
+ if (listItem && section && listKey) {
93
+ const bucket = /** @type {Record<string, string[]>} */ (result[section]);
94
+ bucket[listKey] = bucket[listKey] ?? [];
95
+ bucket[listKey].push(listItem[1].replace(/^['"]|['"]$/g, ""));
96
+ }
97
+ }
98
+
99
+ return result;
100
+ }
101
+
102
+ /**
103
+ * @param {ExecutionPolicy} base
104
+ * @param {Partial<ExecutionPolicy>} overlay
105
+ * @returns {ExecutionPolicy}
106
+ */
107
+ export function mergeExecutionPolicy(base, overlay) {
108
+ return {
109
+ budget: { ...base.budget, ...(overlay.budget ?? {}) },
110
+ scope: {
111
+ allowed_paths: overlay.scope?.allowed_paths ?? base.scope.allowed_paths,
112
+ denied_paths: overlay.scope?.denied_paths ?? base.scope.denied_paths,
113
+ },
114
+ escalation: { ...base.escalation, ...(overlay.escalation ?? {}) },
115
+ };
116
+ }
117
+
118
+ /**
119
+ * @param {string} cwd
120
+ * @returns {Promise<ExecutionPolicy>}
121
+ */
122
+ export async function loadExecutionPolicy(cwd = process.cwd()) {
123
+ const configPath = path.join(cwd, ".specs/config.yaml");
124
+ let policy = structuredClone(DEFAULT_POLICY);
125
+
126
+ try {
127
+ const text = await readFileSafe(configPath);
128
+ policy = mergeExecutionPolicy(policy, parseExecutionPolicySections(text));
129
+ } catch (err) {
130
+ if (!/cannot read/i.test(String(err.message)) && err.code !== "ENOENT") {
131
+ throw err;
132
+ }
133
+ }
134
+
135
+ return policy;
136
+ }
137
+
138
+ /**
139
+ * @param {string} pattern
140
+ * @param {string} value
141
+ * @returns {boolean}
142
+ */
143
+ export function matchGlobPattern(pattern, value) {
144
+ const normalizedPattern = pattern.replace(/\\/g, "/");
145
+ const normalizedValue = value.replace(/\\/g, "/");
146
+ const regex = new RegExp(
147
+ `^${normalizedPattern
148
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
149
+ .replace(/\*\*/g, ".*")
150
+ .replace(/\*/g, "[^/]*")}$`,
151
+ );
152
+ return regex.test(normalizedValue);
153
+ }
154
+
155
+ /**
156
+ * @param {string} relativePath
157
+ * @param {ExecutionPolicy} policy
158
+ * @returns {{ allowed: boolean, severity: "blocking" | "info", reason: string }}
159
+ */
160
+ export function checkPathScope(relativePath, policy) {
161
+ const normalized = relativePath.replace(/\\/g, "/").replace(/^\.\//, "");
162
+
163
+ for (const denied of policy.scope.denied_paths) {
164
+ if (matchGlobPattern(denied, normalized)) {
165
+ return {
166
+ allowed: false,
167
+ severity: "blocking",
168
+ reason: `path matches denied pattern: ${denied}`,
169
+ };
170
+ }
171
+ }
172
+
173
+ if (!policy.scope.allowed_paths.length) {
174
+ return { allowed: true, severity: "info", reason: "no allowlist configured" };
175
+ }
176
+
177
+ for (const allowed of policy.scope.allowed_paths) {
178
+ if (matchGlobPattern(allowed, normalized)) {
179
+ return {
180
+ allowed: true,
181
+ severity: "info",
182
+ reason: `path matches allowed pattern: ${allowed}`,
183
+ };
184
+ }
185
+ }
186
+
187
+ return {
188
+ allowed: false,
189
+ severity: "blocking",
190
+ reason: "path outside configured allowed_paths",
191
+ };
192
+ }
193
+
194
+ /**
195
+ * @param {string} cwd
196
+ * @returns {Promise<ExecutionPolicyState>}
197
+ */
198
+ export async function loadPolicyState(cwd = process.cwd()) {
199
+ const statePath = path.join(cwd, POLICY_STATE_PATH);
200
+ try {
201
+ const raw = await readFileSafe(statePath);
202
+ return /** @type {ExecutionPolicyState} */ (JSON.parse(raw));
203
+ } catch {
204
+ return { iterations: 0, agent_runs: 0, retries: {} };
205
+ }
206
+ }
207
+
208
+ /**
209
+ * @param {string} cwd
210
+ * @param {ExecutionPolicyState} state
211
+ */
212
+ export async function savePolicyState(cwd, state) {
213
+ const statePath = path.join(cwd, POLICY_STATE_PATH);
214
+ await fs.mkdir(path.dirname(statePath), { recursive: true });
215
+ await writeFileSafe(statePath, `${JSON.stringify(state, null, 2)}\n`);
216
+ }
217
+
218
+ /**
219
+ * @param {ExecutionPolicyState} state
220
+ * @param {ExecutionPolicy} policy
221
+ * @returns {{ ok: boolean, issues: Array<{ severity: "blocking", message: string }> }}
222
+ */
223
+ export function checkBudget(state, policy) {
224
+ /** @type {Array<{ severity: "blocking", message: string }>} */
225
+ const issues = [];
226
+
227
+ if (state.iterations >= policy.budget.max_iterations) {
228
+ issues.push({ severity: "blocking", message: "max_iterations budget exhausted" });
229
+ }
230
+
231
+ if (state.agent_runs >= policy.budget.max_agent_runs) {
232
+ issues.push({ severity: "blocking", message: "max_agent_runs budget exhausted" });
233
+ }
234
+
235
+ return { ok: issues.length === 0, issues };
236
+ }
237
+
238
+ /**
239
+ * @param {string} taskId
240
+ * @param {ExecutionPolicyState} state
241
+ * @param {ExecutionPolicy} policy
242
+ * @returns {{ ok: boolean, severity?: "blocking", message?: string, retries: number }}
243
+ */
244
+ export function checkTaskRetries(taskId, state, policy) {
245
+ const retries = state.retries[taskId] ?? 0;
246
+ if (retries >= policy.budget.max_retries_per_task) {
247
+ return {
248
+ ok: false,
249
+ severity: "blocking",
250
+ message: `max_retries_per_task exhausted for ${taskId}`,
251
+ retries,
252
+ };
253
+ }
254
+ return { ok: true, retries };
255
+ }
256
+
257
+ /**
258
+ * @param {ExecutionPolicy} policy
259
+ * @param {ExecutionPolicyState} state
260
+ * @param {{ json?: boolean }} [options]
261
+ */
262
+ export function formatPolicyStatus(policy, state, options = {}) {
263
+ const budget = checkBudget(state, policy);
264
+ const payload = {
265
+ policy,
266
+ state,
267
+ budget,
268
+ escalation: policy.escalation,
269
+ };
270
+
271
+ if (options.json) {
272
+ return JSON.stringify(payload, null, 2);
273
+ }
274
+
275
+ const lines = [
276
+ "Execution policy status:",
277
+ ` iterations: ${state.iterations}/${policy.budget.max_iterations}`,
278
+ ` agent_runs: ${state.agent_runs}/${policy.budget.max_agent_runs}`,
279
+ ` allowed_paths: ${policy.scope.allowed_paths.length ? policy.scope.allowed_paths.join(", ") : "(none — all non-denied paths allowed)"}`,
280
+ ` denied_paths: ${policy.scope.denied_paths.join(", ")}`,
281
+ ` budget_ok: ${budget.ok ? "yes" : "no"}`,
282
+ ];
283
+
284
+ if (!budget.ok) {
285
+ for (const issue of budget.issues) {
286
+ lines.push(` blocking: ${issue.message}`);
287
+ }
288
+ }
289
+
290
+ return `${lines.join("\n")}\n`;
291
+ }
@@ -9,6 +9,7 @@ import {
9
9
  readActiveFeatureFromState,
10
10
  resolveFeatureId,
11
11
  } from "./specs-utils.js";
12
+ import { findVerdict } from "./validation-verdict.js";
12
13
 
13
14
  /**
14
15
  * @param {string} cwd
@@ -39,17 +40,6 @@ function summarizeTasks(tasksText) {
39
40
  };
40
41
  }
41
42
 
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
43
  /**
54
44
  * @param {string} [featureArg]
55
45
  * @param {{ cwd?: string }} [options]
@@ -97,7 +87,7 @@ export async function featureStatus(featureArg, options = {}) {
97
87
  let verdict = null;
98
88
  if (artifacts.validation) {
99
89
  const text = await readFileSafe(path.join(dir, "validation.md"));
100
- verdict = readVerdict(text);
90
+ verdict = findVerdict(text);
101
91
  }
102
92
 
103
93
  const executeHint = await resolveExecuteHint(cwd, featureId);
package/lib/feature.js CHANGED
@@ -220,6 +220,39 @@ ${description.trim()}
220
220
  await writeFileSafe(specPath, stub);
221
221
  }
222
222
 
223
+ /**
224
+ * Allocate a unique feature directory using exclusive mkdir (race-safe).
225
+ *
226
+ * @param {string} cwd
227
+ * @param {string} slug
228
+ * @param {number} [maxAttempts]
229
+ * @returns {Promise<{ featureId: string, featureDir: string }>}
230
+ */
231
+ export async function allocateFeatureWorkspace(cwd, slug, maxAttempts = 10) {
232
+ const featuresRoot = path.join(cwd, FEATURES_DIR);
233
+ await ensureDir(featuresRoot);
234
+
235
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
236
+ const number = await nextFeatureNumber(cwd);
237
+ const featureId = formatFeatureId(number, slug);
238
+ const featureDirPath = path.join(featuresRoot, featureId);
239
+
240
+ try {
241
+ await fs.mkdir(featureDirPath);
242
+ return { featureId, featureDir: featureDirPath };
243
+ } catch (err) {
244
+ if (err.code === "EEXIST") {
245
+ continue;
246
+ }
247
+ throw err;
248
+ }
249
+ }
250
+
251
+ throw new Error(
252
+ `Could not allocate a unique feature id for "${slug}" after ${maxAttempts} attempts.`,
253
+ );
254
+ }
255
+
223
256
  /**
224
257
  * Initialize a numbered feature workspace (Tier 0).
225
258
  *
@@ -235,19 +268,15 @@ export async function featureInit(description, options = {}) {
235
268
  }
236
269
 
237
270
  const slug = slugifyDescription(trimmed);
238
- const number = await nextFeatureNumber(cwd);
239
- const featureId = formatFeatureId(number, slug);
271
+ const { featureId, featureDir: featureDirPath } = await allocateFeatureWorkspace(cwd, slug);
240
272
 
241
273
  const resolvedConfig = await loadResolvedConfig(cwd);
242
274
  const branchPrefix =
243
275
  options.branchPrefix ?? readBranchPrefix(resolvedConfig) ?? DEFAULT_BRANCH_PREFIX;
244
276
  const branchName = featureBranchName(featureId, branchPrefix);
245
- const featureDir = path.join(cwd, FEATURES_DIR, featureId);
246
-
247
- await ensureDir(featureDir);
248
277
 
249
278
  if (!options.skipSpec) {
250
- const specPath = path.join(featureDir, "spec.md");
279
+ const specPath = path.join(featureDirPath, "spec.md");
251
280
  try {
252
281
  await fs.access(specPath);
253
282
  } catch {
package/lib/fs-utils.js CHANGED
@@ -1,16 +1,74 @@
1
1
  import fs from "node:fs/promises";
2
+ import path from "node:path";
2
3
 
3
4
  function isPermissionError(err) {
4
5
  return err && (err.code === "EACCES" || err.code === "EPERM");
5
6
  }
6
7
 
8
+ /**
9
+ * @param {string} child
10
+ * @param {string} parent
11
+ * @returns {boolean}
12
+ */
13
+ function isInsidePath(child, parent) {
14
+ const resolvedChild = path.resolve(child);
15
+ const resolvedParent = path.resolve(parent);
16
+ if (resolvedChild === resolvedParent) {
17
+ return true;
18
+ }
19
+ const rel = path.relative(resolvedParent, resolvedChild);
20
+ return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
21
+ }
22
+
23
+ /**
24
+ * Refuse writes when an existing ancestor directory under `boundary` is a symlink.
25
+ *
26
+ * @param {string} destPath
27
+ * @param {string} boundary
28
+ */
29
+ export async function assertNoSymlinkAncestors(destPath, boundary) {
30
+ const resolvedBoundary = path.resolve(boundary);
31
+ let current = path.dirname(path.resolve(destPath));
32
+
33
+ while (isInsidePath(current, resolvedBoundary) && current !== resolvedBoundary) {
34
+ try {
35
+ const st = await fs.lstat(current);
36
+ if (st.isSymbolicLink()) {
37
+ throw new Error(
38
+ `Refusing to write under symlinked directory: ${current} — ` +
39
+ "remove the link or choose another destination before installing.",
40
+ );
41
+ }
42
+ } catch (err) {
43
+ if (err.code === "ENOENT") {
44
+ // Parent not created yet — keep walking toward the boundary.
45
+ } else if (isPermissionError(err)) {
46
+ throw new Error(`Permission denied: cannot access ${current}`);
47
+ } else {
48
+ throw err;
49
+ }
50
+ }
51
+
52
+ const parent = path.dirname(current);
53
+ if (parent === current) {
54
+ break;
55
+ }
56
+ current = parent;
57
+ }
58
+ }
59
+
7
60
  /**
8
61
  * Refuse to overwrite through a symlink (install must not clobber .env etc.).
9
62
  * Used for packaged copies, remote downloads, memory files, and `.cursorrules`.
10
63
  *
11
64
  * @param {string} destPath
65
+ * @param {{ boundary?: string }} [options]
12
66
  */
13
- export async function assertSafeWriteTarget(destPath) {
67
+ export async function assertSafeWriteTarget(destPath, options = {}) {
68
+ if (options.boundary) {
69
+ await assertNoSymlinkAncestors(destPath, options.boundary);
70
+ }
71
+
14
72
  let st;
15
73
  try {
16
74
  st = await fs.lstat(destPath);
@@ -43,8 +101,8 @@ export async function ensureDir(dirPath) {
43
101
  }
44
102
  }
45
103
 
46
- export async function writeFileIfMissing(filePath, content) {
47
- await assertSafeWriteTarget(filePath);
104
+ export async function writeFileIfMissing(filePath, content, options = {}) {
105
+ await assertSafeWriteTarget(filePath, options);
48
106
 
49
107
  try {
50
108
  await fs.writeFile(filePath, content, { encoding: "utf8", flag: "wx" });
@@ -60,8 +118,8 @@ export async function writeFileIfMissing(filePath, content) {
60
118
  }
61
119
  }
62
120
 
63
- export async function appendFileSafe(filePath, content) {
64
- await assertSafeWriteTarget(filePath);
121
+ export async function appendFileSafe(filePath, content, options = {}) {
122
+ await assertSafeWriteTarget(filePath, options);
65
123
 
66
124
  try {
67
125
  await fs.appendFile(filePath, content, "utf8");
@@ -73,8 +131,8 @@ export async function appendFileSafe(filePath, content) {
73
131
  }
74
132
  }
75
133
 
76
- export async function writeFileSafe(filePath, content) {
77
- await assertSafeWriteTarget(filePath);
134
+ export async function writeFileSafe(filePath, content, options = {}) {
135
+ await assertSafeWriteTarget(filePath, options);
78
136
 
79
137
  try {
80
138
  await fs.writeFile(filePath, content, "utf8");
package/lib/install.js CHANGED
@@ -40,7 +40,7 @@ export async function install(options = {}) {
40
40
  }
41
41
 
42
42
  const fetchAsset = (remotePath, destPath) =>
43
- installAsset({ remotePath, destPath, source, state, log });
43
+ installAsset({ remotePath, destPath, source, state, log, boundary: cwd });
44
44
 
45
45
  log(`🚀 Installing ${DISPLAY_NAME}...`);
46
46
 
package/lib/memory.js CHANGED
@@ -7,7 +7,9 @@ import {
7
7
  } from "./constants.js";
8
8
  import { ensureDir, readFileSafe, writeFileIfMissing } from "./fs-utils.js";
9
9
 
10
- export async function initGuardrailsMemory(cwd) {
10
+ export async function initGuardrailsMemory(cwd, options = {}) {
11
+ const boundary = options.boundary ?? cwd;
12
+ const writeOptions = { boundary };
11
13
  const specsDir = path.join(cwd, ".specs");
12
14
  const featuresDir = path.join(specsDir, "features");
13
15
  const projectDir = path.join(specsDir, "project");
@@ -20,14 +22,17 @@ export async function initGuardrailsMemory(cwd) {
20
22
  const stateCreated = await writeFileIfMissing(
21
23
  path.join(specsDir, "STATE.md"),
22
24
  STATE_HEADER,
25
+ writeOptions,
23
26
  );
24
27
  const lessonsCreated = await writeFileIfMissing(
25
28
  path.join(specsDir, "LESSONS.md"),
26
29
  LESSONS_HEADER,
30
+ writeOptions,
27
31
  );
28
32
  await writeFileIfMissing(
29
33
  path.join(specsDir, "config.yaml.example"),
30
34
  await readFileSafe(packagedAssetPath("templates/config.yaml.example")),
35
+ writeOptions,
31
36
  );
32
37
 
33
38
  return { stateCreated, lessonsCreated };
@@ -194,14 +194,14 @@ export async function installProjectRules(cwd, options) {
194
194
  }
195
195
 
196
196
  if (!exists) {
197
- await assertSafeWriteTarget(destPath);
197
+ await assertSafeWriteTarget(destPath, { boundary: cwd });
198
198
  await fs.rename(tmpPath, destPath);
199
199
  continue;
200
200
  }
201
201
 
202
202
  const existing = await fs.readFile(destPath, "utf8");
203
203
  const merged = mergeBaselineRule(existing, shipped);
204
- await assertSafeWriteTarget(destPath);
204
+ await assertSafeWriteTarget(destPath, { boundary: cwd });
205
205
  await fs.writeFile(destPath, merged, "utf8");
206
206
  await fs.rm(tmpPath, { force: true });
207
207
  } catch (err) {
@@ -0,0 +1,101 @@
1
+ const SECTION_START = /^#{2,6}\s/m;
2
+
3
+ const VERDICT_INLINE =
4
+ /^\s*[-*]?\s*\*{0,2}(?:verdict|result|status)\*{0,2}\s*:\s*\*{0,2}([A-Za-z ]+)/im;
5
+
6
+ const VERDICT_HEADING =
7
+ /^#{1,6}\s*(?:verdict|result|status)\s*$\s*\n+\s*\*{0,2}([A-Za-z ]+)/im;
8
+
9
+ const PASS_VERDICTS = new Set(["PASS", "PASSED"]);
10
+
11
+ /**
12
+ * @param {string} text
13
+ * @returns {string}
14
+ */
15
+ function stripHtmlComments(text) {
16
+ return text.replace(/<!--[\s\S]*?-->/g, (match) =>
17
+ "\n".repeat((match.match(/\n/g) ?? []).length),
18
+ );
19
+ }
20
+
21
+ /**
22
+ * Blank fenced code so structural regexes ignore sample snippets.
23
+ *
24
+ * @param {string} text
25
+ * @returns {string}
26
+ */
27
+ function maskFencedBlocks(text) {
28
+ const lines = text.split("\n");
29
+ const masked = [];
30
+ let inFence = false;
31
+
32
+ for (const line of lines) {
33
+ if (line.trimStart().startsWith("```")) {
34
+ inFence = !inFence;
35
+ masked.push("");
36
+ continue;
37
+ }
38
+ masked.push(inFence ? "" : line);
39
+ }
40
+
41
+ return masked.join("\n");
42
+ }
43
+
44
+ /**
45
+ * @param {string} text
46
+ * @returns {string}
47
+ */
48
+ export function visibleMarkdown(text) {
49
+ return maskFencedBlocks(stripHtmlComments(text));
50
+ }
51
+
52
+ /**
53
+ * @param {string} text
54
+ * @returns {string}
55
+ */
56
+ function validationPreamble(text) {
57
+ const visible = visibleMarkdown(text);
58
+ const match = SECTION_START.exec(visible);
59
+ if (!match) {
60
+ return visible;
61
+ }
62
+ return visible.slice(0, match.index);
63
+ }
64
+
65
+ /**
66
+ * @param {string} raw
67
+ * @returns {string}
68
+ */
69
+ function normalizeVerdict(raw) {
70
+ return raw.trim().replace(/\s+/g, " ").toUpperCase();
71
+ }
72
+
73
+ /**
74
+ * Match validate_state.py: verdict only in preamble or under a ## Verdict heading.
75
+ *
76
+ * @param {string} text
77
+ * @returns {string | null}
78
+ */
79
+ export function findVerdict(text) {
80
+ const preamble = validationPreamble(text);
81
+ const inlineMatch = preamble.match(VERDICT_INLINE);
82
+ if (inlineMatch) {
83
+ return normalizeVerdict(inlineMatch[1]);
84
+ }
85
+
86
+ const headingMatch = visibleMarkdown(text).match(VERDICT_HEADING);
87
+ if (headingMatch) {
88
+ return normalizeVerdict(headingMatch[1]);
89
+ }
90
+
91
+ return null;
92
+ }
93
+
94
+ /**
95
+ * @param {string} text
96
+ * @returns {boolean}
97
+ */
98
+ export function isPassVerdict(text) {
99
+ const verdict = findVerdict(text);
100
+ return verdict !== null && PASS_VERDICTS.has(verdict);
101
+ }
@@ -0,0 +1,156 @@
1
+ import { execFile } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { promisify } from "node:util";
5
+
6
+ const execFileAsync = promisify(execFile);
7
+
8
+ export const WORKSPACES_ROOT = ".specs/workspaces";
9
+
10
+ /**
11
+ * @param {string} cwd
12
+ * @param {string} featureId
13
+ * @param {string} taskId
14
+ * @returns {string}
15
+ */
16
+ export function workspacePath(cwd, featureId, taskId) {
17
+ return path.join(cwd, WORKSPACES_ROOT, featureId, taskId);
18
+ }
19
+
20
+ /**
21
+ * @param {string} cwd
22
+ * @returns {Promise<boolean>}
23
+ */
24
+ export async function isGitRepository(cwd) {
25
+ try {
26
+ await execFileAsync("git", ["rev-parse", "--git-dir"], { cwd });
27
+ return true;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * @param {string} cwd
35
+ * @param {string} featureId
36
+ * @returns {Promise<string[]>}
37
+ */
38
+ export async function listWorkspacePaths(cwd, featureId) {
39
+ const base = path.join(cwd, WORKSPACES_ROOT, featureId);
40
+ try {
41
+ const entries = await fs.readdir(base, { withFileTypes: true });
42
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => path.join(base, entry.name));
43
+ } catch {
44
+ return [];
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Create isolated git worktrees for parallel Execute tasks.
50
+ *
51
+ * @param {string} cwd
52
+ * @param {{ featureId: string, taskIds: string[], baseRef?: string }} options
53
+ * @returns {Promise<Array<{ taskId: string, path: string, branch?: string, status: string, error?: string }>>}
54
+ */
55
+ export async function prepareWorkspaces(cwd, { featureId, taskIds, baseRef = "HEAD" }) {
56
+ if (!(await isGitRepository(cwd))) {
57
+ throw new Error("workspace-prepare requires a git repository");
58
+ }
59
+
60
+ if (!taskIds.length) {
61
+ throw new Error("workspace-prepare requires at least one task id (e.g. T1,T2)");
62
+ }
63
+
64
+ /** @type {Array<{ taskId: string, path: string, branch?: string, status: string, error?: string }>} */
65
+ const results = [];
66
+
67
+ for (const taskId of taskIds) {
68
+ const wtPath = workspacePath(cwd, featureId, taskId);
69
+ await fs.mkdir(path.dirname(wtPath), { recursive: true });
70
+
71
+ try {
72
+ await fs.access(wtPath);
73
+ results.push({ taskId, path: wtPath, status: "exists" });
74
+ continue;
75
+ } catch {
76
+ // create new worktree
77
+ }
78
+
79
+ const branchName = `guardrails/ws-${featureId}-${taskId}`;
80
+ try {
81
+ await execFileAsync(
82
+ "git",
83
+ ["worktree", "add", "-B", branchName, wtPath, baseRef],
84
+ { cwd },
85
+ );
86
+ results.push({ taskId, path: wtPath, branch: branchName, status: "created" });
87
+ } catch (err) {
88
+ const message = err instanceof Error ? err.message : String(err);
89
+ results.push({ taskId, path: wtPath, status: "failed", error: message });
90
+ }
91
+ }
92
+
93
+ return results;
94
+ }
95
+
96
+ /**
97
+ * Remove prepared worktrees for a feature.
98
+ *
99
+ * @param {string} cwd
100
+ * @param {{ featureId: string, taskIds?: string[], force?: boolean }} options
101
+ * @returns {Promise<Array<{ path: string, status: string, error?: string }>>}
102
+ */
103
+ export async function cleanupWorkspaces(cwd, { featureId, taskIds, force = false }) {
104
+ if (!(await isGitRepository(cwd))) {
105
+ throw new Error("workspace-cleanup requires a git repository");
106
+ }
107
+
108
+ const targets = taskIds?.length
109
+ ? taskIds.map((taskId) => workspacePath(cwd, featureId, taskId))
110
+ : await listWorkspacePaths(cwd, featureId);
111
+
112
+ /** @type {Array<{ path: string, status: string, error?: string }>} */
113
+ const results = [];
114
+
115
+ for (const wtPath of targets) {
116
+ try {
117
+ await fs.access(wtPath);
118
+ } catch {
119
+ results.push({ path: wtPath, status: "missing" });
120
+ continue;
121
+ }
122
+
123
+ const args = ["worktree", "remove", wtPath];
124
+ if (force) {
125
+ args.push("--force");
126
+ }
127
+
128
+ try {
129
+ await execFileAsync("git", args, { cwd });
130
+ results.push({ path: wtPath, status: "removed" });
131
+ } catch (err) {
132
+ const message = err instanceof Error ? err.message : String(err);
133
+ results.push({ path: wtPath, status: "failed", error: message });
134
+ }
135
+ }
136
+
137
+ return results;
138
+ }
139
+
140
+ /**
141
+ * @param {ReturnType<typeof prepareWorkspaces> extends Promise<infer T> ? T : never} results
142
+ * @param {{ json?: boolean }} [options]
143
+ */
144
+ export function formatWorkspaceResults(results, options = {}) {
145
+ if (options.json) {
146
+ return JSON.stringify({ workspaces: results }, null, 2);
147
+ }
148
+
149
+ const lines = ["Workspace isolation results:"];
150
+ for (const item of results) {
151
+ const branch = item.branch ? ` (${item.branch})` : "";
152
+ const error = item.error ? ` — ${item.error}` : "";
153
+ lines.push(` ${item.taskId ?? path.basename(item.path)}: ${item.status}${branch}${error}`);
154
+ }
155
+ return `${lines.join("\n")}\n`;
156
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luizsantiago/spec-guardrails",
3
- "version": "3.1.10",
3
+ "version": "3.2.0",
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",
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",
16
16
  "test:gates": "node test/run-gate-tests.mjs",
17
17
  "prepublishOnly": "npm test"
18
18
  },
@@ -74,14 +74,14 @@ class Report:
74
74
  print(f"[{self.gate}] {status} - {self.target}")
75
75
 
76
76
  for check in self.checks:
77
- print(f" ok {check}")
77
+ print(f" info {check}")
78
78
  for warning in self.warnings:
79
- print(f" warn {warning}")
79
+ print(f" warning {warning}")
80
80
  for error in self.errors:
81
- print(f" error {error}")
81
+ print(f" blocking {error}")
82
82
 
83
83
  if strict and self.warnings and self.passed:
84
- print(" error strict mode: warnings are treated as failures")
84
+ print(" blocking strict mode: warnings are treated as failures")
85
85
  return EXIT_FAILED
86
86
 
87
87
  if not self.passed:
@@ -91,6 +91,12 @@ class Report:
91
91
  )
92
92
  return EXIT_FAILED
93
93
 
94
+ if self.warnings:
95
+ print(
96
+ f"\n{len(self.warnings)} warning(s), 0 blocking — gate passed "
97
+ "(use --strict to treat warnings as blocking)"
98
+ )
99
+
94
100
  return EXIT_OK
95
101
 
96
102
 
@@ -71,6 +71,14 @@ DELTA_SECTIONS = (
71
71
  ("REMOVED Requirements", "removed"),
72
72
  )
73
73
  CLARIFICATION = re.compile(r"\[NEEDS CLARIFICATION(?:\s*:\s*[^\]]+)?\]", re.IGNORECASE)
74
+ AMBIGUOUS_TERMS = re.compile(
75
+ r"\b(etc\.?|and so on|as appropriate|as needed|somehow|maybe|when possible)\b",
76
+ re.IGNORECASE,
77
+ )
78
+ OUT_OF_SCOPE_HEADING = re.compile(
79
+ r"^(?P<level>#{2,6})\s*Out of Scope\b",
80
+ re.MULTILINE | re.IGNORECASE,
81
+ )
74
82
  REMOVED_ID = re.compile(r"^\s*(?:-\s*)?(?P<id>[A-Z][A-Z0-9]{1,9}-\d{2,4})\b", re.MULTILINE)
75
83
 
76
84
 
@@ -159,6 +167,11 @@ def validate_requirement_block(
159
167
 
160
168
  for item in criteria:
161
169
  excerpt = item if len(item) <= 70 else f"{item[:67]}..."
170
+ if AMBIGUOUS_TERMS.search(item):
171
+ report.warn(
172
+ f"{label} {requirement_id}: ambiguous acceptance criterion "
173
+ f"(prefer concrete, testable language): '{excerpt}'"
174
+ )
162
175
  if not NORMATIVE_VERB.search(item):
163
176
  report.error(
164
177
  f"{label} {requirement_id}: criterion is not testable, it states no "
@@ -248,6 +261,21 @@ def acceptance_lines(body: str) -> list[str]:
248
261
  return lines
249
262
 
250
263
 
264
+ def validate_out_of_scope(report: Report, text: str) -> None:
265
+ body = section_body(text, OUT_OF_SCOPE_HEADING)
266
+ if body is None:
267
+ return
268
+
269
+ stripped = body.strip()
270
+ if not stripped or re.fullmatch(r"-\s*(none|n/a)\s*", stripped, re.IGNORECASE):
271
+ report.warn(
272
+ "Out of Scope section is empty or 'none' — explicit scope boundaries are recommended"
273
+ )
274
+ return
275
+
276
+ report.ok("Out of Scope section documents explicit boundaries")
277
+
278
+
251
279
  def build_report(target: str, text: str) -> Report:
252
280
  report = Report(gate=GATE, target=target)
253
281
  visible = visible_markdown(text)
@@ -299,6 +327,8 @@ def build_report(target: str, text: str) -> Report:
299
327
  else:
300
328
  validate_requirement_block(report, requirements, "Requirements")
301
329
 
330
+ validate_out_of_scope(report, visible)
331
+
302
332
  for malformed in MALFORMED_ID.finditer(visible):
303
333
  raw = malformed.group(0).lstrip("# ").strip()
304
334
  if not REQUIREMENT_HEADING.match(f"### {raw}"):
@@ -26,8 +26,29 @@ Orchestrate tasks from `tasks.md` and `task-graph.md`: **parallel waves with sub
26
26
  3. Run `python3 .specs/guardrails/scripts/validate_tasks.py` when a formal `tasks.md` exists.
27
27
  4. If Tasks was skipped, list the atomic steps inline now. More than 5 steps or real dependencies means the Tasks phase was skipped in error — stop and create `tasks.md`.
28
28
  5. **Plan the wave** — run `python3 .specs/guardrails/scripts/loop_plan.py [feature]` (or `loop-plan --json`) at the start of Execute and after every batch completes. It lists the next runnable tasks and marks **parallel groups** (disjoint `Files`) vs inline work.
29
- 6. When `loop-plan` shows a **parallel group** (2+ tasks), offer sub-agent dispatch per `task-graph-engineering.md` and `sub-agents.md`. Offer and wait; never auto-spawn. Large features (roughly 8+ tasks total) also warrant batching across waves.
30
- 7. Confirm you are the only writer for each file this task names. Two parallel tasks never share a file in the same round.
29
+ 6. When `loop-plan` shows a **parallel group** (2+ tasks), prepare isolated workspaces before dispatch:
30
+
31
+ ```bash
32
+ npx @luizsantiago/spec-guardrails workspace-prepare [feature] --tasks T1,T2
33
+ ```
34
+
35
+ Each worker runs in its own git worktree under `.specs/workspaces/[feature]/`. After local verification and merge, clean up:
36
+
37
+ ```bash
38
+ npx @luizsantiago/spec-guardrails workspace-cleanup [feature] --tasks T1,T2 --force
39
+ ```
40
+
41
+ 7. Consult execution policy before touching files outside the task list:
42
+
43
+ ```bash
44
+ npx @luizsantiago/spec-guardrails execution-policy check-path src/auth.ts
45
+ npx @luizsantiago/spec-guardrails execution-policy status
46
+ ```
47
+
48
+ Record gate retries with `execution-policy record-retry T1` when a task fails its gate (respects `max_retries_per_task` in `.specs/config.yaml`).
49
+
50
+ 8. When `loop-plan` shows a **parallel group** (2+ tasks), offer sub-agent dispatch per `task-graph-engineering.md` and `sub-agents.md`. Offer and wait; never auto-spawn. Large features (roughly 8+ tasks total) also warrant batching across waves.
51
+ 9. Confirm you are the only writer for each file this task names. Two parallel tasks never share a file in the same round.
31
52
 
32
53
  ## Orchestration (each /loop round)
33
54
 
@@ -35,12 +35,15 @@ Hand each worker exactly this and nothing else from sibling features:
35
35
  - Feature: [feature]
36
36
  - Batch: B[n] of [total]
37
37
  - Tasks: T[a] … T[b] (in order)
38
+ - Workspace: `.specs/workspaces/[feature]/T[n]` (git worktree — do not edit the main tree)
38
39
  - Spec excerpt: only the REQ IDs those tasks name
39
40
  - Files ownership: the union of those tasks' Files fields (exclusive)
40
41
  - Constraints: AD-NNN that apply; confirmed lessons that apply
41
42
  - Forbidden: git push, deploy, editing files outside ownership, spawning further sub-agents
42
43
  ```
43
44
 
45
+ Before dispatch, the orchestrator runs `workspace-prepare [feature] --tasks …`. Each worker verifies and commits inside its worktree only. The merge owner integrates into the main tree, runs the project harness once, then `workspace-cleanup [feature] --force`.
46
+
44
47
  Load per `context-limits.md`. Do not paste the author's chat.
45
48
 
46
49
  ## Worker loop
@@ -21,6 +21,24 @@ rules:
21
21
  verify:
22
22
  - Evidence must cite test file:line paths
23
23
 
24
+ # Execution policy (optional — soft enforcement via CLI + skills)
25
+ budget:
26
+ max_iterations: 5
27
+ max_agent_runs: 12
28
+ max_retries_per_task: 3
29
+
30
+ scope:
31
+ allowed_paths: []
32
+ denied_paths:
33
+ - "**/.env"
34
+ - "**/secrets/**"
35
+ - "**/production/**"
36
+
37
+ escalation:
38
+ on_scope_expansion: human
39
+ on_budget_exhaustion: stop
40
+ on_policy_violation: block
41
+
24
42
  # Project-specific overrides (appended on top of preset + rules above):
25
43
  # overrides:
26
44
  # rules: