@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 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.3.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,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
@@ -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.3.0",
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> [--op write]` |
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 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 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