@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,513 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>spec.validate handler — validates vendored spec packages under docs/specs/ (RFC-0394).</purpose>
4
+ <non-goals>
5
+ <item>Do not modify spec files — validate is read-only.</item>
6
+ <item>Do not implement materialization — that is RFC-0396.</item>
7
+ <item>Do not implement amendment validation — that is RFC-0397.</item>
8
+ </non-goals>
9
+ </MODULE_CONTRACT>
10
+ <CHANGE_SUMMARY>
11
+ <item>RFC-0394: initial spec.validate handler with SPEC-01..07 rules.</item>
12
+ </CHANGE_SUMMARY>
13
+ */
14
+
15
+ import fs from "node:fs/promises";
16
+ import { existsSync } from "node:fs";
17
+ import path from "node:path";
18
+ import { parse as parseYaml } from "yaml";
19
+ import { byteHash } from "../../src/utils/hash.ts";
20
+ import { collectFiles } from "../../src/utils/fs.ts";
21
+ import { loadForgeConfig } from "../../src/config/forge-config.ts";
22
+ import type {
23
+ ForgeCommandInput,
24
+ ForgeCommandResult,
25
+ ForgeRuntimeContext,
26
+ } from "../../src/types.ts";
27
+ import {
28
+ forgeSpecSchema,
29
+ specIntegritySchema,
30
+ specAmendmentSchema,
31
+ type ForgeSpec,
32
+ type SpecIntegrity,
33
+ type SpecAmendment,
34
+ type SpecViolation,
35
+ type SpecValidateResult,
36
+ } from "./spec-schema.ts";
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Helpers
40
+ // ---------------------------------------------------------------------------
41
+
42
+ async function readYaml<T>(filePath: string): Promise<T | null> {
43
+ try {
44
+ const content = await fs.readFile(filePath, "utf8");
45
+ return parseYaml(content) as T;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ async function listSpecDirs(specsDir: string): Promise<string[]> {
52
+ if (!existsSync(specsDir)) return [];
53
+ const entries = await fs.readdir(specsDir, { withFileTypes: true });
54
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
55
+ }
56
+
57
+ async function listSnapshotFiles(
58
+ specDir: string,
59
+ exclude: Set<string>,
60
+ ): Promise<string[]> {
61
+ const files = await collectFiles(specDir, {
62
+ ignore: (name) => exclude.has(name),
63
+ });
64
+ return files.map((abs) => path.relative(specDir, abs).replace(/\\/g, "/"));
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // SPEC-01: Integrity check
69
+ // ---------------------------------------------------------------------------
70
+
71
+ async function checkIntegrity(
72
+ specDir: string,
73
+ violations: SpecViolation[],
74
+ ): Promise<void> {
75
+ const integrityPath = path.join(specDir, "integrity.yaml");
76
+ const integrity = await readYaml<SpecIntegrity>(integrityPath);
77
+ if (!integrity) {
78
+ violations.push({ rule: "SPEC-01", message: "integrity.yaml not found" });
79
+ return;
80
+ }
81
+
82
+ const parsed = specIntegritySchema.safeParse(integrity);
83
+ if (!parsed.success) {
84
+ violations.push({
85
+ rule: "SPEC-01",
86
+ message: `integrity.yaml schema violation: ${parsed.error.message}`,
87
+ });
88
+ return;
89
+ }
90
+
91
+ const manifest = parsed.data;
92
+ const exclude = new Set(["forge-spec.yaml", "integrity.yaml", "amendments"]);
93
+
94
+ for (const [relPath, expectedHash] of Object.entries(manifest.files)) {
95
+ const fullPath = path.join(specDir, relPath);
96
+ try {
97
+ const content = await fs.readFile(fullPath);
98
+ const actualHash = byteHash(content);
99
+ if (actualHash !== expectedHash) {
100
+ violations.push({
101
+ rule: "SPEC-01",
102
+ message: `integrity mismatch: ${relPath}`,
103
+ });
104
+ }
105
+ } catch {
106
+ violations.push({
107
+ rule: "SPEC-01",
108
+ message: `file in manifest not found: ${relPath}`,
109
+ });
110
+ }
111
+ }
112
+
113
+ // Check for files in snapshot not in manifest
114
+ const snapshotFiles = await listSnapshotFiles(specDir, exclude);
115
+ for (const file of snapshotFiles) {
116
+ if (!(file in manifest.files)) {
117
+ violations.push({
118
+ rule: "SPEC-01",
119
+ message: `file not in integrity manifest: ${file}`,
120
+ });
121
+ }
122
+ }
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // SPEC-02: Schema validation
127
+ // ---------------------------------------------------------------------------
128
+
129
+ function checkSchema(
130
+ raw: unknown,
131
+ violations: SpecViolation[],
132
+ ): ForgeSpec | null {
133
+ const parsed = forgeSpecSchema.safeParse(raw);
134
+ if (!parsed.success) {
135
+ violations.push({
136
+ rule: "SPEC-02",
137
+ message: `forge-spec.yaml schema violation: ${parsed.error.issues.map((i) => i.message).join("; ")}`,
138
+ });
139
+ return null;
140
+ }
141
+ return parsed.data;
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // SPEC-03: Dependency cycle detection
146
+ // ---------------------------------------------------------------------------
147
+
148
+ function checkCycles(spec: ForgeSpec, violations: SpecViolation[]): void {
149
+ const nodes = new Map<string, string[]>();
150
+ for (const node of spec.rfcs) {
151
+ nodes.set(node.id, node.dependsOn);
152
+ }
153
+
154
+ const visited = new Set<string>();
155
+ const stack = new Set<string>();
156
+
157
+ function dfs(id: string, path: string[]): boolean {
158
+ if (stack.has(id)) {
159
+ const cycle = [...path, id].join(" -> ");
160
+ violations.push({
161
+ rule: "SPEC-03",
162
+ message: `dependency cycle: ${cycle}`,
163
+ });
164
+ return true;
165
+ }
166
+ if (visited.has(id)) return false;
167
+ visited.add(id);
168
+ stack.add(id);
169
+ const deps = nodes.get(id) ?? [];
170
+ for (const dep of deps) {
171
+ if (dfs(dep, [...path, id])) return true;
172
+ }
173
+ stack.delete(id);
174
+ return false;
175
+ }
176
+
177
+ for (const node of spec.rfcs) {
178
+ if (!visited.has(node.id)) {
179
+ dfs(node.id, []);
180
+ }
181
+ }
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // SPEC-04: Unresolvable references
186
+ // ---------------------------------------------------------------------------
187
+
188
+ function checkReferences(spec: ForgeSpec, violations: SpecViolation[]): void {
189
+ const nodeIds = new Set(spec.rfcs.map((n) => n.id));
190
+ const docNames = new Set(Object.keys(spec.documents));
191
+
192
+ for (const node of spec.rfcs) {
193
+ for (const dep of node.dependsOn) {
194
+ if (!nodeIds.has(dep)) {
195
+ violations.push({
196
+ rule: "SPEC-04",
197
+ message: `dependsOn reference does not resolve: ${dep} (in node ${node.id})`,
198
+ });
199
+ }
200
+ }
201
+ for (const source of node.sources) {
202
+ const docName = source.split("#")[0]!;
203
+ if (!docNames.has(docName)) {
204
+ violations.push({
205
+ rule: "SPEC-04",
206
+ message: `source reference does not resolve: ${source} (in node ${node.id})`,
207
+ });
208
+ }
209
+ }
210
+ }
211
+ }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // SPEC-05: Roadmap node without a wave
215
+ // ---------------------------------------------------------------------------
216
+
217
+ function checkWaveCoverage(spec: ForgeSpec, violations: SpecViolation[]): void {
218
+ const waveIds = new Set(spec.waves.map((w) => w.id));
219
+ for (const node of spec.rfcs) {
220
+ if (!waveIds.has(node.wave)) {
221
+ violations.push({
222
+ rule: "SPEC-05",
223
+ message: `node ${node.id} references wave ${node.wave} which does not exist`,
224
+ });
225
+ }
226
+ }
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // SPEC-06: Duplicate node or decision id
231
+ // ---------------------------------------------------------------------------
232
+
233
+ function checkDuplicates(spec: ForgeSpec, violations: SpecViolation[]): void {
234
+ const nodeIds = new Set<string>();
235
+ for (const node of spec.rfcs) {
236
+ if (nodeIds.has(node.id)) {
237
+ violations.push({
238
+ rule: "SPEC-06",
239
+ message: `duplicate node id: ${node.id}`,
240
+ });
241
+ }
242
+ nodeIds.add(node.id);
243
+ }
244
+
245
+ const decisionIds = new Set<string>();
246
+ for (const decision of spec.decisions) {
247
+ if (decisionIds.has(decision.id)) {
248
+ violations.push({
249
+ rule: "SPEC-06",
250
+ message: `duplicate decision id: ${decision.id}`,
251
+ });
252
+ }
253
+ decisionIds.add(decision.id);
254
+ }
255
+ }
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // SPEC-07: materializedAs points to missing RFC file
259
+ // ---------------------------------------------------------------------------
260
+
261
+ async function checkMaterializedAs(
262
+ spec: ForgeSpec,
263
+ workspaceRoot: string,
264
+ rfcDir: string,
265
+ violations: SpecViolation[],
266
+ ): Promise<void> {
267
+ for (const node of spec.rfcs) {
268
+ if (!node.materializedAs) continue;
269
+ const rfcId = node.materializedAs;
270
+ const rfcFiles = await fs.readdir(rfcDir).catch(() => []);
271
+ const found = rfcFiles.some((f) => f.startsWith(rfcId.toLowerCase().replace(/^RFC-/, "rfc-")));
272
+ if (!found) {
273
+ violations.push({
274
+ rule: "SPEC-07",
275
+ message: `materializedAs ${rfcId} for node ${node.id} not found in ${rfcDir}`,
276
+ });
277
+ }
278
+ }
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // SPEC-08..11: Amendment validation (RFC-0397)
283
+ // ---------------------------------------------------------------------------
284
+
285
+ async function loadAmendments(specDir: string): Promise<SpecAmendment[]> {
286
+ const amendmentsDir = path.join(specDir, "amendments");
287
+ if (!existsSync(amendmentsDir)) return [];
288
+
289
+ const entries = await fs.readdir(amendmentsDir);
290
+ const amendments: SpecAmendment[] = [];
291
+
292
+ for (const entry of entries) {
293
+ if (!entry.endsWith(".md")) continue;
294
+ try {
295
+ const content = await fs.readFile(path.join(amendmentsDir, entry), "utf8");
296
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
297
+ if (!fmMatch) continue;
298
+ const raw = parseYaml(fmMatch[1]) as unknown;
299
+ const parsed = specAmendmentSchema.safeParse(raw);
300
+ if (parsed.success) {
301
+ amendments.push(parsed.data);
302
+ }
303
+ } catch {
304
+ // skip unreadable files
305
+ }
306
+ }
307
+
308
+ return amendments;
309
+ }
310
+
311
+ function checkAmendments(
312
+ spec: ForgeSpec,
313
+ amendments: SpecAmendment[],
314
+ violations: SpecViolation[],
315
+ ): void {
316
+ const nodeIds = new Set(spec.rfcs.map((n) => n.id));
317
+ const decisionIds = new Set(spec.decisions.map((d) => d.id));
318
+ const docNames = new Set(Object.keys(spec.documents));
319
+
320
+ const seenIds = new Set<string>();
321
+
322
+ for (const amendment of amendments) {
323
+ // SPEC-08: schema/id violations
324
+ if (seenIds.has(amendment.id)) {
325
+ violations.push({
326
+ rule: "SPEC-08",
327
+ message: `duplicate amendment id: ${amendment.id}`,
328
+ });
329
+ }
330
+ seenIds.add(amendment.id);
331
+
332
+ // SPEC-09: unresolvable targets
333
+ for (const target of amendment.targets) {
334
+ if (target.kind === "section") {
335
+ if (!docNames.has(target.document)) {
336
+ violations.push({
337
+ rule: "SPEC-09",
338
+ message: `amendment ${amendment.id} targets document not found: ${target.document}`,
339
+ });
340
+ }
341
+ } else if (target.kind === "decision") {
342
+ if (!decisionIds.has(target.id)) {
343
+ violations.push({
344
+ rule: "SPEC-09",
345
+ message: `amendment ${amendment.id} targets decision not found: ${target.id}`,
346
+ });
347
+ }
348
+ } else if (target.kind === "node") {
349
+ if (!nodeIds.has(target.id)) {
350
+ violations.push({
351
+ rule: "SPEC-09",
352
+ message: `amendment ${amendment.id} targets node not found: ${target.id}`,
353
+ });
354
+ }
355
+ }
356
+ }
357
+
358
+ // SPEC-11: accepted amendment without reviewers
359
+ if (amendment.status === "accepted" && amendment.reviewers.length === 0) {
360
+ violations.push({
361
+ rule: "SPEC-11",
362
+ message: `accepted amendment ${amendment.id} has no reviewers`,
363
+ });
364
+ }
365
+ }
366
+
367
+ // SPEC-10: two accepted amendments target the same anchor with conflicting Becomes
368
+ const acceptedAmendments = amendments.filter((a) => a.status === "accepted");
369
+ const targetCounts = new Map<string, number>();
370
+ for (const amendment of acceptedAmendments) {
371
+ for (const target of amendment.targets) {
372
+ const key = target.kind === "section"
373
+ ? `section:${target.document}:${target.anchor}`
374
+ : target.kind === "decision"
375
+ ? `decision:${target.id}`
376
+ : `node:${target.id}`;
377
+ targetCounts.set(key, (targetCounts.get(key) ?? 0) + 1);
378
+ }
379
+ }
380
+ for (const [key, count] of targetCounts) {
381
+ if (count > 1) {
382
+ violations.push({
383
+ rule: "SPEC-10",
384
+ message: `conflicting accepted amendments targeting ${key} — manual review required`,
385
+ });
386
+ }
387
+ }
388
+ }
389
+
390
+ // ---------------------------------------------------------------------------
391
+ // Main handler
392
+ // ---------------------------------------------------------------------------
393
+
394
+ export async function runSpecValidate(
395
+ input: ForgeCommandInput,
396
+ context: ForgeRuntimeContext,
397
+ ): Promise<ForgeCommandResult<SpecValidateResult>> {
398
+ const { workspaceRoot, logger, outputFormat } = context;
399
+
400
+ // Resolve specs directory from forge.yaml
401
+ let specsDir = "docs/specs";
402
+ try {
403
+ const config = loadForgeConfig(workspaceRoot);
404
+ specsDir = config.paths.specsDir;
405
+ } catch {
406
+ // Use default
407
+ }
408
+
409
+ const specsRoot = path.join(workspaceRoot, specsDir);
410
+ const filterSpec = input.flags["spec"] as string | undefined;
411
+
412
+ let specDirs = await listSpecDirs(specsRoot);
413
+ if (filterSpec) {
414
+ if (!specDirs.includes(filterSpec)) {
415
+ return {
416
+ data: {
417
+ command: "spec.validate",
418
+ status: "fail",
419
+ specs: [],
420
+ },
421
+ exitCode: 1,
422
+ summary: `spec.validate: spec '${filterSpec}' not found. Available: ${specDirs.join(", ") || "(none)"}`,
423
+ };
424
+ }
425
+ specDirs = [filterSpec];
426
+ }
427
+
428
+ const rfcDir = path.join(workspaceRoot, "docs", "rfcs");
429
+ const results: SpecValidateResult["specs"] = [];
430
+
431
+ for (const specId of specDirs) {
432
+ const specDir = path.join(specsRoot, specId);
433
+ const violations: SpecViolation[] = [];
434
+
435
+ // Load forge-spec.yaml
436
+ const forgeSpecPath = path.join(specDir, "forge-spec.yaml");
437
+ const rawSpec = await readYaml<unknown>(forgeSpecPath);
438
+ if (!rawSpec) {
439
+ results.push({
440
+ id: specId,
441
+ status: "unknown",
442
+ violations: [{ rule: "SPEC-02", message: "forge-spec.yaml not found" }],
443
+ });
444
+ continue;
445
+ }
446
+
447
+ // SPEC-02: Schema
448
+ const spec = checkSchema(rawSpec, violations);
449
+ if (!spec) {
450
+ results.push({ id: specId, status: "unknown", violations });
451
+ continue;
452
+ }
453
+
454
+ // SPEC-01: Integrity
455
+ await checkIntegrity(specDir, violations);
456
+
457
+ // SPEC-03: Cycles
458
+ checkCycles(spec, violations);
459
+
460
+ // SPEC-04: References
461
+ checkReferences(spec, violations);
462
+
463
+ // SPEC-05: Wave coverage
464
+ checkWaveCoverage(spec, violations);
465
+
466
+ // SPEC-06: Duplicates
467
+ checkDuplicates(spec, violations);
468
+
469
+ // SPEC-07: materializedAs
470
+ await checkMaterializedAs(spec, workspaceRoot, rfcDir, violations);
471
+
472
+ // SPEC-08..11: Amendments (RFC-0397)
473
+ const amendments = await loadAmendments(specDir);
474
+ checkAmendments(spec, amendments, violations);
475
+
476
+ results.push({
477
+ id: spec.id,
478
+ status: spec.status,
479
+ violations,
480
+ });
481
+ }
482
+
483
+ const hasFailures = results.some((r) => r.violations.length > 0);
484
+
485
+ if (outputFormat === "pretty") {
486
+ if (results.length === 0) {
487
+ logger.info("No vendored specs found.");
488
+ } else {
489
+ for (const result of results) {
490
+ if (result.violations.length === 0) {
491
+ logger.success(`spec.validate: ${result.id} — pass`);
492
+ } else {
493
+ logger.error(`spec.validate: ${result.id} — ${result.violations.length} violation(s)`);
494
+ for (const v of result.violations) {
495
+ logger.error(` ${v.rule}: ${v.message}`);
496
+ }
497
+ }
498
+ }
499
+ }
500
+ }
501
+
502
+ return {
503
+ data: {
504
+ command: "spec.validate",
505
+ status: hasFailures ? "fail" : "pass",
506
+ specs: results,
507
+ },
508
+ exitCode: hasFailures ? 1 : 0,
509
+ summary: hasFailures
510
+ ? `spec.validate: ${results.filter((r) => r.violations.length > 0).length} spec(s) with violations`
511
+ : `spec.validate: all ${results.length} spec(s) pass`,
512
+ };
513
+ }
@@ -0,0 +1,135 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Register the forge spec module — spec.validate (RFC-0394), spec.status + spec.materialize (RFC-0396), spec.live.merge/list/show/validate (RFC-0711).</purpose>
4
+ <non-goals>
5
+ <item>Do not implement skill logic — skills live in skills/.</item>
6
+ </non-goals>
7
+ </MODULE_CONTRACT>
8
+ <CHANGE_SUMMARY>
9
+ <item>RFC-0394: initial forgeSpecModule registering spec.validate.</item>
10
+ <item>RFC-0396: added spec.status and spec.materialize commands.</item>
11
+ <item>RFC-0711: added spec.live.merge, spec.live.list, spec.live.show, spec.live.validate commands.</item>
12
+ </CHANGE_SUMMARY>
13
+ */
14
+
15
+ import type { ForgeModule } from "../../src/forge-module.ts";
16
+
17
+ export const forgeSpecModule: ForgeModule = {
18
+ name: "forge-spec",
19
+ version: "0.2.0",
20
+ async register(registry) {
21
+ const { runSpecValidate } = await import("./spec-validate.ts");
22
+ const { runSpecStatus } = await import("./spec-status.ts");
23
+ const { runSpecMaterialize } = await import("./spec-materialize.ts");
24
+ const { runSpecLiveMerge } = await import("./live-spec-merge.ts");
25
+ const { runSpecLiveList } = await import("./live-spec-list.ts");
26
+ const { runSpecLiveShow } = await import("./live-spec-show.ts");
27
+ const { runSpecLiveValidate } = await import("./live-spec-validate.ts");
28
+
29
+ registry.registerCommand({
30
+ name: "spec.validate",
31
+ description:
32
+ "Validate vendored spec packages under docs/specs/. " +
33
+ "Checks integrity (SHA-256), schema, dependency graph (acyclic), " +
34
+ "reference resolution, wave coverage, duplicate ids, and materializedAs links. " +
35
+ "Use --spec=<id> to validate a single spec.",
36
+ scope: "workspace",
37
+ flags: {
38
+ spec: {
39
+ kind: "string",
40
+ description: "Validate only the named spec.",
41
+ },
42
+ },
43
+ reads: ["docs/specs/**/*"],
44
+ execute: runSpecValidate,
45
+ });
46
+
47
+ registry.registerCommand({
48
+ name: "spec.status",
49
+ description:
50
+ "Show roadmap progress for vendored specs. " +
51
+ "Without --spec, summarizes all specs; with it, full per-node table + computed front.",
52
+ scope: "workspace",
53
+ flags: {
54
+ spec: {
55
+ kind: "string",
56
+ description: "Show status for a single spec.",
57
+ },
58
+ },
59
+ reads: ["docs/specs/**/*", "docs/rfcs/**/*.md"],
60
+ execute: runSpecStatus,
61
+ });
62
+
63
+ registry.registerCommand({
64
+ name: "spec.materialize",
65
+ description:
66
+ "Scaffold RFC files for the next N front nodes of a spec roadmap. " +
67
+ "Requires --spec=<id>. Optional: --next=<N> (default 8, max 12), --nodes=<id,id> explicit selection.",
68
+ scope: "workspace",
69
+ mutatesState: true,
70
+ writes: ["docs/rfcs/rfc-*.md", "docs/specs/*/forge-spec.yaml"],
71
+ reads: ["docs/specs/**/*", "docs/rfcs/**/*.md"],
72
+ flags: {
73
+ spec: { kind: "string", required: true, description: "Spec id to materialize from." },
74
+ next: { kind: "string", description: "Number of front nodes to materialize (default 8, max 12)." },
75
+ nodes: { kind: "string", description: "Comma-separated explicit node ids to materialize." },
76
+ },
77
+ execute: runSpecMaterialize,
78
+ });
79
+
80
+ registry.registerCommand({
81
+ name: "spec.live.merge",
82
+ description:
83
+ "Merge deltas from an implemented RFC's ## Design section into a living feature spec " +
84
+ "under docs/specs/live/<domain>.md. Requires --id=<RFC-XXXX>. " +
85
+ "Domain is auto-derived from packagesImpacted[0] when liveSpec: true, or uses " +
86
+ "the string value when liveSpec: <domain>. " +
87
+ "All-or-nothing: aborts on any heading conflict without writing. " +
88
+ "Use --dry-run to preview deltas without writing.",
89
+ scope: "workspace",
90
+ mutatesState: true,
91
+ writes: ["docs/specs/live/*.md"],
92
+ reads: ["docs/rfcs/**/*.md", "docs/specs/live/*.md"],
93
+ flags: {
94
+ id: { kind: "string", required: true, description: "RFC id to merge (e.g. RFC-0711)." },
95
+ "dry-run": { kind: "boolean", description: "Preview deltas without writing files." },
96
+ },
97
+ execute: runSpecLiveMerge,
98
+ });
99
+
100
+ registry.registerCommand({
101
+ name: "spec.live.list",
102
+ description:
103
+ "List all living feature specs in docs/specs/live/. " +
104
+ "Returns domain, title, lastMergedRfc, updatedAt, and historyCount for each spec.",
105
+ scope: "workspace",
106
+ reads: ["docs/specs/live/*.md"],
107
+ execute: runSpecLiveList,
108
+ });
109
+
110
+ registry.registerCommand({
111
+ name: "spec.live.show",
112
+ description:
113
+ "Show a single living feature spec by domain. " +
114
+ "Requires --domain=<name>. Returns full frontmatter and body content.",
115
+ scope: "workspace",
116
+ reads: ["docs/specs/live/*.md"],
117
+ flags: {
118
+ domain: { kind: "string", required: true, description: "Domain name (filename without .md)." },
119
+ },
120
+ execute: runSpecLiveShow,
121
+ });
122
+
123
+ registry.registerCommand({
124
+ name: "spec.live.validate",
125
+ description:
126
+ "Validate all living feature specs in docs/specs/live/. " +
127
+ "Checks V-LS-01 (frontmatter), V-LS-02 (domain/filename match), " +
128
+ "V-LS-03 (lastMergedRfc is archived), V-LS-04 (history entries are archived), " +
129
+ "V-LS-05 (no duplicate domains).",
130
+ scope: "workspace",
131
+ reads: ["docs/specs/live/*.md", "docs/rfcs/**/*.md"],
132
+ execute: runSpecLiveValidate,
133
+ });
134
+ },
135
+ };