@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
@@ -0,0 +1,234 @@
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
+ // Stop at next ## section if any
119
+ const nextSection = /^##\s+/m.exec(afterReq.replace(/^\n/, ''));
120
+ // Simpler: treat everything after ## Requirements as the requirements body
121
+ // until EOF (OpenSpec main specs usually end there).
122
+ let reqBody = afterReq.replace(/^\n+/, '');
123
+ const trailingMatch = /\n##\s+(?!Requirements).*$/m.exec(`\n${reqBody}`);
124
+ let trailing = '';
125
+ if (trailingMatch && trailingMatch.index > 0) {
126
+ trailing = reqBody.slice(trailingMatch.index);
127
+ reqBody = reqBody.slice(0, trailingMatch.index).replace(/\n+$/, '');
128
+ }
129
+
130
+ let reqs = splitRequirements(reqBody);
131
+ const byKey = new Map(reqs.map((r) => [titleKey(r.title), r]));
132
+
133
+ for (const r of splitRequirements(delta.added)) {
134
+ if (!byKey.has(titleKey(r.title))) {
135
+ reqs.push(r);
136
+ byKey.set(titleKey(r.title), r);
137
+ } else {
138
+ // Already present — treat ADDED as upsert
139
+ const idx = reqs.findIndex((x) => titleKey(x.title) === titleKey(r.title));
140
+ reqs[idx] = r;
141
+ byKey.set(titleKey(r.title), r);
142
+ }
143
+ }
144
+
145
+ for (const r of splitRequirements(delta.modified)) {
146
+ const idx = reqs.findIndex((x) => titleKey(x.title) === titleKey(r.title));
147
+ if (idx >= 0) {
148
+ reqs[idx] = r;
149
+ byKey.set(titleKey(r.title), r);
150
+ } else {
151
+ reqs.push(r);
152
+ byKey.set(titleKey(r.title), r);
153
+ }
154
+ }
155
+
156
+ for (const r of splitRequirements(delta.removed)) {
157
+ reqs = reqs.filter((x) => titleKey(x.title) !== titleKey(r.title));
158
+ }
159
+
160
+ const mergedReqs = reqs.map((r) => r.block).join('\n\n');
161
+ const next = `${before.replace(/\n+$/, '\n')}\n${mergedReqs}${
162
+ mergedReqs ? '\n' : ''
163
+ }${trailing}`;
164
+ return next.endsWith('\n') ? next : `${next}\n`;
165
+ }
166
+
167
+ /**
168
+ * List capability ids that have a delta spec.md under changeDir/specs/.
169
+ * @param {string} changeDir
170
+ * @returns {string[]}
171
+ */
172
+ export function listDeltaCapabilities(changeDir) {
173
+ const specsRoot = path.join(changeDir, 'specs');
174
+ if (!fs.existsSync(specsRoot)) return [];
175
+ return fs
176
+ .readdirSync(specsRoot, { withFileTypes: true })
177
+ .filter((d) => d.isDirectory())
178
+ .map((d) => d.name)
179
+ .filter((name) => fs.existsSync(path.join(specsRoot, name, 'spec.md')))
180
+ .sort();
181
+ }
182
+
183
+ /**
184
+ * Merge all deltas from a change folder into `<mainSpecsDir>/<cap>/spec.md`.
185
+ * @param {string} changeDir absolute path to the change folder
186
+ * @param {string} mainSpecsDir absolute path to `<plan.dir>/specs`
187
+ * @returns {{ capability: string, status: string, file: string }[]}
188
+ */
189
+ export function mergeChangeDeltas(changeDir, mainSpecsDir) {
190
+ /** @type {{ capability: string, status: string, file: string }[]} */
191
+ const results = [];
192
+ fs.mkdirSync(mainSpecsDir, { recursive: true });
193
+
194
+ for (const capability of listDeltaCapabilities(changeDir)) {
195
+ const deltaPath = path.join(changeDir, 'specs', capability, 'spec.md');
196
+ const mainPath = path.join(mainSpecsDir, capability, 'spec.md');
197
+ const deltaBody = fs.readFileSync(deltaPath, 'utf8');
198
+ const hadMain = fs.existsSync(mainPath);
199
+ const mainBody = hadMain ? fs.readFileSync(mainPath, 'utf8') : '';
200
+ const next = applyDeltaToMain(capability, mainBody, deltaBody);
201
+ fs.mkdirSync(path.dirname(mainPath), { recursive: true });
202
+ fs.writeFileSync(mainPath, next, 'utf8');
203
+ results.push({
204
+ capability,
205
+ status: hadMain ? 'updated' : 'created',
206
+ file: mainPath,
207
+ });
208
+ }
209
+ return results;
210
+ }
211
+
212
+ /**
213
+ * OpenSpec-format delta stub for a new capability.
214
+ * @param {string} capability
215
+ */
216
+ export function deltaSpecTemplate(capability) {
217
+ const label = capability
218
+ .split(/[-_/]/)
219
+ .filter(Boolean)
220
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
221
+ .join(' ');
222
+ return `# Delta for ${label}
223
+
224
+ ## ADDED Requirements
225
+
226
+ ### Requirement: ${label} behavior
227
+ The system SHALL …
228
+
229
+ #### Scenario: Happy path
230
+ - GIVEN …
231
+ - WHEN …
232
+ - THEN …
233
+ `;
234
+ }
@@ -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);