@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
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Merge OpenSpec-format delta specs into the main capability catalog.
4
+ *
5
+ * Delta: <change>/specs/<capability>/spec.md (## ADDED / MODIFIED / REMOVED)
6
+ * Main: <plan.dir>/specs/<capability>/spec.md
7
+ *
8
+ * Format matches OpenSpec (Fission-AI) — there is no `deltas/` directory name.
9
+ */
10
+
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+
14
+ /**
15
+ * @param {string} body
16
+ * @returns {{ added: string, modified: string, removed: string, preamble: string }}
17
+ */
18
+ export function parseDeltaSections(body) {
19
+ const text = String(body || '').replace(/\r\n/g, '\n');
20
+ const markers = [
21
+ { key: 'added', re: /^##\s+ADDED\s+Requirements\s*$/im },
22
+ { key: 'modified', re: /^##\s+MODIFIED\s+Requirements\s*$/im },
23
+ { key: 'removed', re: /^##\s+REMOVED\s+Requirements\s*$/im },
24
+ ];
25
+
26
+ /** @type {{ key: string, index: number, headerLen: number }[]} */
27
+ const hits = [];
28
+ for (const m of markers) {
29
+ const match = m.re.exec(text);
30
+ if (match) hits.push({ key: m.key, index: match.index, headerLen: match[0].length });
31
+ }
32
+ hits.sort((a, b) => a.index - b.index);
33
+
34
+ /** @type {{ added: string, modified: string, removed: string, preamble: string }} */
35
+ const out = { added: '', modified: '', removed: '', preamble: text };
36
+ if (hits.length === 0) return out;
37
+
38
+ out.preamble = text.slice(0, hits[0].index).trimEnd();
39
+ for (let i = 0; i < hits.length; i += 1) {
40
+ const start = hits[i].index + hits[i].headerLen;
41
+ const end = i + 1 < hits.length ? hits[i + 1].index : text.length;
42
+ out[/** @type {'added'|'modified'|'removed'} */ (hits[i].key)] = text
43
+ .slice(start, end)
44
+ .replace(/^\n+/, '')
45
+ .replace(/\n+$/, '');
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /**
51
+ * Split a requirements body into `### Requirement: …` blocks.
52
+ * @param {string} body
53
+ * @returns {{ title: string, block: string }[]}
54
+ */
55
+ export function splitRequirements(body) {
56
+ const text = String(body || '').replace(/\r\n/g, '\n').trim();
57
+ if (!text) return [];
58
+ const parts = text.split(/^###\s+Requirement:\s*/m);
59
+ /** @type {{ title: string, block: string }[]} */
60
+ const out = [];
61
+ for (const part of parts) {
62
+ const trimmed = part.trim();
63
+ if (!trimmed) continue;
64
+ const nl = trimmed.indexOf('\n');
65
+ const title = (nl === -1 ? trimmed : trimmed.slice(0, nl)).trim();
66
+ const rest = nl === -1 ? '' : trimmed.slice(nl + 1).replace(/^\n/, '');
67
+ out.push({
68
+ title,
69
+ block: `### Requirement: ${title}${rest ? `\n${rest}` : ''}`.trimEnd(),
70
+ });
71
+ }
72
+ return out;
73
+ }
74
+
75
+ /**
76
+ * @param {string} title
77
+ */
78
+ function titleKey(title) {
79
+ return title.trim().toLowerCase();
80
+ }
81
+
82
+ /**
83
+ * Ensure a main capability spec has Purpose + Requirements scaffolding.
84
+ * @param {string} capability
85
+ * @param {string} [existing]
86
+ */
87
+ export function ensureMainSpecSkeleton(capability, existing = '') {
88
+ const body = String(existing || '').replace(/\r\n/g, '\n').trim();
89
+ if (body) return body.endsWith('\n') ? body : `${body}\n`;
90
+ const label = capability
91
+ .split(/[-_/]/)
92
+ .filter(Boolean)
93
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
94
+ .join(' ');
95
+ return `# ${label} Spec\n\n## Purpose\n\nDescribe this capability.\n\n## Requirements\n`;
96
+ }
97
+
98
+ /**
99
+ * Apply one delta file onto a main capability spec body.
100
+ * @param {string} capability
101
+ * @param {string} mainBody
102
+ * @param {string} deltaBody
103
+ * @returns {string}
104
+ */
105
+ export function applyDeltaToMain(capability, mainBody, deltaBody) {
106
+ const delta = parseDeltaSections(deltaBody);
107
+ let main = ensureMainSpecSkeleton(capability, mainBody);
108
+
109
+ // Locate ## Requirements section; append after it if missing.
110
+ if (!/^##\s+Requirements\s*$/m.test(main)) {
111
+ main = `${main.trimEnd()}\n\n## Requirements\n`;
112
+ }
113
+
114
+ const reqMatch = /^##\s+Requirements\s*$/m.exec(main);
115
+ const reqHeaderEnd = reqMatch ? reqMatch.index + reqMatch[0].length : main.length;
116
+ const before = main.slice(0, reqHeaderEnd);
117
+ const afterReq = main.slice(reqHeaderEnd);
118
+ // Treat everything after ## Requirements as the requirements body until the
119
+ // next non-Requirements ## heading (OpenSpec main specs usually end there).
120
+ let reqBody = afterReq.replace(/^\n+/, '');
121
+ const trailingMatch = /\n##\s+(?!Requirements).*$/m.exec(`\n${reqBody}`);
122
+ let trailing = '';
123
+ if (trailingMatch && trailingMatch.index > 0) {
124
+ trailing = reqBody.slice(trailingMatch.index);
125
+ reqBody = reqBody.slice(0, trailingMatch.index).replace(/\n+$/, '');
126
+ }
127
+
128
+ let reqs = splitRequirements(reqBody);
129
+ const byKey = new Map(reqs.map((r) => [titleKey(r.title), r]));
130
+
131
+ for (const r of splitRequirements(delta.added)) {
132
+ if (!byKey.has(titleKey(r.title))) {
133
+ reqs.push(r);
134
+ byKey.set(titleKey(r.title), r);
135
+ } else {
136
+ // Already present — treat ADDED as upsert
137
+ const idx = reqs.findIndex((x) => titleKey(x.title) === titleKey(r.title));
138
+ reqs[idx] = r;
139
+ byKey.set(titleKey(r.title), r);
140
+ }
141
+ }
142
+
143
+ for (const r of splitRequirements(delta.modified)) {
144
+ const idx = reqs.findIndex((x) => titleKey(x.title) === titleKey(r.title));
145
+ if (idx >= 0) {
146
+ reqs[idx] = r;
147
+ byKey.set(titleKey(r.title), r);
148
+ } else {
149
+ reqs.push(r);
150
+ byKey.set(titleKey(r.title), r);
151
+ }
152
+ }
153
+
154
+ for (const r of splitRequirements(delta.removed)) {
155
+ reqs = reqs.filter((x) => titleKey(x.title) !== titleKey(r.title));
156
+ }
157
+
158
+ const mergedReqs = reqs.map((r) => r.block).join('\n\n');
159
+ const next = `${before.replace(/\n+$/, '\n')}\n${mergedReqs}${
160
+ mergedReqs ? '\n' : ''
161
+ }${trailing}`;
162
+ return next.endsWith('\n') ? next : `${next}\n`;
163
+ }
164
+
165
+ /**
166
+ * List capability ids that have a delta spec.md under changeDir/specs/.
167
+ * @param {string} changeDir
168
+ * @returns {string[]}
169
+ */
170
+ export function listDeltaCapabilities(changeDir) {
171
+ const specsRoot = path.join(changeDir, 'specs');
172
+ if (!fs.existsSync(specsRoot)) return [];
173
+ return fs
174
+ .readdirSync(specsRoot, { withFileTypes: true })
175
+ .filter((d) => d.isDirectory())
176
+ .map((d) => d.name)
177
+ .filter((name) => fs.existsSync(path.join(specsRoot, name, 'spec.md')))
178
+ .sort();
179
+ }
180
+
181
+ /**
182
+ * Merge all deltas from a change folder into `<mainSpecsDir>/<cap>/spec.md`.
183
+ * @param {string} changeDir absolute path to the change folder
184
+ * @param {string} mainSpecsDir absolute path to `<plan.dir>/specs`
185
+ * @returns {{ capability: string, status: string, file: string }[]}
186
+ */
187
+ export function mergeChangeDeltas(changeDir, mainSpecsDir) {
188
+ /** @type {{ capability: string, status: string, file: string }[]} */
189
+ const results = [];
190
+ fs.mkdirSync(mainSpecsDir, { recursive: true });
191
+
192
+ for (const capability of listDeltaCapabilities(changeDir)) {
193
+ const deltaPath = path.join(changeDir, 'specs', capability, 'spec.md');
194
+ const mainPath = path.join(mainSpecsDir, capability, 'spec.md');
195
+ const deltaBody = fs.readFileSync(deltaPath, 'utf8');
196
+ const hadMain = fs.existsSync(mainPath);
197
+ const mainBody = hadMain ? fs.readFileSync(mainPath, 'utf8') : '';
198
+ const next = applyDeltaToMain(capability, mainBody, deltaBody);
199
+ fs.mkdirSync(path.dirname(mainPath), { recursive: true });
200
+ fs.writeFileSync(mainPath, next, 'utf8');
201
+ results.push({
202
+ capability,
203
+ status: hadMain ? 'updated' : 'created',
204
+ file: mainPath,
205
+ });
206
+ }
207
+ return results;
208
+ }
209
+
210
+ /**
211
+ * OpenSpec-format delta stub for a new capability.
212
+ * @param {string} capability
213
+ */
214
+ export function deltaSpecTemplate(capability) {
215
+ const label = capability
216
+ .split(/[-_/]/)
217
+ .filter(Boolean)
218
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
219
+ .join(' ');
220
+ return `# Delta for ${label}
221
+
222
+ ## ADDED Requirements
223
+
224
+ ### Requirement: ${label} behavior
225
+ The system SHALL …
226
+
227
+ #### Scenario: Happy path
228
+ - GIVEN …
229
+ - WHEN …
230
+ - THEN …
231
+ `;
232
+ }
@@ -0,0 +1,114 @@
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
+ applyDeltaToMain,
8
+ deltaSpecTemplate,
9
+ listDeltaCapabilities,
10
+ mergeChangeDeltas,
11
+ parseDeltaSections,
12
+ splitRequirements,
13
+ } from './specs-sync.mjs';
14
+
15
+ test('parseDeltaSections extracts ADDED/MODIFIED/REMOVED', () => {
16
+ const body = `# Delta for Auth
17
+
18
+ ## ADDED Requirements
19
+
20
+ ### Requirement: Two-Factor
21
+ The system MUST require 2FA.
22
+
23
+ ## MODIFIED Requirements
24
+
25
+ ### Requirement: Session Timeout
26
+ The system SHALL expire after 30m.
27
+
28
+ ## REMOVED Requirements
29
+
30
+ ### Requirement: Remember Me
31
+ `;
32
+ const parsed = parseDeltaSections(body);
33
+ assert.match(parsed.preamble, /Delta for Auth/);
34
+ assert.match(parsed.added, /Two-Factor/);
35
+ assert.match(parsed.modified, /Session Timeout/);
36
+ assert.match(parsed.removed, /Remember Me/);
37
+ });
38
+
39
+ test('splitRequirements parses ### Requirement blocks', () => {
40
+ const blocks = splitRequirements(`### Requirement: A
41
+ Body A
42
+
43
+ ### Requirement: B
44
+ Body B
45
+ `);
46
+ assert.equal(blocks.length, 2);
47
+ assert.equal(blocks[0].title, 'A');
48
+ assert.match(blocks[1].block, /### Requirement: B/);
49
+ });
50
+
51
+ test('applyDeltaToMain merges ADDED into empty main', () => {
52
+ const delta = deltaSpecTemplate('auth');
53
+ const main = applyDeltaToMain('auth', '', delta);
54
+ assert.match(main, /# Auth Spec/);
55
+ assert.match(main, /## Requirements/);
56
+ assert.match(main, /### Requirement: Auth behavior/);
57
+ });
58
+
59
+ test('applyDeltaToMain MODIFIED replaces and REMOVED deletes', () => {
60
+ const existing = `# Auth Spec
61
+
62
+ ## Purpose
63
+
64
+ Auth.
65
+
66
+ ## Requirements
67
+
68
+ ### Requirement: Login
69
+ Old login.
70
+
71
+ ### Requirement: Logout
72
+ Keep me.
73
+ `;
74
+ const delta = `# Delta
75
+
76
+ ## MODIFIED Requirements
77
+
78
+ ### Requirement: Login
79
+ New login.
80
+
81
+ ## REMOVED Requirements
82
+
83
+ ### Requirement: Logout
84
+ `;
85
+ const main = applyDeltaToMain('auth', existing, delta);
86
+ assert.match(main, /New login/);
87
+ assert.doesNotMatch(main, /Old login/);
88
+ assert.doesNotMatch(main, /### Requirement: Logout/);
89
+ });
90
+
91
+ test('mergeChangeDeltas writes main catalog from change deltas', () => {
92
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'forgekit-sync-'));
93
+ try {
94
+ const changeDir = path.join(root, 'changes', 'add-auth');
95
+ const mainSpecs = path.join(root, 'specs');
96
+ fs.mkdirSync(path.join(changeDir, 'specs', 'auth'), { recursive: true });
97
+ fs.writeFileSync(
98
+ path.join(changeDir, 'specs', 'auth', 'spec.md'),
99
+ deltaSpecTemplate('auth'),
100
+ 'utf8',
101
+ );
102
+ assert.deepEqual(listDeltaCapabilities(changeDir), ['auth']);
103
+ const results = mergeChangeDeltas(changeDir, mainSpecs);
104
+ assert.equal(results.length, 1);
105
+ assert.equal(results[0].status, 'created');
106
+ assert.ok(fs.existsSync(path.join(mainSpecs, 'auth', 'spec.md')));
107
+ assert.match(
108
+ fs.readFileSync(path.join(mainSpecs, 'auth', 'spec.md'), 'utf8'),
109
+ /Auth behavior/,
110
+ );
111
+ } finally {
112
+ fs.rmSync(root, { recursive: true, force: true });
113
+ }
114
+ });
package/src/spine.mjs CHANGED
@@ -1,93 +1,93 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Forge spine matrix — scaffold + validate the capability→runtime wiring map.
4
- *
5
- * Usage:
6
- * forge spine # show status (path, validity)
7
- * forge spine init [--force] # scaffold spine.json for the active change
8
- * forge spine check # validate; exit 1 with problems
9
- * [--session <id>]
10
- *
11
- * spine.json lives in the change dir (openspec/changes/<name>/ or
12
- * <specsDir>/changes/<name>/), falling back to the session dir when the
13
- * session has no tracked change.
14
- */
15
-
16
- import fs from 'node:fs';
17
- import { loadSession, readActive, readJson } from './lib.mjs';
18
- import { initSpine, spinePath, validateSpine } from './integrity.mjs';
19
-
20
- const args = process.argv.slice(2);
21
- const sub = args[0] && !args[0].startsWith('--') ? args[0] : 'status';
22
-
23
- if (args[0] === '--help' || sub === 'help') {
24
- process.stdout.write(
25
- 'Usage: forge spine [init [--force] | check | status] [--session <id>]\n',
26
- );
27
- process.exit(0);
28
- }
29
-
30
- let sessionId = null;
31
- let force = false;
32
- for (let i = 0; i < args.length; i += 1) {
33
- if (args[i] === '--session' && args[i + 1]) {
34
- sessionId = args[i + 1];
35
- i += 1;
36
- } else if (args[i] === '--force') {
37
- force = true;
38
- }
39
- }
40
-
41
- if (!sessionId) {
42
- const active = readActive();
43
- sessionId = active?.sessionId;
44
- }
45
- if (!sessionId) {
46
- process.stderr.write('No active session. Run forge new first.\n');
47
- process.exit(1);
48
- }
49
-
50
- const { dir, session } = loadSession(sessionId);
51
- // init writes: target the live change dir only (never fall back into the
52
- // archive). check/status read: allow the archive fallback.
53
- const file = spinePath({ session, sessionDir: dir, forWrite: sub === 'init' });
54
-
55
- if (sub === 'init') {
56
- try {
57
- initSpine({ file, change: session.openspecChange ?? null, force });
58
- } catch (err) {
59
- process.stderr.write(`${err instanceof Error ? err.message : err}\n`);
60
- process.exit(1);
61
- }
62
- process.stdout.write(
63
- `Scaffolded ${file}\nFill every row (one per capability/REQ cluster); reads/uiConsumer may be "N/A".\nValidate with: forge spine check\n`,
64
- );
65
- process.exit(0);
66
- }
67
-
68
- if (sub === 'check' || sub === 'status') {
69
- if (!fs.existsSync(file)) {
70
- const msg = `spine.json not found at ${file} — run forge spine init\n`;
71
- if (sub === 'check') {
72
- process.stderr.write(msg);
73
- process.exit(1);
74
- }
75
- process.stdout.write(JSON.stringify({ file, exists: false }, null, 2));
76
- process.stdout.write('\n');
77
- process.exit(0);
78
- }
79
- let result;
80
- try {
81
- result = validateSpine(readJson(file));
82
- } catch (err) {
83
- result = { ok: false, problems: [`unreadable: ${err instanceof Error ? err.message : err}`] };
84
- }
85
- process.stdout.write(
86
- JSON.stringify({ file, exists: true, ok: result.ok, problems: result.problems }, null, 2),
87
- );
88
- process.stdout.write('\n');
89
- process.exit(sub === 'check' && !result.ok ? 1 : 0);
90
- }
91
-
92
- process.stderr.write(`Unknown subcommand: ${sub}\n`);
93
- process.exit(1);
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Forge spine matrix — scaffold + validate the capability→runtime wiring map.
4
+ *
5
+ * Usage:
6
+ * forge spine # show status (path, validity)
7
+ * forge spine init [--force] # scaffold spine.json for the active change
8
+ * forge spine check # validate; exit 1 with problems
9
+ * [--session <id>]
10
+ *
11
+ * spine.json lives in the change dir (openspec/changes/<name>/ or
12
+ * <specsDir>/changes/<name>/), falling back to the session dir when the
13
+ * session has no tracked change.
14
+ */
15
+
16
+ import fs from 'node:fs';
17
+ import { loadSession, readActive, readJson } from './lib.mjs';
18
+ import { initSpine, spinePath, validateSpine } from './integrity.mjs';
19
+
20
+ const args = process.argv.slice(2);
21
+ const sub = args[0] && !args[0].startsWith('--') ? args[0] : 'status';
22
+
23
+ if (args[0] === '--help' || sub === 'help') {
24
+ process.stdout.write(
25
+ 'Usage: forge spine [init [--force] | check | status] [--session <id>]\n',
26
+ );
27
+ process.exit(0);
28
+ }
29
+
30
+ let sessionId = null;
31
+ let force = false;
32
+ for (let i = 0; i < args.length; i += 1) {
33
+ if (args[i] === '--session' && args[i + 1]) {
34
+ sessionId = args[i + 1];
35
+ i += 1;
36
+ } else if (args[i] === '--force') {
37
+ force = true;
38
+ }
39
+ }
40
+
41
+ if (!sessionId) {
42
+ const active = readActive();
43
+ sessionId = active?.sessionId;
44
+ }
45
+ if (!sessionId) {
46
+ process.stderr.write('No active session. Run forge new first.\n');
47
+ process.exit(1);
48
+ }
49
+
50
+ const { dir, session } = loadSession(sessionId);
51
+ // init writes: target the live change dir only (never fall back into the
52
+ // archive). check/status read: allow the archive fallback.
53
+ const file = spinePath({ session, sessionDir: dir, forWrite: sub === 'init' });
54
+
55
+ if (sub === 'init') {
56
+ try {
57
+ initSpine({ file, change: session.openspecChange ?? null, force });
58
+ } catch (err) {
59
+ process.stderr.write(`${err instanceof Error ? err.message : err}\n`);
60
+ process.exit(1);
61
+ }
62
+ process.stdout.write(
63
+ `Scaffolded ${file}\nFill every row (one per capability/REQ cluster); reads/uiConsumer may be "N/A".\nValidate with: forge spine check\n`,
64
+ );
65
+ process.exit(0);
66
+ }
67
+
68
+ if (sub === 'check' || sub === 'status') {
69
+ if (!fs.existsSync(file)) {
70
+ const msg = `spine.json not found at ${file} — run forge spine init\n`;
71
+ if (sub === 'check') {
72
+ process.stderr.write(msg);
73
+ process.exit(1);
74
+ }
75
+ process.stdout.write(JSON.stringify({ file, exists: false }, null, 2));
76
+ process.stdout.write('\n');
77
+ process.exit(0);
78
+ }
79
+ let result;
80
+ try {
81
+ result = validateSpine(readJson(file));
82
+ } catch (err) {
83
+ result = { ok: false, problems: [`unreadable: ${err instanceof Error ? err.message : err}`] };
84
+ }
85
+ process.stdout.write(
86
+ JSON.stringify({ file, exists: true, ok: result.ok, problems: result.problems }, null, 2),
87
+ );
88
+ process.stdout.write('\n');
89
+ process.exit(sub === 'check' && !result.ok ? 1 : 0);
90
+ }
91
+
92
+ process.stderr.write(`Unknown subcommand: ${sub}\n`);
93
+ process.exit(1);