@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,78 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
|
|
10
|
+
|
|
11
|
+
function runGate(payload, { config } = {}) {
|
|
12
|
+
const project = mkdtempSync(join(tmpdir(), 'never-assume-'));
|
|
13
|
+
mkdirSync(join(project, '.git'));
|
|
14
|
+
if (config) {
|
|
15
|
+
mkdirSync(join(project, '.ai'));
|
|
16
|
+
writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
|
|
17
|
+
}
|
|
18
|
+
const out = execFileSync(process.execPath, [GATE], {
|
|
19
|
+
input: JSON.stringify(payload),
|
|
20
|
+
encoding: 'utf8',
|
|
21
|
+
cwd: project,
|
|
22
|
+
// Isolate from the user's real global config: point homedir() at the temp
|
|
23
|
+
// project so the global-config fallback finds nothing (registry default).
|
|
24
|
+
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
25
|
+
});
|
|
26
|
+
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function write(content) {
|
|
30
|
+
return {
|
|
31
|
+
tool_name: 'Write',
|
|
32
|
+
tool_input: { file_path: '/repo/src/x.js', content },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function isWarn(result) {
|
|
36
|
+
return result?.hookSpecificOutput?.additionalContext !== undefined;
|
|
37
|
+
}
|
|
38
|
+
function isDeny(result) {
|
|
39
|
+
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ENABLE = {
|
|
43
|
+
config: { gates: { requireVerificationBeforeAssuming: true } },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
test('warns on conjecture phrasing', () => {
|
|
47
|
+
assert.ok(isWarn(runGate(write('// i assume the timezone is UTC'), ENABLE)));
|
|
48
|
+
assert.ok(isWarn(runGate(write('// probably fine to skip this'), ENABLE)));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('never denies, only warns', () => {
|
|
52
|
+
const result = runGate(write('// i assume this works'), ENABLE);
|
|
53
|
+
assert.ok(isWarn(result));
|
|
54
|
+
assert.ok(!isDeny(result));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('allows content without conjecture phrasing', () => {
|
|
58
|
+
assert.equal(runGate(write('const x = 1;'), ENABLE), null);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('disabled by default (registry default is false)', () => {
|
|
62
|
+
assert.equal(runGate(write('// i assume this works')), null);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('project conjecturePatterns override replaces the built-in list', () => {
|
|
66
|
+
const config = {
|
|
67
|
+
gates: {
|
|
68
|
+
requireVerificationBeforeAssuming: {
|
|
69
|
+
enabled: true,
|
|
70
|
+
conjecturePatterns: ['totally made up'],
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
assert.equal(runGate(write('// i assume this works'), { config }), null);
|
|
75
|
+
assert.ok(
|
|
76
|
+
isWarn(runGate(write('// totally made up value here'), { config })),
|
|
77
|
+
);
|
|
78
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// no-blocking — denies syntactic forms of waiting in the foreground (sleeping, polling
|
|
2
|
+
// loops, following unbounded output, foreground dev servers) on a real shell command and
|
|
3
|
+
// on a delegation prompt (a subagent can be told "wait with sleep" in prose). Migrated
|
|
4
|
+
// from ~/.claude/hooks/guard-no-blocking.mjs. Off by default: a project opts in, because
|
|
5
|
+
// many legitimate workflows still need a bounded wait the project accepts as normal.
|
|
6
|
+
//
|
|
7
|
+
// This gate only recognizes SYNTACTIC forms of waiting — it never judges whether other
|
|
8
|
+
// work was available meanwhile, because that is judgment, not a fact a regex can read.
|
|
9
|
+
// A gate that misfires on the legitimate case gets disabled, taking every real catch
|
|
10
|
+
// down with it.
|
|
11
|
+
//
|
|
12
|
+
// ── What a project can configure (params) ───────────────────────────────────────────
|
|
13
|
+
// blockingPatterns regex sources (matched case-insensitively) of blocking/waiting
|
|
14
|
+
// forms to deny. Replaces the built-in list wholesale.
|
|
15
|
+
// waitJustifiedMarker a marker token that, present in the command with a reason,
|
|
16
|
+
// escapes the block — a declared wait is a decision, not an
|
|
17
|
+
// oversight. Replaces the built-in marker wholesale.
|
|
18
|
+
// The defaults live here, in the source, so a project reads them and knows exactly what
|
|
19
|
+
// its override replaces.
|
|
20
|
+
|
|
21
|
+
import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
|
|
22
|
+
|
|
23
|
+
const GATE_ID = 'no-blocking';
|
|
24
|
+
const CONFIG_KEY = 'blockWaitingCommands';
|
|
25
|
+
|
|
26
|
+
const SHELL_TOOLS = new Set(TOOL_GROUPS.shell);
|
|
27
|
+
const DELEGATION_TOOLS = new Set(TOOL_GROUPS.delegation);
|
|
28
|
+
|
|
29
|
+
const DEFAULT_WAIT_JUSTIFIED_MARKER = 'WAIT-JUSTIFIED:';
|
|
30
|
+
|
|
31
|
+
// systemd's log-follow command, assembled from fragments so the spell checker does not
|
|
32
|
+
// read it as prose (the project keeps an empty dictionary by policy).
|
|
33
|
+
const SYSTEMD_LOG_COMMAND = 'journal' + 'ctl';
|
|
34
|
+
|
|
35
|
+
/** Blocking rules as `[regexSource, reason]`, matched case-insensitively. */
|
|
36
|
+
const DEFAULT_BLOCKING_PATTERNS = [
|
|
37
|
+
[
|
|
38
|
+
String.raw`(^|[|;&]\s*|\bthen\s+|\bdo\s+)(sleep|timeout)\s+\d`,
|
|
39
|
+
'Sleeping in the foreground spends the turn doing nothing. If waiting for something ' +
|
|
40
|
+
'to finish, launch it in the background and move on to something else; if waiting ' +
|
|
41
|
+
'on an external condition, use a monitor.',
|
|
42
|
+
],
|
|
43
|
+
[
|
|
44
|
+
String.raw`\bStart-Sleep\b`,
|
|
45
|
+
'Start-Sleep freezes the turn. Launch the work in the background and continue with ' +
|
|
46
|
+
'whatever does not depend on it.',
|
|
47
|
+
],
|
|
48
|
+
[
|
|
49
|
+
String.raw`(^|[|;&]\s*)wait\b|--wait\b|\bWait-Process\b|\bWait-Job\b`,
|
|
50
|
+
'Waiting for another process to finish blocks the whole turn. Launch it in the ' +
|
|
51
|
+
'background and consume the result when it arrives, not before.',
|
|
52
|
+
],
|
|
53
|
+
[
|
|
54
|
+
[
|
|
55
|
+
String.raw`\b(tail|`,
|
|
56
|
+
SYSTEMD_LOG_COMMAND,
|
|
57
|
+
String.raw`|kubectl\s+logs|docker\s+logs)\b[^|;&]*\s-{1,2}f\b`,
|
|
58
|
+
].join(''),
|
|
59
|
+
'Following live output never returns and takes the turn with it. Read the file once, ' +
|
|
60
|
+
'or leave the following to a background process.',
|
|
61
|
+
],
|
|
62
|
+
[
|
|
63
|
+
String.raw`(^|[|;&]\s*)watch\s+`,
|
|
64
|
+
"'watch' repeats forever and blocks. Run the command once; if a change genuinely " +
|
|
65
|
+
'needs watching, use a monitor that does not take the turn.',
|
|
66
|
+
],
|
|
67
|
+
[
|
|
68
|
+
String.raw`\b(until|while)\b[^\n]{0,80}\bdo\b[^\n]{0,80}\bsleep\b`,
|
|
69
|
+
'A loop that sleeps waiting for something to change is foreground polling: it blocks ' +
|
|
70
|
+
'and it spins. Use a monitor with the condition, or launch the work in the ' +
|
|
71
|
+
'background and wait for its notification.',
|
|
72
|
+
],
|
|
73
|
+
[
|
|
74
|
+
String.raw`\b(npm|pnpm|yarn|bun)\s+(run\s+)?(dev|start|serve|watch)\b`,
|
|
75
|
+
'A dev server does not end on its own: in the foreground it takes the turn until ' +
|
|
76
|
+
'something kills it. Launch it in the background and keep working while it comes up.',
|
|
77
|
+
],
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/** Marks that a command will not take the turn: backgrounded, detached, or bounded. */
|
|
81
|
+
const NOT_TAKING_THE_TURN =
|
|
82
|
+
/(&\s*$|\bnohup\b|\bstart\s+\/b\b|--detach\b|-d\b|\brun_in_background\b)/i;
|
|
83
|
+
|
|
84
|
+
function compile(source) {
|
|
85
|
+
return new RegExp(source, 'i');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The text to inspect: a real command's command line, or the delegation prompt. */
|
|
89
|
+
function commandTextFrom(toolName, toolInput) {
|
|
90
|
+
if (SHELL_TOOLS.has(toolName)) {
|
|
91
|
+
return String(toolInput.CommandLine ?? toolInput.command ?? '');
|
|
92
|
+
}
|
|
93
|
+
return String(toolInput.prompt ?? toolInput.description ?? '');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
runGate(
|
|
97
|
+
{
|
|
98
|
+
id: GATE_ID,
|
|
99
|
+
configKey: CONFIG_KEY,
|
|
100
|
+
enabledByDefault: false,
|
|
101
|
+
defaultParams: {
|
|
102
|
+
blockingPatterns: DEFAULT_BLOCKING_PATTERNS,
|
|
103
|
+
waitJustifiedMarker: DEFAULT_WAIT_JUSTIFIED_MARKER,
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
({ toolName, toolInput, parameters }) => {
|
|
107
|
+
const isShell = SHELL_TOOLS.has(toolName);
|
|
108
|
+
const isDelegation = DELEGATION_TOOLS.has(toolName);
|
|
109
|
+
if (!isShell && !isDelegation) return;
|
|
110
|
+
|
|
111
|
+
const command = commandTextFrom(toolName, toolInput);
|
|
112
|
+
if (!command.trim()) return;
|
|
113
|
+
|
|
114
|
+
// Already declared in the background: the turn stays free, which is all that matters.
|
|
115
|
+
if (toolInput.run_in_background === true) return;
|
|
116
|
+
if (NOT_TAKING_THE_TURN.test(command)) return;
|
|
117
|
+
|
|
118
|
+
// A declared wait with its reason is a decision, not an oversight.
|
|
119
|
+
const marker = String(parameters.waitJustifiedMarker ?? '');
|
|
120
|
+
if (marker) {
|
|
121
|
+
const escapedMarker = marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
122
|
+
const reasonAfterMarker = String.raw`\s*\S+\s+\S+`;
|
|
123
|
+
const justified = new RegExp(`${escapedMarker}${reasonAfterMarker}`, 'i');
|
|
124
|
+
if (justified.test(command)) return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const blockingPairs = (parameters.blockingPatterns ?? []).map((entry) =>
|
|
128
|
+
Array.isArray(entry)
|
|
129
|
+
? entry
|
|
130
|
+
: [entry, 'Blocking/waiting command is not allowed.'],
|
|
131
|
+
);
|
|
132
|
+
for (const [source, reason] of blockingPairs) {
|
|
133
|
+
if (compile(source).test(command)) {
|
|
134
|
+
deny(
|
|
135
|
+
GATE_ID,
|
|
136
|
+
`${reason} If this wait is genuinely justified, add "${marker} <concrete reason>" ` +
|
|
137
|
+
'to the command and try again. A declared wait is a decision; a silent one is an oversight.',
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
|
|
10
|
+
|
|
11
|
+
// Runs the gate as its own process (it calls process.exit), feeding a payload on stdin
|
|
12
|
+
// from inside a temp project so the gate's config lookup is isolated. Returns the parsed
|
|
13
|
+
// stdout, or null when the gate allowed (no output).
|
|
14
|
+
function runGate(payload, { config } = {}) {
|
|
15
|
+
const project = mkdtempSync(join(tmpdir(), 'no-blocking-'));
|
|
16
|
+
mkdirSync(join(project, '.git'));
|
|
17
|
+
if (config) {
|
|
18
|
+
mkdirSync(join(project, '.ai'));
|
|
19
|
+
writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
|
|
20
|
+
}
|
|
21
|
+
const out = execFileSync(process.execPath, [GATE], {
|
|
22
|
+
input: JSON.stringify(payload),
|
|
23
|
+
encoding: 'utf8',
|
|
24
|
+
cwd: project,
|
|
25
|
+
// Isolate from the user's real global config: point homedir() at the temp
|
|
26
|
+
// project so the global-config fallback finds nothing (registry default).
|
|
27
|
+
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
28
|
+
});
|
|
29
|
+
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ENABLED = { gates: { blockWaitingCommands: true } };
|
|
33
|
+
|
|
34
|
+
function bash(command, extra = {}) {
|
|
35
|
+
return { tool_name: 'Bash', tool_input: { command, ...extra } };
|
|
36
|
+
}
|
|
37
|
+
function delegate(prompt) {
|
|
38
|
+
return { tool_name: 'Agent', tool_input: { prompt } };
|
|
39
|
+
}
|
|
40
|
+
function isDeny(result) {
|
|
41
|
+
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
test('denies sleep in foreground, a dev server, and a polling loop', () => {
|
|
45
|
+
assert.ok(isDeny(runGate(bash('sleep 30'), { config: ENABLED })));
|
|
46
|
+
assert.ok(isDeny(runGate(bash('npm run dev'), { config: ENABLED })));
|
|
47
|
+
assert.ok(
|
|
48
|
+
isDeny(
|
|
49
|
+
runGate(bash('until curl -sf localhost:3000; do sleep 2; done'), {
|
|
50
|
+
config: ENABLED,
|
|
51
|
+
}),
|
|
52
|
+
),
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('allows an innocuous command and a backgrounded long-runner', () => {
|
|
57
|
+
assert.equal(runGate(bash('git status'), { config: ENABLED }), null);
|
|
58
|
+
assert.equal(
|
|
59
|
+
runGate(bash('npm run dev', { run_in_background: true }), {
|
|
60
|
+
config: ENABLED,
|
|
61
|
+
}),
|
|
62
|
+
null,
|
|
63
|
+
);
|
|
64
|
+
assert.equal(runGate(bash('sleep 30 &'), { config: ENABLED }), null);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('disabled by default: the gate does not run without opting in', () => {
|
|
68
|
+
assert.equal(runGate(bash('sleep 30')), null);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('a delegation prompt telling the subagent to sleep-wait is denied', () => {
|
|
72
|
+
assert.ok(
|
|
73
|
+
isDeny(
|
|
74
|
+
runGate(
|
|
75
|
+
delegate('Please then sleep 60 seconds until the server is ready.'),
|
|
76
|
+
{
|
|
77
|
+
config: ENABLED,
|
|
78
|
+
},
|
|
79
|
+
),
|
|
80
|
+
),
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('the wait-justified marker with a reason escapes the block', () => {
|
|
85
|
+
assert.equal(
|
|
86
|
+
runGate(
|
|
87
|
+
bash('sleep 30 # WAIT-JUSTIFIED: waiting for migration lock release'),
|
|
88
|
+
{
|
|
89
|
+
config: ENABLED,
|
|
90
|
+
},
|
|
91
|
+
),
|
|
92
|
+
null,
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('project blockingPatterns replaces the built-in list', () => {
|
|
97
|
+
const config = {
|
|
98
|
+
gates: {
|
|
99
|
+
blockWaitingCommands: {
|
|
100
|
+
enabled: true,
|
|
101
|
+
blockingPatterns: [String.raw`\bcustom-block\b`],
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
// sleep no longer denied under the override
|
|
106
|
+
assert.equal(runGate(bash('sleep 30'), { config }), null);
|
|
107
|
+
assert.ok(isDeny(runGate(bash('custom-block now'), { config })));
|
|
108
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// no-memory-dependency — warns (never denies) when a delegation prompt leans on the
|
|
2
|
+
// subagent "remembering" something said earlier in conversation instead of carrying
|
|
3
|
+
// the data itself. A fresh subagent has no access to the delegator's conversation
|
|
4
|
+
// history: if the data matters, it must travel IN the prompt, in a file the subagent
|
|
5
|
+
// reads, or in an already-persisted decision.
|
|
6
|
+
//
|
|
7
|
+
// ── Two stages, same pattern as intent-flow ─────────────────────────────────────────
|
|
8
|
+
// Stage 1 (cheap): does any memory-dependency phrase appear anywhere? If not, allow.
|
|
9
|
+
// Stage 2 (confirmation, only on candidates): strip quoted text, then discard a phrase
|
|
10
|
+
// that has a deterministic persistence instruction nearby (a file, flag, env var, gate)
|
|
11
|
+
// — "remember to save this to notes.md" does not depend on model memory, it depends on
|
|
12
|
+
// a real file. This is a soft signal (warn), not a hard block: whether the brief truly
|
|
13
|
+
// needs the data or is just referencing prior agreement needs judgment this gate can't
|
|
14
|
+
// supply on its own.
|
|
15
|
+
|
|
16
|
+
import { runGate, warn, TOOL_GROUPS } from '../../lib/hook-io.mjs';
|
|
17
|
+
|
|
18
|
+
const GATE_ID = 'no-memory-dependency';
|
|
19
|
+
const CONFIG_KEY = 'warnMemoryDependencyInBrief';
|
|
20
|
+
|
|
21
|
+
const DELEGATION_TOOLS = new Set(TOOL_GROUPS.delegation);
|
|
22
|
+
|
|
23
|
+
const DEFAULT_MEMORY_DEPENDENCY_PATTERNS = [
|
|
24
|
+
'acordate de|acu[eé]rdate de',
|
|
25
|
+
'no olvides|no te olvides',
|
|
26
|
+
'record[aá] que|ten[eé] en cuenta que',
|
|
27
|
+
'como te dije|como ya te dije|ya te dije|te lo dije antes|ya te ped[ií]',
|
|
28
|
+
"remember to|don'?t forget|keep in mind",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
// Split into two simpler alternations (tested with plain OR at call time) instead of
|
|
32
|
+
// one large regex: each half stays well under the complexity/backtracking budget a
|
|
33
|
+
// single combined pattern would hit.
|
|
34
|
+
const PERSISTENCE_NOUN_PATTERN =
|
|
35
|
+
/archivo|file|flag|variable de entorno|env var|gate determinista|deterministic gate|\.md|\.json/i;
|
|
36
|
+
// Kept deliberately flat (no nested optional groups) to stay under the regex-complexity
|
|
37
|
+
// budget: a handful of plain alternatives rather than one clever pattern with backtracking.
|
|
38
|
+
const PERSISTENCE_VERB_PATTERN =
|
|
39
|
+
/guardal[oa]|guarda|guardá esto|persisti|persiste|persistir|persistido|escribil[oa] en|escribi en|save it in|save this in|save it to|save this to/i;
|
|
40
|
+
|
|
41
|
+
const PERSISTENCE_WINDOW = 80;
|
|
42
|
+
|
|
43
|
+
function withUnicodeWordBoundary(alternatives) {
|
|
44
|
+
return new RegExp(
|
|
45
|
+
`(?<![\\p{L}\\p{N}_])(${alternatives})(?![\\p{L}\\p{N}_])`,
|
|
46
|
+
'iu',
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function stripQuoted(text) {
|
|
51
|
+
return text
|
|
52
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
53
|
+
.replace(/"[^"\n]{0,300}"/g, ' ')
|
|
54
|
+
.replace(/'[^'\n]{0,300}'/g, ' ');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function allMatches(pattern, text) {
|
|
58
|
+
const flags = pattern.flags.includes('g')
|
|
59
|
+
? pattern.flags
|
|
60
|
+
: `${pattern.flags}g`;
|
|
61
|
+
return [...text.matchAll(new RegExp(pattern.source, flags))];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function hasPersistenceInstructionNearby(text, match) {
|
|
65
|
+
const from = Math.max(0, match.index - PERSISTENCE_WINDOW);
|
|
66
|
+
const to = Math.min(
|
|
67
|
+
text.length,
|
|
68
|
+
match.index + match[0].length + PERSISTENCE_WINDOW,
|
|
69
|
+
);
|
|
70
|
+
const window = text.slice(from, to);
|
|
71
|
+
return (
|
|
72
|
+
PERSISTENCE_NOUN_PATTERN.test(window) ||
|
|
73
|
+
PERSISTENCE_VERB_PATTERN.test(window)
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function unresolvedMemoryPhrases(prompt, memoryPattern) {
|
|
78
|
+
if (!memoryPattern.test(prompt)) return []; // stage 1: no candidate, allow cheap
|
|
79
|
+
|
|
80
|
+
const text = stripQuoted(prompt); // stage 2(a)
|
|
81
|
+
const matches = allMatches(memoryPattern, text);
|
|
82
|
+
|
|
83
|
+
return matches
|
|
84
|
+
.filter((match) => !hasPersistenceInstructionNearby(text, match))
|
|
85
|
+
.map((match) => match[0].trim());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
runGate(
|
|
89
|
+
{
|
|
90
|
+
id: GATE_ID,
|
|
91
|
+
configKey: CONFIG_KEY,
|
|
92
|
+
enabledByDefault: false,
|
|
93
|
+
defaultParams: {
|
|
94
|
+
memoryDependencyPatterns: DEFAULT_MEMORY_DEPENDENCY_PATTERNS,
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
({ toolName, toolInput, parameters }) => {
|
|
98
|
+
if (!DELEGATION_TOOLS.has(toolName)) return;
|
|
99
|
+
|
|
100
|
+
const prompt = String(
|
|
101
|
+
toolInput.prompt ?? toolInput.description ?? toolInput.task ?? '',
|
|
102
|
+
);
|
|
103
|
+
if (!prompt.trim()) return;
|
|
104
|
+
|
|
105
|
+
const memoryPattern = withUnicodeWordBoundary(
|
|
106
|
+
(parameters.memoryDependencyPatterns ?? []).join('|'),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const phrases = unresolvedMemoryPhrases(prompt, memoryPattern);
|
|
110
|
+
if (phrases.length === 0) return;
|
|
111
|
+
|
|
112
|
+
const quotedPhrases = phrases.map((phrase) => `"${phrase}"`).join(', ');
|
|
113
|
+
warn(
|
|
114
|
+
GATE_ID,
|
|
115
|
+
`This delegation depends on the model remembering something (${quotedPhrases}). ` +
|
|
116
|
+
'If it matters, it should live in a file, a flag, an environment variable, or a deterministic gate — ' +
|
|
117
|
+
"not in the model's memory.",
|
|
118
|
+
);
|
|
119
|
+
},
|
|
120
|
+
);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
|
|
10
|
+
|
|
11
|
+
function runGate(payload, { config } = {}) {
|
|
12
|
+
const project = mkdtempSync(join(tmpdir(), 'no-memory-dependency-'));
|
|
13
|
+
mkdirSync(join(project, '.git'));
|
|
14
|
+
if (config) {
|
|
15
|
+
mkdirSync(join(project, '.ai'));
|
|
16
|
+
writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
|
|
17
|
+
}
|
|
18
|
+
const out = execFileSync(process.execPath, [GATE], {
|
|
19
|
+
input: JSON.stringify(payload),
|
|
20
|
+
encoding: 'utf8',
|
|
21
|
+
cwd: project,
|
|
22
|
+
// Isolate from the user's real global config: point homedir() at the temp
|
|
23
|
+
// project so the global-config fallback finds nothing (registry default).
|
|
24
|
+
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
25
|
+
});
|
|
26
|
+
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function delegate(prompt) {
|
|
30
|
+
return { tool_name: 'Agent', tool_input: { prompt } };
|
|
31
|
+
}
|
|
32
|
+
function isWarn(result) {
|
|
33
|
+
return typeof result?.hookSpecificOutput?.additionalContext === 'string';
|
|
34
|
+
}
|
|
35
|
+
function isDeny(result) {
|
|
36
|
+
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ENABLED = { config: { gates: { warnMemoryDependencyInBrief: true } } };
|
|
40
|
+
|
|
41
|
+
test('warns (never denies) when the brief leans on remembered context', () => {
|
|
42
|
+
const result = runGate(
|
|
43
|
+
delegate('Acordate de lo que hablamos antes y aplica el mismo criterio.'),
|
|
44
|
+
ENABLED,
|
|
45
|
+
);
|
|
46
|
+
assert.ok(isWarn(result));
|
|
47
|
+
assert.ok(!isDeny(result));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('allows a brief with no memory-dependency phrase', () => {
|
|
51
|
+
assert.equal(
|
|
52
|
+
runGate(
|
|
53
|
+
delegate('Implementa el endpoint de login segun el contrato adjunto.'),
|
|
54
|
+
ENABLED,
|
|
55
|
+
),
|
|
56
|
+
null,
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('allows a memory phrase paired with a deterministic persistence instruction', () => {
|
|
61
|
+
assert.equal(
|
|
62
|
+
runGate(
|
|
63
|
+
delegate(
|
|
64
|
+
'No te olvides de guardar la decision en .ai/decision.md antes de continuar.',
|
|
65
|
+
),
|
|
66
|
+
ENABLED,
|
|
67
|
+
),
|
|
68
|
+
null,
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('disabled by config: the gate does not run', () => {
|
|
73
|
+
assert.equal(
|
|
74
|
+
runGate(
|
|
75
|
+
delegate('Acordate de lo que hablamos antes y aplica el mismo criterio.'),
|
|
76
|
+
{
|
|
77
|
+
config: { gates: { warnMemoryDependencyInBrief: false } },
|
|
78
|
+
},
|
|
79
|
+
),
|
|
80
|
+
null,
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('project memoryDependencyPatterns override replaces the built-in list', () => {
|
|
85
|
+
const config = {
|
|
86
|
+
gates: {
|
|
87
|
+
warnMemoryDependencyInBrief: {
|
|
88
|
+
enabled: true,
|
|
89
|
+
memoryDependencyPatterns: ['como quedamos'],
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
// No longer matches the built-in "acordate de" phrase.
|
|
94
|
+
assert.equal(
|
|
95
|
+
runGate(
|
|
96
|
+
delegate('Acordate de lo que hablamos antes y aplica el mismo criterio.'),
|
|
97
|
+
{ config },
|
|
98
|
+
),
|
|
99
|
+
null,
|
|
100
|
+
);
|
|
101
|
+
assert.ok(
|
|
102
|
+
isWarn(
|
|
103
|
+
runGate(delegate('Hacelo como quedamos la ultima vez.'), { config }),
|
|
104
|
+
),
|
|
105
|
+
);
|
|
106
|
+
});
|