@devrik-tools/claude-gates 0.3.1 → 0.6.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 (58) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +25 -4
  3. package/README.md +75 -43
  4. package/cli/config.mjs +126 -124
  5. package/cli/index.mjs +154 -104
  6. package/cli/init.mjs +303 -276
  7. package/cli/install.mjs +281 -175
  8. package/cli/materialize.mjs +103 -102
  9. package/cli/registry.mjs +139 -136
  10. package/cli/smoke-fixtures.json +441 -0
  11. package/cli/smoke.mjs +129 -0
  12. package/cli/task.mjs +140 -140
  13. package/package.json +1 -1
  14. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  15. package/plugins/gates/hooks/ask-adoption.mjs +147 -147
  16. package/plugins/gates/hooks/doctor.mjs +207 -207
  17. package/plugins/gates/hooks/gates/atomic-commit/index.mjs +229 -0
  18. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +110 -88
  19. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +50 -50
  20. package/plugins/gates/hooks/gates/bash-commands/index.mjs +215 -215
  21. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +269 -265
  22. package/plugins/gates/hooks/gates/capability-map/index.mjs +377 -0
  23. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +527 -501
  24. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +48 -43
  25. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -83
  26. package/plugins/gates/hooks/gates/force-parallel/index.mjs +134 -119
  27. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -134
  28. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -187
  29. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -260
  30. package/plugins/gates/hooks/gates/lint-commit/index.mjs +152 -149
  31. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -180
  32. package/plugins/gates/hooks/gates/never-assume/index.mjs +59 -58
  33. package/plugins/gates/hooks/gates/no-blocking/index.mjs +163 -148
  34. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +127 -0
  35. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +183 -0
  36. package/plugins/gates/hooks/gates/protected-paths/index.mjs +149 -144
  37. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +91 -89
  38. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +263 -159
  39. package/plugins/gates/hooks/gates/risk-level/index.mjs +265 -263
  40. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +57 -56
  41. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +211 -131
  42. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +181 -184
  43. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -256
  44. package/plugins/gates/hooks/gates/staged-lint/index.mjs +187 -0
  45. package/plugins/gates/hooks/gates/stop-pending/index.mjs +169 -164
  46. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -187
  47. package/plugins/gates/hooks/gates/tool-map/index.mjs +168 -143
  48. package/plugins/gates/hooks/hooks.json +51 -0
  49. package/plugins/gates/hooks/lib/config.mjs +179 -172
  50. package/plugins/gates/hooks/lib/hook-io.mjs +367 -357
  51. package/plugins/gates/hooks/lib/signals.mjs +172 -127
  52. package/plugins/gates/hooks/wiring-check.mjs +227 -227
  53. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  54. package/plugins/tasks/hooks/hooks.json +26 -26
  55. package/plugins/tasks/hooks/lib/task-store.mjs +217 -197
  56. package/plugins/tasks/hooks/register-requests.mjs +145 -145
  57. package/plugins/tasks/hooks/session-tasks.mjs +108 -108
  58. package/registry.json +171 -1
@@ -1,197 +1,217 @@
1
- // The task store: the single source of truth for a project's tasks, on disk under
2
- // .ai/tasks/. Self-contained (Node built-ins only) so a hook can use it when the plugin is
3
- // installed on its own.
4
- //
5
- // justification: no existing tool covers this. Audited LOCAL deps (find-up, commander,
6
- // @clack/prompts, zod — none is a task store), CONTEXT7 (generic JSON-store libs), and WEB
7
- // (victor-software-house/task-tracker-plugin and Claude Code's native task manager both
8
- // persist tasks and survive compaction, but neither distills tasks from chat, none is a
9
- // zero-runtime-dependency library, and none fits the active/history + git-root shape here).
10
- // The generic persistence is a solved pattern; this store is the thin, dependency-free,
11
- // domain-specific piece the value layer (distil + remind + unlazy) sits on.
12
- //
13
- // Two files, mirroring the harness-sdd model:
14
- // active.json { tasks: [ {id, title, description, status, size, createdAt, messages[]} ] }
15
- // history.json { tasks: [ ...same shape + closedAt + closeReason ] } — append-only
16
- // A task is never deleted: done and abandoned tasks MOVE from active to history, so the
17
- // record of what was decided (and dropped) is never lost.
18
-
19
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
20
- import { dirname, join } from 'node:path';
21
-
22
- const TASKS_DIR = join('.ai', 'tasks');
23
- const ACTIVE_FILE = 'active.json';
24
- const HISTORY_FILE = 'history.json';
25
- const COUNTER_FILE = 'counter.json';
26
- const JSON_INDENT = 2;
27
- // A project root holds `.git` or `.ai/` — the same set the CLI and the gates config use, so
28
- // a repo-less project (no git yet) still resolves. Anchoring on `.git` alone drops it.
29
- const PROJECT_ROOT_MARKERS = ['.git', '.ai'];
30
-
31
- /** Statuses a task can hold. Active: open/blocked/in_forge. Terminal (history): done/abandoned. */
32
- export const STATUS = Object.freeze({
33
- OPEN: 'open',
34
- BLOCKED: 'blocked',
35
- IN_FORGE: 'in_forge', // promoted to a forge run for pipeline execution; still active
36
- DONE: 'done',
37
- ABANDONED: 'abandoned',
38
- });
39
-
40
- const TERMINAL_STATUSES = new Set([STATUS.DONE, STATUS.ABANDONED]);
41
- // A task closed as done must carry evidence it was actually attended and resolved — the
42
- // project rule "ningún pedido se marca hecho sin evidencia válida" made mechanical. Abandoned
43
- // needs only a reason (it is a deliberate drop, not a claim of completion), so it is exempt.
44
- const STATUS_REQUIRING_EVIDENCE = new Set([STATUS.DONE]);
45
-
46
- /** Climbs to the nearest project root (a dir holding `.git` or `.ai/`); null when none. */
47
- export function projectRootOf(startDirectory) {
48
- let current = startDirectory;
49
- while (true) {
50
- if (
51
- PROJECT_ROOT_MARKERS.some((marker) => existsSync(join(current, marker)))
52
- ) {
53
- return current;
54
- }
55
- const parent = dirname(current);
56
- if (parent === current) return null;
57
- current = parent;
58
- }
59
- }
60
-
61
- // Node's readFileSync('utf8') does not strip a leading UTF-8 BOM (EF BB BF, decoded as
62
- // U+FEFF), and JSON.parse rejects a string starting with it. A file written by a BOM-adding
63
- // editor or `PowerShell Set-Content -Encoding utf8` would otherwise silently read back as
64
- // "corrupt" (caught below, treated as empty) — the same failure mode that made the gates'
65
- // own config.mjs treat a valid project config as absent. Stripping it here is the fix.
66
- function stripBom(text) {
67
- return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
68
- }
69
-
70
- function readCollection(path) {
71
- if (!existsSync(path)) return { tasks: [] };
72
- try {
73
- const parsed = JSON.parse(stripBom(readFileSync(path, 'utf8')));
74
- return Array.isArray(parsed.tasks) ? parsed : { tasks: [] };
75
- } catch {
76
- return { tasks: [] };
77
- }
78
- }
79
-
80
- function writeCollection(path, collection) {
81
- mkdirSync(dirname(path), { recursive: true });
82
- writeFileSync(
83
- path,
84
- `${JSON.stringify(collection, null, JSON_INDENT)}\n`,
85
- 'utf8',
86
- );
87
- }
88
-
89
- /**
90
- * Opens the store rooted at a project. All paths derive from the project root's .ai/tasks/.
91
- * Returns a handle with read/mutate operations; each mutation persists immediately so a
92
- * crash between calls never loses a recorded task. Null when there is no project (no .git).
93
- */
94
- export function openTaskStore(startDirectory) {
95
- const root = projectRootOf(startDirectory);
96
- if (!root) return null;
97
-
98
- const directory = join(root, TASKS_DIR);
99
- const activePath = join(directory, ACTIVE_FILE);
100
- const historyPath = join(directory, HISTORY_FILE);
101
- const counterPath = join(directory, COUNTER_FILE);
102
-
103
- return {
104
- root,
105
- /** Open (non-terminal) tasks. */
106
- active() {
107
- return readCollection(activePath).tasks;
108
- },
109
- /** Terminal (done/abandoned) tasks, newest last. */
110
- history() {
111
- return readCollection(historyPath).tasks;
112
- },
113
- /** Adds a task to active and returns it. `id` is caller-supplied (stable, human-readable). */
114
- add(task) {
115
- const collection = readCollection(activePath);
116
- collection.tasks.push(task);
117
- writeCollection(activePath, collection);
118
- return task;
119
- },
120
- /** Merges fields into the active task with matching id. Null if not found. */
121
- update(id, fields) {
122
- const collection = readCollection(activePath);
123
- const task = collection.tasks.find((entry) => entry.id === id);
124
- if (!task) return null;
125
- Object.assign(task, fields);
126
- writeCollection(activePath, collection);
127
- return task;
128
- },
129
- /**
130
- * Closes an active task: sets a terminal status + closedAt + closeReason, then MOVES it
131
- * from active to history (append-only). Nothing is deleted. Returns { task } on success,
132
- * or { error } describing why it was refused — so a caller can tell "not found" from
133
- * "done without evidence" and surface the right message.
134
- *
135
- * Closing as `done` REQUIRES non-empty `evidence` (a command output, a test result, a
136
- * diff, a verification note): a request is never marked resolved on a claim alone.
137
- * `abandoned` needs only a reason.
138
- */
139
- close(id, status, { reason, evidence } = {}) {
140
- if (!TERMINAL_STATUSES.has(status)) {
141
- return { error: `invalid terminal status: ${status}` };
142
- }
143
- if (STATUS_REQUIRING_EVIDENCE.has(status) && !String(evidence ?? '').trim()) {
144
- return {
145
- error:
146
- 'closing a task as done requires evidence that it was attended and resolved ' +
147
- '(test output, a diff, a verification note). Provide --evidence, or close it as ' +
148
- 'abandoned with a reason if it will not be finished.',
149
- };
150
- }
151
- const activeCollection = readCollection(activePath);
152
- const index = activeCollection.tasks.findIndex(
153
- (entry) => entry.id === id,
154
- );
155
- if (index === -1) return { error: `no active task with id ${id}` };
156
-
157
- const [task] = activeCollection.tasks.splice(index, 1);
158
- task.status = status;
159
- task.closedAt = new Date().toISOString();
160
- task.closeReason = reason ?? '';
161
- if (STATUS_REQUIRING_EVIDENCE.has(status)) task.evidence = String(evidence).trim();
162
-
163
- const historyCollection = readCollection(historyPath);
164
- historyCollection.tasks.push(task);
165
-
166
- writeCollection(historyPath, historyCollection);
167
- writeCollection(activePath, activeCollection);
168
- return { task };
169
- },
170
- /**
171
- * Promotes an active task to a forge run: records the run id and flips status to in_forge
172
- * (still active — it is being executed, not closed). Whether a task is promoted is the
173
- * assistant's judgment, not a rule; this only records the link. Null if id not found.
174
- */
175
- promoteToForge(id, forgeRunId) {
176
- return this.update(id, { status: STATUS.IN_FORGE, forgeRunId: String(forgeRunId) });
177
- },
178
- /** The message counter since the last reminder (0 when unset or unreadable). */
179
- counter() {
180
- if (!existsSync(counterPath)) return 0;
181
- try {
182
- return JSON.parse(stripBom(readFileSync(counterPath, 'utf8'))).count ?? 0;
183
- } catch {
184
- return 0;
185
- }
186
- },
187
- /** Sets the message counter. */
188
- setCounter(count) {
189
- mkdirSync(directory, { recursive: true });
190
- writeFileSync(
191
- counterPath,
192
- `${JSON.stringify({ count }, null, JSON_INDENT)}\n`,
193
- 'utf8',
194
- );
195
- },
196
- };
197
- }
1
+ // The task store: the single source of truth for a project's tasks, on disk under
2
+ // .ai/tasks/. Self-contained (Node built-ins only) so a hook can use it when the plugin is
3
+ // installed on its own.
4
+ //
5
+ // justification: no existing tool covers this. Audited LOCAL deps (find-up, commander,
6
+ // @clack/prompts, zod — none is a task store), CONTEXT7 (generic JSON-store libs), and WEB
7
+ // (victor-software-house/task-tracker-plugin and Claude Code's native task manager both
8
+ // persist tasks and survive compaction, but neither distills tasks from chat, none is a
9
+ // zero-runtime-dependency library, and none fits the active/history + git-root shape here).
10
+ // The generic persistence is a solved pattern; this store is the thin, dependency-free,
11
+ // domain-specific piece the value layer (distil + remind + unlazy) sits on.
12
+ //
13
+ // Two files, mirroring the harness-sdd model:
14
+ // active.json { tasks: [ {id, title, description, status, size, createdAt, messages[]} ] }
15
+ // history.json { tasks: [ ...same shape + closedAt + closeReason ] } — append-only
16
+ // A task is never deleted: done and abandoned tasks MOVE from active to history, so the
17
+ // record of what was decided (and dropped) is never lost.
18
+
19
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
20
+ import { dirname, join } from 'node:path';
21
+
22
+ const TASKS_DIR = join('.ai', 'tasks');
23
+ const ACTIVE_FILE = 'active.json';
24
+ const HISTORY_FILE = 'history.json';
25
+ const COUNTER_FILE = 'counter.json';
26
+ const JSON_INDENT = 2;
27
+ // A project root holds `.git` or `.ai/` — the same set the CLI and the gates config use, so
28
+ // a repo-less project (no git yet) still resolves. Anchoring on `.git` alone drops it.
29
+ const PROJECT_ROOT_MARKERS = ['.git', '.ai'];
30
+
31
+ /** Statuses a task can hold. Active: open/blocked/in_forge. Terminal (history): done/abandoned. */
32
+ export const STATUS = Object.freeze({
33
+ OPEN: 'open',
34
+ BLOCKED: 'blocked',
35
+ IN_FORGE: 'in_forge', // promoted to a forge run for pipeline execution; still active
36
+ DONE: 'done',
37
+ ABANDONED: 'abandoned',
38
+ });
39
+
40
+ const TERMINAL_STATUSES = new Set([STATUS.DONE, STATUS.ABANDONED]);
41
+ // A task closed as done must carry evidence it was actually attended and resolved — the
42
+ // project rule "ningún pedido se marca hecho sin evidencia válida" made mechanical. Abandoned
43
+ // needs only a reason (it is a deliberate drop, not a claim of completion), so it is exempt.
44
+ const STATUS_REQUIRING_EVIDENCE = new Set([STATUS.DONE]);
45
+
46
+ /** Climbs to the nearest project root (a dir holding `.git` or `.ai/`); null when none. */
47
+ export function projectRootOf(startDirectory) {
48
+ let current = startDirectory;
49
+ while (true) {
50
+ if (
51
+ PROJECT_ROOT_MARKERS.some((marker) => existsSync(join(current, marker)))
52
+ ) {
53
+ return current;
54
+ }
55
+ const parent = dirname(current);
56
+ if (parent === current) return null;
57
+ current = parent;
58
+ }
59
+ }
60
+
61
+ // Node's readFileSync('utf8') does not strip a leading UTF-8 BOM (EF BB BF, decoded as
62
+ // U+FEFF), and JSON.parse rejects a string starting with it. A file written by a BOM-adding
63
+ // editor or `PowerShell Set-Content -Encoding utf8` would otherwise silently read back as
64
+ // "corrupt" (caught below, treated as empty) — the same failure mode that made the gates'
65
+ // own config.mjs treat a valid project config as absent. Stripping it here is the fix.
66
+ const BOM_CODE_POINT = 0xfeff;
67
+
68
+ function stripBom(text) {
69
+ return text.charCodeAt(0) === BOM_CODE_POINT ? text.slice(1) : text;
70
+ }
71
+
72
+ function readCollection(path) {
73
+ if (!existsSync(path)) return { tasks: [] };
74
+ try {
75
+ const parsed = JSON.parse(stripBom(readFileSync(path, 'utf8')));
76
+ return Array.isArray(parsed.tasks) ? parsed : { tasks: [] };
77
+ } catch {
78
+ return { tasks: [] };
79
+ }
80
+ }
81
+
82
+ function writeCollection(path, collection) {
83
+ mkdirSync(dirname(path), { recursive: true });
84
+ writeFileSync(
85
+ path,
86
+ `${JSON.stringify(collection, null, JSON_INDENT)}\n`,
87
+ 'utf8',
88
+ );
89
+ }
90
+
91
+ /**
92
+ * Closes an active task: sets a terminal status + closedAt + closeReason, then MOVES it
93
+ * from active to history (append-only). Nothing is deleted. Returns { task } on success,
94
+ * or { error } describing why it was refused — so a caller can tell "not found" from
95
+ * "done without evidence" and surface the right message.
96
+ *
97
+ * Closing as `done` REQUIRES non-empty `evidence` (a command output, a test result, a
98
+ * diff, a verification note): a request is never marked resolved on a claim alone.
99
+ * `abandoned` needs only a reason.
100
+ *
101
+ * Extracted from the `openTaskStore` handle purely to keep that function's line count
102
+ * within the project's budget — same behavior, same order, just a named module function
103
+ * taking the two paths it needs instead of closing over them.
104
+ */
105
+ function closeTask(
106
+ activePath,
107
+ historyPath,
108
+ id,
109
+ status,
110
+ { reason, evidence } = {},
111
+ ) {
112
+ if (!TERMINAL_STATUSES.has(status)) {
113
+ return { error: `invalid terminal status: ${status}` };
114
+ }
115
+ if (STATUS_REQUIRING_EVIDENCE.has(status) && !String(evidence ?? '').trim()) {
116
+ return {
117
+ error:
118
+ 'closing a task as done requires evidence that it was attended and resolved ' +
119
+ '(test output, a diff, a verification note). Provide --evidence, or close it as ' +
120
+ 'abandoned with a reason if it will not be finished.',
121
+ };
122
+ }
123
+ const activeCollection = readCollection(activePath);
124
+ const index = activeCollection.tasks.findIndex((entry) => entry.id === id);
125
+ if (index === -1) return { error: `no active task with id ${id}` };
126
+
127
+ const [task] = activeCollection.tasks.splice(index, 1);
128
+ task.status = status;
129
+ task.closedAt = new Date().toISOString();
130
+ task.closeReason = reason ?? '';
131
+ if (STATUS_REQUIRING_EVIDENCE.has(status))
132
+ task.evidence = String(evidence).trim();
133
+
134
+ const historyCollection = readCollection(historyPath);
135
+ historyCollection.tasks.push(task);
136
+
137
+ writeCollection(historyPath, historyCollection);
138
+ writeCollection(activePath, activeCollection);
139
+ return { task };
140
+ }
141
+
142
+ /**
143
+ * Opens the store rooted at a project. All paths derive from the project root's .ai/tasks/.
144
+ * Returns a handle with read/mutate operations; each mutation persists immediately so a
145
+ * crash between calls never loses a recorded task. Null when there is no project (no .git).
146
+ */
147
+ export function openTaskStore(startDirectory) {
148
+ const root = projectRootOf(startDirectory);
149
+ if (!root) return null;
150
+
151
+ const directory = join(root, TASKS_DIR);
152
+ const activePath = join(directory, ACTIVE_FILE);
153
+ const historyPath = join(directory, HISTORY_FILE);
154
+ const counterPath = join(directory, COUNTER_FILE);
155
+
156
+ return {
157
+ root,
158
+ /** Open (non-terminal) tasks. */
159
+ active() {
160
+ return readCollection(activePath).tasks;
161
+ },
162
+ /** Terminal (done/abandoned) tasks, newest last. */
163
+ history() {
164
+ return readCollection(historyPath).tasks;
165
+ },
166
+ /** Adds a task to active and returns it. `id` is caller-supplied (stable, human-readable). */
167
+ add(task) {
168
+ const collection = readCollection(activePath);
169
+ collection.tasks.push(task);
170
+ writeCollection(activePath, collection);
171
+ return task;
172
+ },
173
+ /** Merges fields into the active task with matching id. Null if not found. */
174
+ update(id, fields) {
175
+ const collection = readCollection(activePath);
176
+ const task = collection.tasks.find((entry) => entry.id === id);
177
+ if (!task) return null;
178
+ Object.assign(task, fields);
179
+ writeCollection(activePath, collection);
180
+ return task;
181
+ },
182
+ close(id, status, options) {
183
+ return closeTask(activePath, historyPath, id, status, options);
184
+ },
185
+ /**
186
+ * Promotes an active task to a forge run: records the run id and flips status to in_forge
187
+ * (still active it is being executed, not closed). Whether a task is promoted is the
188
+ * assistant's judgment, not a rule; this only records the link. Null if id not found.
189
+ */
190
+ promoteToForge(id, forgeRunId) {
191
+ return this.update(id, {
192
+ status: STATUS.IN_FORGE,
193
+ forgeRunId: String(forgeRunId),
194
+ });
195
+ },
196
+ /** The message counter since the last reminder (0 when unset or unreadable). */
197
+ counter() {
198
+ if (!existsSync(counterPath)) return 0;
199
+ try {
200
+ return (
201
+ JSON.parse(stripBom(readFileSync(counterPath, 'utf8'))).count ?? 0
202
+ );
203
+ } catch {
204
+ return 0;
205
+ }
206
+ },
207
+ /** Sets the message counter. */
208
+ setCounter(count) {
209
+ mkdirSync(directory, { recursive: true });
210
+ writeFileSync(
211
+ counterPath,
212
+ `${JSON.stringify({ count }, null, JSON_INDENT)}\n`,
213
+ 'utf8',
214
+ );
215
+ },
216
+ };
217
+ }