@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 +5 -2
- package/index.js +64 -0
- package/lib/constants.js +2 -0
- package/lib/cursor-hooks.js +24 -11
- package/lib/gates.js +2 -0
- package/lib/sandbox-policy.js +173 -0
- package/package.json +2 -2
- package/scripts/_memory_config.py +62 -0
- package/scripts/code_index.py +228 -0
- package/scripts/episodes.py +274 -0
- package/scripts/memory_index.py +49 -0
- package/skills/agent-architecture.md +3 -0
- package/templates/config.yaml.example +10 -0
- package/templates/cursor/hooks/sandbox-shell.mjs +102 -0
- package/templates/cursor/hooks.json +6 -0
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
|
+
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
|
|
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 }[]} */
|
package/lib/cursor-hooks.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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.
|
|
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())
|
package/scripts/memory_index.py
CHANGED
|
@@ -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
|
+
}
|