@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
@@ -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
+ });