@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,344 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>
4
+ RFC-0268: makes RFC acceptance criteria machine-checkable. RFCs may declare a
5
+ typed `acceptance:` list of probes in frontmatter; rfc.acceptance.run executes
6
+ them and reports pass/fail per probe, so an implementation claim is
7
+ reproducible by anyone with one command instead of trusting the implementer's
8
+ prose checklist.
9
+ </purpose>
10
+ <non-goals>
11
+ <item>Do not run automatically inside rfc.validate — probes may run builds; on-demand only.</item>
12
+ <item>Do not allow arbitrary shell in `run` probes — the command string must start with "werkstatt ".</item>
13
+ </non-goals>
14
+ </MODULE_CONTRACT>
15
+ <CHANGE_SUMMARY>
16
+ <item>RFC-0268: initial implementation.</item>
17
+ </CHANGE_SUMMARY>
18
+ */
19
+
20
+ import { spawn } from "node:child_process";
21
+ import { stat, readFile } from "node:fs/promises";
22
+ import path from "node:path";
23
+ import { parse as yamlParse } from "yaml";
24
+ import type {
25
+ Diagnostic,
26
+ ForgeCommandInput,
27
+ ForgeCommandResult,
28
+ ForgeRuntimeContext,
29
+ CommandRegistry,
30
+ } from "../../src/types.ts";
31
+ import type { AcceptanceProbe, ProbeResult, RfcAcceptanceRunResult } from "./types.ts";
32
+ import { RFC_DIR } from "./types.ts";
33
+ import { listRfcFiles, readAndParseRfc } from "./frontmatter-io.ts";
34
+
35
+ const RUN_PROBE_ALLOWED_PREFIX = "werkstatt ";
36
+
37
+ async function loadManifestCommandNames(workspaceRoot: string): Promise<Set<string>> {
38
+ const manifestPath = path.join(workspaceRoot, "docs", "command-manifest.generated.yaml");
39
+ try {
40
+ const raw = await readFile(manifestPath, "utf-8");
41
+ const manifest = yamlParse(raw) as { commands?: Array<{ name: string }> };
42
+ return new Set((manifest.commands ?? []).map((c) => c.name));
43
+ } catch {
44
+ return new Set();
45
+ }
46
+ }
47
+ const RUN_PROBE_TIMEOUT_MS = 120_000;
48
+
49
+ export interface AcceptanceShapeIssue {
50
+ index: number;
51
+ message: string;
52
+ }
53
+
54
+ /**
55
+ * Pure shape validator for a raw `acceptance:` frontmatter value. Returns an
56
+ * empty array when the value is absent (acceptance is optional) or well-formed.
57
+ */
58
+ export function validateAcceptanceShape(value: unknown): AcceptanceShapeIssue[] {
59
+ if (value === undefined) return [];
60
+ if (!Array.isArray(value)) {
61
+ return [{ index: -1, message: "acceptance must be a list of probe objects" }];
62
+ }
63
+
64
+ const issues: AcceptanceShapeIssue[] = [];
65
+ value.forEach((entry, index) => {
66
+ if (!entry || typeof entry !== "object") {
67
+ issues.push({ index, message: "probe entry must be an object" });
68
+ return;
69
+ }
70
+ const probe = (entry as Record<string, unknown>)["probe"];
71
+ switch (probe) {
72
+ case "run": {
73
+ const command = (entry as Record<string, unknown>)["command"];
74
+ const expect = (entry as Record<string, unknown>)["expect"];
75
+ if (typeof command !== "string" || !command.startsWith(RUN_PROBE_ALLOWED_PREFIX)) {
76
+ issues.push({
77
+ index,
78
+ message: `probe "run" command must be a string starting with "${RUN_PROBE_ALLOWED_PREFIX}"`,
79
+ });
80
+ }
81
+ if (
82
+ !expect ||
83
+ typeof expect !== "object" ||
84
+ typeof (expect as Record<string, unknown>)["exitCode"] !== "number"
85
+ ) {
86
+ issues.push({ index, message: 'probe "run" requires expect: { exitCode: <number> }' });
87
+ }
88
+ break;
89
+ }
90
+ case "file-exists": {
91
+ if (typeof (entry as Record<string, unknown>)["path"] !== "string") {
92
+ issues.push({ index, message: 'probe "file-exists" requires a string "path"' });
93
+ }
94
+ break;
95
+ }
96
+ case "file-contains": {
97
+ const p = (entry as Record<string, unknown>)["path"];
98
+ const pattern = (entry as Record<string, unknown>)["pattern"];
99
+ if (typeof p !== "string")
100
+ issues.push({ index, message: 'probe "file-contains" requires a string "path"' });
101
+ if (typeof pattern !== "string") {
102
+ issues.push({ index, message: 'probe "file-contains" requires a string "pattern"' });
103
+ }
104
+ break;
105
+ }
106
+ case "command-registered": {
107
+ if (typeof (entry as Record<string, unknown>)["name"] !== "string") {
108
+ issues.push({ index, message: 'probe "command-registered" requires a string "name"' });
109
+ }
110
+ break;
111
+ }
112
+ case "page": {
113
+ const p = (entry as Record<string, unknown>)["path"];
114
+ if (typeof p !== "string" || !p.startsWith("/")) {
115
+ issues.push({
116
+ index,
117
+ message: 'probe "page" requires a string "path" starting with "/"',
118
+ });
119
+ }
120
+ const expectStatus = (entry as Record<string, unknown>)["expectStatus"];
121
+ if (expectStatus !== undefined && typeof expectStatus !== "number") {
122
+ issues.push({ index, message: 'probe "page" expectStatus must be a number if present' });
123
+ }
124
+ const selector = (entry as Record<string, unknown>)["selector"];
125
+ if (selector !== undefined && typeof selector !== "string") {
126
+ issues.push({ index, message: 'probe "page" selector must be a string if present' });
127
+ }
128
+ const textPattern = (entry as Record<string, unknown>)["textPattern"];
129
+ if (textPattern !== undefined && typeof textPattern !== "string") {
130
+ issues.push({ index, message: 'probe "page" textPattern must be a string if present' });
131
+ }
132
+ const allowConsoleErrors = (entry as Record<string, unknown>)["allowConsoleErrors"];
133
+ if (allowConsoleErrors !== undefined && typeof allowConsoleErrors !== "boolean") {
134
+ issues.push({
135
+ index,
136
+ message: 'probe "page" allowConsoleErrors must be a boolean if present',
137
+ });
138
+ }
139
+ break;
140
+ }
141
+ default:
142
+ issues.push({
143
+ index,
144
+ message: `unknown probe kind "${String(probe)}" — expected one of: run, file-exists, file-contains, command-registered, page`,
145
+ });
146
+ }
147
+ });
148
+
149
+ return issues;
150
+ }
151
+
152
+ async function spawnSiteKernel(
153
+ workspaceRoot: string,
154
+ commandLine: string,
155
+ ): Promise<{ exitCode: number | null; timedOut: boolean }> {
156
+ const args = commandLine
157
+ .slice(RUN_PROBE_ALLOWED_PREFIX.length)
158
+ .trim()
159
+ .split(/\s+/)
160
+ .filter(Boolean);
161
+ const binPath = path.join(workspaceRoot, "packages", "werkstatt", "bin", "werkstatt.mjs");
162
+
163
+ return new Promise((resolve) => {
164
+ const child = spawn(process.execPath, [binPath, ...args], {
165
+ cwd: workspaceRoot,
166
+ stdio: "ignore",
167
+ });
168
+ let settled = false;
169
+ const timer = setTimeout(() => {
170
+ if (settled) return;
171
+ settled = true;
172
+ child.kill();
173
+ resolve({ exitCode: null, timedOut: true });
174
+ }, RUN_PROBE_TIMEOUT_MS);
175
+ timer.unref?.();
176
+
177
+ child.on("close", (code) => {
178
+ if (settled) return;
179
+ settled = true;
180
+ clearTimeout(timer);
181
+ resolve({ exitCode: code, timedOut: false });
182
+ });
183
+ child.on("error", () => {
184
+ if (settled) return;
185
+ settled = true;
186
+ clearTimeout(timer);
187
+ resolve({ exitCode: null, timedOut: false });
188
+ });
189
+ });
190
+ }
191
+
192
+ /** Executes a single probe. Pure aside from its declared filesystem/process/registry effects. */
193
+ export async function runProbe(
194
+ probe: AcceptanceProbe,
195
+ workspaceRoot: string,
196
+ commandRegistry?: CommandRegistry,
197
+ ): Promise<ProbeResult> {
198
+ switch (probe.probe) {
199
+ case "run": {
200
+ if (!probe.command.startsWith(RUN_PROBE_ALLOWED_PREFIX)) {
201
+ return {
202
+ probe,
203
+ ok: false,
204
+ detail: `rejected: command must start with "${RUN_PROBE_ALLOWED_PREFIX}"`,
205
+ };
206
+ }
207
+ const { exitCode, timedOut } = await spawnSiteKernel(workspaceRoot, probe.command);
208
+ if (timedOut) {
209
+ return { probe, ok: false, detail: `timed out after ${RUN_PROBE_TIMEOUT_MS}ms` };
210
+ }
211
+ const ok = exitCode === probe.expect.exitCode;
212
+ return { probe, ok, detail: `exitCode=${exitCode} (expected ${probe.expect.exitCode})` };
213
+ }
214
+ case "file-exists": {
215
+ try {
216
+ await stat(path.join(workspaceRoot, probe.path));
217
+ return { probe, ok: true, detail: "exists" };
218
+ } catch {
219
+ return { probe, ok: false, detail: "does not exist" };
220
+ }
221
+ }
222
+ case "file-contains": {
223
+ try {
224
+ const content = await readFile(path.join(workspaceRoot, probe.path), "utf8");
225
+ const re = new RegExp(probe.pattern, "m");
226
+ const ok = re.test(content);
227
+ return { probe, ok, detail: ok ? "pattern found" : "pattern not found" };
228
+ } catch {
229
+ return { probe, ok: false, detail: "file could not be read" };
230
+ }
231
+ }
232
+ case "command-registered": {
233
+ const registryNames = new Set((commandRegistry?.listCommands() ?? []).map((c) => c.name));
234
+ if (!registryNames.has(probe.name)) {
235
+ const manifestNames = await loadManifestCommandNames(workspaceRoot);
236
+ for (const name of manifestNames) {
237
+ registryNames.add(name);
238
+ }
239
+ }
240
+ const ok = registryNames.has(probe.name);
241
+ return { probe, ok, detail: ok ? "registered" : "not registered" };
242
+ }
243
+ case "page": {
244
+ return {
245
+ probe,
246
+ ok: true,
247
+ detail: "page probe skipped — run qa.independent.run against a built dist",
248
+ };
249
+ }
250
+ }
251
+ }
252
+
253
+ export async function runRfcAcceptanceRun(
254
+ input: ForgeCommandInput,
255
+ context: ForgeRuntimeContext,
256
+ ): Promise<ForgeCommandResult<RfcAcceptanceRunResult>> {
257
+ const { workspaceRoot } = context;
258
+ const rfcDirPath = path.join(workspaceRoot, RFC_DIR);
259
+ const targetId = input.flags["id"] as string | undefined;
260
+ const targetStatus = input.flags["status"] as string | undefined;
261
+
262
+ if (!targetId && !targetStatus) {
263
+ return {
264
+ data: { command: "rfc.acceptance.run", status: "pass", diagnostics: [], results: [] },
265
+ exitCode: 0,
266
+ summary:
267
+ "rfc.acceptance.run: pass --id <rfc-id> or --status <status> to select target RFC(s)",
268
+ };
269
+ }
270
+
271
+ const allFiles = await listRfcFiles(rfcDirPath);
272
+ const results: RfcAcceptanceRunResult["results"] = [];
273
+ const diagnostics: Diagnostic[] = [];
274
+
275
+ for (const fileName of allFiles) {
276
+ const parsedFile = await readAndParseRfc(rfcDirPath, fileName);
277
+ if (!parsedFile) continue;
278
+ if ("error" in parsedFile) continue;
279
+ const fm = parsedFile.parsed.frontmatter;
280
+ const rfcId = String(fm["id"] ?? "");
281
+
282
+ if (targetId && rfcId.toLowerCase() !== targetId.toLowerCase()) continue;
283
+ if (targetStatus && String(fm["status"] ?? "") !== targetStatus) continue;
284
+
285
+ const acceptance = fm["acceptance"];
286
+ const relFile = path.join(RFC_DIR, fileName);
287
+ const status = String(fm["status"] ?? "");
288
+
289
+ if (!Array.isArray(acceptance) || acceptance.length === 0) {
290
+ if (status === "accepted" || status === "implemented") {
291
+ diagnostics.push({
292
+ ruleId: "RFC-ACC-02",
293
+ severity: "info",
294
+ file: relFile,
295
+ message: `${rfcId} (${status}) declares no acceptance probes.`,
296
+ });
297
+ }
298
+ results.push({ rfcId, probeResults: [] });
299
+ continue;
300
+ }
301
+
302
+ const probeResults: ProbeResult[] = [];
303
+ let pageProbeCount = 0;
304
+ for (const raw of acceptance as AcceptanceProbe[]) {
305
+ if (raw.probe === "page") {
306
+ pageProbeCount++;
307
+ probeResults.push({
308
+ probe: raw,
309
+ ok: true,
310
+ detail: "page probe skipped — run qa.independent.run against a built dist",
311
+ });
312
+ continue;
313
+ }
314
+ const result = await runProbe(raw, workspaceRoot, context.commandRegistry);
315
+ probeResults.push(result);
316
+ if (!result.ok) {
317
+ diagnostics.push({
318
+ ruleId: "RFC-ACC-01",
319
+ severity: "error",
320
+ file: relFile,
321
+ message: `${rfcId}: probe "${raw.probe}" failed — ${result.detail}.`,
322
+ });
323
+ }
324
+ }
325
+ if (pageProbeCount > 0) {
326
+ diagnostics.push({
327
+ ruleId: "RFC-ACC-03",
328
+ severity: "info",
329
+ file: relFile,
330
+ message: `${pageProbeCount} page probe(s) skipped — run \`qa.independent.run --site <app>\` against a built dist.`,
331
+ });
332
+ }
333
+ results.push({ rfcId, probeResults });
334
+ }
335
+
336
+ const failedCount = diagnostics.filter((d) => d.severity === "error").length;
337
+ const status: RfcAcceptanceRunResult["status"] = failedCount > 0 ? "fail" : "pass";
338
+
339
+ return {
340
+ data: { command: "rfc.acceptance.run", status, diagnostics, results },
341
+ exitCode: failedCount > 0 ? 1 : 0,
342
+ summary: `rfc.acceptance.run: ${results.length} RFC(s), ${failedCount} failed probe(s)`,
343
+ };
344
+ }
@@ -0,0 +1,310 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>
4
+ RFC-0329: collect decision log entries from all RFCs, score against
5
+ keywords for rfc.create consultation, and generate/validate projections.
6
+ </purpose>
7
+ <non-goals>
8
+ <item>Do not use LLM/embeddings for matching — keyword scoring only.</item>
9
+ <item>Do not block rfc.create when matches are found — consultation is informative.</item>
10
+ <item>Do not wire generation into build pipelines — on-demand only.</item>
11
+ </non-goals>
12
+ </MODULE_CONTRACT>
13
+ <CHANGE_SUMMARY>
14
+ <item>RFC-0329: initial implementation.</item>
15
+ </CHANGE_SUMMARY>
16
+ */
17
+
18
+ import { readFile } from "node:fs/promises";
19
+ import { join } from "node:path";
20
+
21
+ import { writeFileAtomic } from "../../src/utils/fs-atomic.ts";
22
+ import { buildGeneratedHeader } from "../../src/utils/generated-marker.ts";
23
+ import { stringify as yamlStringify } from "yaml";
24
+ import { listRfcFiles, readAndParseRfc } from "./frontmatter-io.ts";
25
+ import { RFC_DIR } from "./types.ts";
26
+ import type {
27
+ DecisionLogEntry,
28
+ DecisionLogEntryKind,
29
+ DecisionLogResult,
30
+ ConsultedDecision,
31
+ RfcStatus,
32
+ } from "./types.ts";
33
+ import type {
34
+ Diagnostic,
35
+ ForgeCommandInput,
36
+ ForgeCommandResult,
37
+ ForgeRuntimeContext,
38
+ } from "../../src/types.ts";
39
+
40
+ const STOPWORDS = new Set(["the", "and", "for", "add", "into", "with", "from", "that"]);
41
+
42
+ function tokenize(text: string): string[] {
43
+ return text
44
+ .toLowerCase()
45
+ .split(/[^a-z0-9]+/)
46
+ .filter((t) => t.length >= 3 && !STOPWORDS.has(t));
47
+ }
48
+
49
+ function extractSection(body: string, heading: string): string | null {
50
+ const re = new RegExp(`^##\\s+${heading}\\s*$`, "im");
51
+ const match = re.exec(body);
52
+ if (!match) return null;
53
+ const start = match.index + match[0].length;
54
+ const nextHeading = body.indexOf("\n## ", start);
55
+ const sectionText =
56
+ nextHeading === -1 ? body.slice(start).trim() : body.slice(start, nextHeading).trim();
57
+ return sectionText || null;
58
+ }
59
+
60
+ function firstParagraph(text: string): string {
61
+ const trimmed = text.trim();
62
+ const firstBreak = trimmed.indexOf("\n\n");
63
+ return firstBreak === -1 ? trimmed : trimmed.slice(0, firstBreak).trim();
64
+ }
65
+
66
+ export async function collectDecisionLog(rfcDirPath: string): Promise<DecisionLogEntry[]> {
67
+ const files = await listRfcFiles(rfcDirPath);
68
+ const entries: DecisionLogEntry[] = [];
69
+
70
+ for (const fileName of files) {
71
+ const result = await readAndParseRfc(rfcDirPath, fileName);
72
+ if (!result || "error" in result) continue;
73
+ const fm = result.parsed.frontmatter;
74
+ const body = result.parsed.body;
75
+ const rfcId = String(fm["id"] ?? "");
76
+ const title = String(fm["title"] ?? "");
77
+ const status = String(fm["status"] ?? "") as RfcStatus;
78
+ const file = join(RFC_DIR, fileName);
79
+
80
+ if (status === "rejected") {
81
+ const decisionSection = extractSection(body, "Decision");
82
+ entries.push({
83
+ kind: "rejected-rfc",
84
+ rfcId,
85
+ title,
86
+ status,
87
+ closedAt: String(fm["closedAt"] ?? "") || undefined,
88
+ decisionSummary: decisionSection ? firstParagraph(decisionSection) : undefined,
89
+ alternativesText: extractSection(body, "Alternatives considered") ?? undefined,
90
+ file,
91
+ });
92
+ } else if (status === "superseded") {
93
+ const decisionSection = extractSection(body, "Decision");
94
+ const supersededBy = fm["supersededBy"];
95
+ entries.push({
96
+ kind: "superseded-rfc",
97
+ rfcId,
98
+ title,
99
+ status,
100
+ closedAt: String(fm["closedAt"] ?? "") || undefined,
101
+ supersededBy:
102
+ Array.isArray(supersededBy) && supersededBy.length > 0
103
+ ? String(supersededBy[0])
104
+ : undefined,
105
+ decisionSummary: decisionSection ? firstParagraph(decisionSection) : undefined,
106
+ file,
107
+ });
108
+ }
109
+
110
+ const alternativesText = extractSection(body, "Alternatives considered");
111
+ if (alternativesText) {
112
+ entries.push({
113
+ kind: "rejected-alternative",
114
+ rfcId,
115
+ title,
116
+ status,
117
+ alternativesText,
118
+ file,
119
+ });
120
+ }
121
+ }
122
+
123
+ entries.sort((a, b) => a.rfcId.localeCompare(b.rfcId));
124
+ return entries;
125
+ }
126
+
127
+ export function scoreDecisions(
128
+ title: string,
129
+ entries: DecisionLogEntry[],
130
+ limit = 5,
131
+ ): ConsultedDecision[] {
132
+ const titleTokens = tokenize(title);
133
+ if (titleTokens.length === 0) return [];
134
+
135
+ const scored: ConsultedDecision[] = [];
136
+ for (const entry of entries) {
137
+ const entryTitleTokens = tokenize(entry.title);
138
+ const entryTextTokens = new Set([
139
+ ...entryTitleTokens,
140
+ ...tokenize(entry.decisionSummary ?? ""),
141
+ ...tokenize(entry.alternativesText ?? ""),
142
+ ]);
143
+
144
+ let score = 0;
145
+ for (const token of titleTokens) {
146
+ if (entryTitleTokens.includes(token)) score += 3;
147
+ if (entryTextTokens.has(token)) score += 1;
148
+ }
149
+
150
+ if (score > 0) {
151
+ scored.push({
152
+ rfcId: entry.rfcId,
153
+ kind: entry.kind,
154
+ title: entry.title,
155
+ score,
156
+ file: entry.file,
157
+ });
158
+ }
159
+ }
160
+
161
+ scored.sort((a, b) => b.score - a.score || a.rfcId.localeCompare(b.rfcId));
162
+ return scored.slice(0, limit);
163
+ }
164
+
165
+ function renderMarkdown(entries: DecisionLogEntry[]): string {
166
+ const lines: string[] = [
167
+ buildGeneratedHeader({
168
+ ownerCommand: "rfc.decision-log.generate",
169
+ filePath: "docs/rfcs/decision-log.generated.md",
170
+ }).trimEnd(),
171
+ "",
172
+ ];
173
+ const groups: Record<DecisionLogEntryKind, DecisionLogEntry[]> = {
174
+ "rejected-rfc": [],
175
+ "superseded-rfc": [],
176
+ "rejected-alternative": [],
177
+ };
178
+ for (const e of entries) groups[e.kind].push(e);
179
+
180
+ lines.push("# RFC Decision Log", "");
181
+ lines.push(`> Generated projection of all rejected/superseded RFCs and rejected alternatives.`);
182
+ lines.push(
183
+ `> Do not edit — run \`site-kernel run rfc.decision-log.generate\` to regenerate.`,
184
+ "",
185
+ );
186
+
187
+ for (const kind of Object.keys(groups) as DecisionLogEntryKind[]) {
188
+ const groupEntries = groups[kind];
189
+ if (groupEntries.length === 0) continue;
190
+ lines.push(`## ${kind} (${groupEntries.length})`, "");
191
+ for (const e of groupEntries) {
192
+ lines.push(`### ${e.rfcId}: ${e.title}`, "");
193
+ lines.push(`- **Status:** ${e.status}`);
194
+ if (e.closedAt) lines.push(`- **Closed:** ${e.closedAt}`);
195
+ if (e.supersededBy) lines.push(`- **Superseded by:** ${e.supersededBy}`);
196
+ if (e.decisionSummary) lines.push(`- **Decision:** ${e.decisionSummary}`);
197
+ if (e.alternativesText) {
198
+ lines.push("- **Alternatives considered:**", "");
199
+ lines.push(e.alternativesText);
200
+ }
201
+ lines.push("");
202
+ }
203
+ }
204
+
205
+ return `${lines.join("\n")}\n`;
206
+ }
207
+
208
+ export async function runRfcDecisionLogGenerate(
209
+ input: ForgeCommandInput,
210
+ context: ForgeRuntimeContext,
211
+ ): Promise<ForgeCommandResult<DecisionLogResult>> {
212
+ const { workspaceRoot, logger, outputFormat } = context;
213
+ const checkMode = Boolean(input.flags["check"]);
214
+ const rfcDirPath = join(workspaceRoot, RFC_DIR);
215
+
216
+ const entries = await collectDecisionLog(rfcDirPath);
217
+
218
+ const jsonRelPath = join(RFC_DIR, "decision-log.generated.yaml");
219
+ const mdRelPath = join(RFC_DIR, "decision-log.generated.md");
220
+
221
+ if (checkMode) {
222
+ const diagnostics: Diagnostic[] = [];
223
+ let drift = false;
224
+
225
+ try {
226
+ const existingJson = await readFile(join(workspaceRoot, jsonRelPath), "utf-8");
227
+ const expectedJson = `${buildGeneratedHeader({ filePath: jsonRelPath, ownerCommand: "rfc.decision-log.generate" })}${yamlStringify({ command: "rfc.decision-log.generate", status: "ok", count: entries.length, entries })}\n`;
228
+ if (existingJson !== expectedJson) {
229
+ drift = true;
230
+ diagnostics.push({
231
+ ruleId: "DECISION-LOG-DRIFT",
232
+ severity: "error",
233
+ file: jsonRelPath,
234
+ message: "JSON projection drifts from recomputed content.",
235
+ });
236
+ }
237
+ } catch {
238
+ drift = true;
239
+ diagnostics.push({
240
+ ruleId: "DECISION-LOG-MISSING",
241
+ severity: "error",
242
+ file: jsonRelPath,
243
+ message: `${jsonRelPath} does not exist — run without --check first.`,
244
+ });
245
+ }
246
+
247
+ try {
248
+ const existingMd = await readFile(join(workspaceRoot, mdRelPath), "utf-8");
249
+ const expectedMd = renderMarkdown(entries);
250
+ if (existingMd !== expectedMd) {
251
+ drift = true;
252
+ diagnostics.push({
253
+ ruleId: "DECISION-LOG-DRIFT",
254
+ severity: "error",
255
+ file: mdRelPath,
256
+ message: "Markdown projection drifts from recomputed content.",
257
+ });
258
+ }
259
+ } catch {
260
+ drift = true;
261
+ diagnostics.push({
262
+ ruleId: "DECISION-LOG-MISSING",
263
+ severity: "error",
264
+ file: mdRelPath,
265
+ message: `${mdRelPath} does not exist — run without --check first.`,
266
+ });
267
+ }
268
+
269
+ if (outputFormat === "pretty") {
270
+ if (drift) {
271
+ for (const d of diagnostics) logger.error(`[${d.ruleId}] ${d.message}`);
272
+ } else {
273
+ logger.success("Decision log projections are up to date.");
274
+ }
275
+ }
276
+
277
+ return {
278
+ data: {
279
+ command: "rfc.decision-log.generate",
280
+ status: drift ? "drift" : "ok",
281
+ count: entries.length,
282
+ entries,
283
+ },
284
+ exitCode: drift ? 1 : 0,
285
+ summary: `rfc.decision-log.generate --check: ${drift ? "drift detected" : "up to date"} (${entries.length} entries)`,
286
+ };
287
+ }
288
+
289
+ const jsonContent = `${buildGeneratedHeader({ filePath: jsonRelPath, ownerCommand: "rfc.decision-log.generate" })}${yamlStringify({ command: "rfc.decision-log.generate", status: "ok", count: entries.length, entries })}\n`;
290
+ const mdContent = renderMarkdown(entries);
291
+
292
+ await writeFileAtomic(join(workspaceRoot, jsonRelPath), jsonContent);
293
+ await writeFileAtomic(join(workspaceRoot, mdRelPath), mdContent);
294
+
295
+ if (outputFormat === "pretty") {
296
+ logger.success(`Wrote ${jsonRelPath} and ${mdRelPath} (${entries.length} entries)`);
297
+ }
298
+
299
+ return {
300
+ data: {
301
+ command: "rfc.decision-log.generate",
302
+ status: "ok",
303
+ count: entries.length,
304
+ entries,
305
+ written: [jsonRelPath, mdRelPath],
306
+ },
307
+ exitCode: 0,
308
+ summary: `rfc.decision-log.generate: wrote ${entries.length} entries to JSON + Markdown`,
309
+ };
310
+ }