@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,503 @@
1
+ import { test, expect, describe, beforeAll, afterAll } from "vitest";
2
+ import { join } from "node:path";
3
+ import { mkdtemp, rm, mkdir, writeFile, readFile, readdir } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import type { ForgeCommandInput, ForgeRuntimeContext, ForgeFlagValue } from "../../src/types.ts";
6
+ import { runSessionSave } from "../../os/session/handlers/save.ts";
7
+ import { runSessionArchive } from "../../os/session/handlers/archive.ts";
8
+ import { runSessionValidate } from "../../os/session/handlers/validate.ts";
9
+ import { runSessionList } from "../../os/session/handlers/list.ts";
10
+ import type {
11
+ SessionSaveResult,
12
+ SessionArchiveResult,
13
+ SessionValidationResult,
14
+ SessionListResult,
15
+ } from "../../os/session/types.ts";
16
+
17
+ async function makeTempDir(): Promise<string> {
18
+ return mkdtemp(join(tmpdir(), "forge-session-test-"));
19
+ }
20
+
21
+ function makeInput(
22
+ flags: Record<string, ForgeFlagValue> = {},
23
+ args: string[] = [],
24
+ ): ForgeCommandInput {
25
+ return { argv: [], args, flags };
26
+ }
27
+
28
+ function makeContext(workspaceRoot: string): ForgeRuntimeContext {
29
+ return {
30
+ workspaceRoot,
31
+ logger: {
32
+ info: () => {},
33
+ warn: () => {},
34
+ error: () => {},
35
+ success: () => {},
36
+ section: () => {},
37
+ },
38
+ dryRun: false,
39
+ outputFormat: "json",
40
+ };
41
+ }
42
+
43
+ const SAMPLE_ATIF = `2026-07-26T12:04:00+02:00
44
+ User: Let's implement RFC-0537 for session documentation.
45
+ Assistant: I'll start by creating the types and handlers.
46
+ User: Run rfc.validate to check the RFC.
47
+ Assistant: Done. The RFC is valid.
48
+ Commit: a3f2c1d implemented session save handler.
49
+ Files: packages/forge/os/session/handlers/save.ts
50
+ Commands: session.save, session.archive, rfc.validate`;
51
+
52
+ describe("session.save", () => {
53
+ let dir: string;
54
+
55
+ beforeAll(async () => {
56
+ dir = await makeTempDir();
57
+ await mkdir(join(dir, "docs/sessions/.raw"), { recursive: true });
58
+ await mkdir(join(dir, "docs/sessions/archive"), { recursive: true });
59
+ });
60
+
61
+ afterAll(async () => {
62
+ await rm(dir, { recursive: true, force: true });
63
+ });
64
+
65
+ test("converts raw ATIF file to structured markdown", async () => {
66
+ const rawPath = join(dir, "docs/sessions/.raw", "2026-07-26-session.atif");
67
+ await writeFile(rawPath, SAMPLE_ATIF, "utf-8");
68
+
69
+ const result = await runSessionSave(makeInput(), makeContext(dir));
70
+ const data = result.data as SessionSaveResult & { skipped: unknown[] };
71
+
72
+ expect(data.command).toBe("session.save");
73
+ expect(data.status).toBe("ok");
74
+ expect(data.id).toMatch(/^\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-[0-9a-f]{6}$/);
75
+ expect(data.file).toContain("docs/sessions/");
76
+ expect(data.file).toMatch(/\.md$/);
77
+ expect(data.rawDeleted).toBe(true);
78
+ expect(data.types.length).toBeGreaterThan(0);
79
+ expect(data.extractedMetadata.relatedRfcs).toContain("RFC-0537");
80
+ expect(data.extractedMetadata.commands).toContain("session.save");
81
+
82
+ const mdContent = await readFile(join(dir, data.file), "utf-8");
83
+ expect(mdContent).toContain("---");
84
+ expect(mdContent).toContain("## Transcript");
85
+ expect(mdContent).toContain(`id: ${data.id}`);
86
+ });
87
+
88
+ test("is idempotent — same raw file produces same output, skips on second run", async () => {
89
+ const rawPath = join(dir, "docs/sessions/.raw", "2026-07-26-session-2.atif");
90
+ await writeFile(rawPath, SAMPLE_ATIF, "utf-8");
91
+
92
+ const result1 = await runSessionSave(makeInput({ "keep-raw": true }), makeContext(dir));
93
+ const data1 = result1.data as SessionSaveResult & { skipped: unknown[] };
94
+ expect(data1.id).toBeDefined();
95
+
96
+ const result2 = await runSessionSave(makeInput({ "keep-raw": true }), makeContext(dir));
97
+ const data2 = result2.data as SessionSaveResult & { skipped: unknown[] };
98
+ expect(data2.skipped.length).toBeGreaterThan(0);
99
+ expect(data2.skipped[0]).toHaveProperty("reason", "already converted");
100
+ });
101
+
102
+ test("no raw files — exit zero with summary", async () => {
103
+ const emptyDir = await makeTempDir();
104
+ try {
105
+ await mkdir(join(emptyDir, "docs/sessions/.raw"), { recursive: true });
106
+ const result = await runSessionSave(makeInput(), makeContext(emptyDir));
107
+ expect(result.summary).toContain("No raw files");
108
+ } finally {
109
+ await rm(emptyDir, { recursive: true, force: true });
110
+ }
111
+ });
112
+
113
+ test("--raw-file not found — throws error", async () => {
114
+ await expect(
115
+ runSessionSave(makeInput({ "raw-file": "nonexistent.atif" }), makeContext(dir)),
116
+ ).rejects.toThrow("Raw file not found");
117
+ });
118
+
119
+ test("--dry-run does not write files", async () => {
120
+ const dryDir = await makeTempDir();
121
+ try {
122
+ await mkdir(join(dryDir, "docs/sessions/.raw"), { recursive: true });
123
+ const rawPath = join(dryDir, "docs/sessions/.raw", "test-dry.atif");
124
+ await writeFile(rawPath, "test content", "utf-8");
125
+
126
+ const ctx = makeContext(dryDir);
127
+ ctx.dryRun = true;
128
+ const result = await runSessionSave(makeInput(), ctx);
129
+ const data = result.data as SessionSaveResult;
130
+ expect(data.dryRun).toBe(true);
131
+
132
+ const sessionsDir = join(dryDir, "docs/sessions");
133
+ const files = await readdir(sessionsDir).catch(() => []);
134
+ const mdFiles = files.filter((f) => f.endsWith(".md"));
135
+ expect(mdFiles.length).toBe(0);
136
+ } finally {
137
+ await rm(dryDir, { recursive: true, force: true });
138
+ }
139
+ });
140
+ });
141
+
142
+ describe("session.archive", () => {
143
+ let dir: string;
144
+
145
+ beforeAll(async () => {
146
+ dir = await makeTempDir();
147
+ await mkdir(join(dir, "docs/sessions/archive"), { recursive: true });
148
+ });
149
+
150
+ afterAll(async () => {
151
+ await rm(dir, { recursive: true, force: true });
152
+ });
153
+
154
+ test("moves old session to archive/", async () => {
155
+ const oldDate = new Date();
156
+ oldDate.setDate(oldDate.getDate() - 10);
157
+ const oldId = `2026-07-16-12-00-00-aaaaaa`;
158
+ const oldFile = join(dir, "docs/sessions", `${oldId}.md`);
159
+ const content = `---
160
+ id: ${oldId}
161
+ date: "${oldDate.toISOString()}"
162
+ types: [implementation]
163
+ summary: "Old session"
164
+ relatedRfcs: []
165
+ relatedArtifacts: []
166
+ decisions: []
167
+ commits: []
168
+ files: []
169
+ commands: []
170
+ ---
171
+
172
+ # Session: ${oldId}
173
+
174
+ ## Transcript
175
+
176
+ Old content.
177
+ `;
178
+ await writeFile(oldFile, content, "utf-8");
179
+
180
+ const result = await runSessionArchive(makeInput({ "max-age-days": "7" }), makeContext(dir));
181
+ const data = result.data as SessionArchiveResult;
182
+
183
+ expect(data.command).toBe("session.archive");
184
+ expect(data.moved.length).toBe(1);
185
+ expect(data.moved[0]!.direction).toBe("into-archive");
186
+ expect(data.moved[0]!.id).toBe(oldId);
187
+ });
188
+
189
+ test("skips young session", async () => {
190
+ const youngId = `2026-07-25-12-00-00-bbbbbb`;
191
+ const youngDate = new Date();
192
+ youngDate.setDate(youngDate.getDate() - 1);
193
+ const youngFile = join(dir, "docs/sessions", `${youngId}.md`);
194
+ const content = `---
195
+ id: ${youngId}
196
+ date: "${youngDate.toISOString()}"
197
+ types: [freeform]
198
+ summary: "Young session"
199
+ relatedRfcs: []
200
+ relatedArtifacts: []
201
+ decisions: []
202
+ commits: []
203
+ files: []
204
+ commands: []
205
+ ---
206
+
207
+ # Session: ${youngId}
208
+
209
+ ## Transcript
210
+
211
+ Young content.
212
+ `;
213
+ await writeFile(youngFile, content, "utf-8");
214
+
215
+ const result = await runSessionArchive(makeInput({ "max-age-days": "7" }), makeContext(dir));
216
+ const data = result.data as SessionArchiveResult;
217
+
218
+ const youngMove = data.moved.find((m) => m.id === youngId);
219
+ expect(youngMove).toBeUndefined();
220
+ });
221
+
222
+ test("bidirectional — moves young archived file back", async () => {
223
+ const archivedId = `2026-07-25-12-00-00-cccccc`;
224
+ const archivedDate = new Date();
225
+ archivedDate.setDate(archivedDate.getDate() - 1);
226
+ const archFile = join(dir, "docs/sessions/archive", `${archivedId}.md`);
227
+ const content = `---
228
+ id: ${archivedId}
229
+ date: "${archivedDate.toISOString()}"
230
+ types: [freeform]
231
+ summary: "Archived young session"
232
+ relatedRfcs: []
233
+ relatedArtifacts: []
234
+ decisions: []
235
+ commits: []
236
+ files: []
237
+ commands: []
238
+ ---
239
+
240
+ # Session: ${archivedId}
241
+
242
+ ## Transcript
243
+
244
+ Content.
245
+ `;
246
+ await writeFile(archFile, content, "utf-8");
247
+
248
+ const result = await runSessionArchive(makeInput({ "max-age-days": "7" }), makeContext(dir));
249
+ const data = result.data as SessionArchiveResult;
250
+
251
+ const move = data.moved.find((m) => m.id === archivedId);
252
+ expect(move).toBeDefined();
253
+ expect(move!.direction).toBe("out-of-archive");
254
+ });
255
+
256
+ test("invalid --max-age-days throws", async () => {
257
+ await expect(
258
+ runSessionArchive(makeInput({ "max-age-days": "0" }), makeContext(dir)),
259
+ ).rejects.toThrow("Invalid --max-age-days");
260
+ });
261
+ });
262
+
263
+ describe("session.validate", () => {
264
+ let dir: string;
265
+
266
+ beforeAll(async () => {
267
+ dir = await makeTempDir();
268
+ await mkdir(join(dir, "docs/sessions"), { recursive: true });
269
+ await mkdir(join(dir, "docs/rfcs"), { recursive: true });
270
+
271
+ const rfcContent = `---
272
+ id: RFC-0537
273
+ title: Test RFC
274
+ status: accepted
275
+ ---
276
+
277
+ # RFC-0537
278
+
279
+ Test content.
280
+ `;
281
+ await writeFile(join(dir, "docs/rfcs", "rfc-0537-test.md"), rfcContent, "utf-8");
282
+ });
283
+
284
+ afterAll(async () => {
285
+ await rm(dir, { recursive: true, force: true });
286
+ });
287
+
288
+ test("passes for valid session file", async () => {
289
+ const validId = "2026-07-26-12-00-00-dddddd";
290
+ const content = `---
291
+ id: ${validId}
292
+ date: "2026-07-26T12:00:00+02:00"
293
+ types: [implementation]
294
+ summary: "Valid session"
295
+ relatedRfcs: [RFC-0537]
296
+ relatedArtifacts: []
297
+ decisions: []
298
+ commits: []
299
+ files: []
300
+ commands: []
301
+ ---
302
+
303
+ # Session: ${validId}
304
+
305
+ ## Transcript
306
+
307
+ Content.
308
+ `;
309
+ await writeFile(join(dir, "docs/sessions", `${validId}.md`), content, "utf-8");
310
+
311
+ const result = await runSessionValidate(makeInput(), makeContext(dir));
312
+ const data = result.data as SessionValidationResult;
313
+
314
+ expect(data.status).toBe("pass");
315
+ const errors = data.violations.filter((v) => v.severity === "error");
316
+ expect(errors.length).toBe(0);
317
+ });
318
+
319
+ test("SES-01: missing required field", async () => {
320
+ const badId = "2026-07-26-12-00-00-eeeeee";
321
+ const content = `---
322
+ id: ${badId}
323
+ date: "2026-07-26T12:00:00+02:00"
324
+ ---
325
+
326
+ # Session: ${badId}
327
+
328
+ ## Transcript
329
+
330
+ Content.
331
+ `;
332
+ await writeFile(join(dir, "docs/sessions", `${badId}.md`), content, "utf-8");
333
+
334
+ const result = await runSessionValidate(makeInput(), makeContext(dir));
335
+ const data = result.data as SessionValidationResult;
336
+
337
+ const ses01 = data.violations.filter((v) => v.rule === "SES-01" && v.severity === "error");
338
+ expect(ses01.length).toBeGreaterThan(0);
339
+ expect(ses01.some((v) => v.message.includes("types"))).toBe(true);
340
+ });
341
+
342
+ test("SES-02: id does not match filename", async () => {
343
+ const fileName = "2026-07-26-12-00-00-ffffff";
344
+ const content = `---
345
+ id: wrong-id
346
+ date: "2026-07-26T12:00:00+02:00"
347
+ types: [freeform]
348
+ summary: ""
349
+ relatedRfcs: []
350
+ relatedArtifacts: []
351
+ decisions: []
352
+ commits: []
353
+ files: []
354
+ commands: []
355
+ ---
356
+
357
+ # Session: wrong-id
358
+
359
+ ## Transcript
360
+
361
+ Content.
362
+ `;
363
+ await writeFile(join(dir, "docs/sessions", `${fileName}.md`), content, "utf-8");
364
+
365
+ const result = await runSessionValidate(makeInput(), makeContext(dir));
366
+ const data = result.data as SessionValidationResult;
367
+
368
+ const ses02 = data.violations.filter((v) => v.rule === "SES-02");
369
+ expect(ses02.length).toBe(1);
370
+ });
371
+
372
+ test("SES-03: relatedRfcs references non-existent RFC", async () => {
373
+ const badId = "2026-07-26-12-00-00-111111";
374
+ const content = `---
375
+ id: ${badId}
376
+ date: "2026-07-26T12:00:00+02:00"
377
+ types: [freeform]
378
+ summary: ""
379
+ relatedRfcs: [RFC-9999]
380
+ relatedArtifacts: []
381
+ decisions: []
382
+ commits: []
383
+ files: []
384
+ commands: []
385
+ ---
386
+
387
+ # Session: ${badId}
388
+
389
+ ## Transcript
390
+
391
+ Content.
392
+ `;
393
+ await writeFile(join(dir, "docs/sessions", `${badId}.md`), content, "utf-8");
394
+
395
+ const result = await runSessionValidate(makeInput(), makeContext(dir));
396
+ const data = result.data as SessionValidationResult;
397
+
398
+ const ses03 = data.violations.filter((v) => v.rule === "SES-03");
399
+ expect(ses03.length).toBe(1);
400
+ });
401
+
402
+ test("no session files — exit zero", async () => {
403
+ const emptyDir = await makeTempDir();
404
+ try {
405
+ await mkdir(join(emptyDir, "docs/sessions"), { recursive: true });
406
+ const result = await runSessionValidate(makeInput(), makeContext(emptyDir));
407
+ expect(result.summary).toContain("No session files");
408
+ } finally {
409
+ await rm(emptyDir, { recursive: true, force: true });
410
+ }
411
+ });
412
+ });
413
+
414
+ describe("session.list", () => {
415
+ let dir: string;
416
+
417
+ beforeAll(async () => {
418
+ dir = await makeTempDir();
419
+ await mkdir(join(dir, "docs/sessions"), { recursive: true });
420
+ await mkdir(join(dir, "docs/sessions/archive"), { recursive: true });
421
+
422
+ const session1Id = "2026-07-26-12-00-00-222222";
423
+ const session1 = `---
424
+ id: ${session1Id}
425
+ date: "2026-07-26T12:00:00+02:00"
426
+ types: [implementation, grilling]
427
+ summary: "Session 1"
428
+ relatedRfcs: [RFC-0537]
429
+ relatedArtifacts: []
430
+ decisions: []
431
+ commits: []
432
+ files: []
433
+ commands: []
434
+ ---
435
+
436
+ # Session: ${session1Id}
437
+
438
+ ## Transcript
439
+
440
+ Content.
441
+ `;
442
+ await writeFile(join(dir, "docs/sessions", `${session1Id}.md`), session1, "utf-8");
443
+
444
+ const session2Id = "2026-07-20-12-00-00-333333";
445
+ const session2 = `---
446
+ id: ${session2Id}
447
+ date: "2026-07-20T12:00:00+02:00"
448
+ types: [mission]
449
+ summary: "Session 2"
450
+ relatedRfcs: []
451
+ relatedArtifacts: []
452
+ decisions: []
453
+ commits: []
454
+ files: []
455
+ commands: []
456
+ ---
457
+
458
+ # Session: ${session2Id}
459
+
460
+ ## Transcript
461
+
462
+ Content.
463
+ `;
464
+ await writeFile(join(dir, "docs/sessions/archive", `${session2Id}.md`), session2, "utf-8");
465
+ });
466
+
467
+ afterAll(async () => {
468
+ await rm(dir, { recursive: true, force: true });
469
+ });
470
+
471
+ test("lists all sessions including archived", async () => {
472
+ const result = await runSessionList(makeInput(), makeContext(dir));
473
+ const data = result.data as SessionListResult;
474
+
475
+ expect(data.count).toBe(2);
476
+ const archived = data.sessions.find((s) => s.archived);
477
+ expect(archived).toBeDefined();
478
+ const active = data.sessions.find((s) => !s.archived);
479
+ expect(active).toBeDefined();
480
+ });
481
+
482
+ test("filter by RFC", async () => {
483
+ const result = await runSessionList(makeInput({ rfc: "RFC-0537" }), makeContext(dir));
484
+ const data = result.data as SessionListResult;
485
+
486
+ expect(data.count).toBe(1);
487
+ expect(data.sessions[0]!.relatedRfcs).toContain("RFC-0537");
488
+ });
489
+
490
+ test("filter by type", async () => {
491
+ const result = await runSessionList(makeInput({ type: "mission" }), makeContext(dir));
492
+ const data = result.data as SessionListResult;
493
+
494
+ expect(data.count).toBe(1);
495
+ expect(data.sessions[0]!.types).toContain("mission");
496
+ });
497
+
498
+ test("invalid date format throws", async () => {
499
+ await expect(
500
+ runSessionList(makeInput({ "date-from": "invalid" }), makeContext(dir)),
501
+ ).rejects.toThrow("Invalid --date-from format");
502
+ });
503
+ });
@@ -0,0 +1,190 @@
1
+ import { test, expect, describe } from "vitest";
2
+ import fc from "fast-check";
3
+ import { join } from "node:path";
4
+ import { mkdtemp, rm, mkdir, writeFile, readFile, access } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import type { ForgeCommandInput, ForgeRuntimeContext, ForgeFlagValue } from "../../src/types.ts";
7
+ import { runSessionSave } from "../../os/session/handlers/save.ts";
8
+ import { runSessionArchive } from "../../os/session/handlers/archive.ts";
9
+ import { runSessionValidate } from "../../os/session/handlers/validate.ts";
10
+ import type {
11
+ SessionSaveResult,
12
+ SessionArchiveResult,
13
+ SessionValidationResult,
14
+ } from "../../os/session/types.ts";
15
+
16
+ async function makeTempDir(): Promise<string> {
17
+ return mkdtemp(join(tmpdir(), "forge-session-pbt-"));
18
+ }
19
+
20
+ function makeInput(
21
+ flags: Record<string, ForgeFlagValue> = {},
22
+ args: string[] = [],
23
+ ): ForgeCommandInput {
24
+ return { argv: [], args, flags };
25
+ }
26
+
27
+ function makeContext(workspaceRoot: string): ForgeRuntimeContext {
28
+ return {
29
+ workspaceRoot,
30
+ logger: {
31
+ info: () => {},
32
+ warn: () => {},
33
+ error: () => {},
34
+ success: () => {},
35
+ section: () => {},
36
+ },
37
+ dryRun: false,
38
+ outputFormat: "json",
39
+ };
40
+ }
41
+
42
+ describe("PBT: session.save idempotency", () => {
43
+ test("calling session.save twice on the same raw file yields the same .md id", async () => {
44
+ await fc.asyncProperty(
45
+ fc.string({ minLength: 10, maxLength: 1000 }).filter((s) => s.trim().length > 0),
46
+ async (rawContent: string) => {
47
+ const dir = await makeTempDir();
48
+ try {
49
+ await mkdir(join(dir, "docs/sessions/.raw"), { recursive: true });
50
+ const rawPath = join(dir, "docs/sessions/.raw", "test.atif");
51
+ await writeFile(rawPath, rawContent, "utf-8");
52
+
53
+ const result1 = await runSessionSave(makeInput({ "keep-raw": true }), makeContext(dir));
54
+ const data1 = result1.data as SessionSaveResult;
55
+ expect(data1.id).toBeDefined();
56
+ expect(data1.id.length).toBeGreaterThan(0);
57
+
58
+ const result2 = await runSessionSave(makeInput({ "keep-raw": true }), makeContext(dir));
59
+ const data2 = result2.data as SessionSaveResult & { skipped: unknown[] };
60
+
61
+ expect(data2.skipped.length).toBeGreaterThan(0);
62
+ expect(data2.skipped[0]).toHaveProperty("reason", "already converted");
63
+ } finally {
64
+ await rm(dir, { recursive: true, force: true });
65
+ }
66
+ },
67
+ );
68
+ }, 60000);
69
+ });
70
+
71
+ describe("PBT: session.archive bidirectional behavior", () => {
72
+ test("archive then unarchive returns file to original state", async () => {
73
+ await fc.asyncProperty(fc.integer({ min: 1, max: 30 }), async (ageDays: number) => {
74
+ const dir = await makeTempDir();
75
+ try {
76
+ await mkdir(join(dir, "docs/sessions/archive"), { recursive: true });
77
+
78
+ const sessionId = "2026-07-01-12-00-00-pbt001";
79
+ const sessionDate = new Date();
80
+ sessionDate.setDate(sessionDate.getDate() - ageDays);
81
+ const content = `---
82
+ id: ${sessionId}
83
+ date: "${sessionDate.toISOString()}"
84
+ types: [freeform]
85
+ summary: "PBT test"
86
+ relatedRfcs: []
87
+ relatedArtifacts: []
88
+ decisions: []
89
+ commits: []
90
+ files: []
91
+ commands: []
92
+ ---
93
+
94
+ # Session: ${sessionId}
95
+
96
+ ## Transcript
97
+
98
+ Content.
99
+ `;
100
+ await writeFile(join(dir, "docs/sessions", `${sessionId}.md`), content, "utf-8");
101
+
102
+ if (ageDays > 7) {
103
+ const archResult = await runSessionArchive(
104
+ makeInput({ "max-age-days": "7" }),
105
+ makeContext(dir),
106
+ );
107
+ const archData = archResult.data as SessionArchiveResult;
108
+ expect(archData.moved.find((m) => m.id === sessionId)).toBeDefined();
109
+
110
+ const unarchResult = await runSessionArchive(
111
+ makeInput({ "max-age-days": "30" }),
112
+ makeContext(dir),
113
+ );
114
+ const unarchData = unarchResult.data as SessionArchiveResult;
115
+ expect(
116
+ unarchData.moved.find((m) => m.id === sessionId && m.direction === "out-of-archive"),
117
+ ).toBeDefined();
118
+
119
+ await expect(
120
+ access(join(dir, "docs/sessions", `${sessionId}.md`)),
121
+ ).resolves.toBeUndefined();
122
+ } else {
123
+ const archResult = await runSessionArchive(
124
+ makeInput({ "max-age-days": "7" }),
125
+ makeContext(dir),
126
+ );
127
+ const archData = archResult.data as SessionArchiveResult;
128
+ expect(archData.moved.find((m) => m.id === sessionId)).toBeUndefined();
129
+ }
130
+ } finally {
131
+ await rm(dir, { recursive: true, force: true });
132
+ }
133
+ });
134
+ }, 60000);
135
+ });
136
+
137
+ describe("Integration: end-to-end flow", () => {
138
+ test("raw file → session.save → .md → session.validate → session.archive", async () => {
139
+ const dir = await makeTempDir();
140
+ try {
141
+ await mkdir(join(dir, "docs/sessions/.raw"), { recursive: true });
142
+ await mkdir(join(dir, "docs/sessions/archive"), { recursive: true });
143
+ await mkdir(join(dir, "docs/rfcs"), { recursive: true });
144
+
145
+ const rfcContent = `---
146
+ id: RFC-0537
147
+ title: Test
148
+ status: accepted
149
+ ---
150
+
151
+ # RFC-0537
152
+ `;
153
+ await writeFile(join(dir, "docs/rfcs", "rfc-0537-test.md"), rfcContent, "utf-8");
154
+
155
+ const rawContent = `2026-01-01T12:00:00Z
156
+ User: Implement RFC-0537
157
+ Assistant: Done. session.save and session.archive are ready.
158
+ Commands: session.save, session.archive`;
159
+ await writeFile(join(dir, "docs/sessions/.raw", "integration.atif"), rawContent, "utf-8");
160
+
161
+ const saveResult = await runSessionSave(makeInput(), makeContext(dir));
162
+ const saveData = saveResult.data as SessionSaveResult;
163
+ expect(saveData.id).toBeDefined();
164
+ expect(saveData.rawDeleted).toBe(true);
165
+
166
+ const validateResult = await runSessionValidate(makeInput(), makeContext(dir));
167
+ const validateData = validateResult.data as SessionValidationResult;
168
+ const errors = validateData.violations.filter((v) => v.severity === "error");
169
+ expect(errors.length).toBe(0);
170
+
171
+ const oldDate = new Date();
172
+ oldDate.setDate(oldDate.getDate() - 10);
173
+ const mdPath = join(dir, saveData.file);
174
+ let mdContent = await readFile(mdPath, "utf-8");
175
+ mdContent = mdContent.replace(/date: ".*"/, `date: "${oldDate.toISOString()}"`);
176
+ await writeFile(mdPath, mdContent, "utf-8");
177
+
178
+ const archiveResult = await runSessionArchive(
179
+ makeInput({ "max-age-days": "7" }),
180
+ makeContext(dir),
181
+ );
182
+ const archiveData = archiveResult.data as SessionArchiveResult;
183
+ expect(archiveData.moved.length).toBe(1);
184
+ expect(archiveData.moved[0]!.id).toBe(saveData.id);
185
+ expect(archiveData.moved[0]!.direction).toBe("into-archive");
186
+ } finally {
187
+ await rm(dir, { recursive: true, force: true });
188
+ }
189
+ });
190
+ });