@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
package/cli/task.mjs ADDED
@@ -0,0 +1,140 @@
1
+ // `claude-gates task` — the CLI half of the task system. The model registers, lists,
2
+ // closes, abandons and promotes tasks through these subcommands; all persistence and the
3
+ // evidence rule live in task-store.mjs (this file is thin wiring, no rules of its own).
4
+ //
5
+ // justification: no existing tool covers this. task-store.mjs was already audited when it
6
+ // was written; this is the CLI surface on top of it, following the same pattern as
7
+ // `registry` in cli/index.mjs.
8
+
9
+ import { randomUUID } from 'node:crypto';
10
+ import {
11
+ openTaskStore,
12
+ STATUS,
13
+ } from '../plugins/tasks/hooks/lib/task-store.mjs';
14
+ import { EXIT_CODE } from './constants.mjs';
15
+
16
+ const DEFAULT_SIZE = 'unspecified';
17
+
18
+ function fail(message) {
19
+ process.stderr.write(`${message}\n`);
20
+ process.exit(EXIT_CODE.FAILURE);
21
+ }
22
+
23
+ function openStoreOrFail(cwd) {
24
+ const store = openTaskStore(cwd);
25
+ if (!store) {
26
+ fail(
27
+ 'No project found here (no .git or .ai/ marker): tasks need a project root.',
28
+ );
29
+ }
30
+ return store;
31
+ }
32
+
33
+ function taskAdd(title, options, { cwd = process.cwd() } = {}) {
34
+ if (!title || !title.trim()) fail('task add requires a non-empty title.');
35
+ const store = openStoreOrFail(cwd);
36
+ const task = store.add({
37
+ id: options.id || randomUUID(),
38
+ title: title.trim(),
39
+ description: options.description ?? '',
40
+ status: STATUS.OPEN,
41
+ size: options.size ?? DEFAULT_SIZE,
42
+ createdAt: new Date().toISOString(),
43
+ messages: [],
44
+ });
45
+ process.stdout.write(`${JSON.stringify(task, null, 2)}\n`);
46
+ }
47
+
48
+ function taskList(options, { cwd = process.cwd() } = {}) {
49
+ const store = openStoreOrFail(cwd);
50
+ const tasks = options.all
51
+ ? [...store.active(), ...store.history()]
52
+ : store.active();
53
+ if (tasks.length === 0) {
54
+ process.stdout.write('No tasks.\n');
55
+ return;
56
+ }
57
+ for (const task of tasks) {
58
+ process.stdout.write(`[${task.status}] ${task.id}: ${task.title}\n`);
59
+ }
60
+ }
61
+
62
+ function reportCloseResult(result) {
63
+ if (result.error) fail(result.error);
64
+ process.stdout.write(`${JSON.stringify(result.task, null, 2)}\n`);
65
+ }
66
+
67
+ function taskClose(id, options, { cwd = process.cwd() } = {}) {
68
+ if (!options.evidence || !options.evidence.trim()) {
69
+ fail(
70
+ 'closing a task as done requires evidence that it was attended and resolved ' +
71
+ '(test output, a diff, a verification note). Pass --evidence, or use ' +
72
+ '`task abandon` if it will not be finished.',
73
+ );
74
+ }
75
+ const store = openStoreOrFail(cwd);
76
+ reportCloseResult(
77
+ store.close(id, STATUS.DONE, {
78
+ reason: options.reason ?? '',
79
+ evidence: options.evidence,
80
+ }),
81
+ );
82
+ }
83
+
84
+ function taskAbandon(id, options, { cwd = process.cwd() } = {}) {
85
+ const store = openStoreOrFail(cwd);
86
+ reportCloseResult(
87
+ store.close(id, STATUS.ABANDONED, { reason: options.reason ?? '' }),
88
+ );
89
+ }
90
+
91
+ function taskPromote(id, runId, { cwd = process.cwd() } = {}) {
92
+ const store = openStoreOrFail(cwd);
93
+ const promoted = store.promoteToForge(id, runId);
94
+ if (!promoted) fail(`no active task with id ${id}`);
95
+ process.stdout.write(`${JSON.stringify(promoted, null, 2)}\n`);
96
+ }
97
+
98
+ /** Registers the `task` command and its subcommands on a commander program. */
99
+ export function registerTaskCommand(program) {
100
+ const task = program
101
+ .command('task')
102
+ .description('Track project tasks (open/blocked/in_forge/done/abandoned).');
103
+
104
+ task
105
+ .command('add <title>')
106
+ .description('Register a new open task.')
107
+ .option('--id <id>', 'explicit task id (default: a generated uuid)')
108
+ .option('--description <text>', 'longer description of the task')
109
+ .option('--size <size>', 'rough size estimate (e.g. trivial, small, large)')
110
+ .action((title, options) => taskAdd(title, options));
111
+
112
+ task
113
+ .command('list')
114
+ .description('List active tasks (or all with --all).')
115
+ .option('--all', 'include closed (done/abandoned) tasks from history')
116
+ .action((options) => taskList(options));
117
+
118
+ task
119
+ .command('close <id>')
120
+ .description('Close a task as done. Requires --evidence.')
121
+ .option(
122
+ '--evidence <text>',
123
+ 'proof the task was attended and resolved (required)',
124
+ )
125
+ .option('--reason <text>', 'closing note')
126
+ .action((id, options) => taskClose(id, options));
127
+
128
+ task
129
+ .command('abandon <id>')
130
+ .description('Close a task as abandoned (no evidence required).')
131
+ .option('--reason <text>', 'why the task was dropped')
132
+ .action((id, options) => taskAbandon(id, options));
133
+
134
+ task
135
+ .command('promote <id> <runId>')
136
+ .description('Link a task to a forge run and mark it in_forge.')
137
+ .action((id, runId) => taskPromote(id, runId));
138
+
139
+ return task;
140
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devrik-tools/claude-gates",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Installable, deterministic gates (hooks) for Claude Code: block destructive commands, protected paths, and enforce delegation/spec/quality rules. Configurable per project.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -57,6 +57,7 @@
57
57
  "devDependencies": {
58
58
  "@cspell/eslint-plugin": "^10.1.1",
59
59
  "@eslint/js": "^10.0.1",
60
+ "@stryker-mutator/core": "^10.0.0",
60
61
  "eslint": "^10.9.1",
61
62
  "eslint-config-prettier": "^10.1.8",
62
63
  "eslint-plugin-boundaries": "^7.2.0",
@@ -1,9 +1,15 @@
1
1
  {
2
2
  "name": "gates",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Deterministic gates for Claude Code: destructive-command blocks, protected paths, delegation briefs, spec-driven flow and session-start validation. Selection lives in config, not in code.",
5
5
  "author": {
6
6
  "name": "Devrik"
7
7
  },
8
- "keywords": ["hooks", "guards", "gates", "PreToolUse", "SessionStart"]
8
+ "keywords": [
9
+ "hooks",
10
+ "guards",
11
+ "gates",
12
+ "PreToolUse",
13
+ "SessionStart"
14
+ ]
9
15
  }
@@ -0,0 +1,83 @@
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
+ });
@@ -0,0 +1,85 @@
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
+ });
@@ -0,0 +1,65 @@
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
+ });
@@ -0,0 +1,147 @@
1
+ // ask-adoption — SessionStart hook. In a project that has never answered the adoption
2
+ // question (no `.ai/config.json` and no recorded "asked" marker), asks the ASSISTANT to
3
+ // ask the USER what to adopt — a SessionStart hook cannot itself pop a question (there is
4
+ // no decision surface on this event), so the only legitimate move is to inject
5
+ // additionalContext asking the model to raise it, exactly once.
6
+ //
7
+ // The hook does NOT depend on the model actually asking for its own correctness: the
8
+ // deterministic half is the "have we already asked" marker, which THIS hook writes itself
9
+ // (see markAsked below) the moment it speaks — not something it waits on the model to
10
+ // report back. That guarantees the question is offered at most once per project even if
11
+ // the model never follows up.
12
+ //
13
+ // "Already answered" is either of:
14
+ // - `.ai/config.json` exists (any content — the project already ran `init`, or has an
15
+ // explicit config; both count as "answered", including a deliberate `adopted: false`).
16
+ // - `.ai/.adoption-asked` exists (written by this hook after it spoke once, for the case
17
+ // where the assistant never ran `init` — so the question is not repeated every session).
18
+ //
19
+ // justification: no existing tool covers this. `ask-adoption` was declared in registry.json
20
+ // (session family) with no script; this is that missing wiring, mirroring `session-tasks.mjs`
21
+ // and `register-requests.mjs`'s "hook asks, model judges" pattern for the tasks plugin.
22
+
23
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
24
+ import { homedir } from 'node:os';
25
+ import { dirname, join } from 'node:path';
26
+
27
+ const STDIN_FILE_DESCRIPTOR = 0;
28
+ const SESSION_START_EVENT = 'SessionStart';
29
+ const CONFIG_KEY = 'askAdoptionInNewProject';
30
+ const PROJECT_STATE_DIRECTORY = '.ai';
31
+ const CONFIG_FILE = 'config.json';
32
+ const ASKED_MARKER_FILE = '.adoption-asked';
33
+
34
+ // Same config lookup as the other session hooks: kept local, dependency-free.
35
+ const PROJECT_ROOT_MARKERS = ['.git', PROJECT_STATE_DIRECTORY];
36
+ const GLOBAL_CONFIG = join('.claude', 'claude-gates', 'config.json');
37
+
38
+ const BOM_CHAR_CODE = 0xfeff;
39
+
40
+ /** Strips a UTF-8 BOM a Windows editor/tool may have written before the JSON. */
41
+ function stripBom(text) {
42
+ return text.charCodeAt(0) === BOM_CHAR_CODE ? text.slice(1) : text;
43
+ }
44
+
45
+ function readJson(path) {
46
+ try {
47
+ return JSON.parse(stripBom(readFileSync(path, 'utf8')));
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ function projectRootOf(startDirectory) {
54
+ let current = startDirectory;
55
+ while (true) {
56
+ if (
57
+ PROJECT_ROOT_MARKERS.some((marker) => existsSync(join(current, marker)))
58
+ ) {
59
+ return current;
60
+ }
61
+ const parent = dirname(current);
62
+ if (parent === current) return null;
63
+ current = parent;
64
+ }
65
+ }
66
+
67
+ /** The ask-adoption gate config entry (project overrides global), or null. */
68
+ function adoptionConfig(startDirectory, projectRoot) {
69
+ const projectData = projectRoot
70
+ ? readJson(join(projectRoot, PROJECT_STATE_DIRECTORY, CONFIG_FILE))
71
+ : null;
72
+ const globalData = readJson(join(homedir(), GLOBAL_CONFIG));
73
+ const layer = projectData?.gates ?? globalData?.gates ?? {};
74
+ const entry = layer[CONFIG_KEY];
75
+ if (typeof entry === 'boolean') return { enabled: entry };
76
+ if (entry && typeof entry === 'object') return entry;
77
+ return null;
78
+ }
79
+
80
+ function readPayload() {
81
+ try {
82
+ return JSON.parse(readFileSync(STDIN_FILE_DESCRIPTOR, 'utf8'));
83
+ } catch {
84
+ return {};
85
+ }
86
+ }
87
+
88
+ function speak(context) {
89
+ process.stdout.write(
90
+ JSON.stringify({
91
+ hookSpecificOutput: {
92
+ hookEventName: SESSION_START_EVENT,
93
+ additionalContext: context,
94
+ },
95
+ }),
96
+ );
97
+ }
98
+
99
+ /** Writes the "already asked" marker so this project is never asked again. */
100
+ function markAsked(projectRoot) {
101
+ try {
102
+ mkdirSync(join(projectRoot, PROJECT_STATE_DIRECTORY), { recursive: true });
103
+ writeFileSync(
104
+ join(projectRoot, PROJECT_STATE_DIRECTORY, ASKED_MARKER_FILE),
105
+ `${new Date().toISOString()}\n`,
106
+ );
107
+ } catch {
108
+ // If the marker can't be written, worst case the question is offered again next
109
+ // session — not a correctness break, just a repeat.
110
+ }
111
+ }
112
+
113
+ const ADOPTION_PROMPT =
114
+ '[ask-adoption] This project has not adopted claude-gates yet (no .ai/config.json found). ' +
115
+ 'Ask the user, once, whether they want to adopt it and with which mode: ' +
116
+ '"defaults" (recommended gates only), "all" (every gate in every family), or "none" ' +
117
+ '(record a no so this is not asked again). Record whatever they choose by running ' +
118
+ '`claude-gates init --<mode> --yes` (add --project or --global for scope) from the ' +
119
+ 'project root — that CLI run is what actually persists the decision.';
120
+
121
+ function main() {
122
+ const payload = readPayload();
123
+ const cwd = payload.cwd || process.cwd();
124
+ const projectRoot = projectRootOf(cwd) ?? cwd;
125
+
126
+ const config = adoptionConfig(cwd, projectRoot);
127
+ if (config && config.enabled === false) return; // gate turned off for this project
128
+
129
+ const configPath = join(projectRoot, PROJECT_STATE_DIRECTORY, CONFIG_FILE);
130
+ if (existsSync(configPath)) return; // already answered (init ran, or an explicit config)
131
+
132
+ const markerPath = join(
133
+ projectRoot,
134
+ PROJECT_STATE_DIRECTORY,
135
+ ASKED_MARKER_FILE,
136
+ );
137
+ if (existsSync(markerPath)) return; // already asked once; do not repeat
138
+
139
+ speak(ADOPTION_PROMPT);
140
+ markAsked(projectRoot);
141
+ }
142
+
143
+ try {
144
+ main();
145
+ } catch {
146
+ // A broken ask-adoption hook must never block session start.
147
+ }