@devrik-tools/claude-gates 0.7.2 → 0.9.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 +3 -3
- package/README.es.md +129 -11
- package/README.md +120 -10
- package/cli/doctor.mjs +132 -0
- package/cli/evidence.mjs +78 -0
- package/cli/hooks-manifest.mjs +89 -0
- package/cli/index.mjs +124 -6
- package/cli/init.mjs +97 -3
- package/cli/log.mjs +70 -0
- package/cli/materialize.mjs +36 -2
- package/cli/registry.mjs +12 -1
- package/cli/selection.mjs +18 -0
- package/cli/smoke-fixtures.json +159 -25
- package/cli/task.mjs +104 -11
- package/cli/toggle.mjs +125 -0
- package/package.json +4 -3
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/doctor.mjs +47 -1
- package/plugins/gates/hooks/gates/atomic-commit/index.mjs +92 -119
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +101 -66
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -8
- package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +13 -64
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +186 -163
- package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +100 -101
- package/plugins/gates/hooks/gates/brief-approved/index.mjs +71 -140
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +56 -137
- package/plugins/gates/hooks/gates/capability-map/index.mjs +280 -506
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +94 -215
- package/plugins/gates/hooks/gates/circuit-breaker/track.mjs +285 -0
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
- package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
- package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
- package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
- package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
- package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +53 -95
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
- package/plugins/gates/hooks/gates/library-docs/index.mjs +338 -0
- package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
- package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +48 -95
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
- package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
- package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
- package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
- package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
- package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
- package/plugins/gates/hooks/gates/no-trivial-scripts/index.mjs +114 -0
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
- package/plugins/gates/hooks/gates/require-monitor/index.mjs +126 -0
- package/plugins/gates/hooks/gates/require-task-split/index.mjs +88 -0
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
- package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -97
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
- package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
- package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
- package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
- package/plugins/gates/hooks/hooks.json +152 -42
- package/plugins/gates/hooks/lib/config.mjs +6 -3
- package/plugins/gates/hooks/lib/delegation.mjs +352 -0
- package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
- package/plugins/gates/hooks/lib/git.mjs +262 -0
- package/plugins/gates/hooks/lib/hook-io.mjs +451 -83
- package/plugins/gates/hooks/lib/session-state.mjs +150 -0
- package/plugins/gates/hooks/lib/signals.mjs +18 -13
- package/plugins/gates/hooks/lib/testing.mjs +148 -0
- package/plugins/gates/hooks/lib/tools.mjs +144 -0
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
- package/plugins/tasks/hooks/lib/task-store.mjs +12 -6
- package/plugins/tasks/hooks/register-requests.mjs +37 -10
- package/registry.json +249 -38
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// session-state — per-session scratch state for gates that must remember something across
|
|
2
|
+
// the separate process Claude Code spawns for every hook call (a retry counter, a timestamp,
|
|
3
|
+
// a set of libraries already looked up). One implementation, so every stateful gate gets the
|
|
4
|
+
// same guarantees:
|
|
5
|
+
//
|
|
6
|
+
// - The session id is SANITIZED before it becomes a path segment: a payload carrying
|
|
7
|
+
// `../../x` or a number can neither escape the state root nor crash `path.join`.
|
|
8
|
+
// - A missing session id falls back to a bucket keyed by the PROJECT, not one global
|
|
9
|
+
// bucket shared by every session on the machine.
|
|
10
|
+
// - Writes are ATOMIC (temp file + rename), so a concurrent hook process never reads a
|
|
11
|
+
// half-written file.
|
|
12
|
+
// - Stale session directories are PRUNED opportunistically (older than `ttlMs`, default 7
|
|
13
|
+
// days), so %TEMP% does not accumulate one directory per session forever.
|
|
14
|
+
// - A read or write failure degrades to "no state" / "not persisted", never to a throw:
|
|
15
|
+
// a gate that cannot count must not block, and a warn-only gate must never deny
|
|
16
|
+
// because its temp directory was read-only.
|
|
17
|
+
//
|
|
18
|
+
// State lives under os.tmpdir()/claude-gates/<gateId>/<session>/state.json — never a path
|
|
19
|
+
// that bakes in a username or a machine name.
|
|
20
|
+
|
|
21
|
+
import { createHash } from 'node:crypto';
|
|
22
|
+
import {
|
|
23
|
+
existsSync,
|
|
24
|
+
mkdirSync,
|
|
25
|
+
readdirSync,
|
|
26
|
+
readFileSync,
|
|
27
|
+
renameSync,
|
|
28
|
+
rmSync,
|
|
29
|
+
statSync,
|
|
30
|
+
writeFileSync,
|
|
31
|
+
} from 'node:fs';
|
|
32
|
+
import { tmpdir } from 'node:os';
|
|
33
|
+
import { dirname, join } from 'node:path';
|
|
34
|
+
|
|
35
|
+
const STATE_ROOT = join(tmpdir(), 'claude-gates');
|
|
36
|
+
const STATE_FILE = 'state.json';
|
|
37
|
+
const NO_SESSION_PREFIX = 'no-session-';
|
|
38
|
+
const MAX_SEGMENT_LENGTH = 80;
|
|
39
|
+
const HASH_LENGTH = 12;
|
|
40
|
+
const HOURS_PER_DAY = 24;
|
|
41
|
+
const MINUTES_PER_HOUR = 60;
|
|
42
|
+
const SECONDS_PER_MINUTE = 60;
|
|
43
|
+
const MS_PER_SECOND = 1000;
|
|
44
|
+
const MS_PER_DAY =
|
|
45
|
+
HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE * MS_PER_SECOND;
|
|
46
|
+
const DEFAULT_TTL_DAYS = 7;
|
|
47
|
+
const DEFAULT_TTL_MS = DEFAULT_TTL_DAYS * MS_PER_DAY;
|
|
48
|
+
// Pruning scans the gate's directory; doing it on every call would be wasteful, so it runs
|
|
49
|
+
// on roughly one call in this many (cheap, and still bounds growth).
|
|
50
|
+
const PRUNE_EVERY = 25;
|
|
51
|
+
|
|
52
|
+
function shortHash(text) {
|
|
53
|
+
return createHash('sha256')
|
|
54
|
+
.update(String(text))
|
|
55
|
+
.digest('hex')
|
|
56
|
+
.slice(0, HASH_LENGTH);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A filesystem-safe, bounded segment for a session id; project-keyed when absent. */
|
|
60
|
+
export function sessionSegmentFor(sessionId, cwd = process.cwd()) {
|
|
61
|
+
const raw = String(sessionId ?? '').trim();
|
|
62
|
+
if (!raw) return `${NO_SESSION_PREFIX}${shortHash(cwd)}`;
|
|
63
|
+
const safe = raw.replace(/[^\w-]/g, '_');
|
|
64
|
+
return safe.length > MAX_SEGMENT_LENGTH
|
|
65
|
+
? `${safe.slice(0, MAX_SEGMENT_LENGTH - HASH_LENGTH - 1)}-${shortHash(raw)}`
|
|
66
|
+
: safe;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Where a gate's state for this session lives. */
|
|
70
|
+
export function stateFileFor(gateId, sessionId, { cwd = process.cwd() } = {}) {
|
|
71
|
+
return join(
|
|
72
|
+
STATE_ROOT,
|
|
73
|
+
gateId,
|
|
74
|
+
sessionSegmentFor(sessionId, cwd),
|
|
75
|
+
STATE_FILE,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The persisted state object, or `fallback` when absent/corrupt/unreadable. */
|
|
80
|
+
export function readSessionState(gateId, sessionId, fallback = {}, options) {
|
|
81
|
+
const path = stateFileFor(gateId, sessionId, options);
|
|
82
|
+
if (!existsSync(path)) return fallback;
|
|
83
|
+
try {
|
|
84
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
85
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
86
|
+
? parsed
|
|
87
|
+
: fallback;
|
|
88
|
+
} catch {
|
|
89
|
+
return fallback;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function pruneStale(gateDirectory, ttlMs) {
|
|
94
|
+
try {
|
|
95
|
+
const cutoff = Date.now() - ttlMs;
|
|
96
|
+
for (const entry of readdirSync(gateDirectory, { withFileTypes: true })) {
|
|
97
|
+
if (!entry.isDirectory()) continue;
|
|
98
|
+
const directory = join(gateDirectory, entry.name);
|
|
99
|
+
const file = join(directory, STATE_FILE);
|
|
100
|
+
const stamp = existsSync(file)
|
|
101
|
+
? statSync(file).mtimeMs
|
|
102
|
+
: statSync(directory).mtimeMs;
|
|
103
|
+
if (stamp < cutoff) rmSync(directory, { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// Pruning is housekeeping; failing at it never affects the gate.
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Persists `state` atomically. Returns true when written, false when the write failed (the
|
|
112
|
+
* caller decides what "cannot persist" means for its rule — usually "cannot count").
|
|
113
|
+
*/
|
|
114
|
+
export function writeSessionState(
|
|
115
|
+
gateId,
|
|
116
|
+
sessionId,
|
|
117
|
+
state,
|
|
118
|
+
{ cwd = process.cwd(), ttlMs = DEFAULT_TTL_MS } = {},
|
|
119
|
+
) {
|
|
120
|
+
const path = stateFileFor(gateId, sessionId, { cwd });
|
|
121
|
+
const directory = dirname(path);
|
|
122
|
+
try {
|
|
123
|
+
mkdirSync(directory, { recursive: true });
|
|
124
|
+
const temporary = join(directory, `${STATE_FILE}.${process.pid}.tmp`);
|
|
125
|
+
writeFileSync(temporary, JSON.stringify(state), 'utf8');
|
|
126
|
+
renameSync(temporary, path);
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
// Deterministic sampling on the clock, not Math.random: roughly one write in PRUNE_EVERY.
|
|
131
|
+
if (Date.now() % PRUNE_EVERY === 0) pruneStale(dirname(directory), ttlMs);
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Read-modify-write in one call: `update(previous)` returns the next state, which is then
|
|
137
|
+
* persisted. Returns { state, persisted }.
|
|
138
|
+
*/
|
|
139
|
+
export function updateSessionState(
|
|
140
|
+
gateId,
|
|
141
|
+
sessionId,
|
|
142
|
+
fallback,
|
|
143
|
+
update,
|
|
144
|
+
options,
|
|
145
|
+
) {
|
|
146
|
+
const previous = readSessionState(gateId, sessionId, fallback, options);
|
|
147
|
+
const next = update(previous);
|
|
148
|
+
const persisted = writeSessionState(gateId, sessionId, next, options);
|
|
149
|
+
return { state: next, persisted };
|
|
150
|
+
}
|
|
@@ -85,7 +85,7 @@ export const RISK_SIGNAL_SOURCES = [
|
|
|
85
85
|
// unsafe to trust this label/verb at all?").
|
|
86
86
|
const MUTATION_RISK_TERMS =
|
|
87
87
|
`${MONEY_TERMS}|${AUTH_TERMS}|credencial|credential|data|datos|borrar|delete|drop|` +
|
|
88
|
-
`write|escrib
|
|
88
|
+
`write|escrib\\p{L}*|${DESTRUCTIVE_DEPLOY_TERMS}`;
|
|
89
89
|
|
|
90
90
|
export const MUTATION_RISK_SIGNAL =
|
|
91
91
|
withUnicodeWordBoundary(MUTATION_RISK_TERMS);
|
|
@@ -120,15 +120,14 @@ export const CONJECTURE = withUnicodeWordBoundary(CONJECTURE_SOURCES.join('|'));
|
|
|
120
120
|
// to <file>" nearby is not flagged as depending on model memory.
|
|
121
121
|
|
|
122
122
|
export const PERSISTENCE_VERB_SOURCES = [
|
|
123
|
-
'
|
|
124
|
-
'
|
|
125
|
-
'escrib
|
|
126
|
-
'
|
|
123
|
+
'sav(?:e|es|ed|ing)|persist\\p{L}*|stor(?:e|es|ed|ing)',
|
|
124
|
+
'guard\\p{L}*|almacen\\p{L}*',
|
|
125
|
+
'escrib\\p{L}* .{0,20}en|writ(?:e|es|ing) .{0,20}(?:to|in)',
|
|
126
|
+
'anot(?:a|á|ar|alo|en)|registr(?:a|á|ar|alo|en)',
|
|
127
127
|
];
|
|
128
128
|
|
|
129
|
-
export const PERSISTENCE_VERB =
|
|
129
|
+
export const PERSISTENCE_VERB = withUnicodeWordBoundary(
|
|
130
130
|
PERSISTENCE_VERB_SOURCES.join('|'),
|
|
131
|
-
'iu',
|
|
132
131
|
);
|
|
133
132
|
|
|
134
133
|
// ── BUILD_INTENT: a request to CREATE a tool/helper (reuse-before-build) ─────────────
|
|
@@ -162,11 +161,17 @@ const BUILD_INTENT_MAX_GAP = 24;
|
|
|
162
161
|
* followed by a tool noun), in Spanish or English. A method, not a bare regex, so each side
|
|
163
162
|
* stays a small pattern and the "verb → noun proximity" rule is explicit. */
|
|
164
163
|
export function isBuildIntent(text) {
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
verbMatch.index + verbMatch[0].length,
|
|
169
|
-
verbMatch.index + verbMatch[0].length + BUILD_INTENT_MAX_GAP,
|
|
164
|
+
const source = String(text ?? '');
|
|
165
|
+
const verbs = source.matchAll(
|
|
166
|
+
new RegExp(BUILD_VERB.source, `${BUILD_VERB.flags}g`),
|
|
170
167
|
);
|
|
171
|
-
|
|
168
|
+
for (const verbMatch of verbs) {
|
|
169
|
+
const start = verbMatch.index + verbMatch[0].length;
|
|
170
|
+
// Extend the window to the end of the word it lands in, so a noun is never cut in half
|
|
171
|
+
// ("components" must not match as "component" merely because the window ended there).
|
|
172
|
+
let end = start + BUILD_INTENT_MAX_GAP;
|
|
173
|
+
while (end < source.length && /[\p{L}\p{N}_]/u.test(source[end])) end += 1;
|
|
174
|
+
if (BUILDABLE_NOUN.test(source.slice(start, end))) return true;
|
|
175
|
+
}
|
|
176
|
+
return false;
|
|
172
177
|
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// testing.mjs — the harness every gate test uses to run a gate exactly as Claude Code does:
|
|
2
|
+
// as its own process, payload on stdin, inside a scratch project so config and state lookups
|
|
3
|
+
// are isolated. One copy here instead of the same twenty lines pasted into every test file.
|
|
4
|
+
//
|
|
5
|
+
// Not imported by any gate at runtime; Node built-ins only.
|
|
6
|
+
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A scratch project: a temp directory with a `.git` marker (so it is a project root), an
|
|
14
|
+
* optional `.ai/config.json`, and any extra files (`{ 'relative/path': content }`).
|
|
15
|
+
*/
|
|
16
|
+
export function makeProject({
|
|
17
|
+
prefix = 'gate-',
|
|
18
|
+
git = true,
|
|
19
|
+
config,
|
|
20
|
+
files = {},
|
|
21
|
+
} = {}) {
|
|
22
|
+
const project = mkdtempSync(join(tmpdir(), prefix));
|
|
23
|
+
if (git) mkdirSync(join(project, '.git'));
|
|
24
|
+
if (config) {
|
|
25
|
+
mkdirSync(join(project, '.ai'), { recursive: true });
|
|
26
|
+
writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
|
|
27
|
+
}
|
|
28
|
+
for (const [relative, content] of Object.entries(files)) {
|
|
29
|
+
const path = join(project, relative);
|
|
30
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
31
|
+
writeFileSync(path, content);
|
|
32
|
+
}
|
|
33
|
+
return project;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Runs a gate script against a payload and returns its parsed JSON output, or null when
|
|
38
|
+
* the gate allowed silently. `project` (or a fresh one built from `config`/`files`) is the
|
|
39
|
+
* cwd and the HOME, so global config never leaks in. The decision log is disabled unless
|
|
40
|
+
* `log` is true. Throws when the gate process exits non-zero (a crash is a test failure).
|
|
41
|
+
*/
|
|
42
|
+
export function runGateProcess(
|
|
43
|
+
gatePath,
|
|
44
|
+
payload,
|
|
45
|
+
{
|
|
46
|
+
project,
|
|
47
|
+
config,
|
|
48
|
+
files,
|
|
49
|
+
cwd,
|
|
50
|
+
environment = {},
|
|
51
|
+
log = false,
|
|
52
|
+
timeout = 15000,
|
|
53
|
+
} = {},
|
|
54
|
+
) {
|
|
55
|
+
const root = project ?? makeProject({ config, files });
|
|
56
|
+
const options = {
|
|
57
|
+
input: typeof payload === 'string' ? payload : JSON.stringify(payload),
|
|
58
|
+
encoding: 'utf8',
|
|
59
|
+
cwd: cwd ?? root,
|
|
60
|
+
env: {
|
|
61
|
+
...process.env,
|
|
62
|
+
HOME: root,
|
|
63
|
+
USERPROFILE: root,
|
|
64
|
+
CLAUDE_GATES_LOG: log ? '1' : '0',
|
|
65
|
+
...environment,
|
|
66
|
+
},
|
|
67
|
+
timeout,
|
|
68
|
+
};
|
|
69
|
+
try {
|
|
70
|
+
const out = execFileSync(process.execPath, [gatePath], options);
|
|
71
|
+
const trimmed = out.trim();
|
|
72
|
+
return trimmed ? JSON.parse(trimmed) : null;
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error.status === 2) {
|
|
75
|
+
const stderr = String(error.stderr ?? '').trim();
|
|
76
|
+
const stdout = String(error.stdout ?? '').trim();
|
|
77
|
+
const source = stderr || stdout;
|
|
78
|
+
return source ? JSON.parse(source) : null;
|
|
79
|
+
}
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 'deny' | 'warn' | 'block' | null from a gate's parsed output. */
|
|
85
|
+
export function decisionOf(result) {
|
|
86
|
+
if (!result) return null;
|
|
87
|
+
if (result.hookSpecificOutput?.permissionDecision === 'deny') return 'deny';
|
|
88
|
+
if (result.decision === 'block') return 'block';
|
|
89
|
+
if (typeof result.hookSpecificOutput?.additionalContext === 'string')
|
|
90
|
+
return 'warn';
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function isDeny(result) {
|
|
95
|
+
return decisionOf(result) === 'deny';
|
|
96
|
+
}
|
|
97
|
+
export function isWarn(result) {
|
|
98
|
+
return decisionOf(result) === 'warn';
|
|
99
|
+
}
|
|
100
|
+
export function isBlock(result) {
|
|
101
|
+
return decisionOf(result) === 'block';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The deny reason / warn context / block reason text, or ''. */
|
|
105
|
+
export function messageOf(result) {
|
|
106
|
+
return (
|
|
107
|
+
result?.hookSpecificOutput?.permissionDecisionReason ??
|
|
108
|
+
result?.hookSpecificOutput?.additionalContext ??
|
|
109
|
+
result?.reason ??
|
|
110
|
+
''
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Payload builders ────────────────────────────────────────────────────────────────
|
|
115
|
+
export function bash(command, extra = {}) {
|
|
116
|
+
return { tool_name: 'Bash', tool_input: { command, ...extra } };
|
|
117
|
+
}
|
|
118
|
+
export function write(filePath, content = '', extra = {}) {
|
|
119
|
+
return {
|
|
120
|
+
tool_name: 'Write',
|
|
121
|
+
tool_input: { file_path: filePath, content, ...extra },
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
export function edit(filePath, newString, oldString = '', extra = {}) {
|
|
125
|
+
return {
|
|
126
|
+
tool_name: 'Edit',
|
|
127
|
+
tool_input: {
|
|
128
|
+
file_path: filePath,
|
|
129
|
+
old_string: oldString,
|
|
130
|
+
new_string: newString,
|
|
131
|
+
...extra,
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
export function delegate(prompt, subagentType, extra = {}) {
|
|
136
|
+
const input = { prompt, ...extra };
|
|
137
|
+
if (subagentType !== undefined) input.subagent_type = subagentType;
|
|
138
|
+
return { tool_name: 'Agent', tool_input: input };
|
|
139
|
+
}
|
|
140
|
+
export function ask(question, options = [], extra = {}) {
|
|
141
|
+
return {
|
|
142
|
+
tool_name: 'AskUserQuestion',
|
|
143
|
+
tool_input: { questions: [{ question, options, ...extra }] },
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
export function withSession(payload, sessionId) {
|
|
147
|
+
return { ...payload, session_id: sessionId };
|
|
148
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// tools.mjs — what counts as a "tool/helper" file and what counts as evidence that the
|
|
2
|
+
// wheel was checked before building one. Shared by the discovery gates (reuse-before-build,
|
|
3
|
+
// tool-map, audit-before-build), which previously carried three divergent copies of these
|
|
4
|
+
// lists and three different audit-evidence regexes — so a build audited in Spanish was
|
|
5
|
+
// allowed by one gate and never recorded by the other.
|
|
6
|
+
//
|
|
7
|
+
// Self-contained: Node built-ins only (plus signals.mjs, this plugin's own).
|
|
8
|
+
|
|
9
|
+
import { withUnicodeWordBoundary } from './signals.mjs';
|
|
10
|
+
|
|
11
|
+
/** Folder names (path segments) that mark a file as a tool/helper. */
|
|
12
|
+
export const DEFAULT_TOOL_FOLDERS = [
|
|
13
|
+
'scripts',
|
|
14
|
+
'hooks',
|
|
15
|
+
'tools',
|
|
16
|
+
'gates',
|
|
17
|
+
'lib',
|
|
18
|
+
'utils',
|
|
19
|
+
'util',
|
|
20
|
+
'helpers',
|
|
21
|
+
'composables',
|
|
22
|
+
'components',
|
|
23
|
+
'services',
|
|
24
|
+
'shared',
|
|
25
|
+
'common',
|
|
26
|
+
'plugins',
|
|
27
|
+
'bin',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/** Extensions of code that can reinvent a wheel. */
|
|
31
|
+
export const DEFAULT_TOOL_EXTENSIONS = [
|
|
32
|
+
'.mjs',
|
|
33
|
+
'.cjs',
|
|
34
|
+
'.js',
|
|
35
|
+
'.ts',
|
|
36
|
+
'.tsx',
|
|
37
|
+
'.jsx',
|
|
38
|
+
'.vue',
|
|
39
|
+
'.svelte',
|
|
40
|
+
'.py',
|
|
41
|
+
'.sh',
|
|
42
|
+
'.ps1',
|
|
43
|
+
'.go',
|
|
44
|
+
'.rs',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/** Regex sources; a new file whose basename matches any is a helper regardless of folder. */
|
|
48
|
+
export const DEFAULT_TOOL_NAME_PATTERNS = [
|
|
49
|
+
String.raw`^use[A-Z]\w*\.`,
|
|
50
|
+
String.raw`(?:^|[-.])(?:helper|helpers|util|utils|service|wrapper|adapter|client|gate|hook|plugin)\.`,
|
|
51
|
+
String.raw`[A-Z]\w*(?:Helper|Util|Utils|Service|Wrapper|Adapter|Client)\.`,
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
/** File-name markers of a test or fixture: never a tool to reuse. */
|
|
55
|
+
const TEST_FILE_PATTERNS = [
|
|
56
|
+
/(?:^|\/)(?:__tests__|__mocks__|__fixtures__|tests?|specs?|e2e|cypress)\//i,
|
|
57
|
+
/\.(?:test|spec|stories)\.[\w.]+$/i,
|
|
58
|
+
/(?:^|\/)test\.mjs$/i,
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/** Whether a path is a test/spec/fixture file. */
|
|
62
|
+
export function isTestPath(path) {
|
|
63
|
+
const normalized = String(path ?? '').replace(/\\/g, '/');
|
|
64
|
+
return TEST_FILE_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function segmentsOf(path) {
|
|
68
|
+
return String(path ?? '')
|
|
69
|
+
.replace(/\\/g, '/')
|
|
70
|
+
.split('/')
|
|
71
|
+
.filter(Boolean);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Whether any path SEGMENT (not substring) is one of `folders`, case-insensitively. */
|
|
75
|
+
export function isUnderToolFolder(path, folders = DEFAULT_TOOL_FOLDERS) {
|
|
76
|
+
const wanted = new Set(folders.map((folder) => String(folder).toLowerCase()));
|
|
77
|
+
return segmentsOf(path)
|
|
78
|
+
.slice(0, -1)
|
|
79
|
+
.some((segment) => wanted.has(segment.toLowerCase()));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Whether the basename matches any of the tool-name patterns (invalid ones skipped). */
|
|
83
|
+
export function hasToolName(path, patterns = DEFAULT_TOOL_NAME_PATTERNS) {
|
|
84
|
+
const base = segmentsOf(path).at(-1) ?? '';
|
|
85
|
+
return patterns.some((source) => {
|
|
86
|
+
try {
|
|
87
|
+
return new RegExp(source).test(base);
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Whether the path's extension is one of `extensions` (each with its leading dot). */
|
|
95
|
+
export function hasToolExtension(path, extensions = DEFAULT_TOOL_EXTENSIONS) {
|
|
96
|
+
const lowered = String(path ?? '').toLowerCase();
|
|
97
|
+
return extensions.some((extension) =>
|
|
98
|
+
lowered.endsWith(String(extension).toLowerCase()),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Whether a written path is a tool/helper worth a reuse check: code by extension, not a
|
|
104
|
+
* test, and either under a tool folder or named like a helper.
|
|
105
|
+
*/
|
|
106
|
+
export function isToolPath(
|
|
107
|
+
path,
|
|
108
|
+
{
|
|
109
|
+
folders = DEFAULT_TOOL_FOLDERS,
|
|
110
|
+
extensions = DEFAULT_TOOL_EXTENSIONS,
|
|
111
|
+
namePatterns = DEFAULT_TOOL_NAME_PATTERNS,
|
|
112
|
+
} = {},
|
|
113
|
+
) {
|
|
114
|
+
if (!path || !hasToolExtension(path, extensions)) return false;
|
|
115
|
+
if (isTestPath(path)) return false;
|
|
116
|
+
return isUnderToolFolder(path, folders) || hasToolName(path, namePatterns);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Evidence that the wheel was checked, Spanish and English: a sentence stating that no
|
|
120
|
+
// existing tool/helper covers this, or that the existing ones were audited/reviewed.
|
|
121
|
+
const AUDIT_DONE_SOURCES = [
|
|
122
|
+
'no existing (?:tool|helper|script|gate|hook|utility|module|library|dependency)',
|
|
123
|
+
'no (?:tool|helper|script|gate|hook|utility) (?:covers|does|exists|handles)',
|
|
124
|
+
'nothing (?:existing|in the (?:repo|codebase|map)) covers',
|
|
125
|
+
'checked (?:the )?(?:tool map|existing (?:tools|helpers|code)|whether)',
|
|
126
|
+
'audited (?:the )?(?:tool map|existing|dependencies)',
|
|
127
|
+
'reuse (?:check|audit) done',
|
|
128
|
+
'justification:',
|
|
129
|
+
'no existe (?:una |un |ninguna |ning[uú]n )?(?:herramienta|helper|script|gate|hook|utilidad|m[oó]dulo|librer[ií]a|dependencia)',
|
|
130
|
+
'ning(?:una|[uú]n) (?:herramienta|helper|script|gate|hook|utilidad) (?:cubre|existe|hace|resuelve)',
|
|
131
|
+
'revis(?:é|e|amos|ado) (?:el mapa|las herramientas|el c[oó]digo existente|lo existente)',
|
|
132
|
+
'audit(?:é|e|amos|ado) (?:el mapa|las herramientas|lo existente|las dependencias)',
|
|
133
|
+
'justificaci[oó]n:',
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
/** Matches prose that states the reuse audit was done (ES + EN). */
|
|
137
|
+
export const AUDIT_DONE_PATTERN = withUnicodeWordBoundary(
|
|
138
|
+
AUDIT_DONE_SOURCES.join('|'),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
/** Whether the text carries an audit statement. */
|
|
142
|
+
export function hasAuditEvidence(text) {
|
|
143
|
+
return AUDIT_DONE_PATTERN.test(String(text ?? ''));
|
|
144
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tasks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Deterministic task tracking for Claude Code: persists tasks the model registers via the CLI, reminds of open tasks on a message counter, and lists active tasks on session start.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Devrik"
|
|
@@ -112,12 +112,13 @@ function closeTask(
|
|
|
112
112
|
if (!TERMINAL_STATUSES.has(status)) {
|
|
113
113
|
return { error: `invalid terminal status: ${status}` };
|
|
114
114
|
}
|
|
115
|
-
if (STATUS_REQUIRING_EVIDENCE.has(status) &&
|
|
115
|
+
if (STATUS_REQUIRING_EVIDENCE.has(status) && evidence?.verified !== true) {
|
|
116
116
|
return {
|
|
117
117
|
error:
|
|
118
|
-
'closing a task as done requires evidence
|
|
119
|
-
'(
|
|
120
|
-
'
|
|
118
|
+
'closing a task as done requires VERIFIED evidence: a command that passed ' +
|
|
119
|
+
'(--check "<command>" [--expect <text>]) or a path that exists (--exists <path> ' +
|
|
120
|
+
'[--contains <text>]). Free text is not evidence. Close it as abandoned with a ' +
|
|
121
|
+
'reason if it will not be finished.',
|
|
121
122
|
};
|
|
122
123
|
}
|
|
123
124
|
const activeCollection = readCollection(activePath);
|
|
@@ -128,8 +129,7 @@ function closeTask(
|
|
|
128
129
|
task.status = status;
|
|
129
130
|
task.closedAt = new Date().toISOString();
|
|
130
131
|
task.closeReason = reason ?? '';
|
|
131
|
-
if (STATUS_REQUIRING_EVIDENCE.has(status))
|
|
132
|
-
task.evidence = String(evidence).trim();
|
|
132
|
+
if (STATUS_REQUIRING_EVIDENCE.has(status)) task.evidence = evidence;
|
|
133
133
|
|
|
134
134
|
const historyCollection = readCollection(historyPath);
|
|
135
135
|
historyCollection.tasks.push(task);
|
|
@@ -170,6 +170,12 @@ export function openTaskStore(startDirectory) {
|
|
|
170
170
|
writeCollection(activePath, collection);
|
|
171
171
|
return task;
|
|
172
172
|
},
|
|
173
|
+
/** Active sub-tasks whose parentId equals the given id. */
|
|
174
|
+
childrenOf(id) {
|
|
175
|
+
return readCollection(activePath).tasks.filter(
|
|
176
|
+
(task) => task.parentId === id,
|
|
177
|
+
);
|
|
178
|
+
},
|
|
173
179
|
/** Merges fields into the active task with matching id. Null if not found. */
|
|
174
180
|
update(id, fields) {
|
|
175
181
|
const collection = readCollection(activePath);
|
|
@@ -77,17 +77,44 @@ function readPayload() {
|
|
|
77
77
|
|
|
78
78
|
// Asked on every message. The hook does not classify — it asks the model to, and the
|
|
79
79
|
// model is the one that persists (via the CLI, which enforces the store's own rules,
|
|
80
|
-
// e.g. evidence on close).
|
|
81
|
-
//
|
|
82
|
-
// judgment call (whether this message describes a new task), same shape as ask-adoption.
|
|
80
|
+
// e.g. evidence on close). Default to registering: the USER defines what is a task, not
|
|
81
|
+
// the model. The model must not use judgment to skip what the user considers actionable.
|
|
83
82
|
const CLASSIFY_PROMPT =
|
|
84
|
-
|
|
85
|
-
'
|
|
86
|
-
'
|
|
87
|
-
'
|
|
88
|
-
|
|
89
|
-
'
|
|
90
|
-
'
|
|
83
|
+
"[tasks] MANDATORY — before writing your reply, you MUST register the user's message as a " +
|
|
84
|
+
'task unless it is UNAMBIGUOUSLY one of these: (a) pure small talk with no request ("hello", ' +
|
|
85
|
+
'"thanks"), (b) a yes/no answer to a question YOU asked, (c) a message that says only "continue" ' +
|
|
86
|
+
'or "go ahead". Everything else is a task — including questions that require research, review ' +
|
|
87
|
+
'requests, error reports, follow-ups that add scope, corrections, and messages with multiple ' +
|
|
88
|
+
'requests (register one task per distinct request). DEFAULT TO REGISTERING: when in doubt, ' +
|
|
89
|
+
'register.\n\n' +
|
|
90
|
+
'VERIFICATION REQUIRED: every task MUST include a deterministic verification criterion. Use ' +
|
|
91
|
+
'one of these:\n' +
|
|
92
|
+
' --verify-command "<shell command>" [--verify-expect <text>] (command must exit 0 when done)\n' +
|
|
93
|
+
' --verify-path <file-or-dir> [--verify-contains <text>] (must exist when done)\n' +
|
|
94
|
+
'Examples:\n' +
|
|
95
|
+
' claude-gates task add "Fix login bug" --verify-command "npm test -- --grep login" --verify-expect "passing"\n' +
|
|
96
|
+
' claude-gates task add "Add config file" --verify-path "src/config.ts" --verify-contains "export"\n' +
|
|
97
|
+
'Pick the criterion that a machine can check: a test that passes, a file that exists, a grep ' +
|
|
98
|
+
'that matches. If the task is a question/research, use --verify-path for the file where the ' +
|
|
99
|
+
'answer will be written, or --verify-command "claude-gates task list" --verify-expect "done".\n\n' +
|
|
100
|
+
'SPLITTING (Depth Tree): tasks with size medium or larger MUST be split into sub-tasks before ' +
|
|
101
|
+
'implementation. SCOPE FIRST: if the task description is vague or you are unsure what files or ' +
|
|
102
|
+
'modules are affected, ASK THE USER to clarify the scope before splitting — do not guess. ' +
|
|
103
|
+
'Once scope is clear:\n' +
|
|
104
|
+
' 1. Each sub-task OWNS specific files (state in --description "OWNS: <paths>") — no overlap\n' +
|
|
105
|
+
' 2. Each sub-task is --size small and independently verifiable\n' +
|
|
106
|
+
' 3. Split at natural boundaries: one module, one function, one test file\n' +
|
|
107
|
+
' 4. Register parent first, then sub-tasks with --parent <parent-id>\n' +
|
|
108
|
+
'Example:\n' +
|
|
109
|
+
' claude-gates task add "Refactor auth" --size large --verify-command "npm test" --verify-expect "passing"\n' +
|
|
110
|
+
' claude-gates task add "Extract token validation" --parent <id> --size small ' +
|
|
111
|
+
'--verify-path "src/auth/validate.ts" --description "OWNS: src/auth/validate.ts"\n' +
|
|
112
|
+
' claude-gates task add "Add token refresh" --parent <id> --size small ' +
|
|
113
|
+
'--verify-command "npm test -- --grep refresh" --description "OWNS: src/auth/refresh.ts"\n\n' +
|
|
114
|
+
'Run `claude-gates task add "<title>" [--description <text>] [--size <size>] --verify-command|--verify-path ...` ' +
|
|
115
|
+
"from the project root (or the CLI's absolute path if `claude-gates` is not on PATH). Do not defer " +
|
|
116
|
+
'this, do not decide to register it "later", do not silently skip it because the answer seems ' +
|
|
117
|
+
"obvious. The user's flow takes priority over your judgment of what deserves tracking.";
|
|
91
118
|
|
|
92
119
|
function formatReminder(tasks) {
|
|
93
120
|
const shown = tasks.slice(0, MAX_TASKS_SHOWN);
|