@devrik-tools/claude-gates 0.7.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/README.es.md +129 -11
  3. package/README.md +120 -10
  4. package/cli/doctor.mjs +132 -0
  5. package/cli/evidence.mjs +78 -0
  6. package/cli/hooks-manifest.mjs +89 -0
  7. package/cli/index.mjs +124 -6
  8. package/cli/init.mjs +97 -3
  9. package/cli/log.mjs +70 -0
  10. package/cli/materialize.mjs +36 -2
  11. package/cli/registry.mjs +12 -1
  12. package/cli/selection.mjs +18 -0
  13. package/cli/smoke-fixtures.json +159 -25
  14. package/cli/task.mjs +104 -11
  15. package/cli/toggle.mjs +125 -0
  16. package/package.json +4 -3
  17. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  18. package/plugins/gates/hooks/doctor.mjs +47 -1
  19. package/plugins/gates/hooks/gates/atomic-commit/index.mjs +92 -119
  20. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +101 -66
  21. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -8
  22. package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +13 -64
  23. package/plugins/gates/hooks/gates/bash-commands/index.mjs +186 -163
  24. package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +100 -101
  25. package/plugins/gates/hooks/gates/brief-approved/index.mjs +71 -140
  26. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +56 -137
  27. package/plugins/gates/hooks/gates/capability-map/index.mjs +280 -506
  28. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +94 -215
  29. package/plugins/gates/hooks/gates/circuit-breaker/track.mjs +285 -0
  30. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
  31. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
  32. package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
  33. package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
  34. package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
  35. package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
  36. package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
  37. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
  38. package/plugins/gates/hooks/gates/force-parallel/index.mjs +53 -95
  39. package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
  40. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
  41. package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
  42. package/plugins/gates/hooks/gates/library-docs/index.mjs +338 -0
  43. package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
  44. package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
  45. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +48 -95
  46. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
  47. package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
  48. package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
  49. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
  50. package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
  51. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
  52. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
  53. package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
  54. package/plugins/gates/hooks/gates/no-trivial-scripts/index.mjs +114 -0
  55. package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
  56. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
  57. package/plugins/gates/hooks/gates/require-monitor/index.mjs +126 -0
  58. package/plugins/gates/hooks/gates/require-task-split/index.mjs +88 -0
  59. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
  60. package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
  61. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
  62. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
  63. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -97
  64. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
  65. package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
  66. package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
  67. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
  68. package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
  69. package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
  70. package/plugins/gates/hooks/hooks.json +152 -42
  71. package/plugins/gates/hooks/lib/config.mjs +6 -3
  72. package/plugins/gates/hooks/lib/delegation.mjs +352 -0
  73. package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
  74. package/plugins/gates/hooks/lib/git.mjs +262 -0
  75. package/plugins/gates/hooks/lib/hook-io.mjs +451 -83
  76. package/plugins/gates/hooks/lib/session-state.mjs +150 -0
  77. package/plugins/gates/hooks/lib/signals.mjs +18 -13
  78. package/plugins/gates/hooks/lib/testing.mjs +148 -0
  79. package/plugins/gates/hooks/lib/tools.mjs +144 -0
  80. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  81. package/plugins/tasks/hooks/lib/task-store.mjs +12 -6
  82. package/plugins/tasks/hooks/register-requests.mjs +37 -10
  83. package/registry.json +249 -38
@@ -0,0 +1,64 @@
1
+ import { gateParameters, isGateEnabled } from '../../lib/config.mjs';
2
+ import { coerceParameters, readHookPayload } from '../../lib/hook-io.mjs';
3
+ import { CONFIG_KEY, DEFAULT_PARAMS } from './shared.mjs';
4
+
5
+ const PROBE_TIMEOUT_MS = 1500;
6
+ const SESSION_START_EVENT = 'SessionStart';
7
+ const NOT_ENROLLED = 'non_enrolled_pending_mutations';
8
+
9
+ async function getJson(url) {
10
+ const response = await fetch(url, {
11
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
12
+ });
13
+ if (!response.ok) return null;
14
+ return response.json();
15
+ }
16
+
17
+ function withoutTrailingSlash(text) {
18
+ let end = text.length;
19
+ while (end > 0 && text[end - 1] === '/') end -= 1;
20
+ return text.slice(0, end);
21
+ }
22
+
23
+ function speak(context) {
24
+ process.stdout.write(
25
+ JSON.stringify({
26
+ hookSpecificOutput: {
27
+ hookEventName: SESSION_START_EVENT,
28
+ additionalContext: context,
29
+ },
30
+ }),
31
+ );
32
+ }
33
+
34
+ function enrollmentProblem(status, projectName) {
35
+ if (!status || status.reason_code !== NOT_ENROLLED) return null;
36
+ const message = String(status.reason_message ?? '');
37
+ if (projectName && !message.includes(`${projectName}=`)) return null;
38
+ return projectName ?? 'this project';
39
+ }
40
+
41
+ async function main() {
42
+ const cwd = process.cwd();
43
+ if (!isGateEnabled(CONFIG_KEY, true, cwd)) return;
44
+ const { parameters } = coerceParameters(
45
+ DEFAULT_PARAMS,
46
+ gateParameters(CONFIG_KEY, cwd),
47
+ );
48
+ const base = withoutTrailingSlash(String(parameters.engramUrl));
49
+ const status = await getJson(`${base}/sync/status`);
50
+ if (!status) return;
51
+ const current = await getJson(
52
+ `${base}/project/current?cwd=${encodeURIComponent(cwd)}`,
53
+ );
54
+ const project = enrollmentProblem(status, current?.project);
55
+ if (!project) return;
56
+ speak(
57
+ `[${CONFIG_KEY}] Engram cloud is configured but "${project}" is not enrolled, so its memories stay ` +
58
+ `local only (daemon reports ${status.reason_code}). Run \`engram cloud enroll ${project}\` once ` +
59
+ 'to replicate them; local SQLite remains the source of truth either way.',
60
+ );
61
+ }
62
+
63
+ readHookPayload();
64
+ main().catch(() => {});
@@ -0,0 +1,90 @@
1
+ import {
2
+ mcpActionSegment,
3
+ mcpServerSegment,
4
+ toolInGroups,
5
+ } from '../../lib/hook-io.mjs';
6
+ import {
7
+ readSessionState,
8
+ writeSessionState,
9
+ } from '../../lib/session-state.mjs';
10
+
11
+ export const GATE_ID = 'engram-first';
12
+ export const CONFIG_KEY = 'requireEngramBeforeResearch';
13
+
14
+ export const DEFAULT_PARAMS = Object.freeze({
15
+ engramServers: ['engram', 'plugin_engram_engram'],
16
+ context7Servers: ['context7', 'plugin_context7_context7'],
17
+ researchTools: ['WebSearch', 'WebFetch'],
18
+ engramUrl: 'http://127.0.0.1:7437',
19
+ requireSaveBeforeStop: true,
20
+ });
21
+
22
+ const MAX_RECORDED_QUERIES = 30;
23
+ const MAX_QUERY_LENGTH = 200;
24
+
25
+ export const EMPTY_STATE = Object.freeze({
26
+ memSearchCount: 0,
27
+ memSearchHits: 0,
28
+ memSearchQueries: [],
29
+ memSaveCount: 0,
30
+ researchCalls: 0,
31
+ lastResearchAt: 0,
32
+ lastMemSaveAt: 0,
33
+ });
34
+
35
+ function serverMatches(toolName, servers) {
36
+ const server = mcpServerSegment(String(toolName ?? '')).toLowerCase();
37
+ return servers.some((name) => String(name).toLowerCase() === server);
38
+ }
39
+
40
+ export function isEngramTool(toolName, parameters) {
41
+ return serverMatches(toolName, parameters.engramServers);
42
+ }
43
+
44
+ export function isContext7Tool(toolName, parameters) {
45
+ return serverMatches(toolName, parameters.context7Servers);
46
+ }
47
+
48
+ export function isResearchTool(toolName, parameters) {
49
+ if (isEngramTool(toolName, parameters)) return false;
50
+ const lowered = String(toolName ?? '').toLowerCase();
51
+ if (parameters.researchTools.some((name) => name.toLowerCase() === lowered))
52
+ return true;
53
+ if (isContext7Tool(toolName, parameters)) return true;
54
+ return toolInGroups(toolName, ['research']);
55
+ }
56
+
57
+ export function engramActionOf(toolName) {
58
+ return mcpActionSegment(String(toolName ?? '')).toLowerCase();
59
+ }
60
+
61
+ export function readState(sessionId, cwd) {
62
+ return {
63
+ ...EMPTY_STATE,
64
+ ...readSessionState(GATE_ID, sessionId, {}, { cwd }),
65
+ };
66
+ }
67
+
68
+ export function writeState(sessionId, state, cwd) {
69
+ return writeSessionState(GATE_ID, sessionId, state, { cwd });
70
+ }
71
+
72
+ export function rememberQuery(state, query) {
73
+ const queries = [
74
+ ...state.memSearchQueries,
75
+ String(query ?? '').slice(0, MAX_QUERY_LENGTH),
76
+ ];
77
+ return queries.slice(-MAX_RECORDED_QUERIES);
78
+ }
79
+
80
+ export function queryOf(toolInput) {
81
+ if (!toolInput || typeof toolInput !== 'object') return '';
82
+ return String(
83
+ toolInput.query ??
84
+ toolInput.title ??
85
+ toolInput.libraryName ??
86
+ toolInput.prompt ??
87
+ toolInput.url ??
88
+ '',
89
+ );
90
+ }
@@ -0,0 +1,23 @@
1
+ import { block, runStopHook } from '../../lib/hook-io.mjs';
2
+ import { CONFIG_KEY, DEFAULT_PARAMS, GATE_ID, readState } from './shared.mjs';
3
+
4
+ runStopHook(
5
+ {
6
+ id: GATE_ID,
7
+ configKey: CONFIG_KEY,
8
+ enabledByDefault: true,
9
+ defaultParams: { ...DEFAULT_PARAMS },
10
+ },
11
+ ({ sessionId, parameters, cwd }) => {
12
+ if (!parameters.requireSaveBeforeStop) return;
13
+ const state = readState(sessionId, cwd);
14
+ if (state.researchCalls === 0) return;
15
+ if (state.lastMemSaveAt >= state.lastResearchAt) return;
16
+ block(
17
+ CONFIG_KEY,
18
+ `This session made ${state.researchCalls} research call(s) (web/context7) and nothing was saved to ` +
19
+ 'engram afterwards. Call mem_save with what the research established (What/Why/Where/Learned, ' +
20
+ 'or mem_session_summary if it was broad), then end the turn. Research that is not saved is repeated next session.',
21
+ );
22
+ },
23
+ );
@@ -0,0 +1,97 @@
1
+ import { gateParameters, isGateEnabled } from '../../lib/config.mjs';
2
+ import {
3
+ allow,
4
+ coerceParameters,
5
+ readHookPayload,
6
+ sessionIdOf,
7
+ toolInputOf,
8
+ toolNameOf,
9
+ toolResponseOf,
10
+ } from '../../lib/hook-io.mjs';
11
+ import {
12
+ CONFIG_KEY,
13
+ DEFAULT_PARAMS,
14
+ engramActionOf,
15
+ isEngramTool,
16
+ isResearchTool,
17
+ queryOf,
18
+ readState,
19
+ rememberQuery,
20
+ writeState,
21
+ } from './shared.mjs';
22
+
23
+ const SAVE_ACTIONS = new Set(['mem_save', 'mem_session_summary', 'mem_update']);
24
+ const NO_RESULT_PATTERN = /no memories found|no results|0 results/i;
25
+
26
+ function responseText(toolResponse) {
27
+ if (typeof toolResponse === 'string') return toolResponse;
28
+ try {
29
+ return JSON.stringify(toolResponse ?? '');
30
+ } catch {
31
+ return '';
32
+ }
33
+ }
34
+
35
+ function searchHit(toolResponse) {
36
+ const text = responseText(toolResponse);
37
+ return text.length > 0 && !NO_RESULT_PATTERN.test(text);
38
+ }
39
+
40
+ function nextState(state, { toolName, toolInput, toolResponse, parameters }) {
41
+ const now = Date.now();
42
+ if (isEngramTool(toolName, parameters)) {
43
+ const action = engramActionOf(toolName);
44
+ if (action === 'mem_search') {
45
+ return {
46
+ ...state,
47
+ memSearchCount: state.memSearchCount + 1,
48
+ memSearchHits: state.memSearchHits + (searchHit(toolResponse) ? 1 : 0),
49
+ memSearchQueries: rememberQuery(state, queryOf(toolInput)),
50
+ };
51
+ }
52
+ if (SAVE_ACTIONS.has(action)) {
53
+ return {
54
+ ...state,
55
+ memSaveCount: state.memSaveCount + 1,
56
+ lastMemSaveAt: now,
57
+ };
58
+ }
59
+ return state;
60
+ }
61
+ if (isResearchTool(toolName, parameters)) {
62
+ return {
63
+ ...state,
64
+ researchCalls: state.researchCalls + 1,
65
+ lastResearchAt: now,
66
+ };
67
+ }
68
+ return state;
69
+ }
70
+
71
+ function main() {
72
+ const rawPayload = readHookPayload();
73
+ if (rawPayload === null) allow();
74
+ const cwd = process.cwd();
75
+ if (!isGateEnabled(CONFIG_KEY, true, cwd)) allow();
76
+ const { parameters } = coerceParameters(
77
+ DEFAULT_PARAMS,
78
+ gateParameters(CONFIG_KEY, cwd),
79
+ );
80
+ const sessionId = sessionIdOf(rawPayload);
81
+ const context = {
82
+ toolName: toolNameOf(rawPayload) ?? '',
83
+ toolInput: toolInputOf(rawPayload),
84
+ toolResponse: toolResponseOf(rawPayload),
85
+ parameters,
86
+ };
87
+ const state = readState(sessionId, cwd);
88
+ const updated = nextState(state, context);
89
+ if (updated !== state) writeState(sessionId, updated, cwd);
90
+ allow();
91
+ }
92
+
93
+ try {
94
+ main();
95
+ } catch {
96
+ allow();
97
+ }
@@ -1,28 +1,22 @@
1
- // feature-catalog — enforces the machine-readable feature catalog's own invariants on
2
- // a write to that file: at most one feature `in_progress` at a time, and `done` is
3
- // never written directly (only a review/QA process closes a feature). Migrated from
4
- // ~/.claude/hooks/guard-feature-catalog.mjs.
1
+ // feature-catalog — enforces the feature catalog's own invariants on a change to that
2
+ // file: at most `maxInProgress` features in_progress, and no feature moved to `done` by a
3
+ // direct write (only a review/QA process closes a feature).
5
4
  //
6
- // ── What a project can configure (params) ───────────────────────────────────────────
7
- // catalogFileName basename of the catalog file this gate watches for (default
8
- // feature_list.json). A write to any other file is ignored.
9
- // maxInProgress how many features may be `in_progress` simultaneously.
10
- // The defaults live here, in the source, so a project reads them and knows exactly what
11
- // its override replaces.
12
- //
13
- // ── Auto-off when the project never adopted the catalog ────────────────────────────
14
- // This gate only inspects the CONTENT being written to a file named `catalogFileName`.
15
- // A project that never uses that file never triggers it — there is nothing to disable
16
- // separately, the check is inert by construction rather than by a discovery pass.
17
- //
18
- // ── What is NOT configurable (base, non-negotiable) ─────────────────────────────────
19
- // Writing `status: done` directly is always denied, regardless of `maxInProgress`: only
20
- // a review/QA subagent or a validated automated process may close a feature, and this
21
- // gate has no way to tell who is writing, so it blocks the write itself.
5
+ // Decisions: the gate judges the CHANGE, not the content — the existing catalog is read
6
+ // from disk and a feature that was already `done` stays writable; only a NEW transition to
7
+ // done is denied. An Edit is applied to the disk content when its old_string is found, so
8
+ // the in_progress count covers the whole file, not the fragment. A shell redirect/copy
9
+ // onto the catalog is denied outright: its resulting content cannot be judged. An empty
10
+ // `catalogFileName` turns the gate off.
22
11
 
12
+ import { readFileSync } from 'node:fs';
13
+ import { isAbsolute, join } from 'node:path';
14
+ import { projectRootOf, stripBom } from '../../lib/config.mjs';
23
15
  import {
24
16
  runGate,
25
17
  deny,
18
+ shellCommandOf,
19
+ shellWrittenPaths,
26
20
  toolInGroups,
27
21
  writtenContentOf,
28
22
  writtenPathOf,
@@ -34,8 +28,131 @@ const CONFIG_KEY = 'requireFeatureCatalog';
34
28
  const DEFAULT_CATALOG_FILE_NAME = 'feature_list.json';
35
29
  const DEFAULT_MAX_IN_PROGRESS = 1;
36
30
 
37
- const DONE_STATUS_PATTERN = /"status"\s*:\s*"done"|status\s*:\s*['"]done['"]/i;
38
- const IN_PROGRESS_STATUS_PATTERN = /"status"\s*:\s*"in_progress"/g;
31
+ const DONE_STATUS = 'done';
32
+ const IN_PROGRESS_STATUS = 'in_progress';
33
+ const STATUS_PATTERN = /["']?status["']?\s*:\s*["']?(done|in_progress)["']?/gi;
34
+
35
+ function parseJsonOrNull(text) {
36
+ try {
37
+ return JSON.parse(text);
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ function featuresOf(parsed) {
44
+ if (Array.isArray(parsed)) return parsed;
45
+ if (parsed && Array.isArray(parsed.features)) return parsed.features;
46
+ return null;
47
+ }
48
+
49
+ function countStatuses(text) {
50
+ const counts = { done: 0, inProgress: 0 };
51
+ for (const match of String(text).matchAll(STATUS_PATTERN)) {
52
+ if (match[1].toLowerCase() === DONE_STATUS) counts.done += 1;
53
+ else counts.inProgress += 1;
54
+ }
55
+ return counts;
56
+ }
57
+
58
+ function snapshotOf(text) {
59
+ const features = featuresOf(parseJsonOrNull(text));
60
+ if (!features) {
61
+ const counts = countStatuses(text);
62
+ return {
63
+ parsed: false,
64
+ done: null,
65
+ doneCount: counts.done,
66
+ inProgress: counts.inProgress,
67
+ };
68
+ }
69
+ const done = new Set();
70
+ let inProgress = 0;
71
+ features.forEach((feature, index) => {
72
+ const status = String(feature?.status ?? '').toLowerCase();
73
+ if (status === DONE_STATUS)
74
+ done.add(String(feature?.name ?? feature?.id ?? `#${index}`));
75
+ if (status === IN_PROGRESS_STATUS) inProgress += 1;
76
+ });
77
+ return { parsed: true, done, doneCount: done.size, inProgress };
78
+ }
79
+
80
+ function newlyDone(before, after) {
81
+ if (before.parsed && after.parsed)
82
+ return [...after.done].filter((name) => !before.done.has(name));
83
+ return after.doneCount > before.doneCount ? ['(unnamed feature)'] : [];
84
+ }
85
+
86
+ function diskContentOf(path) {
87
+ try {
88
+ return stripBom(readFileSync(path, 'utf8'));
89
+ } catch {
90
+ return '';
91
+ }
92
+ }
93
+
94
+ function applyEdits(diskContent, toolInput) {
95
+ const edits = Array.isArray(toolInput.edits) ? toolInput.edits : [toolInput];
96
+ let content = diskContent;
97
+ for (const edit of edits) {
98
+ const oldString = String(edit?.old_string ?? '');
99
+ const newString = String(edit?.new_string ?? '');
100
+ if (!oldString || !content.includes(oldString)) return null;
101
+ content = edit?.replace_all
102
+ ? content.replaceAll(oldString, newString)
103
+ : content.replace(oldString, newString);
104
+ }
105
+ return content;
106
+ }
107
+
108
+ function isEdit(toolInput) {
109
+ return (
110
+ Array.isArray(toolInput.edits) ||
111
+ typeof toolInput.old_string === 'string' ||
112
+ typeof toolInput.new_string === 'string'
113
+ );
114
+ }
115
+
116
+ function beforeAndAfter(toolInput, catalogPath) {
117
+ const diskContent = diskContentOf(catalogPath);
118
+ if (!isEdit(toolInput))
119
+ return { before: diskContent, after: writtenContentOf(toolInput) };
120
+ const applied = applyEdits(diskContent, toolInput);
121
+ if (applied !== null) return { before: diskContent, after: applied };
122
+ // The fragment cannot be placed in the file: judge it on its own, against nothing.
123
+ return { before: '', after: writtenContentOf(toolInput) };
124
+ }
125
+
126
+ function judgeChange(before, after, catalogFileName, maxInProgress) {
127
+ const beforeSnapshot = snapshotOf(before);
128
+ const afterSnapshot = snapshotOf(after);
129
+
130
+ const closed = newlyDone(beforeSnapshot, afterSnapshot);
131
+ if (closed.length > 0) {
132
+ deny(
133
+ CONFIG_KEY,
134
+ `This write moves ${closed.join(', ')} to 'status: done' in ${catalogFileName}. A feature is ` +
135
+ 'never closed by a direct write: only a review/QA subagent or a validated automated process ' +
136
+ 'may set done. Leave the previous status and let the review step close it.',
137
+ );
138
+ }
139
+
140
+ if (afterSnapshot.inProgress > maxInProgress) {
141
+ deny(
142
+ CONFIG_KEY,
143
+ `${catalogFileName} would have ${afterSnapshot.inProgress} features 'in_progress'; ` +
144
+ `the maximum allowed is ${maxInProgress} (maxInProgress). Finish or park one before starting another.`,
145
+ );
146
+ }
147
+ }
148
+
149
+ function denyShellWrite(catalogFileName, path) {
150
+ deny(
151
+ CONFIG_KEY,
152
+ `This shell command writes to the feature catalog (${path}) through a redirect/copy/move. ` +
153
+ `Edit ${catalogFileName} with the Write/Edit tool instead, so the status transitions can be checked.`,
154
+ );
155
+ }
39
156
 
40
157
  runGate(
41
158
  {
@@ -47,37 +164,26 @@ runGate(
47
164
  maxInProgress: DEFAULT_MAX_IN_PROGRESS,
48
165
  },
49
166
  },
50
- ({ toolName, toolInput, parameters }) => {
51
- if (!toolInGroups(toolName, ['write'])) return;
52
-
53
- const target = writtenPathOf(toolInput);
54
- const catalogFileName = String(
55
- parameters.catalogFileName ?? DEFAULT_CATALOG_FILE_NAME,
56
- );
57
- if (!target.includes(catalogFileName)) return;
167
+ ({ toolName, toolInput, parameters, cwd }) => {
168
+ const catalogFileName = String(parameters.catalogFileName ?? '').trim();
169
+ if (!catalogFileName) return;
170
+ const targetsCatalog = (path) => String(path).includes(catalogFileName);
58
171
 
59
- const content = writtenContentOf(toolInput);
60
-
61
- // Base, non-negotiable: `done` is never written directly to the catalog.
62
- if (DONE_STATUS_PATTERN.test(content)) {
63
- deny(
64
- CONFIG_KEY,
65
- `Writing 'status: done' directly to ${catalogFileName} is not allowed. ` +
66
- 'Only a review/QA subagent or a validated automated process may close a feature.',
172
+ if (toolInGroups(toolName, ['shell'])) {
173
+ const written = shellWrittenPaths(shellCommandOf(toolInput)).find(
174
+ targetsCatalog,
67
175
  );
176
+ if (written) denyShellWrite(catalogFileName, written);
177
+ return;
68
178
  }
69
179
 
70
- const maxInProgress = Number(
71
- parameters.maxInProgress ?? DEFAULT_MAX_IN_PROGRESS,
72
- );
73
- const inProgressCount = (content.match(IN_PROGRESS_STATUS_PATTERN) ?? [])
74
- .length;
75
- if (inProgressCount > maxInProgress) {
76
- deny(
77
- CONFIG_KEY,
78
- `${catalogFileName} would have ${inProgressCount} features 'in_progress'; ` +
79
- `the maximum allowed is ${maxInProgress}.`,
80
- );
81
- }
180
+ if (!toolInGroups(toolName, ['write'])) return;
181
+ const target = writtenPathOf(toolInput);
182
+ if (!targetsCatalog(target)) return;
183
+
184
+ const root = projectRootOf(cwd) ?? cwd;
185
+ const catalogPath = isAbsolute(target) ? target : join(root, target);
186
+ const { before, after } = beforeAndAfter(toolInput, catalogPath);
187
+ judgeChange(before, after, catalogFileName, parameters.maxInProgress);
82
188
  },
83
189
  );