@izkac/forgekit 0.3.12 → 0.3.13

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 (116) hide show
  1. package/bin/forge.mjs +107 -107
  2. package/bin/forgekit.mjs +83 -83
  3. package/bin/review.mjs +81 -81
  4. package/package.json +1 -1
  5. package/scripts/prepack.mjs +78 -78
  6. package/scripts/run-tests.mjs +50 -50
  7. package/src/adr.mjs +236 -236
  8. package/src/adr.test.mjs +170 -170
  9. package/src/change.mjs +327 -234
  10. package/src/change.test.mjs +145 -83
  11. package/src/cleanup-sessions.mjs +84 -84
  12. package/src/config.mjs +103 -103
  13. package/src/defer.mjs +75 -75
  14. package/src/doctor.mjs +350 -341
  15. package/src/doctor.test.mjs +114 -114
  16. package/src/init.mjs +680 -621
  17. package/src/install.mjs +815 -815
  18. package/src/install.test.mjs +180 -180
  19. package/src/integrity-check.mjs +60 -60
  20. package/src/integrity.mjs +682 -682
  21. package/src/integrity.test.mjs +566 -566
  22. package/src/lib.mjs +143 -143
  23. package/src/models.defaults.json +41 -41
  24. package/src/new-session.mjs +99 -99
  25. package/src/openspec-overlays/README.md +19 -19
  26. package/src/openspec-overlays/openspec-apply-change-footer.md +14 -14
  27. package/src/openspec-overlays/opsx-apply-completion-step.md +1 -1
  28. package/src/openspec-overlays/opsx-apply-implement-step.md +11 -11
  29. package/src/paths.mjs +92 -92
  30. package/src/plan-engine.mjs +321 -278
  31. package/src/plan-engine.test.mjs +447 -283
  32. package/src/preferences.defaults.json +78 -78
  33. package/src/preferences.mjs +438 -438
  34. package/src/preferences.test.mjs +174 -174
  35. package/src/record-evidence.mjs +204 -204
  36. package/src/resolve-model.mjs +312 -312
  37. package/src/resolve-model.test.mjs +194 -194
  38. package/src/review/cli.test.mjs +117 -117
  39. package/src/review/export.mjs +172 -172
  40. package/src/review/export.test.mjs +197 -197
  41. package/src/review/fixtures/valid-review.json +42 -42
  42. package/src/review/lib.mjs +894 -894
  43. package/src/review/lib.test.mjs +266 -266
  44. package/src/review/schema.json +196 -196
  45. package/src/review/signals.test.mjs +62 -62
  46. package/src/score-cli.mjs +68 -68
  47. package/src/score.mjs +568 -568
  48. package/src/score.test.mjs +366 -366
  49. package/src/session-reminder.mjs +207 -207
  50. package/src/session-status.mjs +70 -70
  51. package/src/set-models.mjs +186 -186
  52. package/src/set-phase.mjs +205 -205
  53. package/src/set-prefs.mjs +294 -294
  54. package/src/specs-sync.mjs +234 -0
  55. package/src/specs-sync.test.mjs +114 -0
  56. package/src/spine.mjs +93 -93
  57. package/src/triage-prompt.mjs +175 -175
  58. package/src/triage-prompt.test.mjs +50 -50
  59. package/src/vendor-openspec-overlays.mjs +176 -176
  60. package/src/vendor-openspec-overlays.test.mjs +62 -62
  61. package/vendor/skills/archive-to-adr/SKILL.md +149 -149
  62. package/vendor/skills/forge/SKILL.md +136 -136
  63. package/vendor/skills/forge/docs/forge.md +650 -647
  64. package/vendor/skills/forge/phases/brainstorm.md +23 -23
  65. package/vendor/skills/forge/phases/finish.md +90 -87
  66. package/vendor/skills/forge/phases/implement.md +77 -77
  67. package/vendor/skills/forge/phases/plan-openspec.md +60 -60
  68. package/vendor/skills/forge/phases/plan-specs.md +163 -117
  69. package/vendor/skills/forge/phases/review.md +25 -25
  70. package/vendor/skills/forge/phases/verify.md +124 -124
  71. package/vendor/skills/forge/references/forge-layout.md +85 -85
  72. package/vendor/skills/forge/references/pace.md +115 -115
  73. package/vendor/skills/forge/references/plan-routing.md +52 -51
  74. package/vendor/skills/forge/references/runtime-integrity.md +232 -232
  75. package/vendor/skills/forge/references/substantial-work.md +37 -37
  76. package/vendor/skills/forge/references/test-evidence.md +30 -30
  77. package/vendor/skills/forge/references/test-strategy.md +68 -68
  78. package/vendor/skills/forge/skills/subagent-driven-development/SKILL.md +87 -87
  79. package/vendor/skills/forge/subagents/final-reviewer-prompt.md +56 -56
  80. package/vendor/skills/forge/subagents/implementer-prompt.md +38 -38
  81. package/vendor/skills/git-resolve-adr-conflict/SKILL.md +132 -132
  82. package/vendor/skills/thorough-code-review/SKILL.md +290 -290
  83. package/vendor/skills/thorough-code-review/examples.md +133 -133
  84. package/vendor/skills/thorough-code-review/reference/accepted-risks.md +26 -26
  85. package/vendor/skills/thorough-code-review/reference/lenses.md +96 -96
  86. package/vendor/skills/thorough-code-review/reference/phase1-scout.md +62 -62
  87. package/vendor/skills/thorough-code-review/reference/phase2-skeptic.md +105 -105
  88. package/vendor/skills/thorough-code-review/reference/report-schema.json +222 -222
  89. package/vendor/skills/thorough-code-review/reference/report-template.md +115 -115
  90. package/vendor/skills/thorough-code-review/reference/severity-rubric.md +49 -49
  91. package/vendor/skills/thorough-code-review/reference/signals-preflight.md +55 -55
  92. package/vendor/templates/adr/README.md +7 -7
  93. package/vendor/templates/adr/decisions.md +141 -141
  94. package/vendor/templates/adr/hooks/check-pending-adrs.mjs +74 -74
  95. package/vendor/templates/adr/hooks/check-pending-adrs.sh +3 -3
  96. package/vendor/templates/adr/hooks/openspec-archive-agent-message.mjs +52 -52
  97. package/vendor/templates/adr/hooks/openspec-archive-agent-message.sh +3 -3
  98. package/vendor/templates/project/claude/commands/forge-apply.md +75 -75
  99. package/vendor/templates/project/claude/commands/forge-brainstorm.md +7 -7
  100. package/vendor/templates/project/claude/commands/forge-build.md +17 -17
  101. package/vendor/templates/project/claude/commands/forge-plan.md +12 -12
  102. package/vendor/templates/project/claude/commands/forge-skip.md +14 -14
  103. package/vendor/templates/project/claude/commands/forge-status.md +16 -16
  104. package/vendor/templates/project/claude/commands/forge.md +16 -16
  105. package/vendor/templates/project/claude/hooks/forge-prompt-hook.mjs +73 -73
  106. package/vendor/templates/project/claude/hooks/forge-session-start.mjs +19 -19
  107. package/vendor/templates/project/claude/hooks/forge-triage-hook.mjs +77 -77
  108. package/vendor/templates/project/cursor/commands/forge-apply.md +75 -75
  109. package/vendor/templates/project/cursor/commands/forge-brainstorm.md +10 -10
  110. package/vendor/templates/project/cursor/commands/forge-build.md +17 -17
  111. package/vendor/templates/project/cursor/commands/forge-plan.md +15 -15
  112. package/vendor/templates/project/cursor/commands/forge-skip.md +14 -14
  113. package/vendor/templates/project/cursor/commands/forge-status.md +16 -16
  114. package/vendor/templates/project/cursor/commands/forge.md +16 -16
  115. package/vendor/templates/project/cursor/hooks/forge-session-start.mjs +30 -30
  116. package/vendor/templates/project/cursor/hooks/forge-session-start.sh +3 -3
package/src/doctor.mjs CHANGED
@@ -1,341 +1,350 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Forge readiness checks (OpenSpec project + CLI).
4
- *
5
- * Usage:
6
- * forge doctor
7
- * forge doctor --json
8
- * forge doctor --install
9
- * forge doctor --warn-only # always exit 0 (for forge:new)
10
- */
11
-
12
- import { spawnSync } from 'node:child_process';
13
- import fs from 'node:fs';
14
- import path from 'node:path';
15
- import { pathToFileURL } from 'node:url';
16
- import {
17
- OPENSPEC_PACKAGE,
18
- OPENSPEC_INSTALL_CMD,
19
- resolveProjectPlanEngine,
20
- } from './plan-engine.mjs';
21
-
22
- export { OPENSPEC_PACKAGE, OPENSPEC_INSTALL_CMD };
23
-
24
- /**
25
- * @param {string[]} argv
26
- */
27
- export function parseArgs(argv) {
28
- const opts = {
29
- json: false,
30
- install: false,
31
- warnOnly: false,
32
- cwd: null,
33
- help: false,
34
- };
35
-
36
- for (let i = 0; i < argv.length; i += 1) {
37
- const arg = argv[i];
38
- if (arg === '--json') opts.json = true;
39
- else if (arg === '--install') opts.install = true;
40
- else if (arg === '--warn-only') opts.warnOnly = true;
41
- else if (arg === '--cwd') opts.cwd = argv[++i];
42
- else if (arg === '--help' || arg === '-h') opts.help = true;
43
- else if (arg === '--') continue;
44
- else throw new Error(`Unknown argument: ${arg}`);
45
- }
46
-
47
- return opts;
48
- }
49
-
50
- function printHelp() {
51
- process.stdout.write(`Usage: forge doctor [options]
52
-
53
- Check planning-engine readiness. OpenSpec projects: config + CLI availability.
54
- Specs-engine projects (.forge/config.json → plan.engine: specs): specs/changes/ layout.
55
-
56
- Options:
57
- --json Machine-readable report
58
- --install Attempt: ${OPENSPEC_INSTALL_CMD}
59
- --warn-only Print warnings but exit 0 (used by forge:new)
60
- --cwd <path> Project root (default: process.cwd())
61
- --help
62
- `);
63
- }
64
-
65
- /**
66
- * @param {{ cwd: string, existsSync?: typeof fs.existsSync }} opts
67
- */
68
- export function checkOpenSpecProject(opts) {
69
- const existsSync = opts.existsSync ?? fs.existsSync;
70
- const configPath = path.join(opts.cwd, 'openspec', 'config.yaml');
71
- const ok = existsSync(configPath);
72
- return {
73
- id: 'openspec-project',
74
- ok,
75
- configPath,
76
- message: ok
77
- ? 'openspec/config.yaml found'
78
- : 'openspec/config.yaml missing — run openspec init in the repo root if this is a new project',
79
- };
80
- }
81
-
82
- /**
83
- * @param {{
84
- * runCommand?: (cmd: string, args: string[], opts?: { cwd?: string }) => { status: number | null, stdout: string, stderr: string },
85
- * cwd?: string,
86
- * }} [opts]
87
- */
88
- export function checkOpenSpecCli(opts = {}) {
89
- const run =
90
- opts.runCommand ??
91
- ((cmd, args, runOpts = {}) => {
92
- // Prefer argv form without shell. On Windows, fall back to a single
93
- // shell string so `.cmd` shims resolve without DEP0190 (args+shell).
94
- let result = spawnSync(cmd, args, {
95
- encoding: 'utf8',
96
- shell: false,
97
- cwd: runOpts.cwd,
98
- });
99
- if (result.error && process.platform === 'win32') {
100
- const line = [cmd, ...args].join(' ');
101
- result = spawnSync(line, {
102
- encoding: 'utf8',
103
- shell: true,
104
- cwd: runOpts.cwd,
105
- });
106
- }
107
- return {
108
- status: result.status,
109
- stdout: result.stdout || '',
110
- stderr: result.stderr || '',
111
- error: result.error,
112
- };
113
- });
114
-
115
- const attempt = run('openspec', ['--version'], { cwd: opts.cwd });
116
- if (attempt.status === 0) {
117
- const version = String(attempt.stdout || '').trim().split(/\r?\n/)[0] || 'unknown';
118
- return {
119
- id: 'openspec-cli',
120
- ok: true,
121
- version,
122
- message: `openspec CLI available (${version})`,
123
- installCommand: OPENSPEC_INSTALL_CMD,
124
- };
125
- }
126
-
127
- return {
128
- id: 'openspec-cli',
129
- ok: false,
130
- version: null,
131
- message:
132
- `openspec CLI not found on PATH. Install with:\n ${OPENSPEC_INSTALL_CMD}\n` +
133
- `Then re-run: forge doctor`,
134
- installCommand: OPENSPEC_INSTALL_CMD,
135
- detail: String(attempt.stderr || attempt.stdout || attempt.error || '').trim() || null,
136
- };
137
- }
138
-
139
- /**
140
- * @param {{ cwd: string, dir: string, existsSync?: typeof fs.existsSync }} opts
141
- */
142
- export function checkSpecsProject(opts) {
143
- const existsSync = opts.existsSync ?? fs.existsSync;
144
- const changesPath = path.join(opts.cwd, opts.dir, 'changes');
145
- const ok = existsSync(changesPath);
146
- return {
147
- id: 'specs-project',
148
- ok,
149
- changesPath,
150
- message: ok
151
- ? `${opts.dir}/changes/ found (built-in specs engine)`
152
- : `${opts.dir}/changes/ missing — run \`forge init --no-openspec\` to scaffold the specs engine`,
153
- };
154
- }
155
-
156
- /**
157
- * @param {{
158
- * cwd?: string,
159
- * install?: boolean,
160
- * existsSync?: typeof fs.existsSync,
161
- * runCommand?: Function,
162
- * }} [opts]
163
- */
164
- export function runDoctorChecks(opts = {}) {
165
- const cwd = opts.cwd ?? process.cwd();
166
- const engine = resolveProjectPlanEngine(cwd, { useUserDefault: false });
167
-
168
- if (engine.engine === 'specs') {
169
- const project = checkSpecsProject({
170
- cwd,
171
- dir: engine.dir,
172
- existsSync: opts.existsSync,
173
- });
174
- const cli = {
175
- id: 'openspec-cli',
176
- ok: true,
177
- skipped: true,
178
- version: null,
179
- message: 'built-in specs engine — OpenSpec CLI not required',
180
- installCommand: OPENSPEC_INSTALL_CMD,
181
- };
182
- return {
183
- ok: project.ok,
184
- engine: engine.engine,
185
- checks: { project, cli },
186
- installCommand: OPENSPEC_INSTALL_CMD,
187
- actions: [],
188
- };
189
- }
190
-
191
- const project = checkOpenSpecProject({ cwd, existsSync: opts.existsSync });
192
- let cli = checkOpenSpecCli({ cwd, runCommand: opts.runCommand });
193
-
194
- /** @type {string[]} */
195
- const actions = [];
196
-
197
- if (opts.install && !cli.ok) {
198
- actions.push(OPENSPEC_INSTALL_CMD);
199
- const run =
200
- opts.runCommand ??
201
- ((cmd, args) => {
202
- let result = spawnSync(cmd, args, {
203
- encoding: 'utf8',
204
- shell: false,
205
- cwd,
206
- });
207
- if (result.error && process.platform === 'win32') {
208
- result = spawnSync([cmd, ...args].join(' '), {
209
- encoding: 'utf8',
210
- shell: true,
211
- cwd,
212
- });
213
- }
214
- return {
215
- status: result.status,
216
- stdout: result.stdout || '',
217
- stderr: result.stderr || '',
218
- error: result.error,
219
- };
220
- });
221
- const installResult = run('npm', ['install', '-g', OPENSPEC_PACKAGE], { cwd });
222
- actions.push(
223
- installResult.status === 0
224
- ? 'install: ok'
225
- : `install: failed (${String(installResult.stderr || installResult.stdout || '').trim() || installResult.status})`,
226
- );
227
- cli = checkOpenSpecCli({ cwd, runCommand: opts.runCommand });
228
- }
229
-
230
- const ok = project.ok && cli.ok;
231
- return {
232
- ok,
233
- engine: engine.engine,
234
- checks: { project, cli },
235
- installCommand: OPENSPEC_INSTALL_CMD,
236
- actions,
237
- };
238
- }
239
-
240
- /**
241
- * @param {string[]} argv
242
- * @param {{
243
- * cwd?: string,
244
- * stdout?: NodeJS.WritableStream,
245
- * stderr?: NodeJS.WritableStream,
246
- * existsSync?: typeof fs.existsSync,
247
- * runCommand?: Function,
248
- * }} [io]
249
- */
250
- export function runDoctor(argv, io = {}) {
251
- const stdout = io.stdout ?? process.stdout;
252
- const stderr = io.stderr ?? process.stderr;
253
- const cwd = io.cwd ?? process.cwd();
254
-
255
- let opts;
256
- try {
257
- opts = parseArgs(argv);
258
- } catch (err) {
259
- const msg = err instanceof Error ? err.message : String(err);
260
- stderr.write(`${msg}\n`);
261
- return 2;
262
- }
263
-
264
- if (opts.help) {
265
- printHelp();
266
- return 0;
267
- }
268
-
269
- const report = runDoctorChecks({
270
- cwd: opts.cwd ?? cwd,
271
- install: opts.install,
272
- existsSync: io.existsSync,
273
- runCommand: io.runCommand,
274
- });
275
-
276
- if (opts.json) {
277
- stdout.write(`${JSON.stringify(report, null, 2)}\n`);
278
- } else {
279
- const { project, cli } = report.checks;
280
- stdout.write(`Forge doctor (plan engine: ${report.engine ?? 'openspec'})\n`);
281
- stdout.write(` [${project.ok ? 'ok' : 'FAIL'}] ${project.message}\n`);
282
- stdout.write(` [${cli.ok ? 'ok' : 'FAIL'}] ${cli.message}\n`);
283
- if (!cli.ok) {
284
- stdout.write(`\nOffer: install OpenSpec CLI?\n ${cli.installCommand}\n`);
285
- stdout.write(`Or re-run with: forge doctor --install\n`);
286
- }
287
- for (const action of report.actions) {
288
- stdout.write(` action: ${action}\n`);
289
- }
290
- stdout.write(report.ok ? '\nAll checks passed.\n' : '\nDoctor found issues.\n');
291
- }
292
-
293
- if (opts.warnOnly) return 0;
294
- return report.ok ? 0 : 1;
295
- }
296
-
297
- /**
298
- * Warn-only helper for forge:new (never throws).
299
- * @param {{
300
- * cwd?: string,
301
- * stderr?: NodeJS.WritableStream,
302
- * existsSync?: typeof fs.existsSync,
303
- * runCommand?: Function,
304
- * }} [opts]
305
- */
306
- export function warnIfDoctorFails(opts = {}) {
307
- const stderr = opts.stderr ?? process.stderr;
308
- try {
309
- const report = runDoctorChecks({
310
- cwd: opts.cwd ?? process.cwd(),
311
- existsSync: opts.existsSync,
312
- runCommand: opts.runCommand,
313
- });
314
- if (!report.ok) {
315
- stderr.write('[forge:doctor] plan-engine readiness check failed:\n');
316
- if (!report.checks.project.ok) {
317
- stderr.write(` - ${report.checks.project.message}\n`);
318
- }
319
- if (!report.checks.cli.ok) {
320
- stderr.write(` - ${report.checks.cli.message}\n`);
321
- }
322
- if (!report.checks.cli.ok) {
323
- stderr.write(` Install: ${report.installCommand}\n`);
324
- stderr.write(' Or: forge doctor --install\n');
325
- }
326
- }
327
- return report;
328
- } catch (err) {
329
- const msg = err instanceof Error ? err.message : String(err);
330
- stderr.write(`[forge:doctor] check error: ${msg}\n`);
331
- return null;
332
- }
333
- }
334
-
335
- const isMain =
336
- process.argv[1] &&
337
- import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
338
-
339
- if (isMain) {
340
- process.exitCode = runDoctor(process.argv.slice(2));
341
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Forge readiness checks (OpenSpec project + CLI).
4
+ *
5
+ * Usage:
6
+ * forge doctor
7
+ * forge doctor --json
8
+ * forge doctor --install
9
+ * forge doctor --warn-only # always exit 0 (for forge:new)
10
+ */
11
+
12
+ import { spawnSync } from 'node:child_process';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { pathToFileURL } from 'node:url';
16
+ import {
17
+ OPENSPEC_PACKAGE,
18
+ OPENSPEC_INSTALL_CMD,
19
+ resolveProjectPlanEngine,
20
+ } from './plan-engine.mjs';
21
+
22
+ export { OPENSPEC_PACKAGE, OPENSPEC_INSTALL_CMD };
23
+
24
+ /**
25
+ * @param {string[]} argv
26
+ */
27
+ export function parseArgs(argv) {
28
+ const opts = {
29
+ json: false,
30
+ install: false,
31
+ warnOnly: false,
32
+ cwd: null,
33
+ help: false,
34
+ };
35
+
36
+ for (let i = 0; i < argv.length; i += 1) {
37
+ const arg = argv[i];
38
+ if (arg === '--json') opts.json = true;
39
+ else if (arg === '--install') opts.install = true;
40
+ else if (arg === '--warn-only') opts.warnOnly = true;
41
+ else if (arg === '--cwd') opts.cwd = argv[++i];
42
+ else if (arg === '--help' || arg === '-h') opts.help = true;
43
+ else if (arg === '--') continue;
44
+ else throw new Error(`Unknown argument: ${arg}`);
45
+ }
46
+
47
+ return opts;
48
+ }
49
+
50
+ function printHelp() {
51
+ process.stdout.write(`Usage: forge doctor [options]
52
+
53
+ Check planning-engine readiness. OpenSpec projects: config + CLI availability.
54
+ Specs-engine projects (.forge/config.json → plan.engine: specs):
55
+ \`<plan.dir>/changes/\` + \`<plan.dir>/specs/\` layout.
56
+
57
+ Options:
58
+ --json Machine-readable report
59
+ --install Attempt: ${OPENSPEC_INSTALL_CMD}
60
+ --warn-only Print warnings but exit 0 (used by forge:new)
61
+ --cwd <path> Project root (default: process.cwd())
62
+ --help
63
+ `);
64
+ }
65
+
66
+ /**
67
+ * @param {{ cwd: string, existsSync?: typeof fs.existsSync }} opts
68
+ */
69
+ export function checkOpenSpecProject(opts) {
70
+ const existsSync = opts.existsSync ?? fs.existsSync;
71
+ const configPath = path.join(opts.cwd, 'openspec', 'config.yaml');
72
+ const ok = existsSync(configPath);
73
+ return {
74
+ id: 'openspec-project',
75
+ ok,
76
+ configPath,
77
+ message: ok
78
+ ? 'openspec/config.yaml found'
79
+ : 'openspec/config.yaml missing — run openspec init in the repo root if this is a new project',
80
+ };
81
+ }
82
+
83
+ /**
84
+ * @param {{
85
+ * runCommand?: (cmd: string, args: string[], opts?: { cwd?: string }) => { status: number | null, stdout: string, stderr: string },
86
+ * cwd?: string,
87
+ * }} [opts]
88
+ */
89
+ export function checkOpenSpecCli(opts = {}) {
90
+ const run =
91
+ opts.runCommand ??
92
+ ((cmd, args, runOpts = {}) => {
93
+ // Prefer argv form without shell. On Windows, fall back to a single
94
+ // shell string so `.cmd` shims resolve without DEP0190 (args+shell).
95
+ let result = spawnSync(cmd, args, {
96
+ encoding: 'utf8',
97
+ shell: false,
98
+ cwd: runOpts.cwd,
99
+ });
100
+ if (result.error && process.platform === 'win32') {
101
+ const line = [cmd, ...args].join(' ');
102
+ result = spawnSync(line, {
103
+ encoding: 'utf8',
104
+ shell: true,
105
+ cwd: runOpts.cwd,
106
+ });
107
+ }
108
+ return {
109
+ status: result.status,
110
+ stdout: result.stdout || '',
111
+ stderr: result.stderr || '',
112
+ error: result.error,
113
+ };
114
+ });
115
+
116
+ const attempt = run('openspec', ['--version'], { cwd: opts.cwd });
117
+ if (attempt.status === 0) {
118
+ const version = String(attempt.stdout || '').trim().split(/\r?\n/)[0] || 'unknown';
119
+ return {
120
+ id: 'openspec-cli',
121
+ ok: true,
122
+ version,
123
+ message: `openspec CLI available (${version})`,
124
+ installCommand: OPENSPEC_INSTALL_CMD,
125
+ };
126
+ }
127
+
128
+ return {
129
+ id: 'openspec-cli',
130
+ ok: false,
131
+ version: null,
132
+ message:
133
+ `openspec CLI not found on PATH. Install with:\n ${OPENSPEC_INSTALL_CMD}\n` +
134
+ `Then re-run: forge doctor`,
135
+ installCommand: OPENSPEC_INSTALL_CMD,
136
+ detail: String(attempt.stderr || attempt.stdout || attempt.error || '').trim() || null,
137
+ };
138
+ }
139
+
140
+ /**
141
+ * @param {{ cwd: string, dir: string, existsSync?: typeof fs.existsSync }} opts
142
+ */
143
+ export function checkSpecsProject(opts) {
144
+ const existsSync = opts.existsSync ?? fs.existsSync;
145
+ const changesPath = path.join(opts.cwd, opts.dir, 'changes');
146
+ const specsPath = path.join(opts.cwd, opts.dir, 'specs');
147
+ const changesOk = existsSync(changesPath);
148
+ const specsOk = existsSync(specsPath);
149
+ const ok = changesOk && specsOk;
150
+ const missing = [
151
+ !changesOk ? `${opts.dir}/changes/` : null,
152
+ !specsOk ? `${opts.dir}/specs/` : null,
153
+ ].filter(Boolean);
154
+ return {
155
+ id: 'specs-project',
156
+ ok,
157
+ changesPath,
158
+ specsPath,
159
+ message: ok
160
+ ? `${opts.dir}/changes/ + ${opts.dir}/specs/ found (built-in specs engine)`
161
+ : `${missing.join(' + ')} missing — run \`forge init --no-openspec\` (optionally \`--plan-dir ${opts.dir}\`) to scaffold`,
162
+ };
163
+ }
164
+
165
+ /**
166
+ * @param {{
167
+ * cwd?: string,
168
+ * install?: boolean,
169
+ * existsSync?: typeof fs.existsSync,
170
+ * runCommand?: Function,
171
+ * }} [opts]
172
+ */
173
+ export function runDoctorChecks(opts = {}) {
174
+ const cwd = opts.cwd ?? process.cwd();
175
+ const engine = resolveProjectPlanEngine(cwd, { useUserDefault: false });
176
+
177
+ if (engine.engine === 'specs') {
178
+ const project = checkSpecsProject({
179
+ cwd,
180
+ dir: engine.dir,
181
+ existsSync: opts.existsSync,
182
+ });
183
+ const cli = {
184
+ id: 'openspec-cli',
185
+ ok: true,
186
+ skipped: true,
187
+ version: null,
188
+ message: 'built-in specs engine — OpenSpec CLI not required',
189
+ installCommand: OPENSPEC_INSTALL_CMD,
190
+ };
191
+ return {
192
+ ok: project.ok,
193
+ engine: engine.engine,
194
+ checks: { project, cli },
195
+ installCommand: OPENSPEC_INSTALL_CMD,
196
+ actions: [],
197
+ };
198
+ }
199
+
200
+ const project = checkOpenSpecProject({ cwd, existsSync: opts.existsSync });
201
+ let cli = checkOpenSpecCli({ cwd, runCommand: opts.runCommand });
202
+
203
+ /** @type {string[]} */
204
+ const actions = [];
205
+
206
+ if (opts.install && !cli.ok) {
207
+ actions.push(OPENSPEC_INSTALL_CMD);
208
+ const run =
209
+ opts.runCommand ??
210
+ ((cmd, args) => {
211
+ let result = spawnSync(cmd, args, {
212
+ encoding: 'utf8',
213
+ shell: false,
214
+ cwd,
215
+ });
216
+ if (result.error && process.platform === 'win32') {
217
+ result = spawnSync([cmd, ...args].join(' '), {
218
+ encoding: 'utf8',
219
+ shell: true,
220
+ cwd,
221
+ });
222
+ }
223
+ return {
224
+ status: result.status,
225
+ stdout: result.stdout || '',
226
+ stderr: result.stderr || '',
227
+ error: result.error,
228
+ };
229
+ });
230
+ const installResult = run('npm', ['install', '-g', OPENSPEC_PACKAGE], { cwd });
231
+ actions.push(
232
+ installResult.status === 0
233
+ ? 'install: ok'
234
+ : `install: failed (${String(installResult.stderr || installResult.stdout || '').trim() || installResult.status})`,
235
+ );
236
+ cli = checkOpenSpecCli({ cwd, runCommand: opts.runCommand });
237
+ }
238
+
239
+ const ok = project.ok && cli.ok;
240
+ return {
241
+ ok,
242
+ engine: engine.engine,
243
+ checks: { project, cli },
244
+ installCommand: OPENSPEC_INSTALL_CMD,
245
+ actions,
246
+ };
247
+ }
248
+
249
+ /**
250
+ * @param {string[]} argv
251
+ * @param {{
252
+ * cwd?: string,
253
+ * stdout?: NodeJS.WritableStream,
254
+ * stderr?: NodeJS.WritableStream,
255
+ * existsSync?: typeof fs.existsSync,
256
+ * runCommand?: Function,
257
+ * }} [io]
258
+ */
259
+ export function runDoctor(argv, io = {}) {
260
+ const stdout = io.stdout ?? process.stdout;
261
+ const stderr = io.stderr ?? process.stderr;
262
+ const cwd = io.cwd ?? process.cwd();
263
+
264
+ let opts;
265
+ try {
266
+ opts = parseArgs(argv);
267
+ } catch (err) {
268
+ const msg = err instanceof Error ? err.message : String(err);
269
+ stderr.write(`${msg}\n`);
270
+ return 2;
271
+ }
272
+
273
+ if (opts.help) {
274
+ printHelp();
275
+ return 0;
276
+ }
277
+
278
+ const report = runDoctorChecks({
279
+ cwd: opts.cwd ?? cwd,
280
+ install: opts.install,
281
+ existsSync: io.existsSync,
282
+ runCommand: io.runCommand,
283
+ });
284
+
285
+ if (opts.json) {
286
+ stdout.write(`${JSON.stringify(report, null, 2)}\n`);
287
+ } else {
288
+ const { project, cli } = report.checks;
289
+ stdout.write(`Forge doctor (plan engine: ${report.engine ?? 'openspec'})\n`);
290
+ stdout.write(` [${project.ok ? 'ok' : 'FAIL'}] ${project.message}\n`);
291
+ stdout.write(` [${cli.ok ? 'ok' : 'FAIL'}] ${cli.message}\n`);
292
+ if (!cli.ok) {
293
+ stdout.write(`\nOffer: install OpenSpec CLI?\n ${cli.installCommand}\n`);
294
+ stdout.write(`Or re-run with: forge doctor --install\n`);
295
+ }
296
+ for (const action of report.actions) {
297
+ stdout.write(` action: ${action}\n`);
298
+ }
299
+ stdout.write(report.ok ? '\nAll checks passed.\n' : '\nDoctor found issues.\n');
300
+ }
301
+
302
+ if (opts.warnOnly) return 0;
303
+ return report.ok ? 0 : 1;
304
+ }
305
+
306
+ /**
307
+ * Warn-only helper for forge:new (never throws).
308
+ * @param {{
309
+ * cwd?: string,
310
+ * stderr?: NodeJS.WritableStream,
311
+ * existsSync?: typeof fs.existsSync,
312
+ * runCommand?: Function,
313
+ * }} [opts]
314
+ */
315
+ export function warnIfDoctorFails(opts = {}) {
316
+ const stderr = opts.stderr ?? process.stderr;
317
+ try {
318
+ const report = runDoctorChecks({
319
+ cwd: opts.cwd ?? process.cwd(),
320
+ existsSync: opts.existsSync,
321
+ runCommand: opts.runCommand,
322
+ });
323
+ if (!report.ok) {
324
+ stderr.write('[forge:doctor] plan-engine readiness check failed:\n');
325
+ if (!report.checks.project.ok) {
326
+ stderr.write(` - ${report.checks.project.message}\n`);
327
+ }
328
+ if (!report.checks.cli.ok) {
329
+ stderr.write(` - ${report.checks.cli.message}\n`);
330
+ }
331
+ if (!report.checks.cli.ok) {
332
+ stderr.write(` Install: ${report.installCommand}\n`);
333
+ stderr.write(' Or: forge doctor --install\n');
334
+ }
335
+ }
336
+ return report;
337
+ } catch (err) {
338
+ const msg = err instanceof Error ? err.message : String(err);
339
+ stderr.write(`[forge:doctor] check error: ${msg}\n`);
340
+ return null;
341
+ }
342
+ }
343
+
344
+ const isMain =
345
+ process.argv[1] &&
346
+ import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
347
+
348
+ if (isMain) {
349
+ process.exitCode = runDoctor(process.argv.slice(2));
350
+ }