@entelligentsia/forgecli 0.1.0 → 0.3.0

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 (144) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/README.md +177 -38
  3. package/dist/bin/argv.js +5 -0
  4. package/dist/bin/argv.js.map +1 -1
  5. package/dist/bin/forge.js +1 -0
  6. package/dist/bin/forge.js.map +1 -1
  7. package/dist/extensions/forgecli/ask-user-tool.d.ts +17 -0
  8. package/dist/extensions/forgecli/ask-user-tool.js +139 -0
  9. package/dist/extensions/forgecli/ask-user-tool.js.map +1 -0
  10. package/dist/extensions/forgecli/forge-commands.d.ts +21 -0
  11. package/dist/extensions/forgecli/forge-commands.js +141 -0
  12. package/dist/extensions/forgecli/forge-commands.js.map +1 -1
  13. package/dist/extensions/forgecli/forge-init.d.ts +26 -0
  14. package/dist/extensions/forgecli/forge-init.js +948 -0
  15. package/dist/extensions/forgecli/forge-init.js.map +1 -0
  16. package/dist/extensions/forgecli/health-check.d.ts +18 -0
  17. package/dist/extensions/forgecli/health-check.js +154 -0
  18. package/dist/extensions/forgecli/health-check.js.map +1 -0
  19. package/dist/extensions/forgecli/hook-dispatcher.d.ts +34 -1
  20. package/dist/extensions/forgecli/hook-dispatcher.js +237 -3
  21. package/dist/extensions/forgecli/hook-dispatcher.js.map +1 -1
  22. package/dist/extensions/forgecli/index.js +28 -11
  23. package/dist/extensions/forgecli/index.js.map +1 -1
  24. package/dist/extensions/forgecli/init-context.d.ts +99 -0
  25. package/dist/extensions/forgecli/init-context.js +163 -0
  26. package/dist/extensions/forgecli/init-context.js.map +1 -0
  27. package/dist/extensions/forgecli/init-progress.d.ts +39 -0
  28. package/dist/extensions/forgecli/init-progress.js +117 -0
  29. package/dist/extensions/forgecli/init-progress.js.map +1 -0
  30. package/dist/extensions/forgecli/refresh-kb-links.d.ts +18 -0
  31. package/dist/extensions/forgecli/refresh-kb-links.js +228 -0
  32. package/dist/extensions/forgecli/refresh-kb-links.js.map +1 -0
  33. package/dist/extensions/forgecli/store-validator.d.ts +13 -0
  34. package/dist/extensions/forgecli/store-validator.js +35 -0
  35. package/dist/extensions/forgecli/store-validator.js.map +1 -0
  36. package/dist/extensions/forgecli/transition-guard.d.ts +20 -0
  37. package/dist/extensions/forgecli/transition-guard.js +125 -0
  38. package/dist/extensions/forgecli/transition-guard.js.map +1 -0
  39. package/dist/forge-payload/.base-pack/commands/approve.md +22 -0
  40. package/dist/forge-payload/.base-pack/commands/collate.md +22 -0
  41. package/dist/forge-payload/.base-pack/commands/commit.md +22 -0
  42. package/dist/forge-payload/.base-pack/commands/enhance.md +37 -0
  43. package/dist/forge-payload/.base-pack/commands/fix-bug.md +22 -0
  44. package/dist/forge-payload/.base-pack/commands/implement.md +22 -0
  45. package/dist/forge-payload/.base-pack/commands/plan.md +22 -0
  46. package/dist/forge-payload/.base-pack/commands/quiz-agent.md +22 -0
  47. package/dist/forge-payload/.base-pack/commands/retrospective.md +22 -0
  48. package/dist/forge-payload/.base-pack/commands/review-code.md +22 -0
  49. package/dist/forge-payload/.base-pack/commands/review-plan.md +22 -0
  50. package/dist/forge-payload/.base-pack/commands/run-sprint.md +22 -0
  51. package/dist/forge-payload/.base-pack/commands/run-task.md +22 -0
  52. package/dist/forge-payload/.base-pack/commands/sprint-intake.md +22 -0
  53. package/dist/forge-payload/.base-pack/commands/sprint-plan.md +22 -0
  54. package/dist/forge-payload/.base-pack/commands/validate.md +22 -0
  55. package/dist/forge-payload/.claude-plugin/plugin.json +15 -0
  56. package/dist/forge-payload/.init/discovery/discover-database.md +32 -0
  57. package/dist/forge-payload/.init/discovery/discover-processes.md +31 -0
  58. package/dist/forge-payload/.init/discovery/discover-routing.md +31 -0
  59. package/dist/forge-payload/.init/discovery/discover-stack.md +33 -0
  60. package/dist/forge-payload/.init/discovery/discover-testing.md +34 -0
  61. package/dist/forge-payload/.init/generation/generate-kb-doc.md +60 -0
  62. package/dist/forge-payload/.schemas/bug.schema.json +53 -0
  63. package/dist/forge-payload/.schemas/collation-state.schema.json +16 -0
  64. package/dist/forge-payload/.schemas/event-sidecar.schema.json +22 -0
  65. package/dist/forge-payload/.schemas/event.schema.json +32 -0
  66. package/dist/forge-payload/.schemas/feature.schema.json +22 -0
  67. package/dist/forge-payload/.schemas/progress-entry.schema.json +16 -0
  68. package/dist/forge-payload/.schemas/project-context.schema.json +167 -0
  69. package/dist/forge-payload/.schemas/project-overlay.schema.json +25 -0
  70. package/dist/forge-payload/.schemas/sprint.schema.json +27 -0
  71. package/dist/forge-payload/.schemas/structure-versions.schema.json +57 -0
  72. package/dist/forge-payload/.schemas/task.schema.json +58 -0
  73. package/dist/forge-payload/.tools/banners.cjs +435 -0
  74. package/dist/forge-payload/.tools/build-context-pack.cjs +290 -0
  75. package/dist/forge-payload/.tools/build-init-context.cjs +322 -0
  76. package/dist/forge-payload/.tools/build-overlay.cjs +326 -0
  77. package/dist/forge-payload/.tools/build-persona-pack.cjs +226 -0
  78. package/dist/forge-payload/.tools/collate.cjs +1041 -0
  79. package/dist/forge-payload/.tools/generation-manifest.cjs +311 -0
  80. package/dist/forge-payload/.tools/lib/forge-root.cjs +59 -0
  81. package/dist/forge-payload/.tools/lib/paths.cjs +29 -0
  82. package/dist/forge-payload/.tools/lib/pricing.cjs +165 -0
  83. package/dist/forge-payload/.tools/lib/project-root.cjs +32 -0
  84. package/dist/forge-payload/.tools/lib/result.js +40 -0
  85. package/dist/forge-payload/.tools/lib/validate.js +131 -0
  86. package/dist/forge-payload/.tools/manage-config.cjs +340 -0
  87. package/dist/forge-payload/.tools/manage-versions.cjs +365 -0
  88. package/dist/forge-payload/.tools/seed-store.cjs +237 -0
  89. package/dist/forge-payload/.tools/store-cli.cjs +1123 -0
  90. package/dist/forge-payload/.tools/store.cjs +315 -0
  91. package/dist/forge-payload/.tools/substitute-placeholders.cjs +625 -0
  92. package/dist/forge-payload/.tools/validate-store.cjs +522 -0
  93. package/package.json +1 -1
  94. /package/dist/forge-payload/{personas → .base-pack/personas}/architect.md +0 -0
  95. /package/dist/forge-payload/{personas → .base-pack/personas}/bug-fixer.md +0 -0
  96. /package/dist/forge-payload/{personas → .base-pack/personas}/collator.md +0 -0
  97. /package/dist/forge-payload/{personas → .base-pack/personas}/engineer.md +0 -0
  98. /package/dist/forge-payload/{personas → .base-pack/personas}/librarian.md +0 -0
  99. /package/dist/forge-payload/{personas → .base-pack/personas}/orchestrator.md +0 -0
  100. /package/dist/forge-payload/{personas → .base-pack/personas}/product-manager.md +0 -0
  101. /package/dist/forge-payload/{personas → .base-pack/personas}/qa-engineer.md +0 -0
  102. /package/dist/forge-payload/{personas → .base-pack/personas}/supervisor.md +0 -0
  103. /package/dist/forge-payload/{skills → .base-pack/skills}/architect-skills.md +0 -0
  104. /package/dist/forge-payload/{skills → .base-pack/skills}/bug-fixer-skills.md +0 -0
  105. /package/dist/forge-payload/{skills → .base-pack/skills}/collator-skills.md +0 -0
  106. /package/dist/forge-payload/{skills → .base-pack/skills}/engineer-skills.md +0 -0
  107. /package/dist/forge-payload/{skills → .base-pack/skills}/generic-skills.md +0 -0
  108. /package/dist/forge-payload/{skills → .base-pack/skills}/librarian-skills.md +0 -0
  109. /package/dist/forge-payload/{skills → .base-pack/skills}/qa-engineer-skills.md +0 -0
  110. /package/dist/forge-payload/{skills → .base-pack/skills}/store-custodian-skills.md +0 -0
  111. /package/dist/forge-payload/{skills → .base-pack/skills}/supervisor-skills.md +0 -0
  112. /package/dist/forge-payload/{templates → .base-pack/templates}/CODE_REVIEW_TEMPLATE.md +0 -0
  113. /package/dist/forge-payload/{templates → .base-pack/templates}/COST_REPORT_TEMPLATE.md +0 -0
  114. /package/dist/forge-payload/{templates → .base-pack/templates}/PLAN_REVIEW_TEMPLATE.md +0 -0
  115. /package/dist/forge-payload/{templates → .base-pack/templates}/PLAN_SUMMARY_TEMPLATE.json +0 -0
  116. /package/dist/forge-payload/{templates → .base-pack/templates}/PLAN_TEMPLATE.md +0 -0
  117. /package/dist/forge-payload/{templates → .base-pack/templates}/PROGRESS_TEMPLATE.md +0 -0
  118. /package/dist/forge-payload/{templates → .base-pack/templates}/RETROSPECTIVE_TEMPLATE.md +0 -0
  119. /package/dist/forge-payload/{templates → .base-pack/templates}/SPRINT_MANIFEST_TEMPLATE.md +0 -0
  120. /package/dist/forge-payload/{templates → .base-pack/templates}/SPRINT_REQUIREMENTS_TEMPLATE.md +0 -0
  121. /package/dist/forge-payload/{templates → .base-pack/templates}/TASK_PROMPT_TEMPLATE.md +0 -0
  122. /package/dist/forge-payload/{workflows → .base-pack/workflows}/_fragments/context-injection.md +0 -0
  123. /package/dist/forge-payload/{workflows → .base-pack/workflows}/_fragments/event-emission-schema.md +0 -0
  124. /package/dist/forge-payload/{workflows → .base-pack/workflows}/_fragments/finalize.md +0 -0
  125. /package/dist/forge-payload/{workflows → .base-pack/workflows}/_fragments/progress-reporting.md +0 -0
  126. /package/dist/forge-payload/{workflows → .base-pack/workflows}/architect_approve.md +0 -0
  127. /package/dist/forge-payload/{workflows → .base-pack/workflows}/architect_review_sprint_completion.md +0 -0
  128. /package/dist/forge-payload/{workflows → .base-pack/workflows}/architect_sprint_intake.md +0 -0
  129. /package/dist/forge-payload/{workflows → .base-pack/workflows}/architect_sprint_plan.md +0 -0
  130. /package/dist/forge-payload/{workflows → .base-pack/workflows}/collator_agent.md +0 -0
  131. /package/dist/forge-payload/{workflows → .base-pack/workflows}/commit_task.md +0 -0
  132. /package/dist/forge-payload/{workflows → .base-pack/workflows}/fix_bug.md +0 -0
  133. /package/dist/forge-payload/{workflows → .base-pack/workflows}/implement_plan.md +0 -0
  134. /package/dist/forge-payload/{workflows → .base-pack/workflows}/migrate_structural.md +0 -0
  135. /package/dist/forge-payload/{workflows → .base-pack/workflows}/orchestrate_task.md +0 -0
  136. /package/dist/forge-payload/{workflows → .base-pack/workflows}/plan_task.md +0 -0
  137. /package/dist/forge-payload/{workflows → .base-pack/workflows}/quiz_agent.md +0 -0
  138. /package/dist/forge-payload/{workflows → .base-pack/workflows}/review_code.md +0 -0
  139. /package/dist/forge-payload/{workflows → .base-pack/workflows}/review_plan.md +0 -0
  140. /package/dist/forge-payload/{workflows → .base-pack/workflows}/run_sprint.md +0 -0
  141. /package/dist/forge-payload/{workflows → .base-pack/workflows}/sprint_retrospective.md +0 -0
  142. /package/dist/forge-payload/{workflows → .base-pack/workflows}/update_implementation.md +0 -0
  143. /package/dist/forge-payload/{workflows → .base-pack/workflows}/update_plan.md +0 -0
  144. /package/dist/forge-payload/{workflows → .base-pack/workflows}/validate_task.md +0 -0
@@ -0,0 +1,315 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { findProjectRoot } = require('./lib/project-root.cjs');
6
+
7
+ /**
8
+ * Store Facade for Forge
9
+ * Provides a backend-agnostic interface for CRUD operations on core store entities.
10
+ */
11
+ class Store {
12
+ constructor(implementation) {
13
+ this.impl = implementation;
14
+ }
15
+
16
+ // --- Sprints ---
17
+ getSprint(id) { return this.impl.getSprint(id); }
18
+ listSprints(filter) { return this.impl.listSprints(filter); }
19
+ writeSprint(data) { return this.impl.writeSprint(data); }
20
+ deleteSprint(id) { return this.impl.deleteSprint(id); }
21
+
22
+ // --- Tasks ---
23
+ getTask(id) { return this.impl.getTask(id); }
24
+ listTasks(filter) { return this.impl.listTasks(filter); }
25
+ writeTask(data) { return this.impl.writeTask(data); }
26
+ deleteTask(id) { return this.impl.deleteTask(id); }
27
+
28
+ // --- Bugs ---
29
+ getBug(id) { return this.impl.getBug(id); }
30
+ listBugs(filter) { return this.impl.listBugs(filter); }
31
+ writeBug(data) { return this.impl.writeBug(data); }
32
+ deleteBug(id) { return this.impl.deleteBug(id); }
33
+
34
+ // --- Events ---
35
+ getEvent(id, sprintId) { return this.impl.getEvent(id, sprintId); }
36
+ listEvents(sprintId, filter) { return this.impl.listEvents(sprintId, filter); }
37
+ writeEvent(sprintId, data) { return this.impl.writeEvent(sprintId, data); }
38
+ deleteEvent(id, sprintId) { return this.impl.deleteEvent(id, sprintId); }
39
+ renameEvent(sprintId, oldFilename, newEventId) { return this.impl.renameEvent(sprintId, oldFilename, newEventId); }
40
+
41
+ // --- Features ---
42
+ getFeature(id) { return this.impl.getFeature(id); }
43
+ listFeatures(filter) { return this.impl.listFeatures(filter); }
44
+ writeFeature(data) { return this.impl.writeFeature(data); }
45
+ deleteFeature(id) { return this.impl.deleteFeature(id); }
46
+
47
+ // --- Collation State ---
48
+ writeCollationState(data) { return this.impl.writeCollationState(data); }
49
+ readCollationState() { return this.impl.readCollationState(); }
50
+
51
+ // --- Event Operations (extended) ---
52
+ /**
53
+ * Purge all event files for a sprint directory.
54
+ * @param {string} sprintId
55
+ * @param {{ dryRun?: boolean }} opts - dryRun: return file list without deleting
56
+ * @returns {{ purged: boolean, fileCount: number, files: string[] }}
57
+ */
58
+ purgeEvents(sprintId, opts) { return this.impl.purgeEvents(sprintId, opts); }
59
+ /**
60
+ * List event filenames for a sprint directory.
61
+ * @param {string} sprintId
62
+ * @returns {{ filename: string, id: string }[]}
63
+ */
64
+ listEventFilenames(sprintId) { return this.impl.listEventFilenames(sprintId); }
65
+ }
66
+
67
+ /**
68
+ * Filesystem Implementation of the Store facade.
69
+ * Manages JSON flat-files in the .forge/store directory.
70
+ */
71
+ class FSImpl {
72
+ constructor(configPath = '.forge/config.json') {
73
+ this.configPath = configPath;
74
+ this.storeRoot = this._resolveStoreRoot();
75
+ }
76
+
77
+ _resolveStoreRoot() {
78
+ const configPathIsAbsolute = path.isAbsolute(this.configPath);
79
+ const projectRoot = configPathIsAbsolute ? null : findProjectRoot();
80
+ try {
81
+ const resolved = projectRoot ? path.join(projectRoot, this.configPath) : this.configPath;
82
+ const config = JSON.parse(fs.readFileSync(resolved, 'utf8'));
83
+ const storePath = config.paths.store;
84
+ return path.isAbsolute(storePath) ? storePath
85
+ : projectRoot ? path.join(projectRoot, storePath) : storePath;
86
+ } catch (e) {
87
+ // Fallback to default if config is missing or corrupt
88
+ return projectRoot ? path.join(projectRoot, '.forge', 'store') : '.forge/store';
89
+ }
90
+ }
91
+
92
+ _getPath(entity, id) {
93
+ const entityMap = {
94
+ sprint: 'sprints',
95
+ task: 'tasks',
96
+ bug: 'bugs',
97
+ event: 'events',
98
+ feature: 'features'
99
+ };
100
+ const dir = entityMap[entity];
101
+ if (!dir) throw new Error(`Unknown entity type: ${entity}`);
102
+ return path.join(this.storeRoot, dir, `${id}.json`);
103
+ }
104
+
105
+ _readJson(filePath) {
106
+ if (!fs.existsSync(filePath)) return null;
107
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
108
+ }
109
+
110
+ _writeJson(filePath, data) {
111
+ const dir = path.dirname(filePath);
112
+ if (!fs.existsSync(dir)) {
113
+ fs.mkdirSync(dir, { recursive: true });
114
+ }
115
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
116
+ return data;
117
+ }
118
+
119
+ // Sprints
120
+ getSprint(id) { return this._readJson(this._getPath('sprint', id)); }
121
+ listSprints(filter) {
122
+ const dir = path.join(this.storeRoot, 'sprints');
123
+ if (!fs.existsSync(dir)) return [];
124
+ return fs.readdirSync(dir)
125
+ .filter(f => f.endsWith('.json'))
126
+ .map(f => this._readJson(path.join(dir, f)))
127
+ .filter(s => !s || (filter ? this._matches(s, filter) : true));
128
+ }
129
+ writeSprint(data) {
130
+ return this._writeJson(this._getPath('sprint', data.sprintId), data);
131
+ }
132
+ deleteSprint(id) {
133
+ const p = this._getPath('sprint', id);
134
+ if (fs.existsSync(p)) fs.unlinkSync(p);
135
+ }
136
+
137
+ // Tasks
138
+ getTask(id) { return this._readJson(this._getPath('task', id)); }
139
+ listTasks(filter) {
140
+ const dir = path.join(this.storeRoot, 'tasks');
141
+ if (!fs.existsSync(dir)) return [];
142
+ return fs.readdirSync(dir)
143
+ .filter(f => f.endsWith('.json'))
144
+ .map(f => this._readJson(path.join(dir, f)))
145
+ .filter(t => !t || (filter ? this._matches(t, filter) : true));
146
+ }
147
+ writeTask(data) {
148
+ return this._writeJson(this._getPath('task', data.taskId), data);
149
+ }
150
+ deleteTask(id) {
151
+ const p = this._getPath('task', id);
152
+ if (fs.existsSync(p)) fs.unlinkSync(p);
153
+ }
154
+
155
+ // Bugs
156
+ getBug(id) { return this._readJson(this._getPath('bug', id)); }
157
+ listBugs(filter) {
158
+ const dir = path.join(this.storeRoot, 'bugs');
159
+ if (!fs.existsSync(dir)) return [];
160
+ return fs.readdirSync(dir)
161
+ .filter(f => f.endsWith('.json'))
162
+ .map(f => this._readJson(path.join(dir, f)))
163
+ .filter(b => !b || (filter ? this._matches(b, filter) : true));
164
+ }
165
+ writeBug(data) {
166
+ return this._writeJson(this._getPath('bug', data.bugId), data);
167
+ }
168
+ deleteBug(id) {
169
+ const p = this._getPath('bug', id);
170
+ if (fs.existsSync(p)) fs.unlinkSync(p);
171
+ }
172
+
173
+ // Events
174
+ getEvent(id, sprintId) {
175
+ const p = path.join(this.storeRoot, 'events', sprintId, `${id}.json`);
176
+ return this._readJson(p);
177
+ }
178
+ listEvents(sprintId, filter) {
179
+ const dir = path.join(this.storeRoot, 'events', sprintId);
180
+ if (!fs.existsSync(dir)) return [];
181
+ return fs.readdirSync(dir)
182
+ .filter(f => f.endsWith('.json'))
183
+ .map(f => this._readJson(path.join(dir, f)))
184
+ .filter(e => !e || (filter ? this._matches(e, filter) : true));
185
+ }
186
+ /**
187
+ * Find an event file whose internal eventId matches the given eventId,
188
+ * but whose filename does not. Returns the mismatched filename (without
189
+ * .json extension), or null if none found. Skips _-prefixed (ephemeral) files.
190
+ */
191
+ _findEventFileByContentId(sprintId, eventId) {
192
+ const dir = path.join(this.storeRoot, 'events', sprintId);
193
+ if (!fs.existsSync(dir)) return null;
194
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.json') && !f.startsWith('_'));
195
+ for (const file of files) {
196
+ const filename = file.slice(0, -5); // strip .json
197
+ if (filename === eventId) continue; // already canonical
198
+ const rec = this._readJson(path.join(dir, file));
199
+ if (rec && rec.eventId === eventId) {
200
+ return filename;
201
+ }
202
+ }
203
+ return null;
204
+ }
205
+ /**
206
+ * Rename an event file from oldFilename to match newEventId.
207
+ * Throws if the target file already exists (collision).
208
+ */
209
+ renameEvent(sprintId, oldFilename, newEventId) {
210
+ const dir = path.join(this.storeRoot, 'events', sprintId);
211
+ const oldPath = path.join(dir, `${oldFilename}.json`);
212
+ const newPath = path.join(dir, `${newEventId}.json`);
213
+ if (oldPath === newPath) return; // no-op
214
+ if (fs.existsSync(newPath)) {
215
+ throw new Error(`Cannot rename event: target file already exists: ${newPath}`);
216
+ }
217
+ if (fs.existsSync(oldPath)) {
218
+ fs.renameSync(oldPath, newPath);
219
+ }
220
+ }
221
+ writeEvent(sprintId, data) {
222
+ // Detect ghost file: an existing file whose content eventId matches but
223
+ // whose filename does not. Rename it to the canonical name before writing.
224
+ const ghostFilename = this._findEventFileByContentId(sprintId, data.eventId);
225
+ if (ghostFilename !== null) {
226
+ this.renameEvent(sprintId, ghostFilename, data.eventId);
227
+ }
228
+ const p = path.join(this.storeRoot, 'events', sprintId, `${data.eventId}.json`);
229
+ return this._writeJson(p, data);
230
+ }
231
+ deleteEvent(id, sprintId) {
232
+ const p = path.join(this.storeRoot, 'events', sprintId, `${id}.json`);
233
+ if (fs.existsSync(p)) fs.unlinkSync(p);
234
+ }
235
+
236
+ // Features
237
+ getFeature(id) { return this._readJson(this._getPath('feature', id)); }
238
+ listFeatures(filter) {
239
+ const dir = path.join(this.storeRoot, 'features');
240
+ if (!fs.existsSync(dir)) return [];
241
+ return fs.readdirSync(dir)
242
+ .filter(f => f.endsWith('.json'))
243
+ .map(f => this._readJson(path.join(dir, f)))
244
+ .filter(f => !f || (filter ? this._matches(f, filter) : true));
245
+ }
246
+ writeFeature(data) {
247
+ return this._writeJson(this._getPath('feature', data.feature_id), data);
248
+ }
249
+ deleteFeature(id) {
250
+ const p = this._getPath('feature', id);
251
+ if (fs.existsSync(p)) fs.unlinkSync(p);
252
+ }
253
+
254
+ // Collation State
255
+ writeCollationState(data) {
256
+ const filePath = path.join(this.storeRoot, 'COLLATION_STATE.json');
257
+ return this._writeJson(filePath, data);
258
+ }
259
+ readCollationState() {
260
+ const filePath = path.join(this.storeRoot, 'COLLATION_STATE.json');
261
+ return this._readJson(filePath);
262
+ }
263
+
264
+ // Event Operations (extended)
265
+ /**
266
+ * Purge the events directory for a given sprint.
267
+ * Includes a path-traversal guard: the resolved directory must remain
268
+ * within the events base directory. Throws on escape attempt.
269
+ * Note: fileCount reflects .json files only, but the directory is removed
270
+ * entirely (including any non-.json files).
271
+ */
272
+ purgeEvents(sprintId, { dryRun = false } = {}) {
273
+ const eventsBase = path.resolve(this.storeRoot, 'events');
274
+ const eventsDir = path.resolve(eventsBase, sprintId);
275
+ // Guard: resolved path must stay within the events base directory.
276
+ if (!eventsDir.startsWith(eventsBase + path.sep) && eventsDir !== eventsBase) {
277
+ throw new Error(`Resolved events path '${eventsDir}' escapes store root — aborting purge`);
278
+ }
279
+ if (!fs.existsSync(eventsDir)) {
280
+ return { purged: false, fileCount: 0, files: [] };
281
+ }
282
+ const files = fs.readdirSync(eventsDir).filter(f => f.endsWith('.json'));
283
+ if (dryRun) {
284
+ return { purged: false, fileCount: files.length, files };
285
+ }
286
+ fs.rmSync(eventsDir, { recursive: true, force: true });
287
+ return { purged: true, fileCount: files.length, files };
288
+ }
289
+
290
+ /**
291
+ * List all event filenames for a sprint directory.
292
+ * Returns { filename, id } objects for ALL .json files including
293
+ * _-prefixed ephemeral sidecars. Callers filter internally.
294
+ */
295
+ listEventFilenames(sprintId) {
296
+ const dir = path.join(this.storeRoot, 'events', sprintId);
297
+ if (!fs.existsSync(dir)) return [];
298
+ return fs.readdirSync(dir)
299
+ .filter(f => f.endsWith('.json'))
300
+ .map(f => ({
301
+ filename: f,
302
+ id: f.slice(0, -5) // strip .json extension
303
+ }));
304
+ }
305
+
306
+ _matches(record, filter) {
307
+ if (!filter) return true;
308
+ return Object.entries(filter).every(([key, value]) => record[key] === value);
309
+ }
310
+ }
311
+
312
+ // Export a singleton instance for the plugin, plus classes for testing
313
+ module.exports = new Store(new FSImpl());
314
+ module.exports.Store = Store;
315
+ module.exports.FSImpl = FSImpl;