@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 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.2.x**
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
- const relativePath = rest[0];
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("Usage: execution-policy check-path <relative-path>");
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
- ? "allowed"
531
+ ? result.severity === "warning"
532
+ ? "allowed (warn)"
533
+ : "allowed"
495
534
  : result.severity === "warning"
496
535
  ? "blocked (warn)"
497
536
  : "blocked";
498
- console.log(`${relativePath}: ${label} (${result.reason})`);
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
+ }
@@ -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
@@ -43,6 +43,7 @@ const AUX_SCRIPTS = {
43
43
  "loop-plan": "loop_plan.py",
44
44
  "memory-index": "memory_index.py",
45
45
  "memory-query": "memory_query.py",
46
+ "memory-search": "memory_search.py",
46
47
  };
47
48
 
48
49
  const GUARDRAILS_SCRIPTS = { ...GATE_SCRIPTS, ...AUX_SCRIPTS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luizsantiago/spec-guardrails",
3
- "version": "3.2.1",
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 execution-policy check-path <path>` |
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]` |
@@ -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 outside the task list:
41
+ 7. Consult execution policy and contextual guards before touching files:
42
42
 
43
43
  ```bash
44
- npx @luizsantiago/spec-guardrails execution-policy check-path src/auth.ts
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: