@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.
- package/.claude-plugin/marketplace.json +7 -1
- package/cli/__tests__/config.test.mjs +113 -101
- package/cli/__tests__/install.test.mjs +102 -0
- package/cli/__tests__/materialize.test.mjs +95 -0
- package/cli/__tests__/registry-gates-consistency.test.mjs +52 -44
- package/cli/__tests__/task.test.mjs +124 -0
- package/cli/__tests__/version-consistency.test.mjs +44 -0
- package/cli/config.mjs +124 -113
- package/cli/index.mjs +100 -97
- package/cli/init.mjs +259 -256
- package/cli/install.mjs +150 -80
- package/cli/materialize.mjs +102 -51
- package/cli/registry.mjs +136 -127
- package/cli/task.mjs +140 -0
- package/package.json +2 -1
- package/plugins/gates/.claude-plugin/plugin.json +8 -2
- package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +83 -0
- package/plugins/gates/hooks/__tests__/doctor.test.mjs +85 -0
- package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +65 -0
- package/plugins/gates/hooks/ask-adoption.mjs +147 -0
- package/plugins/gates/hooks/doctor.mjs +207 -0
- package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +83 -0
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -88
- package/plugins/gates/hooks/gates/audit-before-build/test.mjs +117 -92
- package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +104 -0
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +48 -45
- package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +165 -0
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +285 -268
- package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +151 -0
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +265 -177
- package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +207 -0
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +501 -456
- package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +161 -143
- package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +69 -0
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +126 -118
- package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +68 -0
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +43 -49
- package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +83 -0
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -100
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +119 -0
- package/plugins/gates/hooks/gates/force-parallel/test.mjs +88 -0
- package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +155 -0
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -112
- package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +66 -0
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -186
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -238
- package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +96 -0
- package/plugins/gates/hooks/gates/intent-flow/test.mjs +165 -136
- package/plugins/gates/hooks/gates/lint-commit/index.mjs +149 -0
- package/plugins/gates/hooks/gates/lint-commit/test.mjs +85 -0
- package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +100 -0
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -166
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +79 -85
- package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +80 -0
- package/plugins/gates/hooks/gates/never-assume/index.mjs +58 -55
- package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +71 -0
- package/plugins/gates/hooks/gates/never-assume/test.mjs +95 -78
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +148 -142
- package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +109 -0
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +123 -120
- package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +83 -0
- package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +91 -0
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +144 -147
- package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +126 -0
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +89 -64
- package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +102 -0
- package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +94 -0
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +159 -150
- package/plugins/gates/hooks/gates/risk-level/index.mjs +263 -203
- package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +116 -0
- package/plugins/gates/hooks/gates/risk-level/test.mjs +153 -125
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +56 -50
- package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +58 -0
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +116 -111
- package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +97 -0
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +184 -99
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +126 -0
- package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +130 -0
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -251
- package/plugins/gates/hooks/gates/stop-pending/index.mjs +159 -0
- package/plugins/gates/hooks/gates/stop-pending/test.mjs +114 -0
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -101
- package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +72 -0
- package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +63 -0
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -181
- package/plugins/gates/hooks/gates/test-matrix/test.mjs +114 -87
- package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +62 -0
- package/plugins/gates/hooks/gates/tool-map/index.mjs +143 -140
- package/plugins/gates/hooks/hooks.json +346 -286
- package/plugins/gates/hooks/lib/__tests__/config.test.mjs +173 -154
- package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +210 -154
- package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +114 -0
- package/plugins/gates/hooks/lib/config.mjs +172 -165
- package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +189 -0
- package/plugins/gates/hooks/lib/hook-io.mjs +318 -208
- package/plugins/gates/hooks/lib/signals.mjs +127 -0
- package/plugins/gates/hooks/wiring-check.mjs +227 -0
- package/plugins/tasks/.claude-plugin/plugin.json +14 -0
- package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +100 -0
- package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +95 -0
- package/plugins/tasks/hooks/hooks.json +26 -0
- package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +180 -132
- package/plugins/tasks/hooks/lib/task-store.mjs +197 -159
- package/plugins/tasks/hooks/register-requests.mjs +141 -108
- package/plugins/tasks/hooks/session-tasks.mjs +108 -0
- 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,
|
|
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 (!
|
|
52
|
-
|
|
53
|
-
const filePath = 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.
|
|
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.
|
package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs
ADDED
|
@@ -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
|
+
});
|