@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 +2 -1
- package/index.js +99 -17
- package/lib/adapter-registry.js +128 -0
- package/lib/adapters.js +3 -9
- package/lib/constants.js +2 -0
- package/lib/execution-policy.js +239 -4
- package/lib/gates.js +2 -0
- package/lib/install.js +0 -4
- package/lib/memory-index.js +59 -0
- package/lib/workspace-isolation.js +33 -0
- package/package.json +2 -2
- package/rules/engineering-baseline.mdc +3 -1
- package/scripts/analyze_artifacts.py +23 -0
- package/scripts/lessons.py +163 -30
- package/scripts/memory_index.py +325 -0
- package/scripts/memory_query.py +177 -0
- package/scripts/validate_spec.py +1 -0
- package/skills/agent-architecture.md +4 -0
- package/skills/git-handoff.md +16 -0
- package/skills/references/implement.md +4 -1
- package/skills/references/lessons.md +16 -10
- package/templates/config.yaml.example +10 -0
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.
|
|
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
|
-
|
|
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(
|
|
503
|
+
throw new Error(
|
|
504
|
+
"Usage: execution-policy check-path <relative-path> [--op read|write|delete]",
|
|
505
|
+
);
|
|
453
506
|
}
|
|
454
|
-
const result =
|
|
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}
|
|
519
|
+
`${relativePath} [${result.operation}]: ${label} (${result.reason})`,
|
|
460
520
|
);
|
|
461
521
|
}
|
|
462
|
-
if (
|
|
463
|
-
process.exit(
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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(
|
|
551
|
+
console.log(
|
|
552
|
+
JSON.stringify({ agent_runs: recorded.state.agent_runs, state: recorded.state }, null, 2),
|
|
553
|
+
);
|
|
475
554
|
} else {
|
|
476
|
-
console.log(
|
|
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)) {
|
|
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 {
|
|
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
|
|
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
|
|
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 }[]} */
|
package/lib/execution-policy.js
CHANGED
|
@@ -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
|
|
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