@entelligentsia/forgecli 0.8.4 → 0.9.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 (170) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +165 -2
  3. package/dist/bin/argv.d.ts +2 -2
  4. package/dist/bin/argv.js +17 -0
  5. package/dist/bin/argv.js.map +1 -1
  6. package/dist/bin/config.d.ts +69 -0
  7. package/dist/bin/config.js +315 -0
  8. package/dist/bin/config.js.map +1 -0
  9. package/dist/bin/doctor.d.ts +1 -0
  10. package/dist/bin/doctor.js +12 -0
  11. package/dist/bin/doctor.js.map +1 -1
  12. package/dist/bin/forge.js +7 -0
  13. package/dist/bin/forge.js.map +1 -1
  14. package/dist/extensions/forgecli/config-command.d.ts +8 -0
  15. package/dist/extensions/forgecli/config-command.js +66 -0
  16. package/dist/extensions/forgecli/config-command.js.map +1 -0
  17. package/dist/extensions/forgecli/config-layer.d.ts +38 -0
  18. package/dist/extensions/forgecli/config-layer.js +68 -0
  19. package/dist/extensions/forgecli/config-layer.js.map +1 -0
  20. package/dist/extensions/forgecli/config-tui/component.d.ts +35 -0
  21. package/dist/extensions/forgecli/config-tui/component.js +236 -0
  22. package/dist/extensions/forgecli/config-tui/component.js.map +1 -0
  23. package/dist/extensions/forgecli/config-tui/handler.d.ts +40 -0
  24. package/dist/extensions/forgecli/config-tui/handler.js +240 -0
  25. package/dist/extensions/forgecli/config-tui/handler.js.map +1 -0
  26. package/dist/extensions/forgecli/config-tui/index.d.ts +5 -0
  27. package/dist/extensions/forgecli/config-tui/index.js +5 -0
  28. package/dist/extensions/forgecli/config-tui/index.js.map +1 -0
  29. package/dist/extensions/forgecli/config-tui/keys.d.ts +26 -0
  30. package/dist/extensions/forgecli/config-tui/keys.js +33 -0
  31. package/dist/extensions/forgecli/config-tui/keys.js.map +1 -0
  32. package/dist/extensions/forgecli/config-tui/plugin-config-reader.d.ts +23 -0
  33. package/dist/extensions/forgecli/config-tui/plugin-config-reader.js +58 -0
  34. package/dist/extensions/forgecli/config-tui/plugin-config-reader.js.map +1 -0
  35. package/dist/extensions/forgecli/config-tui/screens/advanced-menu.d.ts +7 -0
  36. package/dist/extensions/forgecli/config-tui/screens/advanced-menu.js +83 -0
  37. package/dist/extensions/forgecli/config-tui/screens/advanced-menu.js.map +1 -0
  38. package/dist/extensions/forgecli/config-tui/screens/confirm-quit.d.ts +11 -0
  39. package/dist/extensions/forgecli/config-tui/screens/confirm-quit.js +54 -0
  40. package/dist/extensions/forgecli/config-tui/screens/confirm-quit.js.map +1 -0
  41. package/dist/extensions/forgecli/config-tui/screens/override-editor.d.ts +11 -0
  42. package/dist/extensions/forgecli/config-tui/screens/override-editor.js +233 -0
  43. package/dist/extensions/forgecli/config-tui/screens/override-editor.js.map +1 -0
  44. package/dist/extensions/forgecli/config-tui/screens/overrides-list-phases.d.ts +7 -0
  45. package/dist/extensions/forgecli/config-tui/screens/overrides-list-phases.js +91 -0
  46. package/dist/extensions/forgecli/config-tui/screens/overrides-list-phases.js.map +1 -0
  47. package/dist/extensions/forgecli/config-tui/screens/overrides-list.d.ts +7 -0
  48. package/dist/extensions/forgecli/config-tui/screens/overrides-list.js +71 -0
  49. package/dist/extensions/forgecli/config-tui/screens/overrides-list.js.map +1 -0
  50. package/dist/extensions/forgecli/config-tui/screens/persona-editor.d.ts +10 -0
  51. package/dist/extensions/forgecli/config-tui/screens/persona-editor.js +182 -0
  52. package/dist/extensions/forgecli/config-tui/screens/persona-editor.js.map +1 -0
  53. package/dist/extensions/forgecli/config-tui/screens/persona-picker.d.ts +7 -0
  54. package/dist/extensions/forgecli/config-tui/screens/persona-picker.js +76 -0
  55. package/dist/extensions/forgecli/config-tui/screens/persona-picker.js.map +1 -0
  56. package/dist/extensions/forgecli/config-tui/screens/personas-list.d.ts +7 -0
  57. package/dist/extensions/forgecli/config-tui/screens/personas-list.js +98 -0
  58. package/dist/extensions/forgecli/config-tui/screens/personas-list.js.map +1 -0
  59. package/dist/extensions/forgecli/config-tui/screens/shared.d.ts +29 -0
  60. package/dist/extensions/forgecli/config-tui/screens/shared.js +100 -0
  61. package/dist/extensions/forgecli/config-tui/screens/shared.js.map +1 -0
  62. package/dist/extensions/forgecli/config-tui/screens/show-resolved.d.ts +23 -0
  63. package/dist/extensions/forgecli/config-tui/screens/show-resolved.js +128 -0
  64. package/dist/extensions/forgecli/config-tui/screens/show-resolved.js.map +1 -0
  65. package/dist/extensions/forgecli/config-tui/screens/tier-menu.d.ts +7 -0
  66. package/dist/extensions/forgecli/config-tui/screens/tier-menu.js +135 -0
  67. package/dist/extensions/forgecli/config-tui/screens/tier-menu.js.map +1 -0
  68. package/dist/extensions/forgecli/config-tui/screens/tier-picker.d.ts +9 -0
  69. package/dist/extensions/forgecli/config-tui/screens/tier-picker.js +122 -0
  70. package/dist/extensions/forgecli/config-tui/screens/tier-picker.js.map +1 -0
  71. package/dist/extensions/forgecli/config-tui/screens/types.d.ts +24 -0
  72. package/dist/extensions/forgecli/config-tui/screens/types.js +5 -0
  73. package/dist/extensions/forgecli/config-tui/screens/types.js.map +1 -0
  74. package/dist/extensions/forgecli/config-tui/screens.d.ts +24 -0
  75. package/dist/extensions/forgecli/config-tui/screens.js +78 -0
  76. package/dist/extensions/forgecli/config-tui/screens.js.map +1 -0
  77. package/dist/extensions/forgecli/config-tui/state/buffer.d.ts +11 -0
  78. package/dist/extensions/forgecli/config-tui/state/buffer.js +91 -0
  79. package/dist/extensions/forgecli/config-tui/state/buffer.js.map +1 -0
  80. package/dist/extensions/forgecli/config-tui/state/constants.d.ts +4 -0
  81. package/dist/extensions/forgecli/config-tui/state/constants.js +14 -0
  82. package/dist/extensions/forgecli/config-tui/state/constants.js.map +1 -0
  83. package/dist/extensions/forgecli/config-tui/state/index.d.ts +6 -0
  84. package/dist/extensions/forgecli/config-tui/state/index.js +9 -0
  85. package/dist/extensions/forgecli/config-tui/state/index.js.map +1 -0
  86. package/dist/extensions/forgecli/config-tui/state/init.d.ts +2 -0
  87. package/dist/extensions/forgecli/config-tui/state/init.js +30 -0
  88. package/dist/extensions/forgecli/config-tui/state/init.js.map +1 -0
  89. package/dist/extensions/forgecli/config-tui/state/model.d.ts +192 -0
  90. package/dist/extensions/forgecli/config-tui/state/model.js +4 -0
  91. package/dist/extensions/forgecli/config-tui/state/model.js.map +1 -0
  92. package/dist/extensions/forgecli/config-tui/state/reducer.d.ts +2 -0
  93. package/dist/extensions/forgecli/config-tui/state/reducer.js +212 -0
  94. package/dist/extensions/forgecli/config-tui/state/reducer.js.map +1 -0
  95. package/dist/extensions/forgecli/config-tui/state/selectors.d.ts +91 -0
  96. package/dist/extensions/forgecli/config-tui/state/selectors.js +231 -0
  97. package/dist/extensions/forgecli/config-tui/state/selectors.js.map +1 -0
  98. package/dist/extensions/forgecli/config-tui/state.d.ts +6 -0
  99. package/dist/extensions/forgecli/config-tui/state.js +11 -0
  100. package/dist/extensions/forgecli/config-tui/state.js.map +1 -0
  101. package/dist/extensions/forgecli/config-tui/theme.d.ts +37 -0
  102. package/dist/extensions/forgecli/config-tui/theme.js +88 -0
  103. package/dist/extensions/forgecli/config-tui/theme.js.map +1 -0
  104. package/dist/extensions/forgecli/config-tui/tier-meta.d.ts +28 -0
  105. package/dist/extensions/forgecli/config-tui/tier-meta.js +69 -0
  106. package/dist/extensions/forgecli/config-tui/tier-meta.js.map +1 -0
  107. package/dist/extensions/forgecli/config-writer.d.ts +16 -0
  108. package/dist/extensions/forgecli/config-writer.js +63 -0
  109. package/dist/extensions/forgecli/config-writer.js.map +1 -0
  110. package/dist/extensions/forgecli/fix-bug.js +85 -1
  111. package/dist/extensions/forgecli/fix-bug.js.map +1 -1
  112. package/dist/extensions/forgecli/forge-cli-schema.json +54 -0
  113. package/dist/extensions/forgecli/forge-commands.js +3 -8
  114. package/dist/extensions/forgecli/forge-commands.js.map +1 -1
  115. package/dist/extensions/forgecli/forge-subagent.d.ts +13 -0
  116. package/dist/extensions/forgecli/forge-subagent.js +19 -0
  117. package/dist/extensions/forgecli/forge-subagent.js.map +1 -1
  118. package/dist/extensions/forgecli/index.js +16 -0
  119. package/dist/extensions/forgecli/index.js.map +1 -1
  120. package/dist/extensions/forgecli/input-router.d.ts +33 -0
  121. package/dist/extensions/forgecli/input-router.js +133 -0
  122. package/dist/extensions/forgecli/input-router.js.map +1 -0
  123. package/dist/extensions/forgecli/model-resolver.d.ts +32 -0
  124. package/dist/extensions/forgecli/model-resolver.js +65 -0
  125. package/dist/extensions/forgecli/model-resolver.js.map +1 -0
  126. package/dist/extensions/forgecli/model-validator.d.ts +29 -0
  127. package/dist/extensions/forgecli/model-validator.js +107 -0
  128. package/dist/extensions/forgecli/model-validator.js.map +1 -0
  129. package/dist/extensions/forgecli/run-sprint.js +59 -0
  130. package/dist/extensions/forgecli/run-sprint.js.map +1 -1
  131. package/dist/extensions/forgecli/run-task.js +93 -1
  132. package/dist/extensions/forgecli/run-task.js.map +1 -1
  133. package/dist/extensions/forgecli/thread-switcher.js +5 -2
  134. package/dist/extensions/forgecli/thread-switcher.js.map +1 -1
  135. package/dist/extensions/forgecli/whats-new-widget.js +5 -2
  136. package/dist/extensions/forgecli/whats-new-widget.js.map +1 -1
  137. package/package.json +11 -3
  138. package/dist/extensions/forgecli/review-command.d.ts +0 -2
  139. package/dist/extensions/forgecli/review-command.js +0 -184
  140. package/dist/extensions/forgecli/review-command.js.map +0 -1
  141. package/dist/forge-payload/.tools/banners.cjs +0 -435
  142. package/dist/forge-payload/.tools/build-context-pack.cjs +0 -290
  143. package/dist/forge-payload/.tools/build-init-context.cjs +0 -322
  144. package/dist/forge-payload/.tools/build-overlay.cjs +0 -326
  145. package/dist/forge-payload/.tools/build-persona-pack.cjs +0 -226
  146. package/dist/forge-payload/.tools/collate.cjs +0 -1041
  147. package/dist/forge-payload/.tools/generation-manifest.cjs +0 -311
  148. package/dist/forge-payload/.tools/lib/forge-root.cjs +0 -59
  149. package/dist/forge-payload/.tools/lib/paths.cjs +0 -29
  150. package/dist/forge-payload/.tools/lib/pricing.cjs +0 -165
  151. package/dist/forge-payload/.tools/lib/project-root.cjs +0 -32
  152. package/dist/forge-payload/.tools/lib/result.js +0 -40
  153. package/dist/forge-payload/.tools/lib/store-facade.cjs +0 -162
  154. package/dist/forge-payload/.tools/lib/store-nlp.cjs +0 -250
  155. package/dist/forge-payload/.tools/lib/store-query-exec.cjs +0 -272
  156. package/dist/forge-payload/.tools/lib/validate.js +0 -141
  157. package/dist/forge-payload/.tools/manage-config.cjs +0 -340
  158. package/dist/forge-payload/.tools/manage-versions.cjs +0 -365
  159. package/dist/forge-payload/.tools/package.json +0 -3
  160. package/dist/forge-payload/.tools/parse-gates.cjs +0 -151
  161. package/dist/forge-payload/.tools/parse-verdict.cjs +0 -67
  162. package/dist/forge-payload/.tools/preflight-gate.cjs +0 -350
  163. package/dist/forge-payload/.tools/prompts/sprint-plan-prompt.md +0 -70
  164. package/dist/forge-payload/.tools/schemas/task-list.schema.json +0 -53
  165. package/dist/forge-payload/.tools/seed-store.cjs +0 -237
  166. package/dist/forge-payload/.tools/store-cli.cjs +0 -1226
  167. package/dist/forge-payload/.tools/store-query.cjs +0 -319
  168. package/dist/forge-payload/.tools/store.cjs +0 -315
  169. package/dist/forge-payload/.tools/substitute-placeholders.cjs +0 -625
  170. package/dist/forge-payload/.tools/validate-store.cjs +0 -593
@@ -1,1041 +0,0 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- // Forge tool: collate
5
- // Regenerate markdown views from the JSON store. Deterministic — no AI needed.
6
- // Usage: collate [SPRINT_ID | BUG_ID] [--dry-run] [--purge-events]
7
- //
8
- // Positional argument can be a sprint ID (e.g. FORGE-S12) or a bug ID
9
- // (e.g. FORGE-BUG-007, HELLO-B02). Bug IDs are first-class arguments:
10
- // passing a bug ID automatically enables event purging for that entity,
11
- // so `collate HELLO-B02` works identically to `collate HELLO-B02 --purge-events`.
12
- //
13
- // --purge-events After generating COST_REPORT.md for the given SPRINT_ID,
14
- // delete .forge/store/events/{SPRINT_ID}/ entirely.
15
- // If SPRINT_ID is not a known sprint (e.g. a bug ID), no
16
- // COST_REPORT is generated but the event directory is still
17
- // purged. Requires a positional argument. Safe to combine with
18
- // --dry-run (reports what would be deleted without deleting).
19
-
20
- const fs = require('fs');
21
- const path = require('path');
22
- const { ok: resultOk, fail: resultFail, RESULT_CODES } = require('./lib/result.js');
23
- const { computeCost, canonicalizeModel } = require('./lib/pricing.cjs');
24
-
25
- let _store;
26
- function _getStore() { return _store || (_store = require('./store.cjs')); }
27
-
28
- const GENERATED = '<!-- GENERATED by forge/collate — do not edit manually -->';
29
-
30
- // --- Pure helpers (exported for testing) ---
31
-
32
- function statusBadge(status) {
33
- const map = {
34
- committed: '✅', approved: '✅', completed: '✅',
35
- 'retrospective-done': '✅', fixed: '✅', verified: '✅',
36
- implemented: '🔧', implementing: '🔧',
37
- 'in-progress': '🔵', active: '🔵',
38
- 'plan-approved': '📋', 'review-approved': '📋', planned: '📋',
39
- planning: '📝', draft: '📝',
40
- triaged: '🟡', 'partially-completed': '🟡',
41
- 'plan-revision-required': '🔄', 'code-revision-required': '🔄',
42
- reported: '🔴', blocked: '🚫', escalated: '⚠️',
43
- abandoned: '❌',
44
- };
45
- const badge = map[status];
46
- return badge ? `${badge} ${status}` : status;
47
- }
48
-
49
- function padTable(rows) {
50
- if (rows.length === 0) return '';
51
- const cols = rows[0].length;
52
- const widths = Array.from({ length: cols }, (_, i) => Math.max(...rows.map(r => String(r[i] ?? '').length)));
53
- return rows.map((r, ri) => {
54
- const cells = r.map((c, i) => String(c ?? '').padEnd(widths[i]));
55
- const line = `| ${cells.join(' | ')} |`;
56
- if (ri === 0) return line + '\n' + `| ${widths.map(w => '-'.repeat(w)).join(' | ')} |`;
57
- return line;
58
- }).join('\n');
59
- }
60
-
61
- function fmtTokens(n) {
62
- if (n === undefined || n === null) return '—';
63
- return n.toLocaleString('en-US');
64
- }
65
-
66
- function fmtCost(n) {
67
- if (n === undefined || n === null) return '—';
68
- return `$${n.toFixed(4)}`;
69
- }
70
-
71
- function sourceLabel(sources) {
72
- // sources: Set of tokenSource values (may include undefined)
73
- const vals = [...sources];
74
- const hasReported = vals.includes('reported');
75
- const hasEstimated = vals.includes('estimated');
76
- const hasUnknown = vals.some(v => v === undefined || v === null);
77
-
78
- if (hasReported && !hasEstimated && !hasUnknown) return '(reported)';
79
- if (hasEstimated && !hasReported && !hasUnknown) return '(estimated)';
80
- if (!hasReported && !hasEstimated && hasUnknown) return '(unknown)';
81
- return '(mixed)';
82
- }
83
-
84
- const SPRINT_DOCS = [
85
- { file: 'SPRINT_PLAN.md', label: 'Sprint Plan', purpose: 'Sprint scope and task definitions' },
86
- { file: 'SPRINT_REQUIREMENTS.md', label: 'Sprint Requirements', purpose: 'Requirements and acceptance criteria' },
87
- { file: 'COST_REPORT.md', label: 'Cost Report', purpose: 'Token usage and cost analysis' },
88
- ];
89
-
90
- const TASK_DOCS = [
91
- { file: 'TASK_PROMPT.md', label: 'Task Prompt', purpose: 'Task definition and prompt' },
92
- { file: 'PLAN.md', label: 'Plan', purpose: 'Implementation plan' },
93
- { file: 'PROGRESS.md', label: 'Progress', purpose: 'Implementation progress log' },
94
- { file: 'ARCHITECT_APPROVAL.md', label: 'Architect Approval', purpose: 'Architecture review' },
95
- { file: 'CODE_REVIEW.md', label: 'Code Review', purpose: 'Code review' },
96
- { file: 'VALIDATION_REPORT.md', label: 'Validation Report', purpose: 'Validation results' },
97
- ];
98
-
99
- // Resolve the task directory name within a sprint directory.
100
- // Returns the directory name string, or null if no directory can be found.
101
- //
102
- // Resolution order:
103
- // 1. If task.path is under engPath (an engineering KB path) — basename of that path.
104
- // 2. Filesystem scan of sprintDirPath for a directory whose name starts with taskId
105
- // or whose leading integer matches that of taskId (slug-named dirs).
106
- // 3. null if nothing matches.
107
- function resolveTaskDir(task, sprintDirPath, engPath) {
108
- const normalizedTaskPath = task.path ? task.path.replace(/\\/g, '/').replace(/\/$/, '') : null;
109
- const normalizedEngPath = engPath ? engPath.replace(/\\/g, '/').replace(/\/$/, '') : '';
110
-
111
- // Case 1: path is under the engineering root — it IS the task directory
112
- if (normalizedTaskPath && normalizedEngPath && normalizedTaskPath.startsWith(normalizedEngPath + '/')) {
113
- return resultOk(path.basename(normalizedTaskPath));
114
- }
115
-
116
- // Case 2 (and fallback for case 1 missing): filesystem scan
117
- if (fs.existsSync(sprintDirPath)) {
118
- const entries = fs.readdirSync(sprintDirPath).sort();
119
- // Prefer exact match first
120
- if (entries.includes(task.taskId)) return resultOk(task.taskId);
121
- // Then prefix match (slug-named dirs like TST-S01-T01-my-feature)
122
- const prefix = task.taskId + '-';
123
- const slugMatch = entries.find(e => e.startsWith(prefix) && fs.statSync(path.join(sprintDirPath, e)).isDirectory());
124
- if (slugMatch) return resultOk(slugMatch);
125
- // Numeric fallback: match first integer in taskId
126
- const numMatch = task.taskId.match(/\d+/g);
127
- const targetNum = numMatch ? parseInt(numMatch[numMatch.length - 1], 10) : null;
128
- if (targetNum !== null) {
129
- for (const e of entries) {
130
- const em = e.match(/\d+/g);
131
- if (em && parseInt(em[em.length - 1], 10) === targetNum && fs.statSync(path.join(sprintDirPath, e)).isDirectory()) {
132
- return resultOk(e);
133
- }
134
- }
135
- }
136
- }
137
-
138
- return resultFail(RESULT_CODES.MISSING_DIR, `No task directory found for ${task.taskId} in ${sprintDirPath}`);
139
- }
140
-
141
- function buildSprintIndex(sprint, tasks, availableDocs) {
142
- const avail = new Set(availableDocs);
143
- const lines = [GENERATED, '', `# Sprint: ${sprint.title || sprint.sprintId}`, '',
144
- `> Sprint ID: ${sprint.sprintId}`,
145
- `> Status: ${statusBadge(sprint.status)}`,
146
- sprint.executionMode ? `> Execution Mode: ${sprint.executionMode}` : null,
147
- ''].filter(l => l !== null);
148
-
149
- if (sprint.goal) lines.push('## Goal', '', sprint.goal, '');
150
- if (sprint.description && sprint.description !== sprint.goal) lines.push('## Description', '', sprint.description, '');
151
-
152
- const presentDocs = SPRINT_DOCS.filter(d => avail.has(d.file));
153
- if (presentDocs.length > 0) {
154
- lines.push('## Sprint Documents', '');
155
- const rows = [['Document', 'Purpose']];
156
- for (const d of presentDocs) rows.push([`[${d.label}](${d.file})`, d.purpose]);
157
- lines.push(padTable(rows), '');
158
- }
159
-
160
- lines.push('## Tasks', '');
161
- if (tasks.length > 0) {
162
- const rows = [['Task', 'Title', 'Status', 'Estimate']];
163
- for (const t of tasks) {
164
- const linkDir = t._taskDir || t.taskId;
165
- rows.push([`[${t.taskId}](${linkDir}/INDEX.md)`, t.title || '—', statusBadge(t.status), t.estimate || '—']);
166
- }
167
- lines.push(padTable(rows), '');
168
- } else {
169
- lines.push('_No tasks._', '');
170
- }
171
-
172
- return lines.join('\n') + '\n';
173
- }
174
-
175
- function buildTaskIndex(task, availableDocs) {
176
- const avail = new Set(availableDocs);
177
- const lines = [GENERATED, '', `# Task: ${task.title || task.taskId}`, '',
178
- `> Task ID: ${task.taskId}`,
179
- `> Sprint: [${task.sprintId}](../INDEX.md)`,
180
- `> Status: ${statusBadge(task.status)}`,
181
- task.estimate ? `> Estimate: ${task.estimate}` : null,
182
- ''].filter(l => l !== null);
183
-
184
- if (task.description) lines.push('## Description', '', task.description, '');
185
-
186
- const presentDocs = TASK_DOCS.filter(d => avail.has(d.file));
187
- if (presentDocs.length > 0) {
188
- lines.push('## Task Documents', '');
189
- const rows = [['Document', 'Purpose']];
190
- for (const d of presentDocs) rows.push([`[${d.label}](${d.file})`, d.purpose]);
191
- lines.push(padTable(rows), '');
192
- }
193
-
194
- return lines.join('\n') + '\n';
195
- }
196
-
197
- const BUG_DOCS = [
198
- { file: 'ANALYSIS.md', label: 'Analysis', purpose: 'Bug analysis and triage' },
199
- { file: 'BUG_FIX_PLAN.md', label: 'Fix Plan', purpose: 'Fix implementation plan' },
200
- { file: 'PLAN.md', label: 'Plan', purpose: 'Fix implementation plan' },
201
- { file: 'PLAN_REVIEW.md', label: 'Plan Review', purpose: 'Fix plan review' },
202
- { file: 'PROGRESS.md', label: 'Progress', purpose: 'Fix progress log' },
203
- { file: 'APPROVAL.md', label: 'Approval', purpose: 'Architecture approval' },
204
- { file: 'ARCHITECT_APPROVAL.md', label: 'Architect Approval', purpose: 'Architecture approval' },
205
- { file: 'CODE_REVIEW.md', label: 'Code Review', purpose: 'Code review' },
206
- { file: 'VALIDATION_REPORT.md', label: 'Validation Report', purpose: 'Fix validation' },
207
- ];
208
-
209
- function buildBugIndex(bug, availableDocs, costTotals) {
210
- const avail = new Set(availableDocs);
211
- const lines = [GENERATED, '', `# Bug: ${bug.title || bug.bugId}`, '',
212
- `> Bug ID: ${bug.bugId}`,
213
- `> Severity: ${bug.severity || '—'}`,
214
- `> Status: ${statusBadge(bug.status)}`,
215
- bug.reportedAt ? `> Reported: ${bug.reportedAt.slice(0, 10)}` : null,
216
- bug.resolvedAt ? `> Resolved: ${bug.resolvedAt.slice(0, 10)}` : null,
217
- ''].filter(l => l !== null);
218
-
219
- if (bug.description) lines.push('## Description', '', bug.description, '');
220
-
221
- // Cost aggregation section — included when costTotals is provided
222
- // (typically when --purge-events aggregates event costs before deletion).
223
- if (costTotals && costTotals.inputTokens !== undefined) {
224
- lines.push('## Cost', '');
225
- const rows = [
226
- ['Input Tokens', 'Output Tokens', 'Cache Read', 'Cache Write', 'Est. Cost USD', 'Source'],
227
- [
228
- fmtTokens(costTotals.inputTokens),
229
- fmtTokens(costTotals.outputTokens),
230
- fmtTokens(costTotals.cacheReadTokens),
231
- fmtTokens(costTotals.cacheWriteTokens),
232
- fmtCost(costTotals.estimatedCostUSD),
233
- costTotals.sourceLabel || '—',
234
- ],
235
- ];
236
- lines.push(padTable(rows), '');
237
- }
238
-
239
- const presentDocs = BUG_DOCS.filter(d => avail.has(d.file));
240
- if (presentDocs.length > 0) {
241
- lines.push('## Bug Documents', '');
242
- const rows = [['Document', 'Purpose']];
243
- for (const d of presentDocs) rows.push([`[${d.label}](${d.file})`, d.purpose]);
244
- lines.push(padTable(rows), '');
245
- }
246
-
247
- return lines.join('\n') + '\n';
248
- }
249
-
250
- /**
251
- * Detect whether a string matches the bug-ID pattern.
252
- * Bug IDs match one of these patterns:
253
- * 1. Contains 'BUG-' followed by digits (e.g., BUG-001, FORGE-BUG-007)
254
- * 2. Contains '-B' followed by digits at a segment boundary (e.g., HELLO-B02)
255
- * Sprint IDs (FORGE-S12) and task IDs (FORGE-S12-T03) are excluded.
256
- */
257
- function isBugId(id) {
258
- if (!id || typeof id !== 'string') return false;
259
- return /BUG-\d+/.test(id) || /-B\d+\b/.test(id);
260
- }
261
-
262
- /**
263
- * mergeSidecarEvents(primaryEvents, sidecars)
264
- *
265
- * Merges sidecar token data onto matching primary events by eventId.
266
- * Returns { events, orphanSidecars, huskPrimaries } where:
267
- * - events: all primary events (merged primaries have token fields from sidecar)
268
- * - orphanSidecars: sidecars with no matching primary
269
- * - huskPrimaries: primaries that still have no token data after merge attempt
270
- *
271
- * On merge:
272
- * - Token fields (inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens,
273
- * estimatedCostUSD) are copied from the sidecar onto the primary.
274
- * - If all four token counts are present after merge and the primary has a known
275
- * model, estimatedCostUSD is recomputed via pricing.computeCost (overwrites
276
- * any fabricated value from the sidecar).
277
- * - The model field on the primary is canonicalized if possible.
278
- *
279
- * Pure function — no filesystem I/O.
280
- */
281
- function mergeSidecarEvents(primaryEvents, sidecars) {
282
- // Build sidecar lookup map by eventId
283
- const sidecarMap = new Map();
284
- for (const s of sidecars) {
285
- if (s && s.eventId) {
286
- sidecarMap.set(s.eventId, s);
287
- }
288
- }
289
-
290
- const usedSidecarIds = new Set();
291
- const events = [];
292
- const huskPrimaries = [];
293
-
294
- for (const primary of primaryEvents) {
295
- if (!primary) continue;
296
- const merged = Object.assign({}, primary);
297
-
298
- // Canonicalize model on read
299
- if (merged.model) {
300
- const canonResult = canonicalizeModel(merged.model);
301
- if (canonResult !== null) {
302
- merged.model = canonResult.canonical;
303
- }
304
- }
305
-
306
- // Attempt sidecar merge
307
- const sidecar = sidecarMap.get(merged.eventId);
308
- if (sidecar) {
309
- usedSidecarIds.add(merged.eventId);
310
- // Merge token fields from sidecar
311
- if (sidecar.inputTokens !== undefined) merged.inputTokens = sidecar.inputTokens;
312
- if (sidecar.outputTokens !== undefined) merged.outputTokens = sidecar.outputTokens;
313
- if (sidecar.cacheReadTokens !== undefined) merged.cacheReadTokens = sidecar.cacheReadTokens;
314
- if (sidecar.cacheWriteTokens !== undefined) merged.cacheWriteTokens = sidecar.cacheWriteTokens;
315
- if (sidecar.estimatedCostUSD !== undefined) merged.estimatedCostUSD = sidecar.estimatedCostUSD;
316
- if (sidecar.tokenSource !== undefined) merged.tokenSource = sidecar.tokenSource;
317
- if (sidecar.model && !merged.model) merged.model = sidecar.model;
318
- }
319
-
320
- // Recompute cost when all four counts are present and model is known
321
- if (
322
- merged.inputTokens !== undefined &&
323
- merged.outputTokens !== undefined &&
324
- merged.cacheReadTokens !== undefined &&
325
- merged.cacheWriteTokens !== undefined &&
326
- merged.model
327
- ) {
328
- const recomputed = computeCost({
329
- inputTokens: merged.inputTokens,
330
- outputTokens: merged.outputTokens,
331
- cacheReadTokens: merged.cacheReadTokens,
332
- cacheWriteTokens: merged.cacheWriteTokens,
333
- model: merged.model,
334
- });
335
- if (recomputed !== null) {
336
- merged.estimatedCostUSD = recomputed;
337
- }
338
- }
339
-
340
- events.push(merged);
341
-
342
- // Classify as husk if no token data after merge
343
- if (merged.inputTokens === undefined) {
344
- huskPrimaries.push(merged);
345
- }
346
- }
347
-
348
- // Collect orphan sidecars (no matching primary)
349
- const orphanSidecars = sidecars.filter(s => s && s.eventId && !usedSidecarIds.has(s.eventId));
350
-
351
- return { events, orphanSidecars, huskPrimaries };
352
- }
353
-
354
- /**
355
- * loadSprintEvents(sprintId)
356
- *
357
- * Reads primary and sidecar event files for a sprint from the store.
358
- * Returns { events, orphanSidecars, huskPrimaries } — see mergeSidecarEvents.
359
- *
360
- * Primary files: .json files NOT starting with '_'
361
- * Sidecar files: .json files starting with '_' and ending with '_usage.json'
362
- */
363
- function loadSprintEvents(sprintId) {
364
- const store = _getStore();
365
- const allFilenames = store.listEventFilenames(sprintId);
366
-
367
- const primaryFilenames = allFilenames.filter(({ filename }) => !filename.startsWith('_'));
368
- const sidecarFilenames = allFilenames.filter(({ filename }) =>
369
- filename.startsWith('_') && filename.endsWith('_usage.json')
370
- );
371
-
372
- // Read each primary event individually (avoids re-reading sidecars)
373
- const primaryEvents = primaryFilenames
374
- .map(({ id }) => store.getEvent(id, sprintId))
375
- .filter(Boolean);
376
-
377
- // Read sidecar files via getEvent using the id (filename without .json)
378
- const sidecars = sidecarFilenames
379
- .map(({ id }) => store.getEvent(id, sprintId))
380
- .filter(Boolean);
381
-
382
- return mergeSidecarEvents(primaryEvents, sidecars);
383
- }
384
-
385
- /**
386
- * buildIngestionQuality(orphanSidecars, huskPrimaries, noTaskEvents, unmappedModels, totalEvents, tokenSourceCounts)
387
- *
388
- * Builds the "## Ingestion Quality" section for COST_REPORT.md.
389
- * Returns a markdown string (without trailing newline; caller adds one).
390
- *
391
- * @param {object[]} orphanSidecars - sidecars with no matching primary
392
- * @param {object[]} huskPrimaries - primaries with no token data
393
- * @param {object[]} noTaskEvents - token events with no taskId
394
- * @param {string[]} unmappedModels - raw model strings that did not canonicalize
395
- * @param {number} totalEvents - total primary events loaded (with and without token data)
396
- * @param {{reported: number, estimated: number, missing: number}} tokenSourceCounts - tokenSource tallies
397
- */
398
- function buildIngestionQuality(orphanSidecars, huskPrimaries, noTaskEvents, unmappedModels, totalEvents, tokenSourceCounts) {
399
- const lines = ['## Ingestion Quality', ''];
400
-
401
- const orphanCount = orphanSidecars ? orphanSidecars.length : 0;
402
- const huskCount = huskPrimaries ? huskPrimaries.length : 0;
403
- const noTaskCount = noTaskEvents ? noTaskEvents.length : 0;
404
- const unmappedCount = unmappedModels ? unmappedModels.length : 0;
405
- const total = (typeof totalEvents === 'number') ? totalEvents : null;
406
- const tsc = tokenSourceCounts || null;
407
-
408
- const hasIssues = orphanCount > 0 || huskCount > 0 || noTaskCount > 0 || unmappedCount > 0;
409
-
410
- if (!hasIssues && total === null && tsc === null) {
411
- lines.push('_All events attributed cleanly — no data gaps detected._', '');
412
- return lines.join('\n');
413
- }
414
-
415
- const rows = [['Metric', 'Count', 'Detail']];
416
-
417
- // Total events row — always rendered when totalEvents is provided
418
- if (total !== null) {
419
- const withTokenData = total - huskCount;
420
- rows.push(['Total events', String(total), `${withTokenData} with token data`]);
421
- }
422
-
423
- // Token source breakdown — always rendered when tokenSourceCounts is provided
424
- if (tsc !== null) {
425
- const reported = tsc.reported || 0;
426
- const estimated = tsc.estimated || 0;
427
- const missing = tsc.missing || 0;
428
- rows.push(['Token source breakdown', String(reported + estimated + missing), `reported: ${reported}, estimated: ${estimated}, missing: ${missing}`]);
429
- }
430
-
431
- if (orphanCount > 0) {
432
- const ids = orphanSidecars.map(s => s.eventId || '?').join(', ');
433
- rows.push(['Orphan sidecars (no matching primary)', String(orphanCount), ids.length > 80 ? ids.slice(0, 77) + '…' : ids]);
434
- }
435
-
436
- if (huskCount > 0) {
437
- const ids = huskPrimaries.map(h => h.eventId || '?').join(', ');
438
- rows.push(['Primary events with no token data (husks)', String(huskCount), ids.length > 80 ? ids.slice(0, 77) + '…' : ids]);
439
- }
440
-
441
- if (noTaskCount > 0) {
442
- rows.push(['Primary events with no taskId (no-task)', String(noTaskCount), 'Attributed to "no-task" row in Per-Task Totals']);
443
- }
444
-
445
- if (unmappedCount > 0) {
446
- const raw = unmappedModels.join(', ');
447
- rows.push(['Unmapped model names (cost not recomputed)', String(unmappedCount), raw.length > 80 ? raw.slice(0, 77) + '…' : raw]);
448
- }
449
-
450
- lines.push(padTable(rows), '');
451
- return lines.join('\n');
452
- }
453
-
454
- /**
455
- * buildCostReport(sprint, events, orphanSidecars, huskPrimaries)
456
- *
457
- * Pure function — builds the full COST_REPORT.md content string for a sprint.
458
- *
459
- * @param {object} sprint - sprint record with sprintId and title
460
- * @param {object[]} events - merged primary events (from mergeSidecarEvents)
461
- * @param {object[]} orphanSidecars - sidecars with no matching primary
462
- * @param {object[]} huskPrimaries - primaries with no token data
463
- * @returns {string} - full markdown content for COST_REPORT.md
464
- */
465
- function buildCostReport(sprint, events, orphanSidecars, huskPrimaries) {
466
- const REVIEW_PHASES = new Set(['review', 'review-plan', 'review-code', 'review-implementation']);
467
-
468
- // Only events that have at least inputTokens present
469
- const tokenEvents = events.filter(e => e.inputTokens !== undefined);
470
-
471
- const lines = [
472
- GENERATED,
473
- '',
474
- `# Cost Report — ${sprint.sprintId}`,
475
- '',
476
- `> Generated: ${new Date().toISOString().slice(0, 10)}`,
477
- `> Sprint: ${sprint.title || sprint.sprintId}`,
478
- '',
479
- ];
480
-
481
- // Tally total events and tokenSource counts over all primary events (including husks)
482
- const totalEvents = events.length;
483
- const tokenSourceCounts = { reported: 0, estimated: 0, missing: 0 };
484
- for (const e of events) {
485
- if (e.tokenSource === 'reported') tokenSourceCounts.reported++;
486
- else if (e.tokenSource === 'estimated') tokenSourceCounts.estimated++;
487
- else tokenSourceCounts.missing++;
488
- }
489
-
490
- if (tokenEvents.length === 0) {
491
- lines.push('_No token data available for this sprint._', '');
492
- lines.push(buildIngestionQuality(orphanSidecars || [], huskPrimaries || [], [], [], totalEvents, tokenSourceCounts), '');
493
- return lines.join('\n') + '\n';
494
- }
495
-
496
- // Collect no-task events and unmapped models for IQ section
497
- const noTaskEvents = tokenEvents.filter(e => !e.taskId);
498
- const unmappedModels = [];
499
- const seenUnmapped = new Set();
500
- for (const e of tokenEvents) {
501
- if (e.model) {
502
- const c = canonicalizeModel(e.model);
503
- if (c === null && !seenUnmapped.has(e.model)) {
504
- seenUnmapped.add(e.model);
505
- unmappedModels.push(e.model);
506
- }
507
- }
508
- }
509
-
510
- // --- Section 1: Per-task totals ---
511
- lines.push('## Per-Task Totals', '');
512
- {
513
- const byTask = {};
514
- for (const e of tokenEvents) {
515
- const tid = e.taskId || 'no-task';
516
- if (!byTask[tid]) byTask[tid] = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, estimatedCostUSD: 0, sources: new Set() };
517
- const g = byTask[tid];
518
- g.inputTokens += e.inputTokens || 0;
519
- g.outputTokens += e.outputTokens || 0;
520
- g.cacheReadTokens += e.cacheReadTokens || 0;
521
- g.cacheWriteTokens+= e.cacheWriteTokens || 0;
522
- g.estimatedCostUSD+= e.estimatedCostUSD || 0;
523
- g.sources.add(e.tokenSource);
524
- }
525
- const rows = [['Task', 'Input Tokens', 'Output Tokens', 'Cache Read', 'Cache Write', 'Est. Cost USD', 'Source']];
526
- for (const [tid, g] of Object.entries(byTask).sort(([a], [b]) => a.localeCompare(b))) {
527
- rows.push([
528
- tid,
529
- fmtTokens(g.inputTokens),
530
- fmtTokens(g.outputTokens),
531
- fmtTokens(g.cacheReadTokens),
532
- fmtTokens(g.cacheWriteTokens),
533
- fmtCost(g.estimatedCostUSD),
534
- sourceLabel(g.sources),
535
- ]);
536
- }
537
- lines.push(padTable(rows), '');
538
- }
539
-
540
- // --- Section 2: Per-role breakdown ---
541
- lines.push('## Per-Role Breakdown', '');
542
- {
543
- const byRole = {};
544
- for (const e of tokenEvents) {
545
- const role = e.role || '(unknown)';
546
- if (!byRole[role]) byRole[role] = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, estimatedCostUSD: 0 };
547
- const g = byRole[role];
548
- g.inputTokens += e.inputTokens || 0;
549
- g.outputTokens += e.outputTokens || 0;
550
- g.cacheReadTokens += e.cacheReadTokens || 0;
551
- g.cacheWriteTokens+= e.cacheWriteTokens || 0;
552
- g.estimatedCostUSD+= e.estimatedCostUSD || 0;
553
- }
554
- const rows = [['Role', 'Input Tokens', 'Output Tokens', 'Cache Read', 'Cache Write', 'Est. Cost USD']];
555
- for (const [role, g] of Object.entries(byRole).sort(([a], [b]) => a.localeCompare(b))) {
556
- rows.push([
557
- role,
558
- fmtTokens(g.inputTokens),
559
- fmtTokens(g.outputTokens),
560
- fmtTokens(g.cacheReadTokens),
561
- fmtTokens(g.cacheWriteTokens),
562
- fmtCost(g.estimatedCostUSD),
563
- ]);
564
- }
565
- lines.push(padTable(rows), '');
566
- }
567
-
568
- // --- Section 3: Revision waste ---
569
- lines.push('## Revision Waste', '');
570
- {
571
- const revisionEvents = tokenEvents.filter(e => (e.iteration || 1) > 1 && REVIEW_PHASES.has(e.phase));
572
- if (revisionEvents.length === 0) {
573
- lines.push('_No revision waste in this sprint._', '');
574
- } else {
575
- const byTask = {};
576
- for (const e of revisionEvents) {
577
- const tid = e.taskId || 'no-task';
578
- if (!byTask[tid]) byTask[tid] = { iterations: new Set(), inputTokens: 0, outputTokens: 0, estimatedCostUSD: 0 };
579
- const g = byTask[tid];
580
- g.iterations.add(e.iteration);
581
- g.inputTokens += e.inputTokens || 0;
582
- g.outputTokens += e.outputTokens || 0;
583
- g.estimatedCostUSD+= e.estimatedCostUSD || 0;
584
- }
585
- const rows = [['Task', 'Revision Iterations', 'Input Tokens', 'Output Tokens', 'Est. Cost USD']];
586
- for (const [tid, g] of Object.entries(byTask).sort(([a], [b]) => a.localeCompare(b))) {
587
- rows.push([
588
- tid,
589
- g.iterations.size,
590
- fmtTokens(g.inputTokens),
591
- fmtTokens(g.outputTokens),
592
- fmtCost(g.estimatedCostUSD),
593
- ]);
594
- }
595
- lines.push(padTable(rows), '');
596
- }
597
- }
598
-
599
- // --- Section 4: Model split ---
600
- lines.push('## Model Split', '');
601
- {
602
- const byModel = {};
603
- for (const e of tokenEvents) {
604
- const model = e.model || '(unknown)';
605
- if (!byModel[model]) byModel[model] = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, estimatedCostUSD: 0 };
606
- const g = byModel[model];
607
- g.inputTokens += e.inputTokens || 0;
608
- g.outputTokens += e.outputTokens || 0;
609
- g.cacheReadTokens += e.cacheReadTokens || 0;
610
- g.cacheWriteTokens+= e.cacheWriteTokens || 0;
611
- g.estimatedCostUSD+= e.estimatedCostUSD || 0;
612
- }
613
- const rows = [['Model', 'Input Tokens', 'Output Tokens', 'Cache Read', 'Cache Write', 'Est. Cost USD']];
614
- for (const [model, g] of Object.entries(byModel).sort(([a], [b]) => a.localeCompare(b))) {
615
- rows.push([
616
- model,
617
- fmtTokens(g.inputTokens),
618
- fmtTokens(g.outputTokens),
619
- fmtTokens(g.cacheReadTokens),
620
- fmtTokens(g.cacheWriteTokens),
621
- fmtCost(g.estimatedCostUSD),
622
- ]);
623
- }
624
- lines.push(padTable(rows), '');
625
- }
626
-
627
- // --- Section 5: Ingestion Quality ---
628
- lines.push(buildIngestionQuality(orphanSidecars || [], huskPrimaries || [], noTaskEvents, unmappedModels, totalEvents, tokenSourceCounts), '');
629
-
630
- return lines.join('\n') + '\n';
631
- }
632
-
633
- module.exports = { statusBadge, padTable, fmtTokens, fmtCost, sourceLabel, GENERATED, buildSprintIndex, buildTaskIndex, buildBugIndex, resolveTaskDir, isBugId, mergeSidecarEvents, buildIngestionQuality, buildCostReport };
634
-
635
- // --- CLI ---
636
- if (require.main === module) {
637
-
638
- const DRY_RUN = process.argv.includes('--dry-run');
639
- const SPRINT_ARG = process.argv.slice(2).find(a => !a.startsWith('--'));
640
-
641
- // Bug IDs are first-class arguments — auto-enable purge when a bug ID is passed.
642
- // This makes `collate.cjs HELLO-B02` work identically to `collate.cjs HELLO-B02 --purge-events`.
643
- const IS_BUG_ARG = isBugId(SPRINT_ARG);
644
- const PURGE_EVENTS = process.argv.includes('--purge-events') || IS_BUG_ARG;
645
-
646
- if (PURGE_EVENTS && !SPRINT_ARG) {
647
- console.error('Error: --purge-events requires a sprint or bug ID argument');
648
- process.exit(1);
649
- }
650
- const cwd = process.cwd();
651
-
652
- function writeFile(filePath, content) {
653
- if (DRY_RUN) { console.log(`[dry-run] would write: ${path.relative(cwd, filePath)}`); return; }
654
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
655
- fs.writeFileSync(filePath, content, 'utf8');
656
- }
657
-
658
- // Resolve a directory name under `base` by trying candidates in order.
659
- // Returns the first candidate whose directory exists. If none exist,
660
- // attempts a numeric glob: finds the first alphabetically-sorted dir
661
- // in `base` whose first integer matches the first integer in the last
662
- // candidate. Falls back to the last candidate if no match is found.
663
- function resolveDir(base, ...candidates) {
664
- for (const c of candidates) {
665
- if (fs.existsSync(path.join(base, c))) return c;
666
- }
667
- // Numeric glob fallback
668
- const last = candidates[candidates.length - 1];
669
- const numMatch = last.match(/\d+/);
670
- if (numMatch && fs.existsSync(base)) {
671
- const target = parseInt(numMatch[0], 10);
672
- const dirs = fs.readdirSync(base).sort();
673
- for (const d of dirs) {
674
- const m = d.match(/\d+/);
675
- if (m && parseInt(m[0], 10) === target) return d;
676
- }
677
- }
678
- return last;
679
- }
680
-
681
- // --- Load store ---
682
- function readConfig() {
683
- const p = path.join(cwd, '.forge', 'config.json');
684
- if (!fs.existsSync(p)) { console.error('Error: .forge/config.json not found'); process.exit(1); }
685
- try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (e) {
686
- console.error(`Error: .forge/config.json is not valid JSON: ${e.message}`); process.exit(1);
687
- }
688
- }
689
-
690
- const config = readConfig();
691
- const storePath = config.paths?.store || '.forge/store';
692
- const engPath = config.paths?.engineering || 'engineering';
693
- const projectName = config.project?.name || config.project?.prefix || 'Project';
694
-
695
- const storeRoot = path.join(cwd, storePath);
696
- const engRoot = path.join(cwd, engPath);
697
-
698
- const store = _getStore();
699
- const allSprints = store.listSprints()
700
- .sort((a, b) => a.sprintId.localeCompare(b.sprintId));
701
- const allTasks = store.listTasks();
702
- const allBugs = store.listBugs();
703
- const allFeatures= store.listFeatures()
704
- .sort((a, b) => (a.id || '').localeCompare(b.id || ''));
705
-
706
- // Group tasks by sprint
707
- const tasksBySprint = {};
708
- for (const t of allTasks) {
709
- if (!tasksBySprint[t.sprintId]) tasksBySprint[t.sprintId] = [];
710
- tasksBySprint[t.sprintId].push(t);
711
- }
712
-
713
- // Sprints to process
714
- const targetSprints = SPRINT_ARG
715
- ? allSprints.filter(s => s.sprintId === SPRINT_ARG || s.sprintId.endsWith(`-${SPRINT_ARG}`))
716
- : allSprints;
717
-
718
- if (SPRINT_ARG && targetSprints.length === 0) {
719
- if (IS_BUG_ARG || PURGE_EVENTS) {
720
- // Bug ID or non-sprint entity with --purge-events — skip sprint processing,
721
- // fall through to bug INDEX generation and purge step.
722
- } else {
723
- console.error(`Error: '${SPRINT_ARG}' not found as a sprint or bug in store`);
724
- process.exit(1);
725
- }
726
- }
727
-
728
- // --- Generate MASTER_INDEX.md ---
729
- {
730
- const masterPath = path.join(engRoot, 'MASTER_INDEX.md');
731
- let preserved = {};
732
-
733
- // Extract preserved sections (## headings not in GENERATED marker block)
734
- if (fs.existsSync(masterPath)) {
735
- const existing = fs.readFileSync(masterPath, 'utf8');
736
- const isGenerated = existing.trimStart().startsWith(GENERATED.trim().slice(0, 10));
737
- if (!isGenerated) {
738
- // Preserve all sections from manually written file
739
- const sections = existing.split(/^(?=## )/m);
740
- for (const s of sections) {
741
- const m = s.match(/^## (.+)/);
742
- if (m) preserved[m[1].trim()] = s;
743
- }
744
- }
745
- }
746
-
747
- const lines = [GENERATED, '', `# ${projectName} — Master Index`, '',
748
- `> Generated: ${new Date().toISOString().slice(0, 10)}`, ''];
749
-
750
- // Feature Registry
751
- lines.push('## Feature Registry', '');
752
- if (allFeatures.length > 0) {
753
- lines.push(`[Browse all ${allFeatures.length} features ↗](features/INDEX.md)`, '');
754
- } else {
755
- lines.push(`_[No features yet ↗](features/INDEX.md)_`, '');
756
- }
757
-
758
- // Sprint registry
759
- lines.push('## Sprint Registry', '');
760
- if (allSprints.length > 0) {
761
- const rows = [['Sprint', 'Title', 'Status', 'Tasks']];
762
- for (const s of [...allSprints].reverse()) {
763
- const tasks = tasksBySprint[s.sprintId] || [];
764
- const completed = tasks.filter(t => t.status === 'committed').length;
765
- rows.push([
766
- s.sprintId, s.title || '—', statusBadge(s.status),
767
- `${completed}/${tasks.length}`,
768
- ]);
769
- }
770
- lines.push(padTable(rows), '');
771
- } else {
772
- lines.push('_No sprints yet._', '');
773
- }
774
-
775
- // Task registry — most recent sprint first
776
- lines.push('## Task Registry', '');
777
- for (const sprint of [...allSprints].reverse()) {
778
- const tasks = (tasksBySprint[sprint.sprintId] || []).sort((a, b) => a.taskId.localeCompare(b.taskId));
779
- if (tasks.length === 0) continue;
780
- lines.push(`### ${sprint.sprintId}`, '');
781
- const rows = [['Task', 'Title', 'Status', 'Estimate']];
782
- const sprintDir = sprint.path
783
- ? path.basename(sprint.path.replace(/\/$/, ''))
784
- : resolveDir(path.join(engRoot, 'sprints'), sprint.sprintId, sprint.sprintId.split('-').pop());
785
- for (const t of tasks) {
786
- // Derive task link from t.path if available (most reliable — already has correct dir names).
787
- // Fall back to filesystem detection against full task ID vs. trailing segment.
788
- let taskLink;
789
- if (t.path) {
790
- // If path is under the engineering root, it IS the task directory — link directly.
791
- // Otherwise (e.g. forge/tools/seed-store.cjs), it's a plugin source reference;
792
- // strip the filename to get the containing directory.
793
- const normalizedPath = t.path.replace(/\\/g, '/').replace(/\/$/, '');
794
- const normalizedEngPath = engPath.replace(/\\/g, '/').replace(/\/$/, '');
795
- if (normalizedPath.startsWith(normalizedEngPath + '/')) {
796
- const rel = path.relative(engPath, t.path).replace(/\\/g, '/');
797
- taskLink = rel + '/INDEX.md';
798
- } else {
799
- const rel = path.relative(engPath, t.path).replace(/\\/g, '/');
800
- taskLink = path.dirname(rel).replace(/\\/g, '/') + '/INDEX.md';
801
- }
802
- } else {
803
- const taskDir = resolveDir(path.join(engRoot, 'sprints', sprintDir), t.taskId, t.taskId.split('-').pop());
804
- taskLink = `sprints/${sprintDir}/${taskDir}/INDEX.md`;
805
- }
806
- rows.push([
807
- `[${t.taskId}](${taskLink})`,
808
- t.title || '—', statusBadge(t.status), t.estimate || '—',
809
- ]);
810
- }
811
- lines.push(padTable(rows), '');
812
- }
813
-
814
- // Bug registry
815
- if (allBugs.length > 0) {
816
- lines.push('## Bug Registry', '');
817
- const open = allBugs.filter(b => !['fixed', 'verified'].includes(b.status));
818
- const closed = allBugs.filter(b => ['fixed', 'verified'].includes(b.status));
819
- const rows = [['Bug', 'Title', 'Severity', 'Status']];
820
- for (const b of [...open, ...closed]) {
821
- const bugDir = resolveDir(path.join(engRoot, 'bugs'), b.bugId, b.bugId.split('-').pop());
822
- rows.push([
823
- `[${b.bugId}](bugs/${bugDir}/INDEX.md)`,
824
- b.title || '—', b.severity || '—', statusBadge(b.status),
825
- ]);
826
- }
827
- lines.push(padTable(rows), '');
828
- }
829
-
830
- // Re-append preserved sections not already present
831
- for (const [heading, content] of Object.entries(preserved)) {
832
- if (!['Feature Registry', 'Sprint Registry', 'Task Registry', 'Bug Registry'].includes(heading)) {
833
- lines.push(content);
834
- }
835
- }
836
-
837
- writeFile(masterPath, lines.join('\n') + '\n');
838
- }
839
-
840
- // --- Generate features/INDEX.md and per-feature pages ---
841
- {
842
- const featRoot = path.join(engRoot, 'features');
843
-
844
- const featLines = [GENERATED, '', `# Feature Registry`, '',
845
- `> Generated: ${new Date().toISOString().slice(0, 10)}`, ''];
846
-
847
- if (allFeatures.length > 0) {
848
- const rows = [['Feature ID', 'Title', 'Status', 'Sprints', 'Tasks']];
849
- for (const f of allFeatures) {
850
- if (!f.id) continue;
851
- const sp = f.sprints || [];
852
- const ts = f.tasks || [];
853
- rows.push([
854
- `[${f.id}](${f.id}.md)`,
855
- f.title || '—',
856
- statusBadge(f.status || 'draft'),
857
- sp.length ? sp.join(', ') : '—',
858
- ts.length ? ts.join(', ') : '—'
859
- ]);
860
-
861
- // Per-feature page
862
- const pfLines = [GENERATED, '', `# Feature: ${f.title || f.id}`, '',
863
- `> Feature ID: ${f.id}`,
864
- `> Status: ${statusBadge(f.status || 'draft')}`,
865
- `> Created: ${f.created_at || '—'}`,
866
- ''];
867
-
868
- if (f.description) pfLines.push('## Description', '', f.description, '');
869
-
870
- if (f.requirements && f.requirements.length > 0) {
871
- pfLines.push('## Requirements', '');
872
- f.requirements.forEach(req => pfLines.push(`- ${req}`));
873
- pfLines.push('');
874
- }
875
-
876
- if (sp.length > 0) {
877
- pfLines.push('## Linked Sprints', '');
878
- sp.forEach(s => pfLines.push(`- ${s}`));
879
- pfLines.push('');
880
- }
881
-
882
- if (ts.length > 0) {
883
- pfLines.push('## Linked Tasks', '');
884
- ts.forEach(t => pfLines.push(`- ${t}`));
885
- pfLines.push('');
886
- }
887
-
888
- writeFile(path.join(featRoot, `${f.id}.md`), pfLines.join('\n') + '\n');
889
- }
890
- featLines.push(padTable(rows), '');
891
- } else {
892
- featLines.push('_No features yet._', '');
893
- }
894
-
895
- writeFile(path.join(featRoot, 'INDEX.md'), featLines.join('\n') + '\n');
896
- }
897
-
898
- // --- Generate Sprint INDEX.md and Task INDEX.md ---
899
- function availableDocsIn(dir, knownDocs) {
900
- if (!fs.existsSync(dir)) return [];
901
- return knownDocs.map(d => d.file).filter(f => fs.existsSync(path.join(dir, f)));
902
- }
903
-
904
- let sprintIndexesWritten = 0;
905
- let taskIndexesWritten = 0;
906
-
907
- for (const sprint of targetSprints) {
908
- let sprintDirName;
909
- if (sprint.path) {
910
- sprintDirName = path.basename(sprint.path.replace(/\/$/, ''));
911
- } else {
912
- sprintDirName = resolveDir(
913
- path.join(engRoot, 'sprints'),
914
- sprint.sprintId,
915
- sprint.sprintId.split('-').pop()
916
- );
917
- }
918
- const sprintDir = path.join(engRoot, 'sprints', sprintDirName);
919
-
920
- // Resolve task directories for all tasks in this sprint
921
- const rawSprintTasks = (tasksBySprint[sprint.sprintId] || []).sort((a, b) => a.taskId.localeCompare(b.taskId));
922
- const sprintTasks = rawSprintTasks.map(t => {
923
- const dirResult = resolveTaskDir(t, sprintDir, engPath);
924
- return dirResult.ok ? Object.assign({}, t, { _taskDir: dirResult.value }) : t;
925
- });
926
-
927
- // Sprint INDEX.md — pass tasks with _taskDir so links resolve correctly
928
- const sprintAvailDocs = availableDocsIn(sprintDir, SPRINT_DOCS);
929
- writeFile(path.join(sprintDir, 'INDEX.md'), buildSprintIndex(sprint, sprintTasks, sprintAvailDocs));
930
- sprintIndexesWritten++;
931
-
932
- // Task INDEX.md files — generate for every task that has a KB directory
933
- for (const task of sprintTasks) {
934
- if (!task._taskDir) continue;
935
- const taskDir = path.join(sprintDir, task._taskDir);
936
- if (!fs.existsSync(taskDir)) continue;
937
- const taskAvailDocs = availableDocsIn(taskDir, TASK_DOCS);
938
- writeFile(path.join(taskDir, 'INDEX.md'), buildTaskIndex(task, taskAvailDocs));
939
- taskIndexesWritten++;
940
- }
941
- }
942
-
943
- // --- Generate Bug INDEX.md files ---
944
- let bugIndexesWritten = 0;
945
- for (const bug of allBugs) {
946
- const bugDirName = resolveDir(path.join(engRoot, 'bugs'), bug.bugId, bug.bugId.split('-').pop());
947
- const bugDir = path.join(engRoot, 'bugs', bugDirName);
948
- if (!fs.existsSync(bugDir)) continue;
949
- const bugAvailDocs = availableDocsIn(bugDir, BUG_DOCS);
950
-
951
- // When purging events for a bug, aggregate cost data from event files
952
- // before they are deleted. The aggregated cost summary is embedded in
953
- // the bug's INDEX.md so the information survives the purge.
954
- let costTotals;
955
- if (PURGE_EVENTS && SPRINT_ARG && SPRINT_ARG === bug.bugId) {
956
- const { events } = loadSprintEvents(bug.bugId);
957
- const tokenEvents = events.filter(e => e.inputTokens !== undefined);
958
- if (tokenEvents.length > 0) {
959
- const totals = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, estimatedCostUSD: 0, sources: new Set() };
960
- for (const e of tokenEvents) {
961
- totals.inputTokens += e.inputTokens || 0;
962
- totals.outputTokens += e.outputTokens || 0;
963
- totals.cacheReadTokens += e.cacheReadTokens || 0;
964
- totals.cacheWriteTokens+= e.cacheWriteTokens || 0;
965
- totals.estimatedCostUSD+= e.estimatedCostUSD || 0;
966
- totals.sources.add(e.tokenSource);
967
- }
968
- costTotals = {
969
- inputTokens: totals.inputTokens,
970
- outputTokens: totals.outputTokens,
971
- cacheReadTokens: totals.cacheReadTokens,
972
- cacheWriteTokens: totals.cacheWriteTokens,
973
- estimatedCostUSD: totals.estimatedCostUSD,
974
- sourceLabel: sourceLabel(totals.sources),
975
- };
976
- }
977
- }
978
-
979
- writeFile(path.join(bugDir, 'INDEX.md'), buildBugIndex(bug, bugAvailDocs, costTotals));
980
- bugIndexesWritten++;
981
- }
982
-
983
- // --- Generate COST_REPORT.md per sprint ---
984
- let costReportsWritten = 0;
985
-
986
- for (const sprint of targetSprints) {
987
- const { events, orphanSidecars, huskPrimaries } = loadSprintEvents(sprint.sprintId);
988
-
989
- let sprintDirName;
990
- if (sprint.path) {
991
- sprintDirName = path.basename(sprint.path.replace(/\/$/, ''));
992
- } else {
993
- sprintDirName = resolveDir(
994
- path.join(engRoot, 'sprints'),
995
- sprint.sprintId,
996
- sprint.sprintId.split('-').pop()
997
- );
998
- }
999
- const reportPath = path.join(engRoot, 'sprints', sprintDirName, 'COST_REPORT.md');
1000
- const reportContent = buildCostReport(sprint, events, orphanSidecars, huskPrimaries);
1001
-
1002
- writeFile(reportPath, reportContent);
1003
- costReportsWritten++;
1004
- }
1005
-
1006
- // --- Write COLLATION_STATE.json ---
1007
- const stateData = {
1008
- collatedAt: new Date().toISOString(),
1009
- featureCount: allFeatures.length,
1010
- sprintCount: targetSprints.length,
1011
- taskCount: allTasks.filter(t => targetSprints.some(s => s.sprintId === t.sprintId)).length,
1012
- bugCount: allBugs.length,
1013
- };
1014
- if (DRY_RUN) {
1015
- console.log(`[dry-run] would write: ${path.relative(cwd, path.join(storeRoot, 'COLLATION_STATE.json'))}`);
1016
- } else {
1017
- _getStore().writeCollationState(stateData);
1018
- }
1019
-
1020
- const tag = DRY_RUN ? '[dry-run] ' : '';
1021
- console.log(`${tag}Collated: ${targetSprints.length} sprint(s), ${allBugs.length} bug(s) → MASTER_INDEX.md updated, ${sprintIndexesWritten} sprint INDEX(es), ${taskIndexesWritten} task INDEX(es), ${bugIndexesWritten} bug INDEX(es), ${costReportsWritten} COST_REPORT(s) written`);
1022
-
1023
- // --- Purge event directory if requested ---
1024
- if (PURGE_EVENTS) {
1025
- const relDir = path.relative(cwd, path.join(storeRoot, 'events', SPRINT_ARG));
1026
- try {
1027
- const result = _getStore().purgeEvents(SPRINT_ARG, { dryRun: DRY_RUN });
1028
- if (result.fileCount === 0) {
1029
- console.log(`${tag}Purge: no events directory found for '${SPRINT_ARG}' — nothing to delete`);
1030
- } else if (DRY_RUN) {
1031
- console.log(`[dry-run] would purge: ${relDir}/ (${result.fileCount} file(s))`);
1032
- } else {
1033
- console.log(`Purged: ${relDir}/ (${result.fileCount} event file(s) deleted)`);
1034
- }
1035
- } catch (err) {
1036
- console.error(`Error: ${err.message}`);
1037
- process.exit(1);
1038
- }
1039
- }
1040
-
1041
- } // end if (require.main === module)