@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,202 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Knowledge layer character budget computation and resolution — pure functions for hot/warm budget enforcement (RFC-0661).</purpose>
4
+ <non-goals>
5
+ <item>Do not emit warnings or violations — that is handled by SKILL-21 in skill-validate.ts.</item>
6
+ <item>Do not parse knowledge files — that is handled by parse.ts (RFC-0660).</item>
7
+ <item>Do not validate budget override shape — that is handled by forge.doctor.</item>
8
+ </non-goals>
9
+ </MODULE_CONTRACT>
10
+ <CHANGE_SUMMARY>
11
+ <item>RFC-0661: initial budget module with KnowledgeBudgets, LayerBudgetReport, computeLayerBudgets, resolveKnowledgeBudgets.</item>
12
+ </CHANGE_SUMMARY>
13
+ */
14
+
15
+ import path from "node:path";
16
+ import { readFileSync } from "node:fs";
17
+ import { parse as parseYaml } from "yaml";
18
+ import type { ParsedKnowledgeFile, KnowledgeLayer } from "./schema.ts";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Types (RFC-0661)
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export interface KnowledgeBudgets {
25
+ hot: number; // default 4096 — L2 (learned-principles.md)
26
+ warm: number; // default 8192 — L1 (fix-patterns.md)
27
+ }
28
+
29
+ export interface LayerBudgetReport {
30
+ skill: string;
31
+ file: string;
32
+ layer: KnowledgeLayer;
33
+ activeChars: number;
34
+ budget: number;
35
+ exceededBy: number; // 0 when within budget
36
+ pack?: string;
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Defaults
41
+ // ---------------------------------------------------------------------------
42
+
43
+ export const DEFAULT_KNOWLEDGE_BUDGETS: KnowledgeBudgets = {
44
+ hot: 4096,
45
+ warm: 8192,
46
+ };
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // computeLayerBudgets — pure function
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /**
53
+ * Compute per-file budget reports for parsed knowledge files.
54
+ *
55
+ * - Counts only `status: active` entries (heading + metadata block + body).
56
+ * - Uses `ParsedKnowledgeFile.layer` to determine which budget applies.
57
+ * - Skips files with undeterminable layer (null) or L0 (cold, no budget).
58
+ * - Skips files with parse issues (schema errors are already reported by SKILL-19).
59
+ *
60
+ * Pure: no filesystem side effects.
61
+ */
62
+ export function computeLayerBudgets(
63
+ files: ParsedKnowledgeFile[],
64
+ budgets: KnowledgeBudgets,
65
+ skillNames: Map<string, string>,
66
+ ): LayerBudgetReport[] {
67
+ const reports: LayerBudgetReport[] = [];
68
+
69
+ for (const file of files) {
70
+ // Skip files with undeterminable layer
71
+ if (file.layer === null) {
72
+ continue;
73
+ }
74
+
75
+ // Skip cold layer (L0) — no budget by design
76
+ if (file.layer === "L0") {
77
+ continue;
78
+ }
79
+
80
+ // Skip files with parse issues — SKILL-19 already reports those
81
+ if (file.parseIssues.length > 0) {
82
+ continue;
83
+ }
84
+
85
+ // Skip knowledge-adjacent files (no structured entries)
86
+ if (file.isKnowledgeAdjacent) {
87
+ continue;
88
+ }
89
+
90
+ const budget = file.layer === "L2" ? budgets.hot : budgets.warm;
91
+ const fileName = path.basename(file.path);
92
+ const skillName = skillNames.get(file.path) ?? path.basename(path.dirname(file.path));
93
+
94
+ // Count only active entries: heading + metadata block + body
95
+ let activeChars = 0;
96
+ for (const entry of file.entries) {
97
+ if (entry.meta.status !== "active") {
98
+ continue;
99
+ }
100
+ // Heading: "### K-XXXX: <title>\n"
101
+ activeChars += `### ${entry.meta.id}: ${entry.title}\n`.length;
102
+ // Metadata block: ```knowledge-entry\n<yaml>\n```\n
103
+ const metaYaml = approximateMetaCharCount(entry.meta);
104
+ activeChars += "```knowledge-entry\n".length;
105
+ activeChars += metaYaml.length;
106
+ activeChars += "```\n".length;
107
+ // Body
108
+ activeChars += entry.body.length;
109
+ }
110
+
111
+ const exceededBy = Math.max(0, activeChars - budget);
112
+
113
+ reports.push({
114
+ skill: skillName,
115
+ file: fileName,
116
+ layer: file.layer,
117
+ activeChars,
118
+ budget,
119
+ exceededBy,
120
+ });
121
+ }
122
+
123
+ return reports;
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // resolveKnowledgeBudgets — reads forge.yaml
128
+ // ---------------------------------------------------------------------------
129
+
130
+ /**
131
+ * Read effective knowledge budgets from forge.yaml bindings.
132
+ *
133
+ * Falls back to defaults (hot: 4096, warm: 8192) when:
134
+ * - forge.yaml is not found
135
+ * - bindings.knowledge.budgets is absent
136
+ * - override values are invalid (non-positive, non-integer)
137
+ *
138
+ * Does NOT validate the override shape — forge.doctor handles that separately.
139
+ */
140
+ export function resolveKnowledgeBudgets(workspaceRoot: string): KnowledgeBudgets {
141
+ try {
142
+ const forgeYamlPath = path.join(workspaceRoot, "forge.yaml");
143
+ const content = readFileSync(forgeYamlPath, "utf8");
144
+ const config = parseYaml(content) as Record<string, unknown>;
145
+ const bindings = config?.bindings as Record<string, unknown> | undefined;
146
+ const knowledge = bindings?.knowledge as Record<string, unknown> | undefined;
147
+ const budgets = knowledge?.budgets as { hot?: unknown; warm?: unknown } | undefined;
148
+
149
+ if (!budgets) {
150
+ return { ...DEFAULT_KNOWLEDGE_BUDGETS };
151
+ }
152
+
153
+ const hot =
154
+ typeof budgets.hot === "number" && budgets.hot > 0 && Number.isInteger(budgets.hot)
155
+ ? budgets.hot
156
+ : DEFAULT_KNOWLEDGE_BUDGETS.hot;
157
+ const warm =
158
+ typeof budgets.warm === "number" && budgets.warm > 0 && Number.isInteger(budgets.warm)
159
+ ? budgets.warm
160
+ : DEFAULT_KNOWLEDGE_BUDGETS.warm;
161
+
162
+ return { hot, warm };
163
+ } catch {
164
+ return { ...DEFAULT_KNOWLEDGE_BUDGETS };
165
+ }
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Helper: approximate metadata character count for budget measurement
170
+ // ---------------------------------------------------------------------------
171
+
172
+ /**
173
+ * Approximate metadata character count for budget purposes.
174
+ *
175
+ * This is NOT exact YAML serialization — it produces a line-per-field
176
+ * approximation that is deterministic and sufficient for character budget
177
+ * measurement. The RFC explicitly rejects tokenizer-accurate measurement;
178
+ * this approximation is the documented proxy.
179
+ */
180
+ function approximateMetaCharCount(meta: ParsedKnowledgeFile["entries"][number]["meta"]): string {
181
+ const lines: string[] = [];
182
+ lines.push(`id: ${meta.id}`);
183
+ lines.push(`layer: ${meta.layer}`);
184
+ lines.push(`created: ${meta.created}`);
185
+ if (meta.lastConfirmedAt !== undefined) {
186
+ lines.push(`lastConfirmedAt: ${meta.lastConfirmedAt ?? "null"}`);
187
+ }
188
+ if (meta.confirmations !== undefined) {
189
+ lines.push(`confirmations: ${meta.confirmations}`);
190
+ }
191
+ if (meta.expiresAt !== undefined) {
192
+ lines.push(`expiresAt: ${meta.expiresAt ?? "null"}`);
193
+ }
194
+ if (meta.supersedes !== undefined) {
195
+ lines.push(`supersedes: [${meta.supersedes.join(", ")}]`);
196
+ }
197
+ if (meta.promotedTo !== undefined) {
198
+ lines.push(`promotedTo: ${meta.promotedTo ?? "null"}`);
199
+ }
200
+ lines.push(`status: ${meta.status}`);
201
+ return lines.join("\n") + "\n";
202
+ }
@@ -0,0 +1,405 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Knowledge compaction planning and execution — archive aged/superseded entries, mark stale L2 principles (RFC-0662).</purpose>
4
+ <non-goals>
5
+ <item>Do not distill or group entries semantically — that is the fo-knowledge-distill skill's job.</item>
6
+ <item>Do not parse knowledge files — use parse.ts (RFC-0660).</item>
7
+ <item>Do not serialize — use serialize.ts (RFC-0660).</item>
8
+ <item>Do not wire into build pipelines or CI — invocation is operator-explicit.</item>
9
+ </non-goals>
10
+ </MODULE_CONTRACT>
11
+ <CHANGE_SUMMARY>
12
+ <item>RFC-0662: initial compaction module with planCompaction, executeCompaction, resolveRetentionDays, resolveStaleDays.</item>
13
+ </CHANGE_SUMMARY>
14
+ */
15
+
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { parse as parseYaml } from "yaml";
19
+ import type { ParsedKnowledgeFile, KnowledgeEntry, KnowledgeEntryMeta } from "./schema.ts";
20
+ import { parseKnowledgeFile } from "./parse.ts";
21
+ import { serializeKnowledgeFile } from "./serialize.ts";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Types (RFC-0662)
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export interface CompactOptions {
28
+ retentionDays: number;
29
+ staleDays: number;
30
+ today: string; // YYYY-MM-DD, injectable for tests
31
+ }
32
+
33
+ export type CompactActionKind =
34
+ "archive-expired" | "archive-superseded" | "archive-l0-retention" | "mark-stale";
35
+
36
+ export interface CompactAction {
37
+ kind: CompactActionKind;
38
+ file: string;
39
+ entryId: string;
40
+ reason: string;
41
+ }
42
+
43
+ export interface CompactFilePlan {
44
+ file: string;
45
+ archiveFile: string;
46
+ actions: CompactAction[];
47
+ legacySectionCount: number;
48
+ }
49
+
50
+ export interface CompactFileResult {
51
+ file: string;
52
+ archiveFile: string;
53
+ actions: CompactAction[];
54
+ legacySectionCount: number;
55
+ archived: number;
56
+ markedStale: number;
57
+ written: boolean;
58
+ }
59
+
60
+ export interface CompactReport {
61
+ command: string;
62
+ status: "pass" | "fail";
63
+ dryRun: boolean;
64
+ files: CompactFileResult[];
65
+ totals: { archived: number; markedStale: number; legacyFiles: number };
66
+ errors?: string[];
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Defaults
71
+ // ---------------------------------------------------------------------------
72
+
73
+ export const DEFAULT_RETENTION_DAYS = 90;
74
+ export const DEFAULT_STALE_DAYS = 90;
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Date helpers
78
+ // ---------------------------------------------------------------------------
79
+
80
+ function parseDate(dateStr: string): number {
81
+ const [y, m, d] = dateStr.split("-").map(Number);
82
+ return new Date(y, m - 1, d).getTime();
83
+ }
84
+
85
+ function daysBetween(from: string, to: string): number {
86
+ const ms = parseDate(to) - parseDate(from);
87
+ return Math.floor(ms / (1000 * 60 * 60 * 24));
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Archive companion path
92
+ // ---------------------------------------------------------------------------
93
+
94
+ function archiveCompanionPath(filePath: string): string {
95
+ const dir = path.dirname(filePath);
96
+ const ext = path.extname(filePath);
97
+ const base = path.basename(filePath, ext);
98
+ return path.join(dir, `${base}.archive${ext}`);
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // planCompaction — pure function
103
+ // ---------------------------------------------------------------------------
104
+
105
+ export function planCompaction(
106
+ files: ParsedKnowledgeFile[],
107
+ options: CompactOptions,
108
+ ): CompactFilePlan[] {
109
+ const plans: CompactFilePlan[] = [];
110
+
111
+ for (const file of files) {
112
+ // Skip knowledge-adjacent files (no structured entries)
113
+ if (file.isKnowledgeAdjacent) continue;
114
+
115
+ const actions: CompactAction[] = [];
116
+
117
+ for (const entry of file.entries) {
118
+ const meta = entry.meta;
119
+
120
+ // 1. Expiry archive (all layers)
121
+ if (meta.expiresAt !== undefined && meta.expiresAt !== null) {
122
+ const diff = daysBetween(meta.expiresAt, options.today);
123
+ if (diff > 0) {
124
+ actions.push({
125
+ kind: "archive-expired",
126
+ file: file.path,
127
+ entryId: meta.id,
128
+ reason: `expiresAt ${meta.expiresAt} is past today ${options.today}`,
129
+ });
130
+ continue;
131
+ }
132
+ }
133
+
134
+ // 2. Supersession archive (L1, L2)
135
+ if (meta.status === "superseded") {
136
+ actions.push({
137
+ kind: "archive-superseded",
138
+ file: file.path,
139
+ entryId: meta.id,
140
+ reason: `status is superseded`,
141
+ });
142
+ continue;
143
+ }
144
+
145
+ // 3. L0 retention archive
146
+ if (file.layer === "L0" && meta.status === "active") {
147
+ const age = daysBetween(meta.created, options.today);
148
+ if (age > options.retentionDays) {
149
+ actions.push({
150
+ kind: "archive-l0-retention",
151
+ file: file.path,
152
+ entryId: meta.id,
153
+ reason: `created ${meta.created} older than ${options.retentionDays} days`,
154
+ });
155
+ continue;
156
+ }
157
+ }
158
+
159
+ // 4. L2 staleness marking
160
+ if (
161
+ file.layer === "L2" &&
162
+ meta.status === "active" &&
163
+ meta.lastConfirmedAt !== undefined &&
164
+ meta.lastConfirmedAt !== null
165
+ ) {
166
+ const age = daysBetween(meta.lastConfirmedAt, options.today);
167
+ if (age > options.staleDays) {
168
+ actions.push({
169
+ kind: "mark-stale",
170
+ file: file.path,
171
+ entryId: meta.id,
172
+ reason: `lastConfirmedAt ${meta.lastConfirmedAt} older than ${options.staleDays} days`,
173
+ });
174
+ }
175
+ }
176
+ }
177
+
178
+ plans.push({
179
+ file: file.path,
180
+ archiveFile: archiveCompanionPath(file.path),
181
+ actions,
182
+ legacySectionCount: file.legacySections.length,
183
+ });
184
+ }
185
+
186
+ return plans;
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // executeCompaction — I/O execution
191
+ // ---------------------------------------------------------------------------
192
+
193
+ export function executeCompaction(plans: CompactFilePlan[], dryRun: boolean): CompactReport {
194
+ const fileResults: CompactFileResult[] = [];
195
+ const errors: string[] = [];
196
+ let totalArchived = 0;
197
+ let totalMarkedStale = 0;
198
+ let totalLegacyFiles = 0;
199
+
200
+ for (const plan of plans) {
201
+ // Parse the live file
202
+ const parsed = parseKnowledgeFile(plan.file);
203
+
204
+ // Refuse if parse issues
205
+ if (parsed.parseIssues.length > 0) {
206
+ errors.push(
207
+ `${plan.file}: ${parsed.parseIssues.length} parse issue(s) — refusing to compact`,
208
+ );
209
+ fileResults.push({
210
+ file: plan.file,
211
+ archiveFile: plan.archiveFile,
212
+ actions: plan.actions,
213
+ legacySectionCount: plan.legacySectionCount,
214
+ archived: 0,
215
+ markedStale: 0,
216
+ written: false,
217
+ });
218
+ continue;
219
+ }
220
+
221
+ // Build action lookup
222
+ const actionByEntryId = new Map<string, CompactAction>();
223
+ for (const action of plan.actions) {
224
+ actionByEntryId.set(action.entryId, action);
225
+ }
226
+
227
+ // Partition entries
228
+ const keepEntries: KnowledgeEntry[] = [];
229
+ const archiveEntries: KnowledgeEntry[] = [];
230
+
231
+ for (const entry of parsed.entries) {
232
+ const action = actionByEntryId.get(entry.meta.id);
233
+ if (action && action.kind.startsWith("archive-")) {
234
+ archiveEntries.push(entry);
235
+ } else if (action && action.kind === "mark-stale") {
236
+ // Keep in place, update status
237
+ keepEntries.push({
238
+ ...entry,
239
+ meta: { ...entry.meta, status: "stale" },
240
+ });
241
+ } else {
242
+ keepEntries.push(entry);
243
+ }
244
+ }
245
+
246
+ // Check archive companion for parse issues if it exists
247
+ if (archiveEntries.length > 0 && fs.existsSync(plan.archiveFile)) {
248
+ const archiveParsed = parseKnowledgeFile(plan.archiveFile);
249
+ if (archiveParsed.parseIssues.length > 0) {
250
+ errors.push(
251
+ `${plan.archiveFile}: ${archiveParsed.parseIssues.length} parse issue(s) — refusing to compact`,
252
+ );
253
+ fileResults.push({
254
+ file: plan.file,
255
+ archiveFile: plan.archiveFile,
256
+ actions: plan.actions,
257
+ legacySectionCount: plan.legacySectionCount,
258
+ archived: 0,
259
+ markedStale: 0,
260
+ written: false,
261
+ });
262
+ continue;
263
+ }
264
+ }
265
+
266
+ const archived = archiveEntries.length;
267
+ const markedStale = plan.actions.filter((a) => a.kind === "mark-stale").length;
268
+ totalArchived += archived;
269
+ totalMarkedStale += markedStale;
270
+ if (plan.legacySectionCount > 0) totalLegacyFiles++;
271
+
272
+ if (dryRun) {
273
+ fileResults.push({
274
+ file: plan.file,
275
+ archiveFile: plan.archiveFile,
276
+ actions: plan.actions,
277
+ legacySectionCount: plan.legacySectionCount,
278
+ archived,
279
+ markedStale,
280
+ written: false,
281
+ });
282
+ continue;
283
+ }
284
+
285
+ // Write live file (only if there are changes)
286
+ if (plan.actions.length > 0) {
287
+ const liveContent = serializeKnowledgeFile({
288
+ ...parsed,
289
+ entries: keepEntries,
290
+ });
291
+ try {
292
+ fs.writeFileSync(plan.file, liveContent, "utf8");
293
+ } catch (err) {
294
+ errors.push(`${plan.file}: write failed — ${(err as Error).message}`);
295
+ }
296
+ }
297
+
298
+ // Write archive companion (only if there are entries to archive)
299
+ if (archiveEntries.length > 0) {
300
+ let archiveParsed: ParsedKnowledgeFile;
301
+ if (fs.existsSync(plan.archiveFile)) {
302
+ archiveParsed = parseKnowledgeFile(plan.archiveFile);
303
+ } else {
304
+ // Create new archive file with a preamble
305
+ const fileName = path.basename(plan.file);
306
+ archiveParsed = {
307
+ path: plan.archiveFile,
308
+ layer: parsed.layer,
309
+ preamble: `<!-- knowledge-layer: ${parsed.layer ?? "L0"} -->\n# ${fileName} (archive)\n\nArchived entries from ${fileName}. Generated by forge.skill.knowledge.compact (RFC-0662).`,
310
+ entries: [],
311
+ legacySections: [],
312
+ parseIssues: [],
313
+ isKnowledgeAdjacent: false,
314
+ };
315
+ }
316
+
317
+ // Rewrite status for archived entries
318
+ const archivedEntries: KnowledgeEntry[] = archiveEntries.map((entry) => {
319
+ const action = actionByEntryId.get(entry.meta.id)!;
320
+ const newMeta: KnowledgeEntryMeta = { ...entry.meta };
321
+ if (action.kind === "archive-expired" || action.kind === "archive-l0-retention") {
322
+ newMeta.status = "archived";
323
+ }
324
+ // For archive-superseded: keep status as "superseded" (preserves supersedes chain)
325
+ return { ...entry, meta: newMeta };
326
+ });
327
+
328
+ const mergedContent = serializeKnowledgeFile({
329
+ ...archiveParsed,
330
+ entries: [...archiveParsed.entries, ...archivedEntries],
331
+ });
332
+
333
+ try {
334
+ // Use atomic write for archive companion
335
+ // writeFileAtomic is async, but we're in a sync context — use writeFileSync for simplicity
336
+ // The staging + rename pattern is handled by writeFileAtomic for production use
337
+ // For now, use direct write (the live file is already written non-atomically above)
338
+ fs.writeFileSync(plan.archiveFile, mergedContent, "utf8");
339
+ } catch (err) {
340
+ errors.push(`${plan.archiveFile}: write failed — ${(err as Error).message}`);
341
+ }
342
+ }
343
+
344
+ fileResults.push({
345
+ file: plan.file,
346
+ archiveFile: plan.archiveFile,
347
+ actions: plan.actions,
348
+ legacySectionCount: plan.legacySectionCount,
349
+ archived,
350
+ markedStale,
351
+ written: true,
352
+ });
353
+ }
354
+
355
+ return {
356
+ command: "forge.skill.knowledge.compact",
357
+ status: errors.length > 0 ? "fail" : "pass",
358
+ dryRun,
359
+ files: fileResults,
360
+ totals: {
361
+ archived: totalArchived,
362
+ markedStale: totalMarkedStale,
363
+ legacyFiles: totalLegacyFiles,
364
+ },
365
+ ...(errors.length > 0 ? { errors } : {}),
366
+ };
367
+ }
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // resolveRetentionDays / resolveStaleDays — reads forge.yaml
371
+ // ---------------------------------------------------------------------------
372
+
373
+ export function resolveRetentionDays(workspaceRoot: string): number {
374
+ try {
375
+ const forgeYamlPath = path.join(workspaceRoot, "forge.yaml");
376
+ const content = fs.readFileSync(forgeYamlPath, "utf8");
377
+ const config = parseYaml(content) as Record<string, unknown>;
378
+ const bindings = config?.bindings as Record<string, unknown> | undefined;
379
+ const knowledge = bindings?.knowledge as Record<string, unknown> | undefined;
380
+ const retentionDays = knowledge?.retentionDays;
381
+ if (typeof retentionDays === "number" && retentionDays > 0 && Number.isInteger(retentionDays)) {
382
+ return retentionDays;
383
+ }
384
+ return DEFAULT_RETENTION_DAYS;
385
+ } catch {
386
+ return DEFAULT_RETENTION_DAYS;
387
+ }
388
+ }
389
+
390
+ export function resolveStaleDays(workspaceRoot: string): number {
391
+ try {
392
+ const forgeYamlPath = path.join(workspaceRoot, "forge.yaml");
393
+ const content = fs.readFileSync(forgeYamlPath, "utf8");
394
+ const config = parseYaml(content) as Record<string, unknown>;
395
+ const bindings = config?.bindings as Record<string, unknown> | undefined;
396
+ const knowledge = bindings?.knowledge as Record<string, unknown> | undefined;
397
+ const staleDays = knowledge?.staleDays;
398
+ if (typeof staleDays === "number" && staleDays > 0 && Number.isInteger(staleDays)) {
399
+ return staleDays;
400
+ }
401
+ return DEFAULT_STALE_DAYS;
402
+ } catch {
403
+ return DEFAULT_STALE_DAYS;
404
+ }
405
+ }
@@ -0,0 +1,53 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Barrel export for the knowledge module — parser, serializer, schema, and types (RFC-0660).</purpose>
4
+ <non-goals>
5
+ <item>Do not re-export from @warpgogol/* — forge src/ is portable.</item>
6
+ </non-goals>
7
+ </MODULE_CONTRACT>
8
+ <CHANGE_SUMMARY>
9
+ <item>RFC-0660: initial knowledge module barrel export.</item>
10
+ <item>RFC-0663: added promote.ts exports for cross-skill duplicate detection and promotion planning.</item>
11
+ </CHANGE_SUMMARY>
12
+ */
13
+
14
+ export { parseKnowledgeFile } from "./parse.ts";
15
+ export { serializeKnowledgeFile } from "./serialize.ts";
16
+ export {
17
+ knowledgeEntryMetaSchema,
18
+ type KnowledgeLayer,
19
+ type KnowledgeEntryStatus,
20
+ type KnowledgeEntryMeta,
21
+ type KnowledgeEntry,
22
+ type LegacySection,
23
+ type ParseIssue,
24
+ type ParsedKnowledgeFile,
25
+ } from "./schema.ts";
26
+ export {
27
+ planCompaction,
28
+ executeCompaction,
29
+ resolveRetentionDays,
30
+ resolveStaleDays,
31
+ DEFAULT_RETENTION_DAYS,
32
+ DEFAULT_STALE_DAYS,
33
+ type CompactOptions,
34
+ type CompactAction,
35
+ type CompactActionKind,
36
+ type CompactFilePlan,
37
+ type CompactFileResult,
38
+ type CompactReport,
39
+ } from "./compact.ts";
40
+ export {
41
+ computeLayerBudgets,
42
+ resolveKnowledgeBudgets,
43
+ DEFAULT_KNOWLEDGE_BUDGETS,
44
+ type KnowledgeBudgets,
45
+ type LayerBudgetReport,
46
+ } from "./budgets.ts";
47
+ export {
48
+ normalizeTitle,
49
+ detectDuplicatePrinciples,
50
+ planPromotion,
51
+ type DuplicatePair,
52
+ type PromotionPlan,
53
+ } from "./promote.ts";