@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 +15 -2
- package/index.js +163 -0
- package/lib/archive.js +2 -10
- package/lib/assets.js +11 -7
- package/lib/download.js +2 -2
- package/lib/execution-policy.js +291 -0
- package/lib/feature-status.js +2 -12
- package/lib/feature.js +35 -6
- package/lib/fs-utils.js +65 -7
- package/lib/install.js +1 -1
- package/lib/memory.js +6 -1
- package/lib/project-rules.js +2 -2
- package/lib/validation-verdict.js +101 -0
- package/lib/workspace-isolation.js +156 -0
- package/package.json +2 -2
- package/scripts/_common.py +10 -4
- package/scripts/validate_spec.py +30 -0
- package/skills/references/implement.md +23 -2
- package/skills/references/sub-agents.md +3 -0
- package/templates/config.yaml.example +18 -0
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.
|
|
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.
|
|
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 (!
|
|
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
|
+
}
|
package/lib/feature-status.js
CHANGED
|
@@ -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 =
|
|
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
|
|
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(
|
|
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 };
|
package/lib/project-rules.js
CHANGED
|
@@ -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.
|
|
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
|
},
|
package/scripts/_common.py
CHANGED
|
@@ -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"
|
|
77
|
+
print(f" info {check}")
|
|
78
78
|
for warning in self.warnings:
|
|
79
|
-
print(f"
|
|
79
|
+
print(f" warning {warning}")
|
|
80
80
|
for error in self.errors:
|
|
81
|
-
print(f"
|
|
81
|
+
print(f" blocking {error}")
|
|
82
82
|
|
|
83
83
|
if strict and self.warnings and self.passed:
|
|
84
|
-
print("
|
|
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
|
|
package/scripts/validate_spec.py
CHANGED
|
@@ -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),
|
|
30
|
-
|
|
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:
|