@devrik-tools/claude-gates 0.1.2 → 0.2.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 (106) hide show
  1. package/.claude-plugin/marketplace.json +7 -1
  2. package/cli/__tests__/config.test.mjs +113 -101
  3. package/cli/__tests__/install.test.mjs +102 -0
  4. package/cli/__tests__/materialize.test.mjs +95 -0
  5. package/cli/__tests__/registry-gates-consistency.test.mjs +52 -44
  6. package/cli/__tests__/task.test.mjs +124 -0
  7. package/cli/__tests__/version-consistency.test.mjs +44 -0
  8. package/cli/config.mjs +124 -113
  9. package/cli/index.mjs +100 -97
  10. package/cli/init.mjs +259 -256
  11. package/cli/install.mjs +150 -80
  12. package/cli/materialize.mjs +102 -51
  13. package/cli/registry.mjs +136 -127
  14. package/cli/task.mjs +140 -0
  15. package/package.json +2 -1
  16. package/plugins/gates/.claude-plugin/plugin.json +8 -2
  17. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +83 -0
  18. package/plugins/gates/hooks/__tests__/doctor.test.mjs +85 -0
  19. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +65 -0
  20. package/plugins/gates/hooks/ask-adoption.mjs +147 -0
  21. package/plugins/gates/hooks/doctor.mjs +207 -0
  22. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +83 -0
  23. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -88
  24. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +117 -92
  25. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +104 -0
  26. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +48 -45
  27. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +165 -0
  28. package/plugins/gates/hooks/gates/bash-commands/index.mjs +285 -268
  29. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +151 -0
  30. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +265 -177
  31. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +207 -0
  32. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +501 -456
  33. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +161 -143
  34. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +69 -0
  35. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +126 -118
  36. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +68 -0
  37. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +43 -49
  38. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +83 -0
  39. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -100
  40. package/plugins/gates/hooks/gates/force-parallel/index.mjs +119 -0
  41. package/plugins/gates/hooks/gates/force-parallel/test.mjs +88 -0
  42. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +155 -0
  43. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -112
  44. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +66 -0
  45. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -186
  46. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -238
  47. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +96 -0
  48. package/plugins/gates/hooks/gates/intent-flow/test.mjs +165 -136
  49. package/plugins/gates/hooks/gates/lint-commit/index.mjs +149 -0
  50. package/plugins/gates/hooks/gates/lint-commit/test.mjs +85 -0
  51. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +100 -0
  52. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -166
  53. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +79 -85
  54. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +80 -0
  55. package/plugins/gates/hooks/gates/never-assume/index.mjs +58 -55
  56. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +71 -0
  57. package/plugins/gates/hooks/gates/never-assume/test.mjs +95 -78
  58. package/plugins/gates/hooks/gates/no-blocking/index.mjs +148 -142
  59. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +109 -0
  60. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +123 -120
  61. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +83 -0
  62. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +91 -0
  63. package/plugins/gates/hooks/gates/protected-paths/index.mjs +144 -147
  64. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +126 -0
  65. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +89 -64
  66. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +102 -0
  67. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +94 -0
  68. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +159 -150
  69. package/plugins/gates/hooks/gates/risk-level/index.mjs +263 -203
  70. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +116 -0
  71. package/plugins/gates/hooks/gates/risk-level/test.mjs +153 -125
  72. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +56 -50
  73. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +58 -0
  74. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +116 -111
  75. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +97 -0
  76. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +184 -99
  77. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +126 -0
  78. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +130 -0
  79. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -251
  80. package/plugins/gates/hooks/gates/stop-pending/index.mjs +159 -0
  81. package/plugins/gates/hooks/gates/stop-pending/test.mjs +114 -0
  82. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -101
  83. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +72 -0
  84. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +63 -0
  85. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -181
  86. package/plugins/gates/hooks/gates/test-matrix/test.mjs +114 -87
  87. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +62 -0
  88. package/plugins/gates/hooks/gates/tool-map/index.mjs +143 -140
  89. package/plugins/gates/hooks/hooks.json +346 -286
  90. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +173 -154
  91. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +210 -154
  92. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +114 -0
  93. package/plugins/gates/hooks/lib/config.mjs +172 -165
  94. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +189 -0
  95. package/plugins/gates/hooks/lib/hook-io.mjs +318 -208
  96. package/plugins/gates/hooks/lib/signals.mjs +127 -0
  97. package/plugins/gates/hooks/wiring-check.mjs +227 -0
  98. package/plugins/tasks/.claude-plugin/plugin.json +14 -0
  99. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +100 -0
  100. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +95 -0
  101. package/plugins/tasks/hooks/hooks.json +26 -0
  102. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +180 -132
  103. package/plugins/tasks/hooks/lib/task-store.mjs +197 -159
  104. package/plugins/tasks/hooks/register-requests.mjs +141 -108
  105. package/plugins/tasks/hooks/session-tasks.mjs +108 -0
  106. package/registry.json +793 -686
@@ -1,159 +1,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. Terminal (moved to history): done/abandoned. */
32
- export const STATUS = Object.freeze({
33
- OPEN: 'open',
34
- BLOCKED: 'blocked',
35
- DONE: 'done',
36
- ABANDONED: 'abandoned',
37
- });
38
-
39
- const TERMINAL_STATUSES = new Set([STATUS.DONE, STATUS.ABANDONED]);
40
-
41
- /** Climbs to the nearest project root (a dir holding `.git` or `.ai/`); null when none. */
42
- export function projectRootOf(startDirectory) {
43
- let current = startDirectory;
44
- while (true) {
45
- if (
46
- PROJECT_ROOT_MARKERS.some((marker) => existsSync(join(current, marker)))
47
- ) {
48
- return current;
49
- }
50
- const parent = dirname(current);
51
- if (parent === current) return null;
52
- current = parent;
53
- }
54
- }
55
-
56
- function readCollection(path) {
57
- if (!existsSync(path)) return { tasks: [] };
58
- try {
59
- const parsed = JSON.parse(readFileSync(path, 'utf8'));
60
- return Array.isArray(parsed.tasks) ? parsed : { tasks: [] };
61
- } catch {
62
- return { tasks: [] };
63
- }
64
- }
65
-
66
- function writeCollection(path, collection) {
67
- mkdirSync(dirname(path), { recursive: true });
68
- writeFileSync(
69
- path,
70
- `${JSON.stringify(collection, null, JSON_INDENT)}\n`,
71
- 'utf8',
72
- );
73
- }
74
-
75
- /**
76
- * Opens the store rooted at a project. All paths derive from the project root's .ai/tasks/.
77
- * Returns a handle with read/mutate operations; each mutation persists immediately so a
78
- * crash between calls never loses a recorded task. Null when there is no project (no .git).
79
- */
80
- export function openTaskStore(startDirectory) {
81
- const root = projectRootOf(startDirectory);
82
- if (!root) return null;
83
-
84
- const directory = join(root, TASKS_DIR);
85
- const activePath = join(directory, ACTIVE_FILE);
86
- const historyPath = join(directory, HISTORY_FILE);
87
- const counterPath = join(directory, COUNTER_FILE);
88
-
89
- return {
90
- root,
91
- /** Open (non-terminal) tasks. */
92
- active() {
93
- return readCollection(activePath).tasks;
94
- },
95
- /** Terminal (done/abandoned) tasks, newest last. */
96
- history() {
97
- return readCollection(historyPath).tasks;
98
- },
99
- /** Adds a task to active and returns it. `id` is caller-supplied (stable, human-readable). */
100
- add(task) {
101
- const collection = readCollection(activePath);
102
- collection.tasks.push(task);
103
- writeCollection(activePath, collection);
104
- return task;
105
- },
106
- /** Merges fields into the active task with matching id. Null if not found. */
107
- update(id, fields) {
108
- const collection = readCollection(activePath);
109
- const task = collection.tasks.find((entry) => entry.id === id);
110
- if (!task) return null;
111
- Object.assign(task, fields);
112
- writeCollection(activePath, collection);
113
- return task;
114
- },
115
- /**
116
- * Closes an active task: sets a terminal status + closedAt + closeReason, then MOVES it
117
- * from active to history (append-only). Nothing is deleted. Returns the closed task, or
118
- * null when the id or status is invalid.
119
- */
120
- close(id, status, reason) {
121
- if (!TERMINAL_STATUSES.has(status)) return null;
122
- const activeCollection = readCollection(activePath);
123
- const index = activeCollection.tasks.findIndex(
124
- (entry) => entry.id === id,
125
- );
126
- if (index === -1) return null;
127
-
128
- const [task] = activeCollection.tasks.splice(index, 1);
129
- task.status = status;
130
- task.closedAt = new Date().toISOString();
131
- task.closeReason = reason ?? '';
132
-
133
- const historyCollection = readCollection(historyPath);
134
- historyCollection.tasks.push(task);
135
-
136
- writeCollection(historyPath, historyCollection);
137
- writeCollection(activePath, activeCollection);
138
- return task;
139
- },
140
- /** The message counter since the last reminder (0 when unset or unreadable). */
141
- counter() {
142
- if (!existsSync(counterPath)) return 0;
143
- try {
144
- return JSON.parse(readFileSync(counterPath, 'utf8')).count ?? 0;
145
- } catch {
146
- return 0;
147
- }
148
- },
149
- /** Sets the message counter. */
150
- setCounter(count) {
151
- mkdirSync(directory, { recursive: true });
152
- writeFileSync(
153
- counterPath,
154
- `${JSON.stringify({ count }, null, JSON_INDENT)}\n`,
155
- 'utf8',
156
- );
157
- },
158
- };
159
- }
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,108 +1,141 @@
1
- // register-requests — UserPromptSubmit hook. Runs on every user message, BEFORE the
2
- // assistant responds. It is the deterministic half of the task system: it never decides
3
- // what is a task (that judgment is the assistant's), it only makes sure open tasks are not
4
- // forgotten. Two jobs, both cheap and side-effect-light:
5
- // 1. Count messages. Every `remindEvery` messages, surface a stronger reminder.
6
- // 2. Inject the open tasks (title + status) into the assistant's context so a long or
7
- // compacted conversation never loses the thread of what is still pending.
8
- //
9
- // A UserPromptSubmit hook's stdout is added to the assistant's context as plain text, so
10
- // the reminder is simply written to stdout. Self-contained: Node built-ins + the task-store
11
- // lib only, no npm at runtime.
12
- //
13
- // justification: no existing tool covers this. The task store and the audit were done when
14
- // task-store.mjs was written (see its header); this file is the hook wiring on top.
15
-
16
- import { existsSync, readFileSync } from 'node:fs';
17
- import { homedir } from 'node:os';
18
- import { dirname, join } from 'node:path';
19
- import { openTaskStore } from './lib/task-store.mjs';
20
-
21
- const STDIN_FILE_DESCRIPTOR = 0;
22
- const CONFIG_KEY = 'runTaskReminders';
23
- const DEFAULT_REMIND_EVERY = 10;
24
- const MAX_TASKS_SHOWN = 12;
25
-
26
- // Config lookup mirrors the gates' config.mjs (project → global), kept local so this plugin
27
- // stays independent of the gates plugin.
28
- const PROJECT_ROOT_MARKERS = ['.git', '.ai'];
29
- const PROJECT_CONFIG = join('.ai', 'config.json');
30
- const GLOBAL_CONFIG = join('.claude', 'claude-gates', 'config.json');
31
-
32
- function readJson(path) {
33
- try {
34
- return JSON.parse(readFileSync(path, 'utf8'));
35
- } catch {
36
- return null;
37
- }
38
- }
39
-
40
- function projectRootOf(startDirectory) {
41
- let current = startDirectory;
42
- while (true) {
43
- if (
44
- PROJECT_ROOT_MARKERS.some((marker) => existsSync(join(current, marker)))
45
- ) {
46
- return current;
47
- }
48
- const parent = dirname(current);
49
- if (parent === current) return null;
50
- current = parent;
51
- }
52
- }
53
-
54
- /** The tasks gate config entry (project overrides global), or null. */
55
- function taskConfig(startDirectory) {
56
- const root = projectRootOf(startDirectory);
57
- const projectData = root ? readJson(join(root, PROJECT_CONFIG)) : null;
58
- const globalData = readJson(join(homedir(), GLOBAL_CONFIG));
59
- const layer = projectData?.gates ?? globalData?.gates ?? {};
60
- const entry = layer[CONFIG_KEY];
61
- if (typeof entry === 'boolean') return { enabled: entry };
62
- if (entry && typeof entry === 'object') return entry;
63
- return null;
64
- }
65
-
66
- function readPayload() {
67
- try {
68
- return JSON.parse(readFileSync(STDIN_FILE_DESCRIPTOR, 'utf8'));
69
- } catch {
70
- return {};
71
- }
72
- }
73
-
74
- function formatReminder(tasks, forced) {
75
- const shown = tasks.slice(0, MAX_TASKS_SHOWN);
76
- const lines = shown.map(
77
- (task) => ` - [${task.status}] ${task.id}: ${task.title}`,
78
- );
79
- const extra = tasks.length - shown.length;
80
- if (extra > 0) lines.push(` - …and ${extra} more`);
81
- const header = forced
82
- ? 'OPEN TASKS recite these pending tasks to the user and confirm whether any should be dropped:'
83
- : 'Open tasks (do not lose track of these):';
84
- return `[tasks] ${header}\n${lines.join('\n')}`;
85
- }
86
-
87
- function main() {
88
- const payload = readPayload();
89
- const cwd = payload.cwd || process.cwd();
90
-
91
- const config = taskConfig(cwd);
92
- if (config && config.enabled === false) return; // gate turned off for this project
93
-
94
- const store = openTaskStore(cwd);
95
- if (!store) return; // no project: nowhere to track
96
-
97
- const tasks = store.active();
98
- if (tasks.length === 0) return; // nothing pending: stay silent
99
-
100
- const remindEvery = Number(config?.remindEvery) || DEFAULT_REMIND_EVERY;
101
- const next = store.counter() + 1;
102
- const forced = next >= remindEvery;
103
- store.setCounter(forced ? 0 : next);
104
-
105
- process.stdout.write(`${formatReminder(tasks, forced)}\n`);
106
- }
107
-
108
- main();
1
+ // register-requests — UserPromptSubmit hook. Runs on every user message, BEFORE the
2
+ // assistant responds. It is the deterministic half of the task system: it never decides
3
+ // what is a task (that judgment is the assistant's the hook only asks it to judge), it
4
+ // only makes sure the judgment is asked and open tasks are not forgotten. Two jobs:
5
+ // 1. On every message, ask the assistant to classify: if this message is a new task,
6
+ // register it via `claude-gates task add` (persistence is deterministic the store
7
+ // but the classification itself is the model's judgment, never a regex here).
8
+ // 2. Every `remindEveryMessages` messages (counted via the store's counter), also recite
9
+ // the currently active tasks so a long or compacted conversation never loses the
10
+ // thread of what is still pending.
11
+ //
12
+ // A UserPromptSubmit hook's stdout is added to the assistant's context as plain text, so
13
+ // both are simply written to stdout. Self-contained: Node built-ins + the task-store lib
14
+ // only, no npm at runtime.
15
+ //
16
+ // justification: no existing tool covers this. The task store and the audit were done when
17
+ // task-store.mjs was written (see its header); this file is the hook wiring on top.
18
+
19
+ import { existsSync, readFileSync } from 'node:fs';
20
+ import { homedir } from 'node:os';
21
+ import { dirname, join } from 'node:path';
22
+ import { openTaskStore } from './lib/task-store.mjs';
23
+
24
+ const STDIN_FILE_DESCRIPTOR = 0;
25
+ const CONFIG_KEY = 'remindOpenTasks';
26
+ const DEFAULT_REMIND_EVERY_MESSAGES = 5;
27
+ const MAX_TASKS_SHOWN = 12;
28
+ const DUMP_ENV = 'CLAUDE_GATES_DUMP_DEFAULTS';
29
+
30
+ // Config lookup mirrors the gates' config.mjs (project → global), kept local so this plugin
31
+ // stays independent of the gates plugin.
32
+ const PROJECT_ROOT_MARKERS = ['.git', '.ai'];
33
+ const PROJECT_CONFIG = join('.ai', 'config.json');
34
+ const GLOBAL_CONFIG = join('.claude', 'claude-gates', 'config.json');
35
+
36
+ function readJson(path) {
37
+ try {
38
+ return JSON.parse(readFileSync(path, 'utf8'));
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ function projectRootOf(startDirectory) {
45
+ let current = startDirectory;
46
+ while (true) {
47
+ if (
48
+ PROJECT_ROOT_MARKERS.some((marker) => existsSync(join(current, marker)))
49
+ ) {
50
+ return current;
51
+ }
52
+ const parent = dirname(current);
53
+ if (parent === current) return null;
54
+ current = parent;
55
+ }
56
+ }
57
+
58
+ /** The tasks gate config entry (project overrides global), or null. */
59
+ function taskConfig(startDirectory) {
60
+ const root = projectRootOf(startDirectory);
61
+ const projectData = root ? readJson(join(root, PROJECT_CONFIG)) : null;
62
+ const globalData = readJson(join(homedir(), GLOBAL_CONFIG));
63
+ const layer = projectData?.gates ?? globalData?.gates ?? {};
64
+ const entry = layer[CONFIG_KEY];
65
+ if (typeof entry === 'boolean') return { enabled: entry };
66
+ if (entry && typeof entry === 'object') return entry;
67
+ return null;
68
+ }
69
+
70
+ function readPayload() {
71
+ try {
72
+ return JSON.parse(readFileSync(STDIN_FILE_DESCRIPTOR, 'utf8'));
73
+ } catch {
74
+ return {};
75
+ }
76
+ }
77
+
78
+ // Asked on every message. The hook does not classify — it asks the model to, and the
79
+ // model is the one that persists (via the CLI, which enforces the store's own rules,
80
+ // e.g. evidence on close). This is legitimate: the hook is not depending on the model to
81
+ // make something OCCUR deterministically; it is depending on the model's judgment for a
82
+ // judgment call (whether this message describes a new task), same shape as ask-adoption.
83
+ const CLASSIFY_PROMPT =
84
+ '[tasks] If the user just described a new actionable task (not a question, not small talk), ' +
85
+ 'register it now: run `claude-gates task add "<title>" [--description <text>] [--size <size>]` ' +
86
+ 'from the project root. Skip this for messages that are not a new task.';
87
+
88
+ function formatReminder(tasks) {
89
+ const shown = tasks.slice(0, MAX_TASKS_SHOWN);
90
+ const lines = shown.map(
91
+ (task) => ` - [${task.status}] ${task.id}: ${task.title}`,
92
+ );
93
+ const extra = tasks.length - shown.length;
94
+ if (extra > 0) lines.push(` - …and ${extra} more`);
95
+ return (
96
+ '[tasks] OPEN TASKS — recite these pending tasks to the user and confirm whether any ' +
97
+ `should be dropped:\n${lines.join('\n')}`
98
+ );
99
+ }
100
+
101
+ function main() {
102
+ // Same defaults-dump protocol as the gates plugin's runGate (see hook-io.mjs): the CLI's
103
+ // materialize.mjs spawns this script with the env var set to read its built-in params as
104
+ // the single source of truth, before touching stdin.
105
+ if (process.env[DUMP_ENV]) {
106
+ process.stdout.write(
107
+ JSON.stringify({
108
+ id: 'remind-open-tasks',
109
+ configKey: CONFIG_KEY,
110
+ enabledByDefault: true,
111
+ defaultParams: { remindEveryMessages: DEFAULT_REMIND_EVERY_MESSAGES },
112
+ }),
113
+ );
114
+ return;
115
+ }
116
+
117
+ const payload = readPayload();
118
+ const cwd = payload.cwd || process.cwd();
119
+
120
+ const config = taskConfig(cwd);
121
+ if (config && config.enabled === false) return; // gate turned off for this project
122
+
123
+ const store = openTaskStore(cwd);
124
+ if (!store) return; // no project: nowhere to track
125
+
126
+ const lines = [CLASSIFY_PROMPT];
127
+
128
+ const tasks = store.active();
129
+ if (tasks.length > 0) {
130
+ const remindEveryMessages =
131
+ Number(config?.remindEveryMessages) || DEFAULT_REMIND_EVERY_MESSAGES;
132
+ const next = store.counter() + 1;
133
+ const forced = next >= remindEveryMessages;
134
+ store.setCounter(forced ? 0 : next);
135
+ if (forced) lines.push(formatReminder(tasks));
136
+ }
137
+
138
+ process.stdout.write(`${lines.join('\n')}\n`);
139
+ }
140
+
141
+ main();