@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
package/src/paths.mjs ADDED
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Resolve forgekit asset roots (skills, templates).
4
+ *
5
+ * Search order for assets:
6
+ * 1. FORGEKIT_ROOT env (dev / linked monorepo)
7
+ * 2. Monorepo root (packages/cli/../../..)
8
+ * 3. Vendored copy inside the published package (packages/cli/vendor)
9
+ */
10
+
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { createHash } from 'node:crypto';
15
+
16
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+
18
+ /** @returns {string} Absolute path to packages/cli */
19
+ export function cliPackageRoot() {
20
+ return path.resolve(__dirname, '..');
21
+ }
22
+
23
+ /**
24
+ * Candidate forgekit roots that may contain skills/ and templates/.
25
+ * @returns {string[]}
26
+ */
27
+ export function forgekitRootCandidates() {
28
+ const roots = [];
29
+ if (process.env.FORGEKIT_ROOT) roots.push(process.env.FORGEKIT_ROOT);
30
+ // Monorepo: packages/cli/src → ../../../
31
+ roots.push(path.resolve(__dirname, '..', '..', '..'));
32
+ // Published package: vendor/ next to src/
33
+ roots.push(path.join(cliPackageRoot(), 'vendor'));
34
+ return [...new Set(roots.map((r) => path.resolve(r)))];
35
+ }
36
+
37
+ /**
38
+ * @param {string} relativePath e.g. 'skills/forge' or 'templates/project'
39
+ * @param {{ requireFile?: string }} [opts] file that must exist under the candidate
40
+ * @returns {string}
41
+ */
42
+ export function resolveAsset(relativePath, opts = {}) {
43
+ const requireFile = opts.requireFile;
44
+ const tried = [];
45
+ for (const root of forgekitRootCandidates()) {
46
+ const candidate = path.join(root, relativePath);
47
+ tried.push(candidate);
48
+ const check = requireFile ? path.join(candidate, requireFile) : candidate;
49
+ if (fs.existsSync(check)) return candidate;
50
+ }
51
+ throw new Error(
52
+ `Asset not found: ${relativePath}${requireFile ? ` (${requireFile})` : ''}.\nTried:\n ${tried.join('\n ')}`,
53
+ );
54
+ }
55
+
56
+ /**
57
+ * @returns {string} package version from packages/cli/package.json
58
+ */
59
+ export function packageVersion() {
60
+ const pkgPath = path.join(cliPackageRoot(), 'package.json');
61
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
62
+ return pkg.version ?? '0.0.0';
63
+ }
64
+
65
+ /**
66
+ * Stable content hash of a directory (sorted relative paths + file bytes).
67
+ * @param {string} dir
68
+ * @returns {string} sha256 hex
69
+ */
70
+ export function hashDirectory(dir) {
71
+ const hash = createHash('sha256');
72
+ /** @type {string[]} */
73
+ const files = [];
74
+ const walk = (d, prefix = '') => {
75
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
76
+ if (entry.name === '.forgekit.json') continue;
77
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
78
+ const full = path.join(d, entry.name);
79
+ if (entry.isDirectory()) walk(full, rel);
80
+ else files.push(rel);
81
+ }
82
+ };
83
+ walk(dir);
84
+ files.sort();
85
+ for (const rel of files) {
86
+ hash.update(rel);
87
+ hash.update('\0');
88
+ hash.update(fs.readFileSync(path.join(dir, ...rel.split('/'))));
89
+ hash.update('\0');
90
+ }
91
+ return hash.digest('hex');
92
+ }
@@ -0,0 +1,260 @@
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
+ * Install the OpenSpec CLI (if missing) and run `openspec init` in the project.
209
+ * Interactive: inherits stdio so `openspec init` can prompt.
210
+ *
211
+ * @param {string} cwd
212
+ * @param {{ runCommand?: Function, interactive?: boolean }} [opts]
213
+ * @returns {{ ok: boolean, steps: { step: string, ok: boolean, detail?: string }[] }}
214
+ */
215
+ export function setupOpenSpec(cwd, opts = {}) {
216
+ /** @type {{ step: string, ok: boolean, detail?: string }[]} */
217
+ const steps = [];
218
+
219
+ const runInherit =
220
+ opts.runCommand ??
221
+ ((cmd, args) => {
222
+ let result = spawnSync(cmd, args, { stdio: 'inherit', shell: false, cwd });
223
+ if (result.error && process.platform === 'win32') {
224
+ result = spawnSync([cmd, ...args].join(' '), {
225
+ stdio: 'inherit',
226
+ shell: true,
227
+ cwd,
228
+ });
229
+ }
230
+ return { status: result.status, error: result.error };
231
+ });
232
+
233
+ const cli = checkOpenSpecCliQuick(opts.runCommand);
234
+ if (!cli.ok) {
235
+ const install = runInherit('npm', ['install', '-g', OPENSPEC_PACKAGE]);
236
+ const ok = install.status === 0;
237
+ steps.push({
238
+ step: OPENSPEC_INSTALL_CMD,
239
+ ok,
240
+ detail: ok ? undefined : String(install.error ?? `exit ${install.status}`),
241
+ });
242
+ if (!ok) return { ok: false, steps };
243
+ } else {
244
+ steps.push({ step: `openspec CLI present (${cli.version})`, ok: true });
245
+ }
246
+
247
+ if (hasOpenSpecConfig(cwd)) {
248
+ steps.push({ step: 'openspec/config.yaml already present', ok: true });
249
+ return { ok: true, steps };
250
+ }
251
+
252
+ const init = runInherit('openspec', ['init']);
253
+ const initOk = init.status === 0;
254
+ steps.push({
255
+ step: 'openspec init',
256
+ ok: initOk,
257
+ detail: initOk ? undefined : String(init.error ?? `exit ${init.status}`),
258
+ });
259
+ return { ok: initOk && hasOpenSpecConfig(cwd), steps };
260
+ }
@@ -0,0 +1,245 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import {
7
+ DEFAULT_SPECS_DIR,
8
+ PLAN_ENGINES,
9
+ hasOpenSpecConfig,
10
+ loadUserPlanEngine,
11
+ resolveProjectPlanEngine,
12
+ saveUserPlanEngine,
13
+ scaffoldSpecs,
14
+ setupOpenSpec,
15
+ writeProjectPlanConfig,
16
+ } from './plan-engine.mjs';
17
+ import { loadProjectConfig, loadUserConfig, saveUserConfig } from './adr.mjs';
18
+ import { parseArgs as parseInstallArgs } from './install.mjs';
19
+ import { parseArgs as parseInitArgs, initProject } from './init.mjs';
20
+ import { runDoctorChecks } from './doctor.mjs';
21
+
22
+ function tmpdir(prefix) {
23
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
24
+ }
25
+
26
+ test('user plan engine round-trip preserves adr config', () => {
27
+ const home = tmpdir('forgekit-plan-user-');
28
+ try {
29
+ saveUserConfig({ adr: { enabled: true, dir: 'docs/adr' } }, home);
30
+ saveUserPlanEngine('specs', home);
31
+ assert.equal(loadUserPlanEngine(home), 'specs');
32
+ assert.equal(loadUserConfig(home).adr.enabled, true);
33
+ saveUserPlanEngine('openspec', home);
34
+ assert.equal(loadUserPlanEngine(home), 'openspec');
35
+ assert.throws(() => saveUserPlanEngine('bogus', home), /Unknown plan engine/);
36
+ } finally {
37
+ fs.rmSync(home, { recursive: true, force: true });
38
+ }
39
+ });
40
+
41
+ test('writeProjectPlanConfig preserves adr block and sets dir for specs', () => {
42
+ const cwd = tmpdir('forgekit-plan-proj-');
43
+ try {
44
+ fs.mkdirSync(path.join(cwd, '.forge'), { recursive: true });
45
+ fs.writeFileSync(
46
+ path.join(cwd, '.forge', 'config.json'),
47
+ JSON.stringify({ adr: { enabled: true, dir: 'docs/adr' } }),
48
+ 'utf8',
49
+ );
50
+ writeProjectPlanConfig(cwd, { engine: 'specs' });
51
+ const cfg = loadProjectConfig(cwd);
52
+ assert.equal(cfg.plan.engine, 'specs');
53
+ assert.equal(cfg.plan.dir, DEFAULT_SPECS_DIR);
54
+ assert.equal(cfg.adr.enabled, true);
55
+
56
+ writeProjectPlanConfig(cwd, { engine: 'openspec' });
57
+ assert.equal(loadProjectConfig(cwd).plan.engine, 'openspec');
58
+ assert.equal(loadProjectConfig(cwd).plan.dir, undefined);
59
+ } finally {
60
+ fs.rmSync(cwd, { recursive: true, force: true });
61
+ }
62
+ });
63
+
64
+ test('resolveProjectPlanEngine precedence: project > detection > user > default', () => {
65
+ const cwd = tmpdir('forgekit-plan-res-');
66
+ const home = tmpdir('forgekit-plan-res-home-');
67
+ try {
68
+ // default
69
+ assert.equal(
70
+ resolveProjectPlanEngine(cwd, { home }).engine,
71
+ 'openspec',
72
+ );
73
+ assert.equal(resolveProjectPlanEngine(cwd, { home }).source, 'default');
74
+
75
+ // user default
76
+ saveUserPlanEngine('specs', home);
77
+ const viaUser = resolveProjectPlanEngine(cwd, { home });
78
+ assert.equal(viaUser.engine, 'specs');
79
+ assert.equal(viaUser.source, 'user');
80
+
81
+ // useUserDefault=false ignores user config
82
+ assert.equal(
83
+ resolveProjectPlanEngine(cwd, { home, useUserDefault: false }).source,
84
+ 'default',
85
+ );
86
+
87
+ // openspec detection beats user default
88
+ fs.mkdirSync(path.join(cwd, 'openspec'), { recursive: true });
89
+ fs.writeFileSync(path.join(cwd, 'openspec', 'config.yaml'), 'name: x\n', 'utf8');
90
+ assert.equal(hasOpenSpecConfig(cwd), true);
91
+ const detected = resolveProjectPlanEngine(cwd, { home });
92
+ assert.equal(detected.engine, 'openspec');
93
+ assert.equal(detected.source, 'detected');
94
+
95
+ // project config beats detection
96
+ writeProjectPlanConfig(cwd, { engine: 'specs', dir: 'specs' });
97
+ const viaProject = resolveProjectPlanEngine(cwd, { home });
98
+ assert.equal(viaProject.engine, 'specs');
99
+ assert.equal(viaProject.source, 'project');
100
+ } finally {
101
+ fs.rmSync(cwd, { recursive: true, force: true });
102
+ fs.rmSync(home, { recursive: true, force: true });
103
+ }
104
+ });
105
+
106
+ test('scaffoldSpecs writes README + changes/archive', () => {
107
+ const cwd = tmpdir('forgekit-specs-scaffold-');
108
+ try {
109
+ const result = scaffoldSpecs(cwd, { force: true });
110
+ assert.equal(result.dir, DEFAULT_SPECS_DIR);
111
+ assert.ok(fs.existsSync(path.join(cwd, 'specs', 'README.md')));
112
+ assert.ok(fs.existsSync(path.join(cwd, 'specs', 'changes', 'archive')));
113
+ const readme = fs.readFileSync(path.join(cwd, 'specs', 'README.md'), 'utf8');
114
+ assert.match(readme, /changes\/<change-name>/);
115
+ assert.match(readme, /tasks\.md/);
116
+
117
+ // second run without force skips
118
+ const again = scaffoldSpecs(cwd);
119
+ assert.ok(again.files.some((f) => f.status === 'skipped'));
120
+ } finally {
121
+ fs.rmSync(cwd, { recursive: true, force: true });
122
+ }
123
+ });
124
+
125
+ test('setupOpenSpec runs install + init via injected runner', () => {
126
+ const cwd = tmpdir('forgekit-openspec-setup-');
127
+ try {
128
+ /** @type {string[]} */
129
+ const calls = [];
130
+ const runCommand = (cmd, args) => {
131
+ const line = [cmd, ...args].join(' ');
132
+ calls.push(line);
133
+ if (line === 'openspec --version') return { status: 1, stdout: '' };
134
+ if (line.startsWith('npm install -g')) return { status: 0, stdout: '' };
135
+ if (line === 'openspec init') {
136
+ fs.mkdirSync(path.join(cwd, 'openspec'), { recursive: true });
137
+ fs.writeFileSync(path.join(cwd, 'openspec', 'config.yaml'), 'x: 1\n', 'utf8');
138
+ return { status: 0, stdout: '' };
139
+ }
140
+ return { status: 1, stdout: '' };
141
+ };
142
+ const result = setupOpenSpec(cwd, { runCommand });
143
+ assert.equal(result.ok, true);
144
+ assert.ok(calls.some((c) => c.startsWith('npm install -g')));
145
+ assert.ok(calls.includes('openspec init'));
146
+ } finally {
147
+ fs.rmSync(cwd, { recursive: true, force: true });
148
+ }
149
+ });
150
+
151
+ test('setupOpenSpec reports failure when install fails', () => {
152
+ const cwd = tmpdir('forgekit-openspec-fail-');
153
+ try {
154
+ const runCommand = () => ({ status: 1, stdout: '' });
155
+ const result = setupOpenSpec(cwd, { runCommand });
156
+ assert.equal(result.ok, false);
157
+ assert.ok(result.steps.some((s) => !s.ok));
158
+ } finally {
159
+ fs.rmSync(cwd, { recursive: true, force: true });
160
+ }
161
+ });
162
+
163
+ test('install parseArgs --openspec / --no-openspec', () => {
164
+ assert.equal(parseInstallArgs(['--openspec']).openspec, true);
165
+ assert.equal(parseInstallArgs(['--no-openspec']).openspec, false);
166
+ assert.equal(parseInstallArgs([]).openspec, null);
167
+ });
168
+
169
+ test('init parseArgs --openspec / --no-openspec', () => {
170
+ assert.equal(parseInitArgs(['--openspec']).openspec, true);
171
+ assert.equal(parseInitArgs(['--no-openspec']).openspec, false);
172
+ assert.equal(parseInitArgs([]).openspec, null);
173
+ });
174
+
175
+ test('initProject planEngine=specs scaffolds and writes config', () => {
176
+ const cwd = tmpdir('forgekit-init-specs-');
177
+ try {
178
+ const report = initProject(['codex'], {
179
+ cwd,
180
+ force: true,
181
+ planEngine: 'specs',
182
+ });
183
+ assert.equal(report.plan.engine, 'specs');
184
+ assert.ok(fs.existsSync(path.join(cwd, 'specs', 'README.md')));
185
+ assert.equal(loadProjectConfig(cwd).plan.engine, 'specs');
186
+ } finally {
187
+ fs.rmSync(cwd, { recursive: true, force: true });
188
+ }
189
+ });
190
+
191
+ test('initProject planEngine=openspec records engine without scaffolding specs', () => {
192
+ const cwd = tmpdir('forgekit-init-os-');
193
+ try {
194
+ const report = initProject(['codex'], {
195
+ cwd,
196
+ force: true,
197
+ planEngine: 'openspec',
198
+ });
199
+ assert.equal(report.plan.engine, 'openspec');
200
+ assert.equal(report.plan.configured, false);
201
+ assert.ok(!fs.existsSync(path.join(cwd, 'specs')));
202
+ assert.equal(loadProjectConfig(cwd).plan.engine, 'openspec');
203
+ } finally {
204
+ fs.rmSync(cwd, { recursive: true, force: true });
205
+ }
206
+ });
207
+
208
+ test('doctor: specs engine checks specs layout instead of OpenSpec CLI', () => {
209
+ const cwd = tmpdir('forgekit-doctor-specs-');
210
+ try {
211
+ writeProjectPlanConfig(cwd, { engine: 'specs' });
212
+
213
+ const missing = runDoctorChecks({ cwd });
214
+ assert.equal(missing.engine, 'specs');
215
+ assert.equal(missing.ok, false);
216
+ assert.match(missing.checks.project.message, /specs\/changes/);
217
+ assert.equal(missing.checks.cli.ok, true);
218
+ assert.equal(missing.checks.cli.skipped, true);
219
+
220
+ scaffoldSpecs(cwd);
221
+ const present = runDoctorChecks({ cwd });
222
+ assert.equal(present.ok, true);
223
+ } finally {
224
+ fs.rmSync(cwd, { recursive: true, force: true });
225
+ }
226
+ });
227
+
228
+ test('doctor: openspec engine unchanged for openspec projects', () => {
229
+ const cwd = tmpdir('forgekit-doctor-os-');
230
+ try {
231
+ const report = runDoctorChecks({
232
+ cwd,
233
+ existsSync: () => true,
234
+ runCommand: () => ({ status: 0, stdout: '1.0.0', stderr: '' }),
235
+ });
236
+ assert.equal(report.engine, 'openspec');
237
+ assert.equal(report.ok, true);
238
+ } finally {
239
+ fs.rmSync(cwd, { recursive: true, force: true });
240
+ }
241
+ });
242
+
243
+ test('PLAN_ENGINES is the closed engine set', () => {
244
+ assert.deepEqual([...PLAN_ENGINES], ['openspec', 'specs']);
245
+ });
@@ -0,0 +1,78 @@
1
+ {
2
+ "pace": "auto",
3
+ "integrity": {
4
+ "forbidStubs": true,
5
+ "specsBeatNarrowTasks": true,
6
+ "requireE2E": "when-jobs-or-workers"
7
+ },
8
+ "presets": {
9
+ "thorough": {
10
+ "review": {
11
+ "perTask": "always",
12
+ "final": "always",
13
+ "depth": "full",
14
+ "maxRounds": 3
15
+ },
16
+ "verify": {
17
+ "tier3": "full-workspace"
18
+ },
19
+ "models": {
20
+ "bias": "default"
21
+ },
22
+ "brainstorm": {
23
+ "depth": "full"
24
+ }
25
+ },
26
+ "standard": {
27
+ "review": {
28
+ "perTask": "per-group",
29
+ "final": "always",
30
+ "depth": "full",
31
+ "maxRounds": 2
32
+ },
33
+ "verify": {
34
+ "tier3": "full-workspace"
35
+ },
36
+ "models": {
37
+ "bias": "default"
38
+ },
39
+ "brainstorm": {
40
+ "depth": "full"
41
+ }
42
+ },
43
+ "brisk": {
44
+ "review": {
45
+ "perTask": "high-risk-only",
46
+ "final": "high-risk-only",
47
+ "depth": "spec-only",
48
+ "maxRounds": 1
49
+ },
50
+ "verify": {
51
+ "tier3": "affected-only"
52
+ },
53
+ "models": {
54
+ "bias": "prefer-fast"
55
+ },
56
+ "brainstorm": {
57
+ "depth": "short"
58
+ }
59
+ },
60
+ "lite": {
61
+ "review": {
62
+ "perTask": "never",
63
+ "final": "never",
64
+ "depth": "spec-only",
65
+ "maxRounds": 0
66
+ },
67
+ "verify": {
68
+ "tier3": "audit-tier2-only"
69
+ },
70
+ "models": {
71
+ "bias": "prefer-fast"
72
+ },
73
+ "brainstorm": {
74
+ "depth": "minimal"
75
+ }
76
+ }
77
+ }
78
+ }