@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.
- package/bin/forge.mjs +119 -107
- package/bin/forgekit.mjs +83 -83
- package/bin/review.mjs +81 -81
- package/package.json +2 -1
- package/scripts/prepack.mjs +78 -78
- package/scripts/run-tests.mjs +50 -50
- package/src/adr.mjs +236 -236
- package/src/adr.test.mjs +170 -170
- package/src/change.mjs +327 -234
- package/src/change.test.mjs +145 -83
- package/src/cleanup-sessions.mjs +84 -84
- package/src/config.mjs +103 -103
- package/src/defer.mjs +75 -75
- package/src/doctor.mjs +350 -341
- package/src/doctor.test.mjs +114 -114
- package/src/fleet.test.mjs +50 -0
- package/src/init.mjs +680 -621
- package/src/install.mjs +815 -815
- package/src/install.test.mjs +180 -180
- package/src/integrity-check.mjs +60 -60
- package/src/integrity.mjs +688 -682
- package/src/integrity.test.mjs +612 -566
- package/src/lib/fleet.mjs +61 -4
- package/src/lib.mjs +160 -143
- package/src/lib.test.mjs +128 -0
- package/src/models.defaults.json +41 -41
- package/src/new-session.mjs +99 -99
- package/src/openspec-overlays/README.md +19 -19
- package/src/openspec-overlays/openspec-apply-change-footer.md +14 -14
- package/src/openspec-overlays/opsx-apply-completion-step.md +1 -1
- package/src/openspec-overlays/opsx-apply-implement-step.md +11 -11
- package/src/paths.mjs +92 -92
- package/src/plan-engine.mjs +321 -278
- package/src/plan-engine.test.mjs +447 -283
- package/src/preferences.defaults.json +78 -78
- package/src/preferences.mjs +438 -438
- package/src/preferences.test.mjs +174 -174
- package/src/record-evidence.mjs +204 -204
- package/src/repo-root.mjs +33 -0
- package/src/resolve-model.mjs +312 -312
- package/src/resolve-model.test.mjs +194 -194
- package/src/review/cli.test.mjs +117 -117
- package/src/review/export.mjs +172 -172
- package/src/review/export.test.mjs +197 -197
- package/src/review/fixtures/valid-review.json +42 -42
- package/src/review/lib.mjs +894 -894
- package/src/review/lib.test.mjs +266 -266
- package/src/review/schema.json +196 -196
- package/src/review/signals.test.mjs +62 -62
- package/src/score-cli.mjs +68 -68
- package/src/score.mjs +568 -568
- package/src/score.test.mjs +366 -366
- package/src/session-reminder.mjs +207 -207
- package/src/session-status.mjs +70 -70
- package/src/set-models.mjs +186 -186
- package/src/set-phase.mjs +205 -205
- package/src/set-prefs.mjs +294 -294
- package/src/specs-sync.mjs +232 -0
- package/src/specs-sync.test.mjs +114 -0
- package/src/spine.mjs +93 -93
- package/src/triage-prompt.mjs +175 -175
- package/src/triage-prompt.test.mjs +50 -50
- package/src/vendor-openspec-overlays.mjs +176 -176
- package/src/vendor-openspec-overlays.test.mjs +62 -62
- package/vendor/skills/archive-to-adr/SKILL.md +149 -149
- package/vendor/skills/forge/SKILL.md +136 -136
- package/vendor/skills/forge/docs/forge.md +650 -647
- package/vendor/skills/forge/phases/brainstorm.md +23 -23
- package/vendor/skills/forge/phases/finish.md +90 -87
- package/vendor/skills/forge/phases/implement.md +77 -77
- package/vendor/skills/forge/phases/plan-openspec.md +60 -60
- package/vendor/skills/forge/phases/plan-specs.md +163 -117
- package/vendor/skills/forge/phases/review.md +25 -25
- package/vendor/skills/forge/phases/verify.md +124 -124
- package/vendor/skills/forge/references/forge-layout.md +85 -85
- package/vendor/skills/forge/references/pace.md +115 -115
- package/vendor/skills/forge/references/plan-routing.md +52 -51
- package/vendor/skills/forge/references/runtime-integrity.md +232 -232
- package/vendor/skills/forge/references/substantial-work.md +37 -37
- package/vendor/skills/forge/references/test-evidence.md +30 -30
- package/vendor/skills/forge/references/test-strategy.md +68 -68
- package/vendor/skills/forge/skills/subagent-driven-development/SKILL.md +87 -87
- package/vendor/skills/forge/subagents/final-reviewer-prompt.md +56 -56
- package/vendor/skills/forge/subagents/implementer-prompt.md +38 -38
- package/vendor/skills/git-resolve-adr-conflict/SKILL.md +132 -132
- package/vendor/skills/thorough-code-review/SKILL.md +290 -290
- package/vendor/skills/thorough-code-review/examples.md +133 -133
- package/vendor/skills/thorough-code-review/reference/accepted-risks.md +26 -26
- package/vendor/skills/thorough-code-review/reference/lenses.md +96 -96
- package/vendor/skills/thorough-code-review/reference/phase1-scout.md +62 -62
- package/vendor/skills/thorough-code-review/reference/phase2-skeptic.md +105 -105
- package/vendor/skills/thorough-code-review/reference/report-schema.json +222 -222
- package/vendor/skills/thorough-code-review/reference/report-template.md +115 -115
- package/vendor/skills/thorough-code-review/reference/severity-rubric.md +49 -49
- package/vendor/skills/thorough-code-review/reference/signals-preflight.md +55 -55
- package/vendor/templates/adr/README.md +7 -7
- package/vendor/templates/adr/decisions.md +141 -141
- package/vendor/templates/adr/hooks/check-pending-adrs.mjs +74 -74
- package/vendor/templates/adr/hooks/check-pending-adrs.sh +3 -3
- package/vendor/templates/adr/hooks/openspec-archive-agent-message.mjs +52 -52
- package/vendor/templates/adr/hooks/openspec-archive-agent-message.sh +3 -3
- package/vendor/templates/project/claude/commands/forge-apply.md +75 -75
- package/vendor/templates/project/claude/commands/forge-brainstorm.md +7 -7
- package/vendor/templates/project/claude/commands/forge-build.md +17 -17
- package/vendor/templates/project/claude/commands/forge-plan.md +12 -12
- package/vendor/templates/project/claude/commands/forge-skip.md +14 -14
- package/vendor/templates/project/claude/commands/forge-status.md +16 -16
- package/vendor/templates/project/claude/commands/forge.md +16 -16
- package/vendor/templates/project/claude/hooks/forge-prompt-hook.mjs +73 -73
- package/vendor/templates/project/claude/hooks/forge-session-start.mjs +19 -19
- package/vendor/templates/project/claude/hooks/forge-triage-hook.mjs +77 -77
- package/vendor/templates/project/cursor/commands/forge-apply.md +75 -75
- package/vendor/templates/project/cursor/commands/forge-brainstorm.md +10 -10
- package/vendor/templates/project/cursor/commands/forge-build.md +17 -17
- package/vendor/templates/project/cursor/commands/forge-plan.md +15 -15
- package/vendor/templates/project/cursor/commands/forge-skip.md +14 -14
- package/vendor/templates/project/cursor/commands/forge-status.md +16 -16
- package/vendor/templates/project/cursor/commands/forge.md +16 -16
- package/vendor/templates/project/cursor/hooks/forge-session-start.mjs +30 -30
- 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);
|