@devrik-tools/claude-gates 0.1.2 → 0.1.3

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 (104) hide show
  1. package/.claude-plugin/marketplace.json +23 -17
  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/config.mjs +124 -113
  8. package/cli/index.mjs +100 -97
  9. package/cli/init.mjs +259 -256
  10. package/cli/install.mjs +150 -80
  11. package/cli/materialize.mjs +102 -51
  12. package/cli/registry.mjs +136 -127
  13. package/cli/task.mjs +140 -0
  14. package/package.json +74 -73
  15. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +83 -0
  16. package/plugins/gates/hooks/__tests__/doctor.test.mjs +85 -0
  17. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +65 -0
  18. package/plugins/gates/hooks/ask-adoption.mjs +147 -0
  19. package/plugins/gates/hooks/doctor.mjs +207 -0
  20. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +83 -0
  21. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -88
  22. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +117 -92
  23. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +104 -0
  24. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +48 -45
  25. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +165 -0
  26. package/plugins/gates/hooks/gates/bash-commands/index.mjs +285 -268
  27. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +151 -0
  28. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +265 -177
  29. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +207 -0
  30. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +501 -456
  31. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +161 -143
  32. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +69 -0
  33. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +126 -118
  34. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +68 -0
  35. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +43 -49
  36. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +83 -0
  37. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -100
  38. package/plugins/gates/hooks/gates/force-parallel/index.mjs +119 -0
  39. package/plugins/gates/hooks/gates/force-parallel/test.mjs +88 -0
  40. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +155 -0
  41. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -112
  42. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +66 -0
  43. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -186
  44. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -238
  45. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +96 -0
  46. package/plugins/gates/hooks/gates/intent-flow/test.mjs +165 -136
  47. package/plugins/gates/hooks/gates/lint-commit/index.mjs +149 -0
  48. package/plugins/gates/hooks/gates/lint-commit/test.mjs +85 -0
  49. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +100 -0
  50. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -166
  51. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +79 -85
  52. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +80 -0
  53. package/plugins/gates/hooks/gates/never-assume/index.mjs +58 -55
  54. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +71 -0
  55. package/plugins/gates/hooks/gates/never-assume/test.mjs +95 -78
  56. package/plugins/gates/hooks/gates/no-blocking/index.mjs +148 -142
  57. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +109 -0
  58. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +123 -120
  59. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +83 -0
  60. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +91 -0
  61. package/plugins/gates/hooks/gates/protected-paths/index.mjs +144 -147
  62. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +126 -0
  63. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +89 -64
  64. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +102 -0
  65. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +94 -0
  66. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +159 -150
  67. package/plugins/gates/hooks/gates/risk-level/index.mjs +263 -203
  68. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +116 -0
  69. package/plugins/gates/hooks/gates/risk-level/test.mjs +153 -125
  70. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +56 -50
  71. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +58 -0
  72. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +116 -111
  73. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +97 -0
  74. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +184 -99
  75. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +126 -0
  76. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +130 -0
  77. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -251
  78. package/plugins/gates/hooks/gates/stop-pending/index.mjs +159 -0
  79. package/plugins/gates/hooks/gates/stop-pending/test.mjs +114 -0
  80. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -101
  81. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +72 -0
  82. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +63 -0
  83. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -181
  84. package/plugins/gates/hooks/gates/test-matrix/test.mjs +114 -87
  85. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +62 -0
  86. package/plugins/gates/hooks/gates/tool-map/index.mjs +143 -140
  87. package/plugins/gates/hooks/hooks.json +346 -286
  88. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +173 -154
  89. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +210 -154
  90. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +114 -0
  91. package/plugins/gates/hooks/lib/config.mjs +172 -165
  92. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +189 -0
  93. package/plugins/gates/hooks/lib/hook-io.mjs +318 -208
  94. package/plugins/gates/hooks/lib/signals.mjs +127 -0
  95. package/plugins/gates/hooks/wiring-check.mjs +227 -0
  96. package/plugins/tasks/.claude-plugin/plugin.json +9 -0
  97. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +100 -0
  98. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +95 -0
  99. package/plugins/tasks/hooks/hooks.json +26 -0
  100. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +180 -132
  101. package/plugins/tasks/hooks/lib/task-store.mjs +197 -159
  102. package/plugins/tasks/hooks/register-requests.mjs +141 -108
  103. package/plugins/tasks/hooks/session-tasks.mjs +108 -0
  104. package/registry.json +793 -686
@@ -1,125 +1,153 @@
1
- import assert from 'node:assert/strict';
2
- import { execFileSync } from 'node:child_process';
3
- import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
4
- import { tmpdir } from 'node:os';
5
- import { dirname, join } from 'node:path';
6
- import { test } from 'node:test';
7
- import { fileURLToPath } from 'node:url';
8
-
9
- const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
10
-
11
- function runGate(payload, { config } = {}) {
12
- const project = mkdtempSync(join(tmpdir(), 'risk-level-'));
13
- mkdirSync(join(project, '.git'));
14
- if (config) {
15
- mkdirSync(join(project, '.ai'));
16
- writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
17
- }
18
- const out = execFileSync(process.execPath, [GATE], {
19
- input: JSON.stringify(payload),
20
- encoding: 'utf8',
21
- cwd: project,
22
- // Isolate from the user's real global config: point homedir() at the temp
23
- // project so the global-config fallback finds nothing (registry default).
24
- env: { ...process.env, HOME: project, USERPROFILE: project },
25
- });
26
- return out.trim() ? JSON.parse(out.trim()) : null;
27
- }
28
-
29
- function delegate(prompt, extra = {}) {
30
- return { tool_name: 'Agent', tool_input: { prompt, ...extra } };
31
- }
32
- function isDeny(result) {
33
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
34
- }
35
-
36
- const ENABLED = { config: { gates: { requireDeclaredRiskLevel: true } } };
37
-
38
- test('denies an implementation delegation with no declared level', () => {
39
- assert.ok(
40
- isDeny(
41
- runGate(
42
- delegate('Arregla el boton que no cambia de color en la pagina.'),
43
- ENABLED,
44
- ),
45
- ),
46
- );
47
- });
48
-
49
- test('allows a declared level consistent with a low-risk request', () => {
50
- assert.equal(
51
- runGate(
52
- delegate(
53
- 'NIVEL: STANDARD\n\nArregla el boton que no cambia de color en la pagina.',
54
- ),
55
- ENABLED,
56
- ),
57
- null,
58
- );
59
- });
60
-
61
- test('denies a declared level that contradicts a real high-impact signal', () => {
62
- assert.ok(
63
- isDeny(
64
- runGate(
65
- delegate(
66
- 'NIVEL: MICRO\n\nImplementa el cobro del pago con la nueva pasarela de dinero.',
67
- ),
68
- ENABLED,
69
- ),
70
- ),
71
- );
72
- });
73
-
74
- test('allows HIGH-RISK declared for a high-impact request', () => {
75
- assert.equal(
76
- runGate(
77
- delegate(
78
- 'NIVEL: HIGH-RISK\n\nImplementa el cobro del pago con la nueva pasarela de dinero.',
79
- ),
80
- ENABLED,
81
- ),
82
- null,
83
- );
84
- });
85
-
86
- test('allows a read-only subagent without a declared level', () => {
87
- assert.equal(
88
- runGate(
89
- delegate(
90
- 'Implementa el cobro del pago con la nueva pasarela de dinero.',
91
- {
92
- subagent_type: 'explore',
93
- },
94
- ),
95
- ENABLED,
96
- ),
97
- null,
98
- );
99
- });
100
-
101
- test('disabled by config: the gate does not run', () => {
102
- assert.equal(
103
- runGate(delegate('Arregla el boton que no cambia de color en la pagina.'), {
104
- config: { gates: { requireDeclaredRiskLevel: false } },
105
- }),
106
- null,
107
- );
108
- });
109
-
110
- test('project highImpactPatterns override narrows what forces HIGH-RISK', () => {
111
- const config = {
112
- gates: {
113
- requireDeclaredRiskLevel: {
114
- enabled: true,
115
- highImpactPatterns: ['pagin[ao]ci[oó]n'],
116
- },
117
- },
118
- };
119
- const prompt =
120
- 'NIVEL: MICRO\n\nImplementa el cobro del pago con la nueva pasarela de dinero.';
121
- // With the default patterns (money/payment), MICRO contradicts the signal and is denied.
122
- assert.ok(isDeny(runGate(delegate(prompt), ENABLED)));
123
- // Once overridden, "dinero/pago" no longer counts as high-impact, so MICRO is allowed.
124
- assert.equal(runGate(delegate(prompt), { config }), null);
125
- });
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { test } from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
10
+
11
+ function runGate(payload, { config } = {}) {
12
+ const project = mkdtempSync(join(tmpdir(), 'risk-level-'));
13
+ mkdirSync(join(project, '.git'));
14
+ if (config) {
15
+ mkdirSync(join(project, '.ai'));
16
+ writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
17
+ }
18
+ const out = execFileSync(process.execPath, [GATE], {
19
+ input: JSON.stringify(payload),
20
+ encoding: 'utf8',
21
+ cwd: project,
22
+ // Isolate from the user's real global config: point homedir() at the temp
23
+ // project so the global-config fallback finds nothing (registry default).
24
+ env: { ...process.env, HOME: project, USERPROFILE: project },
25
+ });
26
+ return out.trim() ? JSON.parse(out.trim()) : null;
27
+ }
28
+
29
+ function delegate(prompt, extra = {}) {
30
+ return { tool_name: 'Agent', tool_input: { prompt, ...extra } };
31
+ }
32
+ function isDeny(result) {
33
+ return result?.hookSpecificOutput?.permissionDecision === 'deny';
34
+ }
35
+
36
+ const ENABLED = { config: { gates: { requireDeclaredRiskLevel: true } } };
37
+
38
+ test('denies an implementation delegation with no declared level', () => {
39
+ assert.ok(
40
+ isDeny(
41
+ runGate(
42
+ delegate('Arregla el boton que no cambia de color en la pagina.'),
43
+ ENABLED,
44
+ ),
45
+ ),
46
+ );
47
+ });
48
+
49
+ test('allows a declared level consistent with a low-risk request', () => {
50
+ assert.equal(
51
+ runGate(
52
+ delegate(
53
+ 'NIVEL: STANDARD\n\nArregla el boton que no cambia de color en la pagina.',
54
+ ),
55
+ ENABLED,
56
+ ),
57
+ null,
58
+ );
59
+ });
60
+
61
+ test('denies a declared level that contradicts a real high-impact signal', () => {
62
+ assert.ok(
63
+ isDeny(
64
+ runGate(
65
+ delegate(
66
+ 'NIVEL: MICRO\n\nImplementa el cobro del pago con la nueva pasarela de dinero.',
67
+ ),
68
+ ENABLED,
69
+ ),
70
+ ),
71
+ );
72
+ });
73
+
74
+ test('allows HIGH-RISK declared for a high-impact request', () => {
75
+ assert.equal(
76
+ runGate(
77
+ delegate(
78
+ 'NIVEL: HIGH-RISK\n\nImplementa el cobro del pago con la nueva pasarela de dinero.',
79
+ ),
80
+ ENABLED,
81
+ ),
82
+ null,
83
+ );
84
+ });
85
+
86
+ test('bilingual control: an EN money-mutation brief contradicts a low level exactly like its ES equivalent', () => {
87
+ const es = delegate(
88
+ 'NIVEL: MICRO\n\nImplementa el cobro del pago con la nueva pasarela de dinero.',
89
+ );
90
+ const en = delegate(
91
+ 'LEVEL: MICRO\n\nImplement the payment charge with the new money gateway.',
92
+ );
93
+ assert.ok(isDeny(runGate(es, ENABLED)));
94
+ assert.ok(isDeny(runGate(en, ENABLED)));
95
+ });
96
+
97
+ // A whitelisted read-only subagent name is now void whenever the prompt itself carries
98
+ // a mutation-risk signal (money/auth/data/write/deploy): the label is self-declared,
99
+ // never a verified capability, and a real risk signal in the text must win over it
100
+ // (bug fixed in this gate; see risk-level.edge.test.mjs).
101
+ test('allows a read-only subagent without a declared level when the prompt carries no mutation-risk signal', () => {
102
+ assert.equal(
103
+ runGate(
104
+ delegate('Explica como funciona el flujo de checkout actual.', {
105
+ subagent_type: 'explore',
106
+ }),
107
+ ENABLED,
108
+ ),
109
+ null,
110
+ );
111
+ });
112
+
113
+ test('a read-only subagent name no longer exempts a real money-mutation prompt from declaring a level', () => {
114
+ assert.ok(
115
+ isDeny(
116
+ runGate(
117
+ delegate(
118
+ 'Implementa el cobro del pago con la nueva pasarela de dinero.',
119
+ {
120
+ subagent_type: 'explore',
121
+ },
122
+ ),
123
+ ENABLED,
124
+ ),
125
+ ),
126
+ );
127
+ });
128
+
129
+ test('disabled by config: the gate does not run', () => {
130
+ assert.equal(
131
+ runGate(delegate('Arregla el boton que no cambia de color en la pagina.'), {
132
+ config: { gates: { requireDeclaredRiskLevel: false } },
133
+ }),
134
+ null,
135
+ );
136
+ });
137
+
138
+ test('project highImpactPatterns override narrows what forces HIGH-RISK', () => {
139
+ const config = {
140
+ gates: {
141
+ requireDeclaredRiskLevel: {
142
+ enabled: true,
143
+ highImpactPatterns: ['pagin[ao]ci[oó]n'],
144
+ },
145
+ },
146
+ };
147
+ const prompt =
148
+ 'NIVEL: MICRO\n\nImplementa el cobro del pago con la nueva pasarela de dinero.';
149
+ // With the default patterns (money/payment), MICRO contradicts the signal and is denied.
150
+ assert.ok(isDeny(runGate(delegate(prompt), ENABLED)));
151
+ // Once overridden, "dinero/pago" no longer counts as high-impact, so MICRO is allowed.
152
+ assert.equal(runGate(delegate(prompt), { config }), null);
153
+ });
@@ -1,50 +1,56 @@
1
- import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
2
-
3
- const GATE_ID = 'root-cause-first';
4
- const CONFIG_KEY = 'requireRootCauseBeforePatch';
5
-
6
- const DEFAULT_PATCH_MARKER_PATTERNS = ['//\\s*todo:?\\s*fix\\s+later\\s+patch'];
7
-
8
- function extractContent(toolName, toolInput) {
9
- if (TOOL_GROUPS.delegation.includes(toolName)) {
10
- return toolInput?.prompt;
11
- }
12
- return toolInput?.content ?? toolInput?.new_string;
13
- }
14
-
15
- runGate(
16
- {
17
- id: GATE_ID,
18
- configKey: CONFIG_KEY,
19
- enabledByDefault: false,
20
- defaultParams: {
21
- patchMarkerPatterns: DEFAULT_PATCH_MARKER_PATTERNS,
22
- },
23
- },
24
- ({ toolName, toolInput, parameters }) => {
25
- const isWrite = TOOL_GROUPS.write.includes(toolName);
26
- const isDelegation = TOOL_GROUPS.delegation.includes(toolName);
27
- if (!isWrite && !isDelegation) return;
28
-
29
- const content = extractContent(toolName, toolInput);
30
- if (typeof content !== 'string') return;
31
-
32
- const patterns = parameters.patchMarkerPatterns.map(
33
- (source) => new RegExp(source, 'i'),
34
- );
35
- const matched = patterns.find((pattern) => pattern.test(content));
36
- if (!matched) return;
37
-
38
- deny(
39
- GATE_ID,
40
- `Content matches a patch-without-diagnosis marker (${matched.source}). Identify and fix the root cause before patching; do not defer with a "fix later" marker.`,
41
- );
42
- },
43
- );
44
-
45
- // Simplified vs. the source guard (guard-root-cause-first.mjs): the original
46
- // used an auxiliary lib/embedded-content-detection.mjs module to tell real
47
- // file content apart from a quoted example inside markdown. That module does
48
- // not exist in this repo, so this gate matches directly against the new
49
- // content/prompt. Distinguishing markdown quotes from real code is future
50
- // work if false positives on quoted examples become a problem.
1
+ import {
2
+ runGate,
3
+ deny,
4
+ toolInGroups,
5
+ writtenContentOf,
6
+ delegationPromptOf,
7
+ } from '../../lib/hook-io.mjs';
8
+
9
+ const GATE_ID = 'root-cause-first';
10
+ const CONFIG_KEY = 'requireRootCauseBeforePatch';
11
+
12
+ const DEFAULT_PATCH_MARKER_PATTERNS = ['//\\s*todo:?\\s*fix\\s+later\\s+patch'];
13
+
14
+ // The text to scan: a delegation's brief, or the content a write puts on disk. writtenContentOf
15
+ // covers every native and MCP write shape (Write's content, Edit's new_string, NotebookEdit's
16
+ // new_source, replace_file_content's new_content) — the old reader missed new_source, so a
17
+ // deferral marker written via NotebookEdit was never caught by this DENY gate.
18
+ function textToScan(toolName, toolInput) {
19
+ if (toolInGroups(toolName, ['delegation'])) return delegationPromptOf(toolInput);
20
+ return writtenContentOf(toolInput);
21
+ }
22
+
23
+ runGate(
24
+ {
25
+ id: GATE_ID,
26
+ configKey: CONFIG_KEY,
27
+ enabledByDefault: false,
28
+ defaultParams: {
29
+ patchMarkerPatterns: DEFAULT_PATCH_MARKER_PATTERNS,
30
+ },
31
+ },
32
+ ({ toolName, toolInput, parameters }) => {
33
+ if (!toolInGroups(toolName, ['write', 'delegation'])) return;
34
+
35
+ const content = textToScan(toolName, toolInput);
36
+ if (!content) return;
37
+
38
+ const patterns = parameters.patchMarkerPatterns.map(
39
+ (source) => new RegExp(source, 'i'),
40
+ );
41
+ const matched = patterns.find((pattern) => pattern.test(content));
42
+ if (!matched) return;
43
+
44
+ deny(
45
+ GATE_ID,
46
+ `Content matches a patch-without-diagnosis marker (${matched.source}). Identify and fix the root cause before patching; do not defer with a "fix later" marker.`,
47
+ );
48
+ },
49
+ );
50
+
51
+ // Simplified vs. the source guard (guard-root-cause-first.mjs): the original
52
+ // used an auxiliary lib/embedded-content-detection.mjs module to tell real
53
+ // file content apart from a quoted example inside markdown. That module does
54
+ // not exist in this repo, so this gate matches directly against the new
55
+ // content/prompt. Distinguishing markdown quotes from real code is future
56
+ // work if false positives on quoted examples become a problem.
@@ -0,0 +1,58 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { test } from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
10
+
11
+ function runGate(payload, { config } = {}) {
12
+ const project = mkdtempSync(join(tmpdir(), 'root-cause-first-edge-'));
13
+ mkdirSync(join(project, '.git'));
14
+ if (config) {
15
+ mkdirSync(join(project, '.ai'));
16
+ writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
17
+ }
18
+ const out = execFileSync(process.execPath, [GATE], {
19
+ input: JSON.stringify(payload),
20
+ encoding: 'utf8',
21
+ cwd: project,
22
+ env: { ...process.env, HOME: project, USERPROFILE: project },
23
+ });
24
+ return out.trim() ? JSON.parse(out.trim()) : null;
25
+ }
26
+
27
+ function isDeny(result) {
28
+ return result?.hookSpecificOutput?.permissionDecision === 'deny';
29
+ }
30
+
31
+ const ENABLE = { config: { gates: { requireRootCauseBeforePatch: true } } };
32
+
33
+ // FIXED: writtenContentOf reads NotebookEdit's new_source, so a deferral marker written via
34
+ // NotebookEdit is now caught by this DENY gate.
35
+ test('FIXED: patch-marker comment via NotebookEdit is now denied', () => {
36
+ const payload = {
37
+ tool_name: 'NotebookEdit',
38
+ tool_input: {
39
+ notebook_path: '/repo/nb.ipynb',
40
+ cell_id: 'x',
41
+ new_source: '// TODO fix later patch',
42
+ cell_type: 'code',
43
+ },
44
+ };
45
+ assert.ok(isDeny(runGate(payload, ENABLE)), 'a patch marker via NotebookEdit must now be denied');
46
+ });
47
+
48
+ // FIXED: writtenContentOf reads replace_file_content's new_content field too.
49
+ test('FIXED: patch-marker via replace_file_content (new_content field) is now denied', () => {
50
+ const payload = {
51
+ tool_name: 'replace_file_content',
52
+ tool_input: {
53
+ file_path: '/repo/x.js',
54
+ new_content: '// TODO fix later patch',
55
+ },
56
+ };
57
+ assert.ok(isDeny(runGate(payload, ENABLE)), 'the field-name variant must now be denied');
58
+ });