@luizsantiago/spec-guardrails 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  | **Solution** | One kit, two deliberate modes: **Process** (Node only) for a flexible spec-driven workflow; **Brakes** (Node + Python) for the **full product** — structural gates that exit non-zero when paperwork or evidence is missing. You approve specs/tasks in both. |
12
12
  | **Result** | Traceable `.specs/` memory, fewer fake finishes, cheaper turns (~70% less skill text on planning). Choose Process for light ceremony; add Python when you want the [Guarantees matrix](#guarantees-matrix) enforced automatically. |
13
13
 
14
- npm: [`@luizsantiago/spec-guardrails`](https://www.npmjs.com/package/@luizsantiago/spec-guardrails) **3.2.x**
14
+ npm: [`@luizsantiago/spec-guardrails`](https://www.npmjs.com/package/@luizsantiago/spec-guardrails) **3.3.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.3.x** | Intent/effect policy — `check-path --op read|write|delete` and `effects` config block |
220
221
  | **3.2.x** | Single-package focus; artifact gate severity labels; git worktree isolation CLI; execution policy (budget/scope/escalation) |
221
222
  | **3.1.x** | Copilot/Codex/AGENTS.md adapters; doctor Process + Brakes scores; `validate-traceability` / `validate-quick`; `classify-change` / `feature-status` |
222
223
  | **3.0.x** | Final name Spec Guardrails; `.specs/guardrails/`; no dual-path ([Migration](docs/guide/Migration.md)) |
package/index.js CHANGED
@@ -9,10 +9,12 @@ import { PACKAGE_VERSION, CLI_NAME } from "./lib/constants.js";
9
9
  import { phaseContext } from "./lib/config.js";
10
10
  import { doctor } from "./lib/doctor.js";
11
11
  import {
12
- checkPathScope,
13
12
  formatPolicyStatus,
14
13
  loadExecutionPolicy,
15
14
  loadPolicyState,
15
+ recordAgentRun,
16
+ recordTaskRetry,
17
+ resolvePathCheck,
16
18
  savePolicyState,
17
19
  } from "./lib/execution-policy.js";
18
20
  import { featureInit } from "./lib/feature.js";
@@ -21,7 +23,9 @@ import { GATE_COMMANDS, AUX_COMMANDS, runGate, runGuardrailsScript } from "./lib
21
23
  import { install } from "./lib/install.js";
22
24
  import {
23
25
  cleanupWorkspaces,
26
+ formatWorkspaceList,
24
27
  formatWorkspaceResults,
28
+ listWorkspaces,
25
29
  prepareWorkspaces,
26
30
  } from "./lib/workspace-isolation.js";
27
31
  import {
@@ -70,13 +74,21 @@ Commands:
70
74
  [--json] Machine-readable output
71
75
  workspace-cleanup <feature> Remove isolated worktrees for a feature
72
76
  [--tasks T1,T2] Limit cleanup to specific tasks
73
- [--force] Force-remove dirty worktrees
77
+ [--force] Force-remove dirty worktrees (recovery after worker FAIL)
78
+ [--json] Machine-readable output
79
+ workspace-list <feature> List isolated worktrees for a feature
74
80
  [--json] Machine-readable output
75
81
  execution-policy status Show configured budgets, scope, and runtime counters
76
82
  [--json] Machine-readable output
77
83
  execution-policy check-path <path> Check whether a relative path is allowed by scope policy
84
+ [--op read|write|delete] Intended operation (default: inferred from path)
85
+ [--json] Machine-readable output
86
+ execution-policy record-retry <task> Increment retry counter for a task id (blocks at limit)
87
+ execution-policy record-run Increment agent-run counter (blocks at budget)
88
+ memory-index rebuild Rebuild SQLite memory index from .specs/ artifacts
89
+ memory-query --from <id> Bounded context package from the knowledge graph
90
+ [--depth N] Traversal depth (default 2)
78
91
  [--json] Machine-readable output
79
- execution-policy record-retry <task> Increment retry counter for a task id
80
92
  validate-spec [spec.md|feature] Closure gate for a feature spec
81
93
  analyze-artifacts [feature] Cross-artifact consistency before task approval
82
94
  validate-tasks [tasks.md|feature] Granularity gate for a task breakdown
@@ -86,7 +98,7 @@ Commands:
86
98
  validate-quick [quick-folder] Quick-mode TASK.md / SUMMARY.md structural gate
87
99
  validate-state [feature] Completion gate before declaring a feature done
88
100
  check-commit --message "<msg>" Conventional Commits gate
89
- lessons <add|list|penalize|prune|status> Lessons engine
101
+ lessons <add|list|penalize|prune|promote|graduate|status> Lessons engine
90
102
  --help Show this message
91
103
  --version Print the package version
92
104
  `;
@@ -426,6 +438,30 @@ if (command === "--version" || command === "-v" || command === "version") {
426
438
  console.error(`❌ ${err.message}`);
427
439
  process.exit(1);
428
440
  }
441
+ } else if (command === "workspace-list") {
442
+ try {
443
+ let json = false;
444
+ const positional = [];
445
+
446
+ for (const arg of args) {
447
+ if (arg === "--json") {
448
+ json = true;
449
+ } else {
450
+ positional.push(arg);
451
+ }
452
+ }
453
+
454
+ const featureId = positional[0];
455
+ if (!featureId) {
456
+ throw new Error("Usage: workspace-list <feature> [--json]");
457
+ }
458
+
459
+ const workspaces = await listWorkspaces(process.cwd(), featureId);
460
+ process.stdout.write(formatWorkspaceList(workspaces, { json, featureId }));
461
+ } catch (err) {
462
+ console.error(`❌ ${err.message}`);
463
+ process.exit(1);
464
+ }
429
465
  } else if (command === "execution-policy") {
430
466
  try {
431
467
  const sub = args[0];
@@ -447,37 +483,82 @@ if (command === "--version" || command === "-v" || command === "version") {
447
483
  if (sub === "status") {
448
484
  process.stdout.write(formatPolicyStatus(policy, state, { json }));
449
485
  } else if (sub === "check-path") {
450
- const relativePath = rest[0];
486
+ let operation;
487
+ const positional = [];
488
+
489
+ for (let i = 0; i < rest.length; i++) {
490
+ const arg = rest[i];
491
+ if (arg === "--op") {
492
+ operation = rest[++i];
493
+ if (!operation) {
494
+ throw new Error("--op requires read, write, or delete");
495
+ }
496
+ } else {
497
+ positional.push(arg);
498
+ }
499
+ }
500
+
501
+ const relativePath = positional[0];
451
502
  if (!relativePath) {
452
- throw new Error("Usage: execution-policy check-path <relative-path>");
503
+ throw new Error(
504
+ "Usage: execution-policy check-path <relative-path> [--op read|write|delete]",
505
+ );
453
506
  }
454
- const result = checkPathScope(relativePath, policy);
507
+ const result = resolvePathCheck(relativePath, policy, { operation });
455
508
  if (json) {
456
509
  console.log(JSON.stringify({ path: relativePath, ...result }, null, 2));
457
510
  } else {
511
+ const label = result.allowed
512
+ ? result.severity === "warning"
513
+ ? "allowed (warn)"
514
+ : "allowed"
515
+ : result.severity === "warning"
516
+ ? "blocked (warn)"
517
+ : "blocked";
458
518
  console.log(
459
- `${relativePath}: ${result.allowed ? "allowed" : "blocked"} (${result.reason})`,
519
+ `${relativePath} [${result.operation}]: ${label} (${result.reason})`,
460
520
  );
461
521
  }
462
- if (!result.allowed) {
463
- process.exit(1);
522
+ if (result.exitCode !== 0) {
523
+ process.exit(result.exitCode);
464
524
  }
465
525
  } else if (sub === "record-retry") {
466
526
  const taskId = rest[0];
467
527
  if (!taskId) {
468
528
  throw new Error("Usage: execution-policy record-retry <task-id>");
469
529
  }
470
- state.retries[taskId] = (state.retries[taskId] ?? 0) + 1;
471
- state.iterations += 1;
472
- await savePolicyState(cwd, state);
530
+ const recorded = recordTaskRetry(state, taskId, policy);
531
+ if (!recorded.ok) {
532
+ console.error(`❌ ${recorded.message}`);
533
+ process.exit(1);
534
+ }
535
+ await savePolicyState(cwd, recorded.state);
536
+ if (json) {
537
+ console.log(
538
+ JSON.stringify({ taskId, retries: recorded.retries, state: recorded.state }, null, 2),
539
+ );
540
+ } else {
541
+ console.log(`Recorded retry for ${taskId}: ${recorded.retries}`);
542
+ }
543
+ } else if (sub === "record-run") {
544
+ const recorded = recordAgentRun(state, policy);
545
+ if (!recorded.ok) {
546
+ console.error(`❌ ${recorded.message}`);
547
+ process.exit(1);
548
+ }
549
+ await savePolicyState(cwd, recorded.state);
473
550
  if (json) {
474
- console.log(JSON.stringify({ taskId, retries: state.retries[taskId], state }, null, 2));
551
+ console.log(
552
+ JSON.stringify({ agent_runs: recorded.state.agent_runs, state: recorded.state }, null, 2),
553
+ );
475
554
  } else {
476
- console.log(`Recorded retry for ${taskId}: ${state.retries[taskId]}`);
555
+ console.log(
556
+ `Recorded agent run: ${recorded.state.agent_runs}/${policy.budget.max_agent_runs}`,
557
+ );
477
558
  }
478
559
  } else {
479
560
  throw new Error(
480
- "Usage: execution-policy status | check-path <path> | record-retry <task>",
561
+ "Usage: execution-policy status | check-path <path> | record-retry <task> | record-run",
481
562
  );
482
563
  }
483
564
  } catch (err) {
@@ -536,7 +617,8 @@ if (command === "--version" || command === "-v" || command === "version") {
536
617
  console.error(`❌ ${err.message}`);
537
618
  process.exit(1);
538
619
  }
539
- } else if (AUX_COMMANDS.includes(command)) { try {
620
+ } else if (AUX_COMMANDS.includes(command)) {
621
+ try {
540
622
  const code = await runGuardrailsScript(command, args);
541
623
  process.exit(code);
542
624
  } catch (err) {
@@ -0,0 +1,128 @@
1
+ import { injectAgentsMd } from "./agents-md.js";
2
+ import { injectCodexAgents } from "./codex-agents.js";
3
+ import { injectCopilotInstructions } from "./copilot-instructions.js";
4
+ import { injectClaudeMd } from "./claude-md.js";
5
+ import { injectCursorRules } from "./cursorrules.js";
6
+
7
+ /**
8
+ * @typedef {{
9
+ * id: string,
10
+ * label: string,
11
+ * skillsDir: string | null,
12
+ * entryFiles: string[],
13
+ * capabilities: {
14
+ * supports_hooks: boolean,
15
+ * supports_commands: boolean,
16
+ * supports_rules: boolean,
17
+ * supports_skills: boolean,
18
+ * },
19
+ * install: (cwd: string) => Promise<void>,
20
+ * }} PlatformAdapter
21
+ */
22
+
23
+ /** @type {PlatformAdapter[]} */
24
+ export const ADAPTER_REGISTRY = [
25
+ {
26
+ id: "cursor",
27
+ label: "Cursor",
28
+ skillsDir: ".cursor/skills",
29
+ entryFiles: [".cursorrules", ".cursor/rules/engineering-baseline.mdc"],
30
+ capabilities: {
31
+ supports_hooks: true,
32
+ supports_commands: true,
33
+ supports_rules: true,
34
+ supports_skills: true,
35
+ },
36
+ install: injectCursorRules,
37
+ },
38
+ {
39
+ id: "claude",
40
+ label: "Claude Code",
41
+ skillsDir: ".claude/skills",
42
+ entryFiles: [".claude/CLAUDE.md"],
43
+ capabilities: {
44
+ supports_hooks: false,
45
+ supports_commands: true,
46
+ supports_rules: false,
47
+ supports_skills: true,
48
+ },
49
+ install: injectClaudeMd,
50
+ },
51
+ {
52
+ id: "copilot",
53
+ label: "GitHub Copilot",
54
+ skillsDir: ".github/skills",
55
+ entryFiles: [".github/copilot-instructions.md"],
56
+ capabilities: {
57
+ supports_hooks: false,
58
+ supports_commands: false,
59
+ supports_rules: false,
60
+ supports_skills: true,
61
+ },
62
+ install: injectCopilotInstructions,
63
+ },
64
+ {
65
+ id: "codex",
66
+ label: "OpenAI Codex",
67
+ skillsDir: ".codex/skills",
68
+ entryFiles: [".codex/AGENTS.md"],
69
+ capabilities: {
70
+ supports_hooks: false,
71
+ supports_commands: false,
72
+ supports_rules: false,
73
+ supports_skills: true,
74
+ },
75
+ install: injectCodexAgents,
76
+ },
77
+ {
78
+ id: "agents-md",
79
+ label: "AGENTS.md (open standard)",
80
+ skillsDir: null,
81
+ entryFiles: ["AGENTS.md"],
82
+ capabilities: {
83
+ supports_hooks: false,
84
+ supports_commands: false,
85
+ supports_rules: false,
86
+ supports_skills: false,
87
+ },
88
+ install: injectAgentsMd,
89
+ },
90
+ ];
91
+
92
+ /**
93
+ * @param {string} id
94
+ * @returns {PlatformAdapter | undefined}
95
+ */
96
+ export function getAdapter(id) {
97
+ return ADAPTER_REGISTRY.find((adapter) => adapter.id === id);
98
+ }
99
+
100
+ /** @returns {PlatformAdapter[]} */
101
+ export function listAdapters() {
102
+ return [...ADAPTER_REGISTRY];
103
+ }
104
+
105
+ /**
106
+ * @param {keyof PlatformAdapter["capabilities"]} capability
107
+ * @returns {PlatformAdapter[]}
108
+ */
109
+ export function getAdaptersWithCapability(capability) {
110
+ return ADAPTER_REGISTRY.filter((adapter) => adapter.capabilities[capability]);
111
+ }
112
+
113
+ /**
114
+ * @param {string} cwd
115
+ * @param {PlatformAdapter} [adapter]
116
+ */
117
+ export async function installAdapter(cwd, adapter) {
118
+ await adapter.install(cwd);
119
+ }
120
+
121
+ /**
122
+ * Install all registered platform adapters.
123
+ *
124
+ * @param {string} cwd
125
+ */
126
+ export async function installAllAdapters(cwd) {
127
+ await Promise.all(ADAPTER_REGISTRY.map((adapter) => installAdapter(cwd, adapter)));
128
+ }
package/lib/adapters.js CHANGED
@@ -1,17 +1,11 @@
1
- import { injectAgentsMd } from "./agents-md.js";
2
- import { injectCodexAgents } from "./codex-agents.js";
3
- import { injectCopilotInstructions } from "./copilot-instructions.js";
1
+ import { installAllAdapters } from "./adapter-registry.js";
4
2
 
5
3
  /**
6
4
  * Install shipped platform adapter entry files (Copilot, Codex, AGENTS.md).
7
- * Cursor and Claude adapters are injected separately in install.js.
5
+ * Cursor and Claude adapters are injected via the same registry during install.
8
6
  *
9
7
  * @param {string} cwd
10
8
  */
11
9
  export async function installPlatformAdapters(cwd) {
12
- return Promise.all([
13
- injectCopilotInstructions(cwd),
14
- injectAgentsMd(cwd),
15
- injectCodexAgents(cwd),
16
- ]);
10
+ return installAllAdapters(cwd);
17
11
  }
package/lib/constants.js CHANGED
@@ -108,6 +108,8 @@ export const SCRIPT_ASSETS = [
108
108
  { file: "check_commit.py", remotePath: "scripts/check_commit.py" },
109
109
  { file: "lessons.py", remotePath: "scripts/lessons.py" },
110
110
  { file: "loop_plan.py", remotePath: "scripts/loop_plan.py" },
111
+ { file: "memory_index.py", remotePath: "scripts/memory_index.py" },
112
+ { file: "memory_query.py", remotePath: "scripts/memory_query.py" },
111
113
  ];
112
114
 
113
115
  /** @type {{ file: string, remotePath: string }[]} */
@@ -21,6 +21,14 @@ export const DEFAULT_POLICY = {
21
21
  on_budget_exhaustion: "stop",
22
22
  on_policy_violation: "block",
23
23
  },
24
+ effects: {
25
+ deny_read: [],
26
+ deny_write: [],
27
+ deny_delete: ["**/.git/**"],
28
+ warn_read: [],
29
+ warn_write: [],
30
+ warn_delete: [],
31
+ },
24
32
  };
25
33
 
26
34
  /**
@@ -28,9 +36,15 @@ export const DEFAULT_POLICY = {
28
36
  * budget: { max_iterations: number, max_agent_runs: number, max_retries_per_task: number },
29
37
  * scope: { allowed_paths: string[], denied_paths: string[] },
30
38
  * escalation: { on_scope_expansion: string, on_budget_exhaustion: string, on_policy_violation: string },
39
+ * effects: {
40
+ * deny_read: string[], deny_write: string[], deny_delete: string[],
41
+ * warn_read: string[], warn_write: string[], warn_delete: string[],
42
+ * },
31
43
  * }} ExecutionPolicy
32
44
  */
33
45
 
46
+ /** @typedef {"read" | "write" | "delete"} PathOperation */
47
+
34
48
  /**
35
49
  * @typedef {{
36
50
  * iterations: number,
@@ -48,7 +62,7 @@ export const DEFAULT_POLICY = {
48
62
  export function parseExecutionPolicySections(text) {
49
63
  /** @type {Partial<ExecutionPolicy>} */
50
64
  const result = {};
51
- /** @type {"budget" | "scope" | "escalation" | null} */
65
+ /** @type {"budget" | "scope" | "escalation" | "effects" | null} */
52
66
  let section = null;
53
67
  /** @type {string | null} */
54
68
  let listKey = null;
@@ -59,9 +73,9 @@ export function parseExecutionPolicySections(text) {
59
73
  continue;
60
74
  }
61
75
 
62
- const sectionMatch = trimmed.match(/^(budget|scope|escalation):\s*$/);
76
+ const sectionMatch = trimmed.match(/^(budget|scope|escalation|effects):\s*$/);
63
77
  if (sectionMatch) {
64
- section = /** @type {"budget" | "scope" | "escalation"} */ (sectionMatch[1]);
78
+ section = /** @type {"budget" | "scope" | "escalation" | "effects"} */ (sectionMatch[1]);
65
79
  result[section] = result[section] ?? {};
66
80
  listKey = null;
67
81
  continue;
@@ -112,6 +126,14 @@ export function mergeExecutionPolicy(base, overlay) {
112
126
  denied_paths: overlay.scope?.denied_paths ?? base.scope.denied_paths,
113
127
  },
114
128
  escalation: { ...base.escalation, ...(overlay.escalation ?? {}) },
129
+ effects: {
130
+ deny_read: overlay.effects?.deny_read ?? base.effects.deny_read,
131
+ deny_write: overlay.effects?.deny_write ?? base.effects.deny_write,
132
+ deny_delete: overlay.effects?.deny_delete ?? base.effects.deny_delete,
133
+ warn_read: overlay.effects?.warn_read ?? base.effects.warn_read,
134
+ warn_write: overlay.effects?.warn_write ?? base.effects.warn_write,
135
+ warn_delete: overlay.effects?.warn_delete ?? base.effects.warn_delete,
136
+ },
115
137
  };
116
138
  }
117
139
 
@@ -191,6 +213,154 @@ export function checkPathScope(relativePath, policy) {
191
213
  };
192
214
  }
193
215
 
216
+ const PATH_OPERATIONS = new Set(["read", "write", "delete"]);
217
+
218
+ /**
219
+ * @param {string | undefined | null} raw
220
+ * @returns {PathOperation}
221
+ */
222
+ export function normalizePathOperation(raw) {
223
+ const value = String(raw ?? "write").trim().toLowerCase();
224
+ if (!PATH_OPERATIONS.has(value)) {
225
+ throw new Error(`unknown operation '${raw}' — use read, write, or delete`);
226
+ }
227
+ return /** @type {PathOperation} */ (value);
228
+ }
229
+
230
+ /**
231
+ * Heuristic intent when the agent did not declare an operation explicitly.
232
+ *
233
+ * @param {string} relativePath
234
+ * @returns {PathOperation}
235
+ */
236
+ export function inferPathOperation(relativePath) {
237
+ const normalized = relativePath.replace(/\\/g, "/").replace(/^\.\//, "");
238
+ const readOnly = /\.(md|txt|json|yaml|yml|lock)$/i.test(normalized);
239
+ return readOnly ? "read" : "write";
240
+ }
241
+
242
+ /**
243
+ * @param {PathOperation} operation
244
+ * @param {ExecutionPolicy["effects"]} effects
245
+ * @returns {{ deny: string[], warn: string[] }}
246
+ */
247
+ export function effectRulesForOperation(operation, effects) {
248
+ return {
249
+ deny: effects[`deny_${operation}`] ?? [],
250
+ warn: effects[`warn_${operation}`] ?? [],
251
+ };
252
+ }
253
+
254
+ /**
255
+ * @param {string} relativePath
256
+ * @param {PathOperation} operation
257
+ * @param {ExecutionPolicy} policy
258
+ * @returns {{ allowed: boolean, severity: "blocking" | "warning" | "info", reason: string, operation: PathOperation }}
259
+ */
260
+ export function checkPathEffect(relativePath, operation, policy) {
261
+ const normalized = relativePath.replace(/\\/g, "/").replace(/^\.\//, "");
262
+ const { deny, warn } = effectRulesForOperation(operation, policy.effects);
263
+
264
+ for (const pattern of deny) {
265
+ if (matchGlobPattern(pattern, normalized)) {
266
+ return {
267
+ allowed: false,
268
+ severity: "blocking",
269
+ reason: `${operation.toUpperCase()} denied for path matching ${pattern}`,
270
+ operation,
271
+ };
272
+ }
273
+ }
274
+
275
+ for (const pattern of warn) {
276
+ if (matchGlobPattern(pattern, normalized)) {
277
+ return {
278
+ allowed: true,
279
+ severity: "warning",
280
+ reason: `${operation.toUpperCase()} warned for path matching ${pattern}`,
281
+ operation,
282
+ };
283
+ }
284
+ }
285
+
286
+ return {
287
+ allowed: true,
288
+ severity: "info",
289
+ reason: `no ${operation} effect rules matched`,
290
+ operation,
291
+ };
292
+ }
293
+
294
+ /**
295
+ * @param {string} relativePath
296
+ * @param {ExecutionPolicy} policy
297
+ * @param {{ operation?: string | null }} [options]
298
+ * @returns {{
299
+ * allowed: boolean,
300
+ * exitCode: number,
301
+ * severity: "blocking" | "warning" | "info",
302
+ * reason: string,
303
+ * operation: PathOperation,
304
+ * scope: ReturnType<typeof checkPathScope>,
305
+ * effect: ReturnType<typeof checkPathEffect>,
306
+ * }}
307
+ */
308
+ export function resolvePathCheck(relativePath, policy, options = {}) {
309
+ const operation = options.operation
310
+ ? normalizePathOperation(options.operation)
311
+ : inferPathOperation(relativePath);
312
+ const scope = checkPathScope(relativePath, policy);
313
+ const effect = checkPathEffect(relativePath, operation, policy);
314
+
315
+ if (!scope.allowed) {
316
+ const mode = policy.escalation.on_policy_violation ?? "block";
317
+ return {
318
+ allowed: false,
319
+ severity: mode === "warn" ? "warning" : "blocking",
320
+ reason: scope.reason,
321
+ operation,
322
+ scope,
323
+ effect,
324
+ exitCode: mode === "warn" ? 0 : 1,
325
+ };
326
+ }
327
+
328
+ if (!effect.allowed) {
329
+ const mode = policy.escalation.on_policy_violation ?? "block";
330
+ return {
331
+ allowed: false,
332
+ severity: mode === "warn" ? "warning" : "blocking",
333
+ reason: effect.reason,
334
+ operation,
335
+ scope,
336
+ effect,
337
+ exitCode: mode === "warn" ? 0 : 1,
338
+ };
339
+ }
340
+
341
+ if (effect.severity === "warning") {
342
+ return {
343
+ allowed: true,
344
+ severity: "warning",
345
+ reason: effect.reason,
346
+ operation,
347
+ scope,
348
+ effect,
349
+ exitCode: 0,
350
+ };
351
+ }
352
+
353
+ return {
354
+ allowed: true,
355
+ severity: "info",
356
+ reason: scope.reason,
357
+ operation,
358
+ scope,
359
+ effect,
360
+ exitCode: 0,
361
+ };
362
+ }
363
+
194
364
  /**
195
365
  * @param {string} cwd
196
366
  * @returns {Promise<ExecutionPolicyState>}
@@ -241,7 +411,7 @@ export function checkBudget(state, policy) {
241
411
  * @param {ExecutionPolicy} policy
242
412
  * @returns {{ ok: boolean, severity?: "blocking", message?: string, retries: number }}
243
413
  */
244
- export function checkTaskRetries(taskId, state, policy) {
414
+ export function previewTaskRetry(taskId, state, policy) {
245
415
  const retries = state.retries[taskId] ?? 0;
246
416
  if (retries >= policy.budget.max_retries_per_task) {
247
417
  return {
@@ -254,6 +424,70 @@ export function checkTaskRetries(taskId, state, policy) {
254
424
  return { ok: true, retries };
255
425
  }
256
426
 
427
+ /**
428
+ * @param {string} taskId
429
+ * @param {ExecutionPolicyState} state
430
+ * @param {ExecutionPolicy} policy
431
+ * @returns {{ ok: boolean, severity?: "blocking", message?: string, retries: number }}
432
+ */
433
+ export function checkTaskRetries(taskId, state, policy) {
434
+ const preview = previewTaskRetry(taskId, state, policy);
435
+ return preview.ok
436
+ ? { ok: true, retries: preview.retries }
437
+ : {
438
+ ok: false,
439
+ severity: preview.severity,
440
+ message: preview.message,
441
+ retries: preview.retries,
442
+ };
443
+ }
444
+
445
+ /**
446
+ * @param {ExecutionPolicyState} state
447
+ * @param {ExecutionPolicy} policy
448
+ * @returns {{ ok: boolean, message?: string }}
449
+ */
450
+ export function previewAgentRun(state, policy) {
451
+ if (state.agent_runs >= policy.budget.max_agent_runs) {
452
+ return { ok: false, message: "max_agent_runs budget exhausted" };
453
+ }
454
+ return { ok: true };
455
+ }
456
+
457
+ /**
458
+ * @param {ExecutionPolicyState} state
459
+ * @param {string} taskId
460
+ * @param {ExecutionPolicy} policy
461
+ * @returns {{ ok: boolean, state: ExecutionPolicyState, retries: number, message?: string }}
462
+ */
463
+ export function recordTaskRetry(state, taskId, policy) {
464
+ const preview = previewTaskRetry(taskId, state, policy);
465
+ if (!preview.ok) {
466
+ return { ok: false, state, retries: preview.retries, message: preview.message };
467
+ }
468
+
469
+ const next = structuredClone(state);
470
+ next.retries[taskId] = (next.retries[taskId] ?? 0) + 1;
471
+ next.iterations += 1;
472
+ return { ok: true, state: next, retries: next.retries[taskId] };
473
+ }
474
+
475
+ /**
476
+ * @param {ExecutionPolicyState} state
477
+ * @param {ExecutionPolicy} policy
478
+ * @returns {{ ok: boolean, state: ExecutionPolicyState, message?: string }}
479
+ */
480
+ export function recordAgentRun(state, policy) {
481
+ const preview = previewAgentRun(state, policy);
482
+ if (!preview.ok) {
483
+ return { ok: false, state, message: preview.message };
484
+ }
485
+
486
+ const next = structuredClone(state);
487
+ next.agent_runs += 1;
488
+ return { ok: true, state: next };
489
+ }
490
+
257
491
  /**
258
492
  * @param {ExecutionPolicy} policy
259
493
  * @param {ExecutionPolicyState} state
@@ -278,6 +512,7 @@ export function formatPolicyStatus(policy, state, options = {}) {
278
512
  ` agent_runs: ${state.agent_runs}/${policy.budget.max_agent_runs}`,
279
513
  ` allowed_paths: ${policy.scope.allowed_paths.length ? policy.scope.allowed_paths.join(", ") : "(none — all non-denied paths allowed)"}`,
280
514
  ` denied_paths: ${policy.scope.denied_paths.join(", ")}`,
515
+ ` deny_delete: ${policy.effects.deny_delete.join(", ") || "(default .git only)"}`,
281
516
  ` budget_ok: ${budget.ok ? "yes" : "no"}`,
282
517
  ];
283
518
 
package/lib/gates.js CHANGED
@@ -41,6 +41,8 @@ const GATE_SCRIPTS = {
41
41
 
42
42
  const AUX_SCRIPTS = {
43
43
  "loop-plan": "loop_plan.py",
44
+ "memory-index": "memory_index.py",
45
+ "memory-query": "memory_query.py",
44
46
  };
45
47
 
46
48
  const GUARDRAILS_SCRIPTS = { ...GATE_SCRIPTS, ...AUX_SCRIPTS };