@devrik-tools/claude-gates 0.1.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 +17 -0
- package/README.es.md +219 -0
- package/README.md +222 -0
- package/cli/__tests__/config.test.mjs +101 -0
- package/cli/__tests__/init-flags.test.mjs +111 -0
- package/cli/__tests__/registry-gates-consistency.test.mjs +44 -0
- package/cli/__tests__/selection.test.mjs +200 -0
- package/cli/config.mjs +113 -0
- package/cli/constants.mjs +36 -0
- package/cli/index.mjs +97 -0
- package/cli/init.mjs +256 -0
- package/cli/install.mjs +80 -0
- package/cli/materialize.mjs +51 -0
- package/cli/registry.mjs +127 -0
- package/cli/selection.mjs +83 -0
- package/package.json +73 -0
- package/plugins/gates/.claude-plugin/plugin.json +9 -0
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -0
- package/plugins/gates/hooks/gates/audit-before-build/test.mjs +92 -0
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +233 -0
- package/plugins/gates/hooks/gates/bash-commands/test.mjs +113 -0
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +177 -0
- package/plugins/gates/hooks/gates/brief-before-delegate/test.mjs +111 -0
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +456 -0
- package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +143 -0
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +118 -0
- package/plugins/gates/hooks/gates/dependency-skills/test.mjs +109 -0
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +49 -0
- package/plugins/gates/hooks/gates/diagnosis-before-patch/test.mjs +68 -0
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +100 -0
- package/plugins/gates/hooks/gates/feature-catalog/test.mjs +97 -0
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +112 -0
- package/plugins/gates/hooks/gates/forge-flow/test.mjs +135 -0
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +186 -0
- package/plugins/gates/hooks/gates/implementation-pipeline/test.mjs +86 -0
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +238 -0
- package/plugins/gates/hooks/gates/intent-flow/test.mjs +136 -0
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +166 -0
- package/plugins/gates/hooks/gates/mandatory-flow/test.mjs +119 -0
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +85 -0
- package/plugins/gates/hooks/gates/neutral-spanish/test.mjs +65 -0
- package/plugins/gates/hooks/gates/never-assume/index.mjs +55 -0
- package/plugins/gates/hooks/gates/never-assume/test.mjs +78 -0
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +142 -0
- package/plugins/gates/hooks/gates/no-blocking/test.mjs +108 -0
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +120 -0
- package/plugins/gates/hooks/gates/no-memory-dependency/test.mjs +106 -0
- package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +202 -0
- package/plugins/gates/hooks/gates/no-reconfirm/test.mjs +131 -0
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +147 -0
- package/plugins/gates/hooks/gates/protected-paths/test.mjs +75 -0
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +64 -0
- package/plugins/gates/hooks/gates/recurrence-lock/test.mjs +99 -0
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +150 -0
- package/plugins/gates/hooks/gates/reuse-before-build/test.mjs +101 -0
- package/plugins/gates/hooks/gates/risk-level/index.mjs +203 -0
- package/plugins/gates/hooks/gates/risk-level/test.mjs +125 -0
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +50 -0
- package/plugins/gates/hooks/gates/root-cause-first/test.mjs +73 -0
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +111 -0
- package/plugins/gates/hooks/gates/root-whitelist/test.mjs +80 -0
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +99 -0
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/test.mjs +78 -0
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +251 -0
- package/plugins/gates/hooks/gates/sdd-specs/test.mjs +163 -0
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -0
- package/plugins/gates/hooks/gates/test-after-implementation/test.mjs +81 -0
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +181 -0
- package/plugins/gates/hooks/gates/test-matrix/test.mjs +87 -0
- package/plugins/gates/hooks/gates/tool-map/index.mjs +140 -0
- package/plugins/gates/hooks/gates/tool-map/test.mjs +87 -0
- package/plugins/gates/hooks/hooks.json +266 -0
- package/plugins/gates/hooks/lib/__tests__/config.test.mjs +154 -0
- package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +154 -0
- package/plugins/gates/hooks/lib/config.mjs +165 -0
- package/plugins/gates/hooks/lib/hook-io.mjs +208 -0
- package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +132 -0
- package/plugins/tasks/hooks/lib/task-store.mjs +159 -0
- package/plugins/tasks/hooks/register-requests.mjs +108 -0
- package/registry.json +668 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Runtime gate configuration for the hooks. Self-contained: Node built-ins only, no npm,
|
|
2
|
+
// so it works when the plugin is installed on its own.
|
|
3
|
+
//
|
|
4
|
+
// This is the READER the gates use at runtime. The CLI (`cli/config.mjs`) is the WRITER;
|
|
5
|
+
// the two never import each other. Both agree on the file: `<project root>/.ai/config.json`,
|
|
6
|
+
// with `~/.claude/claude-gates/config.json` as the global fallback.
|
|
7
|
+
//
|
|
8
|
+
// ── Two things a project controls per gate ──────────────────────────────────────────
|
|
9
|
+
// 1. enabled — is the gate on? A gate absent from config keeps the registry default.
|
|
10
|
+
// 2. params — how the gate behaves: its whitelist, its patterns, its watched paths.
|
|
11
|
+
// A param the project declares REPLACES the gate's built-in default wholesale (it
|
|
12
|
+
// does not merge). The gate ships its defaults in its own source so the user can
|
|
13
|
+
// read them and know exactly what to override.
|
|
14
|
+
//
|
|
15
|
+
// ── Config shape a project may write ────────────────────────────────────────────────
|
|
16
|
+
// "gates": {
|
|
17
|
+
// "blockDestructiveShellCommands": true, // shorthand: enabled only
|
|
18
|
+
// "blockWritesToProtectedPaths": { "enabled": true, "protectedPaths": ["...","..."] }
|
|
19
|
+
// }
|
|
20
|
+
// A bare boolean is the enabled-only shorthand the CLI writes today; an object carries
|
|
21
|
+
// enabled plus any params. Both are accepted so an old plain config keeps working.
|
|
22
|
+
|
|
23
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
24
|
+
import { homedir } from 'node:os';
|
|
25
|
+
import { dirname, join } from 'node:path';
|
|
26
|
+
|
|
27
|
+
const CLAUDE_USER_DIRECTORY = '.claude';
|
|
28
|
+
const GLOBAL_STATE_DIRECTORY = 'claude-gates';
|
|
29
|
+
const PROJECT_STATE_DIRECTORY = '.ai';
|
|
30
|
+
const CONFIG_FILE = 'config.json';
|
|
31
|
+
// Markers that identify a project root while climbing. Both `.git` AND `.ai/` count — the
|
|
32
|
+
// same set the CLI uses — so a repo-less project (no git yet) still gets its project config
|
|
33
|
+
// read. Anchoring on `.git` alone silently dropped the project layer in such repos.
|
|
34
|
+
const PROJECT_ROOT_MARKERS = ['.git', PROJECT_STATE_DIRECTORY];
|
|
35
|
+
|
|
36
|
+
function readJsonOrNull(path) {
|
|
37
|
+
if (!existsSync(path)) return null;
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
40
|
+
} catch {
|
|
41
|
+
// A corrupt project config must not silently disable protection: treat it as absent,
|
|
42
|
+
// which falls through to the global config and then to the registry defaults.
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Climbs to the nearest project root (a dir holding `.git` or `.ai/`); null when none. */
|
|
48
|
+
function projectRootOf(startDirectory) {
|
|
49
|
+
let current = startDirectory;
|
|
50
|
+
while (true) {
|
|
51
|
+
if (
|
|
52
|
+
PROJECT_ROOT_MARKERS.some((marker) => existsSync(join(current, marker)))
|
|
53
|
+
) {
|
|
54
|
+
return current;
|
|
55
|
+
}
|
|
56
|
+
const parent = dirname(current);
|
|
57
|
+
if (parent === current) return null;
|
|
58
|
+
current = parent;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function projectConfigPath(startDirectory) {
|
|
63
|
+
const root = projectRootOf(startDirectory);
|
|
64
|
+
if (!root) return null;
|
|
65
|
+
return join(root, PROJECT_STATE_DIRECTORY, CONFIG_FILE);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function globalConfigPath(home) {
|
|
69
|
+
return join(home, CLAUDE_USER_DIRECTORY, GLOBAL_STATE_DIRECTORY, CONFIG_FILE);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The `gates` object from the first config that exists: project first, then global.
|
|
74
|
+
* A project config that exists but lacks a `gates` key still wins (an empty object) —
|
|
75
|
+
* declaring a config is a deliberate act, and falling through to global would silently
|
|
76
|
+
* re-enable what the project meant to turn off.
|
|
77
|
+
*/
|
|
78
|
+
function gatesLayerFor(startDirectory, home) {
|
|
79
|
+
const projectPath = projectConfigPath(startDirectory);
|
|
80
|
+
const projectData = projectPath ? readJsonOrNull(projectPath) : null;
|
|
81
|
+
if (projectData) return projectData.gates ?? {};
|
|
82
|
+
|
|
83
|
+
const globalData = readJsonOrNull(globalConfigPath(home));
|
|
84
|
+
if (globalData) return globalData.gates ?? {};
|
|
85
|
+
|
|
86
|
+
return null; // nothing declared anywhere: gates fall back to registry defaults
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Normalizes a gate's config entry (bool shorthand or object) to a plain object. */
|
|
90
|
+
function gateEntryOf(gatesLayer, configKey) {
|
|
91
|
+
if (!gatesLayer) return null;
|
|
92
|
+
const entry = gatesLayer[configKey];
|
|
93
|
+
if (entry === undefined) return null;
|
|
94
|
+
if (typeof entry === 'boolean') return { enabled: entry };
|
|
95
|
+
if (entry && typeof entry === 'object') return entry;
|
|
96
|
+
return null; // malformed (string/number/null): ignore, fall back to default
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Reads the gates layer once per dispatcher invocation and hands back a lookup the
|
|
101
|
+
* dispatcher and gates share. Reading once matters: N gates in the same tool call must
|
|
102
|
+
* not each re-read and re-parse the file.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} startDirectory usually process.cwd()
|
|
105
|
+
* @returns {{ isEnabled(configKey, registryDefault): boolean,
|
|
106
|
+
* paramsFor(configKey): object }}
|
|
107
|
+
*/
|
|
108
|
+
export function loadGateConfig(startDirectory, { home = homedir() } = {}) {
|
|
109
|
+
const gatesLayer = gatesLayerFor(startDirectory, home);
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
/**
|
|
113
|
+
* A gate runs unless a config explicitly turns it off. `enabled` absent means "use
|
|
114
|
+
* the registry default"; only an explicit `false` disables. When no config exists
|
|
115
|
+
* anywhere, the registry default decides.
|
|
116
|
+
*/
|
|
117
|
+
isEnabled(configKey, registryDefault) {
|
|
118
|
+
const entry = gateEntryOf(gatesLayer, configKey);
|
|
119
|
+
if (!entry || entry.enabled === undefined) return registryDefault;
|
|
120
|
+
return entry.enabled !== false;
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The project's params for a gate (everything except `enabled`), or an empty object
|
|
125
|
+
* when none are declared. The gate merges these over its own built-in defaults —
|
|
126
|
+
* a declared param replaces the corresponding default wholesale.
|
|
127
|
+
*/
|
|
128
|
+
paramsFor(configKey) {
|
|
129
|
+
const entry = gateEntryOf(gatesLayer, configKey);
|
|
130
|
+
if (!entry) return {};
|
|
131
|
+
const parameters = { ...entry };
|
|
132
|
+
delete parameters.enabled;
|
|
133
|
+
return parameters;
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Whether a gate should run, standalone. `registryDefault` decides when the project (and
|
|
140
|
+
* global) config is silent about this gate; only an explicit `enabled: false` turns a gate
|
|
141
|
+
* off. Used by `runGate` at each gate's start.
|
|
142
|
+
*/
|
|
143
|
+
export function isGateEnabled(
|
|
144
|
+
configKey,
|
|
145
|
+
registryDefault,
|
|
146
|
+
startDirectory,
|
|
147
|
+
{ home = homedir() } = {},
|
|
148
|
+
) {
|
|
149
|
+
return loadGateConfig(startDirectory, { home }).isEnabled(
|
|
150
|
+
configKey,
|
|
151
|
+
registryDefault,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A single gate's project params (its whitelist/patterns), standalone. Returns {} when
|
|
157
|
+
* nothing is declared, so the gate falls back to its own built-in defaults.
|
|
158
|
+
*/
|
|
159
|
+
export function gateParameters(
|
|
160
|
+
configKey,
|
|
161
|
+
startDirectory,
|
|
162
|
+
{ home = homedir() } = {},
|
|
163
|
+
) {
|
|
164
|
+
return loadGateConfig(startDirectory, { home }).paramsFor(configKey);
|
|
165
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Process I/O shared by every gate. Self-contained: Node built-ins only, so a gate keeps
|
|
2
|
+
// working when installed outside this repo.
|
|
3
|
+
//
|
|
4
|
+
// There is no dispatcher: Claude Code runs one hook entry per gate natively (each with its
|
|
5
|
+
// own `matcher`, all matching entries in parallel), so each gate is its own process and
|
|
6
|
+
// reads its own stdin. This module holds what every gate needs, none of it gate-specific:
|
|
7
|
+
// 1. Reading and parsing the hook payload Claude Code writes to stdin.
|
|
8
|
+
// 2. Translating the registry's semantic tool groups (write/shell/...) into the concrete
|
|
9
|
+
// tool names Claude Code sends — used to derive each gate's `matcher` and, when a gate
|
|
10
|
+
// also inspects delegation prompts, to recognize the tool at runtime.
|
|
11
|
+
// 3. Reading a deny/warn decision out of a gate's own JSON output shape.
|
|
12
|
+
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { gateParameters, isGateEnabled } from './config.mjs';
|
|
15
|
+
|
|
16
|
+
const STDIN_FILE_DESCRIPTOR = 0;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Semantic tool groups (as declared in registry.json's `tools`) mapped to the concrete
|
|
20
|
+
* tool names Claude Code emits. Keeping the map here (not in the registry) keeps the
|
|
21
|
+
* catalog declarative: which tool names exist is a fact about the Claude Code runtime,
|
|
22
|
+
* not about the gate. `matcherFor` turns a group set into the `Tool1|Tool2` string a
|
|
23
|
+
* hooks.json entry uses; `toolNamesFor` gives the flat list a gate matches at runtime.
|
|
24
|
+
*/
|
|
25
|
+
export const TOOL_GROUPS = Object.freeze({
|
|
26
|
+
write: [
|
|
27
|
+
'Write',
|
|
28
|
+
'Edit',
|
|
29
|
+
'NotebookEdit',
|
|
30
|
+
'write_to_file',
|
|
31
|
+
'replace_file_content',
|
|
32
|
+
],
|
|
33
|
+
shell: ['Bash', 'run_command'],
|
|
34
|
+
delegation: ['Agent', 'Task', 'invoke_subagent'],
|
|
35
|
+
question: ['AskUserQuestion'],
|
|
36
|
+
// The main agent materializing a change directly, whatever the surface.
|
|
37
|
+
execution: [
|
|
38
|
+
'Write',
|
|
39
|
+
'Edit',
|
|
40
|
+
'NotebookEdit',
|
|
41
|
+
'write_to_file',
|
|
42
|
+
'replace_file_content',
|
|
43
|
+
'Bash',
|
|
44
|
+
'run_command',
|
|
45
|
+
'mcp__ide__executeCode',
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/** Every concrete tool name a set of groups expands to, de-duplicated. */
|
|
50
|
+
export function toolNamesFor(groups) {
|
|
51
|
+
const names = new Set();
|
|
52
|
+
for (const group of groups) {
|
|
53
|
+
for (const name of TOOL_GROUPS[group] ?? []) names.add(name);
|
|
54
|
+
}
|
|
55
|
+
return [...names];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The `matcher` string for a hooks.json entry: `Bash|Edit|Write`, or '' when empty. */
|
|
59
|
+
export function matcherFor(groups) {
|
|
60
|
+
return toolNamesFor(groups).join('|');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Reads the raw hook payload from stdin. Returns null when stdin cannot be read. */
|
|
64
|
+
export function readHookPayload() {
|
|
65
|
+
try {
|
|
66
|
+
return readFileSync(STDIN_FILE_DESCRIPTOR, 'utf8');
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The tool name inside a raw payload. Returns null when the payload is unparseable —
|
|
74
|
+
* the caller cannot filter by tool then, so it must run every gate rather than skip
|
|
75
|
+
* silently (erring toward over-running protects; erring toward skipping leaves a mute hole).
|
|
76
|
+
*/
|
|
77
|
+
export function toolNameOf(rawPayload) {
|
|
78
|
+
try {
|
|
79
|
+
const payload = JSON.parse(rawPayload);
|
|
80
|
+
return payload?.tool_name ?? payload?.name ?? '';
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The session id inside a raw payload, or null when absent/unparseable. */
|
|
87
|
+
export function sessionIdOf(rawPayload) {
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(rawPayload)?.session_id ?? null;
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The tool input object inside a raw payload, or {} when absent/unparseable. */
|
|
96
|
+
export function toolInputOf(rawPayload) {
|
|
97
|
+
try {
|
|
98
|
+
const payload = JSON.parse(rawPayload);
|
|
99
|
+
return payload?.tool_input ?? payload?.input ?? {};
|
|
100
|
+
} catch {
|
|
101
|
+
return {};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const PRE_TOOL_USE_EVENT = 'PreToolUse';
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A gate answers Claude Code in exactly one of three ways, and every gate uses these
|
|
109
|
+
* emitters so the JSON shape is written once:
|
|
110
|
+
* - deny: the rule is deterministic and the action is wrong. Blocks the tool call.
|
|
111
|
+
* - warn: the question needs judgment; the gate surfaces context at the right moment
|
|
112
|
+
* (additionalContext) and lets the call proceed.
|
|
113
|
+
* - allow: nothing to say. The common path — silent and cheap.
|
|
114
|
+
* Each emitter exits the process (exit 0 always: a PreToolUse deny is expressed in the
|
|
115
|
+
* JSON, not in the exit code — reserving exit codes keeps a crash distinguishable).
|
|
116
|
+
*
|
|
117
|
+
* `label` is the gate id, prefixed to every message so a block names its source.
|
|
118
|
+
*/
|
|
119
|
+
export function deny(label, reason) {
|
|
120
|
+
process.stdout.write(
|
|
121
|
+
JSON.stringify({
|
|
122
|
+
hookSpecificOutput: {
|
|
123
|
+
hookEventName: PRE_TOOL_USE_EVENT,
|
|
124
|
+
permissionDecision: 'deny',
|
|
125
|
+
permissionDecisionReason: `[${label}] ${reason}`,
|
|
126
|
+
},
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
process.exit(0);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function warn(label, context) {
|
|
133
|
+
process.stdout.write(
|
|
134
|
+
JSON.stringify({
|
|
135
|
+
hookSpecificOutput: {
|
|
136
|
+
hookEventName: PRE_TOOL_USE_EVENT,
|
|
137
|
+
additionalContext: `[${label}] ${context}`,
|
|
138
|
+
},
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
process.exit(0);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function allow() {
|
|
145
|
+
process.exit(0);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The scaffolding every gate shares, so a gate file is just its rule. Given a gate's
|
|
150
|
+
* identity and a `check` function, this: reads stdin (allow when unreadable), turns the
|
|
151
|
+
* gate off when the project config disables it (allow), loads the project's params merged
|
|
152
|
+
* over the gate's own defaults, and runs `check`. `check` calls `deny`/`warn` to object,
|
|
153
|
+
* or returns to allow. A throw inside `check` is caught and turned into a deny — a gate
|
|
154
|
+
* that cannot evaluate must not silently permit.
|
|
155
|
+
*
|
|
156
|
+
* @param {object} gate
|
|
157
|
+
* @param {string} gate.id registry id, used as the message label
|
|
158
|
+
* @param {string} gate.configKey the config flag that enables/disables this gate
|
|
159
|
+
* @param {boolean} gate.enabledByDefault the registry default when config is silent
|
|
160
|
+
* @param {object} [gate.defaultParams] the gate's built-in params (the readable defaults)
|
|
161
|
+
* @param {(context: { rawPayload: string, toolName: string, toolInput: object,
|
|
162
|
+
* sessionId: string|null, parameters: object }) => void} check
|
|
163
|
+
*/
|
|
164
|
+
export async function runGate(gate, check) {
|
|
165
|
+
// Defaults-dump mode: when the CLI spawns a gate with this flag set, the gate prints its
|
|
166
|
+
// own descriptor (id, configKey, default flag and built-in params) and exits — before
|
|
167
|
+
// touching stdin. This lets `init` materialize each gate's defaults into the config it
|
|
168
|
+
// writes, with the gate as the single source of truth (no duplication in the registry).
|
|
169
|
+
if (process.env.CLAUDE_GATES_DUMP_DEFAULTS) {
|
|
170
|
+
process.stdout.write(
|
|
171
|
+
JSON.stringify({
|
|
172
|
+
id: gate.id,
|
|
173
|
+
configKey: gate.configKey,
|
|
174
|
+
enabledByDefault: gate.enabledByDefault,
|
|
175
|
+
defaultParams: gate.defaultParams ?? {},
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
process.exit(0);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const rawPayload = readHookPayload();
|
|
182
|
+
if (rawPayload === null) allow();
|
|
183
|
+
|
|
184
|
+
if (!isGateEnabled(gate.configKey, gate.enabledByDefault, process.cwd()))
|
|
185
|
+
allow();
|
|
186
|
+
|
|
187
|
+
const declared = gateParameters(gate.configKey, process.cwd());
|
|
188
|
+
const parameters = { ...(gate.defaultParams ?? {}), ...declared };
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
// `check` may be sync or async; awaiting a non-promise is transparent, so the same
|
|
192
|
+
// scaffolding serves both. Crucially, allow() runs only AFTER the check settles — an
|
|
193
|
+
// async gate that permits early would never block.
|
|
194
|
+
await check({
|
|
195
|
+
rawPayload,
|
|
196
|
+
toolName: toolNameOf(rawPayload) ?? '',
|
|
197
|
+
toolInput: toolInputOf(rawPayload),
|
|
198
|
+
sessionId: sessionIdOf(rawPayload),
|
|
199
|
+
parameters,
|
|
200
|
+
});
|
|
201
|
+
} catch (error) {
|
|
202
|
+
deny(
|
|
203
|
+
gate.id,
|
|
204
|
+
`The gate failed to evaluate and blocks the action for safety: ${error?.message ?? error}.`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
allow();
|
|
208
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {
|
|
3
|
+
mkdtempSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { test } from 'node:test';
|
|
12
|
+
import { openTaskStore, STATUS } from '../task-store.mjs';
|
|
13
|
+
|
|
14
|
+
// A temp project with a .git marker, so the store anchors its .ai/tasks/ there.
|
|
15
|
+
function makeProject() {
|
|
16
|
+
const project = mkdtempSync(join(tmpdir(), 'task-store-'));
|
|
17
|
+
mkdirSync(join(project, '.git'));
|
|
18
|
+
return project;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function sampleTask(id) {
|
|
22
|
+
return {
|
|
23
|
+
id,
|
|
24
|
+
title: `task ${id}`,
|
|
25
|
+
description: 'desc',
|
|
26
|
+
status: STATUS.OPEN,
|
|
27
|
+
size: 'trivial',
|
|
28
|
+
createdAt: new Date().toISOString(),
|
|
29
|
+
messages: [],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test('openTaskStore anchors at the nearest project marker (.ai/ counts, not only .git)', () => {
|
|
34
|
+
// A project rooted only by .ai/ (no .git) must still resolve — the repo-less case.
|
|
35
|
+
const project = mkdtempSync(join(tmpdir(), 'ai-only-'));
|
|
36
|
+
mkdirSync(join(project, '.ai'));
|
|
37
|
+
const nested = join(project, 'src', 'deep');
|
|
38
|
+
mkdirSync(nested, { recursive: true });
|
|
39
|
+
const store = openTaskStore(nested);
|
|
40
|
+
assert.ok(
|
|
41
|
+
store,
|
|
42
|
+
'store resolves from a nested dir under an .ai/-rooted project',
|
|
43
|
+
);
|
|
44
|
+
assert.equal(store.root, project);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('add and active: a new task shows up in active', () => {
|
|
48
|
+
const store = openTaskStore(makeProject());
|
|
49
|
+
store.add(sampleTask('t1'));
|
|
50
|
+
const active = store.active();
|
|
51
|
+
assert.equal(active.length, 1);
|
|
52
|
+
assert.equal(active[0].id, 't1');
|
|
53
|
+
assert.deepEqual(store.history(), []);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('update merges fields into the active task', () => {
|
|
57
|
+
const store = openTaskStore(makeProject());
|
|
58
|
+
store.add(sampleTask('t1'));
|
|
59
|
+
store.update('t1', { status: STATUS.BLOCKED, note: 'waiting' });
|
|
60
|
+
const task = store.active()[0];
|
|
61
|
+
assert.equal(task.status, STATUS.BLOCKED);
|
|
62
|
+
assert.equal(task.note, 'waiting');
|
|
63
|
+
assert.equal(store.update('missing', {}), null);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('close MOVES a task from active to history, never deletes', () => {
|
|
67
|
+
const store = openTaskStore(makeProject());
|
|
68
|
+
store.add(sampleTask('t1'));
|
|
69
|
+
store.add(sampleTask('t2'));
|
|
70
|
+
|
|
71
|
+
const closed = store.close('t1', STATUS.DONE, 'finished');
|
|
72
|
+
assert.equal(closed.status, STATUS.DONE);
|
|
73
|
+
assert.equal(closed.closeReason, 'finished');
|
|
74
|
+
assert.ok(closed.closedAt);
|
|
75
|
+
|
|
76
|
+
assert.deepEqual(
|
|
77
|
+
store.active().map((task) => task.id),
|
|
78
|
+
['t2'],
|
|
79
|
+
);
|
|
80
|
+
assert.deepEqual(
|
|
81
|
+
store.history().map((task) => task.id),
|
|
82
|
+
['t1'],
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('abandon is a terminal close: moves to history with reason', () => {
|
|
87
|
+
const store = openTaskStore(makeProject());
|
|
88
|
+
store.add(sampleTask('t1'));
|
|
89
|
+
store.close('t1', STATUS.ABANDONED, 'no longer needed');
|
|
90
|
+
assert.equal(store.active().length, 0);
|
|
91
|
+
assert.equal(store.history()[0].status, STATUS.ABANDONED);
|
|
92
|
+
assert.equal(store.history()[0].closeReason, 'no longer needed');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('close rejects a non-terminal status and an unknown id', () => {
|
|
96
|
+
const store = openTaskStore(makeProject());
|
|
97
|
+
store.add(sampleTask('t1'));
|
|
98
|
+
assert.equal(store.close('t1', STATUS.OPEN, 'x'), null);
|
|
99
|
+
assert.equal(store.close('missing', STATUS.DONE, 'x'), null);
|
|
100
|
+
assert.equal(store.active().length, 1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('counter persists and reads back', () => {
|
|
104
|
+
const store = openTaskStore(makeProject());
|
|
105
|
+
assert.equal(store.counter(), 0);
|
|
106
|
+
store.setCounter(7);
|
|
107
|
+
assert.equal(store.counter(), 7);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('history is append-only across multiple closes', () => {
|
|
111
|
+
const store = openTaskStore(makeProject());
|
|
112
|
+
store.add(sampleTask('t1'));
|
|
113
|
+
store.add(sampleTask('t2'));
|
|
114
|
+
store.close('t1', STATUS.DONE, 'a');
|
|
115
|
+
store.close('t2', STATUS.ABANDONED, 'b');
|
|
116
|
+
assert.deepEqual(
|
|
117
|
+
store.history().map((task) => task.id),
|
|
118
|
+
['t1', 't2'],
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('a corrupt active.json is treated as empty, then healed by a write', () => {
|
|
123
|
+
const project = makeProject();
|
|
124
|
+
const store = openTaskStore(project);
|
|
125
|
+
mkdirSync(join(project, '.ai', 'tasks'), { recursive: true });
|
|
126
|
+
const path = join(project, '.ai', 'tasks', 'active.json');
|
|
127
|
+
writeFileSync(path, '{broken');
|
|
128
|
+
assert.deepEqual(store.active(), []);
|
|
129
|
+
store.add(sampleTask('t1'));
|
|
130
|
+
assert.ok(existsSync(path));
|
|
131
|
+
assert.equal(JSON.parse(readFileSync(path, 'utf8')).tasks.length, 1);
|
|
132
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// The task store: the single source of truth for a project's tasks, on disk under
|
|
2
|
+
// .ai/tasks/. Self-contained (Node built-ins only) so a hook can use it when the plugin is
|
|
3
|
+
// installed on its own.
|
|
4
|
+
//
|
|
5
|
+
// justification: no existing tool covers this. Audited LOCAL deps (find-up, commander,
|
|
6
|
+
// @clack/prompts, zod — none is a task store), CONTEXT7 (generic JSON-store libs), and WEB
|
|
7
|
+
// (victor-software-house/task-tracker-plugin and Claude Code's native task manager both
|
|
8
|
+
// persist tasks and survive compaction, but neither distills tasks from chat, none is a
|
|
9
|
+
// zero-runtime-dependency library, and none fits the active/history + git-root shape here).
|
|
10
|
+
// The generic persistence is a solved pattern; this store is the thin, dependency-free,
|
|
11
|
+
// domain-specific piece the value layer (distil + remind + unlazy) sits on.
|
|
12
|
+
//
|
|
13
|
+
// Two files, mirroring the harness-sdd model:
|
|
14
|
+
// active.json { tasks: [ {id, title, description, status, size, createdAt, messages[]} ] }
|
|
15
|
+
// history.json { tasks: [ ...same shape + closedAt + closeReason ] } — append-only
|
|
16
|
+
// A task is never deleted: done and abandoned tasks MOVE from active to history, so the
|
|
17
|
+
// record of what was decided (and dropped) is never lost.
|
|
18
|
+
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { dirname, join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
const TASKS_DIR = join('.ai', 'tasks');
|
|
23
|
+
const ACTIVE_FILE = 'active.json';
|
|
24
|
+
const HISTORY_FILE = 'history.json';
|
|
25
|
+
const COUNTER_FILE = 'counter.json';
|
|
26
|
+
const JSON_INDENT = 2;
|
|
27
|
+
// A project root holds `.git` or `.ai/` — the same set the CLI and the gates config use, so
|
|
28
|
+
// a repo-less project (no git yet) still resolves. Anchoring on `.git` alone drops it.
|
|
29
|
+
const PROJECT_ROOT_MARKERS = ['.git', '.ai'];
|
|
30
|
+
|
|
31
|
+
/** Statuses a task can hold. Active: open/blocked. Terminal (moved to history): done/abandoned. */
|
|
32
|
+
export const STATUS = Object.freeze({
|
|
33
|
+
OPEN: 'open',
|
|
34
|
+
BLOCKED: 'blocked',
|
|
35
|
+
DONE: 'done',
|
|
36
|
+
ABANDONED: 'abandoned',
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const TERMINAL_STATUSES = new Set([STATUS.DONE, STATUS.ABANDONED]);
|
|
40
|
+
|
|
41
|
+
/** Climbs to the nearest project root (a dir holding `.git` or `.ai/`); null when none. */
|
|
42
|
+
export function projectRootOf(startDirectory) {
|
|
43
|
+
let current = startDirectory;
|
|
44
|
+
while (true) {
|
|
45
|
+
if (
|
|
46
|
+
PROJECT_ROOT_MARKERS.some((marker) => existsSync(join(current, marker)))
|
|
47
|
+
) {
|
|
48
|
+
return current;
|
|
49
|
+
}
|
|
50
|
+
const parent = dirname(current);
|
|
51
|
+
if (parent === current) return null;
|
|
52
|
+
current = parent;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readCollection(path) {
|
|
57
|
+
if (!existsSync(path)) return { tasks: [] };
|
|
58
|
+
try {
|
|
59
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
60
|
+
return Array.isArray(parsed.tasks) ? parsed : { tasks: [] };
|
|
61
|
+
} catch {
|
|
62
|
+
return { tasks: [] };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function writeCollection(path, collection) {
|
|
67
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
68
|
+
writeFileSync(
|
|
69
|
+
path,
|
|
70
|
+
`${JSON.stringify(collection, null, JSON_INDENT)}\n`,
|
|
71
|
+
'utf8',
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Opens the store rooted at a project. All paths derive from the project root's .ai/tasks/.
|
|
77
|
+
* Returns a handle with read/mutate operations; each mutation persists immediately so a
|
|
78
|
+
* crash between calls never loses a recorded task. Null when there is no project (no .git).
|
|
79
|
+
*/
|
|
80
|
+
export function openTaskStore(startDirectory) {
|
|
81
|
+
const root = projectRootOf(startDirectory);
|
|
82
|
+
if (!root) return null;
|
|
83
|
+
|
|
84
|
+
const directory = join(root, TASKS_DIR);
|
|
85
|
+
const activePath = join(directory, ACTIVE_FILE);
|
|
86
|
+
const historyPath = join(directory, HISTORY_FILE);
|
|
87
|
+
const counterPath = join(directory, COUNTER_FILE);
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
root,
|
|
91
|
+
/** Open (non-terminal) tasks. */
|
|
92
|
+
active() {
|
|
93
|
+
return readCollection(activePath).tasks;
|
|
94
|
+
},
|
|
95
|
+
/** Terminal (done/abandoned) tasks, newest last. */
|
|
96
|
+
history() {
|
|
97
|
+
return readCollection(historyPath).tasks;
|
|
98
|
+
},
|
|
99
|
+
/** Adds a task to active and returns it. `id` is caller-supplied (stable, human-readable). */
|
|
100
|
+
add(task) {
|
|
101
|
+
const collection = readCollection(activePath);
|
|
102
|
+
collection.tasks.push(task);
|
|
103
|
+
writeCollection(activePath, collection);
|
|
104
|
+
return task;
|
|
105
|
+
},
|
|
106
|
+
/** Merges fields into the active task with matching id. Null if not found. */
|
|
107
|
+
update(id, fields) {
|
|
108
|
+
const collection = readCollection(activePath);
|
|
109
|
+
const task = collection.tasks.find((entry) => entry.id === id);
|
|
110
|
+
if (!task) return null;
|
|
111
|
+
Object.assign(task, fields);
|
|
112
|
+
writeCollection(activePath, collection);
|
|
113
|
+
return task;
|
|
114
|
+
},
|
|
115
|
+
/**
|
|
116
|
+
* Closes an active task: sets a terminal status + closedAt + closeReason, then MOVES it
|
|
117
|
+
* from active to history (append-only). Nothing is deleted. Returns the closed task, or
|
|
118
|
+
* null when the id or status is invalid.
|
|
119
|
+
*/
|
|
120
|
+
close(id, status, reason) {
|
|
121
|
+
if (!TERMINAL_STATUSES.has(status)) return null;
|
|
122
|
+
const activeCollection = readCollection(activePath);
|
|
123
|
+
const index = activeCollection.tasks.findIndex(
|
|
124
|
+
(entry) => entry.id === id,
|
|
125
|
+
);
|
|
126
|
+
if (index === -1) return null;
|
|
127
|
+
|
|
128
|
+
const [task] = activeCollection.tasks.splice(index, 1);
|
|
129
|
+
task.status = status;
|
|
130
|
+
task.closedAt = new Date().toISOString();
|
|
131
|
+
task.closeReason = reason ?? '';
|
|
132
|
+
|
|
133
|
+
const historyCollection = readCollection(historyPath);
|
|
134
|
+
historyCollection.tasks.push(task);
|
|
135
|
+
|
|
136
|
+
writeCollection(historyPath, historyCollection);
|
|
137
|
+
writeCollection(activePath, activeCollection);
|
|
138
|
+
return task;
|
|
139
|
+
},
|
|
140
|
+
/** The message counter since the last reminder (0 when unset or unreadable). */
|
|
141
|
+
counter() {
|
|
142
|
+
if (!existsSync(counterPath)) return 0;
|
|
143
|
+
try {
|
|
144
|
+
return JSON.parse(readFileSync(counterPath, 'utf8')).count ?? 0;
|
|
145
|
+
} catch {
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
/** Sets the message counter. */
|
|
150
|
+
setCounter(count) {
|
|
151
|
+
mkdirSync(directory, { recursive: true });
|
|
152
|
+
writeFileSync(
|
|
153
|
+
counterPath,
|
|
154
|
+
`${JSON.stringify({ count }, null, JSON_INDENT)}\n`,
|
|
155
|
+
'utf8',
|
|
156
|
+
);
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|