@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
package/src/change.mjs CHANGED
@@ -1,234 +1,327 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Specs-engine change scaffolding.
4
- *
5
- * Usage:
6
- * forge change new <name> [--cwd <path>] [--force]
7
- * forge change archive <name> [--cwd <path>] [--date YYYY-MM-DD]
8
- */
9
-
10
- import fs from 'node:fs';
11
- import path from 'node:path';
12
- import { pathToFileURL } from 'node:url';
13
- import {
14
- DEFAULT_SPECS_DIR,
15
- resolveProjectPlanEngine,
16
- } from './plan-engine.mjs';
17
-
18
- /**
19
- * @param {string[]} argv
20
- */
21
- export function parseArgs(argv) {
22
- const opts = {
23
- help: false,
24
- force: false,
25
- cwd: process.cwd(),
26
- date: /** @type {string | null} */ (null),
27
- action: /** @type {string | null} */ (null),
28
- name: /** @type {string | null} */ (null),
29
- };
30
-
31
- for (let i = 0; i < argv.length; i += 1) {
32
- const arg = argv[i];
33
- if (arg === '--help' || arg === '-h') opts.help = true;
34
- else if (arg === '--force' || arg === '-f') opts.force = true;
35
- else if (arg === '--cwd') opts.cwd = argv[++i];
36
- else if (arg === '--date') opts.date = argv[++i];
37
- else if (!opts.action && (arg === 'new' || arg === 'archive')) opts.action = arg;
38
- else if (!opts.name && !arg.startsWith('-')) opts.name = arg;
39
- else throw new Error(`Unknown argument: ${arg}`);
40
- }
41
- return opts;
42
- }
43
-
44
- function printHelp() {
45
- process.stdout.write(`Usage: forge change <new|archive> <name> [options]
46
-
47
- Scaffold or archive a change for the built-in specs planning engine.
48
-
49
- Commands:
50
- new <name> Create specs/changes/<name>/{proposal.md,tasks.md}
51
- archive <name> Move specs/changes/<name> → changes/archive/YYYY-MM-DD-<name>
52
-
53
- Options:
54
- --cwd <path> Project root (default: cwd)
55
- --date YYYY-MM-DD Archive date prefix (default: today UTC)
56
- --force, -f Overwrite existing proposal/tasks on new
57
- --help
58
-
59
- Requires \`.forge/config.json\` → plan.engine: specs (or run \`forge init --no-openspec\`).
60
- `);
61
- }
62
-
63
- /**
64
- * @param {string} name
65
- */
66
- export function assertChangeName(name) {
67
- if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
68
- throw new Error(
69
- `Change name must be kebab-case (got: ${name}). Example: add-stripe-refunds`,
70
- );
71
- }
72
- return name;
73
- }
74
-
75
- const PROPOSAL_TMPL = (title) => `# ${title}
76
-
77
- ## Why
78
-
79
- One or two paragraphs: problem / pressure.
80
-
81
- ## What Changes
82
-
83
- -
84
-
85
- ## Impact
86
-
87
- Affected code/areas, risks, migration notes.
88
- `;
89
-
90
- const TASKS_TMPL = `# Tasks
91
-
92
- ## 1. First group
93
- - [ ] 1.1 Bite-sized task — exact files, expected tests
94
-
95
- ## 2. Second group
96
- - [ ] 2.1 …
97
- `;
98
-
99
- /**
100
- * @param {string} cwd
101
- * @param {string} name
102
- * @param {{ force?: boolean }} [opts]
103
- */
104
- export function createSpecsChange(cwd, name, opts = {}) {
105
- assertChangeName(name);
106
- const engine = resolveProjectPlanEngine(cwd, { useUserDefault: false });
107
- if (engine.engine !== 'specs') {
108
- throw new Error(
109
- `Project plan engine is "${engine.engine}", not "specs". ` +
110
- `Use \`openspec-propose\` / \`/opsx:propose\` for OpenSpec projects, ` +
111
- `or \`forge init --no-openspec\` to switch.`,
112
- );
113
- }
114
- const dir = engine.dir || DEFAULT_SPECS_DIR;
115
- const changeDir = path.join(cwd, dir, 'changes', name);
116
- fs.mkdirSync(changeDir, { recursive: true });
117
-
118
- /** @type {{ file: string, status: string }[]} */
119
- const files = [];
120
- const write = (rel, body) => {
121
- const dest = path.join(changeDir, rel);
122
- if (fs.existsSync(dest) && !opts.force) {
123
- files.push({ file: `${dir}/changes/${name}/${rel}`, status: 'skipped' });
124
- return;
125
- }
126
- fs.writeFileSync(dest, body, 'utf8');
127
- files.push({ file: `${dir}/changes/${name}/${rel}`, status: 'written' });
128
- };
129
-
130
- const title = name
131
- .split('-')
132
- .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
133
- .join(' ');
134
- write('proposal.md', PROPOSAL_TMPL(title));
135
- write('tasks.md', TASKS_TMPL);
136
-
137
- return { dir, changeDir, name, files };
138
- }
139
-
140
- /**
141
- * @param {string} cwd
142
- * @param {string} name
143
- * @param {{ date?: string | null }} [opts]
144
- */
145
- export function archiveSpecsChange(cwd, name, opts = {}) {
146
- assertChangeName(name);
147
- const engine = resolveProjectPlanEngine(cwd, { useUserDefault: false });
148
- if (engine.engine !== 'specs') {
149
- throw new Error(
150
- `Project plan engine is "${engine.engine}", not "specs". ` +
151
- `Use \`openspec archive\` / \`/opsx:archive\` for OpenSpec projects.`,
152
- );
153
- }
154
- const dir = engine.dir || DEFAULT_SPECS_DIR;
155
- const src = path.join(cwd, dir, 'changes', name);
156
- if (!fs.existsSync(src)) {
157
- throw new Error(`Change not found: ${dir}/changes/${name}`);
158
- }
159
- const date =
160
- opts.date ||
161
- new Date().toISOString().slice(0, 10);
162
- if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
163
- throw new Error(`Invalid --date (want YYYY-MM-DD): ${date}`);
164
- }
165
- const archiveParent = path.join(cwd, dir, 'changes', 'archive');
166
- fs.mkdirSync(archiveParent, { recursive: true });
167
- const destName = `${date}-${name}`;
168
- const dest = path.join(archiveParent, destName);
169
- if (fs.existsSync(dest)) {
170
- throw new Error(`Archive already exists: ${dir}/changes/archive/${destName}`);
171
- }
172
- fs.renameSync(src, dest);
173
- return {
174
- dir,
175
- from: `${dir}/changes/${name}`,
176
- to: `${dir}/changes/archive/${destName}`,
177
- };
178
- }
179
-
180
- /**
181
- * @param {string[]} argv
182
- */
183
- export function runChange(argv) {
184
- const opts = parseArgs(argv);
185
- if (opts.help || !opts.action) {
186
- printHelp();
187
- return opts.help ? 0 : 1;
188
- }
189
- if (!opts.name) {
190
- process.stderr.write('Missing change name.\n');
191
- printHelp();
192
- return 1;
193
- }
194
-
195
- if (opts.action === 'new') {
196
- const result = createSpecsChange(opts.cwd, opts.name, { force: opts.force });
197
- process.stdout.write(
198
- `Created specs change "${result.name}" under ${result.dir}/changes/${result.name}/\n`,
199
- );
200
- for (const f of result.files) {
201
- process.stdout.write(` ${f.status.padEnd(8)} ${f.file}\n`);
202
- }
203
- process.stdout.write(
204
- `\nNext: edit proposal.md / tasks.md, then:\n` +
205
- ` forge phase plan --plan-type specs --openspec ${result.name}\n`,
206
- );
207
- return 0;
208
- }
209
-
210
- if (opts.action === 'archive') {
211
- const result = archiveSpecsChange(opts.cwd, opts.name, { date: opts.date });
212
- process.stdout.write(`Archived ${result.from} ${result.to}\n`);
213
- process.stdout.write(
214
- `If ADRs are enabled, run archive-to-adr on the archived folder.\n`,
215
- );
216
- return 0;
217
- }
218
-
219
- process.stderr.write(`Unknown action: ${opts.action}\n`);
220
- return 1;
221
- }
222
-
223
- const isMain =
224
- process.argv[1] &&
225
- import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
226
-
227
- if (isMain) {
228
- try {
229
- process.exitCode = runChange(process.argv.slice(2));
230
- } catch (err) {
231
- process.stderr.write(`${err instanceof Error ? err.message : err}\n`);
232
- process.exitCode = 1;
233
- }
234
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Specs-engine change scaffolding.
4
+ *
5
+ * Usage:
6
+ * forge change new <name> [--capability <id>]… [--cwd <path>] [--force]
7
+ * forge change archive <name> [--cwd <path>] [--date YYYY-MM-DD] [--no-sync]
8
+ */
9
+
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { pathToFileURL } from 'node:url';
13
+ import {
14
+ DEFAULT_SPECS_DIR,
15
+ resolveProjectPlanEngine,
16
+ } from './plan-engine.mjs';
17
+ import { deltaSpecTemplate, mergeChangeDeltas } from './specs-sync.mjs';
18
+
19
+ /**
20
+ * @param {string[]} argv
21
+ */
22
+ export function parseArgs(argv) {
23
+ const opts = {
24
+ help: false,
25
+ force: false,
26
+ noSync: false,
27
+ cwd: process.cwd(),
28
+ date: /** @type {string | null} */ (null),
29
+ action: /** @type {string | null} */ (null),
30
+ name: /** @type {string | null} */ (null),
31
+ capabilities: /** @type {string[]} */ ([]),
32
+ };
33
+
34
+ for (let i = 0; i < argv.length; i += 1) {
35
+ const arg = argv[i];
36
+ if (arg === '--help' || arg === '-h') opts.help = true;
37
+ else if (arg === '--force' || arg === '-f') opts.force = true;
38
+ else if (arg === '--no-sync') opts.noSync = true;
39
+ else if (arg === '--cwd') opts.cwd = argv[++i];
40
+ else if (arg === '--date') opts.date = argv[++i];
41
+ else if (arg === '--capability' || arg === '--cap') {
42
+ const cap = argv[++i];
43
+ if (!cap) throw new Error(`${arg} requires a capability id`);
44
+ opts.capabilities.push(cap);
45
+ } else if (!opts.action && (arg === 'new' || arg === 'archive')) opts.action = arg;
46
+ else if (!opts.name && !arg.startsWith('-')) opts.name = arg;
47
+ else throw new Error(`Unknown argument: ${arg}`);
48
+ }
49
+ return opts;
50
+ }
51
+
52
+ function printHelp() {
53
+ process.stdout.write(`Usage: forge change <new|archive> <name> [options]
54
+
55
+ Scaffold or archive a change for the built-in specs planning engine.
56
+ Layout matches OpenSpec: proposal / design / tasks / delta specs under
57
+ \`changes/<name>/specs/<capability>/spec.md\`, main catalog at
58
+ \`<plan.dir>/specs/\`.
59
+
60
+ Commands:
61
+ new <name> Create <plan.dir>/changes/<name>/{proposal,design,tasks}.md
62
+ plus delta stubs for each --capability
63
+ archive <name> Merge deltas → <plan.dir>/specs/, then move change to
64
+ changes/archive/YYYY-MM-DD-<name>
65
+
66
+ Options:
67
+ --capability <id> Delta capability to stub (repeatable). kebab-case id
68
+ matching OpenSpec domains (e.g. auth, payments)
69
+ --cwd <path> Project root (default: cwd)
70
+ --date YYYY-MM-DD Archive date prefix (default: today UTC)
71
+ --no-sync Archive without merging deltas into the main catalog
72
+ --force, -f Overwrite existing scaffold files on new
73
+ --help
74
+
75
+ Requires \`.forge/config.json\` plan.engine: specs (or run \`forge init --no-openspec\`).
76
+ Engine root is \`plan.dir\` (default \`specs\`; use \`openspec\` to reuse an
77
+ OpenSpec tree).
78
+ `);
79
+ }
80
+
81
+ /**
82
+ * @param {string} name
83
+ */
84
+ export function assertChangeName(name) {
85
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
86
+ throw new Error(
87
+ `Change name must be kebab-case (got: ${name}). Example: add-stripe-refunds`,
88
+ );
89
+ }
90
+ return name;
91
+ }
92
+
93
+ /**
94
+ * @param {string} capability
95
+ */
96
+ export function assertCapabilityName(capability) {
97
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(capability)) {
98
+ throw new Error(
99
+ `Capability id must be kebab-case (got: ${capability}). Example: auth`,
100
+ );
101
+ }
102
+ return capability;
103
+ }
104
+
105
+ const PROPOSAL_TMPL = (title, capabilities) => {
106
+ const caps =
107
+ capabilities.length > 0
108
+ ? capabilities.map((c) => `- \`${c}\`: … (delta: \`specs/${c}/spec.md\`)`).join('\n')
109
+ : `- \`<capability>\`: add with \`forge change new … --capability <id>\` or create \`specs/<id>/spec.md\``;
110
+ return `# ${title}
111
+
112
+ ## Why
113
+
114
+ One or two paragraphs: problem / pressure.
115
+
116
+ ## What Changes
117
+
118
+ -
119
+
120
+ ## Capabilities
121
+
122
+ ${caps}
123
+
124
+ ## Impact
125
+
126
+ Affected code/areas, risks, migration notes.
127
+ `;
128
+ };
129
+
130
+ const DESIGN_TMPL = `# Design
131
+
132
+ ## Context
133
+
134
+
135
+
136
+ ## Decisions
137
+
138
+ - Decision: …
139
+ - Alternatives considered: …
140
+ - Rationale: …
141
+
142
+ ## Risks / Trade-offs
143
+
144
+ -
145
+ `;
146
+
147
+ const TASKS_TMPL = `# Tasks
148
+
149
+ ## 1. First group
150
+ - [ ] 1.1 Bite-sized task exact files, expected tests
151
+
152
+ ## 2. Second group
153
+ - [ ] 2.1 …
154
+ `;
155
+
156
+ /**
157
+ * @param {string} cwd
158
+ * @param {string} name
159
+ * @param {{ force?: boolean, capabilities?: string[] }} [opts]
160
+ */
161
+ export function createSpecsChange(cwd, name, opts = {}) {
162
+ assertChangeName(name);
163
+ const capabilities = (opts.capabilities ?? []).map(assertCapabilityName);
164
+ const engine = resolveProjectPlanEngine(cwd, { useUserDefault: false });
165
+ if (engine.engine !== 'specs') {
166
+ throw new Error(
167
+ `Project plan engine is "${engine.engine}", not "specs". ` +
168
+ `Use \`openspec-propose\` / \`/opsx:propose\` for OpenSpec projects, ` +
169
+ `or \`forge init --no-openspec\` to switch.`,
170
+ );
171
+ }
172
+ const dir = engine.dir || DEFAULT_SPECS_DIR;
173
+ const changeDir = path.join(cwd, dir, 'changes', name);
174
+ fs.mkdirSync(changeDir, { recursive: true });
175
+ fs.mkdirSync(path.join(changeDir, 'specs'), { recursive: true });
176
+
177
+ /** @type {{ file: string, status: string }[]} */
178
+ const files = [];
179
+ const write = (rel, body) => {
180
+ const dest = path.join(changeDir, rel);
181
+ if (fs.existsSync(dest) && !opts.force) {
182
+ files.push({ file: `${dir}/changes/${name}/${rel}`, status: 'skipped' });
183
+ return;
184
+ }
185
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
186
+ fs.writeFileSync(dest, body, 'utf8');
187
+ files.push({ file: `${dir}/changes/${name}/${rel}`, status: 'written' });
188
+ };
189
+
190
+ const title = name
191
+ .split('-')
192
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
193
+ .join(' ');
194
+ write('proposal.md', PROPOSAL_TMPL(title, capabilities));
195
+ write('design.md', DESIGN_TMPL);
196
+ write('tasks.md', TASKS_TMPL);
197
+
198
+ for (const cap of capabilities) {
199
+ write(`specs/${cap}/spec.md`, deltaSpecTemplate(cap));
200
+ }
201
+
202
+ return { dir, changeDir, name, files, capabilities };
203
+ }
204
+
205
+ /**
206
+ * @param {string} cwd
207
+ * @param {string} name
208
+ * @param {{ date?: string | null, noSync?: boolean }} [opts]
209
+ */
210
+ export function archiveSpecsChange(cwd, name, opts = {}) {
211
+ assertChangeName(name);
212
+ const engine = resolveProjectPlanEngine(cwd, { useUserDefault: false });
213
+ if (engine.engine !== 'specs') {
214
+ throw new Error(
215
+ `Project plan engine is "${engine.engine}", not "specs". ` +
216
+ `Use \`openspec archive\` / \`/opsx:archive\` for OpenSpec projects.`,
217
+ );
218
+ }
219
+ const dir = engine.dir || DEFAULT_SPECS_DIR;
220
+ const src = path.join(cwd, dir, 'changes', name);
221
+ if (!fs.existsSync(src)) {
222
+ throw new Error(`Change not found: ${dir}/changes/${name}`);
223
+ }
224
+ const date = opts.date || new Date().toISOString().slice(0, 10);
225
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
226
+ throw new Error(`Invalid --date (want YYYY-MM-DD): ${date}`);
227
+ }
228
+
229
+ /** @type {{ capability: string, status: string, file: string }[]} */
230
+ let synced = [];
231
+ if (!opts.noSync) {
232
+ const mainSpecsDir = path.join(cwd, dir, 'specs');
233
+ synced = mergeChangeDeltas(src, mainSpecsDir);
234
+ }
235
+
236
+ const archiveParent = path.join(cwd, dir, 'changes', 'archive');
237
+ fs.mkdirSync(archiveParent, { recursive: true });
238
+ const destName = `${date}-${name}`;
239
+ const dest = path.join(archiveParent, destName);
240
+ if (fs.existsSync(dest)) {
241
+ throw new Error(`Archive already exists: ${dir}/changes/archive/${destName}`);
242
+ }
243
+ fs.renameSync(src, dest);
244
+ return {
245
+ dir,
246
+ from: `${dir}/changes/${name}`,
247
+ to: `${dir}/changes/archive/${destName}`,
248
+ synced,
249
+ };
250
+ }
251
+
252
+ /**
253
+ * @param {string[]} argv
254
+ */
255
+ export function runChange(argv) {
256
+ const opts = parseArgs(argv);
257
+ if (opts.help || !opts.action) {
258
+ printHelp();
259
+ return opts.help ? 0 : 1;
260
+ }
261
+ if (!opts.name) {
262
+ process.stderr.write('Missing change name.\n');
263
+ printHelp();
264
+ return 1;
265
+ }
266
+
267
+ if (opts.action === 'new') {
268
+ const result = createSpecsChange(opts.cwd, opts.name, {
269
+ force: opts.force,
270
+ capabilities: opts.capabilities,
271
+ });
272
+ process.stdout.write(
273
+ `Created specs change "${result.name}" under ${result.dir}/changes/${result.name}/\n`,
274
+ );
275
+ for (const f of result.files) {
276
+ process.stdout.write(` ${f.status.padEnd(8)} ${f.file}\n`);
277
+ }
278
+ if (result.capabilities.length === 0) {
279
+ process.stdout.write(
280
+ `\nNo delta stubs yet — add with:\n` +
281
+ ` forge change new ${result.name} --capability <domain> --force\n` +
282
+ `or create ${result.dir}/changes/${result.name}/specs/<domain>/spec.md\n`,
283
+ );
284
+ }
285
+ process.stdout.write(
286
+ `\nNext: edit proposal.md / design.md / tasks.md / specs/, then:\n` +
287
+ ` forge phase plan --plan-type specs --openspec ${result.name}\n`,
288
+ );
289
+ return 0;
290
+ }
291
+
292
+ if (opts.action === 'archive') {
293
+ const result = archiveSpecsChange(opts.cwd, opts.name, {
294
+ date: opts.date,
295
+ noSync: opts.noSync,
296
+ });
297
+ for (const s of result.synced) {
298
+ process.stdout.write(
299
+ ` sync ${s.status.padEnd(8)} ${result.dir}/specs/${s.capability}/spec.md\n`,
300
+ );
301
+ }
302
+ if (!opts.noSync && result.synced.length === 0) {
303
+ process.stdout.write(' sync (no delta specs to merge)\n');
304
+ }
305
+ process.stdout.write(`Archived ${result.from} → ${result.to}\n`);
306
+ process.stdout.write(
307
+ `If ADRs are enabled, run archive-to-adr on the archived folder.\n`,
308
+ );
309
+ return 0;
310
+ }
311
+
312
+ process.stderr.write(`Unknown action: ${opts.action}\n`);
313
+ return 1;
314
+ }
315
+
316
+ const isMain =
317
+ process.argv[1] &&
318
+ import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
319
+
320
+ if (isMain) {
321
+ try {
322
+ process.exitCode = runChange(process.argv.slice(2));
323
+ } catch (err) {
324
+ process.stderr.write(`${err instanceof Error ? err.message : err}\n`);
325
+ process.exitCode = 1;
326
+ }
327
+ }