@luizsantiago/spec-guardrails 3.8.0 → 3.9.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
@@ -5,7 +5,7 @@
5
5
 
6
6
  **Keep AI coding agents honest — specify the work, prove each step, verify independently.**
7
7
 
8
- npm: [`@luizsantiago/spec-guardrails`](https://www.npmjs.com/package/@luizsantiago/spec-guardrails) **3.8.x**
8
+ npm: [`@luizsantiago/spec-guardrails`](https://www.npmjs.com/package/@luizsantiago/spec-guardrails) **3.9.x**
9
9
 
10
10
  ---
11
11
 
@@ -106,7 +106,10 @@ Most projects start with Specify → Tasks → Loop → Verify only. Turn these
106
106
  | Capability | Plain purpose | Learn more |
107
107
  | --- | --- | --- |
108
108
  | **Memory search** | Find text in past specs and validations | [Memory guide](docs/guide/Memory.md) |
109
- | **Context guards** | Check scope before edit or “done” | [Agent commands](docs/guide/agent-commands.md) · **Cursor:** auto via hooks (3.8+) |
109
+ | **Context guards** | Check scope before edit or “done” | [Agent commands](docs/guide/agent-commands.md) · **Cursor:** auto via hooks |
110
+ | **Episodic memory** | Session notes → episodic → lessons | [Memory → Episodes](docs/guide/Memory.md#episodic-memory-lifecycle) |
111
+ | **Code index** | Lightweight brownfield file/symbol map | [Brownfield context](docs/guide/brownfield-context.md) |
112
+ | **Sandbox policy** | Block/warn destructive shell commands | [Overview → Safety](docs/guide/Overview.md#safety-and-limits) |
110
113
  | **Execution policy** | Limit paths, retries, dangerous ops | [Overview → Safety](docs/guide/Overview.md#safety-and-limits) |
111
114
  | **Solution exploration** | Compare two+ implementations before committing | [Overview → Exploration](docs/guide/Overview.md#optional-exploration-mode) |
112
115
  | **Semantic retrieval** | Search by meaning (needs OpenAI or Ollama) | [Memory → Semantic](docs/guide/Memory.md#semantic-search-optional) |
package/index.js CHANGED
@@ -45,6 +45,11 @@ import {
45
45
  recordExplorationDecision,
46
46
  validateExplorationArtifact,
47
47
  } from "./lib/solution-exploration.js";
48
+ import {
49
+ checkSandboxCommand,
50
+ formatSandboxCheck,
51
+ loadSandboxPolicy,
52
+ } from "./lib/sandbox-policy.js";
48
53
  import {
49
54
  initProjectConfig,
50
55
  listPresets,
@@ -113,6 +118,13 @@ Commands:
113
118
  memory-retrieve "<query>" Hybrid retrieval (FTS + graph + optional semantic)
114
119
  [--mode fts|hybrid|semantic] Strategy (default: hybrid)
115
120
  [--json] Machine-readable output
121
+ episodes record --summary "…" Capture working-session episodic memory
122
+ episodes list|archive|prune|promote Episodic lifecycle (working → episodic → promoted)
123
+ code-index rebuild [--roots src,lib] Lightweight brownfield code map (not full RepoGraph)
124
+ code-index search "<query>" Search indexed files/symbols/imports
125
+ sandbox status Show sandbox policy mode (off|warn|strict)
126
+ sandbox check-command "<cmd>" Soft OS sandbox — block/warn destructive shell commands
127
+ [--json] Machine-readable output
116
128
  context-guard status Execute readiness from STATE + tasks.md
117
129
  [--json] Machine-readable output
118
130
  context-guard check-edit <path> Contextual guard before editing a file
@@ -614,6 +626,58 @@ if (command === "--version" || command === "-v" || command === "version") {
614
626
  console.error(`❌ ${err.message}`);
615
627
  process.exit(1);
616
628
  }
629
+ } else if (command === "sandbox") {
630
+ try {
631
+ const sub = args[0];
632
+ let json = false;
633
+ /** @type {string[]} */
634
+ const rest = [];
635
+
636
+ for (let i = 1; i < args.length; i++) {
637
+ if (args[i] === "--json") {
638
+ json = true;
639
+ } else {
640
+ rest.push(args[i]);
641
+ }
642
+ }
643
+
644
+ const cwd = process.cwd();
645
+ const policy = await loadSandboxPolicy(cwd);
646
+
647
+ if (sub === "status") {
648
+ if (json) {
649
+ console.log(
650
+ JSON.stringify(
651
+ {
652
+ mode: policy.mode,
653
+ deny_rules: policy.deny_patterns.map((rule) => rule.id),
654
+ },
655
+ null,
656
+ 2,
657
+ ),
658
+ );
659
+ } else {
660
+ console.log(
661
+ `Sandbox mode: ${policy.mode} (${policy.deny_patterns.length} deny rule(s))`,
662
+ );
663
+ }
664
+ } else if (sub === "check-command") {
665
+ const shellCommand = rest.join(" ").trim();
666
+ if (!shellCommand) {
667
+ throw new Error('Usage: sandbox check-command "<shell command>" [--json]');
668
+ }
669
+ const result = checkSandboxCommand(shellCommand, policy);
670
+ process.stdout.write(formatSandboxCheck(result, shellCommand, { json }));
671
+ if (!result.allowed) {
672
+ process.exit(1);
673
+ }
674
+ } else {
675
+ throw new Error('Usage: sandbox status | check-command "<cmd>" [--json]');
676
+ }
677
+ } catch (err) {
678
+ console.error(`❌ ${err.message}`);
679
+ process.exit(1);
680
+ }
617
681
  } else if (command === "context-guard") {
618
682
  try {
619
683
  const sub = args[0];
package/lib/constants.js CHANGED
@@ -115,6 +115,8 @@ export const SCRIPT_ASSETS = [
115
115
  { file: "memory_retrieve.py", remotePath: "scripts/memory_retrieve.py" },
116
116
  { file: "_memory_config.py", remotePath: "scripts/_memory_config.py" },
117
117
  { file: "_memory_embed.py", remotePath: "scripts/_memory_embed.py" },
118
+ { file: "episodes.py", remotePath: "scripts/episodes.py" },
119
+ { file: "code_index.py", remotePath: "scripts/code_index.py" },
118
120
  ];
119
121
 
120
122
  /** @type {{ file: string, remotePath: string }[]} */
@@ -4,9 +4,22 @@ import path from "node:path";
4
4
  import { packagedAssetPath } from "./assets.js";
5
5
  import { ensureDir, readFileSafe } from "./fs-utils.js";
6
6
 
7
- export const CURSOR_HOOK_SCRIPT = ".cursor/hooks/context-guard-edit.mjs";
7
+ export const CURSOR_HOOK_EDIT = ".cursor/hooks/context-guard-edit.mjs";
8
+ export const CURSOR_HOOK_SANDBOX = ".cursor/hooks/sandbox-shell.mjs";
8
9
  export const CURSOR_HOOKS_JSON = ".cursor/hooks.json";
9
10
 
11
+ /** @type {readonly { source: string, dest: string }[]} */
12
+ export const CURSOR_HOOK_SCRIPTS = [
13
+ {
14
+ source: "templates/cursor/hooks/context-guard-edit.mjs",
15
+ dest: CURSOR_HOOK_EDIT,
16
+ },
17
+ {
18
+ source: "templates/cursor/hooks/sandbox-shell.mjs",
19
+ dest: CURSOR_HOOK_SANDBOX,
20
+ },
21
+ ];
22
+
10
23
  /**
11
24
  * Merge shipped hook entries without removing user hooks.
12
25
  *
@@ -58,7 +71,7 @@ export function mergeCursorHooksConfig(existing, template) {
58
71
  }
59
72
 
60
73
  /**
61
- * Install Cursor hooks that auto-run context-guard before write/edit tools.
74
+ * Install Cursor hooks for context-guard and sandbox policy.
62
75
  *
63
76
  * @param {string} cwd
64
77
  * @param {{ log?: (message: string) => void }} [options]
@@ -68,14 +81,14 @@ export async function installCursorHooks(cwd, options = {}) {
68
81
  const hooksDir = path.join(cwd, ".cursor/hooks");
69
82
  await ensureDir(hooksDir);
70
83
 
71
- const scriptSource = packagedAssetPath("templates/cursor/hooks/context-guard-edit.mjs");
72
- const scriptDest = path.join(cwd, CURSOR_HOOK_SCRIPT);
73
- await fs.copyFile(scriptSource, scriptDest);
74
-
75
- try {
76
- await fs.chmod(scriptDest, 0o755);
77
- } catch {
78
- // Windows may ignore chmod; node can still execute the script.
84
+ for (const script of CURSOR_HOOK_SCRIPTS) {
85
+ const scriptDest = path.join(cwd, script.dest);
86
+ await fs.copyFile(packagedAssetPath(script.source), scriptDest);
87
+ try {
88
+ await fs.chmod(scriptDest, 0o755);
89
+ } catch {
90
+ // Windows may ignore chmod; node can still execute the script.
91
+ }
79
92
  }
80
93
 
81
94
  const template = JSON.parse(
@@ -92,5 +105,5 @@ export async function installCursorHooks(cwd, options = {}) {
92
105
 
93
106
  const merged = mergeCursorHooksConfig(existing, template);
94
107
  await fs.writeFile(hooksJsonPath, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
95
- log(`✅ Cursor hooks → ${CURSOR_HOOKS_JSON} (context-guard on write/edit tools)`);
108
+ log(`✅ Cursor hooks → ${CURSOR_HOOKS_JSON} (context-guard + sandbox shell checks)`);
96
109
  }
package/lib/gates.js CHANGED
@@ -45,6 +45,8 @@ const AUX_SCRIPTS = {
45
45
  "memory-query": "memory_query.py",
46
46
  "memory-search": "memory_search.py",
47
47
  "memory-retrieve": "memory_retrieve.py",
48
+ episodes: "episodes.py",
49
+ "code-index": "code_index.py",
48
50
  };
49
51
 
50
52
  const GUARDRAILS_SCRIPTS = { ...GATE_SCRIPTS, ...AUX_SCRIPTS };
@@ -0,0 +1,173 @@
1
+ import path from "node:path";
2
+
3
+ import { readFileSafe } from "./fs-utils.js";
4
+
5
+ export const SANDBOX_LOG_PATH = ".specs/state/sandbox-log.json";
6
+
7
+ /** @type {readonly { id: string, pattern: RegExp, reason: string }[]} */
8
+ export const DEFAULT_SANDBOX_DENIES = [
9
+ {
10
+ id: "rm-rf",
11
+ pattern: /\brm\s+(-[^\s]*f[^\s]*\s+-[^\s]*r|-[^\s]*r[^\s]*\s+-[^\s]*f|-rf|-fr)\s/i,
12
+ reason: "recursive force delete is blocked by sandbox policy",
13
+ },
14
+ {
15
+ id: "curl-pipe-sh",
16
+ pattern: /\bcurl\b[^\n|]*\|\s*(?:ba)?sh\b/i,
17
+ reason: "curl piped to shell is blocked by sandbox policy",
18
+ },
19
+ {
20
+ id: "wget-pipe-sh",
21
+ pattern: /\bwget\b[^\n|]*\|\s*(?:ba)?sh\b/i,
22
+ reason: "wget piped to shell is blocked by sandbox policy",
23
+ },
24
+ {
25
+ id: "force-push-main",
26
+ pattern: /\bgit\s+push\b[^\n]*(--force|--force-with-lease)[^\n]*\b(main|master)\b/i,
27
+ reason: "force push to main/master is blocked by sandbox policy",
28
+ },
29
+ {
30
+ id: "drop-database",
31
+ pattern: /\b(DROP\s+DATABASE|DROP\s+SCHEMA|TRUNCATE\s+TABLE)\b/i,
32
+ reason: "destructive SQL is blocked by sandbox policy",
33
+ },
34
+ ];
35
+
36
+ /**
37
+ * @typedef {"off" | "warn" | "strict"} SandboxMode
38
+ */
39
+
40
+ /**
41
+ * @typedef {{
42
+ * mode: SandboxMode,
43
+ * deny_patterns: { id: string, pattern: RegExp, reason: string }[],
44
+ * }} SandboxPolicy
45
+ */
46
+
47
+ /**
48
+ * @param {string} text
49
+ * @returns {SandboxPolicy}
50
+ */
51
+ export function parseSandboxPolicy(text) {
52
+ /** @type {SandboxPolicy} */
53
+ const policy = {
54
+ mode: "warn",
55
+ deny_patterns: [...DEFAULT_SANDBOX_DENIES],
56
+ };
57
+
58
+ let inSandbox = false;
59
+ let sandboxIndent = 0;
60
+ let inDenyList = false;
61
+
62
+ for (const line of text.split("\n")) {
63
+ const trimmed = line.trim();
64
+ if (!trimmed || trimmed.startsWith("#")) {
65
+ continue;
66
+ }
67
+
68
+ const indent = line.length - line.trimStart().length;
69
+
70
+ if (trimmed === "sandbox:") {
71
+ inSandbox = true;
72
+ inDenyList = false;
73
+ sandboxIndent = indent;
74
+ continue;
75
+ }
76
+
77
+ if (inSandbox && indent <= sandboxIndent && trimmed !== "sandbox:") {
78
+ inSandbox = false;
79
+ inDenyList = false;
80
+ }
81
+
82
+ if (!inSandbox) {
83
+ continue;
84
+ }
85
+
86
+ const modeMatch = trimmed.match(/^mode:\s*(off|warn|strict)\s*$/i);
87
+ if (modeMatch) {
88
+ policy.mode = /** @type {SandboxMode} */ (modeMatch[1].toLowerCase());
89
+ continue;
90
+ }
91
+
92
+ if (trimmed === "deny_patterns:") {
93
+ inDenyList = true;
94
+ continue;
95
+ }
96
+
97
+ if (inDenyList && trimmed.startsWith("- ")) {
98
+ const raw = trimmed.slice(2).trim().replace(/^['"]|['"]$/g, "");
99
+ if (raw) {
100
+ try {
101
+ policy.deny_patterns.push({
102
+ id: `custom-${policy.deny_patterns.length + 1}`,
103
+ pattern: new RegExp(raw, "i"),
104
+ reason: `matched custom sandbox deny pattern: ${raw}`,
105
+ });
106
+ } catch {
107
+ // ignore invalid regex in config
108
+ }
109
+ }
110
+ }
111
+ }
112
+
113
+ return policy;
114
+ }
115
+
116
+ /**
117
+ * @param {string} cwd
118
+ * @returns {Promise<SandboxPolicy>}
119
+ */
120
+ export async function loadSandboxPolicy(cwd) {
121
+ try {
122
+ const text = await readFileSafe(path.join(cwd, ".specs/config.yaml"));
123
+ return parseSandboxPolicy(text);
124
+ } catch {
125
+ return { mode: "warn", deny_patterns: [...DEFAULT_SANDBOX_DENIES] };
126
+ }
127
+ }
128
+
129
+ /**
130
+ * @param {string} command
131
+ * @param {SandboxPolicy} policy
132
+ * @returns {{ allowed: boolean, severity: "info" | "warning" | "blocking", reason: string | null, mode: SandboxMode }}
133
+ */
134
+ export function checkSandboxCommand(command, policy) {
135
+ const mode = policy.mode ?? "warn";
136
+ if (mode === "off") {
137
+ return { allowed: true, severity: "info", reason: null, mode };
138
+ }
139
+
140
+ const normalized = String(command || "").trim();
141
+ if (!normalized) {
142
+ return { allowed: true, severity: "info", reason: null, mode };
143
+ }
144
+
145
+ for (const rule of policy.deny_patterns) {
146
+ if (rule.pattern.test(normalized)) {
147
+ if (mode === "strict") {
148
+ return { allowed: false, severity: "blocking", reason: rule.reason, mode };
149
+ }
150
+ return { allowed: true, severity: "warning", reason: rule.reason, mode };
151
+ }
152
+ }
153
+
154
+ return { allowed: true, severity: "info", reason: null, mode };
155
+ }
156
+
157
+ /**
158
+ * @param {Awaited<ReturnType<typeof checkSandboxCommand>>} result
159
+ * @param {string} command
160
+ * @param {{ json?: boolean }} [options]
161
+ */
162
+ export function formatSandboxCheck(result, command, options = {}) {
163
+ if (options.json) {
164
+ return JSON.stringify({ command, ...result }, null, 2);
165
+ }
166
+
167
+ if (!result.reason) {
168
+ return `Sandbox [${result.mode}]: allowed\n`;
169
+ }
170
+
171
+ const label = result.allowed ? "warn" : "blocked";
172
+ return `Sandbox [${result.mode}]: ${label}\n ${result.reason}\n`;
173
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luizsantiago/spec-guardrails",
3
- "version": "3.8.0",
3
+ "version": "3.9.0",
4
4
  "description": "Spec-driven process kit for AI coding agents: write goals in .specs/, break into tasks, implement in waves, verify with proof. Process mode (Node) or Brakes mode (Node + Python gates). Works with Cursor, Claude, Copilot, and Codex.",
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 test/test_context_guard.test.js test/test_solution_exploration.test.js test/test_memory_doctor.test.js test/test_cursor_hooks.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 test/test_solution_exploration.test.js test/test_memory_doctor.test.js test/test_cursor_hooks.test.js test/test_sandbox_policy.test.js",
16
16
  "test:gates": "node test/run-gate-tests.mjs",
17
17
  "prepublishOnly": "npm test"
18
18
  },
@@ -21,6 +21,11 @@ DEFAULT_RETRIEVAL = {
21
21
  "graph_depth": 1,
22
22
  }
23
23
 
24
+ DEFAULT_LIFECYCLE = {
25
+ "retention_days": 90,
26
+ "auto_archive_on_handoff": False,
27
+ }
28
+
24
29
  BOOL = {"true", "false", "yes", "no", "on", "off"}
25
30
 
26
31
 
@@ -95,3 +100,60 @@ def load_memory_retrieval_config() -> dict:
95
100
  config[key] = _parse_scalar(raw_value)
96
101
 
97
102
  return config
103
+
104
+
105
+ def load_memory_lifecycle_config() -> dict:
106
+ """Return memory.lifecycle settings with defaults."""
107
+
108
+ config = dict(DEFAULT_LIFECYCLE)
109
+ if not CONFIG_PATH.is_file():
110
+ return config
111
+
112
+ lines = CONFIG_PATH.read_text(encoding="utf-8").splitlines()
113
+ in_memory = False
114
+ in_lifecycle = False
115
+ memory_indent = 0
116
+ lifecycle_indent = 0
117
+
118
+ for line in lines:
119
+ if not line.strip() or line.lstrip().startswith("#"):
120
+ continue
121
+
122
+ indent = len(line) - len(line.lstrip())
123
+ stripped = line.strip()
124
+
125
+ if stripped == "memory:":
126
+ in_memory = True
127
+ in_lifecycle = False
128
+ memory_indent = indent
129
+ continue
130
+
131
+ if not in_memory:
132
+ continue
133
+
134
+ if indent <= memory_indent and stripped != "memory:":
135
+ in_memory = False
136
+ in_lifecycle = False
137
+ continue
138
+
139
+ if stripped == "lifecycle:":
140
+ in_lifecycle = True
141
+ lifecycle_indent = indent
142
+ continue
143
+
144
+ if in_lifecycle and indent <= lifecycle_indent and not stripped.startswith("lifecycle:"):
145
+ in_lifecycle = False
146
+
147
+ if not in_lifecycle:
148
+ continue
149
+
150
+ match = re.match(r"^([a-z_]+):\s*(.*)$", stripped)
151
+ if not match:
152
+ continue
153
+
154
+ key, raw_value = match.group(1), match.group(2)
155
+ if key not in config or raw_value == "":
156
+ continue
157
+ config[key] = _parse_scalar(raw_value)
158
+
159
+ return config
@@ -0,0 +1,228 @@
1
+ #!/usr/bin/env python3
2
+ """Lightweight brownfield code index — shallow file/symbol map, not full RepoGraph.
3
+
4
+ python3 code_index.py rebuild [--roots src,lib] [--json]
5
+ python3 code_index.py search "auth" [--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 sys
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+
19
+ from _common import EXIT_FAILED, EXIT_OK, EXIT_USAGE
20
+
21
+ GATE = "code-index"
22
+ INDEX_PATH = Path(".specs/memory/code-index.json")
23
+ DEFAULT_ROOTS = ("src", "lib", "app", "packages", "internal", "cmd")
24
+ SKIP_DIRS = {
25
+ ".git",
26
+ ".specs",
27
+ ".cursor",
28
+ "node_modules",
29
+ "dist",
30
+ "build",
31
+ "coverage",
32
+ "vendor",
33
+ "__pycache__",
34
+ ".next",
35
+ ".turbo",
36
+ }
37
+ CODE_EXTENSIONS = {
38
+ ".ts": "typescript",
39
+ ".tsx": "typescript",
40
+ ".js": "javascript",
41
+ ".jsx": "javascript",
42
+ ".py": "python",
43
+ ".go": "go",
44
+ ".rs": "rust",
45
+ }
46
+ SYMBOL_PATTERNS = {
47
+ "typescript": [
48
+ re.compile(r"^\s*(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_]\w*)", re.MULTILINE),
49
+ re.compile(r"^\s*(?:export\s+)?class\s+([A-Za-z_]\w*)", re.MULTILINE),
50
+ re.compile(r"^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_]\w*)\s*=", re.MULTILINE),
51
+ ],
52
+ "python": [
53
+ re.compile(r"^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)", re.MULTILINE),
54
+ re.compile(r"^\s*class\s+([A-Za-z_]\w*)", re.MULTILINE),
55
+ ],
56
+ "javascript": [
57
+ re.compile(r"^\s*(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_]\w*)", re.MULTILINE),
58
+ re.compile(r"^\s*(?:export\s+)?class\s+([A-Za-z_]\w*)", re.MULTILINE),
59
+ ],
60
+ "go": [
61
+ re.compile(r"^\s*func\s+(?:\([^)]+\)\s+)?([A-Za-z_]\w*)", re.MULTILINE),
62
+ re.compile(r"^\s*type\s+([A-Za-z_]\w*)\s+", re.MULTILINE),
63
+ ],
64
+ "rust": [
65
+ re.compile(r"^\s*(?:pub\s+)?fn\s+([A-Za-z_]\w*)", re.MULTILINE),
66
+ re.compile(r"^\s*(?:pub\s+)?struct\s+([A-Za-z_]\w*)", re.MULTILINE),
67
+ ],
68
+ }
69
+ IMPORT_PATTERN = re.compile(
70
+ r"^\s*(?:import|from|require\(|use)\s+['\"]?([^'\";\n]+)",
71
+ re.MULTILINE,
72
+ )
73
+
74
+
75
+ def fail(message: str, code: int = EXIT_FAILED) -> int:
76
+ print(f"[{GATE}] FAIL - {INDEX_PATH}")
77
+ print(f" error {message}")
78
+ return code
79
+
80
+
81
+ def ok(message: str) -> int:
82
+ print(f"[{GATE}] PASS - {message}")
83
+ return EXIT_OK
84
+
85
+
86
+ def utc_now() -> str:
87
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
88
+
89
+
90
+ def discover_roots(explicit: list[str] | None) -> list[Path]:
91
+ if explicit:
92
+ return [Path(root) for root in explicit if root.strip()]
93
+ return [Path(root) for root in DEFAULT_ROOTS if Path(root).is_dir()]
94
+
95
+
96
+ def extract_symbols(language: str, text: str) -> list[str]:
97
+ symbols: list[str] = []
98
+ for pattern in SYMBOL_PATTERNS.get(language, []):
99
+ for match in pattern.finditer(text):
100
+ name = match.group(1)
101
+ if name and name not in symbols:
102
+ symbols.append(name)
103
+ return symbols[:40]
104
+
105
+
106
+ def extract_imports(text: str) -> list[str]:
107
+ imports: list[str] = []
108
+ for match in IMPORT_PATTERN.finditer(text):
109
+ value = match.group(1).strip().strip("'\"")
110
+ if value and value not in imports:
111
+ imports.append(value)
112
+ return imports[:20]
113
+
114
+
115
+ def scan_file(path: Path) -> dict | None:
116
+ suffix = path.suffix.lower()
117
+ language = CODE_EXTENSIONS.get(suffix)
118
+ if not language:
119
+ return None
120
+
121
+ try:
122
+ text = path.read_text(encoding="utf-8", errors="ignore")
123
+ except OSError:
124
+ return None
125
+
126
+ if len(text) > 250_000:
127
+ text = text[:250_000]
128
+
129
+ return {
130
+ "path": path.as_posix(),
131
+ "language": language,
132
+ "symbols": extract_symbols(language, text),
133
+ "imports": extract_imports(text),
134
+ }
135
+
136
+
137
+ def rebuild(roots: list[str] | None, json_output: bool) -> int:
138
+ scan_roots = discover_roots(roots)
139
+ if not scan_roots:
140
+ return fail("no code roots found — pass --roots src,lib or run from a repo with src/")
141
+
142
+ files: list[dict] = []
143
+ for root in scan_roots:
144
+ if not root.is_dir():
145
+ continue
146
+ for path in root.rglob("*"):
147
+ if not path.is_file():
148
+ continue
149
+ if any(part in SKIP_DIRS for part in path.parts):
150
+ continue
151
+ entry = scan_file(path)
152
+ if entry:
153
+ files.append(entry)
154
+
155
+ payload = {
156
+ "updated_at": utc_now(),
157
+ "roots": [root.as_posix() for root in scan_roots],
158
+ "files": sorted(files, key=lambda item: item["path"]),
159
+ "file_count": len(files),
160
+ }
161
+
162
+ INDEX_PATH.parent.mkdir(parents=True, exist_ok=True)
163
+ INDEX_PATH.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
164
+
165
+ if json_output:
166
+ print(json.dumps(payload, indent=2))
167
+ else:
168
+ ok(f"indexed {len(files)} file(s) -> {INDEX_PATH}")
169
+ return EXIT_OK
170
+
171
+
172
+ def search(query: str, json_output: bool) -> int:
173
+ if not INDEX_PATH.is_file():
174
+ return fail(f"{INDEX_PATH} not found — run `code-index rebuild` first")
175
+
176
+ needle = query.lower().strip()
177
+ payload = json.loads(INDEX_PATH.read_text(encoding="utf-8"))
178
+ results = []
179
+ for entry in payload.get("files") or []:
180
+ haystack = " ".join(
181
+ [entry.get("path", ""), *entry.get("symbols", []), *entry.get("imports", [])]
182
+ ).lower()
183
+ if needle in haystack:
184
+ results.append(entry)
185
+
186
+ summary = {"query": query, "count": len(results), "results": results[:50]}
187
+ if json_output:
188
+ print(json.dumps(summary, indent=2))
189
+ else:
190
+ for entry in summary["results"]:
191
+ symbols = ", ".join(entry.get("symbols") or []) or "(no symbols)"
192
+ print(f"{entry['path']} [{entry['language']}] — {symbols}")
193
+ return EXIT_OK
194
+
195
+
196
+ def build_parser() -> argparse.ArgumentParser:
197
+ parser = argparse.ArgumentParser(description="Lightweight brownfield code index")
198
+ sub = parser.add_subparsers(dest="command")
199
+
200
+ rebuild_cmd = sub.add_parser("rebuild", help="scan code roots and write code-index.json")
201
+ rebuild_cmd.add_argument("--roots")
202
+ rebuild_cmd.add_argument("--json", action="store_true")
203
+ rebuild_cmd.set_defaults(
204
+ func=lambda args: rebuild(
205
+ [part.strip() for part in args.roots.split(",")] if args.roots else None,
206
+ args.json,
207
+ )
208
+ )
209
+
210
+ search_cmd = sub.add_parser("search", help="search indexed files/symbols")
211
+ search_cmd.add_argument("query")
212
+ search_cmd.add_argument("--json", action="store_true")
213
+ search_cmd.set_defaults(func=lambda args: search(args.query, args.json))
214
+
215
+ return parser
216
+
217
+
218
+ def main(argv: list[str] | None = None) -> int:
219
+ parser = build_parser()
220
+ args = parser.parse_args(argv)
221
+ if not args.command:
222
+ parser.print_help()
223
+ return EXIT_USAGE
224
+ return args.func(args)
225
+
226
+
227
+ if __name__ == "__main__":
228
+ sys.exit(main())
@@ -0,0 +1,274 @@
1
+ #!/usr/bin/env python3
2
+ """Episodic memory lifecycle for `.specs/state/episodes.json`.
3
+
4
+ Working session notes graduate to episodic memory, then archive or promote to lessons.
5
+
6
+ python3 episodes.py record --summary "..." [--feature 001-auth] [--phase Execute]
7
+ python3 episodes.py list [--status episodic]
8
+ python3 episodes.py archive EP-001
9
+ python3 episodes.py prune [--days 90]
10
+ python3 episodes.py promote EP-001 --title "..." --rule "..."
11
+
12
+ Exit codes: 0 ok, 1 failure, 2 usage error.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import re
20
+ import sys
21
+ from datetime import datetime, timedelta, timezone
22
+ from pathlib import Path
23
+
24
+ from _common import EXIT_FAILED, EXIT_OK, EXIT_USAGE
25
+ from _memory_config import load_memory_lifecycle_config
26
+
27
+ GATE = "episodes"
28
+ STORE_PATH = Path(".specs/state/episodes.json")
29
+ STATE_PATH = Path(".specs/STATE.md")
30
+ STATUSES = ("working", "episodic", "archived", "promoted")
31
+ INDEXABLE_STATUSES = {"episodic", "archived", "promoted"}
32
+
33
+
34
+ def fail(message: str, code: int = EXIT_FAILED) -> int:
35
+ print(f"[{GATE}] FAIL - {STORE_PATH}")
36
+ print(f" error {message}")
37
+ return code
38
+
39
+
40
+ def ok(message: str) -> int:
41
+ print(f"[{GATE}] PASS - {message}")
42
+ return EXIT_OK
43
+
44
+
45
+ def utc_now() -> str:
46
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
47
+
48
+
49
+ def load_store() -> dict:
50
+ if not STORE_PATH.is_file():
51
+ return {"episodes": []}
52
+ return json.loads(STORE_PATH.read_text(encoding="utf-8"))
53
+
54
+
55
+ def save_store(store: dict) -> None:
56
+ STORE_PATH.parent.mkdir(parents=True, exist_ok=True)
57
+ STORE_PATH.write_text(json.dumps(store, indent=2) + "\n", encoding="utf-8")
58
+
59
+
60
+ def next_episode_id(store: dict) -> str:
61
+ max_id = 0
62
+ for episode in store.get("episodes") or []:
63
+ match = re.fullmatch(r"EP-(\d+)", str(episode.get("id") or ""), re.IGNORECASE)
64
+ if match:
65
+ max_id = max(max_id, int(match.group(1)))
66
+ return f"EP-{max_id + 1:03d}"
67
+
68
+
69
+ def read_state_feature() -> str | None:
70
+ if not STATE_PATH.is_file():
71
+ return None
72
+ for line in STATE_PATH.read_text(encoding="utf-8").splitlines():
73
+ match = re.match(r"^-\s*Feature:\s*(.+)$", line.strip())
74
+ if match:
75
+ value = match.group(1).strip()
76
+ if value and value not in {"—", "-"}:
77
+ return value
78
+ return None
79
+
80
+
81
+ def read_state_phase() -> str | None:
82
+ if not STATE_PATH.is_file():
83
+ return None
84
+ for line in STATE_PATH.read_text(encoding="utf-8").splitlines():
85
+ match = re.match(r"^-\s*Phase:\s*(.+)$", line.strip())
86
+ if match:
87
+ value = match.group(1).strip()
88
+ if value and value not in {"—", "-"}:
89
+ return value
90
+ return None
91
+
92
+
93
+ def find_episode(store: dict, episode_id: str) -> dict | None:
94
+ target = episode_id.upper()
95
+ for episode in store.get("episodes") or []:
96
+ if str(episode.get("id") or "").upper() == target:
97
+ return episode
98
+ return None
99
+
100
+
101
+ def record_episode(summary: str, feature_id: str | None, phase: str | None, json_output: bool) -> int:
102
+ summary = summary.strip()
103
+ if not summary:
104
+ return fail("summary is required")
105
+
106
+ store = load_store()
107
+ episode = {
108
+ "id": next_episode_id(store),
109
+ "feature_id": feature_id or read_state_feature(),
110
+ "phase": phase or read_state_phase(),
111
+ "summary": summary,
112
+ "status": "working",
113
+ "recorded_at": utc_now(),
114
+ "source": "manual",
115
+ }
116
+ store.setdefault("episodes", []).append(episode)
117
+ save_store(store)
118
+
119
+ if json_output:
120
+ print(json.dumps(episode, indent=2))
121
+ else:
122
+ ok(f"recorded {episode['id']} ({episode['status']})")
123
+ return EXIT_OK
124
+
125
+
126
+ def list_episodes(status: str | None, json_output: bool) -> int:
127
+ store = load_store()
128
+ episodes = store.get("episodes") or []
129
+ if status:
130
+ episodes = [ep for ep in episodes if str(ep.get("status") or "").lower() == status.lower()]
131
+
132
+ if json_output:
133
+ print(json.dumps({"count": len(episodes), "episodes": episodes}, indent=2))
134
+ else:
135
+ for episode in episodes:
136
+ print(
137
+ f"{episode.get('id')} [{episode.get('status')}] "
138
+ f"{episode.get('feature_id') or '-'} — {episode.get('summary')}"
139
+ )
140
+ return EXIT_OK
141
+
142
+
143
+ def archive_episode(episode_id: str, json_output: bool) -> int:
144
+ store = load_store()
145
+ episode = find_episode(store, episode_id)
146
+ if not episode:
147
+ return fail(f"episode not found: {episode_id}")
148
+
149
+ if episode.get("status") != "working":
150
+ return fail(f"only working episodes can be archived (current: {episode.get('status')})")
151
+
152
+ episode["status"] = "episodic"
153
+ episode["archived_at"] = utc_now()
154
+ save_store(store)
155
+
156
+ if json_output:
157
+ print(json.dumps(episode, indent=2))
158
+ else:
159
+ ok(f"archived {episode['id']} -> episodic")
160
+ return EXIT_OK
161
+
162
+
163
+ def prune_episodes(days: int | None, json_output: bool) -> int:
164
+ config = load_memory_lifecycle_config()
165
+ retention = days if days is not None else int(config.get("retention_days") or 90)
166
+ cutoff = datetime.now(timezone.utc) - timedelta(days=retention)
167
+
168
+ store = load_store()
169
+ kept = []
170
+ removed = []
171
+ for episode in store.get("episodes") or []:
172
+ if str(episode.get("status") or "") != "episodic":
173
+ kept.append(episode)
174
+ continue
175
+ raw = str(episode.get("archived_at") or episode.get("recorded_at") or "")
176
+ try:
177
+ recorded = datetime.fromisoformat(raw.replace("Z", "+00:00"))
178
+ except ValueError:
179
+ kept.append(episode)
180
+ continue
181
+ if recorded < cutoff:
182
+ removed.append(episode)
183
+ else:
184
+ kept.append(episode)
185
+
186
+ store["episodes"] = kept
187
+ save_store(store)
188
+
189
+ summary = {"removed": len(removed), "retention_days": retention, "ids": [ep["id"] for ep in removed]}
190
+ if json_output:
191
+ print(json.dumps(summary, indent=2))
192
+ else:
193
+ ok(f"pruned {len(removed)} episodic episode(s) older than {retention} days")
194
+ return EXIT_OK
195
+
196
+
197
+ def promote_episode(episode_id: str, title: str, rule: str, json_output: bool) -> int:
198
+ store = load_store()
199
+ episode = find_episode(store, episode_id)
200
+ if not episode:
201
+ return fail(f"episode not found: {episode_id}")
202
+
203
+ if episode.get("status") not in {"episodic", "archived"}:
204
+ return fail(f"only episodic/archived episodes can be promoted (current: {episode.get('status')})")
205
+
206
+ title = title.strip() or str(episode.get("summary") or episode_id)
207
+ rule = rule.strip() or str(episode.get("summary") or "")
208
+ if not rule:
209
+ return fail("rule is required for promotion")
210
+
211
+ episode["status"] = "promoted"
212
+ episode["promoted_at"] = utc_now()
213
+ episode["lesson_title"] = title
214
+ episode["lesson_rule"] = rule
215
+ save_store(store)
216
+
217
+ if json_output:
218
+ print(json.dumps(episode, indent=2))
219
+ else:
220
+ ok(f"promoted {episode['id']} — feed into lessons.py add when grounded in validation")
221
+ return EXIT_OK
222
+
223
+
224
+ def build_parser() -> argparse.ArgumentParser:
225
+ parser = argparse.ArgumentParser(description="Episodic memory lifecycle")
226
+ sub = parser.add_subparsers(dest="command")
227
+
228
+ record = sub.add_parser("record", help="capture a working-session episode")
229
+ record.add_argument("--summary", required=True)
230
+ record.add_argument("--feature")
231
+ record.add_argument("--phase")
232
+ record.add_argument("--json", action="store_true")
233
+ record.set_defaults(
234
+ func=lambda args: record_episode(args.summary, args.feature, args.phase, args.json)
235
+ )
236
+
237
+ list_cmd = sub.add_parser("list", help="list episodes")
238
+ list_cmd.add_argument("--status", choices=STATUSES)
239
+ list_cmd.add_argument("--json", action="store_true")
240
+ list_cmd.set_defaults(func=lambda args: list_episodes(args.status, args.json))
241
+
242
+ archive = sub.add_parser("archive", help="move working -> episodic")
243
+ archive.add_argument("episode_id")
244
+ archive.add_argument("--json", action="store_true")
245
+ archive.set_defaults(func=lambda args: archive_episode(args.episode_id, args.json))
246
+
247
+ prune = sub.add_parser("prune", help="drop old episodic episodes")
248
+ prune.add_argument("--days", type=int)
249
+ prune.add_argument("--json", action="store_true")
250
+ prune.set_defaults(func=lambda args: prune_episodes(args.days, args.json))
251
+
252
+ promote = sub.add_parser("promote", help="mark episode promoted for lesson graduation")
253
+ promote.add_argument("episode_id")
254
+ promote.add_argument("--title")
255
+ promote.add_argument("--rule")
256
+ promote.add_argument("--json", action="store_true")
257
+ promote.set_defaults(
258
+ func=lambda args: promote_episode(args.episode_id, args.title or "", args.rule or "", args.json)
259
+ )
260
+
261
+ return parser
262
+
263
+
264
+ def main(argv: list[str] | None = None) -> int:
265
+ parser = build_parser()
266
+ args = parser.parse_args(argv)
267
+ if not args.command:
268
+ parser.print_help()
269
+ return EXIT_USAGE
270
+ return args.func(args)
271
+
272
+
273
+ if __name__ == "__main__":
274
+ sys.exit(main())
@@ -404,6 +404,52 @@ def index_lessons(conn: sqlite3.Connection, now: str) -> tuple[int, int]:
404
404
  return entity_count, chunk_count
405
405
 
406
406
 
407
+ def index_episodes(conn: sqlite3.Connection, now: str) -> tuple[int, int]:
408
+ episodes_path = SPECS_DIR / "state" / "episodes.json"
409
+ if not episodes_path.is_file():
410
+ return 0, 0
411
+
412
+ payload = json.loads(episodes_path.read_text(encoding="utf-8"))
413
+ entity_count = 0
414
+ chunk_count = 0
415
+
416
+ for episode in payload.get("episodes") or []:
417
+ episode_id = str(episode.get("id") or "").strip()
418
+ if not episode_id:
419
+ continue
420
+ status = str(episode.get("status") or "").strip().lower()
421
+ if status not in {"episodic", "archived", "promoted"}:
422
+ continue
423
+
424
+ feature_id = str(episode.get("feature_id") or "").strip() or None
425
+ summary = str(episode.get("summary") or episode_id)
426
+ upsert_entity(
427
+ conn,
428
+ episode_id,
429
+ "episode",
430
+ summary[:120],
431
+ str(episodes_path),
432
+ now,
433
+ )
434
+ entity_count += 1
435
+
436
+ if feature_id:
437
+ upsert_relation(conn, feature_id, episode_id, "contains")
438
+ upsert_relation(conn, episode_id, feature_id, "documents")
439
+
440
+ body_parts = [summary]
441
+ for key in ("phase", "lesson_title", "lesson_rule"):
442
+ value = episode.get(key)
443
+ if value:
444
+ body_parts.append(f"{key}: {value}")
445
+ body = "\n".join(body_parts).strip()
446
+ chunk_id = f"chunk:episode:{episode_id}"
447
+ upsert_chunk(conn, chunk_id, episode_id, "episode", str(episodes_path), body, now)
448
+ chunk_count += 1
449
+
450
+ return entity_count, chunk_count
451
+
452
+
407
453
  def rebuild(json_output: bool = False) -> int:
408
454
  if not SPECS_DIR.is_dir():
409
455
  return fail(".specs/ not found — run install first")
@@ -543,6 +589,9 @@ def rebuild(json_output: bool = False) -> int:
543
589
  lesson_entities, lesson_chunks = index_lessons(conn, now)
544
590
  entity_count += lesson_entities
545
591
  chunk_count += lesson_chunks
592
+ episode_entities, episode_chunks = index_episodes(conn, now)
593
+ entity_count += episode_entities
594
+ chunk_count += episode_chunks
546
595
  prune_embeddings(conn)
547
596
  conn.commit()
548
597
 
@@ -50,6 +50,9 @@ Structural gates run **before** owner review, so they cannot drift when the mode
50
50
  | After parallel wave merge | `npx @luizsantiago/spec-guardrails workspace-cleanup [feature] --force` |
51
51
  | Before editing paths outside task Files | `npx @luizsantiago/spec-guardrails context-guard check-edit <path> [--op write]` — **Cursor:** also auto-runs via `.cursor/hooks/` on write/edit tools |
52
52
  | Before claiming feature complete | `npx @luizsantiago/spec-guardrails context-guard check-complete [feature]` |
53
+ | Session episodic note | `npx @luizsantiago/spec-guardrails episodes record --summary "…"` |
54
+ | Brownfield code lookup | `npx @luizsantiago/spec-guardrails code-index rebuild` · `code-index search "…"` |
55
+ | Shell safety check | `npx @luizsantiago/spec-guardrails sandbox check-command "<cmd>"` |
53
56
  | Solution exploration (explicit) | `npx @luizsantiago/spec-guardrails solution-explore init <feature> --candidates A,B` |
54
57
  | Before exploration decision | `npx @luizsantiago/spec-guardrails solution-explore validate [feature]` |
55
58
  | Retrieve related context | `npx @luizsantiago/spec-guardrails memory-retrieve "<query>"` |
@@ -51,6 +51,9 @@ effects:
51
51
 
52
52
  # Memory retrieval (optional — hybrid FTS + graph + semantic)
53
53
  memory:
54
+ lifecycle:
55
+ retention_days: 90
56
+ auto_archive_on_handoff: false
54
57
  retrieval:
55
58
  semantic: false
56
59
  provider: none
@@ -60,6 +63,13 @@ memory:
60
63
  semantic_weight: 0.4
61
64
  graph_depth: 1
62
65
 
66
+ # Soft OS sandbox (optional — policy, not containers)
67
+ sandbox:
68
+ mode: warn
69
+ deny_patterns:
70
+ - "rm\\s+-rf"
71
+ - "curl.*\\|.*sh"
72
+
63
73
  # Project-specific overrides (appended on top of preset + rules above):
64
74
  # overrides:
65
75
  # rules:
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Cursor beforeShellExecution hook — soft sandbox for shell commands.
4
+ */
5
+ import { readFileSync } from "node:fs";
6
+ import { spawnSync } from "node:child_process";
7
+ import { pathToFileURL } from "node:url";
8
+
9
+ /**
10
+ * @param {Record<string, unknown>} input
11
+ * @returns {string | null}
12
+ */
13
+ export function extractShellCommand(input) {
14
+ const command =
15
+ input.command ??
16
+ input.shellCommand ??
17
+ input.tool_input?.command ??
18
+ input.toolInput?.command;
19
+ return typeof command === "string" && command.trim() ? command.trim() : null;
20
+ }
21
+
22
+ function allow() {
23
+ process.stdout.write(`${JSON.stringify({ permission: "allow" })}\n`);
24
+ process.exit(0);
25
+ }
26
+
27
+ /**
28
+ * @param {string} reason
29
+ * @param {boolean} strict
30
+ */
31
+ function respond(reason, strict) {
32
+ if (strict) {
33
+ process.stdout.write(
34
+ `${JSON.stringify({
35
+ permission: "deny",
36
+ user_message: `Spec Guardrails sandbox blocked this command: ${reason}`,
37
+ agent_message: `Sandbox policy blocked the shell command. ${reason}`,
38
+ })}\n`,
39
+ );
40
+ process.exit(2);
41
+ }
42
+
43
+ process.stdout.write(
44
+ `${JSON.stringify({
45
+ permission: "allow",
46
+ agent_message: `Sandbox warning: ${reason}`,
47
+ })}\n`,
48
+ );
49
+ process.exit(0);
50
+ }
51
+
52
+ function main() {
53
+ let input = null;
54
+ try {
55
+ input = JSON.parse(readFileSync(0, "utf8"));
56
+ } catch {
57
+ allow();
58
+ }
59
+
60
+ const command = extractShellCommand(input ?? {});
61
+ if (!command) {
62
+ allow();
63
+ }
64
+
65
+ const cli = process.env.SPEC_GUARDRAILS_CLI ?? "npx @luizsantiago/spec-guardrails";
66
+ const result = spawnSync(cli, ["sandbox", "check-command", command, "--json"], {
67
+ encoding: "utf8",
68
+ shell: process.platform === "win32",
69
+ });
70
+
71
+ if (result.status === 0 && result.stdout) {
72
+ try {
73
+ const parsed = JSON.parse(result.stdout);
74
+ if (parsed.reason && parsed.mode === "strict" && parsed.allowed === false) {
75
+ respond(parsed.reason, true);
76
+ }
77
+ if (parsed.reason && parsed.mode === "warn") {
78
+ respond(parsed.reason, false);
79
+ }
80
+ } catch {
81
+ allow();
82
+ }
83
+ }
84
+
85
+ if (result.status !== 0 && result.stdout) {
86
+ try {
87
+ const parsed = JSON.parse(result.stdout);
88
+ if (parsed.reason) {
89
+ respond(parsed.reason, parsed.mode === "strict");
90
+ }
91
+ } catch {
92
+ allow();
93
+ }
94
+ }
95
+
96
+ allow();
97
+ }
98
+
99
+ const entry = process.argv[1] ? pathToFileURL(process.argv[1]).href : "";
100
+ if (entry && import.meta.url === entry) {
101
+ main();
102
+ }
@@ -7,6 +7,12 @@
7
7
  "matcher": "Write|StrReplace|search_replace|EditNotebook|ApplyPatch",
8
8
  "failClosed": false
9
9
  }
10
+ ],
11
+ "beforeShellExecution": [
12
+ {
13
+ "command": ".cursor/hooks/sandbox-shell.mjs",
14
+ "failClosed": false
15
+ }
10
16
  ]
11
17
  }
12
18
  }