@devrik-tools/claude-gates 0.1.2 → 0.2.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.
Files changed (106) hide show
  1. package/.claude-plugin/marketplace.json +7 -1
  2. package/cli/__tests__/config.test.mjs +113 -101
  3. package/cli/__tests__/install.test.mjs +102 -0
  4. package/cli/__tests__/materialize.test.mjs +95 -0
  5. package/cli/__tests__/registry-gates-consistency.test.mjs +52 -44
  6. package/cli/__tests__/task.test.mjs +124 -0
  7. package/cli/__tests__/version-consistency.test.mjs +44 -0
  8. package/cli/config.mjs +124 -113
  9. package/cli/index.mjs +100 -97
  10. package/cli/init.mjs +259 -256
  11. package/cli/install.mjs +150 -80
  12. package/cli/materialize.mjs +102 -51
  13. package/cli/registry.mjs +136 -127
  14. package/cli/task.mjs +140 -0
  15. package/package.json +2 -1
  16. package/plugins/gates/.claude-plugin/plugin.json +8 -2
  17. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +83 -0
  18. package/plugins/gates/hooks/__tests__/doctor.test.mjs +85 -0
  19. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +65 -0
  20. package/plugins/gates/hooks/ask-adoption.mjs +147 -0
  21. package/plugins/gates/hooks/doctor.mjs +207 -0
  22. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +83 -0
  23. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -88
  24. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +117 -92
  25. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +104 -0
  26. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +48 -45
  27. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +165 -0
  28. package/plugins/gates/hooks/gates/bash-commands/index.mjs +285 -268
  29. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +151 -0
  30. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +265 -177
  31. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +207 -0
  32. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +501 -456
  33. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +161 -143
  34. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +69 -0
  35. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +126 -118
  36. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +68 -0
  37. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +43 -49
  38. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +83 -0
  39. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -100
  40. package/plugins/gates/hooks/gates/force-parallel/index.mjs +119 -0
  41. package/plugins/gates/hooks/gates/force-parallel/test.mjs +88 -0
  42. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +155 -0
  43. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -112
  44. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +66 -0
  45. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -186
  46. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -238
  47. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +96 -0
  48. package/plugins/gates/hooks/gates/intent-flow/test.mjs +165 -136
  49. package/plugins/gates/hooks/gates/lint-commit/index.mjs +149 -0
  50. package/plugins/gates/hooks/gates/lint-commit/test.mjs +85 -0
  51. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +100 -0
  52. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -166
  53. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +79 -85
  54. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +80 -0
  55. package/plugins/gates/hooks/gates/never-assume/index.mjs +58 -55
  56. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +71 -0
  57. package/plugins/gates/hooks/gates/never-assume/test.mjs +95 -78
  58. package/plugins/gates/hooks/gates/no-blocking/index.mjs +148 -142
  59. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +109 -0
  60. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +123 -120
  61. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +83 -0
  62. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +91 -0
  63. package/plugins/gates/hooks/gates/protected-paths/index.mjs +144 -147
  64. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +126 -0
  65. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +89 -64
  66. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +102 -0
  67. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +94 -0
  68. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +159 -150
  69. package/plugins/gates/hooks/gates/risk-level/index.mjs +263 -203
  70. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +116 -0
  71. package/plugins/gates/hooks/gates/risk-level/test.mjs +153 -125
  72. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +56 -50
  73. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +58 -0
  74. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +116 -111
  75. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +97 -0
  76. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +184 -99
  77. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +126 -0
  78. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +130 -0
  79. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -251
  80. package/plugins/gates/hooks/gates/stop-pending/index.mjs +159 -0
  81. package/plugins/gates/hooks/gates/stop-pending/test.mjs +114 -0
  82. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -101
  83. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +72 -0
  84. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +63 -0
  85. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -181
  86. package/plugins/gates/hooks/gates/test-matrix/test.mjs +114 -87
  87. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +62 -0
  88. package/plugins/gates/hooks/gates/tool-map/index.mjs +143 -140
  89. package/plugins/gates/hooks/hooks.json +346 -286
  90. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +173 -154
  91. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +210 -154
  92. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +114 -0
  93. package/plugins/gates/hooks/lib/config.mjs +172 -165
  94. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +189 -0
  95. package/plugins/gates/hooks/lib/hook-io.mjs +318 -208
  96. package/plugins/gates/hooks/lib/signals.mjs +127 -0
  97. package/plugins/gates/hooks/wiring-check.mjs +227 -0
  98. package/plugins/tasks/.claude-plugin/plugin.json +14 -0
  99. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +100 -0
  100. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +95 -0
  101. package/plugins/tasks/hooks/hooks.json +26 -0
  102. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +180 -132
  103. package/plugins/tasks/hooks/lib/task-store.mjs +197 -159
  104. package/plugins/tasks/hooks/register-requests.mjs +141 -108
  105. package/plugins/tasks/hooks/session-tasks.mjs +108 -0
  106. package/registry.json +793 -686
@@ -0,0 +1,155 @@
1
+ // Edge audit for forge-flow: fail-open vs fail-closed behavior, and the MCP write/shell
2
+ // matcher hole (same structural gap as autonomous-mode's question hole, applied here to
3
+ // ACTING_TOOLS = TOOL_GROUPS.write + TOOL_GROUPS.shell).
4
+
5
+ import assert from 'node:assert/strict';
6
+ import { execFileSync } from 'node:child_process';
7
+ import { mkdtempSync, mkdirSync, writeFileSync, chmodSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { dirname, join } from 'node:path';
10
+ import { DatabaseSync } from 'node:sqlite';
11
+ import { test } from 'node:test';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
15
+
16
+ function adoptedProject() {
17
+ const project = mkdtempSync(join(tmpdir(), 'forge-edge-'));
18
+ mkdirSync(join(project, '.git'));
19
+ mkdirSync(join(project, '.ai'));
20
+ writeFileSync(join(project, '.ai', 'forge.json'), '{}');
21
+ return project;
22
+ }
23
+
24
+ function writeConfig(project, forgeDatabasePath, enabled = true) {
25
+ const gateEntry = { enabled };
26
+ if (forgeDatabasePath) gateEntry.forgeDatabasePath = forgeDatabasePath;
27
+ writeFileSync(
28
+ join(project, '.ai', 'config.json'),
29
+ JSON.stringify({ gates: { requireForgeRunToEdit: gateEntry } }),
30
+ );
31
+ }
32
+
33
+ function runGate(payload, project) {
34
+ const out = execFileSync(process.execPath, [GATE], {
35
+ input: JSON.stringify(payload),
36
+ encoding: 'utf8',
37
+ cwd: project,
38
+ env: { ...process.env, HOME: project, USERPROFILE: project },
39
+ });
40
+ return out.trim() ? JSON.parse(out.trim()) : null;
41
+ }
42
+
43
+ function isDeny(result) {
44
+ return result?.hookSpecificOutput?.permissionDecision === 'deny';
45
+ }
46
+
47
+ function isWarn(result) {
48
+ const output = result?.hookSpecificOutput;
49
+ return Boolean(output?.additionalContext) && output.permissionDecision !== 'deny';
50
+ }
51
+
52
+ function makeForgeDatabase(directory, activeCwd) {
53
+ const databasePath = join(directory, 'forge.db');
54
+ const database = new DatabaseSync(databasePath);
55
+ database.exec(
56
+ 'CREATE TABLE runs (id TEXT, cwd TEXT, current_phase TEXT, status TEXT)',
57
+ );
58
+ if (activeCwd) {
59
+ database
60
+ .prepare('INSERT INTO runs (id, cwd, current_phase, status) VALUES (?,?,?,?)')
61
+ .run('r1', activeCwd, 'build', 'active');
62
+ }
63
+ database.close();
64
+ return databasePath;
65
+ }
66
+
67
+ test('DEGRADED-WARN: a corrupt (non-SQLite) DB file allows the write but WARNS visibly', () => {
68
+ const project = adoptedProject();
69
+ const home = mkdtempSync(join(tmpdir(), 'forge-db-'));
70
+ const databasePath = join(home, 'forge.db');
71
+ writeFileSync(databasePath, 'this is not a sqlite file at all');
72
+ writeConfig(project, databasePath);
73
+ const result = runGate(
74
+ { tool_name: 'Write', tool_input: { file_path: 'x.js', content: 'x' } },
75
+ project,
76
+ );
77
+ // The catch-all no longer returns a silent "run present". A DB that cannot be read yields
78
+ // state 'unknown' -> the gate ALLOWS (a broken lookup must not freeze all work) but emits a
79
+ // warn, so degraded enforcement is never invisible. This is the fix for the old silent
80
+ // fail-open that disabled the whole gate with no trace.
81
+ assert.ok(isWarn(result), 'corrupt DB must allow-with-warning, not allow silently');
82
+ });
83
+
84
+ test('DEGRADED-WARN: DB exists but "runs" table is missing/wrong schema warns visibly', () => {
85
+ const project = adoptedProject();
86
+ const home = mkdtempSync(join(tmpdir(), 'forge-db-'));
87
+ const databasePath = join(home, 'forge.db');
88
+ const database = new DatabaseSync(databasePath);
89
+ database.exec('CREATE TABLE not_runs (id TEXT)');
90
+ database.close();
91
+ writeConfig(project, databasePath);
92
+ const result = runGate(
93
+ { tool_name: 'Write', tool_input: { file_path: 'x.js', content: 'x' } },
94
+ project,
95
+ );
96
+ assert.ok(isWarn(result), 'schema drift must allow-with-warning, not allow silently');
97
+ });
98
+
99
+ test('FAIL-CLOSED (deny): DB file plainly absent at the configured path denies the write', () => {
100
+ const project = adoptedProject();
101
+ writeConfig(project, join(tmpdir(), 'definitely-does-not-exist-' + Date.now(), 'forge.db'));
102
+ const result = runGate(
103
+ { tool_name: 'Write', tool_input: { file_path: 'x.js', content: 'x' } },
104
+ project,
105
+ );
106
+ // existsSync(forgeDatabasePath) is false -> hasActiveForgeRun returns false directly
107
+ // (line 72: "no DB yet -> no runs at all -> deny") -- this is the ONE path that is
108
+ // fail-closed; every other failure mode (corrupt file, missing table, locked file,
109
+ // node:sqlite unavailable) is fail-open via the catch block.
110
+ assert.ok(isDeny(result), 'FALLA(ok): absent DB is the sole fail-closed path');
111
+ });
112
+
113
+ test('FIXED: an MCP filesystem write tool is now gated (denied with no active run)', () => {
114
+ const project = adoptedProject();
115
+ const home = mkdtempSync(join(tmpdir(), 'forge-db-'));
116
+ const databasePath = makeForgeDatabase(home); // no active run: a real Write would be denied
117
+ writeConfig(project, databasePath);
118
+ const result = runGate(
119
+ {
120
+ tool_name: 'mcp__filesystem__write_file',
121
+ tool_input: { path: 'x.js', content: 'x' },
122
+ },
123
+ project,
124
+ );
125
+ // ACTING_GROUPS = ['execution'] and toolInGroups matches mcp__*__write_file via the write
126
+ // signal, so an MCP filesystem write is now subject to the active-run requirement exactly
127
+ // like a native Write. No active run -> deny.
128
+ assert.ok(isDeny(result), 'MCP write tool must now be gated like a native Write');
129
+ });
130
+
131
+ test('control: the same DB/config DOES deny a native Write (proves the MCP case above is a real gap, not a config issue)', () => {
132
+ const project = adoptedProject();
133
+ const home = mkdtempSync(join(tmpdir(), 'forge-db-'));
134
+ const databasePath = makeForgeDatabase(home); // no active run
135
+ writeConfig(project, databasePath);
136
+ const result = runGate(
137
+ { tool_name: 'Write', tool_input: { file_path: 'x.js', content: 'x' } },
138
+ project,
139
+ );
140
+ assert.ok(isDeny(result), 'FALLA(ok): native Write is correctly denied under identical setup');
141
+ });
142
+
143
+ test('FIXED: an MCP shell/exec tool (mcp__ide__executeCode) is now gated', () => {
144
+ const project = adoptedProject();
145
+ const home = mkdtempSync(join(tmpdir(), 'forge-db-'));
146
+ const databasePath = makeForgeDatabase(home); // no active run
147
+ writeConfig(project, databasePath);
148
+ const result = runGate(
149
+ { tool_name: 'mcp__ide__executeCode', tool_input: { code: 'require("fs").writeFileSync("x.js","x")' } },
150
+ project,
151
+ );
152
+ // forge-flow now consumes the 'execution' group, which includes mcp__ide__executeCode and,
153
+ // via toolInGroups, any mcp__* exec/run surface. No active run -> deny.
154
+ assert.ok(isDeny(result), 'mcp__ide__executeCode must now be gated by forge-flow');
155
+ });
@@ -1,112 +1,134 @@
1
- // forge-flow — the enforcer forge cannot be. A forge MCP directs a pipeline but cannot
2
- // intercept Edit/Write/Bash, so nothing makes you actually use it. This gate closes that
3
- // hole: in a project that ADOPTED forge, a code-mutating action is denied unless there is
4
- // an active forge run for this project — so every change goes through the pipeline and you
5
- // always know which phase you are in.
6
- //
7
- // justification: no existing tool covers this. A forge MCP audit (see memory
8
- // forge-mcp-auditoria-flujo) confirmed the structural hole: an MCP cannot gate other tools;
9
- // only a Claude Code PreToolUse hook can. This is that hook.
10
- //
11
- // ── When it acts (never surprises you) ──────────────────────────────────────────────
12
- // Only when the project adopted forge — a marker on disk (.ai/forge.json, or `forge: true`
13
- // in .ai/config.json). In any other project it stays silent. Off by default in the
14
- // registry, so it never fires unless a project turns it on.
15
- //
16
- // ── How it checks (deterministic, no judgment) ──────────────────────────────────────
17
- // forge persists its runs in a SQLite DB (global by default). This gate reads that DB with
18
- // node:sqlite (a Node built-in — no npm, contract intact) and looks for an active run whose
19
- // cwd matches this project. Absent → deny with an actionable message. The DB unreadable or
20
- // node:sqlite unavailable → allow (fail open: a broken lookup must not block all work).
21
-
22
- import { existsSync, readFileSync } from 'node:fs';
23
- import { homedir } from 'node:os';
24
- import { dirname, join } from 'node:path';
25
- import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
26
-
27
- const GATE_ID = 'forge-flow';
28
- const CONFIG_KEY = 'requireForgeRunToEdit';
29
-
30
- const ACTING_TOOLS = new Set([...TOOL_GROUPS.write, ...TOOL_GROUPS.shell]);
31
- const PROJECT_ROOT_MARKERS = ['.git', '.ai'];
32
- const DEFAULT_FORGE_DB = join('.forge', 'forge-mcp.db');
33
- const FORGE_MARKER_FILE = join('.ai', 'forge.json');
34
- const PROJECT_CONFIG_FILE = join('.ai', 'config.json');
35
-
36
- function projectRootOf(startDirectory) {
37
- let current = startDirectory;
38
- while (true) {
39
- if (
40
- PROJECT_ROOT_MARKERS.some((marker) => existsSync(join(current, marker)))
41
- ) {
42
- return current;
43
- }
44
- const parent = dirname(current);
45
- if (parent === current) return null;
46
- current = parent;
47
- }
48
- }
49
-
50
- function readJson(path) {
51
- try {
52
- return JSON.parse(readFileSync(path, 'utf8'));
53
- } catch {
54
- return null;
55
- }
56
- }
57
-
58
- /** True when this project opted into forge: a marker file, or forge:true in .ai/config.json. */
59
- function projectAdoptedForge(root) {
60
- if (existsSync(join(root, FORGE_MARKER_FILE))) return true;
61
- const config = readJson(join(root, PROJECT_CONFIG_FILE));
62
- return config?.forge === true;
63
- }
64
-
65
- /**
66
- * True when forge has an active run whose cwd is this project. Reads forge's SQLite DB with
67
- * node:sqlite (imported dynamically so an older Node without it degrades instead of
68
- * throwing). Any failure — module missing, DB absent, locked, unreadable, schema drift —
69
- * returns true: the gate must fail OPEN, since a broken lookup must never block every edit.
70
- */
71
- async function hasActiveForgeRun(root, forgeDatabasePath) {
72
- if (!existsSync(forgeDatabasePath)) return false; // no DB yet no runs at all deny
73
- try {
74
- const { DatabaseSync } = await import('node:sqlite');
75
- const database = new DatabaseSync(forgeDatabasePath, { readOnly: true });
76
- const rows = database
77
- .prepare("SELECT cwd FROM runs WHERE status = 'active'")
78
- .all();
79
- database.close();
80
- return rows.some((row) => String(row.cwd) === root);
81
- } catch {
82
- return true; // node:sqlite missing, or DB locked/unreadable → fail open
83
- }
84
- }
85
-
86
- const DENY_MESSAGE =
87
- 'This project uses forge, but there is no active forge run for it. Every change should ' +
88
- 'go through the pipeline so the next step is always clear. Start or resume a run ' +
89
- '(forge_start / forge_next) before editing — that is how forge tells you which phase ' +
90
- 'you are in. To work outside the pipeline, turn this gate off in .ai/config.json.';
91
-
92
- runGate(
93
- {
94
- id: GATE_ID,
95
- configKey: CONFIG_KEY,
96
- enabledByDefault: false,
97
- defaultParams: { forgeDatabasePath: join(homedir(), DEFAULT_FORGE_DB) },
98
- },
99
- async ({ toolName, parameters }) => {
100
- if (!ACTING_TOOLS.has(toolName)) return;
101
-
102
- const root = projectRootOf(process.cwd());
103
- if (!root) return; // no project
104
- if (!projectAdoptedForge(root)) return; // project did not opt into forge
105
-
106
- const forgeDatabasePath =
107
- parameters.forgeDatabasePath ?? join(homedir(), DEFAULT_FORGE_DB);
108
- if (await hasActiveForgeRun(root, forgeDatabasePath)) return;
109
-
110
- deny(GATE_ID, DENY_MESSAGE);
111
- },
112
- );
1
+ // forge-flow — the enforcer forge cannot be. A forge MCP directs a pipeline but cannot
2
+ // intercept Edit/Write/Bash, so nothing makes you actually use it. This gate closes that
3
+ // hole: in a project that ADOPTED forge, a code-mutating action is denied unless there is
4
+ // an active forge run for this project — so every change goes through the pipeline and you
5
+ // always know which phase you are in.
6
+ //
7
+ // justification: no existing tool covers this. A forge MCP audit (see memory
8
+ // forge-mcp-auditoria-flujo) confirmed the structural hole: an MCP cannot gate other tools;
9
+ // only a Claude Code PreToolUse hook can. This is that hook.
10
+ //
11
+ // ── When it acts (never surprises you) ──────────────────────────────────────────────
12
+ // Only when the project adopted forge — a marker on disk (.ai/forge.json, or `forge: true`
13
+ // in .ai/config.json). In any other project it stays silent. Off by default in the
14
+ // registry, so it never fires unless a project turns it on.
15
+ //
16
+ // ── How it checks (deterministic, no judgment) ──────────────────────────────────────
17
+ // forge persists its runs in a SQLite DB (global by default). This gate reads that DB with
18
+ // node:sqlite (a Node built-in — no npm, contract intact) and looks for an active run whose
19
+ // cwd matches this project. Absent → deny with an actionable message. The DB unreadable or
20
+ // node:sqlite unavailable → allow (fail open: a broken lookup must not block all work).
21
+
22
+ import { existsSync, readFileSync } from 'node:fs';
23
+ import { homedir } from 'node:os';
24
+ import { dirname, join } from 'node:path';
25
+ import { runGate, deny, warn, toolInGroups } from '../../lib/hook-io.mjs';
26
+
27
+ const GATE_ID = 'forge-flow';
28
+ const CONFIG_KEY = 'requireForgeRunToEdit';
29
+
30
+ // Any code-mutating surface: native write/shell AND their MCP equivalents. `execution`
31
+ // already unions write+shell+mcp__ide__executeCode, and toolInGroups adds the mcp__* signal
32
+ // match so an MCP filesystem-write or shell-exec tool no longer slips past the enforcer.
33
+ const ACTING_GROUPS = ['execution'];
34
+ const PROJECT_ROOT_MARKERS = ['.git', '.ai'];
35
+ const DEFAULT_FORGE_DB = join('.forge', 'forge-mcp.db');
36
+ const FORGE_MARKER_FILE = join('.ai', 'forge.json');
37
+ const PROJECT_CONFIG_FILE = join('.ai', 'config.json');
38
+
39
+ function projectRootOf(startDirectory) {
40
+ let current = startDirectory;
41
+ while (true) {
42
+ if (
43
+ PROJECT_ROOT_MARKERS.some((marker) => existsSync(join(current, marker)))
44
+ ) {
45
+ return current;
46
+ }
47
+ const parent = dirname(current);
48
+ if (parent === current) return null;
49
+ current = parent;
50
+ }
51
+ }
52
+
53
+ function readJson(path) {
54
+ try {
55
+ return JSON.parse(readFileSync(path, 'utf8'));
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /** True when this project opted into forge: a marker file, or forge:true in .ai/config.json. */
62
+ function projectAdoptedForge(root) {
63
+ if (existsSync(join(root, FORGE_MARKER_FILE))) return true;
64
+ const config = readJson(join(root, PROJECT_CONFIG_FILE));
65
+ return config?.forge === true;
66
+ }
67
+
68
+ /**
69
+ * True when forge has an active run whose cwd is this project. Reads forge's SQLite DB with
70
+ * node:sqlite (imported dynamically so an older Node without it degrades instead of
71
+ * throwing). Any failure — module missing, DB absent, locked, unreadable, schema drift —
72
+ * returns true: the gate must fail OPEN, since a broken lookup must never block every edit.
73
+ */
74
+ // Returns one of three verdicts, so a broken lookup no longer masquerades as "run present":
75
+ // { state: 'active' } → a run for this project exists; allow.
76
+ // { state: 'none' } → DB readable, no active run for this project; deny.
77
+ // { state: 'unknown', reason } DB absent/locked/corrupt/schema-drift, or node:sqlite
78
+ // missing; we cannot tell. Warn (visible) but allow, so a
79
+ // broken DB never blocks all work AND never disables the
80
+ // enforcer silently the earlier code returned true here,
81
+ // which looked identical to "run present".
82
+ async function forgeRunState(root, forgeDatabasePath) {
83
+ if (!existsSync(forgeDatabasePath)) return { state: 'none' }; // no DB yet → no runs → deny
84
+ try {
85
+ const { DatabaseSync } = await import('node:sqlite');
86
+ const database = new DatabaseSync(forgeDatabasePath, { readOnly: true });
87
+ const rows = database
88
+ .prepare("SELECT cwd FROM runs WHERE status = 'active'")
89
+ .all();
90
+ database.close();
91
+ return rows.some((row) => String(row.cwd) === root)
92
+ ? { state: 'active' }
93
+ : { state: 'none' };
94
+ } catch (error) {
95
+ return { state: 'unknown', reason: error?.message ?? String(error) };
96
+ }
97
+ }
98
+
99
+ const DENY_MESSAGE =
100
+ 'This project uses forge, but there is no active forge run for it. Every change should ' +
101
+ 'go through the pipeline so the next step is always clear. Start or resume a run ' +
102
+ '(forge_start / forge_next) before editing — that is how forge tells you which phase ' +
103
+ 'you are in. To work outside the pipeline, turn this gate off in .ai/config.json.';
104
+
105
+ runGate(
106
+ {
107
+ id: GATE_ID,
108
+ configKey: CONFIG_KEY,
109
+ enabledByDefault: false,
110
+ defaultParams: { forgeDatabasePath: join(homedir(), DEFAULT_FORGE_DB) },
111
+ },
112
+ async ({ toolName, parameters }) => {
113
+ if (!toolInGroups(toolName, ACTING_GROUPS)) return;
114
+
115
+ const root = projectRootOf(process.cwd());
116
+ if (!root) return; // no project
117
+ if (!projectAdoptedForge(root)) return; // project did not opt into forge
118
+
119
+ const forgeDatabasePath =
120
+ parameters.forgeDatabasePath ?? join(homedir(), DEFAULT_FORGE_DB);
121
+ const result = await forgeRunState(root, forgeDatabasePath);
122
+
123
+ if (result.state === 'active') return; // pipeline is running → allow
124
+ if (result.state === 'none') deny(GATE_ID, DENY_MESSAGE); // no run → block
125
+ // state 'unknown': the DB could not be read. Allow so a broken lookup never freezes work,
126
+ // but surface it loudly — a silent allow here would disable the enforcer without a trace.
127
+ warn(
128
+ GATE_ID,
129
+ `forge enforcement is degraded: the forge DB could not be read (${result.reason}). ` +
130
+ 'Allowing this action, but the pipeline is NOT being enforced. Check that forge is ' +
131
+ `installed and ${forgeDatabasePath} is readable, or turn this gate off if intended.`,
132
+ );
133
+ },
134
+ );
@@ -0,0 +1,66 @@
1
+ // Edge-case probe for implementation-pipeline. Previously the prompt was read only
2
+ // from toolInput.prompt/Prompt/task, so a Task delegation carrying its brief in
3
+ // `description` bypassed the pipeline check entirely. Migrated to
4
+ // delegationPromptOf() (hook-io.mjs), which also reads description/instructions/
5
+ // message/input, so this is now caught.
6
+ import assert from 'node:assert/strict';
7
+ import { execFileSync } from 'node:child_process';
8
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { dirname, join } from 'node:path';
11
+ import { test } from 'node:test';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
15
+
16
+ function runGate(payload, { config } = {}) {
17
+ const project = mkdtempSync(join(tmpdir(), 'implementation-pipeline-edge-'));
18
+ mkdirSync(join(project, '.git'));
19
+ if (config) {
20
+ mkdirSync(join(project, '.ai'));
21
+ writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
22
+ }
23
+ const out = execFileSync(process.execPath, [GATE], {
24
+ input: JSON.stringify(payload),
25
+ encoding: 'utf8',
26
+ cwd: project,
27
+ env: { ...process.env, HOME: project, USERPROFILE: project },
28
+ });
29
+ return out.trim() ? JSON.parse(out.trim()) : null;
30
+ }
31
+
32
+ function isDeny(result) {
33
+ return result?.hookSpecificOutput?.permissionDecision === 'deny';
34
+ }
35
+
36
+ function enabledConfig() {
37
+ return { gates: { requireImplementationPipeline: true } };
38
+ }
39
+
40
+ test('FIXED: a Task delegation carrying its brief in `description` is now checked', () => {
41
+ const payload = {
42
+ tool_name: 'Task',
43
+ tool_input: {
44
+ description: 'Nivel: STANDARD\nImplementá el checkout.',
45
+ subagent_type: 'backend',
46
+ },
47
+ };
48
+ assert.ok(
49
+ isDeny(runGate(payload, { config: enabledConfig() })),
50
+ 'delegationPromptOf reads description and the missing-pipeline-stages deny fires',
51
+ );
52
+ });
53
+
54
+ test('OK: the equivalent payload using `prompt` IS caught (control)', () => {
55
+ const payload = {
56
+ tool_name: 'Task',
57
+ tool_input: {
58
+ prompt: 'Nivel: STANDARD\nImplementá el checkout.',
59
+ subagent_type: 'backend',
60
+ },
61
+ };
62
+ assert.ok(
63
+ isDeny(runGate(payload, { config: enabledConfig() })),
64
+ 'control failed',
65
+ );
66
+ });