@izkac/forgekit 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 (143) hide show
  1. package/bin/forge.mjs +100 -0
  2. package/bin/forgekit.mjs +84 -0
  3. package/bin/review.mjs +82 -0
  4. package/package.json +46 -0
  5. package/scripts/prepack.mjs +78 -0
  6. package/scripts/run-tests.mjs +43 -0
  7. package/src/adr.mjs +236 -0
  8. package/src/adr.test.mjs +170 -0
  9. package/src/change.mjs +234 -0
  10. package/src/change.test.mjs +83 -0
  11. package/src/cleanup-sessions.mjs +70 -0
  12. package/src/config.mjs +103 -0
  13. package/src/defer.mjs +75 -0
  14. package/src/doctor.mjs +341 -0
  15. package/src/doctor.test.mjs +114 -0
  16. package/src/init.mjs +575 -0
  17. package/src/install.mjs +777 -0
  18. package/src/install.test.mjs +104 -0
  19. package/src/integrity-check.mjs +58 -0
  20. package/src/integrity.mjs +317 -0
  21. package/src/integrity.test.mjs +296 -0
  22. package/src/lib/workspaces.mjs +55 -0
  23. package/src/lib.mjs +138 -0
  24. package/src/models.defaults.json +41 -0
  25. package/src/new-session.mjs +82 -0
  26. package/src/openspec-overlays/README.md +19 -0
  27. package/src/openspec-overlays/openspec-apply-change-footer.md +14 -0
  28. package/src/openspec-overlays/opsx-apply-completion-step.md +1 -0
  29. package/src/openspec-overlays/opsx-apply-implement-step.md +11 -0
  30. package/src/paths.mjs +92 -0
  31. package/src/plan-engine.mjs +260 -0
  32. package/src/plan-engine.test.mjs +245 -0
  33. package/src/preferences.defaults.json +78 -0
  34. package/src/preferences.mjs +438 -0
  35. package/src/preferences.test.mjs +174 -0
  36. package/src/record-evidence.mjs +204 -0
  37. package/src/record-evidence.test.mjs +260 -0
  38. package/src/resolve-model.mjs +312 -0
  39. package/src/resolve-model.test.mjs +194 -0
  40. package/src/review/carryforward.mjs +413 -0
  41. package/src/review/carryforward.test.mjs +587 -0
  42. package/src/review/cli.test.mjs +117 -0
  43. package/src/review/export.mjs +172 -0
  44. package/src/review/export.test.mjs +197 -0
  45. package/src/review/fixtures/valid-review.json +42 -0
  46. package/src/review/lib.mjs +894 -0
  47. package/src/review/lib.test.mjs +266 -0
  48. package/src/review/merge-tentative.mjs +292 -0
  49. package/src/review/merge-tentative.test.mjs +363 -0
  50. package/src/review/new-review.mjs +200 -0
  51. package/src/review/render.mjs +108 -0
  52. package/src/review/schema-consistency.test.mjs +83 -0
  53. package/src/review/schema.json +196 -0
  54. package/src/review/signals.mjs +144 -0
  55. package/src/review/signals.test.mjs +62 -0
  56. package/src/score-cli.mjs +68 -0
  57. package/src/score.mjs +489 -0
  58. package/src/score.test.mjs +253 -0
  59. package/src/session-reminder.mjs +168 -0
  60. package/src/session-status.mjs +70 -0
  61. package/src/set-models.mjs +186 -0
  62. package/src/set-phase.mjs +177 -0
  63. package/src/set-phase.test.mjs +317 -0
  64. package/src/set-prefs.mjs +294 -0
  65. package/src/spine.mjs +91 -0
  66. package/src/triage-prompt.mjs +175 -0
  67. package/src/triage-prompt.test.mjs +50 -0
  68. package/src/vendor-openspec-overlays.mjs +176 -0
  69. package/src/vendor-openspec-overlays.test.mjs +62 -0
  70. package/vendor/skills/archive-to-adr/SKILL.md +149 -0
  71. package/vendor/skills/forge/SKILL.md +136 -0
  72. package/vendor/skills/forge/phases/brainstorm.md +23 -0
  73. package/vendor/skills/forge/phases/finish.md +87 -0
  74. package/vendor/skills/forge/phases/implement.md +76 -0
  75. package/vendor/skills/forge/phases/plan-openspec.md +40 -0
  76. package/vendor/skills/forge/phases/plan-specs.md +97 -0
  77. package/vendor/skills/forge/phases/review.md +25 -0
  78. package/vendor/skills/forge/phases/verify.md +120 -0
  79. package/vendor/skills/forge/references/forge-layout.md +85 -0
  80. package/vendor/skills/forge/references/pace.md +115 -0
  81. package/vendor/skills/forge/references/plan-routing.md +51 -0
  82. package/vendor/skills/forge/references/runtime-integrity.md +157 -0
  83. package/vendor/skills/forge/references/substantial-work.md +37 -0
  84. package/vendor/skills/forge/references/tdd-core.md +29 -0
  85. package/vendor/skills/forge/references/test-evidence.md +30 -0
  86. package/vendor/skills/forge/references/test-strategy.md +68 -0
  87. package/vendor/skills/forge/skills/NOTICE.md +17 -0
  88. package/vendor/skills/forge/skills/brainstorming/SKILL.md +120 -0
  89. package/vendor/skills/forge/skills/requesting-code-review/SKILL.md +67 -0
  90. package/vendor/skills/forge/skills/requesting-code-review/code-reviewer.md +146 -0
  91. package/vendor/skills/forge/skills/subagent-driven-development/SKILL.md +87 -0
  92. package/vendor/skills/forge/skills/systematic-debugging/SKILL.md +234 -0
  93. package/vendor/skills/forge/skills/systematic-debugging/condition-based-waiting.md +115 -0
  94. package/vendor/skills/forge/skills/systematic-debugging/defense-in-depth.md +122 -0
  95. package/vendor/skills/forge/skills/systematic-debugging/find-polluter.sh +63 -0
  96. package/vendor/skills/forge/skills/systematic-debugging/root-cause-tracing.md +169 -0
  97. package/vendor/skills/forge/skills/test-driven-development/SKILL.md +290 -0
  98. package/vendor/skills/forge/skills/test-driven-development/testing-anti-patterns.md +299 -0
  99. package/vendor/skills/forge/skills/verification-before-completion/SKILL.md +59 -0
  100. package/vendor/skills/forge/subagents/final-reviewer-prompt.md +53 -0
  101. package/vendor/skills/forge/subagents/implementer-prompt.md +38 -0
  102. package/vendor/skills/forge/subagents/task-reviewer-prompt.md +61 -0
  103. package/vendor/skills/git-resolve-adr-conflict/SKILL.md +132 -0
  104. package/vendor/skills/thorough-code-review/SKILL.md +290 -0
  105. package/vendor/skills/thorough-code-review/examples/accepted-risks-janus.md +32 -0
  106. package/vendor/skills/thorough-code-review/examples.md +133 -0
  107. package/vendor/skills/thorough-code-review/reference/accepted-risks.md +26 -0
  108. package/vendor/skills/thorough-code-review/reference/lenses.md +96 -0
  109. package/vendor/skills/thorough-code-review/reference/phase1-scout.md +62 -0
  110. package/vendor/skills/thorough-code-review/reference/phase1c-coverage.md +44 -0
  111. package/vendor/skills/thorough-code-review/reference/phase2-skeptic.md +105 -0
  112. package/vendor/skills/thorough-code-review/reference/report-schema.json +222 -0
  113. package/vendor/skills/thorough-code-review/reference/report-template.md +115 -0
  114. package/vendor/skills/thorough-code-review/reference/severity-rubric.md +49 -0
  115. package/vendor/skills/thorough-code-review/reference/signals-preflight.md +55 -0
  116. package/vendor/templates/adr/README.md +7 -0
  117. package/vendor/templates/adr/decisions.md +141 -0
  118. package/vendor/templates/adr/hooks/check-pending-adrs.mjs +74 -0
  119. package/vendor/templates/adr/hooks/check-pending-adrs.sh +3 -0
  120. package/vendor/templates/adr/hooks/openspec-archive-agent-message.mjs +52 -0
  121. package/vendor/templates/adr/hooks/openspec-archive-agent-message.sh +3 -0
  122. package/vendor/templates/project/claude/commands/forge-apply.md +75 -0
  123. package/vendor/templates/project/claude/commands/forge-brainstorm.md +7 -0
  124. package/vendor/templates/project/claude/commands/forge-build.md +17 -0
  125. package/vendor/templates/project/claude/commands/forge-plan.md +12 -0
  126. package/vendor/templates/project/claude/commands/forge-skip.md +14 -0
  127. package/vendor/templates/project/claude/commands/forge-status.md +16 -0
  128. package/vendor/templates/project/claude/commands/forge.md +16 -0
  129. package/vendor/templates/project/claude/hooks/forge-prompt-hook.mjs +73 -0
  130. package/vendor/templates/project/claude/hooks/forge-session-start.mjs +19 -0
  131. package/vendor/templates/project/claude/hooks/forge-triage-hook.mjs +77 -0
  132. package/vendor/templates/project/claude/rules/forge.md +16 -0
  133. package/vendor/templates/project/codex/rules/forge.md +10 -0
  134. package/vendor/templates/project/cursor/commands/forge-apply.md +75 -0
  135. package/vendor/templates/project/cursor/commands/forge-brainstorm.md +10 -0
  136. package/vendor/templates/project/cursor/commands/forge-build.md +17 -0
  137. package/vendor/templates/project/cursor/commands/forge-plan.md +15 -0
  138. package/vendor/templates/project/cursor/commands/forge-skip.md +14 -0
  139. package/vendor/templates/project/cursor/commands/forge-status.md +16 -0
  140. package/vendor/templates/project/cursor/commands/forge.md +16 -0
  141. package/vendor/templates/project/cursor/hooks/forge-session-start.mjs +30 -0
  142. package/vendor/templates/project/cursor/hooks/forge-session-start.sh +3 -0
  143. package/vendor/templates/project/cursor/rules/forge.mdc +21 -0
@@ -0,0 +1,294 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Get or set checkout-local Forge pace preferences.
4
+ *
5
+ * Usage:
6
+ * forge set-prefs # print effective
7
+ * forge set-prefs brisk # set pace
8
+ * forge set-prefs --set review.perTask=always
9
+ * forge set-prefs --session-set lite # pin active session
10
+ * forge set-prefs --resolve --signal "…" # resolve auto now
11
+ *
12
+ * Options:
13
+ * --forge-dir <path>
14
+ * --defaults <path>
15
+ * --json
16
+ * --help
17
+ */
18
+
19
+ import path from 'node:path';
20
+ import { pathToFileURL } from 'node:url';
21
+ import {
22
+ DEFAULTS_PATH,
23
+ PACES,
24
+ parseAssignment,
25
+ resolveEffectivePreferences,
26
+ resolveSessionPaceFields,
27
+ setDotted,
28
+ suggestPaceFromSignals,
29
+ writeLocalPreferences,
30
+ } from './preferences.mjs';
31
+ import {
32
+ loadSession,
33
+ readActive,
34
+ saveSession,
35
+ } from './lib.mjs';
36
+
37
+ /**
38
+ * @param {string[]} argv
39
+ */
40
+ export function parseArgs(argv) {
41
+ const opts = {
42
+ pace: null,
43
+ sets: [],
44
+ sessionSet: null,
45
+ resolve: false,
46
+ signal: null,
47
+ forgeDir: null,
48
+ defaults: null,
49
+ json: false,
50
+ help: false,
51
+ };
52
+
53
+ for (let i = 0; i < argv.length; i += 1) {
54
+ const arg = argv[i];
55
+ if (arg === '--forge-dir') opts.forgeDir = argv[++i];
56
+ else if (arg === '--defaults') opts.defaults = argv[++i];
57
+ else if (arg === '--set') opts.sets.push(argv[++i]);
58
+ else if (arg === '--session-set') opts.sessionSet = argv[++i];
59
+ else if (arg === '--resolve') opts.resolve = true;
60
+ else if (arg === '--signal') opts.signal = argv[++i];
61
+ else if (arg === '--json') opts.json = true;
62
+ else if (arg === '--help' || arg === '-h') opts.help = true;
63
+ else if (arg === '--') continue;
64
+ else if (!arg.startsWith('-') && opts.pace === null) opts.pace = arg;
65
+ else throw new Error(`Unknown argument: ${arg}`);
66
+ }
67
+
68
+ return opts;
69
+ }
70
+
71
+ function printHelp() {
72
+ process.stdout.write(`Usage: forge set-prefs [pace] [options]
73
+
74
+ Get or set Forge pace (thoroughness) preferences.
75
+
76
+ Paces: ${PACES.join(', ')} (default: auto)
77
+
78
+ forge prefs
79
+ Print effective pace + knobs — does NOT create .forge/preferences.local.json
80
+ unless you also pass a pace or --set. local=(none) means committed defaults.
81
+
82
+ forge prefs brisk
83
+ Write/update .forge/preferences.local.json (gitignored, per-checkout).
84
+
85
+ forge prefs --session-set lite
86
+ Pin pace on the active session only (no local file write).
87
+
88
+ forge prefs -- --set review.perTask=always
89
+ forge prefs -- --resolve --signal "add stripe refund"
90
+
91
+ Defaults: preferences.defaults.json
92
+ Local overlay: .forge/preferences.local.json (appears only after a set)
93
+ Matrix: .cursor/skills/forge/references/pace.md
94
+
95
+ Options:
96
+ --set path=value Nested override (repeatable)
97
+ --session-set Pin pace on active session only
98
+ --resolve Re-resolve auto for active session
99
+ --signal <text> Signals for auto / --resolve
100
+ --json JSON stdout
101
+ --forge-dir <path>
102
+ --defaults <path>
103
+ --help
104
+ `);
105
+ }
106
+
107
+ /**
108
+ * @param {string[]} argv
109
+ * @param {{ cwd?: string, stdout?: NodeJS.WritableStream, stderr?: NodeJS.WritableStream }} [io]
110
+ */
111
+ export function runSetPrefs(argv, io = {}) {
112
+ const stdout = io.stdout ?? process.stdout;
113
+ const stderr = io.stderr ?? process.stderr;
114
+ const cwd = io.cwd ?? process.cwd();
115
+
116
+ let opts;
117
+ try {
118
+ opts = parseArgs(argv);
119
+ } catch (err) {
120
+ const msg = err instanceof Error ? err.message : String(err);
121
+ stderr.write(`${msg}\n`);
122
+ return 2;
123
+ }
124
+
125
+ if (opts.help) {
126
+ printHelp();
127
+ return 0;
128
+ }
129
+
130
+ const forgeDir = opts.forgeDir ?? path.join(cwd, '.forge');
131
+ const defaultsPath = opts.defaults ?? DEFAULTS_PATH;
132
+
133
+ try {
134
+ if (opts.sessionSet != null) {
135
+ if (!PACES.includes(opts.sessionSet)) {
136
+ throw new Error(`Unknown pace "${opts.sessionSet}". Expected one of: ${PACES.join(', ')}`);
137
+ }
138
+ const active = readActive();
139
+ if (!active?.sessionId) {
140
+ throw new Error('No active Forge session. Run forge:new first.');
141
+ }
142
+ const { dir, session } = loadSession(active.sessionId);
143
+ if (opts.sessionSet === 'auto') {
144
+ const fields = resolveSessionPaceFields({
145
+ forgeDir,
146
+ defaultsPath,
147
+ cwd,
148
+ slug: session.slug,
149
+ signalText: opts.signal || session.paceSignal || session.slug,
150
+ paceOverride: 'auto',
151
+ });
152
+ Object.assign(session, fields);
153
+ } else {
154
+ session.pace = opts.sessionSet;
155
+ session.resolvedPace = opts.sessionSet;
156
+ session.paceReason = 'session override';
157
+ session.pacePinned = true;
158
+ session.preferencesOverride = { pace: opts.sessionSet };
159
+ }
160
+ saveSession(dir, session);
161
+ if (opts.json) {
162
+ stdout.write(`${JSON.stringify({ sessionId: session.id, pace: session }, null, 2)}\n`);
163
+ } else {
164
+ stdout.write(`session ${session.id}: pace=${session.pace} resolved=${session.resolvedPace}\n`);
165
+ }
166
+ return 0;
167
+ }
168
+
169
+ if (opts.resolve) {
170
+ const active = readActive();
171
+ if (!active?.sessionId) {
172
+ throw new Error('No active Forge session. Run forge:new first.');
173
+ }
174
+ const { dir, session } = loadSession(active.sessionId);
175
+ const signal = opts.signal || session.paceSignal || session.slug || '';
176
+ const fields = resolveSessionPaceFields({
177
+ forgeDir,
178
+ defaultsPath,
179
+ cwd,
180
+ slug: session.slug,
181
+ signalText: signal,
182
+ paceOverride: typeof session.pace === 'string' ? session.pace : 'auto',
183
+ });
184
+ // Force re-resolve even if concrete: when --resolve with auto requested
185
+ if ((session.pace ?? 'auto') === 'auto' || fields.pace === 'auto') {
186
+ const suggested = suggestPaceFromSignals(signal);
187
+ session.pace = 'auto';
188
+ session.resolvedPace = suggested.pace;
189
+ session.paceReason = suggested.reason;
190
+ session.paceSignal = signal || null;
191
+ session.pacePinned = false;
192
+ session.preferencesOverride = null;
193
+ } else {
194
+ Object.assign(session, fields);
195
+ }
196
+ saveSession(dir, session);
197
+ if (opts.json) {
198
+ stdout.write(
199
+ `${JSON.stringify({
200
+ sessionId: session.id,
201
+ requestedPace: session.pace,
202
+ resolvedPace: session.resolvedPace,
203
+ paceReason: session.paceReason,
204
+ }, null, 2)}\n`,
205
+ );
206
+ } else {
207
+ stdout.write(
208
+ `resolved ${session.resolvedPace} (${session.paceReason})\n`,
209
+ );
210
+ }
211
+ return 0;
212
+ }
213
+
214
+ if (opts.pace != null || opts.sets.length > 0) {
215
+ /** @type {Record<string, unknown>} */
216
+ const patch = {};
217
+ if (opts.pace != null) {
218
+ if (!PACES.includes(opts.pace)) {
219
+ throw new Error(`Unknown pace "${opts.pace}". Expected one of: ${PACES.join(', ')}`);
220
+ }
221
+ patch.pace = opts.pace;
222
+ }
223
+ for (const assignment of opts.sets) {
224
+ if (!assignment) throw new Error('--set requires path=value');
225
+ const { key, value } = parseAssignment(assignment);
226
+ setDotted(patch, key, value);
227
+ }
228
+ const written = writeLocalPreferences({ forgeDir, patch });
229
+ if (opts.json) {
230
+ stdout.write(`${JSON.stringify(written, null, 2)}\n`);
231
+ } else {
232
+ stdout.write(`wrote ${written.localPath}\n`);
233
+ if (patch.pace) stdout.write(`pace=${patch.pace}\n`);
234
+ for (const a of opts.sets) stdout.write(`set ${a}\n`);
235
+ }
236
+ return 0;
237
+ }
238
+
239
+ let session = null;
240
+ const active = readActive();
241
+ if (active?.sessionId) {
242
+ try {
243
+ session = loadSession(active.sessionId).session;
244
+ } catch {
245
+ session = null;
246
+ }
247
+ }
248
+
249
+ const effective = resolveEffectivePreferences({
250
+ forgeDir,
251
+ defaultsPath,
252
+ cwd,
253
+ session,
254
+ signalText: opts.signal || undefined,
255
+ });
256
+
257
+ if (opts.json) {
258
+ stdout.write(`${JSON.stringify(effective, null, 2)}\n`);
259
+ } else {
260
+ stdout.write(`pace=${effective.requestedPace}\n`);
261
+ stdout.write(`resolved=${effective.resolvedPace}\n`);
262
+ stdout.write(`reason=${effective.paceReason}\n`);
263
+ stdout.write(`source=${effective.source}\n`);
264
+ stdout.write(`review.perTask=${effective.effective.review.perTask}\n`);
265
+ stdout.write(`review.final=${effective.effective.review.final}\n`);
266
+ stdout.write(`review.depth=${effective.effective.review.depth}\n`);
267
+ stdout.write(`review.maxRounds=${effective.effective.review.maxRounds}\n`);
268
+ stdout.write(`verify.tier3=${effective.effective.verify.tier3}\n`);
269
+ stdout.write(`models.bias=${effective.effective.models.bias}\n`);
270
+ stdout.write(`brainstorm.depth=${effective.effective.brainstorm.depth}\n`);
271
+ if (effective.localExists) {
272
+ stdout.write(`local=${effective.localPath}\n`);
273
+ } else {
274
+ stdout.write(`local=(none — using preferences.defaults.json)\n`);
275
+ stdout.write(
276
+ `hint: forge prefs -- auto|thorough|standard|brisk|lite # writes .forge/preferences.local.json\n`,
277
+ );
278
+ }
279
+ }
280
+ return 0;
281
+ } catch (err) {
282
+ const msg = err instanceof Error ? err.message : String(err);
283
+ stderr.write(`${msg}\n`);
284
+ return 1;
285
+ }
286
+ }
287
+
288
+ const isMain =
289
+ process.argv[1] &&
290
+ import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
291
+
292
+ if (isMain) {
293
+ process.exitCode = runSetPrefs(process.argv.slice(2));
294
+ }
package/src/spine.mjs ADDED
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Forge spine matrix — scaffold + validate the capability→runtime wiring map.
4
+ *
5
+ * Usage:
6
+ * forge spine # show status (path, validity)
7
+ * forge spine init [--force] # scaffold spine.json for the active change
8
+ * forge spine check # validate; exit 1 with problems
9
+ * [--session <id>]
10
+ *
11
+ * spine.json lives in the change dir (openspec/changes/<name>/ or
12
+ * <specsDir>/changes/<name>/), falling back to the session dir when the
13
+ * session has no tracked change.
14
+ */
15
+
16
+ import fs from 'node:fs';
17
+ import { loadSession, readActive, readJson } from './lib.mjs';
18
+ import { initSpine, spinePath, validateSpine } from './integrity.mjs';
19
+
20
+ const args = process.argv.slice(2);
21
+ const sub = args[0] && !args[0].startsWith('--') ? args[0] : 'status';
22
+
23
+ if (args[0] === '--help' || sub === 'help') {
24
+ process.stdout.write(
25
+ 'Usage: forge spine [init [--force] | check | status] [--session <id>]\n',
26
+ );
27
+ process.exit(0);
28
+ }
29
+
30
+ let sessionId = null;
31
+ let force = false;
32
+ for (let i = 0; i < args.length; i += 1) {
33
+ if (args[i] === '--session' && args[i + 1]) {
34
+ sessionId = args[i + 1];
35
+ i += 1;
36
+ } else if (args[i] === '--force') {
37
+ force = true;
38
+ }
39
+ }
40
+
41
+ if (!sessionId) {
42
+ const active = readActive();
43
+ sessionId = active?.sessionId;
44
+ }
45
+ if (!sessionId) {
46
+ process.stderr.write('No active session. Run forge new first.\n');
47
+ process.exit(1);
48
+ }
49
+
50
+ const { dir, session } = loadSession(sessionId);
51
+ const file = spinePath({ session, sessionDir: dir });
52
+
53
+ if (sub === 'init') {
54
+ try {
55
+ initSpine({ file, change: session.openspecChange ?? null, force });
56
+ } catch (err) {
57
+ process.stderr.write(`${err instanceof Error ? err.message : err}\n`);
58
+ process.exit(1);
59
+ }
60
+ process.stdout.write(
61
+ `Scaffolded ${file}\nFill every row (one per capability/REQ cluster); reads/uiConsumer may be "N/A".\nValidate with: forge spine check\n`,
62
+ );
63
+ process.exit(0);
64
+ }
65
+
66
+ if (sub === 'check' || sub === 'status') {
67
+ if (!fs.existsSync(file)) {
68
+ const msg = `spine.json not found at ${file} — run forge spine init\n`;
69
+ if (sub === 'check') {
70
+ process.stderr.write(msg);
71
+ process.exit(1);
72
+ }
73
+ process.stdout.write(JSON.stringify({ file, exists: false }, null, 2));
74
+ process.stdout.write('\n');
75
+ process.exit(0);
76
+ }
77
+ let result;
78
+ try {
79
+ result = validateSpine(readJson(file));
80
+ } catch (err) {
81
+ result = { ok: false, problems: [`unreadable: ${err instanceof Error ? err.message : err}`] };
82
+ }
83
+ process.stdout.write(
84
+ JSON.stringify({ file, exists: true, ok: result.ok, problems: result.problems }, null, 2),
85
+ );
86
+ process.stdout.write('\n');
87
+ process.exit(sub === 'check' && !result.ok ? 1 : 0);
88
+ }
89
+
90
+ process.stderr.write(`Unknown subcommand: ${sub}\n`);
91
+ process.exit(1);
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Heuristics for Forge auto-triage on agent UserPromptSubmit hooks.
4
+ * Mirrors references/substantial-work.md — err toward Forge when unsure.
5
+ */
6
+
7
+ import path from 'node:path';
8
+ import { pathToFileURL } from 'node:url';
9
+
10
+ export function isForgeSkip(prompt) {
11
+ return /^\s*\/forge:skip\b/i.test((prompt || '').trim());
12
+ }
13
+
14
+ export function isForgeInvocation(prompt) {
15
+ return /^\s*\/forge(?::|\s|$)/i.test((prompt || '').trim());
16
+ }
17
+
18
+ export function isTrivialEdit(prompt) {
19
+ const p = (prompt || '').trim();
20
+ if (!p) return true;
21
+ return (
22
+ /\b(typo|formatting only|whitespace only|comment only|rename only|no behavior change|zero behavior)\b/i.test(p)
23
+ || /^\s*fix(ed)?\s+(a|the)?\s*typo\b/i.test(p)
24
+ );
25
+ }
26
+
27
+ export function isReadOnlyQuestion(prompt) {
28
+ const p = (prompt || '').trim();
29
+ if (!p) return true;
30
+
31
+ const implementVerb =
32
+ /\b(add|build|implement|create|fix|update|wire|integrate|migrate|refactor|change|modify|delete|remove|make|enable|support|debug|investigate)\b/i;
33
+ const diagnosticFix =
34
+ /\b(doesn'?t|does not|isn'?t|not)\s+(seem to|work|fire|load|trigger|run)\b/i.test(p)
35
+ || /\b(wired correctly|not wired|broken wiring|misconfigured)\b/i.test(p);
36
+
37
+ if (diagnosticFix) return false;
38
+
39
+ const questionLead =
40
+ /^(what|how does|how do|why|where|when|who|explain|describe|tell me about|can you explain|is there|are there|does the|do we|show me|list|summarize)\b/i;
41
+ if (questionLead.test(p) && !implementVerb.test(p)) return true;
42
+
43
+ if (/\?\s*$/.test(p) && !implementVerb.test(p) && !/\b(check if|verify that|audit)\b/i.test(p)) {
44
+ return true;
45
+ }
46
+
47
+ return false;
48
+ }
49
+
50
+ export function isSubstantialWork(prompt) {
51
+ const p = (prompt || '').trim();
52
+ if (!p) return false;
53
+ if (isForgeSkip(p)) return false;
54
+ if (isForgeInvocation(p)) return true;
55
+ if (isTrivialEdit(p)) return false;
56
+ if (isReadOnlyQuestion(p)) return false;
57
+
58
+ const patterns = [
59
+ /^\s*(please\s+)?(add|build|create|implement|develop|wire|integrate|migrate|port|enable|support|make|change|modify|update|remove|delete|refactor|fix|debug|investigate|set up|setup)\b/i,
60
+ /\b(add|build|implement|create|wire up|hook up)\s+(a|an|the|new)\b/i,
61
+ /\b(doesn'?t|does not|isn'?t)\s+(seem to|work|fire|load|trigger|run)\b/i,
62
+ /\b(bug|regression|broken|misconfigured|not wired)\b/i,
63
+ /\bnew feature\b/i,
64
+ /\bcheck if\b.*\b(wired|configured|working|hook)\b/i,
65
+ /\b(ensure|make sure)\b.*\b(wired|configured|working|fires?)\b/i,
66
+ ];
67
+
68
+ return patterns.some((re) => re.test(p));
69
+ }
70
+
71
+ export function shouldForgeTriage(prompt) {
72
+ if (isForgeInvocation(prompt)) return false;
73
+ return isSubstantialWork(prompt);
74
+ }
75
+
76
+ export function buildForgeTriageMessage(options = {}) {
77
+ const {
78
+ hasActiveSession = false,
79
+ skillPath = 'forge skill (SKILL.md)',
80
+ sessionLines = [],
81
+ } = options;
82
+
83
+ const lines = [];
84
+ lines.push('[forge] Substantial work detected — triage before implementation.');
85
+ lines.push('');
86
+ lines.push(`1. Read the Forge skill (\`${skillPath}\`) and follow triage (references/substantial-work.md).`);
87
+ if (!hasActiveSession) {
88
+ lines.push('2. If entering Forge: `forge new <slug>` then continue the current phase.');
89
+ } else if (sessionLines.length > 0) {
90
+ lines.push('2. Active session:');
91
+ for (const line of sessionLines) lines.push(` ${line}`);
92
+ }
93
+ lines.push('3. Skip Forge for this task only: `/forge:skip`');
94
+ lines.push('Guide: Forge skill + forgekit docs/forge.md');
95
+ return lines.join('\n');
96
+ }
97
+
98
+ /**
99
+ * CLI:
100
+ * forge triage --check "<prompt>" exit 0 if should triage, else 1
101
+ * forge triage --message "<prompt>" print triage reminder (always)
102
+ * forge triage --message --has-session "<prompt>"
103
+ */
104
+ function parseTriageArgs(argv) {
105
+ const opts = {
106
+ check: false,
107
+ message: false,
108
+ hasSession: false,
109
+ help: false,
110
+ prompt: '',
111
+ };
112
+ const rest = [];
113
+ for (let i = 0; i < argv.length; i += 1) {
114
+ const arg = argv[i];
115
+ if (arg === '--check') opts.check = true;
116
+ else if (arg === '--message') opts.message = true;
117
+ else if (arg === '--has-session') opts.hasSession = true;
118
+ else if (arg === '--help' || arg === '-h') opts.help = true;
119
+ else rest.push(arg);
120
+ }
121
+ opts.prompt = rest.join(' ').trim();
122
+ return opts;
123
+ }
124
+
125
+ function printTriageHelp() {
126
+ process.stdout.write(`Usage:
127
+ forge triage --check "<prompt>"
128
+ forge triage --message [--has-session] "<prompt>"
129
+ `);
130
+ }
131
+
132
+ async function triageMain(argv = process.argv.slice(2)) {
133
+ const opts = parseTriageArgs(argv);
134
+ if (opts.help || (!opts.check && !opts.message)) {
135
+ printTriageHelp();
136
+ return opts.help ? 0 : 1;
137
+ }
138
+
139
+ let prompt = opts.prompt;
140
+ if (!prompt && !process.stdin.isTTY) {
141
+ prompt = await new Promise((resolve) => {
142
+ let data = '';
143
+ process.stdin.setEncoding('utf8');
144
+ process.stdin.on('data', (c) => {
145
+ data += c;
146
+ });
147
+ process.stdin.on('end', () => resolve(data.trim()));
148
+ setTimeout(() => resolve(data.trim()), 1000);
149
+ });
150
+ }
151
+
152
+ if (opts.check) {
153
+ return shouldForgeTriage(prompt) ? 0 : 1;
154
+ }
155
+
156
+ process.stdout.write(
157
+ `${buildForgeTriageMessage({
158
+ hasActiveSession: opts.hasSession,
159
+ sessionLines: [],
160
+ })}\n`,
161
+ );
162
+ return 0;
163
+ }
164
+
165
+ const isDirect =
166
+ process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;
167
+
168
+ if (isDirect) {
169
+ triageMain()
170
+ .then((code) => process.exit(code))
171
+ .catch((err) => {
172
+ process.stderr.write(`${err.message || err}\n`);
173
+ process.exit(1);
174
+ });
175
+ }
@@ -0,0 +1,50 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ isForgeSkip,
5
+ isForgeInvocation,
6
+ isReadOnlyQuestion,
7
+ isSubstantialWork,
8
+ shouldForgeTriage,
9
+ } from './triage-prompt.mjs';
10
+
11
+ test('isForgeSkip matches /forge:skip', () => {
12
+ assert.equal(isForgeSkip('/forge:skip fix typo'), true);
13
+ assert.equal(isForgeSkip(' /forge:skip'), true);
14
+ });
15
+
16
+ test('isForgeInvocation matches forge commands', () => {
17
+ assert.equal(isForgeInvocation('/forge'), true);
18
+ assert.equal(isForgeInvocation('/forge:brainstorm'), true);
19
+ });
20
+
21
+ test('read-only questions skip triage', () => {
22
+ assert.equal(isReadOnlyQuestion('How does auth work?'), true);
23
+ assert.equal(isReadOnlyQuestion('What is forge?'), true);
24
+ });
25
+
26
+ test('diagnostic fix requests are substantial', () => {
27
+ const prompt =
28
+ 'Check if Claude is wired correctly. It does not seem to fire the forge flow automatically';
29
+ assert.equal(isReadOnlyQuestion(prompt), false);
30
+ assert.equal(isSubstantialWork(prompt), true);
31
+ assert.equal(shouldForgeTriage(prompt), true);
32
+ });
33
+
34
+ test('implementation prompts triage', () => {
35
+ assert.equal(shouldForgeTriage('Add login to mercury console'), true);
36
+ assert.equal(shouldForgeTriage('Fix the bug in callback verification'), true);
37
+ });
38
+
39
+ test('explicit /forge handled by prompt hook not triage hook', () => {
40
+ assert.equal(shouldForgeTriage('/forge:brainstorm'), false);
41
+ assert.equal(isSubstantialWork('/forge:brainstorm'), true);
42
+ });
43
+
44
+ test('/forge:skip is not substantial', () => {
45
+ assert.equal(isSubstantialWork('/forge:skip rename variable'), false);
46
+ });
47
+
48
+ test('trivial edits skip', () => {
49
+ assert.equal(isSubstantialWork('Fix the typo in README'), false);
50
+ });