@izkac/forgekit 0.3.12 → 0.3.14

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 (120) hide show
  1. package/bin/forge.mjs +119 -107
  2. package/bin/forgekit.mjs +83 -83
  3. package/bin/review.mjs +81 -81
  4. package/package.json +2 -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/fleet.test.mjs +50 -0
  17. package/src/init.mjs +680 -621
  18. package/src/install.mjs +815 -815
  19. package/src/install.test.mjs +180 -180
  20. package/src/integrity-check.mjs +60 -60
  21. package/src/integrity.mjs +688 -682
  22. package/src/integrity.test.mjs +612 -566
  23. package/src/lib/fleet.mjs +61 -4
  24. package/src/lib.mjs +160 -143
  25. package/src/lib.test.mjs +128 -0
  26. package/src/models.defaults.json +41 -41
  27. package/src/new-session.mjs +99 -99
  28. package/src/openspec-overlays/README.md +19 -19
  29. package/src/openspec-overlays/openspec-apply-change-footer.md +14 -14
  30. package/src/openspec-overlays/opsx-apply-completion-step.md +1 -1
  31. package/src/openspec-overlays/opsx-apply-implement-step.md +11 -11
  32. package/src/paths.mjs +92 -92
  33. package/src/plan-engine.mjs +321 -278
  34. package/src/plan-engine.test.mjs +447 -283
  35. package/src/preferences.defaults.json +78 -78
  36. package/src/preferences.mjs +438 -438
  37. package/src/preferences.test.mjs +174 -174
  38. package/src/record-evidence.mjs +204 -204
  39. package/src/repo-root.mjs +33 -0
  40. package/src/resolve-model.mjs +312 -312
  41. package/src/resolve-model.test.mjs +194 -194
  42. package/src/review/cli.test.mjs +117 -117
  43. package/src/review/export.mjs +172 -172
  44. package/src/review/export.test.mjs +197 -197
  45. package/src/review/fixtures/valid-review.json +42 -42
  46. package/src/review/lib.mjs +894 -894
  47. package/src/review/lib.test.mjs +266 -266
  48. package/src/review/schema.json +196 -196
  49. package/src/review/signals.test.mjs +62 -62
  50. package/src/score-cli.mjs +68 -68
  51. package/src/score.mjs +568 -568
  52. package/src/score.test.mjs +366 -366
  53. package/src/session-reminder.mjs +207 -207
  54. package/src/session-status.mjs +70 -70
  55. package/src/set-models.mjs +186 -186
  56. package/src/set-phase.mjs +205 -205
  57. package/src/set-prefs.mjs +294 -294
  58. package/src/specs-sync.mjs +232 -0
  59. package/src/specs-sync.test.mjs +114 -0
  60. package/src/spine.mjs +93 -93
  61. package/src/triage-prompt.mjs +175 -175
  62. package/src/triage-prompt.test.mjs +50 -50
  63. package/src/vendor-openspec-overlays.mjs +176 -176
  64. package/src/vendor-openspec-overlays.test.mjs +62 -62
  65. package/vendor/skills/archive-to-adr/SKILL.md +149 -149
  66. package/vendor/skills/forge/SKILL.md +136 -136
  67. package/vendor/skills/forge/docs/forge.md +650 -647
  68. package/vendor/skills/forge/phases/brainstorm.md +23 -23
  69. package/vendor/skills/forge/phases/finish.md +90 -87
  70. package/vendor/skills/forge/phases/implement.md +77 -77
  71. package/vendor/skills/forge/phases/plan-openspec.md +60 -60
  72. package/vendor/skills/forge/phases/plan-specs.md +163 -117
  73. package/vendor/skills/forge/phases/review.md +25 -25
  74. package/vendor/skills/forge/phases/verify.md +124 -124
  75. package/vendor/skills/forge/references/forge-layout.md +85 -85
  76. package/vendor/skills/forge/references/pace.md +115 -115
  77. package/vendor/skills/forge/references/plan-routing.md +52 -51
  78. package/vendor/skills/forge/references/runtime-integrity.md +232 -232
  79. package/vendor/skills/forge/references/substantial-work.md +37 -37
  80. package/vendor/skills/forge/references/test-evidence.md +30 -30
  81. package/vendor/skills/forge/references/test-strategy.md +68 -68
  82. package/vendor/skills/forge/skills/subagent-driven-development/SKILL.md +87 -87
  83. package/vendor/skills/forge/subagents/final-reviewer-prompt.md +56 -56
  84. package/vendor/skills/forge/subagents/implementer-prompt.md +38 -38
  85. package/vendor/skills/git-resolve-adr-conflict/SKILL.md +132 -132
  86. package/vendor/skills/thorough-code-review/SKILL.md +290 -290
  87. package/vendor/skills/thorough-code-review/examples.md +133 -133
  88. package/vendor/skills/thorough-code-review/reference/accepted-risks.md +26 -26
  89. package/vendor/skills/thorough-code-review/reference/lenses.md +96 -96
  90. package/vendor/skills/thorough-code-review/reference/phase1-scout.md +62 -62
  91. package/vendor/skills/thorough-code-review/reference/phase2-skeptic.md +105 -105
  92. package/vendor/skills/thorough-code-review/reference/report-schema.json +222 -222
  93. package/vendor/skills/thorough-code-review/reference/report-template.md +115 -115
  94. package/vendor/skills/thorough-code-review/reference/severity-rubric.md +49 -49
  95. package/vendor/skills/thorough-code-review/reference/signals-preflight.md +55 -55
  96. package/vendor/templates/adr/README.md +7 -7
  97. package/vendor/templates/adr/decisions.md +141 -141
  98. package/vendor/templates/adr/hooks/check-pending-adrs.mjs +74 -74
  99. package/vendor/templates/adr/hooks/check-pending-adrs.sh +3 -3
  100. package/vendor/templates/adr/hooks/openspec-archive-agent-message.mjs +52 -52
  101. package/vendor/templates/adr/hooks/openspec-archive-agent-message.sh +3 -3
  102. package/vendor/templates/project/claude/commands/forge-apply.md +75 -75
  103. package/vendor/templates/project/claude/commands/forge-brainstorm.md +7 -7
  104. package/vendor/templates/project/claude/commands/forge-build.md +17 -17
  105. package/vendor/templates/project/claude/commands/forge-plan.md +12 -12
  106. package/vendor/templates/project/claude/commands/forge-skip.md +14 -14
  107. package/vendor/templates/project/claude/commands/forge-status.md +16 -16
  108. package/vendor/templates/project/claude/commands/forge.md +16 -16
  109. package/vendor/templates/project/claude/hooks/forge-prompt-hook.mjs +73 -73
  110. package/vendor/templates/project/claude/hooks/forge-session-start.mjs +19 -19
  111. package/vendor/templates/project/claude/hooks/forge-triage-hook.mjs +77 -77
  112. package/vendor/templates/project/cursor/commands/forge-apply.md +75 -75
  113. package/vendor/templates/project/cursor/commands/forge-brainstorm.md +10 -10
  114. package/vendor/templates/project/cursor/commands/forge-build.md +17 -17
  115. package/vendor/templates/project/cursor/commands/forge-plan.md +15 -15
  116. package/vendor/templates/project/cursor/commands/forge-skip.md +14 -14
  117. package/vendor/templates/project/cursor/commands/forge-status.md +16 -16
  118. package/vendor/templates/project/cursor/commands/forge.md +16 -16
  119. package/vendor/templates/project/cursor/hooks/forge-session-start.mjs +30 -30
  120. 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
+ }