@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,288 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>
4
+ session.archive handler — moves session files older than --max-age-days from
5
+ docs/sessions/ to docs/sessions/archive/. Bidirectional: files in archive/
6
+ younger than threshold are moved back to docs/sessions/.
7
+ </purpose>
8
+ <non-goals>
9
+ <item>Does not validate session content — that is session.validate.</item>
10
+ <item>Does not change session frontmatter — only moves files on disk.</item>
11
+ </non-goals>
12
+ </MODULE_CONTRACT>
13
+ <CHANGE_SUMMARY>
14
+ <item>RFC-0537: implement session.archive command handler.</item>
15
+ <item>RFC-0733: add pinned-files pre-check — skip pinned files with warning instead of moving them.</item>
16
+ </CHANGE_SUMMARY>
17
+ */
18
+
19
+ import fs from "node:fs/promises";
20
+ import path from "node:path";
21
+ import {
22
+ listSessionFiles,
23
+ listArchivedSessionFiles,
24
+ readAndParseSession,
25
+ } from "../frontmatter-io.ts";
26
+ import type {
27
+ ForgeCommandInput,
28
+ ForgeCommandResult,
29
+ ForgeRuntimeContext,
30
+ } from "../../../src/types.ts";
31
+ import {
32
+ SESSION_DIR,
33
+ SESSION_ARCHIVE_SUBDIR,
34
+ type SessionArchiveResult,
35
+ type SessionArchiveMove,
36
+ type SessionArchiveSkip,
37
+ } from "../types.ts";
38
+ import { loadPinnedManifest, isPinned, isIntraDirMove } from "../../core/handlers/pinned-check.ts";
39
+
40
+ const DEFAULT_MAX_AGE_DAYS = 7;
41
+
42
+ function parseSessionDate(fm: Record<string, unknown>): Date | null {
43
+ const dateStr = String(fm["date"] ?? "");
44
+ if (!dateStr) return null;
45
+ const date = new Date(dateStr);
46
+ return isNaN(date.getTime()) ? null : date;
47
+ }
48
+
49
+ function computeAgeDays(sessionDate: Date, now: Date): number {
50
+ const diffMs = now.getTime() - sessionDate.getTime();
51
+ return Math.floor(diffMs / (1000 * 60 * 60 * 24));
52
+ }
53
+
54
+ export async function runSessionArchive(
55
+ input: ForgeCommandInput,
56
+ context: ForgeRuntimeContext,
57
+ ): Promise<ForgeCommandResult<SessionArchiveResult>> {
58
+ const { workspaceRoot, logger, outputFormat } = context;
59
+ const sessionDirPath = path.join(workspaceRoot, SESSION_DIR);
60
+ const archiveDirPath = path.join(sessionDirPath, SESSION_ARCHIVE_SUBDIR);
61
+
62
+ const dryRun = context.dryRun || input.flags["dry-run"] === true;
63
+ const maxAgeDaysRaw = input.flags["max-age-days"];
64
+ const maxAgeDays =
65
+ maxAgeDaysRaw !== undefined
66
+ ? typeof maxAgeDaysRaw === "string"
67
+ ? parseInt(maxAgeDaysRaw, 10)
68
+ : typeof maxAgeDaysRaw === "number"
69
+ ? maxAgeDaysRaw
70
+ : DEFAULT_MAX_AGE_DAYS
71
+ : DEFAULT_MAX_AGE_DAYS;
72
+
73
+ if (isNaN(maxAgeDays) || maxAgeDays <= 0) {
74
+ throw new Error(`Invalid --max-age-days: must be a positive integer, got "${maxAgeDaysRaw}"`);
75
+ }
76
+
77
+ const now = new Date();
78
+ const moved: SessionArchiveMove[] = [];
79
+ const skipped: SessionArchiveSkip[] = [];
80
+
81
+ // RFC-0733: Load pinned manifest once per invocation
82
+ let pinnedManifest = null;
83
+ try {
84
+ pinnedManifest = await loadPinnedManifest(workspaceRoot);
85
+ } catch {
86
+ // Malformed manifest — skip pre-check
87
+ }
88
+
89
+ // Process active files — move to archive if older than threshold
90
+ const activeFiles = await listSessionFiles(sessionDirPath);
91
+ for (const fileName of activeFiles) {
92
+ const result = await readAndParseSession(sessionDirPath, fileName);
93
+ if (!result) {
94
+ skipped.push({
95
+ id: "UNKNOWN",
96
+ file: path.join(SESSION_DIR, fileName),
97
+ reason: "unreadable frontmatter",
98
+ });
99
+ continue;
100
+ }
101
+
102
+ const fm = result.parsed.frontmatter;
103
+ const id = String(fm["id"] ?? "UNKNOWN");
104
+ const sessionDate = parseSessionDate(fm);
105
+ const relFile = path.join(SESSION_DIR, fileName);
106
+ const basename = path.basename(fileName);
107
+
108
+ if (!sessionDate) {
109
+ skipped.push({ id, file: relFile, reason: "no valid date in frontmatter" });
110
+ continue;
111
+ }
112
+
113
+ const ageDays = computeAgeDays(sessionDate, now);
114
+
115
+ if (ageDays > maxAgeDays) {
116
+ const targetPath = path.join(archiveDirPath, basename);
117
+ const targetRel = path.join(SESSION_DIR, SESSION_ARCHIVE_SUBDIR, basename);
118
+
119
+ // RFC-0733: Check if file is pinned before moving
120
+ // Gap fix: exempt intra-directory moves (file stays within the same pinned dir)
121
+ if (
122
+ pinnedManifest &&
123
+ isPinned(pinnedManifest, relFile) &&
124
+ !isIntraDirMove(pinnedManifest, relFile, targetRel)
125
+ ) {
126
+ skipped.push({ id, file: relFile, reason: "pinned (protected by .forge/pinned.yaml)" });
127
+ if (outputFormat === "pretty") {
128
+ logger.warn(` pinned: skipping ${relFile} (protected)`);
129
+ }
130
+ continue;
131
+ }
132
+
133
+ try {
134
+ await fs.access(targetPath);
135
+ skipped.push({ id, file: relFile, reason: "destination exists" });
136
+ continue;
137
+ } catch {
138
+ // destination doesn't exist — proceed
139
+ }
140
+
141
+ if (!dryRun) {
142
+ await fs.mkdir(archiveDirPath, { recursive: true });
143
+ try {
144
+ await fs.rename(path.join(sessionDirPath, fileName), targetPath);
145
+ } catch (err) {
146
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
147
+ skipped.push({ id, file: relFile, reason: "already moved by another process" });
148
+ continue;
149
+ }
150
+ throw err;
151
+ }
152
+ }
153
+
154
+ moved.push({
155
+ id,
156
+ file: targetRel,
157
+ from: relFile,
158
+ to: targetRel,
159
+ ageDays,
160
+ direction: "into-archive",
161
+ });
162
+ } else {
163
+ skipped.push({
164
+ id,
165
+ file: relFile,
166
+ reason: `age ${ageDays} days < max-age ${maxAgeDays} days`,
167
+ });
168
+ }
169
+ }
170
+
171
+ // Process archived files — move back to active if younger than threshold
172
+ const archivedFiles = await listArchivedSessionFiles(sessionDirPath);
173
+ for (const archFileName of archivedFiles) {
174
+ const archFullPath = path.join(archiveDirPath, archFileName);
175
+ const result = await readAndParseSession(
176
+ path.join(sessionDirPath, SESSION_ARCHIVE_SUBDIR),
177
+ archFileName,
178
+ );
179
+ if (!result) {
180
+ skipped.push({
181
+ id: "UNKNOWN",
182
+ file: path.join(SESSION_DIR, SESSION_ARCHIVE_SUBDIR, archFileName),
183
+ reason: "unreadable frontmatter",
184
+ });
185
+ continue;
186
+ }
187
+
188
+ const fm = result.parsed.frontmatter;
189
+ const id = String(fm["id"] ?? "UNKNOWN");
190
+ const sessionDate = parseSessionDate(fm);
191
+ const archRelFile = path.join(SESSION_DIR, SESSION_ARCHIVE_SUBDIR, archFileName);
192
+ const basename = path.basename(archFileName);
193
+
194
+ if (!sessionDate) {
195
+ skipped.push({ id, file: archRelFile, reason: "no valid date in frontmatter" });
196
+ continue;
197
+ }
198
+
199
+ const ageDays = computeAgeDays(sessionDate, now);
200
+
201
+ if (ageDays <= maxAgeDays) {
202
+ const targetPath = path.join(sessionDirPath, basename);
203
+ const targetRel = path.join(SESSION_DIR, basename);
204
+
205
+ // RFC-0733: Check if file is pinned before moving
206
+ // Gap fix: exempt intra-directory moves (file stays within the same pinned dir)
207
+ if (
208
+ pinnedManifest &&
209
+ isPinned(pinnedManifest, archRelFile) &&
210
+ !isIntraDirMove(pinnedManifest, archRelFile, targetRel)
211
+ ) {
212
+ skipped.push({ id, file: archRelFile, reason: "pinned (protected by .forge/pinned.yaml)" });
213
+ if (outputFormat === "pretty") {
214
+ logger.warn(` pinned: skipping ${archRelFile} (protected)`);
215
+ }
216
+ continue;
217
+ }
218
+
219
+ try {
220
+ await fs.access(targetPath);
221
+ skipped.push({ id, file: archRelFile, reason: "destination exists" });
222
+ continue;
223
+ } catch {
224
+ // destination doesn't exist — proceed
225
+ }
226
+
227
+ if (!dryRun) {
228
+ try {
229
+ await fs.rename(archFullPath, targetPath);
230
+ } catch (err) {
231
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
232
+ skipped.push({ id, file: archRelFile, reason: "already moved by another process" });
233
+ continue;
234
+ }
235
+ throw err;
236
+ }
237
+ }
238
+
239
+ moved.push({
240
+ id,
241
+ file: targetRel,
242
+ from: archRelFile,
243
+ to: targetRel,
244
+ ageDays,
245
+ direction: "out-of-archive",
246
+ });
247
+ } else {
248
+ skipped.push({
249
+ id,
250
+ file: archRelFile,
251
+ reason: `age ${ageDays} days > max-age ${maxAgeDays} days (already archived)`,
252
+ });
253
+ }
254
+ }
255
+
256
+ if (outputFormat === "pretty") {
257
+ if (dryRun) {
258
+ logger.info(
259
+ `[dry-run] session.archive: would move ${moved.length} file(s), skip ${skipped.length}`,
260
+ );
261
+ } else {
262
+ logger.success(`session.archive: moved ${moved.length} file(s), skipped ${skipped.length}`);
263
+ }
264
+ for (const m of moved) {
265
+ logger.info(` ${m.direction}: ${m.id} (age ${m.ageDays}d) ${m.from} → ${m.to}`);
266
+ }
267
+ }
268
+
269
+ return {
270
+ data: {
271
+ command: "session.archive",
272
+ status: "ok",
273
+ moved,
274
+ skipped,
275
+ maxAgeDays,
276
+ dryRun,
277
+ },
278
+ summary: dryRun
279
+ ? `[dry-run] Would move ${moved.length} file(s), skip ${skipped.length}`
280
+ : `Moved ${moved.length} file(s), skipped ${skipped.length}`,
281
+ nextSteps: [
282
+ {
283
+ action: "Run: pnpm exec werkstatt run session.list --json to verify archive status",
284
+ kind: "optional",
285
+ },
286
+ ],
287
+ };
288
+ }
@@ -0,0 +1,174 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>
4
+ session.list handler — lists session files with optional filters by date
5
+ range, RFC-id, and session type. Parses frontmatter on the fly.
6
+ </purpose>
7
+ <non-goals>
8
+ <item>Does not validate session content — that is session.validate.</item>
9
+ <item>Does not modify files in any way.</item>
10
+ </non-goals>
11
+ </MODULE_CONTRACT>
12
+ <CHANGE_SUMMARY>
13
+ <item>RFC-0537: implement session.list command handler.</item>
14
+ </CHANGE_SUMMARY>
15
+ */
16
+
17
+ import path from "node:path";
18
+ import {
19
+ listSessionFiles,
20
+ listArchivedSessionFiles,
21
+ readAndParseSession,
22
+ } from "../frontmatter-io.ts";
23
+ import type {
24
+ ForgeCommandInput,
25
+ ForgeCommandResult,
26
+ ForgeRuntimeContext,
27
+ } from "../../../src/types.ts";
28
+ import {
29
+ SESSION_DIR,
30
+ SESSION_ARCHIVE_SUBDIR,
31
+ type SessionListResult,
32
+ type SessionListEntry,
33
+ type SessionType,
34
+ } from "../types.ts";
35
+
36
+ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
37
+
38
+ function parseDate(dateStr: string): Date | null {
39
+ if (!DATE_PATTERN.test(dateStr)) return null;
40
+ const date = new Date(dateStr + "T00:00:00Z");
41
+ return isNaN(date.getTime()) ? null : date;
42
+ }
43
+
44
+ export async function runSessionList(
45
+ input: ForgeCommandInput,
46
+ context: ForgeRuntimeContext,
47
+ ): Promise<ForgeCommandResult<SessionListResult>> {
48
+ const { workspaceRoot, logger, outputFormat } = context;
49
+ const sessionDirPath = path.join(workspaceRoot, SESSION_DIR);
50
+
51
+ const filterDateFrom = input.flags["date-from"] as string | undefined;
52
+ const filterDateTo = input.flags["date-to"] as string | undefined;
53
+ const filterRfc = input.flags["rfc"] as string | undefined;
54
+ const filterType = input.flags["type"] as string | undefined;
55
+
56
+ let dateFrom: Date | null = null;
57
+ let dateTo: Date | null = null;
58
+
59
+ if (filterDateFrom) {
60
+ dateFrom = parseDate(filterDateFrom);
61
+ if (!dateFrom) {
62
+ throw new Error(`Invalid --date-from format, use YYYY-MM-DD. Got: "${filterDateFrom}"`);
63
+ }
64
+ }
65
+ if (filterDateTo) {
66
+ dateTo = parseDate(filterDateTo);
67
+ if (!dateTo) {
68
+ throw new Error(`Invalid --date-to format, use YYYY-MM-DD. Got: "${filterDateTo}"`);
69
+ }
70
+ }
71
+
72
+ const entries: SessionListEntry[] = [];
73
+
74
+ // Process active files
75
+ const activeFiles = await listSessionFiles(sessionDirPath);
76
+ for (const fileName of activeFiles) {
77
+ const result = await readAndParseSession(sessionDirPath, fileName);
78
+ if (!result) continue;
79
+
80
+ const fm = result.parsed.frontmatter;
81
+ const id = String(fm["id"] ?? "");
82
+ const date = String(fm["date"] ?? "");
83
+ const types = Array.isArray(fm["types"]) ? (fm["types"] as SessionType[]) : [];
84
+ const summary = String(fm["summary"] ?? "");
85
+ const relatedRfcs = Array.isArray(fm["relatedRfcs"]) ? (fm["relatedRfcs"] as string[]) : [];
86
+
87
+ // Apply filters
88
+ if (filterRfc && !relatedRfcs.includes(filterRfc)) continue;
89
+ if (filterType && !types.includes(filterType as SessionType)) continue;
90
+ if (dateFrom || dateTo) {
91
+ const sessionDate = new Date(date);
92
+ if (isNaN(sessionDate.getTime())) continue;
93
+ const sessionDay = new Date(sessionDate.toISOString().slice(0, 10) + "T00:00:00Z");
94
+ if (dateFrom && sessionDay < dateFrom) continue;
95
+ if (dateTo && sessionDay > dateTo) continue;
96
+ }
97
+
98
+ entries.push({
99
+ id,
100
+ date,
101
+ types,
102
+ summary,
103
+ relatedRfcs,
104
+ file: path.join(SESSION_DIR, fileName),
105
+ archived: false,
106
+ });
107
+ }
108
+
109
+ // Process archived files
110
+ const archivedFiles = await listArchivedSessionFiles(sessionDirPath);
111
+ for (const fileName of archivedFiles) {
112
+ const result = await readAndParseSession(
113
+ path.join(sessionDirPath, SESSION_ARCHIVE_SUBDIR),
114
+ fileName,
115
+ );
116
+ if (!result) continue;
117
+
118
+ const fm = result.parsed.frontmatter;
119
+ const id = String(fm["id"] ?? "");
120
+ const date = String(fm["date"] ?? "");
121
+ const types = Array.isArray(fm["types"]) ? (fm["types"] as SessionType[]) : [];
122
+ const summary = String(fm["summary"] ?? "");
123
+ const relatedRfcs = Array.isArray(fm["relatedRfcs"]) ? (fm["relatedRfcs"] as string[]) : [];
124
+
125
+ // Apply filters
126
+ if (filterRfc && !relatedRfcs.includes(filterRfc)) continue;
127
+ if (filterType && !types.includes(filterType as SessionType)) continue;
128
+ if (dateFrom || dateTo) {
129
+ const sessionDate = new Date(date);
130
+ if (isNaN(sessionDate.getTime())) continue;
131
+ const sessionDay = new Date(sessionDate.toISOString().slice(0, 10) + "T00:00:00Z");
132
+ if (dateFrom && sessionDay < dateFrom) continue;
133
+ if (dateTo && sessionDay > dateTo) continue;
134
+ }
135
+
136
+ entries.push({
137
+ id,
138
+ date,
139
+ types,
140
+ summary,
141
+ relatedRfcs,
142
+ file: path.join(SESSION_DIR, SESSION_ARCHIVE_SUBDIR, fileName),
143
+ archived: true,
144
+ });
145
+ }
146
+
147
+ // Sort by date descending (most recent first)
148
+ entries.sort((a, b) => b.date.localeCompare(a.date));
149
+
150
+ if (outputFormat === "pretty") {
151
+ if (entries.length === 0) {
152
+ logger.info("No sessions found matching the given filters.");
153
+ } else {
154
+ logger.section(`Sessions (${entries.length})`);
155
+ for (const entry of entries) {
156
+ const archTag = entry.archived ? " [archived]" : "";
157
+ const typesStr = entry.types.join(", ");
158
+ logger.info(
159
+ `${entry.id} ${typesStr.padEnd(20)} ${entry.date} ${entry.summary}${archTag}`,
160
+ );
161
+ }
162
+ }
163
+ }
164
+
165
+ return {
166
+ data: {
167
+ command: "session.list",
168
+ status: "ok",
169
+ sessions: entries,
170
+ count: entries.length,
171
+ },
172
+ summary: `${entries.length} session(s) found`,
173
+ };
174
+ }