@luizsantiago/spec-guardrails 3.2.1 → 3.4.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 +3 -1
- package/index.js +123 -5
- package/lib/constants.js +1 -0
- package/lib/context-guard.js +340 -0
- package/lib/execution-policy.js +174 -22
- package/lib/gates.js +1 -0
- package/package.json +2 -2
- package/scripts/memory_search.py +119 -0
- package/skills/agent-architecture.md +3 -1
- package/skills/git-handoff.md +1 -0
- package/skills/references/implement.md +6 -2
- package/templates/config.yaml.example +10 -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.4.x**
|
|
15
15
|
|
|
16
16
|
---
|
|
17
17
|
|
|
@@ -217,6 +217,8 @@ npx @luizsantiago/spec-guardrails install
|
|
|
217
217
|
|
|
218
218
|
| Version | What you gain |
|
|
219
219
|
| --- | --- |
|
|
220
|
+
| **3.4.x** | Contextual guards (`context-guard`); FTS memory search (`memory-search`) |
|
|
221
|
+
| **3.3.x** | Intent/effect policy — `check-path --op read|write|delete` and `effects` config block |
|
|
220
222
|
| **3.2.x** | Single-package focus; artifact gate severity labels; git worktree isolation CLI; execution policy (budget/scope/escalation) |
|
|
221
223
|
| **3.1.x** | Copilot/Codex/AGENTS.md adapters; doctor Process + Brakes scores; `validate-traceability` / `validate-quick`; `classify-change` / `feature-status` |
|
|
222
224
|
| **3.0.x** | Final name Spec Guardrails; `.specs/guardrails/`; no dual-path ([Migration](docs/guide/Migration.md)) |
|
package/index.js
CHANGED
|
@@ -5,6 +5,13 @@ import path from "node:path";
|
|
|
5
5
|
import { archiveFeature } from "./lib/archive.js";
|
|
6
6
|
import { projectInit } from "./lib/brownfield.js";
|
|
7
7
|
import { classifyChange, formatClassifyChange } from "./lib/classify-change.js";
|
|
8
|
+
import {
|
|
9
|
+
checkBeforeComplete,
|
|
10
|
+
checkBeforeEdit,
|
|
11
|
+
evaluateExecuteContext,
|
|
12
|
+
formatCheckBeforeEdit,
|
|
13
|
+
formatContextGuardStatus,
|
|
14
|
+
} from "./lib/context-guard.js";
|
|
8
15
|
import { PACKAGE_VERSION, CLI_NAME } from "./lib/constants.js";
|
|
9
16
|
import { phaseContext } from "./lib/config.js";
|
|
10
17
|
import { doctor } from "./lib/doctor.js";
|
|
@@ -81,6 +88,7 @@ Commands:
|
|
|
81
88
|
execution-policy status Show configured budgets, scope, and runtime counters
|
|
82
89
|
[--json] Machine-readable output
|
|
83
90
|
execution-policy check-path <path> Check whether a relative path is allowed by scope policy
|
|
91
|
+
[--op read|write|delete] Intended operation (default: inferred from path)
|
|
84
92
|
[--json] Machine-readable output
|
|
85
93
|
execution-policy record-retry <task> Increment retry counter for a task id (blocks at limit)
|
|
86
94
|
execution-policy record-run Increment agent-run counter (blocks at budget)
|
|
@@ -88,6 +96,18 @@ Commands:
|
|
|
88
96
|
memory-query --from <id> Bounded context package from the knowledge graph
|
|
89
97
|
[--depth N] Traversal depth (default 2)
|
|
90
98
|
[--json] Machine-readable output
|
|
99
|
+
memory-search <query> Full-text search over the memory index (FTS5)
|
|
100
|
+
[--limit N] Max results (default 10)
|
|
101
|
+
[--json] Machine-readable output
|
|
102
|
+
context-guard status Execute readiness from STATE + tasks.md
|
|
103
|
+
[--json] Machine-readable output
|
|
104
|
+
context-guard check-edit <path> Contextual guard before editing a file
|
|
105
|
+
[--op read|write|delete] Intended operation (default: inferred)
|
|
106
|
+
[--no-strict-files] Skip task Files allowlist check
|
|
107
|
+
[--json] Machine-readable output
|
|
108
|
+
context-guard check-complete Contextual guard before claiming feature done
|
|
109
|
+
[feature] Feature id (default: active in STATE)
|
|
110
|
+
[--json] Machine-readable output
|
|
91
111
|
validate-spec [spec.md|feature] Closure gate for a feature spec
|
|
92
112
|
analyze-artifacts [feature] Cross-artifact consistency before task approval
|
|
93
113
|
validate-tasks [tasks.md|feature] Granularity gate for a task breakdown
|
|
@@ -482,20 +502,41 @@ if (command === "--version" || command === "-v" || command === "version") {
|
|
|
482
502
|
if (sub === "status") {
|
|
483
503
|
process.stdout.write(formatPolicyStatus(policy, state, { json }));
|
|
484
504
|
} else if (sub === "check-path") {
|
|
485
|
-
|
|
505
|
+
let operation;
|
|
506
|
+
const positional = [];
|
|
507
|
+
|
|
508
|
+
for (let i = 0; i < rest.length; i++) {
|
|
509
|
+
const arg = rest[i];
|
|
510
|
+
if (arg === "--op") {
|
|
511
|
+
operation = rest[++i];
|
|
512
|
+
if (!operation) {
|
|
513
|
+
throw new Error("--op requires read, write, or delete");
|
|
514
|
+
}
|
|
515
|
+
} else {
|
|
516
|
+
positional.push(arg);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const relativePath = positional[0];
|
|
486
521
|
if (!relativePath) {
|
|
487
|
-
throw new Error(
|
|
522
|
+
throw new Error(
|
|
523
|
+
"Usage: execution-policy check-path <relative-path> [--op read|write|delete]",
|
|
524
|
+
);
|
|
488
525
|
}
|
|
489
|
-
const result = resolvePathCheck(relativePath, policy);
|
|
526
|
+
const result = resolvePathCheck(relativePath, policy, { operation });
|
|
490
527
|
if (json) {
|
|
491
528
|
console.log(JSON.stringify({ path: relativePath, ...result }, null, 2));
|
|
492
529
|
} else {
|
|
493
530
|
const label = result.allowed
|
|
494
|
-
? "
|
|
531
|
+
? result.severity === "warning"
|
|
532
|
+
? "allowed (warn)"
|
|
533
|
+
: "allowed"
|
|
495
534
|
: result.severity === "warning"
|
|
496
535
|
? "blocked (warn)"
|
|
497
536
|
: "blocked";
|
|
498
|
-
console.log(
|
|
537
|
+
console.log(
|
|
538
|
+
`${relativePath} [${result.operation}]: ${label} (${result.reason})`,
|
|
539
|
+
);
|
|
499
540
|
}
|
|
500
541
|
if (result.exitCode !== 0) {
|
|
501
542
|
process.exit(result.exitCode);
|
|
@@ -543,6 +584,83 @@ if (command === "--version" || command === "-v" || command === "version") {
|
|
|
543
584
|
console.error(`❌ ${err.message}`);
|
|
544
585
|
process.exit(1);
|
|
545
586
|
}
|
|
587
|
+
} else if (command === "context-guard") {
|
|
588
|
+
try {
|
|
589
|
+
const sub = args[0];
|
|
590
|
+
let json = false;
|
|
591
|
+
const rest = [];
|
|
592
|
+
|
|
593
|
+
for (let i = 1; i < args.length; i++) {
|
|
594
|
+
if (args[i] === "--json") {
|
|
595
|
+
json = true;
|
|
596
|
+
} else {
|
|
597
|
+
rest.push(args[i]);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const cwd = process.cwd();
|
|
602
|
+
|
|
603
|
+
if (sub === "status") {
|
|
604
|
+
const context = await evaluateExecuteContext(cwd);
|
|
605
|
+
process.stdout.write(formatContextGuardStatus(context, { json }));
|
|
606
|
+
if (!context.ok && context.severity === "blocking") {
|
|
607
|
+
process.exit(1);
|
|
608
|
+
}
|
|
609
|
+
} else if (sub === "check-edit") {
|
|
610
|
+
let operation;
|
|
611
|
+
let strictFiles = true;
|
|
612
|
+
const positional = [];
|
|
613
|
+
|
|
614
|
+
for (let i = 0; i < rest.length; i++) {
|
|
615
|
+
const arg = rest[i];
|
|
616
|
+
if (arg === "--no-strict-files") {
|
|
617
|
+
strictFiles = false;
|
|
618
|
+
} else if (arg === "--op") {
|
|
619
|
+
operation = rest[++i];
|
|
620
|
+
if (!operation) {
|
|
621
|
+
throw new Error("--op requires read, write, or delete");
|
|
622
|
+
}
|
|
623
|
+
} else {
|
|
624
|
+
positional.push(arg);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const relativePath = positional[0];
|
|
629
|
+
if (!relativePath) {
|
|
630
|
+
throw new Error(
|
|
631
|
+
"Usage: context-guard check-edit <relative-path> [--op read|write|delete] [--no-strict-files]",
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const result = await checkBeforeEdit(cwd, relativePath, { operation, strictFiles });
|
|
636
|
+
process.stdout.write(formatCheckBeforeEdit(result, relativePath, { json }));
|
|
637
|
+
if (result.exitCode !== 0) {
|
|
638
|
+
process.exit(result.exitCode);
|
|
639
|
+
}
|
|
640
|
+
} else if (sub === "check-complete") {
|
|
641
|
+
const featureId = rest[0];
|
|
642
|
+
const result = await checkBeforeComplete(cwd, featureId);
|
|
643
|
+
if (json) {
|
|
644
|
+
console.log(JSON.stringify(result, null, 2));
|
|
645
|
+
} else {
|
|
646
|
+
const label = result.allowed ? "ready" : "blocked";
|
|
647
|
+
console.log(`Feature ${result.featureId}: ${label}`);
|
|
648
|
+
for (const message of result.messages) {
|
|
649
|
+
console.log(` ${message}`);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
if (result.exitCode !== 0) {
|
|
653
|
+
process.exit(result.exitCode);
|
|
654
|
+
}
|
|
655
|
+
} else {
|
|
656
|
+
throw new Error(
|
|
657
|
+
"Usage: context-guard status | check-edit <path> | check-complete [feature]",
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
} catch (err) {
|
|
661
|
+
console.error(`❌ ${err.message}`);
|
|
662
|
+
process.exit(1);
|
|
663
|
+
}
|
|
546
664
|
} else if (command === "classify-change") {
|
|
547
665
|
try {
|
|
548
666
|
let json = false;
|
package/lib/constants.js
CHANGED
|
@@ -110,6 +110,7 @@ export const SCRIPT_ASSETS = [
|
|
|
110
110
|
{ file: "loop_plan.py", remotePath: "scripts/loop_plan.py" },
|
|
111
111
|
{ file: "memory_index.py", remotePath: "scripts/memory_index.py" },
|
|
112
112
|
{ file: "memory_query.py", remotePath: "scripts/memory_query.py" },
|
|
113
|
+
{ file: "memory_search.py", remotePath: "scripts/memory_search.py" },
|
|
113
114
|
];
|
|
114
115
|
|
|
115
116
|
/** @type {{ file: string, remotePath: string }[]} */
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { loadExecutionPolicy, resolvePathCheck } from "./execution-policy.js";
|
|
4
|
+
import { readFileSafe } from "./fs-utils.js";
|
|
5
|
+
import { featureDir, readActiveFeatureFromState, resolveFeatureId } from "./specs-utils.js";
|
|
6
|
+
import { findVerdict } from "./validation-verdict.js";
|
|
7
|
+
|
|
8
|
+
const TASK_FILES = /^\s*[-*]?\s*\*{0,2}Files\*{0,2}\s*:\s*(.+)$/gim;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} cwd
|
|
12
|
+
* @returns {Promise<{ featureId: string | null, phase: string | null }>}
|
|
13
|
+
*/
|
|
14
|
+
export async function readStateContext(cwd) {
|
|
15
|
+
const statePath = path.join(cwd, ".specs/STATE.md");
|
|
16
|
+
let content = "";
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
content = await readFileSafe(statePath);
|
|
20
|
+
} catch {
|
|
21
|
+
return { featureId: null, phase: null };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const featureId = await readActiveFeatureFromState(cwd);
|
|
25
|
+
const phaseMatch = content.match(/^-\s*Phase:\s*(.+)$/m);
|
|
26
|
+
const phase = phaseMatch ? phaseMatch[1].trim() : null;
|
|
27
|
+
|
|
28
|
+
return { featureId, phase: phase && !/^—|-$/i.test(phase) ? phase : null };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} tasksText
|
|
33
|
+
* @returns {number}
|
|
34
|
+
*/
|
|
35
|
+
export function countOpenTasks(tasksText) {
|
|
36
|
+
return [...tasksText.matchAll(/^\s*[-*]\s*\[ \]\s+/gm)].length;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {string} tasksText
|
|
41
|
+
* @returns {Set<string>}
|
|
42
|
+
*/
|
|
43
|
+
export function collectTaskFilePaths(tasksText) {
|
|
44
|
+
/** @type {Set<string>} */
|
|
45
|
+
const files = new Set();
|
|
46
|
+
|
|
47
|
+
for (const match of tasksText.matchAll(TASK_FILES)) {
|
|
48
|
+
const raw = match[1].trim();
|
|
49
|
+
if (!raw || /^—|-$|none|n\/a$/i.test(raw)) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
for (const part of raw.split(/[,;]/)) {
|
|
53
|
+
const cleaned = part.trim().replace(/^`|`$/g, "");
|
|
54
|
+
if (cleaned) {
|
|
55
|
+
files.add(cleaned.replace(/\\/g, "/"));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return files;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {string} relativePath
|
|
65
|
+
* @param {Set<string>} approvedFiles
|
|
66
|
+
* @returns {boolean}
|
|
67
|
+
*/
|
|
68
|
+
export function pathInApprovedTaskFiles(relativePath, approvedFiles) {
|
|
69
|
+
if (!approvedFiles.size) {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const normalized = relativePath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
74
|
+
for (const approved of approvedFiles) {
|
|
75
|
+
const pattern = approved.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*");
|
|
76
|
+
if (new RegExp(`^${pattern}$`).test(normalized)) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
if (normalized === approved || normalized.endsWith(`/${approved}`)) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {string} cwd
|
|
88
|
+
* @param {{ featureId?: string }} [options]
|
|
89
|
+
* @returns {Promise<{
|
|
90
|
+
* ok: boolean,
|
|
91
|
+
* severity: "blocking" | "warning" | "info",
|
|
92
|
+
* featureId: string | null,
|
|
93
|
+
* phase: string | null,
|
|
94
|
+
* openTasks: number,
|
|
95
|
+
* messages: string[],
|
|
96
|
+
* }>}
|
|
97
|
+
*/
|
|
98
|
+
export async function evaluateExecuteContext(cwd, options = {}) {
|
|
99
|
+
const messages = [];
|
|
100
|
+
const state = await readStateContext(cwd);
|
|
101
|
+
let featureId = options.featureId ?? state.featureId;
|
|
102
|
+
|
|
103
|
+
if (!featureId) {
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
severity: "blocking",
|
|
107
|
+
featureId: null,
|
|
108
|
+
phase: state.phase,
|
|
109
|
+
openTasks: 0,
|
|
110
|
+
messages: ["no active feature in .specs/STATE.md — run feature-init or /specify first"],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const tasksPath = path.join(featureDir(featureId, cwd), "tasks.md");
|
|
115
|
+
let tasksText = "";
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
tasksText = await readFileSafe(tasksPath);
|
|
119
|
+
} catch {
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
severity: "blocking",
|
|
123
|
+
featureId,
|
|
124
|
+
phase: state.phase,
|
|
125
|
+
openTasks: 0,
|
|
126
|
+
messages: [`tasks.md missing for active feature ${featureId}`],
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const openTasks = countOpenTasks(tasksText);
|
|
131
|
+
if (openTasks === 0) {
|
|
132
|
+
messages.push("no open tasks in tasks.md — Execute may be complete; run /verify instead");
|
|
133
|
+
} else {
|
|
134
|
+
messages.push(`${openTasks} open task(s) in tasks.md`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const severity = openTasks === 0 ? "warning" : "info";
|
|
138
|
+
return {
|
|
139
|
+
ok: openTasks > 0,
|
|
140
|
+
severity,
|
|
141
|
+
featureId,
|
|
142
|
+
phase: state.phase,
|
|
143
|
+
openTasks,
|
|
144
|
+
messages,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {string} cwd
|
|
150
|
+
* @param {string} relativePath
|
|
151
|
+
* @param {{ operation?: string, featureId?: string, strictFiles?: boolean }} [options]
|
|
152
|
+
*/
|
|
153
|
+
export async function checkBeforeEdit(cwd, relativePath, options = {}) {
|
|
154
|
+
const execute = await evaluateExecuteContext(cwd, { featureId: options.featureId });
|
|
155
|
+
const policy = await loadExecutionPolicy(cwd);
|
|
156
|
+
const pathCheck = resolvePathCheck(relativePath, policy, {
|
|
157
|
+
operation: options.operation,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
/** @type {string[]} */
|
|
161
|
+
const messages = [...execute.messages];
|
|
162
|
+
|
|
163
|
+
if (!execute.ok && execute.severity === "blocking") {
|
|
164
|
+
return {
|
|
165
|
+
allowed: false,
|
|
166
|
+
exitCode: 1,
|
|
167
|
+
severity: "blocking",
|
|
168
|
+
operation: pathCheck.operation,
|
|
169
|
+
featureId: execute.featureId,
|
|
170
|
+
messages,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!pathCheck.allowed) {
|
|
175
|
+
messages.push(pathCheck.reason);
|
|
176
|
+
return {
|
|
177
|
+
allowed: false,
|
|
178
|
+
exitCode: pathCheck.exitCode,
|
|
179
|
+
severity: pathCheck.severity,
|
|
180
|
+
operation: pathCheck.operation,
|
|
181
|
+
featureId: execute.featureId,
|
|
182
|
+
messages,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (options.strictFiles !== false && execute.featureId) {
|
|
187
|
+
const tasksText = await readFileSafe(
|
|
188
|
+
path.join(featureDir(execute.featureId, cwd), "tasks.md"),
|
|
189
|
+
);
|
|
190
|
+
const approved = collectTaskFilePaths(tasksText);
|
|
191
|
+
if (approved.size && !pathInApprovedTaskFiles(relativePath, approved)) {
|
|
192
|
+
messages.push("path is not listed in any task Files field — escalate or update tasks.md");
|
|
193
|
+
const mode = policy.escalation.on_scope_expansion ?? "human";
|
|
194
|
+
if (mode === "human") {
|
|
195
|
+
return {
|
|
196
|
+
allowed: false,
|
|
197
|
+
exitCode: 1,
|
|
198
|
+
severity: "blocking",
|
|
199
|
+
operation: pathCheck.operation,
|
|
200
|
+
featureId: execute.featureId,
|
|
201
|
+
messages,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (pathCheck.severity === "warning") {
|
|
208
|
+
messages.push(pathCheck.reason);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!execute.ok && execute.severity === "warning") {
|
|
212
|
+
messages.push(...execute.messages);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
allowed: true,
|
|
217
|
+
exitCode: 0,
|
|
218
|
+
severity: pathCheck.severity === "warning" || execute.severity === "warning" ? "warning" : "info",
|
|
219
|
+
operation: pathCheck.operation,
|
|
220
|
+
featureId: execute.featureId,
|
|
221
|
+
messages,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* @param {string} cwd
|
|
227
|
+
* @param {string} [featureRaw]
|
|
228
|
+
*/
|
|
229
|
+
export async function checkBeforeComplete(cwd, featureRaw) {
|
|
230
|
+
const featureId = featureRaw ? await resolveFeatureId(featureRaw, cwd) : await resolveFeatureId(undefined, cwd);
|
|
231
|
+
const dir = featureDir(featureId, cwd);
|
|
232
|
+
/** @type {string[]} */
|
|
233
|
+
const messages = [];
|
|
234
|
+
|
|
235
|
+
let tasksText = "";
|
|
236
|
+
try {
|
|
237
|
+
tasksText = await readFileSafe(path.join(dir, "tasks.md"));
|
|
238
|
+
} catch {
|
|
239
|
+
return {
|
|
240
|
+
allowed: false,
|
|
241
|
+
exitCode: 1,
|
|
242
|
+
severity: "blocking",
|
|
243
|
+
featureId,
|
|
244
|
+
messages: ["tasks.md missing — cannot claim completion"],
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const openTasks = countOpenTasks(tasksText);
|
|
249
|
+
if (openTasks > 0) {
|
|
250
|
+
messages.push(`${openTasks} task(s) still open in tasks.md`);
|
|
251
|
+
return {
|
|
252
|
+
allowed: false,
|
|
253
|
+
exitCode: 1,
|
|
254
|
+
severity: "blocking",
|
|
255
|
+
featureId,
|
|
256
|
+
messages,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const validationPath = path.join(dir, "validation.md");
|
|
261
|
+
let validationText = "";
|
|
262
|
+
try {
|
|
263
|
+
validationText = await readFileSafe(validationPath);
|
|
264
|
+
} catch {
|
|
265
|
+
messages.push("validation.md missing — run independent /verify first");
|
|
266
|
+
return {
|
|
267
|
+
allowed: false,
|
|
268
|
+
exitCode: 1,
|
|
269
|
+
severity: "blocking",
|
|
270
|
+
featureId,
|
|
271
|
+
messages,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const verdict = findVerdict(validationText);
|
|
276
|
+
if (!verdict || !["PASS", "PASSED"].includes(verdict)) {
|
|
277
|
+
messages.push(verdict ? `validation verdict is ${verdict}, not PASS` : "validation.md has no PASS/FAIL verdict");
|
|
278
|
+
return {
|
|
279
|
+
allowed: false,
|
|
280
|
+
exitCode: 1,
|
|
281
|
+
severity: "blocking",
|
|
282
|
+
featureId,
|
|
283
|
+
messages,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
messages.push("tasks complete and validation PASS recorded");
|
|
288
|
+
return {
|
|
289
|
+
allowed: true,
|
|
290
|
+
exitCode: 0,
|
|
291
|
+
severity: "info",
|
|
292
|
+
featureId,
|
|
293
|
+
messages,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* @param {Awaited<ReturnType<typeof evaluateExecuteContext>>} context
|
|
299
|
+
* @param {{ json?: boolean }} [options]
|
|
300
|
+
*/
|
|
301
|
+
export function formatContextGuardStatus(context, options = {}) {
|
|
302
|
+
if (options.json) {
|
|
303
|
+
return JSON.stringify(context, null, 2);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const lines = ["Context guard status:"];
|
|
307
|
+
lines.push(` feature: ${context.featureId ?? "(none)"}`);
|
|
308
|
+
lines.push(` phase: ${context.phase ?? "(unknown)"}`);
|
|
309
|
+
lines.push(` open_tasks: ${context.openTasks}`);
|
|
310
|
+
lines.push(` execute_ready: ${context.ok ? "yes" : "no"}`);
|
|
311
|
+
for (const message of context.messages) {
|
|
312
|
+
lines.push(` note: ${message}`);
|
|
313
|
+
}
|
|
314
|
+
return `${lines.join("\n")}\n`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* @param {Awaited<ReturnType<typeof checkBeforeEdit>>} result
|
|
319
|
+
* @param {string} relativePath
|
|
320
|
+
* @param {{ json?: boolean }} [options]
|
|
321
|
+
*/
|
|
322
|
+
export function formatCheckBeforeEdit(result, relativePath, options = {}) {
|
|
323
|
+
if (options.json) {
|
|
324
|
+
return JSON.stringify({ path: relativePath, ...result }, null, 2);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const label = result.allowed
|
|
328
|
+
? result.severity === "warning"
|
|
329
|
+
? "allowed (warn)"
|
|
330
|
+
: "allowed"
|
|
331
|
+
: result.severity === "warning"
|
|
332
|
+
? "blocked (warn)"
|
|
333
|
+
: "blocked";
|
|
334
|
+
|
|
335
|
+
const lines = [`${relativePath} [${result.operation}]: ${label}`];
|
|
336
|
+
for (const message of result.messages) {
|
|
337
|
+
lines.push(` ${message}`);
|
|
338
|
+
}
|
|
339
|
+
return `${lines.join("\n")}\n`;
|
|
340
|
+
}
|
package/lib/execution-policy.js
CHANGED
|
@@ -21,6 +21,14 @@ export const DEFAULT_POLICY = {
|
|
|
21
21
|
on_budget_exhaustion: "stop",
|
|
22
22
|
on_policy_violation: "block",
|
|
23
23
|
},
|
|
24
|
+
effects: {
|
|
25
|
+
deny_read: [],
|
|
26
|
+
deny_write: [],
|
|
27
|
+
deny_delete: ["**/.git/**"],
|
|
28
|
+
warn_read: [],
|
|
29
|
+
warn_write: [],
|
|
30
|
+
warn_delete: [],
|
|
31
|
+
},
|
|
24
32
|
};
|
|
25
33
|
|
|
26
34
|
/**
|
|
@@ -28,9 +36,15 @@ export const DEFAULT_POLICY = {
|
|
|
28
36
|
* budget: { max_iterations: number, max_agent_runs: number, max_retries_per_task: number },
|
|
29
37
|
* scope: { allowed_paths: string[], denied_paths: string[] },
|
|
30
38
|
* escalation: { on_scope_expansion: string, on_budget_exhaustion: string, on_policy_violation: string },
|
|
39
|
+
* effects: {
|
|
40
|
+
* deny_read: string[], deny_write: string[], deny_delete: string[],
|
|
41
|
+
* warn_read: string[], warn_write: string[], warn_delete: string[],
|
|
42
|
+
* },
|
|
31
43
|
* }} ExecutionPolicy
|
|
32
44
|
*/
|
|
33
45
|
|
|
46
|
+
/** @typedef {"read" | "write" | "delete"} PathOperation */
|
|
47
|
+
|
|
34
48
|
/**
|
|
35
49
|
* @typedef {{
|
|
36
50
|
* iterations: number,
|
|
@@ -48,7 +62,7 @@ export const DEFAULT_POLICY = {
|
|
|
48
62
|
export function parseExecutionPolicySections(text) {
|
|
49
63
|
/** @type {Partial<ExecutionPolicy>} */
|
|
50
64
|
const result = {};
|
|
51
|
-
/** @type {"budget" | "scope" | "escalation" | null} */
|
|
65
|
+
/** @type {"budget" | "scope" | "escalation" | "effects" | null} */
|
|
52
66
|
let section = null;
|
|
53
67
|
/** @type {string | null} */
|
|
54
68
|
let listKey = null;
|
|
@@ -59,9 +73,9 @@ export function parseExecutionPolicySections(text) {
|
|
|
59
73
|
continue;
|
|
60
74
|
}
|
|
61
75
|
|
|
62
|
-
const sectionMatch = trimmed.match(/^(budget|scope|escalation):\s*$/);
|
|
76
|
+
const sectionMatch = trimmed.match(/^(budget|scope|escalation|effects):\s*$/);
|
|
63
77
|
if (sectionMatch) {
|
|
64
|
-
section = /** @type {"budget" | "scope" | "escalation"} */ (sectionMatch[1]);
|
|
78
|
+
section = /** @type {"budget" | "scope" | "escalation" | "effects"} */ (sectionMatch[1]);
|
|
65
79
|
result[section] = result[section] ?? {};
|
|
66
80
|
listKey = null;
|
|
67
81
|
continue;
|
|
@@ -112,6 +126,14 @@ export function mergeExecutionPolicy(base, overlay) {
|
|
|
112
126
|
denied_paths: overlay.scope?.denied_paths ?? base.scope.denied_paths,
|
|
113
127
|
},
|
|
114
128
|
escalation: { ...base.escalation, ...(overlay.escalation ?? {}) },
|
|
129
|
+
effects: {
|
|
130
|
+
deny_read: overlay.effects?.deny_read ?? base.effects.deny_read,
|
|
131
|
+
deny_write: overlay.effects?.deny_write ?? base.effects.deny_write,
|
|
132
|
+
deny_delete: overlay.effects?.deny_delete ?? base.effects.deny_delete,
|
|
133
|
+
warn_read: overlay.effects?.warn_read ?? base.effects.warn_read,
|
|
134
|
+
warn_write: overlay.effects?.warn_write ?? base.effects.warn_write,
|
|
135
|
+
warn_delete: overlay.effects?.warn_delete ?? base.effects.warn_delete,
|
|
136
|
+
},
|
|
115
137
|
};
|
|
116
138
|
}
|
|
117
139
|
|
|
@@ -191,6 +213,154 @@ export function checkPathScope(relativePath, policy) {
|
|
|
191
213
|
};
|
|
192
214
|
}
|
|
193
215
|
|
|
216
|
+
const PATH_OPERATIONS = new Set(["read", "write", "delete"]);
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* @param {string | undefined | null} raw
|
|
220
|
+
* @returns {PathOperation}
|
|
221
|
+
*/
|
|
222
|
+
export function normalizePathOperation(raw) {
|
|
223
|
+
const value = String(raw ?? "write").trim().toLowerCase();
|
|
224
|
+
if (!PATH_OPERATIONS.has(value)) {
|
|
225
|
+
throw new Error(`unknown operation '${raw}' — use read, write, or delete`);
|
|
226
|
+
}
|
|
227
|
+
return /** @type {PathOperation} */ (value);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Heuristic intent when the agent did not declare an operation explicitly.
|
|
232
|
+
*
|
|
233
|
+
* @param {string} relativePath
|
|
234
|
+
* @returns {PathOperation}
|
|
235
|
+
*/
|
|
236
|
+
export function inferPathOperation(relativePath) {
|
|
237
|
+
const normalized = relativePath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
238
|
+
const readOnly = /\.(md|txt|json|yaml|yml|lock)$/i.test(normalized);
|
|
239
|
+
return readOnly ? "read" : "write";
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* @param {PathOperation} operation
|
|
244
|
+
* @param {ExecutionPolicy["effects"]} effects
|
|
245
|
+
* @returns {{ deny: string[], warn: string[] }}
|
|
246
|
+
*/
|
|
247
|
+
export function effectRulesForOperation(operation, effects) {
|
|
248
|
+
return {
|
|
249
|
+
deny: effects[`deny_${operation}`] ?? [],
|
|
250
|
+
warn: effects[`warn_${operation}`] ?? [],
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* @param {string} relativePath
|
|
256
|
+
* @param {PathOperation} operation
|
|
257
|
+
* @param {ExecutionPolicy} policy
|
|
258
|
+
* @returns {{ allowed: boolean, severity: "blocking" | "warning" | "info", reason: string, operation: PathOperation }}
|
|
259
|
+
*/
|
|
260
|
+
export function checkPathEffect(relativePath, operation, policy) {
|
|
261
|
+
const normalized = relativePath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
262
|
+
const { deny, warn } = effectRulesForOperation(operation, policy.effects);
|
|
263
|
+
|
|
264
|
+
for (const pattern of deny) {
|
|
265
|
+
if (matchGlobPattern(pattern, normalized)) {
|
|
266
|
+
return {
|
|
267
|
+
allowed: false,
|
|
268
|
+
severity: "blocking",
|
|
269
|
+
reason: `${operation.toUpperCase()} denied for path matching ${pattern}`,
|
|
270
|
+
operation,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
for (const pattern of warn) {
|
|
276
|
+
if (matchGlobPattern(pattern, normalized)) {
|
|
277
|
+
return {
|
|
278
|
+
allowed: true,
|
|
279
|
+
severity: "warning",
|
|
280
|
+
reason: `${operation.toUpperCase()} warned for path matching ${pattern}`,
|
|
281
|
+
operation,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
allowed: true,
|
|
288
|
+
severity: "info",
|
|
289
|
+
reason: `no ${operation} effect rules matched`,
|
|
290
|
+
operation,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* @param {string} relativePath
|
|
296
|
+
* @param {ExecutionPolicy} policy
|
|
297
|
+
* @param {{ operation?: string | null }} [options]
|
|
298
|
+
* @returns {{
|
|
299
|
+
* allowed: boolean,
|
|
300
|
+
* exitCode: number,
|
|
301
|
+
* severity: "blocking" | "warning" | "info",
|
|
302
|
+
* reason: string,
|
|
303
|
+
* operation: PathOperation,
|
|
304
|
+
* scope: ReturnType<typeof checkPathScope>,
|
|
305
|
+
* effect: ReturnType<typeof checkPathEffect>,
|
|
306
|
+
* }}
|
|
307
|
+
*/
|
|
308
|
+
export function resolvePathCheck(relativePath, policy, options = {}) {
|
|
309
|
+
const operation = options.operation
|
|
310
|
+
? normalizePathOperation(options.operation)
|
|
311
|
+
: inferPathOperation(relativePath);
|
|
312
|
+
const scope = checkPathScope(relativePath, policy);
|
|
313
|
+
const effect = checkPathEffect(relativePath, operation, policy);
|
|
314
|
+
|
|
315
|
+
if (!scope.allowed) {
|
|
316
|
+
const mode = policy.escalation.on_policy_violation ?? "block";
|
|
317
|
+
return {
|
|
318
|
+
allowed: false,
|
|
319
|
+
severity: mode === "warn" ? "warning" : "blocking",
|
|
320
|
+
reason: scope.reason,
|
|
321
|
+
operation,
|
|
322
|
+
scope,
|
|
323
|
+
effect,
|
|
324
|
+
exitCode: mode === "warn" ? 0 : 1,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (!effect.allowed) {
|
|
329
|
+
const mode = policy.escalation.on_policy_violation ?? "block";
|
|
330
|
+
return {
|
|
331
|
+
allowed: false,
|
|
332
|
+
severity: mode === "warn" ? "warning" : "blocking",
|
|
333
|
+
reason: effect.reason,
|
|
334
|
+
operation,
|
|
335
|
+
scope,
|
|
336
|
+
effect,
|
|
337
|
+
exitCode: mode === "warn" ? 0 : 1,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (effect.severity === "warning") {
|
|
342
|
+
return {
|
|
343
|
+
allowed: true,
|
|
344
|
+
severity: "warning",
|
|
345
|
+
reason: effect.reason,
|
|
346
|
+
operation,
|
|
347
|
+
scope,
|
|
348
|
+
effect,
|
|
349
|
+
exitCode: 0,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
allowed: true,
|
|
355
|
+
severity: "info",
|
|
356
|
+
reason: scope.reason,
|
|
357
|
+
operation,
|
|
358
|
+
scope,
|
|
359
|
+
effect,
|
|
360
|
+
exitCode: 0,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
194
364
|
/**
|
|
195
365
|
* @param {string} cwd
|
|
196
366
|
* @returns {Promise<ExecutionPolicyState>}
|
|
@@ -235,25 +405,6 @@ export function checkBudget(state, policy) {
|
|
|
235
405
|
return { ok: issues.length === 0, issues };
|
|
236
406
|
}
|
|
237
407
|
|
|
238
|
-
/**
|
|
239
|
-
* @param {string} relativePath
|
|
240
|
-
* @param {ExecutionPolicy} policy
|
|
241
|
-
* @returns {{ allowed: boolean, exitCode: number, severity: "blocking" | "warning" | "info", reason: string }}
|
|
242
|
-
*/
|
|
243
|
-
export function resolvePathCheck(relativePath, policy) {
|
|
244
|
-
const result = checkPathScope(relativePath, policy);
|
|
245
|
-
if (result.allowed) {
|
|
246
|
-
return { ...result, exitCode: 0 };
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
const mode = policy.escalation.on_policy_violation ?? "block";
|
|
250
|
-
if (mode === "warn") {
|
|
251
|
-
return { ...result, severity: "warning", exitCode: 0 };
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
return { ...result, exitCode: 1 };
|
|
255
|
-
}
|
|
256
|
-
|
|
257
408
|
/**
|
|
258
409
|
* @param {string} taskId
|
|
259
410
|
* @param {ExecutionPolicyState} state
|
|
@@ -361,6 +512,7 @@ export function formatPolicyStatus(policy, state, options = {}) {
|
|
|
361
512
|
` agent_runs: ${state.agent_runs}/${policy.budget.max_agent_runs}`,
|
|
362
513
|
` allowed_paths: ${policy.scope.allowed_paths.length ? policy.scope.allowed_paths.join(", ") : "(none — all non-denied paths allowed)"}`,
|
|
363
514
|
` denied_paths: ${policy.scope.denied_paths.join(", ")}`,
|
|
515
|
+
` deny_delete: ${policy.effects.deny_delete.join(", ") || "(default .git only)"}`,
|
|
364
516
|
` budget_ok: ${budget.ok ? "yes" : "no"}`,
|
|
365
517
|
];
|
|
366
518
|
|
package/lib/gates.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luizsantiago/spec-guardrails",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.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 test/test_validation_verdict.test.js test/test_execution_policy.test.js test/test_workspace_isolation.test.js test/test_adapter_registry.test.js",
|
|
15
|
+
"test:node": "node --test test/install.test.js test/test_feature_init.test.js test/test_config.test.js test/test_archive.test.js test/test_delta_merge.test.js test/test_presets.test.js test/test_brownfield.test.js test/test_doctor.test.js test/test_token_cost.test.js test/test_next_steps.test.js test/test_classify_change.test.js test/test_feature_status.test.js test/test_agent_contract.test.js test/test_gates_python.test.js test/test_specs_utils.test.js test/test_validation_verdict.test.js test/test_execution_policy.test.js test/test_workspace_isolation.test.js test/test_adapter_registry.test.js test/test_context_guard.test.js",
|
|
16
16
|
"test:gates": "node test/run-gate-tests.mjs",
|
|
17
17
|
"prepublishOnly": "npm test"
|
|
18
18
|
},
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Full-text search over the SQLite memory index (entity_fts).
|
|
3
|
+
|
|
4
|
+
python3 memory_search.py "authentication route"
|
|
5
|
+
python3 memory_search.py "REQ-001" --limit 5 --json
|
|
6
|
+
|
|
7
|
+
Exit codes: 0 ok, 1 failure, 2 usage error.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import sqlite3
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from _common import EXIT_FAILED, EXIT_OK, EXIT_USAGE
|
|
20
|
+
|
|
21
|
+
GATE = "memory-search"
|
|
22
|
+
DB_PATH = Path(".specs/memory/memory.db")
|
|
23
|
+
DEFAULT_LIMIT = 10
|
|
24
|
+
MAX_LIMIT = 50
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def fail(message: str, code: int = EXIT_FAILED) -> int:
|
|
28
|
+
print(f"[{GATE}] FAIL - {DB_PATH}")
|
|
29
|
+
print(f" error {message}")
|
|
30
|
+
return code
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def sanitize_fts_query(raw: str) -> str:
|
|
34
|
+
"""Return an FTS5-safe query string (token AND chain)."""
|
|
35
|
+
|
|
36
|
+
tokens = re.findall(r"[A-Za-z0-9_\-]+", raw)
|
|
37
|
+
if not tokens:
|
|
38
|
+
raise ValueError("query must contain at least one searchable token")
|
|
39
|
+
return " AND ".join(f'"{token}"' for token in tokens)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def search(query: str, limit: int = DEFAULT_LIMIT) -> list[dict]:
|
|
43
|
+
if not DB_PATH.is_file():
|
|
44
|
+
raise FileNotFoundError(
|
|
45
|
+
f"{DB_PATH} not found — run `memory-index rebuild` after install"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
fts_query = sanitize_fts_query(query)
|
|
49
|
+
conn = sqlite3.connect(DB_PATH)
|
|
50
|
+
try:
|
|
51
|
+
rows = conn.execute(
|
|
52
|
+
"""
|
|
53
|
+
SELECT e.id, e.kind, e.label, e.source_path, e.updated_at
|
|
54
|
+
FROM entity_fts f
|
|
55
|
+
JOIN entities e ON e.id = f.id
|
|
56
|
+
WHERE entity_fts MATCH ?
|
|
57
|
+
ORDER BY rank
|
|
58
|
+
LIMIT ?
|
|
59
|
+
""",
|
|
60
|
+
(fts_query, limit),
|
|
61
|
+
).fetchall()
|
|
62
|
+
finally:
|
|
63
|
+
conn.close()
|
|
64
|
+
|
|
65
|
+
return [
|
|
66
|
+
{
|
|
67
|
+
"id": row[0],
|
|
68
|
+
"kind": row[1],
|
|
69
|
+
"label": row[2],
|
|
70
|
+
"source_path": row[3],
|
|
71
|
+
"updated_at": row[4],
|
|
72
|
+
}
|
|
73
|
+
for row in rows
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def cmd_search(args: argparse.Namespace) -> int:
|
|
78
|
+
try:
|
|
79
|
+
results = search(args.query, limit=args.limit)
|
|
80
|
+
except FileNotFoundError as err:
|
|
81
|
+
return fail(str(err))
|
|
82
|
+
except ValueError as err:
|
|
83
|
+
return fail(str(err), EXIT_USAGE)
|
|
84
|
+
|
|
85
|
+
payload = {"query": args.query, "count": len(results), "results": results}
|
|
86
|
+
|
|
87
|
+
if args.json:
|
|
88
|
+
print(json.dumps(payload, indent=2))
|
|
89
|
+
else:
|
|
90
|
+
print(f"[{GATE}] {len(results)} match(es) for {args.query!r}")
|
|
91
|
+
for item in results:
|
|
92
|
+
print(f" {item['id']} ({item['kind']}): {item['label']}")
|
|
93
|
+
if item.get("source_path"):
|
|
94
|
+
print(f" {item['source_path']}")
|
|
95
|
+
|
|
96
|
+
return EXIT_OK
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
100
|
+
parser = argparse.ArgumentParser(description="Search the SQLite memory index (FTS5)")
|
|
101
|
+
parser.add_argument("query", help="search terms")
|
|
102
|
+
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT)
|
|
103
|
+
parser.add_argument("--json", action="store_true")
|
|
104
|
+
parser.set_defaults(func=cmd_search)
|
|
105
|
+
return parser
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def main(argv: list[str] | None = None) -> int:
|
|
109
|
+
parser = build_parser()
|
|
110
|
+
args = parser.parse_args(argv)
|
|
111
|
+
|
|
112
|
+
if args.limit < 1 or args.limit > MAX_LIMIT:
|
|
113
|
+
return fail(f"--limit must be between 1 and {MAX_LIMIT}", EXIT_USAGE)
|
|
114
|
+
|
|
115
|
+
return args.func(args)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if __name__ == "__main__":
|
|
119
|
+
sys.exit(main())
|
|
@@ -48,7 +48,9 @@ Structural gates run **before** owner review, so they cannot drift when the mode
|
|
|
48
48
|
| Before Execute waves (3+ tasks) | `npx @luizsantiago/spec-guardrails loop-plan [feature]` |
|
|
49
49
|
| Parallel wave (2+ tasks, disjoint Files) | `npx @luizsantiago/spec-guardrails workspace-prepare [feature] --tasks T1,T2` |
|
|
50
50
|
| After parallel wave merge | `npx @luizsantiago/spec-guardrails workspace-cleanup [feature] --force` |
|
|
51
|
-
| Before editing paths outside task Files | `npx @luizsantiago/spec-guardrails
|
|
51
|
+
| Before editing paths outside task Files | `npx @luizsantiago/spec-guardrails context-guard check-edit <path> [--op write]` |
|
|
52
|
+
| Before claiming feature complete | `npx @luizsantiago/spec-guardrails context-guard check-complete [feature]` |
|
|
53
|
+
| Retrieve related context | `npx @luizsantiago/spec-guardrails memory-search "<terms>"` |
|
|
52
54
|
| On gate retry (Execute playbook) | `npx @luizsantiago/spec-guardrails execution-policy record-retry Tn` |
|
|
53
55
|
| On each commit | `python3 .specs/guardrails/scripts/check_commit.py --message "<message>"` |
|
|
54
56
|
| Before declaring a feature done | `python3 .specs/guardrails/scripts/validate_state.py [feature]` |
|
package/skills/git-handoff.md
CHANGED
|
@@ -72,6 +72,7 @@ npx @luizsantiago/spec-guardrails execution-policy check-path src/auth.ts
|
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
- **Scope:** denied paths block with exit 1; allowed_paths (when set) restrict edits to listed globs.
|
|
75
|
+
- **Intent/effect:** pass `--op read|write|delete` to `check-path`; configure `effects.deny_*` and `effects.warn_*` globs per operation in `.specs/config.yaml`.
|
|
75
76
|
- **Retries:** after a task gate failure, run `execution-policy record-retry Tn`; stop at `max_retries_per_task` (default 3) and escalate per the Execute playbook — do not bypass with `--no-verify`.
|
|
76
77
|
- **Agent runs:** orchestrators may call `execution-policy record-run` when dispatching sub-agents; stop when budgets exhaust.
|
|
77
78
|
|
|
@@ -38,13 +38,17 @@ Each worker runs in its own git worktree under `.specs/workspaces/[feature]/`. A
|
|
|
38
38
|
npx @luizsantiago/spec-guardrails workspace-cleanup [feature] --tasks T1,T2 --force
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
7. Consult execution policy before touching files
|
|
41
|
+
7. Consult execution policy and contextual guards before touching files:
|
|
42
42
|
|
|
43
43
|
```bash
|
|
44
|
-
npx @luizsantiago/spec-guardrails
|
|
44
|
+
npx @luizsantiago/spec-guardrails context-guard status
|
|
45
|
+
npx @luizsantiago/spec-guardrails context-guard check-edit src/auth.ts --op write
|
|
46
|
+
npx @luizsantiago/spec-guardrails execution-policy check-path src/auth.ts --op write
|
|
45
47
|
npx @luizsantiago/spec-guardrails execution-policy status
|
|
46
48
|
```
|
|
47
49
|
|
|
50
|
+
Declare the intended operation (`read`, `write`, or `delete`). When `--op` is omitted, the CLI infers `read` for common doc/config extensions and `write` otherwise. Effect rules in `.specs/config.yaml` (`effects.deny_*`, `effects.warn_*`) apply after scope checks.
|
|
51
|
+
|
|
48
52
|
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
53
|
|
|
50
54
|
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.
|
|
@@ -39,6 +39,16 @@ escalation:
|
|
|
39
39
|
on_budget_exhaustion: stop
|
|
40
40
|
on_policy_violation: block
|
|
41
41
|
|
|
42
|
+
# Intent/effect rules (optional — pair with check-path --op read|write|delete)
|
|
43
|
+
effects:
|
|
44
|
+
deny_delete:
|
|
45
|
+
- "**/.git/**"
|
|
46
|
+
deny_write: []
|
|
47
|
+
deny_read: []
|
|
48
|
+
warn_write: []
|
|
49
|
+
warn_read: []
|
|
50
|
+
warn_delete: []
|
|
51
|
+
|
|
42
52
|
# Project-specific overrides (appended on top of preset + rules above):
|
|
43
53
|
# overrides:
|
|
44
54
|
# rules:
|