@devrik-tools/claude-gates 0.2.0 → 0.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/.claude-plugin/marketplace.json +2 -2
- package/README.es.md +7 -6
- package/README.md +16 -7
- package/cli/index.mjs +4 -0
- package/cli/init.mjs +18 -1
- package/cli/install.mjs +26 -1
- package/package.json +5 -2
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -6
- package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +78 -0
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +11 -81
- package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +140 -0
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +130 -126
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +104 -79
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +138 -123
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +59 -44
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +124 -101
- package/plugins/gates/hooks/hooks.json +365 -346
- package/plugins/gates/hooks/lib/hook-io.mjs +39 -0
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
- package/plugins/tasks/hooks/register-requests.mjs +7 -3
- package/registry.json +738 -793
- package/cli/__tests__/config.test.mjs +0 -113
- package/cli/__tests__/init-flags.test.mjs +0 -111
- package/cli/__tests__/install.test.mjs +0 -102
- package/cli/__tests__/materialize.test.mjs +0 -95
- package/cli/__tests__/registry-gates-consistency.test.mjs +0 -52
- package/cli/__tests__/selection.test.mjs +0 -200
- package/cli/__tests__/task.test.mjs +0 -124
- package/cli/__tests__/version-consistency.test.mjs +0 -44
- package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +0 -83
- package/plugins/gates/hooks/__tests__/doctor.test.mjs +0 -85
- package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +0 -65
- package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +0 -83
- package/plugins/gates/hooks/gates/audit-before-build/test.mjs +0 -117
- package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +0 -104
- package/plugins/gates/hooks/gates/autonomous-mode/test.mjs +0 -58
- package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +0 -165
- package/plugins/gates/hooks/gates/bash-commands/test.mjs +0 -126
- package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +0 -151
- package/plugins/gates/hooks/gates/brief-before-delegate/test.mjs +0 -111
- package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +0 -207
- package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +0 -161
- package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +0 -69
- package/plugins/gates/hooks/gates/dependency-skills/test.mjs +0 -109
- package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +0 -68
- package/plugins/gates/hooks/gates/diagnosis-before-patch/test.mjs +0 -68
- package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +0 -83
- package/plugins/gates/hooks/gates/feature-catalog/test.mjs +0 -97
- package/plugins/gates/hooks/gates/force-parallel/test.mjs +0 -88
- package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +0 -155
- package/plugins/gates/hooks/gates/forge-flow/test.mjs +0 -135
- package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +0 -66
- package/plugins/gates/hooks/gates/implementation-pipeline/test.mjs +0 -86
- package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +0 -96
- package/plugins/gates/hooks/gates/intent-flow/test.mjs +0 -165
- package/plugins/gates/hooks/gates/lint-commit/test.mjs +0 -85
- package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +0 -100
- package/plugins/gates/hooks/gates/mandatory-flow/test.mjs +0 -119
- package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +0 -80
- package/plugins/gates/hooks/gates/neutral-spanish/test.mjs +0 -65
- package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +0 -71
- package/plugins/gates/hooks/gates/never-assume/test.mjs +0 -95
- package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +0 -109
- package/plugins/gates/hooks/gates/no-blocking/test.mjs +0 -108
- package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +0 -83
- package/plugins/gates/hooks/gates/no-memory-dependency/test.mjs +0 -106
- package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +0 -91
- package/plugins/gates/hooks/gates/no-reconfirm/test.mjs +0 -131
- package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +0 -126
- package/plugins/gates/hooks/gates/protected-paths/test.mjs +0 -75
- package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +0 -102
- package/plugins/gates/hooks/gates/recurrence-lock/test.mjs +0 -99
- package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +0 -94
- package/plugins/gates/hooks/gates/reuse-before-build/test.mjs +0 -101
- package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +0 -116
- package/plugins/gates/hooks/gates/risk-level/test.mjs +0 -153
- package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +0 -58
- package/plugins/gates/hooks/gates/root-cause-first/test.mjs +0 -73
- package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +0 -97
- package/plugins/gates/hooks/gates/root-whitelist/test.mjs +0 -80
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +0 -126
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/test.mjs +0 -78
- package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +0 -130
- package/plugins/gates/hooks/gates/sdd-specs/test.mjs +0 -163
- package/plugins/gates/hooks/gates/stop-pending/test.mjs +0 -114
- package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +0 -72
- package/plugins/gates/hooks/gates/test-after-implementation/test.mjs +0 -81
- package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +0 -63
- package/plugins/gates/hooks/gates/test-matrix/test.mjs +0 -114
- package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +0 -62
- package/plugins/gates/hooks/gates/tool-map/test.mjs +0 -87
- package/plugins/gates/hooks/lib/__tests__/config.test.mjs +0 -173
- package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +0 -210
- package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +0 -114
- package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +0 -189
- package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +0 -100
- package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +0 -95
- package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +0 -180
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
// The `task` CLI subcommands are thin wiring over task-store.mjs; these tests exercise the
|
|
2
|
-
// CLI surface itself (spawning `cli/index.mjs task ...` as a real child process, the way a
|
|
3
|
-
// user or the register-requests hook's advice actually invokes it) against a temp project,
|
|
4
|
-
// never against this repo's own .ai/config.json or task store.
|
|
5
|
-
|
|
6
|
-
import assert from 'node:assert/strict';
|
|
7
|
-
import { execFileSync } from 'node:child_process';
|
|
8
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync } 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 CLI_ENTRY = join(
|
|
15
|
-
dirname(fileURLToPath(import.meta.url)),
|
|
16
|
-
'..',
|
|
17
|
-
'index.mjs',
|
|
18
|
-
);
|
|
19
|
-
|
|
20
|
-
function makeProject() {
|
|
21
|
-
const project = mkdtempSync(join(tmpdir(), 'task-cli-'));
|
|
22
|
-
mkdirSync(join(project, '.git'));
|
|
23
|
-
return project;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function runCli(args, cwd) {
|
|
27
|
-
try {
|
|
28
|
-
const stdout = execFileSync(process.execPath, [CLI_ENTRY, ...args], {
|
|
29
|
-
cwd,
|
|
30
|
-
encoding: 'utf8',
|
|
31
|
-
});
|
|
32
|
-
return { code: 0, stdout };
|
|
33
|
-
} catch (error) {
|
|
34
|
-
return {
|
|
35
|
-
code: error.status ?? 1,
|
|
36
|
-
stdout: error.stdout ?? '',
|
|
37
|
-
stderr: error.stderr ?? String(error),
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
test('task add persists a new open task under .ai/tasks/active.json', () => {
|
|
43
|
-
const project = makeProject();
|
|
44
|
-
const result = runCli(['task', 'add', 'Fix the thing', '--description', 'details'], project);
|
|
45
|
-
assert.equal(result.code, 0, result.stderr);
|
|
46
|
-
|
|
47
|
-
const activePath = join(project, '.ai', 'tasks', 'active.json');
|
|
48
|
-
assert.ok(existsSync(activePath));
|
|
49
|
-
const active = JSON.parse(readFileSync(activePath, 'utf8'));
|
|
50
|
-
assert.equal(active.tasks.length, 1);
|
|
51
|
-
assert.equal(active.tasks[0].title, 'Fix the thing');
|
|
52
|
-
assert.equal(active.tasks[0].status, 'open');
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test('task list shows the active task, and --all also reaches history', () => {
|
|
56
|
-
const project = makeProject();
|
|
57
|
-
runCli(['task', 'add', 'Task one', '--id', 't1'], project);
|
|
58
|
-
runCli(['task', 'add', 'Task two', '--id', 't2'], project);
|
|
59
|
-
runCli(['task', 'abandon', 't2', '--reason', 'not needed'], project);
|
|
60
|
-
|
|
61
|
-
const active = runCli(['task', 'list'], project);
|
|
62
|
-
assert.match(active.stdout, /Task one/);
|
|
63
|
-
assert.doesNotMatch(active.stdout, /Task two/);
|
|
64
|
-
|
|
65
|
-
const all = runCli(['task', 'list', '--all'], project);
|
|
66
|
-
assert.match(all.stdout, /Task one/);
|
|
67
|
-
assert.match(all.stdout, /Task two/);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test('task close WITHOUT --evidence fails and leaves the task active', () => {
|
|
71
|
-
const project = makeProject();
|
|
72
|
-
runCli(['task', 'add', 'Needs evidence', '--id', 't1'], project);
|
|
73
|
-
|
|
74
|
-
const result = runCli(['task', 'close', 't1'], project);
|
|
75
|
-
assert.notEqual(result.code, 0);
|
|
76
|
-
assert.match(result.stderr, /evidence/i);
|
|
77
|
-
|
|
78
|
-
const activePath = join(project, '.ai', 'tasks', 'active.json');
|
|
79
|
-
const active = JSON.parse(readFileSync(activePath, 'utf8'));
|
|
80
|
-
assert.equal(active.tasks.length, 1, 'the task must still be active');
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
test('task close WITH --evidence succeeds and moves the task to history', () => {
|
|
84
|
-
const project = makeProject();
|
|
85
|
-
runCli(['task', 'add', 'Has evidence', '--id', 't1'], project);
|
|
86
|
-
|
|
87
|
-
const result = runCli(
|
|
88
|
-
['task', 'close', 't1', '--evidence', 'npm test -> 5 passing'],
|
|
89
|
-
project,
|
|
90
|
-
);
|
|
91
|
-
assert.equal(result.code, 0, result.stderr);
|
|
92
|
-
|
|
93
|
-
const historyPath = join(project, '.ai', 'tasks', 'history.json');
|
|
94
|
-
const history = JSON.parse(readFileSync(historyPath, 'utf8'));
|
|
95
|
-
assert.equal(history.tasks.length, 1);
|
|
96
|
-
assert.equal(history.tasks[0].status, 'done');
|
|
97
|
-
assert.equal(history.tasks[0].evidence, 'npm test -> 5 passing');
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test('task abandon needs no evidence and moves the task to history', () => {
|
|
101
|
-
const project = makeProject();
|
|
102
|
-
runCli(['task', 'add', 'Drop me', '--id', 't1'], project);
|
|
103
|
-
|
|
104
|
-
const result = runCli(['task', 'abandon', 't1', '--reason', 'obsolete'], project);
|
|
105
|
-
assert.equal(result.code, 0, result.stderr);
|
|
106
|
-
|
|
107
|
-
const historyPath = join(project, '.ai', 'tasks', 'history.json');
|
|
108
|
-
const history = JSON.parse(readFileSync(historyPath, 'utf8'));
|
|
109
|
-
assert.equal(history.tasks[0].status, 'abandoned');
|
|
110
|
-
assert.equal(history.tasks[0].closeReason, 'obsolete');
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
test('task promote links a forge run and keeps the task active as in_forge', () => {
|
|
114
|
-
const project = makeProject();
|
|
115
|
-
runCli(['task', 'add', 'Promote me', '--id', 't1'], project);
|
|
116
|
-
|
|
117
|
-
const result = runCli(['task', 'promote', 't1', 'run-42'], project);
|
|
118
|
-
assert.equal(result.code, 0, result.stderr);
|
|
119
|
-
|
|
120
|
-
const activePath = join(project, '.ai', 'tasks', 'active.json');
|
|
121
|
-
const active = JSON.parse(readFileSync(activePath, 'utf8'));
|
|
122
|
-
assert.equal(active.tasks[0].status, 'in_forge');
|
|
123
|
-
assert.equal(active.tasks[0].forgeRunId, 'run-42');
|
|
124
|
-
});
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
// Version coherence guard. This exists because of a real incident: the npm package was
|
|
2
|
-
// bumped to 0.1.3 and published, but plugin.json / marketplace.json stayed at 0.1.0. Claude
|
|
3
|
-
// Code versions a plugin by its plugin.json/marketplace version, so a machine that already
|
|
4
|
-
// had 0.1.0 cached NEVER updated the code on reinstall — it ran the old, pre-migration gates
|
|
5
|
-
// while the freshly written config.json said 0.1.3. The gates looked "enabled but broken".
|
|
6
|
-
//
|
|
7
|
-
// The rule this enforces: the npm package version, both plugin.json versions, and every
|
|
8
|
-
// marketplace.json plugin entry must be the SAME string. Bump them together, every release.
|
|
9
|
-
|
|
10
|
-
import assert from 'node:assert/strict';
|
|
11
|
-
import { readFileSync } from 'node:fs';
|
|
12
|
-
import { dirname, join } from 'node:path';
|
|
13
|
-
import { test } from 'node:test';
|
|
14
|
-
import { fileURLToPath } from 'node:url';
|
|
15
|
-
|
|
16
|
-
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
17
|
-
|
|
18
|
-
function readJson(relativePath) {
|
|
19
|
-
return JSON.parse(readFileSync(join(ROOT, relativePath), 'utf8').replace(/^/, ''));
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
test('npm, both plugin.json, and every marketplace entry share one version', () => {
|
|
23
|
-
const versions = {
|
|
24
|
-
'package.json': readJson('package.json').version,
|
|
25
|
-
'plugins/gates/.claude-plugin/plugin.json': readJson(
|
|
26
|
-
'plugins/gates/.claude-plugin/plugin.json',
|
|
27
|
-
).version,
|
|
28
|
-
'plugins/tasks/.claude-plugin/plugin.json': readJson(
|
|
29
|
-
'plugins/tasks/.claude-plugin/plugin.json',
|
|
30
|
-
).version,
|
|
31
|
-
};
|
|
32
|
-
for (const plugin of readJson('.claude-plugin/marketplace.json').plugins) {
|
|
33
|
-
versions[`marketplace.json:${plugin.name}`] = plugin.version;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const distinct = [...new Set(Object.values(versions))];
|
|
37
|
-
assert.equal(
|
|
38
|
-
distinct.length,
|
|
39
|
-
1,
|
|
40
|
-
`all versions must match; found ${JSON.stringify(versions)}. ` +
|
|
41
|
-
'A plugin.json/marketplace version behind the npm version means Claude Code keeps ' +
|
|
42
|
-
'serving the OLD cached plugin code on reinstall. Bump them together.',
|
|
43
|
-
);
|
|
44
|
-
});
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
// ask-adoption — SessionStart hook. Spawned as a real child process with a JSON payload
|
|
2
|
-
// on stdin, against a temp project (never this repo's own .ai/ state).
|
|
3
|
-
|
|
4
|
-
import assert from 'node:assert/strict';
|
|
5
|
-
import { execFileSync } from 'node:child_process';
|
|
6
|
-
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
7
|
-
import { tmpdir } from 'node:os';
|
|
8
|
-
import { dirname, join } from 'node:path';
|
|
9
|
-
import { test } from 'node:test';
|
|
10
|
-
import { fileURLToPath } from 'node:url';
|
|
11
|
-
|
|
12
|
-
const HOOK_PATH = join(
|
|
13
|
-
dirname(fileURLToPath(import.meta.url)),
|
|
14
|
-
'..',
|
|
15
|
-
'ask-adoption.mjs',
|
|
16
|
-
);
|
|
17
|
-
|
|
18
|
-
function makeProject() {
|
|
19
|
-
const project = mkdtempSync(join(tmpdir(), 'ask-adoption-'));
|
|
20
|
-
mkdirSync(join(project, '.git'));
|
|
21
|
-
return project;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function runHook(cwd) {
|
|
25
|
-
return execFileSync(process.execPath, [HOOK_PATH], {
|
|
26
|
-
input: JSON.stringify({ cwd }),
|
|
27
|
-
encoding: 'utf8',
|
|
28
|
-
env: { ...process.env, HOME: cwd, USERPROFILE: cwd },
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
test('a project that never answered gets asked, and the hook records that it asked', () => {
|
|
33
|
-
const project = makeProject();
|
|
34
|
-
const stdout = runHook(project);
|
|
35
|
-
assert.notEqual(stdout, '');
|
|
36
|
-
const payload = JSON.parse(stdout);
|
|
37
|
-
assert.equal(payload.hookSpecificOutput.hookEventName, 'SessionStart');
|
|
38
|
-
assert.match(payload.hookSpecificOutput.additionalContext, /adopt/i);
|
|
39
|
-
assert.ok(existsSync(join(project, '.ai', '.adoption-asked')));
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
test('a second session in the same never-answered project is not asked again', () => {
|
|
43
|
-
const project = makeProject();
|
|
44
|
-
runHook(project); // first session: asks, writes the marker
|
|
45
|
-
const stdout = runHook(project); // second session
|
|
46
|
-
assert.equal(stdout, '');
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test('a project with an existing .ai/config.json is never asked', () => {
|
|
50
|
-
const project = makeProject();
|
|
51
|
-
mkdirSync(join(project, '.ai'), { recursive: true });
|
|
52
|
-
writeFileSync(
|
|
53
|
-
join(project, '.ai', 'config.json'),
|
|
54
|
-
JSON.stringify({ gates: {} }),
|
|
55
|
-
);
|
|
56
|
-
const stdout = runHook(project);
|
|
57
|
-
assert.equal(stdout, '');
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test('askAdoptionInNewProject:false silences the hook even in a never-answered project', () => {
|
|
61
|
-
const project = makeProject();
|
|
62
|
-
mkdirSync(join(project, '.ai'), { recursive: true });
|
|
63
|
-
// A config exists only to carry the disable flag; the adoption question itself would
|
|
64
|
-
// otherwise still be considered "answered" by this same file's presence — so assert the
|
|
65
|
-
// disable path directly against a config that ALSO sets the flag off, confirming this is
|
|
66
|
-
// the flag doing the work and not just "config.json exists" from the previous test.
|
|
67
|
-
writeFileSync(
|
|
68
|
-
join(project, '.ai', 'config.json'),
|
|
69
|
-
JSON.stringify({ gates: { askAdoptionInNewProject: false } }),
|
|
70
|
-
);
|
|
71
|
-
const stdout = runHook(project);
|
|
72
|
-
assert.equal(stdout, '');
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test('never throws even when stdin is not valid JSON', () => {
|
|
76
|
-
const project = makeProject();
|
|
77
|
-
const stdout = execFileSync(process.execPath, [HOOK_PATH], {
|
|
78
|
-
input: 'not json',
|
|
79
|
-
encoding: 'utf8',
|
|
80
|
-
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
81
|
-
});
|
|
82
|
-
assert.equal(typeof stdout, 'string');
|
|
83
|
-
});
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
// doctor — SessionStart hook. Spawned as a real child process with a JSON payload on
|
|
2
|
-
// stdin, HOME isolated so the real global config is never read.
|
|
3
|
-
|
|
4
|
-
import assert from 'node:assert/strict';
|
|
5
|
-
import { execFileSync } from 'node:child_process';
|
|
6
|
-
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
7
|
-
import { tmpdir } from 'node:os';
|
|
8
|
-
import { dirname, join } from 'node:path';
|
|
9
|
-
import { test } from 'node:test';
|
|
10
|
-
import { fileURLToPath } from 'node:url';
|
|
11
|
-
|
|
12
|
-
const HOOK_PATH = join(
|
|
13
|
-
dirname(fileURLToPath(import.meta.url)),
|
|
14
|
-
'..',
|
|
15
|
-
'doctor.mjs',
|
|
16
|
-
);
|
|
17
|
-
|
|
18
|
-
function makeProject() {
|
|
19
|
-
const project = mkdtempSync(join(tmpdir(), 'doctor-'));
|
|
20
|
-
mkdirSync(join(project, '.git'));
|
|
21
|
-
return project;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function runHook(cwd, { env: environment = {} } = {}) {
|
|
25
|
-
return execFileSync(process.execPath, [HOOK_PATH], {
|
|
26
|
-
input: JSON.stringify({ cwd }),
|
|
27
|
-
encoding: 'utf8',
|
|
28
|
-
env: { ...process.env, HOME: cwd, USERPROFILE: cwd, ...environment },
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
test('stays silent when node satisfies the current engines.node and scripts are on disk', () => {
|
|
33
|
-
const project = makeProject();
|
|
34
|
-
const stdout = runHook(project);
|
|
35
|
-
assert.equal(stdout, '');
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
test('speaks when a minNodeVersion above the running node is configured', () => {
|
|
39
|
-
const project = makeProject();
|
|
40
|
-
mkdirSync(join(project, '.ai'), { recursive: true });
|
|
41
|
-
writeFileSync(
|
|
42
|
-
join(project, '.ai', 'config.json'),
|
|
43
|
-
JSON.stringify({
|
|
44
|
-
gates: {
|
|
45
|
-
validateEnvironmentOnStart: {
|
|
46
|
-
enabled: true,
|
|
47
|
-
minNodeVersion: '999.0.0',
|
|
48
|
-
},
|
|
49
|
-
},
|
|
50
|
-
}),
|
|
51
|
-
);
|
|
52
|
-
const stdout = runHook(project);
|
|
53
|
-
assert.notEqual(stdout, '');
|
|
54
|
-
const payload = JSON.parse(stdout);
|
|
55
|
-
assert.equal(payload.hookSpecificOutput.hookEventName, 'SessionStart');
|
|
56
|
-
assert.match(payload.hookSpecificOutput.additionalContext, /999\.0\.0/);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test('validateEnvironmentOnStart:false in project config silences the hook even with a bad minNodeVersion', () => {
|
|
60
|
-
const project = makeProject();
|
|
61
|
-
mkdirSync(join(project, '.ai'), { recursive: true });
|
|
62
|
-
writeFileSync(
|
|
63
|
-
join(project, '.ai', 'config.json'),
|
|
64
|
-
JSON.stringify({
|
|
65
|
-
gates: {
|
|
66
|
-
validateEnvironmentOnStart: {
|
|
67
|
-
enabled: false,
|
|
68
|
-
minNodeVersion: '999.0.0',
|
|
69
|
-
},
|
|
70
|
-
},
|
|
71
|
-
}),
|
|
72
|
-
);
|
|
73
|
-
const stdout = runHook(project);
|
|
74
|
-
assert.equal(stdout, '');
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test('never throws even when stdin is not valid JSON', () => {
|
|
78
|
-
const project = makeProject();
|
|
79
|
-
const stdout = execFileSync(process.execPath, [HOOK_PATH], {
|
|
80
|
-
input: 'not json',
|
|
81
|
-
encoding: 'utf8',
|
|
82
|
-
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
83
|
-
});
|
|
84
|
-
assert.equal(typeof stdout, 'string');
|
|
85
|
-
});
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
// wiring-check — SessionStart hook. Runs against the REAL repo registry/hooks.json/disk
|
|
2
|
-
// (it has no per-project state of its own — it inspects the install, not the project),
|
|
3
|
-
// with HOME isolated only so config lookups do not read a real global config.
|
|
4
|
-
|
|
5
|
-
import assert from 'node:assert/strict';
|
|
6
|
-
import { execFileSync } from 'node:child_process';
|
|
7
|
-
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
8
|
-
import { tmpdir } from 'node:os';
|
|
9
|
-
import { dirname, join } from 'node:path';
|
|
10
|
-
import { test } from 'node:test';
|
|
11
|
-
import { fileURLToPath } from 'node:url';
|
|
12
|
-
|
|
13
|
-
const HOOK_PATH = join(
|
|
14
|
-
dirname(fileURLToPath(import.meta.url)),
|
|
15
|
-
'..',
|
|
16
|
-
'wiring-check.mjs',
|
|
17
|
-
);
|
|
18
|
-
const REPO_ROOT = join(
|
|
19
|
-
dirname(fileURLToPath(import.meta.url)),
|
|
20
|
-
'..',
|
|
21
|
-
'..',
|
|
22
|
-
'..',
|
|
23
|
-
'..',
|
|
24
|
-
);
|
|
25
|
-
|
|
26
|
-
function makeHomeOnly() {
|
|
27
|
-
// wiring-check reads the registry/hooks.json relative to ITS OWN location on disk, so the
|
|
28
|
-
// cwd it's given only matters for config lookup; it does not need to be a project root.
|
|
29
|
-
return mkdtempSync(join(tmpdir(), 'wiring-check-home-'));
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function runHook(cwd, home) {
|
|
33
|
-
return execFileSync(process.execPath, [HOOK_PATH], {
|
|
34
|
-
input: JSON.stringify({ cwd }),
|
|
35
|
-
encoding: 'utf8',
|
|
36
|
-
env: { ...process.env, HOME: home, USERPROFILE: home },
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
test('the real repo (registry, hooks.json, disk all in sync) produces no warning', () => {
|
|
41
|
-
const home = makeHomeOnly();
|
|
42
|
-
const stdout = runHook(REPO_ROOT, home);
|
|
43
|
-
assert.equal(stdout, '', `expected silence, got: ${stdout}`);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
test('checkWiringOnStart:false silences the hook globally', () => {
|
|
47
|
-
const home = makeHomeOnly();
|
|
48
|
-
mkdirSync(join(home, '.claude', 'claude-gates'), { recursive: true });
|
|
49
|
-
writeFileSync(
|
|
50
|
-
join(home, '.claude', 'claude-gates', 'config.json'),
|
|
51
|
-
JSON.stringify({ gates: { checkWiringOnStart: false } }),
|
|
52
|
-
);
|
|
53
|
-
const stdout = runHook(REPO_ROOT, home);
|
|
54
|
-
assert.equal(stdout, '');
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test('never throws even when stdin is not valid JSON', () => {
|
|
58
|
-
const home = makeHomeOnly();
|
|
59
|
-
const stdout = execFileSync(process.execPath, [HOOK_PATH], {
|
|
60
|
-
input: 'not json',
|
|
61
|
-
encoding: 'utf8',
|
|
62
|
-
env: { ...process.env, HOME: home, USERPROFILE: home },
|
|
63
|
-
});
|
|
64
|
-
assert.equal(typeof stdout, 'string');
|
|
65
|
-
});
|
|
@@ -1,83 +0,0 @@
|
|
|
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(), 'audit-before-build-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: out.trim() ? JSON.parse(out.trim()) : null, project };
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function delegate(prompt) {
|
|
28
|
-
return { tool_name: 'Agent', tool_input: { prompt } };
|
|
29
|
-
}
|
|
30
|
-
function write(filePath, content) {
|
|
31
|
-
return { tool_name: 'Write', tool_input: { file_path: filePath, content } };
|
|
32
|
-
}
|
|
33
|
-
function isDeny(result) {
|
|
34
|
-
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const ENABLE = { config: { gates: { requireAuditBeforeBuilding: true } } };
|
|
38
|
-
|
|
39
|
-
// EDGE CASE (BUG): AUDIT_EVIDENCE_PATTERN is a bare substring/regex test over the WHOLE
|
|
40
|
-
// delegation prompt with no proximity/ordering requirement relative to the tool-creation
|
|
41
|
-
// intent. An unrelated, earlier sentence that happens to say "no existing tool" anywhere
|
|
42
|
-
// in a long prompt satisfies the audit-evidence check even when it has nothing to do with
|
|
43
|
-
// the actual tool being created, so a prompt can smuggle past the gate by mentioning
|
|
44
|
-
// boilerplate audit language irrelevantly.
|
|
45
|
-
test('BUG: false negative — audit language elsewhere in the prompt, unrelated to the new tool, satisfies the check', () => {
|
|
46
|
-
const prompt =
|
|
47
|
-
'Context: earlier we confirmed no existing tool handles our deploy pipeline. ' +
|
|
48
|
-
'Now, unrelated task: create a new script that scrapes user passwords from logs.';
|
|
49
|
-
const { out } = runGate(delegate(prompt), ENABLE);
|
|
50
|
-
assert.equal(out, null, 'gate allows because AUDIT_EVIDENCE_PATTERN matched irrelevant boilerplate anywhere in the prompt');
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
// EDGE CASE (BUG): INLINE_JUSTIFICATION_PATTERN for a direct Write is similarly a bare
|
|
54
|
-
// substring test over the whole file content. Any comment anywhere in the file containing
|
|
55
|
-
// the word "justification:" (even quoting/describing the concept, not actually justifying
|
|
56
|
-
// THIS file) passes.
|
|
57
|
-
test('BUG: false negative — the word "justification:" appearing in an unrelated docstring satisfies the check for any new tool file', () => {
|
|
58
|
-
const content = [
|
|
59
|
-
'// This module documents how other files use the phrase justification: <reason> in',
|
|
60
|
-
'// their headers, as an example of our commenting convention.',
|
|
61
|
-
'export function run() {}',
|
|
62
|
-
].join('\n');
|
|
63
|
-
const { out } = runGate(write('/repo/scripts/new-thing.mjs', content), ENABLE);
|
|
64
|
-
assert.equal(out, null, 'gate allows because the literal string "justification:" appears anywhere, regardless of whether it actually justifies this file');
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
// EDGE CASE: confirm the "editing an existing file" exemption (checkWrite, index.mjs line
|
|
68
|
-
// 56-58) can be trivially satisfied by ANY tool call whose file_path happens to already
|
|
69
|
-
// exist on disk from an unrelated prior write within the same directory — verifying this
|
|
70
|
-
// is intentional per the comment, not a further bug, but documenting the boundary.
|
|
71
|
-
test('OK (documented, not a new bug): pre-creating an empty file at the target path bypasses audit entirely on the next edit', () => {
|
|
72
|
-
const { project } = runGate(delegate('noop'), ENABLE); // just to get an isolated project dir
|
|
73
|
-
const targetPath = join(project, 'scripts', 'sneaky.mjs');
|
|
74
|
-
mkdirSync(join(project, 'scripts'), { recursive: true });
|
|
75
|
-
writeFileSync(targetPath, ''); // pre-create empty file outside the gate's view
|
|
76
|
-
const out2 = execFileSync(process.execPath, [GATE], {
|
|
77
|
-
input: JSON.stringify(write(targetPath, 'export function run() {}')),
|
|
78
|
-
encoding: 'utf8',
|
|
79
|
-
cwd: project,
|
|
80
|
-
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
81
|
-
});
|
|
82
|
-
assert.equal(out2.trim() ? JSON.parse(out2.trim()) : null, null, 'existsSync(rawPath) exemption means pre-touching the file first defeats the whole gate');
|
|
83
|
-
});
|
|
@@ -1,117 +0,0 @@
|
|
|
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(), 'audit-before-build-'));
|
|
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) {
|
|
30
|
-
return { tool_name: 'Agent', tool_input: { prompt } };
|
|
31
|
-
}
|
|
32
|
-
function write(filePath, content) {
|
|
33
|
-
return { tool_name: 'Write', tool_input: { file_path: filePath, content } };
|
|
34
|
-
}
|
|
35
|
-
function isDeny(result) {
|
|
36
|
-
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const ENABLE = { config: { gates: { requireAuditBeforeBuilding: true } } };
|
|
40
|
-
|
|
41
|
-
test('denies delegation to create a new tool without audit evidence', () => {
|
|
42
|
-
assert.ok(
|
|
43
|
-
isDeny(runGate(delegate('create a new script that checks X'), ENABLE)),
|
|
44
|
-
);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
test('allows delegation with audit evidence stated', () => {
|
|
48
|
-
assert.equal(
|
|
49
|
-
runGate(
|
|
50
|
-
delegate(
|
|
51
|
-
'create a new script that checks X. audited and no existing tool covers this.',
|
|
52
|
-
),
|
|
53
|
-
ENABLE,
|
|
54
|
-
),
|
|
55
|
-
null,
|
|
56
|
-
);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test('denies writing a new executable tool file without a justification comment', () => {
|
|
60
|
-
assert.equal(
|
|
61
|
-
runGate(
|
|
62
|
-
write('/repo/scripts/check-thing.mjs', 'export function run() {}'),
|
|
63
|
-
ENABLE,
|
|
64
|
-
)?.hookSpecificOutput?.permissionDecision,
|
|
65
|
-
'deny',
|
|
66
|
-
);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test('allows writing a tool file with a justification comment', () => {
|
|
70
|
-
assert.equal(
|
|
71
|
-
runGate(
|
|
72
|
-
write(
|
|
73
|
-
'/repo/scripts/check-thing.mjs',
|
|
74
|
-
'// justification: no existing tool does this\nexport function run() {}',
|
|
75
|
-
),
|
|
76
|
-
ENABLE,
|
|
77
|
-
),
|
|
78
|
-
null,
|
|
79
|
-
);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
test('allows a write outside tool folders or non-executable extension', () => {
|
|
83
|
-
assert.equal(
|
|
84
|
-
runGate(write('/repo/docs/notes.md', 'plain notes'), ENABLE),
|
|
85
|
-
null,
|
|
86
|
-
);
|
|
87
|
-
assert.equal(runGate(write('/repo/scripts/data.json', '{}'), ENABLE), null);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
test('denies delegation to create a new tool without audit evidence, in Spanish', () => {
|
|
91
|
-
assert.ok(
|
|
92
|
-
isDeny(runGate(delegate('crea un nuevo script que verifique X'), ENABLE)),
|
|
93
|
-
);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
test('bilingual control: ES and EN equivalent new-tool delegations both deny without audit evidence', () => {
|
|
97
|
-
const es = delegate('crea un nuevo script que verifique X');
|
|
98
|
-
const en = delegate('create a new script that checks X');
|
|
99
|
-
assert.ok(isDeny(runGate(es, ENABLE)));
|
|
100
|
-
assert.ok(isDeny(runGate(en, ENABLE)));
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
test('allows a Spanish delegation with audit evidence stated', () => {
|
|
104
|
-
assert.equal(
|
|
105
|
-
runGate(
|
|
106
|
-
delegate(
|
|
107
|
-
'crea un nuevo script que verifique X. audite y no existe una herramienta que cubra esto.',
|
|
108
|
-
),
|
|
109
|
-
ENABLE,
|
|
110
|
-
),
|
|
111
|
-
null,
|
|
112
|
-
);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
test('disabled by default (registry default is false)', () => {
|
|
116
|
-
assert.equal(runGate(delegate('create a new script that checks X')), null);
|
|
117
|
-
});
|