@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.
- package/.claude-plugin/marketplace.json +3 -3
- package/README.es.md +129 -11
- package/README.md +120 -10
- 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 +12 -1
- package/cli/selection.mjs +18 -0
- package/cli/smoke-fixtures.json +159 -25
- package/cli/task.mjs +104 -11
- package/cli/toggle.mjs +125 -0
- package/package.json +4 -3
- 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 +94 -215
- package/plugins/gates/hooks/gates/circuit-breaker/track.mjs +285 -0
- 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 +53 -95
- 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 +338 -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/no-trivial-scripts/index.mjs +114 -0
- 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/require-monitor/index.mjs +126 -0
- package/plugins/gates/hooks/gates/require-task-split/index.mjs +88 -0
- 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 +152 -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 +451 -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 +148 -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 +12 -6
- package/plugins/tasks/hooks/register-requests.mjs +37 -10
- 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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
|
38
|
-
const
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
);
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
);
|