@warpgogol/forge 2.8.0 → 2.8.2

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 (271) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +23 -13
  3. package/README.uk.md +19 -12
  4. package/dist/os/session/handlers/validate.d.ts.map +1 -1
  5. package/dist/os/session/handlers/validate.js +23 -1
  6. package/dist/os/session/handlers/validate.js.map +1 -1
  7. package/dist/os/session/types.d.ts +31 -1
  8. package/dist/os/session/types.d.ts.map +1 -1
  9. package/dist/os/session/types.js +7 -0
  10. package/dist/os/session/types.js.map +1 -1
  11. package/os/adr/adr.module.ts +158 -0
  12. package/os/adr/frontmatter-io.ts +80 -0
  13. package/os/adr/handlers/archive.ts +244 -0
  14. package/os/adr/handlers/implement-stamp.ts +331 -0
  15. package/os/adr/handlers/list-create.ts +207 -0
  16. package/os/adr/handlers/validate.test.ts +232 -0
  17. package/os/adr/handlers/validate.ts +429 -0
  18. package/os/adr/index.ts +33 -0
  19. package/os/adr/types.ts +141 -0
  20. package/os/audit/audit.module.ts +48 -0
  21. package/os/audit/frontmatter-io.ts +84 -0
  22. package/os/audit/handlers/archive.ts +239 -0
  23. package/os/audit/index.ts +23 -0
  24. package/os/audit/types.ts +29 -0
  25. package/os/compass/compass.module.ts +186 -0
  26. package/os/compass/handlers/compass-audit-handler.ts +382 -0
  27. package/os/compass/handlers/compass-change-summary-handler.ts +272 -0
  28. package/os/compass/handlers/compass-inventory-handler.ts +294 -0
  29. package/os/compass/handlers/compass-inventory.ts +521 -0
  30. package/os/compass/handlers/git-revision.ts +137 -0
  31. package/os/compass/handlers/resolve-scan-root-workpiece.test.ts +65 -0
  32. package/os/compass/handlers/resolve-scan-root.ts +79 -0
  33. package/os/compass/index.ts +33 -0
  34. package/os/core/core.module.ts +948 -0
  35. package/os/core/handlers/assets-check.ts +122 -0
  36. package/os/core/handlers/assets-helpers.ts +174 -0
  37. package/os/core/handlers/assets-list.ts +99 -0
  38. package/os/core/handlers/build.ts +155 -0
  39. package/os/core/handlers/determinism-check.ts +354 -0
  40. package/os/core/handlers/dev.ts +127 -0
  41. package/os/core/handlers/invariant-engine.test.ts +681 -0
  42. package/os/core/handlers/knowledge-compact.ts +248 -0
  43. package/os/core/handlers/lifecycle-handlers.test.ts +524 -0
  44. package/os/core/handlers/note-frontmatter-validate.test.ts +93 -0
  45. package/os/core/handlers/note-link-validate.test.ts +117 -0
  46. package/os/core/handlers/note-orphan-detect.test.ts +94 -0
  47. package/os/core/handlers/package-health.test.ts +226 -0
  48. package/os/core/handlers/package-health.ts +231 -0
  49. package/os/core/handlers/pinned-check.ts +200 -0
  50. package/os/core/handlers/pinned-init.ts +333 -0
  51. package/os/core/handlers/pinned-types.ts +50 -0
  52. package/os/core/handlers/pinned-validate.ts +301 -0
  53. package/os/core/handlers/profile-resolve.ts +92 -0
  54. package/os/core/handlers/release-prepare.ts +294 -0
  55. package/os/core/handlers/release-publish.ts +211 -0
  56. package/os/core/handlers/validate.ts +262 -0
  57. package/os/core/handlers/workspace-deps.ts +55 -0
  58. package/os/core/index.ts +13 -0
  59. package/os/exploration/exploration.module.ts +90 -0
  60. package/os/exploration/frontmatter-io.ts +77 -0
  61. package/os/exploration/handlers/archive.ts +159 -0
  62. package/os/exploration/handlers/list.ts +77 -0
  63. package/os/exploration/handlers/show.ts +107 -0
  64. package/os/exploration/index.ts +25 -0
  65. package/os/exploration/types.ts +65 -0
  66. package/os/mission/handlers/archive.test.ts +391 -0
  67. package/os/mission/handlers/archive.ts +453 -0
  68. package/os/mission/index.ts +1 -0
  69. package/os/mission/mission.module.ts +48 -0
  70. package/os/mission/types.ts +41 -0
  71. package/os/naming/index.ts +14 -0
  72. package/os/naming/naming-convention.test.ts +163 -0
  73. package/os/naming/naming-convention.ts +378 -0
  74. package/os/naming/naming.module.ts +36 -0
  75. package/os/notes/index.ts +10 -0
  76. package/os/notes/notes.module.ts +109 -0
  77. package/os/plan/frontmatter-io.ts +82 -0
  78. package/os/plan/handlers/archive.ts +238 -0
  79. package/os/plan/index.ts +23 -0
  80. package/os/plan/plan.module.ts +48 -0
  81. package/os/plan/types.ts +29 -0
  82. package/os/program/discovery.ts +294 -0
  83. package/os/program/handlers/complete.ts +419 -0
  84. package/os/program/handlers/lease.ts +433 -0
  85. package/os/program/handlers/seal.ts +332 -0
  86. package/os/program/handlers/validate.ts +213 -0
  87. package/os/program/lease.ts +156 -0
  88. package/os/program/program.module.ts +212 -0
  89. package/os/program/schemas.ts +218 -0
  90. package/os/program/state.ts +264 -0
  91. package/os/rfc/acceptance.ts +344 -0
  92. package/os/rfc/decision-log.ts +310 -0
  93. package/os/rfc/dna-trace.ts +280 -0
  94. package/os/rfc/frontmatter-io.test.ts +132 -0
  95. package/os/rfc/frontmatter-io.ts +130 -0
  96. package/os/rfc/handlers/archive.ts +300 -0
  97. package/os/rfc/handlers/check.ts +238 -0
  98. package/os/rfc/handlers/implement-stamp.ts +515 -0
  99. package/os/rfc/handlers/index-graph.ts +237 -0
  100. package/os/rfc/handlers/lifecycle.test.ts +127 -0
  101. package/os/rfc/handlers/lifecycle.ts +249 -0
  102. package/os/rfc/handlers/list-create.ts +296 -0
  103. package/os/rfc/handlers/pipeline-status.ts +173 -0
  104. package/os/rfc/handlers/shared.ts +121 -0
  105. package/os/rfc/handlers/supersede-propose.ts +241 -0
  106. package/os/rfc/handlers/validate-rules.test.ts +891 -0
  107. package/os/rfc/handlers/validate-rules.ts +984 -0
  108. package/os/rfc/handlers/validate.ts +166 -0
  109. package/os/rfc/handlers.ts +19 -0
  110. package/os/rfc/index.ts +97 -0
  111. package/os/rfc/rfc.module.ts +416 -0
  112. package/os/rfc/types.ts +596 -0
  113. package/os/rfc/verification-evidence.ts +244 -0
  114. package/os/session/atif-parser.ts +166 -0
  115. package/os/session/frontmatter-io.ts +163 -0
  116. package/os/session/handlers/archive.ts +288 -0
  117. package/os/session/handlers/list.ts +174 -0
  118. package/os/session/handlers/save.ts +348 -0
  119. package/os/session/handlers/validate.ts +250 -0
  120. package/os/session/index.ts +50 -0
  121. package/os/session/session.module.ts +126 -0
  122. package/os/session/types.ts +203 -0
  123. package/os/spec/live-spec-list-show-validate.test.ts +257 -0
  124. package/os/spec/live-spec-list.ts +78 -0
  125. package/os/spec/live-spec-merge.test.ts +261 -0
  126. package/os/spec/live-spec-merge.ts +413 -0
  127. package/os/spec/live-spec-show.ts +117 -0
  128. package/os/spec/live-spec-types.ts +93 -0
  129. package/os/spec/live-spec-validate.ts +170 -0
  130. package/os/spec/spec-materialize.ts +390 -0
  131. package/os/spec/spec-schema.ts +174 -0
  132. package/os/spec/spec-status.ts +284 -0
  133. package/os/spec/spec-validate.ts +513 -0
  134. package/os/spec/spec.module.ts +135 -0
  135. package/os/werkstatt/handlers/lock.ts +168 -0
  136. package/os/werkstatt/handlers/schema.ts +47 -0
  137. package/os/werkstatt/handlers/werkstatt-lock-recover.ts +175 -0
  138. package/os/werkstatt/handlers/werkstatt-lock-status.ts +73 -0
  139. package/os/werkstatt/handlers/werkstatt-operation-validate.ts +104 -0
  140. package/os/werkstatt/index.ts +39 -0
  141. package/os/werkstatt/werkstatt.module.ts +62 -0
  142. package/os/workflow/handlers.ts +304 -0
  143. package/os/workflow/index.ts +28 -0
  144. package/os/workflow/types.ts +99 -0
  145. package/os/workflow/workflow.module.ts +54 -0
  146. package/package.json +3 -3
  147. package/profiles/godot-csharp.yaml +4 -4
  148. package/profiles/knowledge-typescript-turborepo.yaml +110 -0
  149. package/profiles/phaser-turborepo.yaml +5 -5
  150. package/skills/_shared/fo-session-summary.md +132 -16
  151. package/skills/fo/fo-add-tests/pbt-guide.md +13 -13
  152. package/skills/fo/fo-doc-audit/SKILL.md +6 -6
  153. package/skills/fo/fo-handoff/SKILL.md +5 -0
  154. package/skills/fo/fo-idea/SKILL.md +1 -1
  155. package/skills/fo/fo-review/SKILL.md +1 -1
  156. package/skills/fo/fo-session-retro/SKILL.md +8 -0
  157. package/skills/fo/fo-session-save/SKILL.md +8 -0
  158. package/src/cli-output.ts +59 -0
  159. package/src/config/__tests__/resolve-terminology.test.ts +45 -0
  160. package/src/config/forge-config.ts +524 -0
  161. package/src/forge-module.ts +39 -0
  162. package/src/index.ts +172 -0
  163. package/src/knowledge/__tests__/promote.test.ts +219 -0
  164. package/src/knowledge/__tests__/serialize.test.ts +192 -0
  165. package/src/knowledge/budgets.ts +202 -0
  166. package/src/knowledge/compact.ts +405 -0
  167. package/src/knowledge/index.ts +53 -0
  168. package/src/knowledge/parse.ts +227 -0
  169. package/src/knowledge/promote.ts +156 -0
  170. package/src/knowledge/schema.ts +112 -0
  171. package/src/knowledge/serialize.ts +72 -0
  172. package/src/migration-adapters/__tests__/ignored-files.test.ts +51 -0
  173. package/src/migration-adapters/__tests__/registry.test.ts +131 -0
  174. package/src/migration-adapters/__tests__/types.test.ts +86 -0
  175. package/src/migration-adapters/git-utils.ts +71 -0
  176. package/src/migration-adapters/ignored-files.ts +221 -0
  177. package/src/migration-adapters/index.ts +21 -0
  178. package/src/migration-adapters/node-typescript-pnpm/index.ts +189 -0
  179. package/src/migration-adapters/phaser-pnpm/index.ts +189 -0
  180. package/src/migration-adapters/registry.ts +61 -0
  181. package/src/migration-adapters/types.ts +68 -0
  182. package/src/onboarding/__tests__/workspace-discovery.test.ts +134 -0
  183. package/src/onboarding/agents-generate.ts +443 -0
  184. package/src/onboarding/create.ts +427 -0
  185. package/src/onboarding/doctor.ts +1297 -0
  186. package/src/onboarding/init.ts +337 -0
  187. package/src/onboarding/invariant-engine.ts +448 -0
  188. package/src/onboarding/memory-scaffold.ts +189 -0
  189. package/src/onboarding/nested-agents-generate.ts +93 -0
  190. package/src/onboarding/nested-agents-templates.ts +233 -0
  191. package/src/onboarding/profile-validate.ts +139 -0
  192. package/src/onboarding/scaffold-project.ts +299 -0
  193. package/src/onboarding/scaffold.ts +143 -0
  194. package/src/onboarding/upgrade.ts +558 -0
  195. package/src/onboarding/workspace-discovery.ts +167 -0
  196. package/src/profiles/__tests__/profile-schema.test.ts +228 -0
  197. package/src/profiles/__tests__/stack-profile.test.ts +190 -0
  198. package/src/profiles/__tests__/terminology-utils.test.ts +71 -0
  199. package/src/profiles/profile-schema.ts +350 -0
  200. package/src/profiles/stack-profile.ts +188 -0
  201. package/src/profiles/terminology-utils.ts +36 -0
  202. package/src/registry.ts +185 -0
  203. package/src/skill-schema.ts +37 -0
  204. package/src/tests/acceptance-criteria.test.ts +99 -0
  205. package/src/tests/adr-implement-stamp.test.ts +368 -0
  206. package/src/tests/agents-generate-domain.test.ts +317 -0
  207. package/src/tests/agents-generate.test.ts +458 -0
  208. package/src/tests/bindings-schema.test.ts +314 -0
  209. package/src/tests/budgets.test.ts +213 -0
  210. package/src/tests/cli-output.test.ts +196 -0
  211. package/src/tests/compact.test.ts +796 -0
  212. package/src/tests/create.test.ts +289 -0
  213. package/src/tests/doctor-autonomy.test.ts +98 -0
  214. package/src/tests/doctor-bindings.test.ts +286 -0
  215. package/src/tests/doctor-domain.test.ts +113 -0
  216. package/src/tests/exploration-handlers.test.ts +256 -0
  217. package/src/tests/fixtures/agents-generate-business-before.txt +288 -0
  218. package/src/tests/forge-config.test.ts +420 -0
  219. package/src/tests/fs-atomic.test.ts +96 -0
  220. package/src/tests/fs.test.ts +103 -0
  221. package/src/tests/generated-marker.test.ts +161 -0
  222. package/src/tests/hash.test.ts +37 -0
  223. package/src/tests/implement-stamp.test.ts +505 -0
  224. package/src/tests/init-bindings.test.ts +183 -0
  225. package/src/tests/knowledge-parse.test.ts +307 -0
  226. package/src/tests/knowledge-pbt.test.ts +164 -0
  227. package/src/tests/memory-scaffold.test.ts +192 -0
  228. package/src/tests/migration-adapters.test.ts +480 -0
  229. package/src/tests/package-files.test.ts +54 -0
  230. package/src/tests/pinned-check.test.ts +194 -0
  231. package/src/tests/pinned-init.test.ts +126 -0
  232. package/src/tests/profile-schema.test.ts +194 -0
  233. package/src/tests/profile-validate.test.ts +120 -0
  234. package/src/tests/program-lease.test.ts +245 -0
  235. package/src/tests/program-paths-extended.test.ts +117 -0
  236. package/src/tests/program-paths.test.ts +98 -0
  237. package/src/tests/program-property.test.ts +139 -0
  238. package/src/tests/program-schemas.test.ts +445 -0
  239. package/src/tests/program-spec-node.test.ts +250 -0
  240. package/src/tests/promote.test.ts +481 -0
  241. package/src/tests/registry.test.ts +73 -0
  242. package/src/tests/scaffold-project.test.ts +150 -0
  243. package/src/tests/session-handlers.test.ts +503 -0
  244. package/src/tests/session-pbt.test.ts +190 -0
  245. package/src/tests/skill-schema.test.ts +146 -0
  246. package/src/tests/skill-validate-knowledge.test.ts +26 -0
  247. package/src/tests/skill-validate.test.ts +254 -0
  248. package/src/tests/stack-profile.test.ts +221 -0
  249. package/src/tests/string-utils.test.ts +42 -0
  250. package/src/tests/upgrade.test.ts +428 -0
  251. package/src/tests/werkstatt-lock.test.ts +343 -0
  252. package/src/tests/workspace-discovery-domain.test.ts +112 -0
  253. package/src/tests/workspace-discovery.test.ts +135 -0
  254. package/src/types.ts +221 -0
  255. package/src/utils/__tests__/fs-idempotent.test.ts +73 -0
  256. package/src/utils/__tests__/fs-trash.test.ts +45 -0
  257. package/src/utils/fs-atomic.ts +93 -0
  258. package/src/utils/fs-idempotent.ts +41 -0
  259. package/src/utils/fs-trash-sync.ts +37 -0
  260. package/src/utils/fs-trash.ts +24 -0
  261. package/src/utils/fs.ts +74 -0
  262. package/src/utils/generated-marker.ts +166 -0
  263. package/src/utils/hash.ts +19 -0
  264. package/src/utils/index.ts +29 -0
  265. package/src/utils/string-utils.ts +19 -0
  266. package/src/validators/__tests__/note-orphan-detect.test.ts +94 -0
  267. package/src/validators/note-frontmatter-validate.ts +142 -0
  268. package/src/validators/note-link-validate.ts +145 -0
  269. package/src/validators/note-orphan-detect.ts +146 -0
  270. package/src/validators/port-validate.ts +97 -0
  271. package/src/validators/skill-validate.ts +831 -0
@@ -0,0 +1,261 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Unit tests for spec.live.merge handler — covers creation, modification,
4
+ conflict detection, dry-run, no-op cases (RFC-0711).</purpose>
5
+ <non-goals>
6
+ <item>Do not test docs.archive integration — covered separately.</item>
7
+ </non-goals>
8
+ </MODULE_CONTRACT>
9
+ <CHANGE_SUMMARY>
10
+ <item>RFC-0711: initial unit tests for spec.live.merge.</item>
11
+ </CHANGE_SUMMARY>
12
+ */
13
+
14
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
15
+ import fs from "node:fs/promises";
16
+ import path from "node:path";
17
+ import os from "node:os";
18
+ import { runSpecLiveMerge } from "./live-spec-merge.ts";
19
+ import type { ForgeCommandInput, ForgeRuntimeContext } from "../../src/types.ts";
20
+
21
+ function makeContext(workspaceRoot: string): ForgeRuntimeContext {
22
+ return {
23
+ workspaceRoot,
24
+ logger: {
25
+ section: () => {},
26
+ info: () => {},
27
+ warn: () => {},
28
+ error: () => {},
29
+ success: () => {},
30
+ },
31
+ dryRun: false,
32
+ outputFormat: "json",
33
+ };
34
+ }
35
+
36
+ const SAMPLE_RFC = `---
37
+ id: RFC-9001
38
+ title: "Test RFC for living specs"
39
+ status: implemented
40
+ kind: architecture
41
+ scope: workspace
42
+ owners:
43
+ - architecture
44
+ reviewers:
45
+ - human:test
46
+ createdAt: 2026-08-06
47
+ updatedAt: 2026-08-06
48
+ implementedAt: 2026-08-06
49
+ versionBump: patch
50
+ liveSpec: true
51
+ packagesImpacted:
52
+ - packages/forge
53
+ commands:
54
+ proposed: []
55
+ added: []
56
+ changed: []
57
+ removed: []
58
+ appsImpacted: []
59
+ successSignals: []
60
+ nonGoals: []
61
+ ---
62
+
63
+ # RFC-9001: Test RFC for living specs
64
+
65
+ ## Context
66
+
67
+ Some context.
68
+
69
+ ## Design
70
+
71
+ ### CLI surface
72
+
73
+ \`\`\`sh
74
+ pnpm exec werkstatt run spec.live.merge --id RFC-9001
75
+ \`\`\`
76
+
77
+ ### TypeScript contracts
78
+
79
+ Some types.
80
+
81
+ ### File system responsibilities
82
+
83
+ Some files.
84
+
85
+ ## Rollout
86
+
87
+ Some rollout.
88
+ `;
89
+
90
+ const SAMPLE_RFC_NO_LIVE_SPEC = `---
91
+ id: RFC-9002
92
+ title: "Test RFC without liveSpec"
93
+ status: implemented
94
+ kind: architecture
95
+ scope: workspace
96
+ owners:
97
+ - architecture
98
+ reviewers:
99
+ - human:test
100
+ createdAt: 2026-08-06
101
+ updatedAt: 2026-08-06
102
+ implementedAt: 2026-08-06
103
+ versionBump: patch
104
+ packagesImpacted:
105
+ - packages/forge
106
+ commands:
107
+ proposed: []
108
+ added: []
109
+ changed: []
110
+ removed: []
111
+ appsImpacted: []
112
+ successSignals: []
113
+ nonGoals: []
114
+ ---
115
+
116
+ # RFC-9002: Test RFC without liveSpec
117
+
118
+ ## Design
119
+
120
+ ### Something
121
+
122
+ Content.
123
+ `;
124
+
125
+ const SAMPLE_RFC_REJECTED = `---
126
+ id: RFC-9003
127
+ title: "Rejected RFC with liveSpec"
128
+ status: rejected
129
+ kind: architecture
130
+ scope: workspace
131
+ owners:
132
+ - architecture
133
+ reviewers:
134
+ - human:test
135
+ createdAt: 2026-08-06
136
+ updatedAt: 2026-08-06
137
+ versionBump: patch
138
+ liveSpec: true
139
+ packagesImpacted:
140
+ - packages/forge
141
+ commands:
142
+ proposed: []
143
+ added: []
144
+ changed: []
145
+ removed: []
146
+ appsImpacted: []
147
+ successSignals: []
148
+ nonGoals: []
149
+ ---
150
+
151
+ # RFC-9003: Rejected RFC with liveSpec
152
+
153
+ ## Design
154
+
155
+ ### Rejected heading
156
+
157
+ Content.
158
+ `;
159
+
160
+ async function setupWorkspace(): Promise<string> {
161
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "spec-live-test-"));
162
+ const rfcDir = path.join(tmpDir, "docs/rfcs");
163
+ await fs.mkdir(rfcDir, { recursive: true });
164
+ await fs.writeFile(path.join(rfcDir, "rfc-9001-test-rfc-for-living-specs.md"), SAMPLE_RFC);
165
+ await fs.writeFile(path.join(rfcDir, "rfc-9002-test-rfc-without-livespec.md"), SAMPLE_RFC_NO_LIVE_SPEC);
166
+ await fs.writeFile(path.join(rfcDir, "rfc-9003-rejected-rfc-with-livespec.md"), SAMPLE_RFC_REJECTED);
167
+ return tmpDir;
168
+ }
169
+
170
+ async function cleanupWorkspace(tmpDir: string): Promise<void> {
171
+ await fs.rm(tmpDir, { recursive: true, force: true });
172
+ }
173
+
174
+ describe("spec.live.merge", () => {
175
+ let tmpDir: string;
176
+
177
+ beforeEach(async () => {
178
+ tmpDir = await setupWorkspace();
179
+ });
180
+
181
+ afterEach(async () => {
182
+ await cleanupWorkspace(tmpDir);
183
+ });
184
+
185
+ it("creates a new living spec when none exists", async () => {
186
+ const input: ForgeCommandInput = { argv: [], flags: { id: "RFC-9001" } };
187
+ const result = await runSpecLiveMerge(input, makeContext(tmpDir));
188
+
189
+ expect(result.exitCode).toBe(0);
190
+ expect(result.data?.operation).toBe("created");
191
+ expect(result.data?.domain).toBe("forge");
192
+ expect(result.data?.deltas.length).toBeGreaterThan(0);
193
+ expect(result.data?.conflicts.length).toBe(0);
194
+
195
+ const specFile = path.join(tmpDir, "docs/specs/live/forge.md");
196
+ const content = await fs.readFile(specFile, "utf-8");
197
+ expect(content).toContain("domain: forge");
198
+ expect(content).toContain("lastMergedRfc: RFC-9001");
199
+ });
200
+
201
+ it("modifies an existing living spec", async () => {
202
+ // First merge creates the spec
203
+ const input1: ForgeCommandInput = { argv: [], flags: { id: "RFC-9001" } };
204
+ await runSpecLiveMerge(input1, makeContext(tmpDir));
205
+
206
+ // Second merge modifies it
207
+ const input2: ForgeCommandInput = { argv: [], flags: { id: "RFC-9001" } };
208
+ const result = await runSpecLiveMerge(input2, makeContext(tmpDir));
209
+
210
+ expect(result.exitCode).toBe(0);
211
+ expect(result.data?.operation).toBe("modified");
212
+ expect(result.data?.domain).toBe("forge");
213
+ });
214
+
215
+ it("skips RFCs without liveSpec field (no-op)", async () => {
216
+ const input: ForgeCommandInput = { argv: [], flags: { id: "RFC-9002" } };
217
+ const result = await runSpecLiveMerge(input, makeContext(tmpDir));
218
+
219
+ expect(result.exitCode).toBe(0);
220
+ expect(result.data?.deltas.length).toBe(0);
221
+ expect(result.data?.domain).toBe("");
222
+ });
223
+
224
+ it("rejects non-implemented RFCs", async () => {
225
+ const input: ForgeCommandInput = { argv: [], flags: { id: "RFC-9003" } };
226
+ const result = await runSpecLiveMerge(input, makeContext(tmpDir));
227
+
228
+ expect(result.exitCode).toBe(1);
229
+ expect(result.summary).toContain("rejected");
230
+ });
231
+
232
+ it("supports --dry-run without writing files", async () => {
233
+ const input: ForgeCommandInput = { argv: [], flags: { id: "RFC-9001", "dry-run": true } };
234
+ const ctx = makeContext(tmpDir);
235
+ ctx.dryRun = true;
236
+ const result = await runSpecLiveMerge(input, ctx);
237
+
238
+ expect(result.exitCode).toBe(0);
239
+ expect(result.data?.dryRun).toBe(true);
240
+ expect(result.data?.operation).toBe("created");
241
+
242
+ const specFile = path.join(tmpDir, "docs/specs/live/forge.md");
243
+ await expect(fs.access(specFile)).rejects.toThrow();
244
+ });
245
+
246
+ it("returns error when --id is missing", async () => {
247
+ const input: ForgeCommandInput = { argv: [], flags: {} };
248
+ const result = await runSpecLiveMerge(input, makeContext(tmpDir));
249
+
250
+ expect(result.exitCode).toBe(1);
251
+ expect(result.summary).toContain("--id");
252
+ });
253
+
254
+ it("returns error when RFC is not found", async () => {
255
+ const input: ForgeCommandInput = { argv: [], flags: { id: "RFC-9999" } };
256
+ const result = await runSpecLiveMerge(input, makeContext(tmpDir));
257
+
258
+ expect(result.exitCode).toBe(1);
259
+ expect(result.summary).toContain("not found");
260
+ });
261
+ });
@@ -0,0 +1,413 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>spec.live.merge handler — extracts deltas from an RFC's ## Design section,
4
+ classifies them as ADDED/MODIFIED/REMOVED, applies to a living spec, handles conflicts
5
+ with all-or-nothing semantics, and writes atomically (RFC-0711).</purpose>
6
+ <non-goals>
7
+ <item>Do not handle docs.archive integration — that is in core.module.ts.</item>
8
+ <item>Do not validate living specs — that is spec.live.validate.</item>
9
+ </non-goals>
10
+ </MODULE_CONTRACT>
11
+ <CHANGE_SUMMARY>
12
+ <item>RFC-0711: initial spec.live.merge handler with delta extraction, classification, conflict detection, and atomic writes.</item>
13
+ </CHANGE_SUMMARY>
14
+ */
15
+
16
+ import fs from "node:fs/promises";
17
+ import { existsSync } from "node:fs";
18
+ import path from "node:path";
19
+ import YAML from "yaml";
20
+ import type {
21
+ ForgeCommandInput,
22
+ ForgeCommandResult,
23
+ ForgeRuntimeContext,
24
+ } from "../../src/types.ts";
25
+ import { writeFileIfChanged, buildGeneratedHeader } from "../../src/utils/index.ts";
26
+ import { parseRfcFile, listRfcFiles } from "../rfc/frontmatter-io.ts";
27
+ import { RFC_DIR } from "../rfc/types.ts";
28
+ import type {
29
+ LivingSpec,
30
+ LivingSpecHistoryEntry,
31
+ DeltaOperation,
32
+ DeltaConflict,
33
+ SpecLiveMergeResult,
34
+ } from "./live-spec-types.ts";
35
+
36
+ const LIVE_SPECS_DIR = "docs/specs/live";
37
+
38
+ interface ParsedHeading {
39
+ level: number;
40
+ text: string;
41
+ body: string;
42
+ }
43
+
44
+ function extractDesignSection(rfcBody: string): string {
45
+ const designMatch = rfcBody.match(/^##\s+Design\s*$/m);
46
+ if (!designMatch) return "";
47
+ const startIndex = designMatch.index! + designMatch[0].length;
48
+ const nextH2Match = rfcBody.slice(startIndex).match(/^##\s+/m);
49
+ if (!nextH2Match) {
50
+ return rfcBody.slice(startIndex).trim();
51
+ }
52
+ return rfcBody.slice(startIndex, startIndex + nextH2Match.index!).trim();
53
+ }
54
+
55
+ function parseHeadings(content: string): ParsedHeading[] {
56
+ const lines = content.split("\n");
57
+ const headings: ParsedHeading[] = [];
58
+ let currentHeading: ParsedHeading | null = null;
59
+ let currentBody: string[] = [];
60
+
61
+ for (const line of lines) {
62
+ const headingMatch = line.match(/^(#{3,})\s+(.+)$/);
63
+ if (headingMatch) {
64
+ if (currentHeading) {
65
+ currentHeading.body = currentBody.join("\n").trim();
66
+ headings.push(currentHeading);
67
+ }
68
+ currentHeading = {
69
+ level: headingMatch[1].length,
70
+ text: headingMatch[2].trim(),
71
+ body: "",
72
+ };
73
+ currentBody = [];
74
+ } else if (currentHeading) {
75
+ currentBody.push(line);
76
+ }
77
+ }
78
+ if (currentHeading) {
79
+ currentHeading.body = currentBody.join("\n").trim();
80
+ headings.push(currentHeading);
81
+ }
82
+ return headings;
83
+ }
84
+
85
+ function deriveDomain(frontmatter: Record<string, unknown>): string | null {
86
+ const liveSpec = frontmatter["liveSpec"];
87
+ if (typeof liveSpec === "string" && liveSpec.length > 0) {
88
+ return liveSpec;
89
+ }
90
+ if (liveSpec === true) {
91
+ const packagesImpacted = frontmatter["packagesImpacted"];
92
+ if (Array.isArray(packagesImpacted) && packagesImpacted.length > 0) {
93
+ const firstPkg = String(packagesImpacted[0]);
94
+ return firstPkg.replace(/^packages\//, "");
95
+ }
96
+ return null;
97
+ }
98
+ return null;
99
+ }
100
+
101
+ function parseLivingSpec(content: string): LivingSpec | null {
102
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
103
+ if (!match) return null;
104
+ const fm = (YAML.parse(match[1]!) ?? {}) as Record<string, unknown>;
105
+ return {
106
+ domain: String(fm["domain"] ?? ""),
107
+ title: String(fm["title"] ?? ""),
108
+ lastMergedRfc: String(fm["lastMergedRfc"] ?? ""),
109
+ updatedAt: String(fm["updatedAt"] ?? ""),
110
+ createdAt: String(fm["createdAt"] ?? ""),
111
+ history: (Array.isArray(fm["history"]) ? fm["history"] : []) as LivingSpecHistoryEntry[],
112
+ body: match[2] ?? "",
113
+ };
114
+ }
115
+
116
+ function serializeLivingSpec(spec: LivingSpec): string {
117
+ const fm: Record<string, unknown> = {
118
+ domain: spec.domain,
119
+ title: spec.title,
120
+ lastMergedRfc: spec.lastMergedRfc,
121
+ updatedAt: spec.updatedAt,
122
+ createdAt: spec.createdAt,
123
+ history: spec.history,
124
+ };
125
+ const fmStr = YAML.stringify(fm).trimEnd();
126
+ return `---\n${fmStr}\n---\n\n${spec.body}`;
127
+ }
128
+
129
+ function findHeadingInSpec(spec: LivingSpec, headingText: string): ParsedHeading | null {
130
+ const headings = parseHeadings(spec.body);
131
+ return headings.find((h) => h.text === headingText) ?? null;
132
+ }
133
+
134
+ function applyDeltasToSpecBody(
135
+ specBody: string,
136
+ deltas: ParsedHeading[],
137
+ operations: DeltaOperation[],
138
+ ): string {
139
+ let body = specBody;
140
+ for (const delta of deltas) {
141
+ const op = operations.find((o) => o.heading === delta.text);
142
+ if (!op) continue;
143
+
144
+ if (op.type === "added") {
145
+ body = `${body}\n\n${"#".repeat(delta.level)} ${delta.text}\n\n${delta.body}`;
146
+ } else if (op.type === "modified") {
147
+ const headingRegex = new RegExp(
148
+ `(${"#".repeat(delta.level)}\\s+${escapeRegex(delta.text)}\\s*\\n)([\\s\\S]*?)(?=#{3,}|$)`,
149
+ );
150
+ body = body.replace(headingRegex, `$1\n${delta.body}\n`);
151
+ } else if (op.type === "removed") {
152
+ const headingRegex = new RegExp(
153
+ `\\n*${"#".repeat(delta.level)}\\s+${escapeRegex(delta.text)}\\s*\\n[\\s\\S]*?(?=#{3,}|$)`,
154
+ );
155
+ body = body.replace(headingRegex, "\n");
156
+ }
157
+ }
158
+ return body.trim();
159
+ }
160
+
161
+ function escapeRegex(str: string): string {
162
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
163
+ }
164
+
165
+ function findRfcFile(rfcDir: string, rfcId: string): Promise<string | null> {
166
+ return (async () => {
167
+ const files = await listRfcFiles(rfcDir);
168
+ for (const file of files) {
169
+ const basename = path.basename(file);
170
+ if (basename.toLowerCase().startsWith(rfcId.toLowerCase())) {
171
+ return file;
172
+ }
173
+ }
174
+ return null;
175
+ })();
176
+ }
177
+
178
+ export async function runSpecLiveMerge(
179
+ input: ForgeCommandInput,
180
+ context: ForgeRuntimeContext,
181
+ ): Promise<ForgeCommandResult<SpecLiveMergeResult>> {
182
+ const { workspaceRoot, logger, outputFormat } = context;
183
+ const rfcId = String(input.flags["id"] ?? "");
184
+ const dryRun = context.dryRun || input.flags["dry-run"] === true;
185
+
186
+ if (!rfcId) {
187
+ return {
188
+ data: {
189
+ command: "spec.live.merge",
190
+ domain: "",
191
+ operation: "modified",
192
+ deltas: [],
193
+ conflicts: [],
194
+ dryRun,
195
+ },
196
+ exitCode: 1,
197
+ summary: "spec.live.merge: --id flag is required",
198
+ };
199
+ }
200
+
201
+ const rfcDir = path.join(workspaceRoot, RFC_DIR);
202
+ const rfcFile = await findRfcFile(rfcDir, rfcId);
203
+
204
+ if (!rfcFile) {
205
+ return {
206
+ data: {
207
+ command: "spec.live.merge",
208
+ domain: "",
209
+ operation: "modified",
210
+ deltas: [],
211
+ conflicts: [],
212
+ dryRun,
213
+ },
214
+ exitCode: 1,
215
+ summary: `spec.live.merge: RFC ${rfcId} not found in ${RFC_DIR}`,
216
+ };
217
+ }
218
+
219
+ const rfcContent = await fs.readFile(path.join(rfcDir, rfcFile), "utf-8");
220
+ const parsed = parseRfcFile(rfcContent);
221
+ const fm = parsed.frontmatter;
222
+
223
+ const status = String(fm["status"] ?? "").trim();
224
+ if (status !== "implemented") {
225
+ return {
226
+ data: {
227
+ command: "spec.live.merge",
228
+ domain: "",
229
+ operation: "modified",
230
+ deltas: [],
231
+ conflicts: [],
232
+ dryRun,
233
+ },
234
+ exitCode: 1,
235
+ summary: `spec.live.merge: RFC ${rfcId} has status "${status}", must be "implemented"`,
236
+ };
237
+ }
238
+
239
+ const domain = deriveDomain(fm);
240
+ if (!domain) {
241
+ return {
242
+ data: {
243
+ command: "spec.live.merge",
244
+ domain: "",
245
+ operation: "modified",
246
+ deltas: [],
247
+ conflicts: [],
248
+ dryRun,
249
+ },
250
+ exitCode: 0,
251
+ summary: `spec.live.merge: RFC ${rfcId} has no liveSpec field — skipping (no-op)`,
252
+ };
253
+ }
254
+
255
+ const designSection = extractDesignSection(parsed.body);
256
+ if (!designSection) {
257
+ return {
258
+ data: {
259
+ command: "spec.live.merge",
260
+ domain,
261
+ operation: "modified",
262
+ deltas: [],
263
+ conflicts: [],
264
+ dryRun,
265
+ },
266
+ exitCode: 0,
267
+ summary: `spec.live.merge: RFC ${rfcId} has no ## Design section — skipping (no-op)`,
268
+ };
269
+ }
270
+
271
+ const rfcHeadings = parseHeadings(designSection);
272
+ const liveSpecsDir = path.join(workspaceRoot, LIVE_SPECS_DIR);
273
+ const specFilePath = path.join(liveSpecsDir, `${domain}.md`);
274
+
275
+ let existingSpec: LivingSpec | null = null;
276
+ if (existsSync(specFilePath)) {
277
+ const specContent = await fs.readFile(specFilePath, "utf-8");
278
+ existingSpec = parseLivingSpec(specContent);
279
+ }
280
+
281
+ const operations: DeltaOperation[] = [];
282
+ const conflicts: DeltaConflict[] = [];
283
+ const today = new Date().toISOString().slice(0, 10);
284
+
285
+ if (!existingSpec) {
286
+ for (const heading of rfcHeadings) {
287
+ operations.push({ type: "added", heading: heading.text, rfc: rfcId });
288
+ }
289
+
290
+ const header = buildGeneratedHeader({
291
+ filePath: specFilePath,
292
+ ownerCommand: "spec.live.merge",
293
+ commandPrefix: "pnpm exec werkstatt run",
294
+ });
295
+
296
+ const newSpec: LivingSpec = {
297
+ domain,
298
+ title: `Living Spec: ${domain}`,
299
+ lastMergedRfc: rfcId,
300
+ updatedAt: today,
301
+ createdAt: today,
302
+ history: [{ rfc: rfcId, mergedAt: today, operation: "created" }],
303
+ body: `${header}# Living Spec: ${domain}\n\n## Overview\n\n${designSection}`,
304
+ };
305
+
306
+ if (!dryRun) {
307
+ await fs.mkdir(liveSpecsDir, { recursive: true });
308
+ await writeFileIfChanged(specFilePath, serializeLivingSpec(newSpec));
309
+ }
310
+
311
+ const result: SpecLiveMergeResult = {
312
+ command: "spec.live.merge",
313
+ domain,
314
+ operation: "created",
315
+ deltas: operations,
316
+ conflicts: [],
317
+ dryRun,
318
+ };
319
+
320
+ if (outputFormat === "pretty") {
321
+ logger.success(
322
+ `spec.live.merge: created living spec for domain "${domain}" from ${rfcId} (${operations.length} deltas)${dryRun ? " [dry-run]" : ""}`,
323
+ );
324
+ }
325
+
326
+ return { data: result, exitCode: 0, summary: `spec.live.merge: created ${domain} from ${rfcId}` };
327
+ }
328
+
329
+ for (const heading of rfcHeadings) {
330
+ const existing = findHeadingInSpec(existingSpec, heading.text);
331
+ if (existing) {
332
+ let lastRfc: string | undefined;
333
+ for (let i = existingSpec.history.length - 1; i >= 0; i--) {
334
+ const entry = existingSpec.history[i]!;
335
+ if (entry.operation !== "removed") {
336
+ lastRfc = entry.rfc;
337
+ break;
338
+ }
339
+ }
340
+ if (lastRfc && lastRfc !== rfcId) {
341
+ conflicts.push({
342
+ heading: heading.text,
343
+ existingRfc: lastRfc,
344
+ newRfc: rfcId,
345
+ resolution: "pending",
346
+ });
347
+ }
348
+ operations.push({ type: "modified", heading: heading.text, rfc: rfcId });
349
+ } else {
350
+ operations.push({ type: "added", heading: heading.text, rfc: rfcId });
351
+ }
352
+ }
353
+
354
+ if (conflicts.length > 0) {
355
+ const result: SpecLiveMergeResult = {
356
+ command: "spec.live.merge",
357
+ domain,
358
+ operation: "modified",
359
+ deltas: operations,
360
+ conflicts,
361
+ dryRun,
362
+ };
363
+
364
+ if (outputFormat === "pretty") {
365
+ logger.error(
366
+ `spec.live.merge: ${conflicts.length} conflict(s) detected — merge aborted (all-or-nothing)`,
367
+ );
368
+ for (const c of conflicts) {
369
+ logger.error(` conflict: heading "${c.heading}" last modified by ${c.existingRfc}, now ${c.newRfc}`);
370
+ }
371
+ }
372
+
373
+ return {
374
+ data: result,
375
+ exitCode: 1,
376
+ summary: `spec.live.merge: ${conflicts.length} conflict(s) — merge aborted`,
377
+ };
378
+ }
379
+
380
+ const updatedBody = applyDeltasToSpecBody(existingSpec.body, rfcHeadings, operations);
381
+
382
+ const updatedSpec: LivingSpec = {
383
+ ...existingSpec,
384
+ lastMergedRfc: rfcId,
385
+ updatedAt: today,
386
+ history: [
387
+ ...existingSpec.history,
388
+ { rfc: rfcId, mergedAt: today, operation: "modified" },
389
+ ],
390
+ body: updatedBody,
391
+ };
392
+
393
+ if (!dryRun) {
394
+ await writeFileIfChanged(specFilePath, serializeLivingSpec(updatedSpec));
395
+ }
396
+
397
+ const result: SpecLiveMergeResult = {
398
+ command: "spec.live.merge",
399
+ domain,
400
+ operation: "modified",
401
+ deltas: operations,
402
+ conflicts: [],
403
+ dryRun,
404
+ };
405
+
406
+ if (outputFormat === "pretty") {
407
+ logger.success(
408
+ `spec.live.merge: modified living spec for domain "${domain}" from ${rfcId} (${operations.length} deltas)${dryRun ? " [dry-run]" : ""}`,
409
+ );
410
+ }
411
+
412
+ return { data: result, exitCode: 0, summary: `spec.live.merge: modified ${domain} from ${rfcId}` };
413
+ }