@devrik-tools/claude-gates 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/README.es.md +219 -0
  3. package/README.md +222 -0
  4. package/cli/__tests__/config.test.mjs +101 -0
  5. package/cli/__tests__/init-flags.test.mjs +111 -0
  6. package/cli/__tests__/registry-gates-consistency.test.mjs +44 -0
  7. package/cli/__tests__/selection.test.mjs +200 -0
  8. package/cli/config.mjs +113 -0
  9. package/cli/constants.mjs +36 -0
  10. package/cli/index.mjs +97 -0
  11. package/cli/init.mjs +256 -0
  12. package/cli/install.mjs +80 -0
  13. package/cli/materialize.mjs +51 -0
  14. package/cli/registry.mjs +127 -0
  15. package/cli/selection.mjs +83 -0
  16. package/package.json +73 -0
  17. package/plugins/gates/.claude-plugin/plugin.json +9 -0
  18. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -0
  19. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +92 -0
  20. package/plugins/gates/hooks/gates/bash-commands/index.mjs +233 -0
  21. package/plugins/gates/hooks/gates/bash-commands/test.mjs +113 -0
  22. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +177 -0
  23. package/plugins/gates/hooks/gates/brief-before-delegate/test.mjs +111 -0
  24. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +456 -0
  25. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +143 -0
  26. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +118 -0
  27. package/plugins/gates/hooks/gates/dependency-skills/test.mjs +109 -0
  28. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +49 -0
  29. package/plugins/gates/hooks/gates/diagnosis-before-patch/test.mjs +68 -0
  30. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +100 -0
  31. package/plugins/gates/hooks/gates/feature-catalog/test.mjs +97 -0
  32. package/plugins/gates/hooks/gates/forge-flow/index.mjs +112 -0
  33. package/plugins/gates/hooks/gates/forge-flow/test.mjs +135 -0
  34. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +186 -0
  35. package/plugins/gates/hooks/gates/implementation-pipeline/test.mjs +86 -0
  36. package/plugins/gates/hooks/gates/intent-flow/index.mjs +238 -0
  37. package/plugins/gates/hooks/gates/intent-flow/test.mjs +136 -0
  38. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +166 -0
  39. package/plugins/gates/hooks/gates/mandatory-flow/test.mjs +119 -0
  40. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +85 -0
  41. package/plugins/gates/hooks/gates/neutral-spanish/test.mjs +65 -0
  42. package/plugins/gates/hooks/gates/never-assume/index.mjs +55 -0
  43. package/plugins/gates/hooks/gates/never-assume/test.mjs +78 -0
  44. package/plugins/gates/hooks/gates/no-blocking/index.mjs +142 -0
  45. package/plugins/gates/hooks/gates/no-blocking/test.mjs +108 -0
  46. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +120 -0
  47. package/plugins/gates/hooks/gates/no-memory-dependency/test.mjs +106 -0
  48. package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +202 -0
  49. package/plugins/gates/hooks/gates/no-reconfirm/test.mjs +131 -0
  50. package/plugins/gates/hooks/gates/protected-paths/index.mjs +147 -0
  51. package/plugins/gates/hooks/gates/protected-paths/test.mjs +75 -0
  52. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +64 -0
  53. package/plugins/gates/hooks/gates/recurrence-lock/test.mjs +99 -0
  54. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +150 -0
  55. package/plugins/gates/hooks/gates/reuse-before-build/test.mjs +101 -0
  56. package/plugins/gates/hooks/gates/risk-level/index.mjs +203 -0
  57. package/plugins/gates/hooks/gates/risk-level/test.mjs +125 -0
  58. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +50 -0
  59. package/plugins/gates/hooks/gates/root-cause-first/test.mjs +73 -0
  60. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +111 -0
  61. package/plugins/gates/hooks/gates/root-whitelist/test.mjs +80 -0
  62. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +99 -0
  63. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/test.mjs +78 -0
  64. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +251 -0
  65. package/plugins/gates/hooks/gates/sdd-specs/test.mjs +163 -0
  66. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -0
  67. package/plugins/gates/hooks/gates/test-after-implementation/test.mjs +81 -0
  68. package/plugins/gates/hooks/gates/test-matrix/index.mjs +181 -0
  69. package/plugins/gates/hooks/gates/test-matrix/test.mjs +87 -0
  70. package/plugins/gates/hooks/gates/tool-map/index.mjs +140 -0
  71. package/plugins/gates/hooks/gates/tool-map/test.mjs +87 -0
  72. package/plugins/gates/hooks/hooks.json +266 -0
  73. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +154 -0
  74. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +154 -0
  75. package/plugins/gates/hooks/lib/config.mjs +165 -0
  76. package/plugins/gates/hooks/lib/hook-io.mjs +208 -0
  77. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +132 -0
  78. package/plugins/tasks/hooks/lib/task-store.mjs +159 -0
  79. package/plugins/tasks/hooks/register-requests.mjs +108 -0
  80. package/registry.json +668 -0
@@ -0,0 +1,118 @@
1
+ import { readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { runGate, warn, TOOL_GROUPS } from '../../lib/hook-io.mjs';
4
+
5
+ const GATE_ID = 'dependency-skills';
6
+ const CONFIG_KEY = 'requireSkillForNewDependency';
7
+
8
+ const DEFAULT_DEPS_WITHOUT_OWN_API = [
9
+ 'clsx',
10
+ 'classnames',
11
+ 'lodash.debounce',
12
+ 'lodash.throttle',
13
+ 'lodash.merge',
14
+ 'tslib',
15
+ 'core-js',
16
+ 'regenerator-runtime',
17
+ ];
18
+
19
+ function normalize(name) {
20
+ return name.replace(/^@/, '').replace(/\//g, '-').toLowerCase();
21
+ }
22
+
23
+ function isExempt(dependencyName, dependenciesWithoutOwnApi) {
24
+ if (dependencyName.startsWith('@types/')) return true;
25
+ return dependenciesWithoutOwnApi.includes(dependencyName);
26
+ }
27
+
28
+ function listSkillDirectories(skillsDirectoryPath) {
29
+ try {
30
+ return readdirSync(skillsDirectoryPath, { withFileTypes: true })
31
+ .filter((entry) => entry.isDirectory())
32
+ .map((entry) => entry.name);
33
+ } catch {
34
+ // No skills directory in this project — nothing to cross-check against.
35
+ return [];
36
+ }
37
+ }
38
+
39
+ function skillNameMatches(normalizedDependency, skillDirectoryName) {
40
+ const normalizedSkill = normalize(skillDirectoryName);
41
+ return (
42
+ normalizedDependency.includes(normalizedSkill) ||
43
+ normalizedSkill.includes(normalizedDependency)
44
+ );
45
+ }
46
+
47
+ function hasMatchingSkill(dependencyName, skillDirectories) {
48
+ const normalizedDependency = normalize(dependencyName);
49
+ return skillDirectories.some((skillDirectoryName) =>
50
+ skillNameMatches(normalizedDependency, skillDirectoryName),
51
+ );
52
+ }
53
+
54
+ function needsSkillReview(dependencyName, parameters, skillDirectories) {
55
+ if (isExempt(dependencyName, parameters.depsWithoutOwnApi)) return false;
56
+ return !hasMatchingSkill(dependencyName, skillDirectories);
57
+ }
58
+
59
+ /** Parsed `package.json` dependency names, or null when it cannot be read yet. */
60
+ function dependencyNamesIn(content) {
61
+ let parsed;
62
+ try {
63
+ parsed = JSON.parse(content);
64
+ } catch {
65
+ // Mid-edit or partially written package.json — nothing reliable to check yet.
66
+ return null;
67
+ }
68
+ const dependencies = {
69
+ ...(parsed.dependencies ?? {}),
70
+ ...(parsed.devDependencies ?? {}),
71
+ };
72
+ return Object.keys(dependencies);
73
+ }
74
+
75
+ runGate(
76
+ {
77
+ id: GATE_ID,
78
+ configKey: CONFIG_KEY,
79
+ enabledByDefault: true,
80
+ defaultParams: {
81
+ depsWithoutOwnApi: DEFAULT_DEPS_WITHOUT_OWN_API,
82
+ projectSkillsDir: '.claude/skills',
83
+ },
84
+ },
85
+ ({ toolName, toolInput, parameters }) => {
86
+ if (!TOOL_GROUPS.write.includes(toolName)) return;
87
+
88
+ const filePath = toolInput?.file_path ?? '';
89
+ if (!filePath.replace(/\\/g, '/').endsWith('package.json')) return;
90
+
91
+ const content = toolInput?.content ?? toolInput?.new_string;
92
+ if (typeof content !== 'string') return;
93
+
94
+ const dependencyNames = dependencyNamesIn(content);
95
+ if (!dependencyNames || dependencyNames.length === 0) return;
96
+
97
+ const skillDirectories = listSkillDirectories(
98
+ join(process.cwd(), parameters.projectSkillsDir),
99
+ );
100
+
101
+ const unmatched = dependencyNames.filter((name) =>
102
+ needsSkillReview(name, parameters, skillDirectories),
103
+ );
104
+ if (unmatched.length === 0) return;
105
+
106
+ warn(
107
+ GATE_ID,
108
+ `New dependencies without a matching skill in ${parameters.projectSkillsDir}: ${unmatched.join(', ')}. Consider adding a skill documenting how to use them, or add them to depsWithoutOwnApi if they need none.`,
109
+ );
110
+ },
111
+ );
112
+
113
+ // Deliberately simplified vs. the source guard (guard-dependencies-skills.mjs):
114
+ // dropped the hardcoded alias table (stripe -> stripe-payments, etc.) and the
115
+ // .ai/skills-baseline.json freeze mechanism — that belongs to a separate
116
+ // adopt-stack-and-skills.mjs script that does not exist in this repo. This
117
+ // gate only does simple substring matching between dependency name and skill
118
+ // directory name, both normalized.
@@ -0,0 +1,109 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { test } from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
10
+
11
+ function runGate(payload, { config, skills } = {}) {
12
+ const project = mkdtempSync(join(tmpdir(), 'dependency-skills-'));
13
+ mkdirSync(join(project, '.git'));
14
+ if (config) {
15
+ mkdirSync(join(project, '.ai'));
16
+ writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
17
+ }
18
+ if (skills) {
19
+ for (const name of skills) {
20
+ mkdirSync(join(project, '.claude', 'skills', name), { recursive: true });
21
+ }
22
+ }
23
+ const out = execFileSync(process.execPath, [GATE], {
24
+ input: JSON.stringify(payload),
25
+ encoding: 'utf8',
26
+ cwd: project,
27
+ // Isolate from the user's real global config: point homedir() at the temp
28
+ // project so the global-config fallback finds nothing (registry default).
29
+ env: { ...process.env, HOME: project, USERPROFILE: project },
30
+ });
31
+ return out.trim() ? JSON.parse(out.trim()) : null;
32
+ }
33
+
34
+ function writePackageJson(content) {
35
+ return {
36
+ tool_name: 'Write',
37
+ tool_input: { file_path: '/repo/package.json', content },
38
+ };
39
+ }
40
+ function isWarn(result) {
41
+ return result?.hookSpecificOutput?.additionalContext !== undefined;
42
+ }
43
+
44
+ test('warns when a new dependency has no matching skill', () => {
45
+ const package_ = JSON.stringify({ dependencies: { stripe: '1.0.0' } });
46
+ assert.ok(isWarn(runGate(writePackageJson(package_))));
47
+ });
48
+
49
+ test('allows when the dependency has a matching skill directory', () => {
50
+ const package_ = JSON.stringify({ dependencies: { stripe: '1.0.0' } });
51
+ assert.equal(
52
+ runGate(writePackageJson(package_), { skills: ['stripe'] }),
53
+ null,
54
+ );
55
+ });
56
+
57
+ test('allows an exempt dependency without its own API', () => {
58
+ const package_ = JSON.stringify({
59
+ dependencies: { clsx: '1.0.0', '@types/node': '1.0.0' },
60
+ });
61
+ assert.equal(runGate(writePackageJson(package_)), null);
62
+ });
63
+
64
+ test('allows a non-package.json write and unparseable content', () => {
65
+ assert.equal(
66
+ runGate({
67
+ tool_name: 'Write',
68
+ tool_input: { file_path: '/repo/src/index.js', content: 'x' },
69
+ }),
70
+ null,
71
+ );
72
+ assert.equal(
73
+ runGate(writePackageJson('{ "dependencies": { incomplete')),
74
+ null,
75
+ );
76
+ });
77
+
78
+ test('disabled by config: the gate does not run', () => {
79
+ const package_ = JSON.stringify({ dependencies: { stripe: '1.0.0' } });
80
+ assert.equal(
81
+ runGate(writePackageJson(package_), {
82
+ config: { gates: { requireSkillForNewDependency: false } },
83
+ }),
84
+ null,
85
+ );
86
+ });
87
+
88
+ test('project projectSkillsDir override is honored', () => {
89
+ const package_ = JSON.stringify({ dependencies: { stripe: '1.0.0' } });
90
+ const config = {
91
+ gates: {
92
+ requireSkillForNewDependency: {
93
+ enabled: true,
94
+ projectSkillsDir: 'custom-skills',
95
+ },
96
+ },
97
+ };
98
+ const project = mkdtempSync(join(tmpdir(), 'dependency-skills-'));
99
+ mkdirSync(join(project, '.git'));
100
+ mkdirSync(join(project, '.ai'));
101
+ writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
102
+ mkdirSync(join(project, 'custom-skills', 'stripe'), { recursive: true });
103
+ const out = execFileSync(process.execPath, [GATE], {
104
+ input: JSON.stringify(writePackageJson(package_)),
105
+ encoding: 'utf8',
106
+ cwd: project,
107
+ });
108
+ assert.equal(out.trim() ? JSON.parse(out.trim()) : null, null);
109
+ });
@@ -0,0 +1,49 @@
1
+ import { runGate, warn } from '../../lib/hook-io.mjs';
2
+
3
+ const GATE_ID = 'diagnosis-before-patch';
4
+ const CONFIG_KEY = 'warnTimeoutChangeWithoutDiagnosis';
5
+
6
+ const DEFAULT_TIMEOUT_PATTERNS = [
7
+ String.raw`\b[A-Z_]*TIMEOUT[A-Z_]*\s*[:=]\s*['"]?\d`,
8
+ String.raw`\b[A-Z_]*DEADLINE[A-Z_]*\s*[:=]\s*['"]?\d`,
9
+ String.raw`\b[A-Z_]*IDLE[A-Z_]*\s*[:=]\s*['"]?\d`,
10
+ String.raw`\b(max_?retry|retries|backoff)\b\s*[:=]\s*['"]?\d`,
11
+ String.raw`\b(query_?timeout|hard_?deadline)\b`,
12
+ ];
13
+
14
+ function extractText(toolName, toolInput) {
15
+ if (toolName === 'Write') return String(toolInput?.content ?? '');
16
+ if (toolName === 'Edit') return String(toolInput?.new_string ?? '');
17
+ if (toolName === 'MultiEdit' && Array.isArray(toolInput?.edits)) {
18
+ return toolInput.edits
19
+ .map((edit) => String(edit?.new_string ?? ''))
20
+ .join('\n');
21
+ }
22
+ return '';
23
+ }
24
+
25
+ runGate(
26
+ {
27
+ id: GATE_ID,
28
+ configKey: CONFIG_KEY,
29
+ enabledByDefault: true,
30
+ defaultParams: {
31
+ timeoutPatterns: DEFAULT_TIMEOUT_PATTERNS,
32
+ },
33
+ },
34
+ ({ toolName, toolInput, parameters }) => {
35
+ const text = extractText(toolName, toolInput);
36
+ if (!text) return;
37
+
38
+ const patterns = parameters.timeoutPatterns.map(
39
+ (source) => new RegExp(source, 'i'),
40
+ );
41
+ const touchesTimeout = patterns.some((pattern) => pattern.test(text));
42
+ if (!touchesTimeout) return;
43
+
44
+ warn(
45
+ GATE_ID,
46
+ 'Diagnosis before patch: you are adjusting a timeout/deadline/retry value. Before changing a value to fix a symptom ("X is slow/fails"), confirm you read the evidence that proves the cause (a log line from the failing provider/process, not a hypothesis). A timeout should measure inactivity, not total time: a process that is progressing should not be cut off.',
47
+ );
48
+ },
49
+ );
@@ -0,0 +1,68 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { test } from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
10
+
11
+ function runGate(payload, { config } = {}) {
12
+ const project = mkdtempSync(join(tmpdir(), 'diagnosis-before-patch-'));
13
+ mkdirSync(join(project, '.git'));
14
+ if (config) {
15
+ mkdirSync(join(project, '.ai'));
16
+ writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
17
+ }
18
+ const out = execFileSync(process.execPath, [GATE], {
19
+ input: JSON.stringify(payload),
20
+ encoding: 'utf8',
21
+ cwd: project,
22
+ // Isolate from the user's real global config: point homedir() at the temp
23
+ // project so the global-config fallback finds nothing (registry default).
24
+ env: { ...process.env, HOME: project, USERPROFILE: project },
25
+ });
26
+ return out.trim() ? JSON.parse(out.trim()) : null;
27
+ }
28
+
29
+ function write(content) {
30
+ return {
31
+ tool_name: 'Write',
32
+ tool_input: { file_path: '/repo/src/config.js', content },
33
+ };
34
+ }
35
+ function isWarn(result) {
36
+ return result?.hookSpecificOutput?.additionalContext !== undefined;
37
+ }
38
+
39
+ test('warns when a timeout value is being changed', () => {
40
+ assert.ok(isWarn(runGate(write('const REQUEST_TIMEOUT_MS = 30000;'))));
41
+ assert.ok(isWarn(runGate(write('max_retry: 5'))));
42
+ });
43
+
44
+ test('allows content that does not touch a timeout/retry key', () => {
45
+ assert.equal(runGate(write('const x = 1;')), null);
46
+ });
47
+
48
+ test('disabled by config: the gate does not run', () => {
49
+ assert.equal(
50
+ runGate(write('TIMEOUT_MS = 5000'), {
51
+ config: { gates: { warnTimeoutChangeWithoutDiagnosis: false } },
52
+ }),
53
+ null,
54
+ );
55
+ });
56
+
57
+ test('project timeoutPatterns override replaces the built-in list', () => {
58
+ const config = {
59
+ gates: {
60
+ warnTimeoutChangeWithoutDiagnosis: {
61
+ enabled: true,
62
+ timeoutPatterns: ['custom_limit'],
63
+ },
64
+ },
65
+ };
66
+ assert.equal(runGate(write('TIMEOUT_MS = 5000'), { config }), null);
67
+ assert.ok(isWarn(runGate(write('custom_limit = 5'), { config })));
68
+ });
@@ -0,0 +1,100 @@
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.
5
+ //
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.
22
+
23
+ import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
24
+
25
+ const GATE_ID = 'feature-catalog';
26
+ const CONFIG_KEY = 'requireFeatureCatalog';
27
+
28
+ const WRITE_TOOLS = new Set(TOOL_GROUPS.write);
29
+
30
+ const DEFAULT_CATALOG_FILE_NAME = 'feature_list.json';
31
+ const DEFAULT_MAX_IN_PROGRESS = 1;
32
+
33
+ const DONE_STATUS_PATTERN = /"status"\s*:\s*"done"|status\s*:\s*['"]done['"]/i;
34
+ const IN_PROGRESS_STATUS_PATTERN = /"status"\s*:\s*"in_progress"/g;
35
+
36
+ /** The path a write tool targets, across the field names different tools use. */
37
+ function writeTargetFrom(toolInput) {
38
+ return String(
39
+ toolInput.TargetFile ??
40
+ toolInput.target_file ??
41
+ toolInput.file_path ??
42
+ toolInput.path ??
43
+ '',
44
+ );
45
+ }
46
+
47
+ /** The content a write tool is about to write, across the field names tools use. */
48
+ function writeContentFrom(toolInput) {
49
+ return String(
50
+ toolInput.CodeContent ??
51
+ toolInput.ReplacementContent ??
52
+ toolInput.content ??
53
+ '',
54
+ );
55
+ }
56
+
57
+ runGate(
58
+ {
59
+ id: GATE_ID,
60
+ configKey: CONFIG_KEY,
61
+ enabledByDefault: true,
62
+ defaultParams: {
63
+ catalogFileName: DEFAULT_CATALOG_FILE_NAME,
64
+ maxInProgress: DEFAULT_MAX_IN_PROGRESS,
65
+ },
66
+ },
67
+ ({ toolName, toolInput, parameters }) => {
68
+ if (!WRITE_TOOLS.has(toolName)) return;
69
+
70
+ const target = writeTargetFrom(toolInput);
71
+ const catalogFileName = String(
72
+ parameters.catalogFileName ?? DEFAULT_CATALOG_FILE_NAME,
73
+ );
74
+ if (!target.includes(catalogFileName)) return;
75
+
76
+ const content = writeContentFrom(toolInput);
77
+
78
+ // Base, non-negotiable: `done` is never written directly to the catalog.
79
+ if (DONE_STATUS_PATTERN.test(content)) {
80
+ deny(
81
+ GATE_ID,
82
+ `Writing 'status: done' directly to ${catalogFileName} is not allowed. ` +
83
+ 'Only a review/QA subagent or a validated automated process may close a feature.',
84
+ );
85
+ }
86
+
87
+ const maxInProgress = Number(
88
+ parameters.maxInProgress ?? DEFAULT_MAX_IN_PROGRESS,
89
+ );
90
+ const inProgressCount = (content.match(IN_PROGRESS_STATUS_PATTERN) ?? [])
91
+ .length;
92
+ if (inProgressCount > maxInProgress) {
93
+ deny(
94
+ GATE_ID,
95
+ `${catalogFileName} would have ${inProgressCount} features 'in_progress'; ` +
96
+ `the maximum allowed is ${maxInProgress}.`,
97
+ );
98
+ }
99
+ },
100
+ );
@@ -0,0 +1,97 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { test } from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
10
+
11
+ function runGate(payload, { config } = {}) {
12
+ const project = mkdtempSync(join(tmpdir(), 'feature-catalog-'));
13
+ mkdirSync(join(project, '.git'));
14
+ if (config) {
15
+ mkdirSync(join(project, '.ai'));
16
+ writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
17
+ }
18
+ const out = execFileSync(process.execPath, [GATE], {
19
+ input: JSON.stringify(payload),
20
+ encoding: 'utf8',
21
+ cwd: project,
22
+ // Isolate from the user's real global config: point homedir() at the temp
23
+ // project so the global-config fallback finds nothing (registry default).
24
+ env: { ...process.env, HOME: project, USERPROFILE: project },
25
+ });
26
+ return out.trim() ? JSON.parse(out.trim()) : null;
27
+ }
28
+
29
+ function writeCatalog(content, targetFile = 'feature_list.json') {
30
+ return {
31
+ tool_name: 'Write',
32
+ tool_input: { file_path: targetFile, content },
33
+ };
34
+ }
35
+
36
+ function isDeny(result) {
37
+ return result?.hookSpecificOutput?.permissionDecision === 'deny';
38
+ }
39
+
40
+ test('denies writing status:done directly to the catalog', () => {
41
+ const content = JSON.stringify({
42
+ features: [{ name: 'checkout', status: 'done' }],
43
+ });
44
+ assert.ok(isDeny(runGate(writeCatalog(content))));
45
+ });
46
+
47
+ test('denies more than maxInProgress features in_progress', () => {
48
+ const content = JSON.stringify({
49
+ features: [
50
+ { name: 'a', status: 'in_progress' },
51
+ { name: 'b', status: 'in_progress' },
52
+ ],
53
+ });
54
+ assert.ok(isDeny(runGate(writeCatalog(content))));
55
+ });
56
+
57
+ test('allows a write with one in_progress feature and no direct done', () => {
58
+ const content = JSON.stringify({
59
+ features: [
60
+ { name: 'a', status: 'in_progress' },
61
+ { name: 'b', status: 'spec_ready' },
62
+ ],
63
+ });
64
+ assert.equal(runGate(writeCatalog(content)), null);
65
+ });
66
+
67
+ test('allows a write to an unrelated file (auto-off: no catalog targeted)', () => {
68
+ const content = JSON.stringify({ status: 'done' });
69
+ assert.equal(runGate(writeCatalog(content, 'notes.json')), null);
70
+ });
71
+
72
+ test('disabled by config: the gate does not run', () => {
73
+ const content = JSON.stringify({
74
+ features: [{ name: 'checkout', status: 'done' }],
75
+ });
76
+ assert.equal(
77
+ runGate(writeCatalog(content), {
78
+ config: { gates: { requireFeatureCatalog: false } },
79
+ }),
80
+ null,
81
+ );
82
+ });
83
+
84
+ test('project param maxInProgress override raises/lowers the threshold', () => {
85
+ const content = JSON.stringify({
86
+ features: [
87
+ { name: 'a', status: 'in_progress' },
88
+ { name: 'b', status: 'in_progress' },
89
+ ],
90
+ });
91
+ const config = {
92
+ gates: {
93
+ requireFeatureCatalog: { enabled: true, maxInProgress: 2 },
94
+ },
95
+ };
96
+ assert.equal(runGate(writeCatalog(content), { config }), null);
97
+ });
@@ -0,0 +1,112 @@
1
+ // forge-flow — the enforcer forge cannot be. A forge MCP directs a pipeline but cannot
2
+ // intercept Edit/Write/Bash, so nothing makes you actually use it. This gate closes that
3
+ // hole: in a project that ADOPTED forge, a code-mutating action is denied unless there is
4
+ // an active forge run for this project — so every change goes through the pipeline and you
5
+ // always know which phase you are in.
6
+ //
7
+ // justification: no existing tool covers this. A forge MCP audit (see memory
8
+ // forge-mcp-auditoria-flujo) confirmed the structural hole: an MCP cannot gate other tools;
9
+ // only a Claude Code PreToolUse hook can. This is that hook.
10
+ //
11
+ // ── When it acts (never surprises you) ──────────────────────────────────────────────
12
+ // Only when the project adopted forge — a marker on disk (.ai/forge.json, or `forge: true`
13
+ // in .ai/config.json). In any other project it stays silent. Off by default in the
14
+ // registry, so it never fires unless a project turns it on.
15
+ //
16
+ // ── How it checks (deterministic, no judgment) ──────────────────────────────────────
17
+ // forge persists its runs in a SQLite DB (global by default). This gate reads that DB with
18
+ // node:sqlite (a Node built-in — no npm, contract intact) and looks for an active run whose
19
+ // cwd matches this project. Absent → deny with an actionable message. The DB unreadable or
20
+ // node:sqlite unavailable → allow (fail open: a broken lookup must not block all work).
21
+
22
+ import { existsSync, readFileSync } from 'node:fs';
23
+ import { homedir } from 'node:os';
24
+ import { dirname, join } from 'node:path';
25
+ import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
26
+
27
+ const GATE_ID = 'forge-flow';
28
+ const CONFIG_KEY = 'requireForgeRunToEdit';
29
+
30
+ const ACTING_TOOLS = new Set([...TOOL_GROUPS.write, ...TOOL_GROUPS.shell]);
31
+ const PROJECT_ROOT_MARKERS = ['.git', '.ai'];
32
+ const DEFAULT_FORGE_DB = join('.forge', 'forge-mcp.db');
33
+ const FORGE_MARKER_FILE = join('.ai', 'forge.json');
34
+ const PROJECT_CONFIG_FILE = join('.ai', 'config.json');
35
+
36
+ function projectRootOf(startDirectory) {
37
+ let current = startDirectory;
38
+ while (true) {
39
+ if (
40
+ PROJECT_ROOT_MARKERS.some((marker) => existsSync(join(current, marker)))
41
+ ) {
42
+ return current;
43
+ }
44
+ const parent = dirname(current);
45
+ if (parent === current) return null;
46
+ current = parent;
47
+ }
48
+ }
49
+
50
+ function readJson(path) {
51
+ try {
52
+ return JSON.parse(readFileSync(path, 'utf8'));
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ /** True when this project opted into forge: a marker file, or forge:true in .ai/config.json. */
59
+ function projectAdoptedForge(root) {
60
+ if (existsSync(join(root, FORGE_MARKER_FILE))) return true;
61
+ const config = readJson(join(root, PROJECT_CONFIG_FILE));
62
+ return config?.forge === true;
63
+ }
64
+
65
+ /**
66
+ * True when forge has an active run whose cwd is this project. Reads forge's SQLite DB with
67
+ * node:sqlite (imported dynamically so an older Node without it degrades instead of
68
+ * throwing). Any failure — module missing, DB absent, locked, unreadable, schema drift —
69
+ * returns true: the gate must fail OPEN, since a broken lookup must never block every edit.
70
+ */
71
+ async function hasActiveForgeRun(root, forgeDatabasePath) {
72
+ if (!existsSync(forgeDatabasePath)) return false; // no DB yet → no runs at all → deny
73
+ try {
74
+ const { DatabaseSync } = await import('node:sqlite');
75
+ const database = new DatabaseSync(forgeDatabasePath, { readOnly: true });
76
+ const rows = database
77
+ .prepare("SELECT cwd FROM runs WHERE status = 'active'")
78
+ .all();
79
+ database.close();
80
+ return rows.some((row) => String(row.cwd) === root);
81
+ } catch {
82
+ return true; // node:sqlite missing, or DB locked/unreadable → fail open
83
+ }
84
+ }
85
+
86
+ const DENY_MESSAGE =
87
+ 'This project uses forge, but there is no active forge run for it. Every change should ' +
88
+ 'go through the pipeline so the next step is always clear. Start or resume a run ' +
89
+ '(forge_start / forge_next) before editing — that is how forge tells you which phase ' +
90
+ 'you are in. To work outside the pipeline, turn this gate off in .ai/config.json.';
91
+
92
+ runGate(
93
+ {
94
+ id: GATE_ID,
95
+ configKey: CONFIG_KEY,
96
+ enabledByDefault: false,
97
+ defaultParams: { forgeDatabasePath: join(homedir(), DEFAULT_FORGE_DB) },
98
+ },
99
+ async ({ toolName, parameters }) => {
100
+ if (!ACTING_TOOLS.has(toolName)) return;
101
+
102
+ const root = projectRootOf(process.cwd());
103
+ if (!root) return; // no project
104
+ if (!projectAdoptedForge(root)) return; // project did not opt into forge
105
+
106
+ const forgeDatabasePath =
107
+ parameters.forgeDatabasePath ?? join(homedir(), DEFAULT_FORGE_DB);
108
+ if (await hasActiveForgeRun(root, forgeDatabasePath)) return;
109
+
110
+ deny(GATE_ID, DENY_MESSAGE);
111
+ },
112
+ );