@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
|
@@ -1,28 +1,44 @@
|
|
|
1
|
+
// neutral-spanish — regional Spanish (voseo, Rioplatense lexicon) is denied in Spanish
|
|
2
|
+
// prose. Only prose is scanned: a text-type file, or content carrying enough Spanish
|
|
3
|
+
// function words to be Spanish — so an English "Author: Dale Carnegie" in code is not a
|
|
4
|
+
// marker hit. A deliberate quote/fixture/log opts out with the escape-hatch marker.
|
|
5
|
+
|
|
6
|
+
import { extname } from 'node:path';
|
|
1
7
|
import {
|
|
2
8
|
runGate,
|
|
3
9
|
deny,
|
|
4
10
|
toolInGroups,
|
|
5
11
|
writtenContentOf,
|
|
12
|
+
writtenPathOf,
|
|
13
|
+
escapeRegExp,
|
|
6
14
|
} from '../../lib/hook-io.mjs';
|
|
15
|
+
import { withUnicodeWordBoundary } from '../../lib/signals.mjs';
|
|
7
16
|
|
|
8
17
|
const GATE_ID = 'neutral-spanish';
|
|
9
18
|
const CONFIG_KEY = 'warnNonNeutralSpanish';
|
|
10
19
|
|
|
11
20
|
const MAX_REPORTED_MARKERS = 8;
|
|
12
|
-
|
|
13
|
-
// Escape hatch: when a written file legitimately needs regional text (a literal quote, a
|
|
14
|
-
// test fixture, a captured log, a data sample), placing this marker anywhere in the content
|
|
15
|
-
// tells the gate the regional wording is intentional and lets the write through. A deny (as
|
|
16
|
-
// opposed to the old warn) cannot "advise and let pass", so a legitimate case needs an
|
|
17
|
-
// explicit, greppable opt-out — a marker the author writes on purpose, never one the agent
|
|
18
|
-
// could infer. A project can override it via config (escapeHatch).
|
|
21
|
+
const MIN_SPANISH_FUNCTION_WORDS = 3;
|
|
19
22
|
const DEFAULT_ESCAPE_HATCH = 'neutral-spanish:allow';
|
|
23
|
+
const DEFAULT_TEXT_EXTENSIONS = [
|
|
24
|
+
'.md',
|
|
25
|
+
'.mdx',
|
|
26
|
+
'.txt',
|
|
27
|
+
'.rst',
|
|
28
|
+
'.html',
|
|
29
|
+
'.htm',
|
|
30
|
+
'.adoc',
|
|
31
|
+
'.json',
|
|
32
|
+
'.yml',
|
|
33
|
+
'.yaml',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const SPANISH_FUNCTION_WORD = withUnicodeWordBoundary(
|
|
37
|
+
'que|de|la|el|los|las|para|con|una|por|como|también|está|más|este|esta|hay|muy|sin',
|
|
38
|
+
);
|
|
20
39
|
|
|
21
40
|
// Data strings, not identifiers — never add these to a cSpell dictionary.
|
|
22
41
|
// neutral-spanish:allow — this list IS the marker data; the gate must not deny its own source.
|
|
23
|
-
// Now that this gate DENIES (not warns), a marker must be unambiguously regional. Tokens that
|
|
24
|
-
// also occur in neutral Spanish were removed: "de una" (matches "de una lista/vez"), "allá"
|
|
25
|
-
// (standard across the whole language) — a false positive here blocks a legitimate write.
|
|
26
42
|
const DEFAULT_REGIONAL_MARKERS = [
|
|
27
43
|
'tenés',
|
|
28
44
|
'podés',
|
|
@@ -54,9 +70,34 @@ const DEFAULT_REGIONAL_MARKERS = [
|
|
|
54
70
|
'capaz que', // neutral-spanish:allow (marker data, not prose)
|
|
55
71
|
];
|
|
56
72
|
|
|
57
|
-
function
|
|
58
|
-
|
|
59
|
-
|
|
73
|
+
function isSpanishProse(text) {
|
|
74
|
+
const global = new RegExp(
|
|
75
|
+
SPANISH_FUNCTION_WORD.source,
|
|
76
|
+
`${SPANISH_FUNCTION_WORD.flags}g`,
|
|
77
|
+
);
|
|
78
|
+
const distinct = new Set();
|
|
79
|
+
for (const match of text.matchAll(global)) {
|
|
80
|
+
distinct.add(match[0].toLowerCase());
|
|
81
|
+
if (distinct.size >= MIN_SPANISH_FUNCTION_WORDS) return true;
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function hasTextExtension(path, textExtensions) {
|
|
87
|
+
const extension = extname(path).toLowerCase();
|
|
88
|
+
return textExtensions.some(
|
|
89
|
+
(candidate) => String(candidate).toLowerCase() === extension,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function markerHits(text, markers) {
|
|
94
|
+
const hits = [];
|
|
95
|
+
for (const marker of markers) {
|
|
96
|
+
if (typeof marker !== 'string' || marker.length === 0) continue;
|
|
97
|
+
if (withUnicodeWordBoundary(escapeRegExp(marker)).test(text))
|
|
98
|
+
hits.push(marker);
|
|
99
|
+
}
|
|
100
|
+
return hits;
|
|
60
101
|
}
|
|
61
102
|
|
|
62
103
|
runGate(
|
|
@@ -66,30 +107,27 @@ runGate(
|
|
|
66
107
|
enabledByDefault: true,
|
|
67
108
|
defaultParams: {
|
|
68
109
|
regionalMarkers: DEFAULT_REGIONAL_MARKERS,
|
|
110
|
+
textExtensions: DEFAULT_TEXT_EXTENSIONS,
|
|
69
111
|
escapeHatch: DEFAULT_ESCAPE_HATCH,
|
|
70
112
|
},
|
|
71
113
|
},
|
|
72
114
|
({ toolName, toolInput, parameters }) => {
|
|
73
|
-
|
|
115
|
+
if (!toolInGroups(toolName, ['write'])) return;
|
|
116
|
+
const text = writtenContentOf(toolInput);
|
|
74
117
|
if (!text) return;
|
|
75
118
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
).toLowerCase();
|
|
80
|
-
if (escapeHatch && text.includes(escapeHatch)) return;
|
|
119
|
+
const escapeHatch = parameters.escapeHatch ?? DEFAULT_ESCAPE_HATCH;
|
|
120
|
+
if (escapeHatch && text.toLowerCase().includes(escapeHatch.toLowerCase()))
|
|
121
|
+
return;
|
|
81
122
|
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
// spell-check dictionary untouched, so no word list may lean on this being a string).
|
|
89
|
-
const pattern = new RegExp(`(^|[^\\p{L}])${escaped}([^\\p{L}]|$)`, 'iu');
|
|
90
|
-
if (pattern.test(text)) hits.push(marker);
|
|
91
|
-
}
|
|
123
|
+
const path = writtenPathOf(toolInput);
|
|
124
|
+
if (
|
|
125
|
+
!hasTextExtension(path, parameters.textExtensions) &&
|
|
126
|
+
!isSpanishProse(text)
|
|
127
|
+
)
|
|
128
|
+
return;
|
|
92
129
|
|
|
130
|
+
const hits = markerHits(text, parameters.regionalMarkers);
|
|
93
131
|
if (hits.length === 0) return;
|
|
94
132
|
|
|
95
133
|
const unique = [...new Set(hits)].slice(0, MAX_REPORTED_MARKERS);
|
|
@@ -97,8 +135,8 @@ runGate(
|
|
|
97
135
|
CONFIG_KEY,
|
|
98
136
|
`Text being written contains regional Spanish markers: ${unique.join(', ')}. ` +
|
|
99
137
|
'Rewrite in neutral Spanish before writing. If the regional wording is intentional ' +
|
|
100
|
-
|
|
101
|
-
`"${
|
|
138
|
+
'(a literal quote, a test fixture, a captured log, a data sample), add the marker ' +
|
|
139
|
+
`"${escapeHatch}" somewhere in the content to allow it.`,
|
|
102
140
|
);
|
|
103
141
|
},
|
|
104
142
|
);
|
|
@@ -1,59 +1,64 @@
|
|
|
1
|
+
// never-assume — conjecture phrasing in a brief or in written code gets a reminder to verify.
|
|
2
|
+
// Advisory only: a guess is a prompt to check, never a blocker. Every pattern (default or
|
|
3
|
+
// configured) is word-bounded, so "might benefit" does not trip "might be".
|
|
4
|
+
|
|
1
5
|
import {
|
|
2
6
|
runGate,
|
|
3
7
|
warn,
|
|
4
8
|
toolInGroups,
|
|
5
9
|
writtenContentOf,
|
|
6
10
|
delegationPromptOf,
|
|
11
|
+
compileRegex,
|
|
7
12
|
} from '../../lib/hook-io.mjs';
|
|
8
13
|
import { CONJECTURE_SOURCES } from '../../lib/signals.mjs';
|
|
9
14
|
|
|
10
15
|
const GATE_ID = 'never-assume';
|
|
11
16
|
const CONFIG_KEY = 'requireVerificationBeforeAssuming';
|
|
12
17
|
|
|
13
|
-
// Bilingual (ES+EN) conjecture phrasing, centralized in lib/signals.mjs so every gate
|
|
14
|
-
// that needs to recognize unverified-assumption prose shares the same coverage. See
|
|
15
|
-
// signals.mjs header for the ES/EN-only limitation.
|
|
16
|
-
const DEFAULT_CONJECTURE_PATTERNS = CONJECTURE_SOURCES;
|
|
17
|
-
|
|
18
18
|
function extractContent(toolName, toolInput) {
|
|
19
19
|
if (toolInGroups(toolName, ['delegation']))
|
|
20
20
|
return delegationPromptOf(toolInput);
|
|
21
21
|
return writtenContentOf(toolInput);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// Same boundary as signals.withUnicodeWordBoundary, applied per source so an invalid
|
|
25
|
+
// config entry is skipped instead of poisoning the whole alternation.
|
|
26
|
+
function boundedPattern(source) {
|
|
27
|
+
if (typeof source !== 'string' || source.length === 0) return null;
|
|
28
|
+
return compileRegex(
|
|
29
|
+
`(?<![\\p{L}\\p{N}_])(?:${source})(?![\\p{L}\\p{N}_])`,
|
|
30
|
+
'iu',
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
24
34
|
runGate(
|
|
25
35
|
{
|
|
26
36
|
id: GATE_ID,
|
|
27
37
|
configKey: CONFIG_KEY,
|
|
28
38
|
enabledByDefault: false,
|
|
39
|
+
severity: 'warn',
|
|
29
40
|
defaultParams: {
|
|
30
|
-
conjecturePatterns:
|
|
41
|
+
conjecturePatterns: CONJECTURE_SOURCES,
|
|
31
42
|
},
|
|
32
43
|
},
|
|
33
44
|
({ toolName, toolInput, parameters }) => {
|
|
34
|
-
|
|
35
|
-
const isDelegation = toolInGroups(toolName, ['delegation']);
|
|
36
|
-
if (!isWrite && !isDelegation) return;
|
|
45
|
+
if (!toolInGroups(toolName, ['write', 'delegation'])) return;
|
|
37
46
|
|
|
38
47
|
const content = extractContent(toolName, toolInput);
|
|
39
48
|
if (!content) return;
|
|
40
49
|
|
|
41
|
-
const patterns = parameters.conjecturePatterns.map(
|
|
42
|
-
(source) => new RegExp(source, 'i'),
|
|
43
|
-
);
|
|
44
50
|
const hits = [];
|
|
45
|
-
for (const
|
|
46
|
-
const match =
|
|
51
|
+
for (const source of parameters.conjecturePatterns) {
|
|
52
|
+
const match = boundedPattern(source)?.exec(content);
|
|
47
53
|
if (match) hits.push(match[0]);
|
|
48
54
|
}
|
|
49
55
|
if (hits.length === 0) return;
|
|
50
56
|
|
|
51
57
|
warn(
|
|
52
58
|
CONFIG_KEY,
|
|
53
|
-
`Content contains conjecture phrasing without stated verification: ${hits.join(', ')}.
|
|
59
|
+
`Content contains conjecture phrasing without stated verification: ${hits.join(', ')}. ` +
|
|
60
|
+
'Verify before asserting instead of assuming. This is advisory; to change the ' +
|
|
61
|
+
`phrases, set conjecturePatterns for ${CONFIG_KEY} in .ai/config.json.`,
|
|
54
62
|
);
|
|
55
63
|
},
|
|
56
64
|
);
|
|
57
|
-
|
|
58
|
-
// The source guard (guard-never-assume.mjs) is explicit: this never denies,
|
|
59
|
-
// only warns — conjecture language is a prompt to verify, not a blocker.
|
|
@@ -1,117 +1,179 @@
|
|
|
1
|
-
// no-blocking — denies syntactic forms of waiting in the foreground (sleeping, polling
|
|
2
|
-
//
|
|
3
|
-
// on a delegation prompt
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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.
|
|
1
|
+
// no-blocking — denies syntactic forms of waiting in the foreground (sleeping, polling loops,
|
|
2
|
+
// following unbounded output, foreground dev servers, Windows pauses) on a real shell command
|
|
3
|
+
// and on a delegation prompt that orders one. Off by default: a project opts in. Deliberate
|
|
4
|
+
// limits: only SYNTACTIC waiting is recognized, never whether other work was available
|
|
5
|
+
// meanwhile; each command segment is judged on its own, so a detach form (`&`, nohup,
|
|
6
|
+
// Start-Job, --detach) exempts only the segment it is in, and only the real
|
|
7
|
+
// `run_in_background: true` flag exempts the whole call — the words in the command do not.
|
|
20
8
|
|
|
9
|
+
import { hasRealCommandIntent } from '../../lib/delegation.mjs';
|
|
21
10
|
import {
|
|
22
|
-
|
|
11
|
+
compileRegex,
|
|
12
|
+
delegationPromptOf,
|
|
23
13
|
deny,
|
|
14
|
+
escapeRegExp,
|
|
15
|
+
normalizeRulePairs,
|
|
16
|
+
runGate,
|
|
17
|
+
shellCommandOf,
|
|
24
18
|
toolInGroups,
|
|
25
|
-
delegationPromptOf,
|
|
26
19
|
} from '../../lib/hook-io.mjs';
|
|
27
20
|
|
|
28
21
|
const GATE_ID = 'no-blocking';
|
|
29
22
|
const CONFIG_KEY = 'blockWaitingCommands';
|
|
30
23
|
|
|
31
24
|
const DEFAULT_WAIT_JUSTIFIED_MARKER = 'WAIT-JUSTIFIED:';
|
|
25
|
+
const DEFAULT_REASON = 'Blocking/waiting command is not allowed.';
|
|
32
26
|
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
const
|
|
27
|
+
// Where a command may begin inside a segment or a sentence: the start, after a control word,
|
|
28
|
+
// or after a separator a prompt may still carry.
|
|
29
|
+
const COMMAND_START = String.raw`(?:^|[|;&(]\s*|\b(?:then|do|else)\s+)`;
|
|
30
|
+
const FOLLOW_COMMANDS =
|
|
31
|
+
'(?:tail|journalctl|kubectl\\s+logs|docker(?:-compose|\\s+compose)?\\s+logs)';
|
|
36
32
|
|
|
37
|
-
/** Blocking rules as `[regexSource, reason]`, matched case-insensitively. */
|
|
38
33
|
const DEFAULT_BLOCKING_PATTERNS = [
|
|
39
34
|
[
|
|
40
|
-
|
|
35
|
+
`${COMMAND_START}sleep\\s`,
|
|
41
36
|
'Sleeping in the foreground spends the turn doing nothing. If waiting for something ' +
|
|
42
37
|
'to finish, launch it in the background and move on to something else; if waiting ' +
|
|
43
38
|
'on an external condition, use a monitor.',
|
|
44
39
|
],
|
|
40
|
+
[
|
|
41
|
+
`${COMMAND_START}timeout(?:\\.exe)?\\s+(?:/t\\s+)?\\d+(?:\\s+/nobreak)?\\s*$`,
|
|
42
|
+
"'timeout N' with no command is a sleep. Wrap a real command (timeout 120 npm test) " +
|
|
43
|
+
'or launch the wait in the background.',
|
|
44
|
+
],
|
|
45
45
|
[
|
|
46
46
|
String.raw`\bStart-Sleep\b`,
|
|
47
47
|
'Start-Sleep freezes the turn. Launch the work in the background and continue with ' +
|
|
48
48
|
'whatever does not depend on it.',
|
|
49
49
|
],
|
|
50
50
|
[
|
|
51
|
-
String.raw
|
|
51
|
+
String.raw`${COMMAND_START}wait\b|\s--wait\b|\bWait-Process\b|\bWait-Job\b|\bStart-Process\b[^|;\n]*\s-Wait\b`,
|
|
52
52
|
'Waiting for another process to finish blocks the whole turn. Launch it in the ' +
|
|
53
53
|
'background and consume the result when it arrives, not before.',
|
|
54
54
|
],
|
|
55
55
|
[
|
|
56
|
-
[
|
|
57
|
-
String.raw`\b(tail|`,
|
|
58
|
-
SYSTEMD_LOG_COMMAND,
|
|
59
|
-
String.raw`|kubectl\s+logs|docker\s+logs)\b[^|;&]*\s-{1,2}f\b`,
|
|
60
|
-
].join(''),
|
|
56
|
+
String.raw`\b${FOLLOW_COMMANDS}\b[^|;&\n]*\s(?:-[a-z]*f[a-z]*|--follow)(?=\s|$)`,
|
|
61
57
|
'Following live output never returns and takes the turn with it. Read the file once, ' +
|
|
62
58
|
'or leave the following to a background process.',
|
|
63
59
|
],
|
|
64
60
|
[
|
|
65
|
-
|
|
61
|
+
`${COMMAND_START}watch\\s+`,
|
|
66
62
|
"'watch' repeats forever and blocks. Run the command once; if a change genuinely " +
|
|
67
63
|
'needs watching, use a monitor that does not take the turn.',
|
|
68
64
|
],
|
|
69
65
|
[
|
|
70
|
-
String.raw`\b(until|while)\b[
|
|
66
|
+
String.raw`\b(?:until|while)\b[\s\S]{0,80}\bdo\b[\s\S]{0,80}\bsleep\b`,
|
|
71
67
|
'A loop that sleeps waiting for something to change is foreground polling: it blocks ' +
|
|
72
68
|
'and it spins. Use a monitor with the condition, or launch the work in the ' +
|
|
73
69
|
'background and wait for its notification.',
|
|
74
70
|
],
|
|
75
71
|
[
|
|
76
|
-
String.raw`\b(npm|pnpm|yarn|bun)\s+(run\s+)?(dev|start|serve|watch)
|
|
72
|
+
String.raw`\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start|serve|watch)(?=\s|$)`,
|
|
77
73
|
'A dev server does not end on its own: in the foreground it takes the turn until ' +
|
|
78
74
|
'something kills it. Launch it in the background and keep working while it comes up.',
|
|
79
75
|
],
|
|
76
|
+
[
|
|
77
|
+
String.raw`${COMMAND_START}ping\b[^|;&\n]*\s-n\s+\d{2,}\b|${COMMAND_START}pause\b|\bRead-Host\b`,
|
|
78
|
+
'A ping loop, pause or Read-Host waits on the clock or on a keyboard nobody is at. ' +
|
|
79
|
+
'Remove the wait; if something must be awaited, do it in the background.',
|
|
80
|
+
],
|
|
80
81
|
];
|
|
81
82
|
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
// its own short regex tested with `.some()` — matching EXACTLY what the combined pattern did
|
|
89
|
-
// (verified case-by-case). Order does not matter: any one match means the command detaches.
|
|
90
|
-
const NOT_TAKING_THE_TURN_FORMS = [
|
|
91
|
-
/&\s*$/i,
|
|
83
|
+
// ── Shell segments ──────────────────────────────────────────────────────────────────
|
|
84
|
+
// A lone `&` backgrounds the segment before it; `>&` and `&>` are redirections, not separators.
|
|
85
|
+
const SEPARATOR_PATTERN = /(\|\||&&|\||;|\n|(?<!>)&(?![&>]))/;
|
|
86
|
+
const SHELL_WRAPPER_PATTERN =
|
|
87
|
+
/^(?:sh|bash|zsh|dash|ksh)\s+(-\w+)\s+(?:"([^"]*)"|'([^']*)')/i;
|
|
88
|
+
const DETACHED_SEGMENT_FORMS = [
|
|
92
89
|
/\bnohup\b/i,
|
|
93
90
|
/\bstart\s+\/b\b/i,
|
|
94
91
|
/--detach\b/i,
|
|
95
|
-
|
|
92
|
+
/^docker(?:-compose|\s+compose)?\b[^|;\n]*\s-d(?=\s|$)/i,
|
|
96
93
|
/\bStart-Job\b/i,
|
|
97
|
-
/\bStart-Process\b[^|;\n]*-NoNewWindow\b/i,
|
|
98
|
-
/\brun_in_background\b/i,
|
|
99
94
|
];
|
|
100
95
|
|
|
101
|
-
function
|
|
102
|
-
|
|
96
|
+
function stripGrouping(text) {
|
|
97
|
+
let stripped = text.trim();
|
|
98
|
+
while (stripped.startsWith('(') || stripped.startsWith('$('))
|
|
99
|
+
stripped = stripped.replace(/^\$?\(/, '').trim();
|
|
100
|
+
while (stripped.endsWith(')')) stripped = stripped.slice(0, -1).trimEnd();
|
|
101
|
+
return stripped;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function shellSegments(command) {
|
|
105
|
+
const parts = String(command).split(SEPARATOR_PATTERN);
|
|
106
|
+
const segments = [];
|
|
107
|
+
for (let index = 0; index < parts.length; index += 2) {
|
|
108
|
+
const text = stripGrouping(parts[index]);
|
|
109
|
+
if (!text) continue;
|
|
110
|
+
const sentToBackground = parts[index + 1] === '&';
|
|
111
|
+
const wrapped = SHELL_WRAPPER_PATTERN.exec(text);
|
|
112
|
+
if (wrapped && wrapped[1].includes('c')) {
|
|
113
|
+
for (const inner of shellSegments(wrapped[2] ?? wrapped[3]))
|
|
114
|
+
segments.push({
|
|
115
|
+
text: inner.text,
|
|
116
|
+
detached: sentToBackground || inner.detached,
|
|
117
|
+
});
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const detached =
|
|
121
|
+
sentToBackground ||
|
|
122
|
+
DETACHED_SEGMENT_FORMS.some((form) => form.test(text));
|
|
123
|
+
segments.push({ text, detached });
|
|
124
|
+
}
|
|
125
|
+
return segments;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Rules ───────────────────────────────────────────────────────────────────────────
|
|
129
|
+
function blockingRules(parameters) {
|
|
130
|
+
return normalizeRulePairs(parameters.blockingPatterns, DEFAULT_REASON)
|
|
131
|
+
.map(({ source, reason }) => ({ pattern: compileRegex(source), reason }))
|
|
132
|
+
.filter((rule) => rule.pattern !== null);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isJustified(text, marker) {
|
|
136
|
+
if (!marker) return false;
|
|
137
|
+
const reasonAfterMarker = String.raw`\s*\S+\s+\S+`;
|
|
138
|
+
return new RegExp(`${escapeRegExp(marker)}${reasonAfterMarker}`, 'i').test(
|
|
139
|
+
text,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function denyBlocking(reason, marker, matched) {
|
|
144
|
+
deny(
|
|
145
|
+
CONFIG_KEY,
|
|
146
|
+
`${reason} (matched: "${matched.trim()}") Launch it with run_in_background: true, or ` +
|
|
147
|
+
`if this wait is genuinely justified add "${marker} <concrete reason>" to the command. ` +
|
|
148
|
+
'A declared wait is a decision; a silent one is an oversight.',
|
|
149
|
+
);
|
|
103
150
|
}
|
|
104
151
|
|
|
105
|
-
function
|
|
106
|
-
|
|
152
|
+
function checkShellCommand(command, parameters, marker) {
|
|
153
|
+
const foreground = shellSegments(command)
|
|
154
|
+
.filter((segment) => !segment.detached)
|
|
155
|
+
.map((segment) => segment.text);
|
|
156
|
+
if (foreground.length === 0) return;
|
|
157
|
+
// The joined text lets a rule see a loop whose body sits in another segment.
|
|
158
|
+
const candidates =
|
|
159
|
+
foreground.length > 1 ? [...foreground, foreground.join('\n')] : foreground;
|
|
160
|
+
for (const { pattern, reason } of blockingRules(parameters)) {
|
|
161
|
+
for (const candidate of candidates) {
|
|
162
|
+
const match = pattern.exec(candidate);
|
|
163
|
+
if (match) denyBlocking(reason, marker, match[0]);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
107
166
|
}
|
|
108
167
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
168
|
+
function checkDelegationPrompt(prompt, parameters, marker) {
|
|
169
|
+
for (const { pattern, reason } of blockingRules(parameters)) {
|
|
170
|
+
if (hasRealCommandIntent(prompt, pattern))
|
|
171
|
+
denyBlocking(
|
|
172
|
+
`${reason} The delegation prompt orders the wait; a subagent must not do it either.`,
|
|
173
|
+
marker,
|
|
174
|
+
pattern.exec(prompt)?.[0] ?? '',
|
|
175
|
+
);
|
|
113
176
|
}
|
|
114
|
-
return delegationPromptOf(toolInput);
|
|
115
177
|
}
|
|
116
178
|
|
|
117
179
|
runGate(
|
|
@@ -126,38 +188,17 @@ runGate(
|
|
|
126
188
|
},
|
|
127
189
|
({ toolName, toolInput, parameters }) => {
|
|
128
190
|
const isShell = toolInGroups(toolName, ['shell']);
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (!
|
|
134
|
-
|
|
135
|
-
// Already declared in the background: the turn stays free, which is all that matters.
|
|
191
|
+
if (!isShell && !toolInGroups(toolName, ['delegation'])) return;
|
|
192
|
+
const text = isShell
|
|
193
|
+
? shellCommandOf(toolInput)
|
|
194
|
+
: delegationPromptOf(toolInput);
|
|
195
|
+
if (!text.trim()) return;
|
|
136
196
|
if (toolInput.run_in_background === true) return;
|
|
137
|
-
if (detachesFromTurn(command)) return;
|
|
138
197
|
|
|
139
|
-
// A declared wait with its reason is a decision, not an oversight.
|
|
140
198
|
const marker = String(parameters.waitJustifiedMarker ?? '');
|
|
141
|
-
if (marker)
|
|
142
|
-
const escapedMarker = marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
143
|
-
const reasonAfterMarker = String.raw`\s*\S+\s+\S+`;
|
|
144
|
-
const justified = new RegExp(`${escapedMarker}${reasonAfterMarker}`, 'i');
|
|
145
|
-
if (justified.test(command)) return;
|
|
146
|
-
}
|
|
199
|
+
if (isJustified(text, marker)) return;
|
|
147
200
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
? entry
|
|
151
|
-
: [entry, 'Blocking/waiting command is not allowed.'],
|
|
152
|
-
);
|
|
153
|
-
for (const [source, reason] of blockingPairs) {
|
|
154
|
-
if (compile(source).test(command)) {
|
|
155
|
-
deny(
|
|
156
|
-
CONFIG_KEY,
|
|
157
|
-
`${reason} If this wait is genuinely justified, add "${marker} <concrete reason>" ` +
|
|
158
|
-
'to the command and try again. A declared wait is a decision; a silent one is an oversight.',
|
|
159
|
-
);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
201
|
+
if (isShell) checkShellCommand(text, parameters, marker);
|
|
202
|
+
else checkDelegationPrompt(text, parameters, marker);
|
|
162
203
|
},
|
|
163
204
|
);
|