@warpgogol/forge 2.7.0 → 2.8.1

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 (268) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +23 -13
  3. package/README.uk.md +19 -12
  4. package/dist/src/config/forge-config.d.ts +1 -1
  5. package/dist/src/config/forge-config.d.ts.map +1 -1
  6. package/dist/src/config/forge-config.js +2 -2
  7. package/dist/src/config/forge-config.js.map +1 -1
  8. package/dist/src/onboarding/agents-generate.d.ts +2 -1
  9. package/dist/src/onboarding/agents-generate.d.ts.map +1 -1
  10. package/dist/src/onboarding/agents-generate.js +24 -4
  11. package/dist/src/onboarding/agents-generate.js.map +1 -1
  12. package/dist/src/profiles/profile-schema.d.ts +2 -0
  13. package/dist/src/profiles/profile-schema.d.ts.map +1 -1
  14. package/dist/src/profiles/profile-schema.js +1 -0
  15. package/dist/src/profiles/profile-schema.js.map +1 -1
  16. package/dist/src/profiles/stack-profile.d.ts +1 -0
  17. package/dist/src/profiles/stack-profile.d.ts.map +1 -1
  18. package/dist/src/profiles/stack-profile.js +2 -0
  19. package/dist/src/profiles/stack-profile.js.map +1 -1
  20. package/os/adr/adr.module.ts +158 -0
  21. package/os/adr/frontmatter-io.ts +80 -0
  22. package/os/adr/handlers/archive.ts +244 -0
  23. package/os/adr/handlers/implement-stamp.ts +331 -0
  24. package/os/adr/handlers/list-create.ts +207 -0
  25. package/os/adr/handlers/validate.test.ts +232 -0
  26. package/os/adr/handlers/validate.ts +429 -0
  27. package/os/adr/index.ts +33 -0
  28. package/os/adr/types.ts +141 -0
  29. package/os/audit/audit.module.ts +48 -0
  30. package/os/audit/frontmatter-io.ts +84 -0
  31. package/os/audit/handlers/archive.ts +239 -0
  32. package/os/audit/index.ts +23 -0
  33. package/os/audit/types.ts +29 -0
  34. package/os/compass/compass.module.ts +186 -0
  35. package/os/compass/handlers/compass-audit-handler.ts +382 -0
  36. package/os/compass/handlers/compass-change-summary-handler.ts +272 -0
  37. package/os/compass/handlers/compass-inventory-handler.ts +294 -0
  38. package/os/compass/handlers/compass-inventory.ts +521 -0
  39. package/os/compass/handlers/git-revision.ts +137 -0
  40. package/os/compass/handlers/resolve-scan-root-workpiece.test.ts +65 -0
  41. package/os/compass/handlers/resolve-scan-root.ts +79 -0
  42. package/os/compass/index.ts +33 -0
  43. package/os/core/core.module.ts +948 -0
  44. package/os/core/handlers/assets-check.ts +122 -0
  45. package/os/core/handlers/assets-helpers.ts +174 -0
  46. package/os/core/handlers/assets-list.ts +99 -0
  47. package/os/core/handlers/build.ts +155 -0
  48. package/os/core/handlers/determinism-check.ts +354 -0
  49. package/os/core/handlers/dev.ts +127 -0
  50. package/os/core/handlers/invariant-engine.test.ts +681 -0
  51. package/os/core/handlers/knowledge-compact.ts +248 -0
  52. package/os/core/handlers/lifecycle-handlers.test.ts +524 -0
  53. package/os/core/handlers/note-frontmatter-validate.test.ts +93 -0
  54. package/os/core/handlers/note-link-validate.test.ts +117 -0
  55. package/os/core/handlers/note-orphan-detect.test.ts +94 -0
  56. package/os/core/handlers/package-health.test.ts +226 -0
  57. package/os/core/handlers/package-health.ts +231 -0
  58. package/os/core/handlers/pinned-check.ts +200 -0
  59. package/os/core/handlers/pinned-init.ts +333 -0
  60. package/os/core/handlers/pinned-types.ts +50 -0
  61. package/os/core/handlers/pinned-validate.ts +301 -0
  62. package/os/core/handlers/profile-resolve.ts +92 -0
  63. package/os/core/handlers/release-prepare.ts +294 -0
  64. package/os/core/handlers/release-publish.ts +211 -0
  65. package/os/core/handlers/validate.ts +262 -0
  66. package/os/core/handlers/workspace-deps.ts +55 -0
  67. package/os/core/index.ts +13 -0
  68. package/os/exploration/exploration.module.ts +90 -0
  69. package/os/exploration/frontmatter-io.ts +77 -0
  70. package/os/exploration/handlers/archive.ts +159 -0
  71. package/os/exploration/handlers/list.ts +77 -0
  72. package/os/exploration/handlers/show.ts +107 -0
  73. package/os/exploration/index.ts +25 -0
  74. package/os/exploration/types.ts +65 -0
  75. package/os/mission/handlers/archive.test.ts +391 -0
  76. package/os/mission/handlers/archive.ts +453 -0
  77. package/os/mission/index.ts +1 -0
  78. package/os/mission/mission.module.ts +48 -0
  79. package/os/mission/types.ts +41 -0
  80. package/os/naming/index.ts +14 -0
  81. package/os/naming/naming-convention.test.ts +163 -0
  82. package/os/naming/naming-convention.ts +378 -0
  83. package/os/naming/naming.module.ts +36 -0
  84. package/os/notes/index.ts +10 -0
  85. package/os/notes/notes.module.ts +109 -0
  86. package/os/plan/frontmatter-io.ts +82 -0
  87. package/os/plan/handlers/archive.ts +238 -0
  88. package/os/plan/index.ts +23 -0
  89. package/os/plan/plan.module.ts +48 -0
  90. package/os/plan/types.ts +29 -0
  91. package/os/program/discovery.ts +294 -0
  92. package/os/program/handlers/complete.ts +419 -0
  93. package/os/program/handlers/lease.ts +433 -0
  94. package/os/program/handlers/seal.ts +332 -0
  95. package/os/program/handlers/validate.ts +213 -0
  96. package/os/program/lease.ts +156 -0
  97. package/os/program/program.module.ts +212 -0
  98. package/os/program/schemas.ts +218 -0
  99. package/os/program/state.ts +264 -0
  100. package/os/rfc/acceptance.ts +344 -0
  101. package/os/rfc/decision-log.ts +310 -0
  102. package/os/rfc/dna-trace.ts +280 -0
  103. package/os/rfc/frontmatter-io.test.ts +132 -0
  104. package/os/rfc/frontmatter-io.ts +130 -0
  105. package/os/rfc/handlers/archive.ts +300 -0
  106. package/os/rfc/handlers/check.ts +238 -0
  107. package/os/rfc/handlers/implement-stamp.ts +515 -0
  108. package/os/rfc/handlers/index-graph.ts +237 -0
  109. package/os/rfc/handlers/lifecycle.test.ts +127 -0
  110. package/os/rfc/handlers/lifecycle.ts +249 -0
  111. package/os/rfc/handlers/list-create.ts +296 -0
  112. package/os/rfc/handlers/pipeline-status.ts +173 -0
  113. package/os/rfc/handlers/shared.ts +121 -0
  114. package/os/rfc/handlers/supersede-propose.ts +241 -0
  115. package/os/rfc/handlers/validate-rules.test.ts +891 -0
  116. package/os/rfc/handlers/validate-rules.ts +984 -0
  117. package/os/rfc/handlers/validate.ts +166 -0
  118. package/os/rfc/handlers.ts +19 -0
  119. package/os/rfc/index.ts +97 -0
  120. package/os/rfc/rfc.module.ts +416 -0
  121. package/os/rfc/types.ts +596 -0
  122. package/os/rfc/verification-evidence.ts +244 -0
  123. package/os/session/atif-parser.ts +166 -0
  124. package/os/session/frontmatter-io.ts +163 -0
  125. package/os/session/handlers/archive.ts +288 -0
  126. package/os/session/handlers/list.ts +174 -0
  127. package/os/session/handlers/save.ts +348 -0
  128. package/os/session/handlers/validate.ts +250 -0
  129. package/os/session/index.ts +50 -0
  130. package/os/session/session.module.ts +126 -0
  131. package/os/session/types.ts +203 -0
  132. package/os/spec/live-spec-list-show-validate.test.ts +257 -0
  133. package/os/spec/live-spec-list.ts +78 -0
  134. package/os/spec/live-spec-merge.test.ts +261 -0
  135. package/os/spec/live-spec-merge.ts +413 -0
  136. package/os/spec/live-spec-show.ts +117 -0
  137. package/os/spec/live-spec-types.ts +93 -0
  138. package/os/spec/live-spec-validate.ts +170 -0
  139. package/os/spec/spec-materialize.ts +390 -0
  140. package/os/spec/spec-schema.ts +174 -0
  141. package/os/spec/spec-status.ts +284 -0
  142. package/os/spec/spec-validate.ts +513 -0
  143. package/os/spec/spec.module.ts +135 -0
  144. package/os/werkstatt/handlers/lock.ts +168 -0
  145. package/os/werkstatt/handlers/schema.ts +47 -0
  146. package/os/werkstatt/handlers/werkstatt-lock-recover.ts +175 -0
  147. package/os/werkstatt/handlers/werkstatt-lock-status.ts +73 -0
  148. package/os/werkstatt/handlers/werkstatt-operation-validate.ts +104 -0
  149. package/os/werkstatt/index.ts +39 -0
  150. package/os/werkstatt/werkstatt.module.ts +62 -0
  151. package/os/workflow/handlers.ts +304 -0
  152. package/os/workflow/index.ts +28 -0
  153. package/os/workflow/types.ts +99 -0
  154. package/os/workflow/workflow.module.ts +54 -0
  155. package/package.json +3 -3
  156. package/profiles/godot-csharp.yaml +5 -4
  157. package/profiles/knowledge-typescript-turborepo.yaml +110 -0
  158. package/profiles/phaser-turborepo.yaml +5 -5
  159. package/profiles/root-agents-godot.md +133 -0
  160. package/skills/_shared/fo-session-summary.md +132 -16
  161. package/skills/fo/fo-add-tests/pbt-guide.md +13 -13
  162. package/skills/fo/fo-doc-audit/SKILL.md +6 -6
  163. package/skills/fo/fo-handoff/SKILL.md +5 -0
  164. package/skills/fo/fo-idea/SKILL.md +1 -1
  165. package/skills/fo/fo-review/SKILL.md +1 -1
  166. package/skills/fo/fo-session-retro/SKILL.md +8 -0
  167. package/skills/fo/fo-session-save/SKILL.md +8 -0
  168. package/src/cli-output.ts +59 -0
  169. package/src/config/forge-config.ts +524 -0
  170. package/src/forge-module.ts +39 -0
  171. package/src/index.ts +172 -0
  172. package/src/knowledge/budgets.ts +202 -0
  173. package/src/knowledge/compact.ts +405 -0
  174. package/src/knowledge/index.ts +53 -0
  175. package/src/knowledge/parse.ts +227 -0
  176. package/src/knowledge/promote.ts +156 -0
  177. package/src/knowledge/schema.ts +112 -0
  178. package/src/knowledge/serialize.ts +72 -0
  179. package/src/migration-adapters/git-utils.ts +71 -0
  180. package/src/migration-adapters/ignored-files.ts +221 -0
  181. package/src/migration-adapters/index.ts +21 -0
  182. package/src/migration-adapters/node-typescript-pnpm/index.ts +189 -0
  183. package/src/migration-adapters/phaser-pnpm/index.ts +189 -0
  184. package/src/migration-adapters/registry.ts +61 -0
  185. package/src/migration-adapters/types.ts +68 -0
  186. package/src/onboarding/agents-generate.ts +443 -0
  187. package/src/onboarding/create.ts +427 -0
  188. package/src/onboarding/doctor.ts +1297 -0
  189. package/src/onboarding/init.ts +337 -0
  190. package/src/onboarding/invariant-engine.ts +448 -0
  191. package/src/onboarding/memory-scaffold.ts +189 -0
  192. package/src/onboarding/nested-agents-generate.ts +93 -0
  193. package/src/onboarding/nested-agents-templates.ts +233 -0
  194. package/src/onboarding/profile-validate.ts +139 -0
  195. package/src/onboarding/scaffold-project.ts +299 -0
  196. package/src/onboarding/scaffold.ts +143 -0
  197. package/src/onboarding/upgrade.ts +558 -0
  198. package/src/onboarding/workspace-discovery.ts +167 -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 +51 -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 +217 -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/fs-atomic.ts +93 -0
  256. package/src/utils/fs-idempotent.ts +41 -0
  257. package/src/utils/fs-trash-sync.ts +37 -0
  258. package/src/utils/fs-trash.ts +24 -0
  259. package/src/utils/fs.ts +74 -0
  260. package/src/utils/generated-marker.ts +166 -0
  261. package/src/utils/hash.ts +19 -0
  262. package/src/utils/index.ts +29 -0
  263. package/src/utils/string-utils.ts +19 -0
  264. package/src/validators/note-frontmatter-validate.ts +142 -0
  265. package/src/validators/note-link-validate.ts +145 -0
  266. package/src/validators/note-orphan-detect.ts +146 -0
  267. package/src/validators/port-validate.ts +97 -0
  268. package/src/validators/skill-validate.ts +831 -0
@@ -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";
@@ -0,0 +1,227 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Tolerant markdown parser for knowledge files — produces ParsedKnowledgeFile with entries, legacy sections, and parse issues (RFC-0660).</purpose>
4
+ <non-goals>
5
+ <item>Do not validate entry uniqueness or cross-references — that is handled by SKILL-20 in skill-validate.ts.</item>
6
+ <item>Do not serialize — that is handled by serialize.ts.</item>
7
+ </non-goals>
8
+ </MODULE_CONTRACT>
9
+ <CHANGE_SUMMARY>
10
+ <item>RFC-0660: initial tolerant parser for structured knowledge entries.</item>
11
+ </CHANGE_SUMMARY>
12
+ */
13
+
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { parse as parseYaml } from "yaml";
17
+ import {
18
+ knowledgeEntryMetaSchema,
19
+ type KnowledgeLayer,
20
+ type KnowledgeEntry,
21
+ type KnowledgeEntryMeta,
22
+ type LegacySection,
23
+ type ParseIssue,
24
+ type ParsedKnowledgeFile,
25
+ } from "./schema.ts";
26
+
27
+ const ENTRY_HEADING_PATTERN = /^###\s+(K-\d{4})\s*:\s*(.+)$/;
28
+ const KNOWLEDGE_ENTRY_FENCE = "knowledge-entry";
29
+ const LAYER_COMMENT_PATTERN = /<!--\s*knowledge-layer:\s*(L[012])\s*-->/;
30
+
31
+ const FILENAME_LAYER_MAP: Record<string, KnowledgeLayer> = {
32
+ "qa-log.md": "L0",
33
+ "fix-patterns.md": "L1",
34
+ "learned-principles.md": "L2",
35
+ };
36
+
37
+ function detectLayer(content: string, fileName: string): KnowledgeLayer | null {
38
+ const commentMatch = content.match(LAYER_COMMENT_PATTERN);
39
+ if (commentMatch) {
40
+ return commentMatch[1] as KnowledgeLayer;
41
+ }
42
+ return FILENAME_LAYER_MAP[fileName] ?? null;
43
+ }
44
+
45
+ export function parseKnowledgeFile(filePath: string): ParsedKnowledgeFile {
46
+ let content: string;
47
+ try {
48
+ content = fs.readFileSync(filePath, "utf-8");
49
+ } catch {
50
+ // File read errors (EISDIR, ENOENT, etc.) — return empty result
51
+ return {
52
+ path: filePath,
53
+ layer: null,
54
+ preamble: "",
55
+ entries: [],
56
+ legacySections: [],
57
+ parseIssues: [],
58
+ isKnowledgeAdjacent: true,
59
+ };
60
+ }
61
+ const fileName = path.basename(filePath);
62
+ const lines = content.split(/\r?\n/);
63
+ const layer = detectLayer(content, fileName);
64
+
65
+ const entries: KnowledgeEntry[] = [];
66
+ const legacySections: LegacySection[] = [];
67
+ const parseIssues: ParseIssue[] = [];
68
+
69
+ let i = 0;
70
+ let preambleEnd = 0;
71
+
72
+ // Find the first entry heading or the first non-preamble content
73
+ let firstEntryLine = -1;
74
+ for (let j = 0; j < lines.length; j++) {
75
+ if (ENTRY_HEADING_PATTERN.test(lines[j])) {
76
+ firstEntryLine = j;
77
+ break;
78
+ }
79
+ }
80
+
81
+ // If no entry headings and no layer declaration, it's a knowledge-adjacent file
82
+ const isKnowledgeAdjacent = firstEntryLine === -1 && layer === null;
83
+
84
+ if (isKnowledgeAdjacent) {
85
+ return {
86
+ path: filePath,
87
+ layer: null,
88
+ preamble: content,
89
+ entries: [],
90
+ legacySections: [],
91
+ parseIssues: [],
92
+ isKnowledgeAdjacent: true,
93
+ };
94
+ }
95
+
96
+ // Preamble is everything before the first entry heading
97
+ if (firstEntryLine === -1) {
98
+ // Has layer but no entries — all content is preamble (structured-empty file)
99
+ return {
100
+ path: filePath,
101
+ layer,
102
+ preamble: content,
103
+ entries: [],
104
+ legacySections: [],
105
+ parseIssues: [],
106
+ isKnowledgeAdjacent: false,
107
+ };
108
+ }
109
+
110
+ preambleEnd = firstEntryLine;
111
+ const preamble = lines.slice(0, preambleEnd).join("\n");
112
+
113
+ // Parse entries and legacy sections
114
+ i = preambleEnd;
115
+ while (i < lines.length) {
116
+ const headingMatch = lines[i].match(ENTRY_HEADING_PATTERN);
117
+
118
+ if (headingMatch) {
119
+ const entryId = headingMatch[1];
120
+ const title = headingMatch[2].trim();
121
+ const entryLineStart = i + 1; // 1-based
122
+ i++;
123
+
124
+ // Look for fenced knowledge-entry block immediately after heading
125
+ let meta: KnowledgeEntryMeta | null = null;
126
+ let metaBlockStart = -1;
127
+ let _metaBlockEnd = -1;
128
+
129
+ // Skip blank lines between heading and fence
130
+ while (i < lines.length && lines[i].trim() === "") {
131
+ i++;
132
+ }
133
+
134
+ if (i < lines.length && lines[i].trim().startsWith("```")) {
135
+ const fenceTag = lines[i].trim().replace(/^```/, "").trim();
136
+ if (fenceTag === KNOWLEDGE_ENTRY_FENCE) {
137
+ metaBlockStart = i + 1; // 1-based line after fence start
138
+ i++;
139
+ const yamlLines: string[] = [];
140
+ while (i < lines.length && !lines[i].trim().startsWith("```")) {
141
+ yamlLines.push(lines[i]);
142
+ i++;
143
+ }
144
+ if (i < lines.length && lines[i].trim().startsWith("```")) {
145
+ _metaBlockEnd = i + 1; // 1-based fence closing line
146
+ i++;
147
+
148
+ const yamlText = yamlLines.join("\n");
149
+ try {
150
+ const raw = parseYaml(yamlText) as Record<string, unknown>;
151
+ const result = knowledgeEntryMetaSchema.safeParse(raw);
152
+ if (result.success) {
153
+ meta = result.data as KnowledgeEntryMeta;
154
+ } else {
155
+ for (const issue of result.error.issues) {
156
+ parseIssues.push({
157
+ line: metaBlockStart,
158
+ message: `Entry ${entryId}: ${issue.message}`,
159
+ entryId,
160
+ });
161
+ }
162
+ }
163
+ } catch (err) {
164
+ parseIssues.push({
165
+ line: metaBlockStart,
166
+ message: `Entry ${entryId}: YAML parse error: ${(err as Error).message}`,
167
+ entryId,
168
+ });
169
+ }
170
+ } else {
171
+ parseIssues.push({
172
+ line: metaBlockStart,
173
+ message: `Entry ${entryId}: unterminated knowledge-entry fence`,
174
+ entryId,
175
+ });
176
+ }
177
+ }
178
+ }
179
+
180
+ // Collect body until next entry heading or EOF
181
+ const bodyLines: string[] = [];
182
+ while (i < lines.length && !ENTRY_HEADING_PATTERN.test(lines[i])) {
183
+ bodyLines.push(lines[i]);
184
+ i++;
185
+ }
186
+ const body = bodyLines.join("\n").replace(/\n+$/, "");
187
+
188
+ if (meta) {
189
+ entries.push({
190
+ meta,
191
+ title,
192
+ body,
193
+ lineStart: entryLineStart,
194
+ });
195
+ } else if (metaBlockStart === -1) {
196
+ // Heading exists but no metadata block followed — it's a legacy section
197
+ legacySections.push({
198
+ text: lines.slice(entryLineStart - 1, i).join("\n"),
199
+ lineStart: entryLineStart,
200
+ });
201
+ }
202
+ // If meta parsing failed, the parse issue is already recorded
203
+ } else {
204
+ // Non-entry content — collect as legacy section
205
+ const legacyStart = i + 1; // 1-based
206
+ const legacyLines: string[] = [];
207
+ while (i < lines.length && !ENTRY_HEADING_PATTERN.test(lines[i])) {
208
+ legacyLines.push(lines[i]);
209
+ i++;
210
+ }
211
+ legacySections.push({
212
+ text: legacyLines.join("\n"),
213
+ lineStart: legacyStart,
214
+ });
215
+ }
216
+ }
217
+
218
+ return {
219
+ path: filePath,
220
+ layer,
221
+ preamble,
222
+ entries,
223
+ legacySections,
224
+ parseIssues,
225
+ isKnowledgeAdjacent: false,
226
+ };
227
+ }