@devrik-tools/claude-gates 0.7.2 → 0.8.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 +2 -2
- package/README.es.md +69 -9
- package/README.md +68 -7
- 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 +11 -1
- package/cli/selection.mjs +18 -0
- package/cli/smoke-fixtures.json +114 -22
- package/cli/task.mjs +36 -8
- package/cli/toggle.mjs +125 -0
- package/package.json +1 -1
- 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 +98 -212
- 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 +51 -92
- 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 +262 -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/protected-paths/index.mjs +137 -90
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
- 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 +112 -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 +448 -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 +137 -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 +6 -6
- package/registry.json +204 -36
|
@@ -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,137 @@
|
|
|
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 out = execFileSync(process.execPath, [gatePath], {
|
|
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
|
+
const trimmed = out.trim();
|
|
70
|
+
return trimmed ? JSON.parse(trimmed) : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 'deny' | 'warn' | 'block' | null from a gate's parsed output. */
|
|
74
|
+
export function decisionOf(result) {
|
|
75
|
+
if (!result) return null;
|
|
76
|
+
if (result.hookSpecificOutput?.permissionDecision === 'deny') return 'deny';
|
|
77
|
+
if (result.decision === 'block') return 'block';
|
|
78
|
+
if (typeof result.hookSpecificOutput?.additionalContext === 'string')
|
|
79
|
+
return 'warn';
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function isDeny(result) {
|
|
84
|
+
return decisionOf(result) === 'deny';
|
|
85
|
+
}
|
|
86
|
+
export function isWarn(result) {
|
|
87
|
+
return decisionOf(result) === 'warn';
|
|
88
|
+
}
|
|
89
|
+
export function isBlock(result) {
|
|
90
|
+
return decisionOf(result) === 'block';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The deny reason / warn context / block reason text, or ''. */
|
|
94
|
+
export function messageOf(result) {
|
|
95
|
+
return (
|
|
96
|
+
result?.hookSpecificOutput?.permissionDecisionReason ??
|
|
97
|
+
result?.hookSpecificOutput?.additionalContext ??
|
|
98
|
+
result?.reason ??
|
|
99
|
+
''
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Payload builders ────────────────────────────────────────────────────────────────
|
|
104
|
+
export function bash(command, extra = {}) {
|
|
105
|
+
return { tool_name: 'Bash', tool_input: { command, ...extra } };
|
|
106
|
+
}
|
|
107
|
+
export function write(filePath, content = '', extra = {}) {
|
|
108
|
+
return {
|
|
109
|
+
tool_name: 'Write',
|
|
110
|
+
tool_input: { file_path: filePath, content, ...extra },
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
export function edit(filePath, newString, oldString = '', extra = {}) {
|
|
114
|
+
return {
|
|
115
|
+
tool_name: 'Edit',
|
|
116
|
+
tool_input: {
|
|
117
|
+
file_path: filePath,
|
|
118
|
+
old_string: oldString,
|
|
119
|
+
new_string: newString,
|
|
120
|
+
...extra,
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
export function delegate(prompt, subagentType, extra = {}) {
|
|
125
|
+
const input = { prompt, ...extra };
|
|
126
|
+
if (subagentType !== undefined) input.subagent_type = subagentType;
|
|
127
|
+
return { tool_name: 'Agent', tool_input: input };
|
|
128
|
+
}
|
|
129
|
+
export function ask(question, options = [], extra = {}) {
|
|
130
|
+
return {
|
|
131
|
+
tool_name: 'AskUserQuestion',
|
|
132
|
+
tool_input: { questions: [{ question, options, ...extra }] },
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
export function withSession(payload, sessionId) {
|
|
136
|
+
return { ...payload, session_id: sessionId };
|
|
137
|
+
}
|
|
@@ -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.8.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);
|