@devrik-tools/claude-gates 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/.claude-plugin/marketplace.json +7 -1
  2. package/cli/__tests__/config.test.mjs +113 -101
  3. package/cli/__tests__/install.test.mjs +102 -0
  4. package/cli/__tests__/materialize.test.mjs +95 -0
  5. package/cli/__tests__/registry-gates-consistency.test.mjs +52 -44
  6. package/cli/__tests__/task.test.mjs +124 -0
  7. package/cli/__tests__/version-consistency.test.mjs +44 -0
  8. package/cli/config.mjs +124 -113
  9. package/cli/index.mjs +100 -97
  10. package/cli/init.mjs +259 -256
  11. package/cli/install.mjs +150 -80
  12. package/cli/materialize.mjs +102 -51
  13. package/cli/registry.mjs +136 -127
  14. package/cli/task.mjs +140 -0
  15. package/package.json +2 -1
  16. package/plugins/gates/.claude-plugin/plugin.json +8 -2
  17. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +83 -0
  18. package/plugins/gates/hooks/__tests__/doctor.test.mjs +85 -0
  19. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +65 -0
  20. package/plugins/gates/hooks/ask-adoption.mjs +147 -0
  21. package/plugins/gates/hooks/doctor.mjs +207 -0
  22. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +83 -0
  23. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -88
  24. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +117 -92
  25. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +104 -0
  26. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +48 -45
  27. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +165 -0
  28. package/plugins/gates/hooks/gates/bash-commands/index.mjs +285 -268
  29. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +151 -0
  30. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +265 -177
  31. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +207 -0
  32. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +501 -456
  33. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +161 -143
  34. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +69 -0
  35. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +126 -118
  36. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +68 -0
  37. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +43 -49
  38. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +83 -0
  39. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -100
  40. package/plugins/gates/hooks/gates/force-parallel/index.mjs +119 -0
  41. package/plugins/gates/hooks/gates/force-parallel/test.mjs +88 -0
  42. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +155 -0
  43. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -112
  44. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +66 -0
  45. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -186
  46. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -238
  47. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +96 -0
  48. package/plugins/gates/hooks/gates/intent-flow/test.mjs +165 -136
  49. package/plugins/gates/hooks/gates/lint-commit/index.mjs +149 -0
  50. package/plugins/gates/hooks/gates/lint-commit/test.mjs +85 -0
  51. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +100 -0
  52. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -166
  53. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +79 -85
  54. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +80 -0
  55. package/plugins/gates/hooks/gates/never-assume/index.mjs +58 -55
  56. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +71 -0
  57. package/plugins/gates/hooks/gates/never-assume/test.mjs +95 -78
  58. package/plugins/gates/hooks/gates/no-blocking/index.mjs +148 -142
  59. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +109 -0
  60. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +123 -120
  61. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +83 -0
  62. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +91 -0
  63. package/plugins/gates/hooks/gates/protected-paths/index.mjs +144 -147
  64. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +126 -0
  65. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +89 -64
  66. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +102 -0
  67. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +94 -0
  68. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +159 -150
  69. package/plugins/gates/hooks/gates/risk-level/index.mjs +263 -203
  70. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +116 -0
  71. package/plugins/gates/hooks/gates/risk-level/test.mjs +153 -125
  72. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +56 -50
  73. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +58 -0
  74. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +116 -111
  75. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +97 -0
  76. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +184 -99
  77. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +126 -0
  78. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +130 -0
  79. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -251
  80. package/plugins/gates/hooks/gates/stop-pending/index.mjs +159 -0
  81. package/plugins/gates/hooks/gates/stop-pending/test.mjs +114 -0
  82. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -101
  83. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +72 -0
  84. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +63 -0
  85. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -181
  86. package/plugins/gates/hooks/gates/test-matrix/test.mjs +114 -87
  87. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +62 -0
  88. package/plugins/gates/hooks/gates/tool-map/index.mjs +143 -140
  89. package/plugins/gates/hooks/hooks.json +346 -286
  90. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +173 -154
  91. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +210 -154
  92. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +114 -0
  93. package/plugins/gates/hooks/lib/config.mjs +172 -165
  94. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +189 -0
  95. package/plugins/gates/hooks/lib/hook-io.mjs +318 -208
  96. package/plugins/gates/hooks/lib/signals.mjs +127 -0
  97. package/plugins/gates/hooks/wiring-check.mjs +227 -0
  98. package/plugins/tasks/.claude-plugin/plugin.json +14 -0
  99. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +100 -0
  100. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +95 -0
  101. package/plugins/tasks/hooks/hooks.json +26 -0
  102. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +180 -132
  103. package/plugins/tasks/hooks/lib/task-store.mjs +197 -159
  104. package/plugins/tasks/hooks/register-requests.mjs +141 -108
  105. package/plugins/tasks/hooks/session-tasks.mjs +108 -0
  106. package/registry.json +793 -686
@@ -0,0 +1,159 @@
1
+ // stop-pending — the hook that obliges the assistant not to leave pending tasks behind.
2
+ // On the Stop event, if the project's task store (`.ai/tasks/active.json`) has ACTIVE
3
+ // tasks (status open/in_forge — see allowStopWithBlockedTasks for `blocked`), this BLOCKS
4
+ // the stop and lists them with how to close each: `task close --evidence` or `task
5
+ // abandon`.
6
+ //
7
+ // justification: no existing tool covers this. The tasks plugin (register-requests.mjs,
8
+ // session-tasks.mjs) reminds and recites; nothing in this repo stops the agent from ending
9
+ // a turn while a task is still open — that requires a Stop hook that can BLOCK, which only
10
+ // this event shape supports.
11
+ //
12
+ // ── Stop hook event shape (Claude Code) ──────────────────────────────────────────────
13
+ // stdin carries JSON: { session_id, stop_hook_active, ... }. To block the stop (make the
14
+ // agent continue instead of ending the turn), print {"decision":"block","reason":"..."}
15
+ // and exit 0 — `reason` is shown to the model so it knows what to do next. To allow the
16
+ // stop, print nothing (or {}) and exit 0.
17
+ //
18
+ // ── stop_hook_active loop-guard (mandatory) ──────────────────────────────────────────
19
+ // If a Stop hook already blocked once in this cycle, Claude Code re-invokes Stop hooks
20
+ // with stop_hook_active=true. Blocking AGAIN here would never let the turn end — an
21
+ // infinite loop. So: whenever stop_hook_active === true, this gate unconditionally allows
22
+ // the stop, even if pending tasks remain. That trades "always caught" for "never hangs the
23
+ // session" — a single missed reminder is recoverable, a frozen session is not.
24
+ //
25
+ // ── Why this re-reads active.json directly instead of importing task-store.mjs ──────
26
+ // The task store lives in a DIFFERENT plugin (plugins/tasks/hooks/lib/task-store.mjs).
27
+ // Importing across plugin boundaries makes each plugin's installability depend on the
28
+ // other being present at a specific relative path — exactly the coupling every other gate
29
+ // in this repo avoids by being self-contained (Node built-ins only). This gate instead
30
+ // re-reads the on-disk JSON shape directly: `{ tasks: [ { id, title, status, ... } ] }`
31
+ // with status one of open/blocked/in_forge/done/abandoned (done/abandoned already live
32
+ // only in history.json, never in active.json, by task-store's own close() contract — so
33
+ // active.json is never filtered by status here beyond blocked/allowStopWithBlockedTasks).
34
+ // A change to that shape would need updating in two places, but a cross-plugin import
35
+ // would need the OTHER plugin installed at all, which is worse for a gate meant to work
36
+ // standalone.
37
+ //
38
+ // ── What a project can configure (params) ───────────────────────────────────────────
39
+ // allowStopWithBlockedTasks when true (default), a task with status 'blocked' (a
40
+ // declared cause + a next condition) does NOT prevent stop —
41
+ // only open/in_forge do. Set false to also block on 'blocked'.
42
+ //
43
+ // ── Fail-safe shape (mandatory for a Stop hook) ──────────────────────────────────────
44
+ // Everything below is wrapped in try/catch. No plugin/tasks installed, no .ai/tasks/, a
45
+ // corrupt active.json, or ANY unexpected error: allow the stop. A broken Stop hook must
46
+ // never hang a session — that is worse than one missed reminder.
47
+
48
+ import { existsSync, readFileSync } from 'node:fs';
49
+ import { dirname, join } from 'node:path';
50
+ import { isGateEnabled, gateParameters } from '../../lib/config.mjs';
51
+ import { readHookPayload } from '../../lib/hook-io.mjs';
52
+
53
+ const GATE_ID = 'stop-pending';
54
+ const CONFIG_KEY = 'blockStopWithPendingTasks';
55
+
56
+ const PROJECT_ROOT_MARKERS = ['.git', '.ai'];
57
+ const ACTIVE_TASKS_FILE = join('.ai', 'tasks', 'active.json');
58
+ const BLOCKING_STATUSES = new Set(['open', 'in_forge']);
59
+ const BLOCKED_STATUS = 'blocked';
60
+
61
+ function stripBom(text) {
62
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
63
+ }
64
+
65
+ function projectRootOf(startDirectory) {
66
+ let current = startDirectory;
67
+ while (true) {
68
+ if (PROJECT_ROOT_MARKERS.some((marker) => existsSync(join(current, marker)))) {
69
+ return current;
70
+ }
71
+ const parent = dirname(current);
72
+ if (parent === current) return null;
73
+ current = parent;
74
+ }
75
+ }
76
+
77
+ /** Reads .ai/tasks/active.json's task list directly. [] on any absence/corruption. */
78
+ function readActiveTasks(root) {
79
+ const path = join(root, ACTIVE_TASKS_FILE);
80
+ if (!existsSync(path)) return [];
81
+ try {
82
+ const parsed = JSON.parse(stripBom(readFileSync(path, 'utf8')));
83
+ return Array.isArray(parsed.tasks) ? parsed.tasks : [];
84
+ } catch {
85
+ return [];
86
+ }
87
+ }
88
+
89
+ function blockingTasksFrom(tasks, allowBlocked) {
90
+ return tasks.filter((task) => {
91
+ if (BLOCKING_STATUSES.has(task.status)) return true;
92
+ return !allowBlocked && task.status === BLOCKED_STATUS;
93
+ });
94
+ }
95
+
96
+ function describeTasks(tasks) {
97
+ return tasks
98
+ .map(
99
+ (task) =>
100
+ ` - [${task.status}] ${task.id}: ${task.title ?? '(untitled)'}` +
101
+ (task.status === BLOCKED_STATUS && task.blockedReason
102
+ ? ` (blocked: ${task.blockedReason})`
103
+ : ''),
104
+ )
105
+ .join('\n');
106
+ }
107
+
108
+ function block(reason) {
109
+ process.stdout.write(JSON.stringify({ decision: 'block', reason }));
110
+ process.exit(0);
111
+ }
112
+
113
+ function allow() {
114
+ process.exit(0);
115
+ }
116
+
117
+ function main() {
118
+ try {
119
+ const rawPayload = readHookPayload();
120
+ if (rawPayload === null) return allow();
121
+
122
+ let payload;
123
+ try {
124
+ payload = JSON.parse(rawPayload);
125
+ } catch {
126
+ return allow(); // unparseable Stop payload: fail-safe, never hang the session
127
+ }
128
+
129
+ // Loop-guard: a Stop hook already blocked once this cycle. Never block again.
130
+ if (payload?.stop_hook_active === true) return allow();
131
+
132
+ const cwd = process.cwd();
133
+ if (!isGateEnabled(CONFIG_KEY, false, cwd)) return allow();
134
+
135
+ const root = projectRootOf(cwd);
136
+ if (!root) return allow(); // no project: nothing to check
137
+
138
+ const parameters = gateParameters(CONFIG_KEY, cwd);
139
+ const allowBlocked = parameters.allowStopWithBlockedTasks !== false; // default true
140
+
141
+ const tasks = readActiveTasks(root);
142
+ const blockingTasks = blockingTasksFrom(tasks, allowBlocked);
143
+ if (blockingTasks.length === 0) return allow();
144
+
145
+ return block(
146
+ `[${GATE_ID}] There are ${blockingTasks.length} pending task(s) still active for this ` +
147
+ `project:\n${describeTasks(blockingTasks)}\n` +
148
+ 'Close each before ending the turn: `task close <id> --evidence "..."` when done, ' +
149
+ 'or `task abandon <id> --reason "..."` when it will not be finished. A task left ' +
150
+ '`blocked` with a stated cause does not require closing unless ' +
151
+ 'allowStopWithBlockedTasks is set to false.',
152
+ );
153
+ } catch {
154
+ // A broken Stop hook must never hang the session.
155
+ return allow();
156
+ }
157
+ }
158
+
159
+ main();
@@ -0,0 +1,114 @@
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 setupProject({ enabled = true, tasks, allowStopWithBlockedTasks } = {}) {
12
+ const project = mkdtempSync(join(tmpdir(), 'stop-pending-'));
13
+ mkdirSync(join(project, '.git'), { recursive: true });
14
+ mkdirSync(join(project, '.ai'), { recursive: true });
15
+
16
+ const gateEntry = { enabled };
17
+ if (allowStopWithBlockedTasks !== undefined) {
18
+ gateEntry.allowStopWithBlockedTasks = allowStopWithBlockedTasks;
19
+ }
20
+ writeFileSync(
21
+ join(project, '.ai', 'config.json'),
22
+ JSON.stringify({ gates: { blockStopWithPendingTasks: gateEntry } }),
23
+ );
24
+
25
+ if (tasks) {
26
+ mkdirSync(join(project, '.ai', 'tasks'), { recursive: true });
27
+ writeFileSync(
28
+ join(project, '.ai', 'tasks', 'active.json'),
29
+ JSON.stringify({ tasks }),
30
+ );
31
+ }
32
+ return project;
33
+ }
34
+
35
+ function runGate(project, payload) {
36
+ const out = execFileSync(process.execPath, [GATE], {
37
+ input: JSON.stringify(payload),
38
+ encoding: 'utf8',
39
+ cwd: project,
40
+ env: { ...process.env, HOME: project, USERPROFILE: project },
41
+ });
42
+ return out.trim() ? JSON.parse(out.trim()) : null;
43
+ }
44
+
45
+ test('an open active task blocks the stop', () => {
46
+ const project = setupProject({
47
+ tasks: [{ id: 't-1', title: 'finish the thing', status: 'open' }],
48
+ });
49
+ const result = runGate(project, { session_id: 's1', stop_hook_active: false });
50
+ assert.equal(result.decision, 'block');
51
+ assert.match(result.reason, /t-1/);
52
+ });
53
+
54
+ test('no active tasks: stop is allowed', () => {
55
+ const project = setupProject({ tasks: [] });
56
+ const result = runGate(project, { session_id: 's1', stop_hook_active: false });
57
+ assert.equal(result, null);
58
+ });
59
+
60
+ test('no .ai/tasks directory at all: stop is allowed', () => {
61
+ const project = setupProject({});
62
+ const result = runGate(project, { session_id: 's1', stop_hook_active: false });
63
+ assert.equal(result, null);
64
+ });
65
+
66
+ test('stop_hook_active=true: always allow, even with an open task (loop guard)', () => {
67
+ const project = setupProject({
68
+ tasks: [{ id: 't-1', title: 'finish the thing', status: 'open' }],
69
+ });
70
+ const result = runGate(project, { session_id: 's1', stop_hook_active: true });
71
+ assert.equal(result, null);
72
+ });
73
+
74
+ test('a blocked task with allowStopWithBlockedTasks default (true): stop is allowed', () => {
75
+ const project = setupProject({
76
+ tasks: [{ id: 't-2', title: 'waiting on X', status: 'blocked', blockedReason: 'waiting on X' }],
77
+ });
78
+ const result = runGate(project, { session_id: 's1', stop_hook_active: false });
79
+ assert.equal(result, null);
80
+ });
81
+
82
+ test('a blocked task with allowStopWithBlockedTasks:false: stop is blocked', () => {
83
+ const project = setupProject({
84
+ tasks: [{ id: 't-2', title: 'waiting on X', status: 'blocked', blockedReason: 'waiting on X' }],
85
+ allowStopWithBlockedTasks: false,
86
+ });
87
+ const result = runGate(project, { session_id: 's1', stop_hook_active: false });
88
+ assert.equal(result.decision, 'block');
89
+ assert.match(result.reason, /t-2/);
90
+ });
91
+
92
+ test('gate disabled: stop is allowed even with an open task', () => {
93
+ const project = setupProject({
94
+ tasks: [{ id: 't-1', title: 'finish the thing', status: 'open' }],
95
+ enabled: false,
96
+ });
97
+ const result = runGate(project, { session_id: 's1', stop_hook_active: false });
98
+ assert.equal(result, null);
99
+ });
100
+
101
+ test('a done task in active.json (should not normally happen) never blocks', () => {
102
+ const project = setupProject({
103
+ tasks: [{ id: 't-3', title: 'already closed', status: 'done' }],
104
+ });
105
+ const result = runGate(project, { session_id: 's1', stop_hook_active: false });
106
+ assert.equal(result, null);
107
+ });
108
+
109
+ test('corrupt active.json: fail-safe allow', () => {
110
+ const project = setupProject({ tasks: [] });
111
+ writeFileSync(join(project, '.ai', 'tasks', 'active.json'), '{not json');
112
+ const result = runGate(project, { session_id: 's1', stop_hook_active: false });
113
+ assert.equal(result, null);
114
+ });
@@ -1,101 +1,101 @@
1
- import { execFileSync } from 'node:child_process';
2
- import { existsSync } from 'node:fs';
3
- import { basename, dirname, relative } from 'node:path';
4
- import { runGate, warn, TOOL_GROUPS } from '../../lib/hook-io.mjs';
5
-
6
- const GATE_ID = 'test-after-implementation';
7
- const CONFIG_KEY = 'warnTestWrittenAfterImplementation';
8
-
9
- const DEFAULT_IMPLEMENTATION_EXTENSIONS = [
10
- '.ts',
11
- '.tsx',
12
- '.js',
13
- '.jsx',
14
- '.mjs',
15
- '.vue',
16
- ];
17
- const TEST_FILE_PATTERN = /\.(spec|test)\.[cm]?[jt]sx?$/i;
18
- // `git status --porcelain` prefixes each line with a 2-character status code plus a space.
19
- const PORCELAIN_STATUS_PREFIX_LENGTH = 3;
20
-
21
- function baseNameWithoutTestSuffix(fileName) {
22
- return fileName.replace(TEST_FILE_PATTERN, '');
23
- }
24
-
25
- function gitStatusPorcelain(cwd) {
26
- try {
27
- const output = execFileSync('git', ['status', '--porcelain'], {
28
- cwd,
29
- timeout: 5000,
30
- }).toString();
31
- return output
32
- .split('\n')
33
- .map((line) => line.trim())
34
- .filter(Boolean);
35
- } catch {
36
- // Not a git repo, or git unavailable — nothing to cross-check against.
37
- return [];
38
- }
39
- }
40
-
41
- runGate(
42
- {
43
- id: GATE_ID,
44
- configKey: CONFIG_KEY,
45
- enabledByDefault: false,
46
- defaultParams: {
47
- implementationExtensions: DEFAULT_IMPLEMENTATION_EXTENSIONS,
48
- },
49
- },
50
- ({ toolName, toolInput, parameters }) => {
51
- if (!TOOL_GROUPS.write.includes(toolName)) return;
52
-
53
- const filePath = toolInput?.file_path ?? '';
54
- if (!TEST_FILE_PATTERN.test(filePath)) return;
55
- if (existsSync(filePath)) return; // only new test file creation, not edits
56
-
57
- const cwd = process.cwd();
58
- const statusLines = gitStatusPorcelain(cwd);
59
- if (statusLines.length === 0) return;
60
-
61
- const testFileName = basename(filePath);
62
- const stem = baseNameWithoutTestSuffix(testFileName);
63
- // `git status --porcelain` reports paths relative to `cwd`; the test's own
64
- // directory (relative to `cwd`) is the fair comparison, not its absolute path.
65
- // `relative()` returns '' for the repo root itself, while `dirname()` on a
66
- // bare file name returns '.' — normalize both to '.' so root-level files compare equal.
67
- const testDirectoryRelativeToRepo = relative(cwd, dirname(filePath)) || '.';
68
-
69
- const pairedImplementationChanged = statusLines.some((line) => {
70
- const changedPath = line.slice(PORCELAIN_STATUS_PREFIX_LENGTH).trim();
71
- if (
72
- !parameters.implementationExtensions.some((extension) =>
73
- changedPath.endsWith(extension),
74
- )
75
- )
76
- return false;
77
- if (TEST_FILE_PATTERN.test(changedPath)) return false;
78
- const lastDot = changedPath.lastIndexOf('.');
79
- const changedBase = basename(
80
- changedPath,
81
- lastDot >= 0 ? changedPath.slice(lastDot) : '',
82
- );
83
- return (
84
- changedBase === stem &&
85
- (dirname(changedPath) || '.') === testDirectoryRelativeToRepo
86
- );
87
- });
88
-
89
- if (!pairedImplementationChanged) return;
90
-
91
- warn(
92
- GATE_ID,
93
- `Creating test ${testFileName} after its paired implementation was already modified (uncommitted). Consider writing tests before or alongside the implementation, not after.`,
94
- );
95
- },
96
- );
97
-
98
- // Note: the source guard (guard-test-despues-de-implementar.mjs) denies this
99
- // case outright. This gate warns instead, because registry.json names the
100
- // config key warnTestWrittenAfterImplementation, which declares warn as the
101
- // intended severity for this migration.
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { basename, dirname, relative } from 'node:path';
4
+ import { runGate, warn, toolInGroups, writtenPathOf } from '../../lib/hook-io.mjs';
5
+
6
+ const GATE_ID = 'test-after-implementation';
7
+ const CONFIG_KEY = 'warnTestWrittenAfterImplementation';
8
+
9
+ const DEFAULT_IMPLEMENTATION_EXTENSIONS = [
10
+ '.ts',
11
+ '.tsx',
12
+ '.js',
13
+ '.jsx',
14
+ '.mjs',
15
+ '.vue',
16
+ ];
17
+ const TEST_FILE_PATTERN = /\.(spec|test)\.[cm]?[jt]sx?$/i;
18
+ // `git status --porcelain` prefixes each line with a 2-character status code plus a space.
19
+ const PORCELAIN_STATUS_PREFIX_LENGTH = 3;
20
+
21
+ function baseNameWithoutTestSuffix(fileName) {
22
+ return fileName.replace(TEST_FILE_PATTERN, '');
23
+ }
24
+
25
+ function gitStatusPorcelain(cwd) {
26
+ try {
27
+ const output = execFileSync('git', ['status', '--porcelain'], {
28
+ cwd,
29
+ timeout: 5000,
30
+ }).toString();
31
+ return output
32
+ .split('\n')
33
+ .map((line) => line.trim())
34
+ .filter(Boolean);
35
+ } catch {
36
+ // Not a git repo, or git unavailable — nothing to cross-check against.
37
+ return [];
38
+ }
39
+ }
40
+
41
+ runGate(
42
+ {
43
+ id: GATE_ID,
44
+ configKey: CONFIG_KEY,
45
+ enabledByDefault: false,
46
+ defaultParams: {
47
+ implementationExtensions: DEFAULT_IMPLEMENTATION_EXTENSIONS,
48
+ },
49
+ },
50
+ ({ toolName, toolInput, parameters }) => {
51
+ if (!toolInGroups(toolName, ['write'])) return;
52
+
53
+ const filePath = writtenPathOf(toolInput);
54
+ if (!TEST_FILE_PATTERN.test(filePath)) return;
55
+ if (existsSync(filePath)) return; // only new test file creation, not edits
56
+
57
+ const cwd = process.cwd();
58
+ const statusLines = gitStatusPorcelain(cwd);
59
+ if (statusLines.length === 0) return;
60
+
61
+ const testFileName = basename(filePath);
62
+ const stem = baseNameWithoutTestSuffix(testFileName);
63
+ // `git status --porcelain` reports paths relative to `cwd`; the test's own
64
+ // directory (relative to `cwd`) is the fair comparison, not its absolute path.
65
+ // `relative()` returns '' for the repo root itself, while `dirname()` on a
66
+ // bare file name returns '.' — normalize both to '.' so root-level files compare equal.
67
+ const testDirectoryRelativeToRepo = relative(cwd, dirname(filePath)) || '.';
68
+
69
+ const pairedImplementationChanged = statusLines.some((line) => {
70
+ const changedPath = line.slice(PORCELAIN_STATUS_PREFIX_LENGTH).trim();
71
+ if (
72
+ !parameters.implementationExtensions.some((extension) =>
73
+ changedPath.endsWith(extension),
74
+ )
75
+ )
76
+ return false;
77
+ if (TEST_FILE_PATTERN.test(changedPath)) return false;
78
+ const lastDot = changedPath.lastIndexOf('.');
79
+ const changedBase = basename(
80
+ changedPath,
81
+ lastDot >= 0 ? changedPath.slice(lastDot) : '',
82
+ );
83
+ return (
84
+ changedBase === stem &&
85
+ (dirname(changedPath) || '.') === testDirectoryRelativeToRepo
86
+ );
87
+ });
88
+
89
+ if (!pairedImplementationChanged) return;
90
+
91
+ warn(
92
+ GATE_ID,
93
+ `Creating test ${testFileName} after its paired implementation was already modified (uncommitted). Consider writing tests before or alongside the implementation, not after.`,
94
+ );
95
+ },
96
+ );
97
+
98
+ // Note: the source guard (guard-test-despues-de-implementar.mjs) denies this
99
+ // case outright. This gate warns instead, because registry.json names the
100
+ // config key warnTestWrittenAfterImplementation, which declares warn as the
101
+ // intended severity for this migration.
@@ -0,0 +1,72 @@
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 newProject({ config, git = true } = {}) {
12
+ const project = mkdtempSync(join(tmpdir(), 'test-after-implementation-edge-'));
13
+ if (git) {
14
+ execFileSync('git', ['init', '-q'], { cwd: project });
15
+ execFileSync('git', ['config', 'user.email', 'a@b.c'], { cwd: project });
16
+ execFileSync('git', ['config', 'user.name', 'a'], { cwd: project });
17
+ }
18
+ if (config) {
19
+ mkdirSync(join(project, '.ai'));
20
+ writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
21
+ }
22
+ return project;
23
+ }
24
+
25
+ function runGateIn(project, payload) {
26
+ const out = execFileSync(process.execPath, [GATE], {
27
+ input: JSON.stringify(payload),
28
+ encoding: 'utf8',
29
+ cwd: project,
30
+ env: { ...process.env, HOME: project, USERPROFILE: project },
31
+ });
32
+ return out.trim() ? JSON.parse(out.trim()) : null;
33
+ }
34
+
35
+ function writeTest(filePath) {
36
+ return { tool_name: 'Write', tool_input: { file_path: filePath, content: 'test content' } };
37
+ }
38
+ function isWarn(result) {
39
+ return result?.hookSpecificOutput?.additionalContext !== undefined;
40
+ }
41
+
42
+ const ENABLE = { gates: { warnTestWrittenAfterImplementation: true } };
43
+
44
+ // EDGE CASE (BUG): the paired-implementation match requires the changed file's directory
45
+ // (relative to cwd) to equal the test file's directory exactly (index.mjs line 83-86). A
46
+ // common layout keeps tests in a sibling __tests__/ directory next to the implementation —
47
+ // this gate then never pairs them, silently missing the exact case (tests-after-impl) it
48
+ // exists to catch.
49
+ test('BUG: false negative — implementation and test in different conventional directories (src/ vs __tests__/) are never paired', () => {
50
+ const project = newProject({ config: ENABLE });
51
+ mkdirSync(join(project, 'src'));
52
+ mkdirSync(join(project, '__tests__'));
53
+ writeFileSync(join(project, 'src', 'thing.js'), 'export const thing = 1;');
54
+ const testPath = join(project, '__tests__', 'thing.test.js');
55
+ const result = runGateIn(project, writeTest(testPath));
56
+ assert.equal(result, null, 'gate misses the exact case it targets because dirname(test) !== dirname(impl) under a __tests__/ layout');
57
+ });
58
+
59
+ // EDGE CASE (BUG): only `git status --porcelain` (uncommitted changes) is checked. If the
60
+ // implementation was written AND COMMITTED before writing the test (a slightly worse
61
+ // practice: commit-then-test rather than just write-then-test), git status shows nothing
62
+ // changed and the gate never warns — the temporal violation it targets still happened but
63
+ // leaves no trace once committed.
64
+ test('BUG: false negative — once the paired implementation change is committed, the warning never fires even though tests were still written strictly after', () => {
65
+ const project = newProject({ config: ENABLE });
66
+ writeFileSync(join(project, 'thing.js'), 'export const thing = 1;');
67
+ execFileSync('git', ['add', '-A'], { cwd: project });
68
+ execFileSync('git', ['commit', '-q', '-m', 'implement thing'], { cwd: project });
69
+ const testPath = join(project, 'thing.test.js');
70
+ const result = runGateIn(project, writeTest(testPath));
71
+ assert.equal(result, null, 'committing the implementation first launders the same temporal violation past a git-status-based check');
72
+ });
@@ -0,0 +1,63 @@
1
+ // Edge-case probe for test-matrix. Previously the prompt was read only from
2
+ // toolInput.prompt/Prompt/task, the same root cause shared by implementation-pipeline,
3
+ // mandatory-flow and the sdd-specs delegation check: a Task delegation carrying its
4
+ // brief in `description` was invisible. Migrated to delegationPromptOf()
5
+ // (hook-io.mjs), so this is now caught.
6
+ import assert from 'node:assert/strict';
7
+ import { execFileSync } from 'node:child_process';
8
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { dirname, join } from 'node:path';
11
+ import { test } from 'node:test';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
15
+
16
+ function runGate(payload, { config } = {}) {
17
+ const project = mkdtempSync(join(tmpdir(), 'test-matrix-edge-'));
18
+ mkdirSync(join(project, '.git'));
19
+ if (config) {
20
+ mkdirSync(join(project, '.ai'));
21
+ writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
22
+ }
23
+ const out = execFileSync(process.execPath, [GATE], {
24
+ input: JSON.stringify(payload),
25
+ encoding: 'utf8',
26
+ cwd: project,
27
+ env: { ...process.env, HOME: project, USERPROFILE: project },
28
+ });
29
+ return out.trim() ? JSON.parse(out.trim()) : null;
30
+ }
31
+
32
+ function isDeny(result) {
33
+ return result?.hookSpecificOutput?.permissionDecision === 'deny';
34
+ }
35
+
36
+ function enabledConfig() {
37
+ return { gates: { requireTestMatrixWhenImplementing: true } };
38
+ }
39
+
40
+ test('FIXED: a Task delegation carrying its brief in `description` is now checked', () => {
41
+ const payload = {
42
+ tool_name: 'Task',
43
+ tool_input: {
44
+ description: 'Nivel: STANDARD\nImplementá el cobro de la cuota.',
45
+ subagent_type: 'backend',
46
+ },
47
+ };
48
+ assert.ok(
49
+ isDeny(runGate(payload, { config: enabledConfig() })),
50
+ 'delegationPromptOf reads description and the missing-test-matrix deny fires',
51
+ );
52
+ });
53
+
54
+ test('OK: the equivalent payload using `prompt` IS caught (control)', () => {
55
+ const payload = {
56
+ tool_name: 'Task',
57
+ tool_input: {
58
+ prompt: 'Nivel: STANDARD\nImplementá el cobro de la cuota.',
59
+ subagent_type: 'backend',
60
+ },
61
+ };
62
+ assert.ok(isDeny(runGate(payload, { config: enabledConfig() })), 'control failed');
63
+ });