@devrik-tools/claude-gates 0.7.0 → 0.7.2
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 +6 -0
- package/README.md +5 -0
- package/package.json +1 -1
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/gates/atomic-commit/index.mjs +2 -2
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +12 -4
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +1 -1
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +3 -3
- package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +2 -2
- package/plugins/gates/hooks/gates/brief-approved/index.mjs +16 -11
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +3 -3
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +1 -1
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +1 -1
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +14 -4
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +2 -2
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +1 -1
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +2 -2
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +1 -1
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +3 -3
- package/plugins/gates/hooks/gates/lint-commit/index.mjs +2 -2
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +7 -4
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +1 -1
- package/plugins/gates/hooks/gates/never-assume/index.mjs +1 -1
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +1 -1
- package/plugins/gates/hooks/gates/no-coauthor/index.mjs +1 -1
- package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +1 -1
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +1 -1
- package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +1 -1
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +2 -2
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +7 -2
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +46 -10
- package/plugins/gates/hooks/gates/risk-level/index.mjs +3 -3
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +1 -1
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +1 -1
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +14 -8
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +33 -12
- package/plugins/gates/hooks/gates/staged-lint/index.mjs +2 -2
- package/plugins/gates/hooks/gates/stop-pending/index.mjs +1 -2
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +1 -1
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +1 -1
- package/plugins/gates/hooks/gates/tool-map/index.mjs +1 -1
- package/plugins/gates/hooks/lib/hook-io.mjs +10 -2
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
|
@@ -11,13 +11,13 @@
|
|
|
11
11
|
"name": "gates",
|
|
12
12
|
"source": "./plugins/gates",
|
|
13
13
|
"description": "PreToolUse gates: destructive-command blocks, protected paths, delegation/spec/quality rules, tool-discovery and forge-pipeline enforcement. Which gates run is decided by .ai/config.json (project) or ~/.claude/claude-gates/config.json (global).",
|
|
14
|
-
"version": "0.7.
|
|
14
|
+
"version": "0.7.2"
|
|
15
15
|
},
|
|
16
16
|
{
|
|
17
17
|
"name": "tasks",
|
|
18
18
|
"source": "./plugins/tasks",
|
|
19
19
|
"description": "Deterministic task tracking: the model registers tasks via the CLI, a UserPromptSubmit hook reminds of open tasks every few messages, and a SessionStart hook lists active tasks when a session opens.",
|
|
20
|
-
"version": "0.7.
|
|
20
|
+
"version": "0.7.2"
|
|
21
21
|
}
|
|
22
22
|
]
|
|
23
23
|
}
|
package/README.es.md
CHANGED
|
@@ -56,6 +56,12 @@ Si algún gate bloquea, la herramienta no se ejecuta.
|
|
|
56
56
|
- **WARN**: la acción necesita criterio → el gate inyecta un aviso y deja seguir.
|
|
57
57
|
- **Silencio**: el caso común. Un gate no molesta si no hay nada que objetar.
|
|
58
58
|
|
|
59
|
+
Todo mensaje DENY/WARN lleva el prefijo `[configKey]` — el nombre exacto de la clave para
|
|
60
|
+
buscar (o apagar/ajustar) bajo `"gates"` en `.ai/config.json`, por ejemplo
|
|
61
|
+
`[blockPathsOutsideRootWhitelist] '...' no está en la whitelist.` significa que la
|
|
62
|
+
configuración es `blockPathsOutsideRootWhitelist` en la tabla de abajo, no otro nombre que
|
|
63
|
+
haya que adivinar o traducir desde un id corto de gate.
|
|
64
|
+
|
|
59
65
|
Cada gate es **autocontenido** (solo Node built-ins, sin dependencias en tiempo de
|
|
60
66
|
ejecución), así que funciona aunque instales uno suelto por fuera.
|
|
61
67
|
|
package/README.md
CHANGED
|
@@ -55,6 +55,11 @@ If any gate blocks, the tool does not run.
|
|
|
55
55
|
- **WARN**: the action needs judgment → the gate injects a note and lets it proceed.
|
|
56
56
|
- **Silence**: the common path. A gate never nags when there is nothing to object to.
|
|
57
57
|
|
|
58
|
+
Every DENY/WARN message is prefixed `[configKey]` — the exact key name to look up (or turn
|
|
59
|
+
off/tune) under `"gates"` in `.ai/config.json`, e.g. `[blockPathsOutsideRootWhitelist] '...'
|
|
60
|
+
is not on the whitelist.` means the setting is `blockPathsOutsideRootWhitelist` in the table
|
|
61
|
+
below, not some other name you'd have to guess or map from a short gate id.
|
|
62
|
+
|
|
58
63
|
Every gate is **self-contained** (Node built-ins only, no runtime dependencies), so it
|
|
59
64
|
works even if you install one on its own.
|
|
60
65
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devrik-tools/claude-gates",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "Installable, deterministic gates (hooks) for Claude Code: block destructive commands, protected paths, and enforce delegation/spec/quality rules. Configurable per project.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gates",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "Deterministic gates for Claude Code: destructive-command blocks, protected paths, delegation briefs, spec-driven flow and session-start validation. Selection lives in config, not in code.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Devrik"
|
|
@@ -208,7 +208,7 @@ runGate(
|
|
|
208
208
|
|
|
209
209
|
if (countedNatures.size > maxNatures) {
|
|
210
210
|
deny(
|
|
211
|
-
|
|
211
|
+
CONFIG_KEY,
|
|
212
212
|
`This commit mixes ${countedNatures.size} kinds of change ` +
|
|
213
213
|
`(${[...countedNatures].join(', ')}) — a commit should be one cohesive change ` +
|
|
214
214
|
`(max ${maxNatures}). Split it: stage and commit one nature at a time (e.g. the ` +
|
|
@@ -219,7 +219,7 @@ runGate(
|
|
|
219
219
|
|
|
220
220
|
if (counted.length > maxFiles) {
|
|
221
221
|
deny(
|
|
222
|
-
|
|
222
|
+
CONFIG_KEY,
|
|
223
223
|
`This commit stages ${counted.length} reviewable files (max ${maxFiles}) — too large ` +
|
|
224
224
|
`to review as one unit. Split it into smaller, cohesive commits (docs/assets/` +
|
|
225
225
|
`generated files are not counted). Add "${escapeHatch}" for one deliberately broad commit.`,
|
|
@@ -53,8 +53,12 @@ function checkDelegation(toolInput) {
|
|
|
53
53
|
if (AUDIT_EVIDENCE_PATTERN.test(prompt)) return;
|
|
54
54
|
|
|
55
55
|
deny(
|
|
56
|
-
|
|
57
|
-
'
|
|
56
|
+
CONFIG_KEY,
|
|
57
|
+
'Blocked: this delegation prompt asks to create a new script/checker/gate/hook/linter/' +
|
|
58
|
+
'tool, and nothing in the prompt shows a prior audit. No filesystem exploration is ' +
|
|
59
|
+
'needed to fix this — add ONE phrase to the prompt matching ' +
|
|
60
|
+
`/${AUDIT_EVIDENCE_PATTERN.source}/i, e.g.: "audited and no existing tool covers this" ` +
|
|
61
|
+
'(this exact phrase is guaranteed to match). Then relaunch the same delegation.',
|
|
58
62
|
);
|
|
59
63
|
}
|
|
60
64
|
|
|
@@ -84,8 +88,12 @@ function checkWrite(toolInput, parameters) {
|
|
|
84
88
|
if (INLINE_JUSTIFICATION_PATTERN.test(content)) return;
|
|
85
89
|
|
|
86
90
|
deny(
|
|
87
|
-
|
|
88
|
-
`
|
|
91
|
+
CONFIG_KEY,
|
|
92
|
+
`Blocked: '${filePath}' is a new executable tool with no justification comment in its ` +
|
|
93
|
+
'content. No filesystem exploration is needed to fix this — add ONE line to the ' +
|
|
94
|
+
`file's content matching /${INLINE_JUSTIFICATION_PATTERN.source}/i, e.g.: ` +
|
|
95
|
+
'"// justification: no existing tool covers this" (this exact phrase is guaranteed ' +
|
|
96
|
+
'to match). Then retry the exact same write.',
|
|
89
97
|
);
|
|
90
98
|
}
|
|
91
99
|
|
|
@@ -173,7 +173,7 @@ function checkDestructive(command, parameters) {
|
|
|
173
173
|
: [entry, 'Destructive command is not allowed.'],
|
|
174
174
|
);
|
|
175
175
|
for (const [source, reason] of denyPairs) {
|
|
176
|
-
if (compile(source).test(normalized)) deny(
|
|
176
|
+
if (compile(source).test(normalized)) deny(CONFIG_KEY, reason);
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
// rm -rf over a protected area: areas read from config at runtime, so editing
|
|
@@ -184,12 +184,12 @@ function checkDestructive(command, parameters) {
|
|
|
184
184
|
rmRfAreas.length > 0 &&
|
|
185
185
|
compile(rmRfSourceFrom(rmRfAreas)).test(command)
|
|
186
186
|
) {
|
|
187
|
-
deny(
|
|
187
|
+
deny(CONFIG_KEY, "'rm -rf' over a protected area is not allowed.");
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
if (parameters.embeddedInterpreterEnabled) {
|
|
191
191
|
const reason = checkEmbeddedInterpreter(command);
|
|
192
|
-
if (reason) deny(
|
|
192
|
+
if (reason) deny(CONFIG_KEY, reason);
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
195
|
|
|
@@ -131,9 +131,9 @@ runGate(
|
|
|
131
131
|
for (const [source, reason] of parameters.publishRules) {
|
|
132
132
|
const pattern = compile(source);
|
|
133
133
|
if (isShell) {
|
|
134
|
-
if (pattern.test(shellCommand)) deny(
|
|
134
|
+
if (pattern.test(shellCommand)) deny(CONFIG_KEY, reason);
|
|
135
135
|
} else if (hasRealPublishIntent(command, pattern)) {
|
|
136
|
-
deny(
|
|
136
|
+
deny(CONFIG_KEY, reason);
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
139
|
},
|
|
@@ -194,21 +194,26 @@ runGate(
|
|
|
194
194
|
const treeRoot = contractTreeRootFor(catalogPath);
|
|
195
195
|
if (!treeRoot) return; // no SDD harness adopted: stay silent
|
|
196
196
|
|
|
197
|
-
const unapproved = citedFeatures
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
197
|
+
const unapproved = citedFeatures
|
|
198
|
+
.map((feature) => ({
|
|
199
|
+
feature,
|
|
200
|
+
contractPath: contractFileFor(treeRoot, feature),
|
|
201
|
+
}))
|
|
202
|
+
.filter(({ contractPath }) => contractPath) // no contractPath: sdd-specs already denies that case
|
|
203
|
+
.filter(({ contractPath }) => !isApproved(contractPath));
|
|
202
204
|
|
|
203
205
|
if (unapproved.length === 0) return;
|
|
204
206
|
|
|
207
|
+
const fileList = unapproved
|
|
208
|
+
.map(({ feature, contractPath }) => `${feature} -> ${contractPath}`)
|
|
209
|
+
.join('\n ');
|
|
205
210
|
deny(
|
|
206
|
-
|
|
207
|
-
`This implementation delegation cites feature(s)
|
|
208
|
-
'
|
|
209
|
-
'
|
|
210
|
-
'
|
|
211
|
-
'the
|
|
211
|
+
CONFIG_KEY,
|
|
212
|
+
`This implementation delegation cites unapproved feature(s):\n ${fileList}\n` +
|
|
213
|
+
'Paste the FULL brief into the chat, get an explicit confirmation from the user ' +
|
|
214
|
+
'(not a vague "dale"/"sigamos" — an actual sentence confirming they read it), ' +
|
|
215
|
+
'then add this frontmatter to the TOP of that exact contract file before ' +
|
|
216
|
+
'relaunching (no filesystem exploration needed — the path above is the file to edit):\n' +
|
|
212
217
|
'---\nstatus: approved\napproved_at: <ISO timestamp>\n' +
|
|
213
218
|
'approval_quote: "<the user\'s own confirming words>"\n---',
|
|
214
219
|
);
|
|
@@ -209,7 +209,7 @@ function denyTooShort(prompt) {
|
|
|
209
209
|
const excerpt = prompt.slice(0, PROMPT_EXCERPT_LENGTH);
|
|
210
210
|
const ellipsis = prompt.length > PROMPT_EXCERPT_LENGTH ? '…' : '';
|
|
211
211
|
deny(
|
|
212
|
-
|
|
212
|
+
CONFIG_KEY,
|
|
213
213
|
`This delegation asks for implementation ("${excerpt}${ellipsis}") in a ${prompt.length}-character ` +
|
|
214
214
|
'prompt — too short to carry a goal, steps and a done-when criterion. State what this aims to ' +
|
|
215
215
|
'achieve, what concretely needs doing (as a list or steps), and how completion is verified, then ' +
|
|
@@ -222,7 +222,7 @@ function reportMissingSignals(missing) {
|
|
|
222
222
|
|
|
223
223
|
if (missing.length < TOTAL_REQUIRED_SIGNALS) {
|
|
224
224
|
warn(
|
|
225
|
-
|
|
225
|
+
CONFIG_KEY,
|
|
226
226
|
`This implementation delegation does not recognizably state: ${missing.join('; ')}. ` +
|
|
227
227
|
'If it is already there under different wording, proceed — this is only a warning. ' +
|
|
228
228
|
'Otherwise add it before the subagent starts blind.',
|
|
@@ -231,7 +231,7 @@ function reportMissingSignals(missing) {
|
|
|
231
231
|
}
|
|
232
232
|
|
|
233
233
|
deny(
|
|
234
|
-
|
|
234
|
+
CONFIG_KEY,
|
|
235
235
|
'This delegation asks for implementation but states neither as a list nor recognizable prose: ' +
|
|
236
236
|
`${missing.join('; ')}. Add to the prompt: (1) the GOAL — what this aims to achieve; ` +
|
|
237
237
|
'(2) the STEPS — concrete files/actions, as a list; (3) the CRITERION — how completion is ' +
|
|
@@ -448,7 +448,7 @@ function identityKey(signature) {
|
|
|
448
448
|
|
|
449
449
|
function denyRepeatedAttempt(count) {
|
|
450
450
|
deny(
|
|
451
|
-
|
|
451
|
+
CONFIG_KEY,
|
|
452
452
|
`This same task received ${count} consecutive attempts (same goal, same scope and the same files) ` +
|
|
453
453
|
'in this session, regardless of which subagent_type carried it. STOP retrying and ASK THE USER ' +
|
|
454
454
|
'for help now: state what you tried, what blocked it, and the specific decision or input you need ' +
|
|
@@ -112,7 +112,7 @@ runGate(
|
|
|
112
112
|
if (unmatched.length === 0) return;
|
|
113
113
|
|
|
114
114
|
deny(
|
|
115
|
-
|
|
115
|
+
CONFIG_KEY,
|
|
116
116
|
`New dependencies without a matching skill in ${parameters.projectSkillsDir}: ${unmatched.join(', ')}. ` +
|
|
117
117
|
'Add a skill documenting how to use each (directory name matching or containing the ' +
|
|
118
118
|
'package name), OR, if a dependency genuinely needs no skill, declare it in ' +
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
warn,
|
|
4
4
|
toolInGroups,
|
|
5
5
|
writtenContentOf,
|
|
6
|
+
writtenPathOf,
|
|
6
7
|
} from '../../lib/hook-io.mjs';
|
|
7
8
|
|
|
8
9
|
const GATE_ID = 'diagnosis-before-patch';
|
|
@@ -37,12 +38,21 @@ runGate(
|
|
|
37
38
|
const patterns = parameters.timeoutPatterns.map(
|
|
38
39
|
(source) => new RegExp(source, 'i'),
|
|
39
40
|
);
|
|
40
|
-
const
|
|
41
|
-
|
|
41
|
+
const matched = patterns
|
|
42
|
+
.map((pattern) => pattern.exec(text))
|
|
43
|
+
.find((match) => match !== null);
|
|
44
|
+
if (!matched) return;
|
|
42
45
|
|
|
46
|
+
const filePath = writtenPathOf(toolInput) || '(unknown path)';
|
|
43
47
|
warn(
|
|
44
|
-
|
|
45
|
-
|
|
48
|
+
CONFIG_KEY,
|
|
49
|
+
`You are writing '${matched[0].trim()}' into ${filePath} — a timeout/deadline/retry ` +
|
|
50
|
+
'value. Before changing a value to fix a symptom ("X is slow/fails"), confirm you ' +
|
|
51
|
+
'have the evidence that proves the cause: a log line from the failing provider/' +
|
|
52
|
+
"process, not a hypothesis. If you don't have that log line yet, get it before " +
|
|
53
|
+
'writing this change — do not guess a new number. A timeout should measure ' +
|
|
54
|
+
'inactivity, not total time: a process that is still progressing should not be cut ' +
|
|
55
|
+
'off. This is a warning, not a block — the write proceeds either way.',
|
|
46
56
|
);
|
|
47
57
|
},
|
|
48
58
|
);
|
|
@@ -61,7 +61,7 @@ runGate(
|
|
|
61
61
|
// Base, non-negotiable: `done` is never written directly to the catalog.
|
|
62
62
|
if (DONE_STATUS_PATTERN.test(content)) {
|
|
63
63
|
deny(
|
|
64
|
-
|
|
64
|
+
CONFIG_KEY,
|
|
65
65
|
`Writing 'status: done' directly to ${catalogFileName} is not allowed. ` +
|
|
66
66
|
'Only a review/QA subagent or a validated automated process may close a feature.',
|
|
67
67
|
);
|
|
@@ -74,7 +74,7 @@ runGate(
|
|
|
74
74
|
.length;
|
|
75
75
|
if (inProgressCount > maxInProgress) {
|
|
76
76
|
deny(
|
|
77
|
-
|
|
77
|
+
CONFIG_KEY,
|
|
78
78
|
`${catalogFileName} would have ${inProgressCount} features 'in_progress'; ` +
|
|
79
79
|
`the maximum allowed is ${maxInProgress}.`,
|
|
80
80
|
);
|
|
@@ -121,11 +121,11 @@ runGate(
|
|
|
121
121
|
const result = await forgeRunState(root, forgeDatabasePath);
|
|
122
122
|
|
|
123
123
|
if (result.state === 'active') return; // pipeline is running → allow
|
|
124
|
-
if (result.state === 'none') deny(
|
|
124
|
+
if (result.state === 'none') deny(CONFIG_KEY, DENY_MESSAGE); // no run → block
|
|
125
125
|
// state 'unknown': the DB could not be read. Allow so a broken lookup never freezes work,
|
|
126
126
|
// but surface it loudly — a silent allow here would disable the enforcer without a trace.
|
|
127
127
|
warn(
|
|
128
|
-
|
|
128
|
+
CONFIG_KEY,
|
|
129
129
|
`forge enforcement is degraded: the forge DB could not be read (${result.reason}). ` +
|
|
130
130
|
'Allowing this action, but the pipeline is NOT being enforced. Check that forge is ' +
|
|
131
131
|
`installed and ${forgeDatabasePath} is readable, or turn this gate off if intended.`,
|
|
@@ -175,7 +175,7 @@ runGate(
|
|
|
175
175
|
const lines = missingStages.map((stage) => ` ${stage.line}`).join('\n');
|
|
176
176
|
|
|
177
177
|
deny(
|
|
178
|
-
|
|
178
|
+
CONFIG_KEY,
|
|
179
179
|
`This delegation is going to build code but skips ${missingStages.length} pipeline ` +
|
|
180
180
|
`stage(s). ${details} ${
|
|
181
181
|
isHighRisk
|
|
@@ -181,7 +181,7 @@ function missingSignals(prompt) {
|
|
|
181
181
|
|
|
182
182
|
function denyUnresolvedUnknown() {
|
|
183
183
|
deny(
|
|
184
|
-
|
|
184
|
+
CONFIG_KEY,
|
|
185
185
|
'This request touches a high-impact signal (money/auth/persisted data/public contract/' +
|
|
186
186
|
'irreversible/production) AND declares an unresolved UNKNOWN. Do not relaunch assuming an ' +
|
|
187
187
|
'answer — ask the user what to decide about that UNKNOWN before delegating again.',
|
|
@@ -192,7 +192,7 @@ function denyTooShortForScopeList(prompt) {
|
|
|
192
192
|
const excerpt = prompt.slice(0, PROMPT_EXCERPT_LENGTH);
|
|
193
193
|
const ellipsis = prompt.length > PROMPT_EXCERPT_LENGTH ? '…' : '';
|
|
194
194
|
deny(
|
|
195
|
-
|
|
195
|
+
CONFIG_KEY,
|
|
196
196
|
`This implementation request ("${excerpt}${ellipsis}") does not carry a scope list: missing IN ` +
|
|
197
197
|
'SCOPE, OUT OF SCOPE and EDGE CASES. Add it to the prompt before relaunching.',
|
|
198
198
|
);
|
|
@@ -200,7 +200,7 @@ function denyTooShortForScopeList(prompt) {
|
|
|
200
200
|
|
|
201
201
|
function denyMissingSignals(missing) {
|
|
202
202
|
deny(
|
|
203
|
-
|
|
203
|
+
CONFIG_KEY,
|
|
204
204
|
`This implementation request does not declare, in recognizable form: ${missing.join('; ')}. ` +
|
|
205
205
|
'Add what is missing to the prompt before relaunching this delegation.',
|
|
206
206
|
);
|
|
@@ -133,7 +133,7 @@ runGate(
|
|
|
133
133
|
// timeout kill). A commit gate that cannot evaluate must not silently permit — deny
|
|
134
134
|
// with the spawn error so the user can fix the environment, not guess.
|
|
135
135
|
deny(
|
|
136
|
-
|
|
136
|
+
CONFIG_KEY,
|
|
137
137
|
`Could not run the lint command ("${lintCommand}"): ${result.error.message}. Fix ` +
|
|
138
138
|
'the lint setup or set lintCommand/blockCommitWithFailingLint in .ai/config.json.',
|
|
139
139
|
);
|
|
@@ -143,7 +143,7 @@ runGate(
|
|
|
143
143
|
const combinedOutput =
|
|
144
144
|
`${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim();
|
|
145
145
|
deny(
|
|
146
|
-
|
|
146
|
+
CONFIG_KEY,
|
|
147
147
|
`Lint failed (exit ${result.status}) — commit blocked until it passes:\n` +
|
|
148
148
|
`${tailLines(combinedOutput, OUTPUT_TAIL_LINES)}`,
|
|
149
149
|
);
|
|
@@ -125,7 +125,7 @@ function readSlug(pointerPath) {
|
|
|
125
125
|
function checkLiveTask(pointerPath) {
|
|
126
126
|
if (!existsSync(pointerPath)) {
|
|
127
127
|
deny(
|
|
128
|
-
|
|
128
|
+
CONFIG_KEY,
|
|
129
129
|
`This delegation is going to implement, but there is no live task: ${pointerPath} ` +
|
|
130
130
|
'does not exist. Start a task pointing to it before delegating, or declare ' +
|
|
131
131
|
'LEVEL: QUESTION/MICRO if this is not implementation.',
|
|
@@ -135,7 +135,7 @@ function checkLiveTask(pointerPath) {
|
|
|
135
135
|
const slug = readSlug(pointerPath);
|
|
136
136
|
if (!slug) {
|
|
137
137
|
deny(
|
|
138
|
-
|
|
138
|
+
CONFIG_KEY,
|
|
139
139
|
`${pointerPath} exists but is empty. An active-task pointer with no slug is not ` +
|
|
140
140
|
'a live task. Write the task slug into it before delegating.',
|
|
141
141
|
);
|
|
@@ -144,10 +144,13 @@ function checkLiveTask(pointerPath) {
|
|
|
144
144
|
const taskDirectory = join(process.cwd(), '.ai', 'pipeline', slug);
|
|
145
145
|
if (!hasTaskContract(taskDirectory)) {
|
|
146
146
|
deny(
|
|
147
|
-
|
|
147
|
+
CONFIG_KEY,
|
|
148
148
|
`The active task '${slug}' has no contract on disk: none of ` +
|
|
149
149
|
`${TASK_CONTRACT_FILES.join(', ')} exists under ${taskDirectory}. ` +
|
|
150
|
-
|
|
150
|
+
`Write ONE of those files there before implementing (this gate's contract root is ` +
|
|
151
|
+
`.ai/pipeline/${slug}/ — a brief.md under .ai/features/${slug}/ satisfies a ` +
|
|
152
|
+
'different gate, sdd-specs, but NOT this one; if you already wrote a brief there, ' +
|
|
153
|
+
`create a short asserts.md under ${taskDirectory} referencing it).`,
|
|
151
154
|
);
|
|
152
155
|
}
|
|
153
156
|
}
|
|
@@ -94,7 +94,7 @@ runGate(
|
|
|
94
94
|
|
|
95
95
|
const unique = [...new Set(hits)].slice(0, MAX_REPORTED_MARKERS);
|
|
96
96
|
deny(
|
|
97
|
-
|
|
97
|
+
CONFIG_KEY,
|
|
98
98
|
`Text being written contains regional Spanish markers: ${unique.join(', ')}. ` +
|
|
99
99
|
'Rewrite in neutral Spanish before writing. If the regional wording is intentional ' +
|
|
100
100
|
`(a literal quote, a test fixture, a captured log, a data sample), add the marker ` +
|
|
@@ -153,7 +153,7 @@ runGate(
|
|
|
153
153
|
for (const [source, reason] of blockingPairs) {
|
|
154
154
|
if (compile(source).test(command)) {
|
|
155
155
|
deny(
|
|
156
|
-
|
|
156
|
+
CONFIG_KEY,
|
|
157
157
|
`${reason} If this wait is genuinely justified, add "${marker} <concrete reason>" ` +
|
|
158
158
|
'to the command and try again. A declared wait is a decision; a silent one is an oversight.',
|
|
159
159
|
);
|
|
@@ -116,7 +116,7 @@ runGate(
|
|
|
116
116
|
if (!match) return;
|
|
117
117
|
|
|
118
118
|
deny(
|
|
119
|
-
|
|
119
|
+
CONFIG_KEY,
|
|
120
120
|
`This commit message carries an AI/agent attribution trailer (matched "${match}"). ` +
|
|
121
121
|
'Remove it — no Co-Authored-By, no "Generated with", no session trailer. If this ' +
|
|
122
122
|
`commit legitimately must keep a co-author (e.g. importing external history), add ` +
|
|
@@ -173,7 +173,7 @@ runGate(
|
|
|
173
173
|
if (!offender) return;
|
|
174
174
|
|
|
175
175
|
deny(
|
|
176
|
-
|
|
176
|
+
CONFIG_KEY,
|
|
177
177
|
`This write silences the linter/type-checker instead of fixing the code: "${offender}". ` +
|
|
178
178
|
'Fix the underlying issue rather than turning the check off. If this is a genuine, ' +
|
|
179
179
|
`documented false positive, put "${escapeHatch} <reason>" on the same line so the ` +
|
|
@@ -128,7 +128,7 @@ runGate(
|
|
|
128
128
|
|
|
129
129
|
const quotedPhrases = phrases.map((phrase) => `"${phrase}"`).join(', ');
|
|
130
130
|
deny(
|
|
131
|
-
|
|
131
|
+
CONFIG_KEY,
|
|
132
132
|
`This delegation depends on the subagent remembering something (${quotedPhrases}), but a ` +
|
|
133
133
|
'fresh subagent has none of this conversation. Put the data IN the prompt, in a file it ' +
|
|
134
134
|
'reads, a flag, or an already-persisted decision. If the phrase directs the subagent’s ' +
|
|
@@ -87,7 +87,7 @@ function checkWrite(toolInput, protectedPaths) {
|
|
|
87
87
|
const target = writtenPathOf(toolInput);
|
|
88
88
|
if (target && isProtectedPath(target, protectedPaths)) {
|
|
89
89
|
deny(
|
|
90
|
-
|
|
90
|
+
CONFIG_KEY,
|
|
91
91
|
`Writing to '${target}' is not allowed: it matches a protected path (${protectedPaths.join(', ')}).`,
|
|
92
92
|
);
|
|
93
93
|
}
|
|
@@ -110,7 +110,7 @@ function checkShellCommand(toolInput, protectedPaths, mutatingCommands) {
|
|
|
110
110
|
|
|
111
111
|
if (targetsProtected) {
|
|
112
112
|
deny(
|
|
113
|
-
|
|
113
|
+
CONFIG_KEY,
|
|
114
114
|
`This command targets a protected path (${protectedPaths.join(', ')}) with a mutating operation. ` +
|
|
115
115
|
'Reading (echo/cat/grep) is fine; modifying it is not.',
|
|
116
116
|
);
|
|
@@ -76,6 +76,7 @@ runGate(
|
|
|
76
76
|
({ toolName, parameters }) => {
|
|
77
77
|
if (!toolInGroups(toolName, ['execution', 'delegation'])) return;
|
|
78
78
|
|
|
79
|
+
const recurrencesFile = join('.ai', 'reincidencias.json');
|
|
79
80
|
const openRecurrences = loadOpenRecurrences(
|
|
80
81
|
process.cwd(),
|
|
81
82
|
parameters.thresholdAppearances,
|
|
@@ -84,8 +85,12 @@ runGate(
|
|
|
84
85
|
|
|
85
86
|
const names = openRecurrences.map((entry) => entry.class).join(', ');
|
|
86
87
|
deny(
|
|
87
|
-
|
|
88
|
-
`Registered recurring issue
|
|
88
|
+
CONFIG_KEY,
|
|
89
|
+
`Registered recurring issue class(es) still open and at/above the ` +
|
|
90
|
+
`${parameters.thresholdAppearances}-occurrence threshold: ${names}. Fix the root ` +
|
|
91
|
+
`cause of the class (not this one instance), then in ${recurrencesFile} set that ` +
|
|
92
|
+
'class\'s "status" to "closed" (or "cerrada") before proceeding — no other file to ' +
|
|
93
|
+
'find, this is the only source this gate reads.',
|
|
89
94
|
);
|
|
90
95
|
},
|
|
91
96
|
);
|
|
@@ -196,14 +196,50 @@ function isToolLikePath(filePath, parameters) {
|
|
|
196
196
|
);
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
199
|
+
// The exact phrase AUDIT_DONE_PATTERN matches — quoted verbatim in the deny message so the
|
|
200
|
+
// model can copy it instead of guessing a paraphrase the regex might not recognize (a vague
|
|
201
|
+
// "I checked" or "no lo encontré" does NOT match AUDIT_DONE_PATTERN and the gate fires again).
|
|
202
|
+
const AUDIT_DONE_EXAMPLE_PHRASE = 'no existing tool covers this';
|
|
203
|
+
|
|
204
|
+
/** The concrete deny message for a blocked WRITE: names the exact file and gives the exact
|
|
205
|
+
* text to add (copy-pasteable, matches AUDIT_DONE_PATTERN verbatim) — no exploration needed
|
|
206
|
+
* to find any of this, it is the gate's own job to hand it over. Adding the phrase also
|
|
207
|
+
* satisfies the tool-map gate (maintainToolMap), which records the file + this same line
|
|
208
|
+
* into .ai/tool-map.json automatically — no separate command to run. */
|
|
209
|
+
function writeDenyMessage(filePath) {
|
|
210
|
+
return (
|
|
211
|
+
`Blocked: '${filePath}' looks like a new tool/helper, and nothing shows the wheel was ` +
|
|
212
|
+
'checked first. Pick ONE, then retry the exact same write:\n' +
|
|
213
|
+
` 1. It already exists here or in an installed dep — don't create '${filePath}'; ` +
|
|
214
|
+
'reuse/import the existing one instead.\n' +
|
|
215
|
+
' 2. It does not exist anywhere you checked (repo, installed deps, npm/marketplaces) — ' +
|
|
216
|
+
"add ONE line to the file's content stating that, e.g.: " +
|
|
217
|
+
`"// ${AUDIT_DONE_EXAMPLE_PHRASE}" (any phrase matching /${AUDIT_DONE_PATTERN.source}/i ` +
|
|
218
|
+
'works, this one is guaranteed to). That line also gets this file auto-recorded into ' +
|
|
219
|
+
'.ai/tool-map.json — nothing further to run.\n' +
|
|
220
|
+
'No filesystem exploration is required to satisfy this gate — the audit is a sentence ' +
|
|
221
|
+
'in the file or a matching package.json dependency, nothing else.'
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The concrete deny message for a blocked DELEGATION prompt: same two options, but no file
|
|
226
|
+
* name exists yet (only a prompt), so both talk about the prompt text, not a file. */
|
|
227
|
+
function delegationDenyMessage() {
|
|
228
|
+
return (
|
|
229
|
+
"Blocked: this delegation's prompt asks to build a new tool/helper, and nothing in " +
|
|
230
|
+
'the prompt shows the wheel was checked first. Pick ONE, then relaunch the same ' +
|
|
231
|
+
'delegation with the prompt updated:\n' +
|
|
232
|
+
' 1. It already exists (repo, installed dep) — do not delegate a new build; ' +
|
|
233
|
+
'reference the existing one in the prompt instead.\n' +
|
|
234
|
+
' 2. It does not exist anywhere you checked — add ONE line to the prompt stating ' +
|
|
235
|
+
`that, e.g.: "${AUDIT_DONE_EXAMPLE_PHRASE}" (any phrase matching ` +
|
|
236
|
+
`/${AUDIT_DONE_PATTERN.source}/i works, this one is guaranteed to). Once the delegate ` +
|
|
237
|
+
'actually writes the file, include the same phrase in its content so it also gets ' +
|
|
238
|
+
'auto-recorded into .ai/tool-map.json.\n' +
|
|
239
|
+
'No filesystem exploration is required to satisfy this gate — the audit is a sentence ' +
|
|
240
|
+
'in the prompt, or a matching package.json dependency, nothing else.'
|
|
241
|
+
);
|
|
242
|
+
}
|
|
207
243
|
|
|
208
244
|
/** A build is cleared when its text declares an audit, its name is already recorded in the
|
|
209
245
|
* map, or an installed dependency covers it. `toolBaseName` is used for the last two. */
|
|
@@ -224,7 +260,7 @@ function checkDelegation(toolInput, cwd, parameters) {
|
|
|
224
260
|
const prompt = delegationPromptOf(toolInput);
|
|
225
261
|
if (!isBuildIntent(prompt)) return;
|
|
226
262
|
if (buildIsCleared(prompt, cwd, parameters, null)) return;
|
|
227
|
-
deny(
|
|
263
|
+
deny(CONFIG_KEY, delegationDenyMessage());
|
|
228
264
|
}
|
|
229
265
|
|
|
230
266
|
/** Write: block a new tool-like file that is not cleared by content, map, or an installed dep. */
|
|
@@ -237,7 +273,7 @@ function checkWrite(toolInput, cwd, parameters) {
|
|
|
237
273
|
const content = writtenContentOf(toolInput);
|
|
238
274
|
const toolBaseName = basename(filePath).replace(/\.[^.]+$/, '');
|
|
239
275
|
if (buildIsCleared(content, cwd, parameters, toolBaseName)) return;
|
|
240
|
-
deny(
|
|
276
|
+
deny(CONFIG_KEY, writeDenyMessage(filePath));
|
|
241
277
|
}
|
|
242
278
|
|
|
243
279
|
runGate(
|
|
@@ -191,7 +191,7 @@ function denyNoLevelDeclared(prompt) {
|
|
|
191
191
|
const excerpt = prompt.slice(0, PROMPT_EXCERPT_LENGTH);
|
|
192
192
|
const ellipsis = prompt.length > PROMPT_EXCERPT_LENGTH ? '…' : '';
|
|
193
193
|
deny(
|
|
194
|
-
|
|
194
|
+
CONFIG_KEY,
|
|
195
195
|
`This implementation delegation ("${excerpt}${ellipsis}") does not declare its risk LEVEL. Add a ` +
|
|
196
196
|
'line such as "LEVEL: STANDARD" (or QUESTION/MICRO/HIGH-RISK, whichever fits) before relaunching ' +
|
|
197
197
|
'this delegation.',
|
|
@@ -200,7 +200,7 @@ function denyNoLevelDeclared(prompt) {
|
|
|
200
200
|
|
|
201
201
|
function denyAmbiguousLevel(levels) {
|
|
202
202
|
deny(
|
|
203
|
-
|
|
203
|
+
CONFIG_KEY,
|
|
204
204
|
`This delegation declares multiple different risk levels (${[...new Set(levels)].join(', ')}) — ` +
|
|
205
205
|
'it is not clear which one governs this task. Declare a single, unambiguous LEVEL for this ' +
|
|
206
206
|
'delegation (remove any decoy/reference mention of a different level) before relaunching.',
|
|
@@ -209,7 +209,7 @@ function denyAmbiguousLevel(levels) {
|
|
|
209
209
|
|
|
210
210
|
function denyLevelContradictsSignal(level, signal) {
|
|
211
211
|
deny(
|
|
212
|
-
|
|
212
|
+
CONFIG_KEY,
|
|
213
213
|
`This delegation declares LEVEL: ${level}, but the request touches the risk signal "${signal}" near ` +
|
|
214
214
|
'an implementation verb (not quoted, not the topic of a documentary deliverable) — that requires ' +
|
|
215
215
|
`LEVEL: HIGH-RISK, not ${level}. Raise the declaration to HIGH-RISK before relaunching.`,
|
|
@@ -150,29 +150,35 @@ runGate(
|
|
|
150
150
|
for (const script of scripts) {
|
|
151
151
|
const before = snapshotConfigs(watchedConfigPaths);
|
|
152
152
|
|
|
153
|
-
let
|
|
153
|
+
let failure = null;
|
|
154
154
|
try {
|
|
155
155
|
execFileSync(process.execPath, [script], {
|
|
156
|
-
stdio: 'ignore',
|
|
156
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
157
157
|
timeout: 5000,
|
|
158
158
|
});
|
|
159
|
-
} catch {
|
|
160
|
-
|
|
159
|
+
} catch (error) {
|
|
160
|
+
failure = error;
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
if (configsTampered(before)) {
|
|
164
164
|
revertConfigs(before);
|
|
165
165
|
deny(
|
|
166
|
-
|
|
166
|
+
CONFIG_KEY,
|
|
167
167
|
`Discovered rule/skill script ${relative(projectRoot, script)} attempted to modify gate security configuration (.ai/config.json or the global config). The change was reverted and the action is blocked.`,
|
|
168
168
|
);
|
|
169
169
|
return;
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
if (
|
|
172
|
+
if (failure) {
|
|
173
|
+
const stderr = String(failure.stderr ?? '').trim();
|
|
174
|
+
const detail = stderr || failure.message || String(failure);
|
|
173
175
|
deny(
|
|
174
|
-
|
|
175
|
-
`Sub-gate
|
|
176
|
+
CONFIG_KEY,
|
|
177
|
+
`Sub-gate '${relative(projectRoot, script)}' failed (exit ` +
|
|
178
|
+
`${failure.status ?? 'unknown'}): ${detail}\n` +
|
|
179
|
+
'No filesystem exploration is needed — fix that script (open it at the path ' +
|
|
180
|
+
'above and address the error shown), or remove it from ' +
|
|
181
|
+
`${parameters.rulesDir}/ if it should not run as a gate.`,
|
|
176
182
|
);
|
|
177
183
|
return;
|
|
178
184
|
}
|
|
@@ -147,15 +147,31 @@ function parseJsonOrNull(text) {
|
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
/** Denies the single first advanced-status feature in the new catalog content that has
|
|
150
|
-
* no contract on disk, or does nothing when every advanced feature has one.
|
|
150
|
+
* no contract on disk, or does nothing when every advanced feature has one. A feature
|
|
151
|
+
* object missing its `name` field is denied too (not silently skipped) — it is malformed
|
|
152
|
+
* catalog data, not a feature this gate has decided has no obligations. */
|
|
151
153
|
function denyIfAdvancedFeatureLacksContract(features, treeRoot) {
|
|
152
154
|
for (const feature of features) {
|
|
153
|
-
if (!
|
|
155
|
+
if (!ADVANCED_STATUSES.has(feature?.status)) continue;
|
|
156
|
+
if (!feature?.name) {
|
|
157
|
+
deny(
|
|
158
|
+
CONFIG_KEY,
|
|
159
|
+
`A feature entry has status '${feature.status}' but no 'name' field: ` +
|
|
160
|
+
`${JSON.stringify(feature)}. Every feature needs a 'name' so its contract tree ` +
|
|
161
|
+
'(.ai/features/<name>/) can be located — add it before writing this status.',
|
|
162
|
+
);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
154
165
|
if (contractExistsFor(treeRoot, feature.name)) continue;
|
|
166
|
+
const featureDirectory = join(
|
|
167
|
+
treeRoot ?? join(process.cwd(), '.ai', 'features'),
|
|
168
|
+
feature.name,
|
|
169
|
+
);
|
|
155
170
|
deny(
|
|
156
|
-
|
|
171
|
+
CONFIG_KEY,
|
|
157
172
|
`Feature '${feature.name}' is set to '${feature.status}' but has no non-empty ` +
|
|
158
|
-
`contract
|
|
173
|
+
`contract on disk. Write ONE of these files under ${featureDirectory}/ (any one ` +
|
|
174
|
+
`is enough): ${CONTRACT_FILES.join(', ')}.`,
|
|
159
175
|
);
|
|
160
176
|
}
|
|
161
177
|
}
|
|
@@ -171,10 +187,11 @@ function checkCatalogWrite(toolInput, catalogPath, treeRoot) {
|
|
|
171
187
|
// still targets the catalog is suspicious on its own -- a spec_ready/done transition
|
|
172
188
|
// hidden behind a malformed payload must not be silently allowed through.
|
|
173
189
|
deny(
|
|
174
|
-
|
|
175
|
-
`The write to ${CATALOG_FILE_NAME} does not parse as JSON.
|
|
176
|
-
'
|
|
177
|
-
'or
|
|
190
|
+
CONFIG_KEY,
|
|
191
|
+
`The write to ${CATALOG_FILE_NAME} does not parse as JSON (JSON.parse threw). This ` +
|
|
192
|
+
'is a syntax problem in the content being written, not a missing-field one — check ' +
|
|
193
|
+
'for a trailing comma, unquoted key, or unclosed bracket in what you are about to ' +
|
|
194
|
+
'write. No filesystem exploration needed; the payload itself is the thing to fix.',
|
|
178
195
|
);
|
|
179
196
|
}
|
|
180
197
|
const features = Array.isArray(parsed?.features) ? parsed.features : [];
|
|
@@ -212,11 +229,15 @@ function checkDelegation(toolInput, treeRoot, exemptSubagents) {
|
|
|
212
229
|
(feature) => !contractExistsFor(treeRoot, feature),
|
|
213
230
|
);
|
|
214
231
|
if (missing.length === citedFeatures.length) {
|
|
232
|
+
const root = treeRoot ?? join(process.cwd(), '.ai', 'features');
|
|
233
|
+
const fileList = missing
|
|
234
|
+
.map((feature) => `${feature} -> ${join(root, feature)}/`)
|
|
235
|
+
.join('\n ');
|
|
215
236
|
deny(
|
|
216
|
-
|
|
217
|
-
`This implementation delegation cites feature(s)
|
|
218
|
-
`
|
|
219
|
-
'
|
|
237
|
+
CONFIG_KEY,
|
|
238
|
+
`This implementation delegation cites feature(s) with no contract on disk:\n ${fileList}\n` +
|
|
239
|
+
`Write ONE of these files in each directory above (any one is enough): ` +
|
|
240
|
+
`${CONTRACT_FILES.join(', ')}. Then relaunch.`,
|
|
220
241
|
);
|
|
221
242
|
}
|
|
222
243
|
}
|
|
@@ -133,7 +133,7 @@ function runLintOverStaged(lintCommand, files, cwd, timeoutMs) {
|
|
|
133
133
|
function denyIfLintFailed(result, lintCommand, escapeHatch) {
|
|
134
134
|
if (result.error) {
|
|
135
135
|
deny(
|
|
136
|
-
|
|
136
|
+
CONFIG_KEY,
|
|
137
137
|
`Could not run the lint command ("${lintCommand}") over the staged files: ` +
|
|
138
138
|
`${result.error.message}. Fix the lint setup or set lintCommand/` +
|
|
139
139
|
`${CONFIG_KEY} in .ai/config.json.`,
|
|
@@ -144,7 +144,7 @@ function denyIfLintFailed(result, lintCommand, escapeHatch) {
|
|
|
144
144
|
`${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim();
|
|
145
145
|
const outputTail = tailLines(combinedOutput, OUTPUT_TAIL_LINES);
|
|
146
146
|
deny(
|
|
147
|
-
|
|
147
|
+
CONFIG_KEY,
|
|
148
148
|
`The files you staged fail lint — commit blocked until your own changes are clean ` +
|
|
149
149
|
`(the rest of the repo is not checked). Fix them, or add "${escapeHatch}" to the ` +
|
|
150
150
|
`commit command for a deliberate exception:\n${outputTail}`,
|
|
@@ -50,7 +50,6 @@ import { dirname, join } from 'node:path';
|
|
|
50
50
|
import { isGateEnabled, gateParameters } from '../../lib/config.mjs';
|
|
51
51
|
import { readHookPayload } from '../../lib/hook-io.mjs';
|
|
52
52
|
|
|
53
|
-
const GATE_ID = 'stop-pending';
|
|
54
53
|
const CONFIG_KEY = 'blockStopWithPendingTasks';
|
|
55
54
|
|
|
56
55
|
const PROJECT_ROOT_MARKERS = ['.git', '.ai'];
|
|
@@ -153,7 +152,7 @@ function main() {
|
|
|
153
152
|
if (blockingTasks.length === 0) return allow();
|
|
154
153
|
|
|
155
154
|
return block(
|
|
156
|
-
`[${
|
|
155
|
+
`[${CONFIG_KEY}] There are ${blockingTasks.length} pending task(s) still active for this ` +
|
|
157
156
|
`project:\n${describeTasks(blockingTasks)}\n` +
|
|
158
157
|
'Close each before ending the turn: `task close <id> --evidence "..."` when done, ' +
|
|
159
158
|
'or `task abandon <id> --reason "..."` when it will not be finished. A task left ' +
|
|
@@ -107,7 +107,7 @@ runGate(
|
|
|
107
107
|
if (!pairedImplementationChanged) return;
|
|
108
108
|
|
|
109
109
|
deny(
|
|
110
|
-
|
|
110
|
+
CONFIG_KEY,
|
|
111
111
|
`Creating test ${testFileName} after its paired implementation was already modified ` +
|
|
112
112
|
'(uncommitted). Write the test before or alongside the implementation, not after — a ' +
|
|
113
113
|
'test that passes the first time it runs proves nothing about the change. If this is a ' +
|
|
@@ -175,7 +175,7 @@ runGate(
|
|
|
175
175
|
|
|
176
176
|
const lines = missing.map((entry) => ` ${entry.line}`).join('\n');
|
|
177
177
|
deny(
|
|
178
|
-
|
|
178
|
+
CONFIG_KEY,
|
|
179
179
|
`Missing ${missing.length} test type(s) the requirement makes mandatory. Test types ` +
|
|
180
180
|
'are chosen ACCORDING TO THE REQUIREMENT: E2E if it touches money/auth/persistence, ' +
|
|
181
181
|
'visual/QA if it touches UI, and always unit or mutation. ' +
|
|
@@ -129,7 +129,7 @@ function recordTool(mapPath, normalizedPath, auditLine, toolMapFile) {
|
|
|
129
129
|
);
|
|
130
130
|
} catch (error) {
|
|
131
131
|
warn(
|
|
132
|
-
|
|
132
|
+
CONFIG_KEY,
|
|
133
133
|
`Could not record this tool in ${toolMapFile}: ${error?.message ?? error}. ` +
|
|
134
134
|
'The build proceeds, but the discovery was not remembered.',
|
|
135
135
|
);
|
|
@@ -178,7 +178,15 @@ const SHELL_WRITE_PATTERNS = [
|
|
|
178
178
|
/\b(?:touch|tee)\s+(?:-\S+\s+)*(['"]?)([^\s'"|;&<>]+)\1/g,
|
|
179
179
|
// cp / mv / install destination is the LAST path; capture the first arg after the command
|
|
180
180
|
// as a cheap proxy (over-detects the source too, which is acceptable — a gate re-checks).
|
|
181
|
-
|
|
181
|
+
// Anchored to the START of a command (start of string, or right after a separator like
|
|
182
|
+
// `;`/`&&`/`||`/`|`), NOT `\b`, which matched "install" as a bare word anywhere — including
|
|
183
|
+
// as npm/pip/yarn's SUBCOMMAND (`npm install -D daisyui` was misread as the Unix `install`
|
|
184
|
+
// utility, capturing the package name "daisyui" as a phantom root-level file target and
|
|
185
|
+
// tripping root-whitelist on a plain dependency install). `npm install`/`pip install`/
|
|
186
|
+
// `yarn install` never start a shell command with the bare word "install" as argv[0], so
|
|
187
|
+
// anchoring at the command boundary excludes them while still catching a real `install ...`
|
|
188
|
+
// invocation (the coreutils/BSD command) at the start of a command.
|
|
189
|
+
/(?:^|[;&|])\s*(?:cp|mv|install)\s+(?:-\S+\s+)*(['"]?)([^\s'"|;&<>]+)\1/g,
|
|
182
190
|
];
|
|
183
191
|
|
|
184
192
|
/**
|
|
@@ -359,7 +367,7 @@ export async function runGate(gate, check) {
|
|
|
359
367
|
});
|
|
360
368
|
} catch (error) {
|
|
361
369
|
deny(
|
|
362
|
-
gate.
|
|
370
|
+
gate.configKey,
|
|
363
371
|
`The gate failed to evaluate and blocks the action for safety: ${error?.message ?? error}.`,
|
|
364
372
|
);
|
|
365
373
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tasks",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
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"
|