@izkac/forgekit 0.3.11 → 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 -566
  48. package/src/score.test.mjs +366 -340
  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 -225
  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
@@ -1,278 +1,321 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Planning engine resolution + scaffolding for forgekit.
4
- *
5
- * Engines:
6
- * openspec — vendor OpenSpec CLI (`openspec/changes/<name>/`)
7
- * specs — built-in markdown engine (`specs/changes/<name>/`), same inner
8
- * layout as OpenSpec so phases / archive→ADR / later migration
9
- * stay compatible.
10
- *
11
- * User default: ~/.forgekit/config.json → { plan: { engine } }
12
- * Project: <repo>/.forge/config.json → { plan: { engine, dir } }
13
- */
14
-
15
- import fs from 'node:fs';
16
- import os from 'node:os';
17
- import path from 'node:path';
18
- import { spawnSync } from 'node:child_process';
19
- import {
20
- loadProjectConfig,
21
- loadUserConfig,
22
- saveProjectConfig,
23
- saveUserConfig,
24
- } from './config.mjs';
25
-
26
- export const PLAN_ENGINES = Object.freeze(['openspec', 'specs']);
27
- export const DEFAULT_SPECS_DIR = 'specs';
28
-
29
- export const OPENSPEC_PACKAGE = '@fission-ai/openspec';
30
- export const OPENSPEC_INSTALL_CMD = `npm install -g ${OPENSPEC_PACKAGE}`;
31
-
32
- /**
33
- * @param {unknown} value
34
- * @returns {{ engine?: string, dir?: string } | null}
35
- */
36
- function asPlan(value) {
37
- if (!value || typeof value !== 'object') return null;
38
- return /** @type {{ engine?: string, dir?: string }} */ (value);
39
- }
40
-
41
- /**
42
- * @param {string} engine
43
- */
44
- export function assertPlanEngine(engine) {
45
- if (!PLAN_ENGINES.includes(engine)) {
46
- throw new Error(`Unknown plan engine: ${engine}. Known: ${PLAN_ENGINES.join(', ')}`);
47
- }
48
- return engine;
49
- }
50
-
51
- /**
52
- * @param {string} cwd
53
- * @returns {boolean}
54
- */
55
- export function hasOpenSpecConfig(cwd) {
56
- return fs.existsSync(path.join(cwd, 'openspec', 'config.yaml'));
57
- }
58
-
59
- /**
60
- * @param {string} [home]
61
- * @returns {string | null}
62
- */
63
- export function loadUserPlanEngine(home = os.homedir()) {
64
- const cfg = loadUserConfig(home);
65
- const engine = asPlan(cfg.plan)?.engine;
66
- return PLAN_ENGINES.includes(engine) ? engine : null;
67
- }
68
-
69
- /**
70
- * Merge { plan: { engine } } into ~/.forgekit/config.json, preserving other keys.
71
- * @param {string} engine
72
- * @param {string} [home]
73
- */
74
- export function saveUserPlanEngine(engine, home = os.homedir()) {
75
- assertPlanEngine(engine);
76
- return saveUserConfig({ plan: { engine } }, home);
77
- }
78
-
79
- /**
80
- * Merge { plan: { engine, dir? } } into <cwd>/.forge/config.json, preserving adr etc.
81
- * @param {string} cwd
82
- * @param {{ engine: string, dir?: string }} plan
83
- */
84
- export function writeProjectPlanConfig(cwd, plan) {
85
- assertPlanEngine(plan.engine);
86
- const current = loadProjectConfig(cwd);
87
- const curPlan = asPlan(current.plan);
88
- /** @type {{ engine: string, dir?: string }} */
89
- const nextPlan = { engine: plan.engine };
90
- if (plan.engine === 'specs') {
91
- nextPlan.dir = plan.dir ?? curPlan?.dir ?? DEFAULT_SPECS_DIR;
92
- }
93
- return saveProjectConfig(cwd, { plan: nextPlan }, { replaceKeys: ['plan'] });
94
- }
95
-
96
- /**
97
- * Effective plan engine for a project.
98
- *
99
- * Precedence: project config → openspec/config.yaml detection → user default
100
- * 'openspec'.
101
- *
102
- * @param {string} cwd
103
- * @param {{ home?: string, useUserDefault?: boolean }} [opts]
104
- * @returns {{ engine: string, dir: string, source: string }}
105
- */
106
- export function resolveProjectPlanEngine(cwd, opts = {}) {
107
- const project = loadProjectConfig(cwd);
108
- const projectPlan = asPlan(project.plan);
109
- if (projectPlan && PLAN_ENGINES.includes(projectPlan.engine)) {
110
- return {
111
- engine: /** @type {string} */ (projectPlan.engine),
112
- dir: projectPlan.dir ?? DEFAULT_SPECS_DIR,
113
- source: 'project',
114
- };
115
- }
116
- if (hasOpenSpecConfig(cwd)) {
117
- return { engine: 'openspec', dir: DEFAULT_SPECS_DIR, source: 'detected' };
118
- }
119
- if (opts.useUserDefault !== false) {
120
- const userEngine = loadUserPlanEngine(opts.home);
121
- if (userEngine) {
122
- return { engine: userEngine, dir: DEFAULT_SPECS_DIR, source: 'user' };
123
- }
124
- }
125
- return { engine: 'openspec', dir: DEFAULT_SPECS_DIR, source: 'default' };
126
- }
127
-
128
- const SPECS_README = (dir) => `# \`${dir}/\` — Forge specs (built-in planning engine)
129
-
130
- OpenSpec-compatible change tracking without the OpenSpec CLI. Managed by the
131
- Forge workflow (see the \`forge\` skill, \`phases/plan-specs.md\`).
132
-
133
- \`\`\`
134
- ${dir}/
135
- changes/<change-name>/
136
- proposal.md # Why / What Changes / Impact
137
- design.md # optional — context, decisions, risks
138
- tasks.md # ## groups with - [ ] task checkboxes
139
- changes/archive/YYYY-MM-DD-<change-name>/ # archived on finish
140
- \`\`\`
141
-
142
- Conventions (kept identical to OpenSpec so migration stays trivial):
143
-
144
- - One change per unit of substantial work; kebab-case change names.
145
- - \`tasks.md\` uses \`##\` section groups and \`- [ ]\` checkboxes; Forge counts
146
- and reviews per group.
147
- - On finish, move the change dir into \`changes/archive/\` with a date prefix,
148
- then follow the project ADR policy if enabled.
149
-
150
- Migrating to OpenSpec later: run \`openspec init\`, then move
151
- \`${dir}/changes/*\` into \`openspec/changes/\`.
152
- `;
153
-
154
- /**
155
- * Scaffold the specs engine directory structure.
156
- * @param {string} cwd
157
- * @param {{ dir?: string, force?: boolean }} [opts]
158
- */
159
- export function scaffoldSpecs(cwd, opts = {}) {
160
- const dir = opts.dir ?? DEFAULT_SPECS_DIR;
161
- /** @type {{ file: string, status: string }[]} */
162
- const files = [];
163
-
164
- const ensureDir = (rel) => {
165
- fs.mkdirSync(path.join(cwd, ...rel.split('/')), { recursive: true });
166
- };
167
- const writeFile = (rel, body) => {
168
- const dest = path.join(cwd, ...rel.split('/'));
169
- if (fs.existsSync(dest) && !opts.force) {
170
- files.push({ file: rel, status: 'skipped' });
171
- return;
172
- }
173
- fs.writeFileSync(dest, body, 'utf8');
174
- files.push({ file: rel, status: 'written' });
175
- };
176
-
177
- ensureDir(`${dir}/changes/archive`);
178
- writeFile(`${dir}/README.md`, SPECS_README(dir));
179
- writeFile(`${dir}/changes/archive/.gitkeep`, '');
180
-
181
- return { dir, files };
182
- }
183
-
184
- /**
185
- * @returns {{ ok: boolean, version: string | null }}
186
- */
187
- export function checkOpenSpecCliQuick(runCommand) {
188
- const run =
189
- runCommand ??
190
- ((cmd, args) => {
191
- let result = spawnSync(cmd, args, { encoding: 'utf8', shell: false });
192
- if (result.error && process.platform === 'win32') {
193
- result = spawnSync([cmd, ...args].join(' '), { encoding: 'utf8', shell: true });
194
- }
195
- return { status: result.status, stdout: result.stdout || '' };
196
- });
197
- const attempt = run('openspec', ['--version']);
198
- if (attempt.status === 0) {
199
- return {
200
- ok: true,
201
- version: String(attempt.stdout || '').trim().split(/\r?\n/)[0] || 'unknown',
202
- };
203
- }
204
- return { ok: false, version: null };
205
- }
206
-
207
- /**
208
- * Map forgekit environment ids → OpenSpec tool ids (mostly identity).
209
- * @type {Record<string, string>}
210
- */
211
- const OPENSPEC_TOOL_ID = { copilot: 'github-copilot' };
212
-
213
- /**
214
- * @param {string[]} agentIds forgekit environment ids
215
- * @returns {string[]} OpenSpec tool ids
216
- */
217
- export function toOpenSpecToolIds(agentIds) {
218
- return agentIds.map((id) => OPENSPEC_TOOL_ID[id] ?? id);
219
- }
220
-
221
- /**
222
- * Install the OpenSpec CLI (if missing) and run `openspec init` in the project.
223
- * Interactive: inherits stdio so `openspec init` can prompt — unless `tools`
224
- * is given, in which case those tools are configured non-interactively
225
- * (`openspec init --tools <ids>`), skipping OpenSpec's own tool picker.
226
- *
227
- * @param {string} cwd
228
- * @param {{ runCommand?: Function, interactive?: boolean, tools?: string[] }} [opts]
229
- * @returns {{ ok: boolean, steps: { step: string, ok: boolean, detail?: string }[] }}
230
- */
231
- export function setupOpenSpec(cwd, opts = {}) {
232
- /** @type {{ step: string, ok: boolean, detail?: string }[]} */
233
- const steps = [];
234
-
235
- const runInherit =
236
- opts.runCommand ??
237
- ((cmd, args) => {
238
- let result = spawnSync(cmd, args, { stdio: 'inherit', shell: false, cwd });
239
- if (result.error && process.platform === 'win32') {
240
- result = spawnSync([cmd, ...args].join(' '), {
241
- stdio: 'inherit',
242
- shell: true,
243
- cwd,
244
- });
245
- }
246
- return { status: result.status, error: result.error };
247
- });
248
-
249
- const cli = checkOpenSpecCliQuick(opts.runCommand);
250
- if (!cli.ok) {
251
- const install = runInherit('npm', ['install', '-g', OPENSPEC_PACKAGE]);
252
- const ok = install.status === 0;
253
- steps.push({
254
- step: OPENSPEC_INSTALL_CMD,
255
- ok,
256
- detail: ok ? undefined : String(install.error ?? `exit ${install.status}`),
257
- });
258
- if (!ok) return { ok: false, steps };
259
- } else {
260
- steps.push({ step: `openspec CLI present (${cli.version})`, ok: true });
261
- }
262
-
263
- if (hasOpenSpecConfig(cwd)) {
264
- steps.push({ step: 'openspec/config.yaml already present', ok: true });
265
- return { ok: true, steps };
266
- }
267
-
268
- const tools = opts.tools?.length ? toOpenSpecToolIds(opts.tools) : null;
269
- const initArgs = tools ? ['init', '--tools', tools.join(',')] : ['init'];
270
- const init = runInherit('openspec', initArgs);
271
- const initOk = init.status === 0;
272
- steps.push({
273
- step: `openspec ${initArgs.join(' ')}`,
274
- ok: initOk,
275
- detail: initOk ? undefined : String(init.error ?? `exit ${init.status}`),
276
- });
277
- return { ok: initOk && hasOpenSpecConfig(cwd), steps };
278
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Planning engine resolution + scaffolding for forgekit.
4
+ *
5
+ * Engines:
6
+ * openspec — vendor OpenSpec CLI (`openspec/changes/<name>/`)
7
+ * specs — built-in markdown engine (`specs/changes/<name>/`), same inner
8
+ * layout as OpenSpec so phases / archive→ADR / later migration
9
+ * stay compatible.
10
+ *
11
+ * User default: ~/.forgekit/config.json → { plan: { engine } }
12
+ * Project: <repo>/.forge/config.json → { plan: { engine, dir } }
13
+ */
14
+
15
+ import fs from 'node:fs';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ import { spawnSync } from 'node:child_process';
19
+ import {
20
+ loadProjectConfig,
21
+ loadUserConfig,
22
+ saveProjectConfig,
23
+ saveUserConfig,
24
+ } from './config.mjs';
25
+
26
+ export const PLAN_ENGINES = Object.freeze(['openspec', 'specs']);
27
+ export const DEFAULT_SPECS_DIR = 'specs';
28
+ /** Default OpenSpec engine root (symmetric with DEFAULT_SPECS_DIR). */
29
+ export const DEFAULT_OPENSPEC_DIR = 'openspec';
30
+
31
+ export const OPENSPEC_PACKAGE = '@fission-ai/openspec';
32
+ export const OPENSPEC_INSTALL_CMD = `npm install -g ${OPENSPEC_PACKAGE}`;
33
+
34
+ /**
35
+ * Relative engine-root path inside the repo (no `..`, no absolute).
36
+ * @param {string} dir
37
+ * @returns {string}
38
+ */
39
+ export function normalizePlanDir(dir) {
40
+ const cleaned = String(dir ?? '')
41
+ .trim()
42
+ .replace(/\\/g, '/')
43
+ .replace(/^\.\/+/, '')
44
+ .replace(/\/+$/, '');
45
+ if (!cleaned) {
46
+ throw new Error('plan.dir must be a non-empty relative path (e.g. specs or openspec)');
47
+ }
48
+ if (path.isAbsolute(cleaned) || cleaned.startsWith('/') || cleaned.includes('..')) {
49
+ throw new Error(
50
+ `plan.dir must be a relative path within the repo (got: ${dir}). Example: openspec`,
51
+ );
52
+ }
53
+ return cleaned;
54
+ }
55
+
56
+ /**
57
+ * @param {unknown} value
58
+ * @returns {{ engine?: string, dir?: string } | null}
59
+ */
60
+ function asPlan(value) {
61
+ if (!value || typeof value !== 'object') return null;
62
+ return /** @type {{ engine?: string, dir?: string }} */ (value);
63
+ }
64
+
65
+ /**
66
+ * @param {string} engine
67
+ */
68
+ export function assertPlanEngine(engine) {
69
+ if (!PLAN_ENGINES.includes(engine)) {
70
+ throw new Error(`Unknown plan engine: ${engine}. Known: ${PLAN_ENGINES.join(', ')}`);
71
+ }
72
+ return engine;
73
+ }
74
+
75
+ /**
76
+ * @param {string} cwd
77
+ * @returns {boolean}
78
+ */
79
+ export function hasOpenSpecConfig(cwd) {
80
+ return fs.existsSync(path.join(cwd, 'openspec', 'config.yaml'));
81
+ }
82
+
83
+ /**
84
+ * @param {string} [home]
85
+ * @returns {string | null}
86
+ */
87
+ export function loadUserPlanEngine(home = os.homedir()) {
88
+ const cfg = loadUserConfig(home);
89
+ const engine = asPlan(cfg.plan)?.engine;
90
+ return PLAN_ENGINES.includes(engine) ? engine : null;
91
+ }
92
+
93
+ /**
94
+ * Merge { plan: { engine } } into ~/.forgekit/config.json, preserving other keys.
95
+ * @param {string} engine
96
+ * @param {string} [home]
97
+ */
98
+ export function saveUserPlanEngine(engine, home = os.homedir()) {
99
+ assertPlanEngine(engine);
100
+ return saveUserConfig({ plan: { engine } }, home);
101
+ }
102
+
103
+ /**
104
+ * Merge { plan: { engine, dir? } } into <cwd>/.forge/config.json, preserving adr etc.
105
+ * @param {string} cwd
106
+ * @param {{ engine: string, dir?: string }} plan
107
+ */
108
+ export function writeProjectPlanConfig(cwd, plan) {
109
+ assertPlanEngine(plan.engine);
110
+ const current = loadProjectConfig(cwd);
111
+ const curPlan = asPlan(current.plan);
112
+ /** @type {{ engine: string, dir?: string }} */
113
+ const nextPlan = { engine: plan.engine };
114
+ if (plan.engine === 'specs') {
115
+ nextPlan.dir = normalizePlanDir(plan.dir ?? curPlan?.dir ?? DEFAULT_SPECS_DIR);
116
+ } else if (plan.dir) {
117
+ // Optional override when recording openspec (rarely needed).
118
+ nextPlan.dir = normalizePlanDir(plan.dir);
119
+ }
120
+ return saveProjectConfig(cwd, { plan: nextPlan }, { replaceKeys: ['plan'] });
121
+ }
122
+
123
+ /**
124
+ * Effective plan engine for a project.
125
+ *
126
+ * Precedence: project config → openspec/config.yaml detection → user default
127
+ * → 'openspec'.
128
+ *
129
+ * @param {string} cwd
130
+ * @param {{ home?: string, useUserDefault?: boolean }} [opts]
131
+ * @returns {{ engine: string, dir: string, source: string }}
132
+ */
133
+ export function resolveProjectPlanEngine(cwd, opts = {}) {
134
+ const project = loadProjectConfig(cwd);
135
+ const projectPlan = asPlan(project.plan);
136
+ if (projectPlan && PLAN_ENGINES.includes(projectPlan.engine)) {
137
+ const fallbackDir =
138
+ projectPlan.engine === 'openspec' ? DEFAULT_OPENSPEC_DIR : DEFAULT_SPECS_DIR;
139
+ return {
140
+ engine: /** @type {string} */ (projectPlan.engine),
141
+ dir: projectPlan.dir ?? fallbackDir,
142
+ source: 'project',
143
+ };
144
+ }
145
+ if (hasOpenSpecConfig(cwd)) {
146
+ return { engine: 'openspec', dir: DEFAULT_OPENSPEC_DIR, source: 'detected' };
147
+ }
148
+ if (opts.useUserDefault !== false) {
149
+ const userEngine = loadUserPlanEngine(opts.home);
150
+ if (userEngine) {
151
+ return {
152
+ engine: userEngine,
153
+ dir: userEngine === 'openspec' ? DEFAULT_OPENSPEC_DIR : DEFAULT_SPECS_DIR,
154
+ source: 'user',
155
+ };
156
+ }
157
+ }
158
+ return { engine: 'openspec', dir: DEFAULT_OPENSPEC_DIR, source: 'default' };
159
+ }
160
+
161
+ const SPECS_README = (dir) => `# \`${dir}/\` Forge specs (built-in planning engine)
162
+
163
+ OpenSpec-compatible change tracking without the OpenSpec CLI. Same **format**
164
+ as OpenSpec (proposal / design / tasks / delta specs / main catalog). Managed
165
+ by the Forge workflow (see the \`forge\` skill, \`phases/plan-specs.md\`).
166
+
167
+ \`\`\`
168
+ ${dir}/
169
+ specs/<capability>/spec.md # source of truth (current behavior)
170
+ changes/<change-name>/
171
+ proposal.md # Why / What Changes / Capabilities / Impact
172
+ design.md # optional — context, decisions, risks
173
+ tasks.md # ## groups with - [ ] task checkboxes
174
+ specs/<capability>/spec.md # DELTA specs (ADDED / MODIFIED / REMOVED)
175
+ changes/archive/YYYY-MM-DD-<change-name>/
176
+ \`\`\`
177
+
178
+ Conventions (kept identical to OpenSpec so migration stays trivial):
179
+
180
+ - One change per unit of substantial work; kebab-case change names.
181
+ - Delta specs live under \`changes/<name>/specs/\` — **not** a \`deltas/\` folder.
182
+ - \`tasks.md\` uses \`##\` section groups and \`- [ ]\` checkboxes; Forge counts
183
+ and reviews per group.
184
+ - On archive (\`forge change archive\`), deltas merge into \`${dir}/specs/\`, then
185
+ the change folder moves under \`changes/archive/\`.
186
+
187
+ Switching from OpenSpec without moving files: set
188
+ \`.forge/config.json\` \`{ "plan": { "engine": "specs", "dir": "openspec" } }\`
189
+ (or \`forge init --no-openspec --plan-dir openspec\`).
190
+
191
+ Migrating the other way: run \`openspec init\`, keep using the same tree if
192
+ \`dir\` already points at \`openspec/\`.
193
+ `;
194
+
195
+ /**
196
+ * Scaffold the specs engine directory structure.
197
+ * @param {string} cwd
198
+ * @param {{ dir?: string, force?: boolean }} [opts]
199
+ */
200
+ export function scaffoldSpecs(cwd, opts = {}) {
201
+ const dir = normalizePlanDir(opts.dir ?? DEFAULT_SPECS_DIR);
202
+ /** @type {{ file: string, status: string }[]} */
203
+ const files = [];
204
+
205
+ const ensureDir = (rel) => {
206
+ fs.mkdirSync(path.join(cwd, ...rel.split('/')), { recursive: true });
207
+ };
208
+ const writeFile = (rel, body) => {
209
+ const dest = path.join(cwd, ...rel.split('/'));
210
+ if (fs.existsSync(dest) && !opts.force) {
211
+ files.push({ file: rel, status: 'skipped' });
212
+ return;
213
+ }
214
+ fs.writeFileSync(dest, body, 'utf8');
215
+ files.push({ file: rel, status: 'written' });
216
+ };
217
+
218
+ ensureDir(`${dir}/changes/archive`);
219
+ ensureDir(`${dir}/specs`);
220
+ writeFile(`${dir}/README.md`, SPECS_README(dir));
221
+ writeFile(`${dir}/changes/archive/.gitkeep`, '');
222
+ writeFile(`${dir}/specs/.gitkeep`, '');
223
+
224
+ return { dir, files };
225
+ }
226
+
227
+ /**
228
+ * @returns {{ ok: boolean, version: string | null }}
229
+ */
230
+ export function checkOpenSpecCliQuick(runCommand) {
231
+ const run =
232
+ runCommand ??
233
+ ((cmd, args) => {
234
+ let result = spawnSync(cmd, args, { encoding: 'utf8', shell: false });
235
+ if (result.error && process.platform === 'win32') {
236
+ result = spawnSync([cmd, ...args].join(' '), { encoding: 'utf8', shell: true });
237
+ }
238
+ return { status: result.status, stdout: result.stdout || '' };
239
+ });
240
+ const attempt = run('openspec', ['--version']);
241
+ if (attempt.status === 0) {
242
+ return {
243
+ ok: true,
244
+ version: String(attempt.stdout || '').trim().split(/\r?\n/)[0] || 'unknown',
245
+ };
246
+ }
247
+ return { ok: false, version: null };
248
+ }
249
+
250
+ /**
251
+ * Map forgekit environment ids → OpenSpec tool ids (mostly identity).
252
+ * @type {Record<string, string>}
253
+ */
254
+ const OPENSPEC_TOOL_ID = { copilot: 'github-copilot' };
255
+
256
+ /**
257
+ * @param {string[]} agentIds forgekit environment ids
258
+ * @returns {string[]} OpenSpec tool ids
259
+ */
260
+ export function toOpenSpecToolIds(agentIds) {
261
+ return agentIds.map((id) => OPENSPEC_TOOL_ID[id] ?? id);
262
+ }
263
+
264
+ /**
265
+ * Install the OpenSpec CLI (if missing) and run `openspec init` in the project.
266
+ * Interactive: inherits stdio so `openspec init` can prompt — unless `tools`
267
+ * is given, in which case those tools are configured non-interactively
268
+ * (`openspec init --tools <ids>`), skipping OpenSpec's own tool picker.
269
+ *
270
+ * @param {string} cwd
271
+ * @param {{ runCommand?: Function, interactive?: boolean, tools?: string[] }} [opts]
272
+ * @returns {{ ok: boolean, steps: { step: string, ok: boolean, detail?: string }[] }}
273
+ */
274
+ export function setupOpenSpec(cwd, opts = {}) {
275
+ /** @type {{ step: string, ok: boolean, detail?: string }[]} */
276
+ const steps = [];
277
+
278
+ const runInherit =
279
+ opts.runCommand ??
280
+ ((cmd, args) => {
281
+ let result = spawnSync(cmd, args, { stdio: 'inherit', shell: false, cwd });
282
+ if (result.error && process.platform === 'win32') {
283
+ result = spawnSync([cmd, ...args].join(' '), {
284
+ stdio: 'inherit',
285
+ shell: true,
286
+ cwd,
287
+ });
288
+ }
289
+ return { status: result.status, error: result.error };
290
+ });
291
+
292
+ const cli = checkOpenSpecCliQuick(opts.runCommand);
293
+ if (!cli.ok) {
294
+ const install = runInherit('npm', ['install', '-g', OPENSPEC_PACKAGE]);
295
+ const ok = install.status === 0;
296
+ steps.push({
297
+ step: OPENSPEC_INSTALL_CMD,
298
+ ok,
299
+ detail: ok ? undefined : String(install.error ?? `exit ${install.status}`),
300
+ });
301
+ if (!ok) return { ok: false, steps };
302
+ } else {
303
+ steps.push({ step: `openspec CLI present (${cli.version})`, ok: true });
304
+ }
305
+
306
+ if (hasOpenSpecConfig(cwd)) {
307
+ steps.push({ step: 'openspec/config.yaml already present', ok: true });
308
+ return { ok: true, steps };
309
+ }
310
+
311
+ const tools = opts.tools?.length ? toOpenSpecToolIds(opts.tools) : null;
312
+ const initArgs = tools ? ['init', '--tools', tools.join(',')] : ['init'];
313
+ const init = runInherit('openspec', initArgs);
314
+ const initOk = init.status === 0;
315
+ steps.push({
316
+ step: `openspec ${initArgs.join(' ')}`,
317
+ ok: initOk,
318
+ detail: initOk ? undefined : String(init.error ?? `exit ${init.status}`),
319
+ });
320
+ return { ok: initOk && hasOpenSpecConfig(cwd), steps };
321
+ }