@luizsantiago/spec-guardrails 3.3.0 → 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 +2 -1
- package/index.js +96 -0
- package/lib/constants.js +1 -0
- package/lib/context-guard.js +340 -0
- 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/references/implement.md +3 -2
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,7 @@ 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`) |
|
|
220
221
|
| **3.3.x** | Intent/effect policy — `check-path --op read|write|delete` and `effects` config block |
|
|
221
222
|
| **3.2.x** | Single-package focus; artifact gate severity labels; git worktree isolation CLI; execution policy (budget/scope/escalation) |
|
|
222
223
|
| **3.1.x** | Copilot/Codex/AGENTS.md adapters; doctor Process + Brakes scores; `validate-traceability` / `validate-quick`; `classify-change` / `feature-status` |
|
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";
|
|
@@ -89,6 +96,18 @@ Commands:
|
|
|
89
96
|
memory-query --from <id> Bounded context package from the knowledge graph
|
|
90
97
|
[--depth N] Traversal depth (default 2)
|
|
91
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
|
|
92
111
|
validate-spec [spec.md|feature] Closure gate for a feature spec
|
|
93
112
|
analyze-artifacts [feature] Cross-artifact consistency before task approval
|
|
94
113
|
validate-tasks [tasks.md|feature] Granularity gate for a task breakdown
|
|
@@ -565,6 +584,83 @@ if (command === "--version" || command === "-v" || command === "version") {
|
|
|
565
584
|
console.error(`❌ ${err.message}`);
|
|
566
585
|
process.exit(1);
|
|
567
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
|
+
}
|
|
568
664
|
} else if (command === "classify-change") {
|
|
569
665
|
try {
|
|
570
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/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]` |
|
|
@@ -38,11 +38,12 @@ 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 context-guard status
|
|
45
|
+
npx @luizsantiago/spec-guardrails context-guard check-edit src/auth.ts --op write
|
|
44
46
|
npx @luizsantiago/spec-guardrails execution-policy check-path src/auth.ts --op write
|
|
45
|
-
npx @luizsantiago/spec-guardrails execution-policy check-path README.md --op read
|
|
46
47
|
npx @luizsantiago/spec-guardrails execution-policy status
|
|
47
48
|
```
|
|
48
49
|
|