@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,1123 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Forge tool: store-cli
5
+ // Deterministic store custodian CLI — wraps store.cjs facade.
6
+ // Enforces schema validation on write and status transition rules.
7
+ // Usage: node store-cli.cjs <command> <args>
8
+
9
+ let _store;
10
+ function _getStore() { return _store || (_store = require('./store.cjs')); }
11
+ let _projectRoot;
12
+ function _getProjectRoot() { return _projectRoot || (_projectRoot = require('./lib/project-root.cjs').findProjectRoot()); }
13
+
14
+ const _path = require('path');
15
+
16
+ // Path traversal guard — resolves the events directory and validates that
17
+ // sprintOrBugId doesn't escape it. Same pattern as store.cjs purgeEvents.
18
+ function _resolveEventsDir(sprintOrBugId) {
19
+ const root = _getProjectRoot();
20
+ const eventsBase = root
21
+ ? _path.resolve(root, '.forge', 'store', 'events')
22
+ : _path.resolve('.forge', 'store', 'events');
23
+ const resolvedDir = _path.resolve(eventsBase, sprintOrBugId);
24
+ if (!resolvedDir.startsWith(eventsBase + _path.sep) && resolvedDir !== eventsBase) {
25
+ console.error(`Path traversal blocked: '${sprintOrBugId}' resolves outside events directory`);
26
+ process.exit(1);
27
+ }
28
+ return resolvedDir;
29
+ }
30
+
31
+ let _schemas;
32
+ function _getSchemas() {
33
+ if (_schemas) return _schemas;
34
+ const fs = require('fs');
35
+ const path = require('path');
36
+
37
+ const ENTITY_TYPES = ['sprint', 'task', 'bug', 'event', 'feature'];
38
+
39
+ const MINIMAL_REQUIRED = {
40
+ sprint: ['sprintId', 'title', 'status', 'taskIds', 'createdAt'],
41
+ task: ['taskId', 'sprintId', 'title', 'status', 'path'],
42
+ bug: ['bugId', 'title', 'severity', 'status', 'path', 'reportedAt'],
43
+ event: ['eventId', 'taskId', 'sprintId', 'role', 'action', 'phase', 'iteration', 'startTimestamp', 'endTimestamp', 'durationMinutes', 'model'],
44
+ feature: ['id', 'title', 'status', 'created_at']
45
+ };
46
+
47
+ const schemas = {};
48
+ const projectDir = path.join('.forge', 'schemas');
49
+ const inTreeDir = path.join('forge', 'schemas');
50
+ const pluginDir = path.join(__dirname, '..', 'schemas');
51
+
52
+ const AUX_SCHEMAS = {
53
+ 'event-sidecar': 'event-sidecar.schema.json',
54
+ 'progress-entry': 'progress-entry.schema.json',
55
+ 'collation-state': 'collation-state.schema.json',
56
+ };
57
+
58
+ const allTypes = [...ENTITY_TYPES, ...Object.keys(AUX_SCHEMAS)];
59
+ for (const type of allTypes) {
60
+ const schemaFile = AUX_SCHEMAS[type] || `${type}.schema.json`;
61
+ let schema = null;
62
+
63
+ // 1. Try project-installed schemas first
64
+ const projectPath = path.join(projectDir, schemaFile);
65
+ try {
66
+ if (fs.existsSync(projectPath)) {
67
+ schema = JSON.parse(fs.readFileSync(projectPath, 'utf8'));
68
+ }
69
+ } catch (_) {}
70
+
71
+ // 2. Fall back to in-tree source schemas (development mode)
72
+ if (!schema) {
73
+ const inTreePath = path.join(inTreeDir, schemaFile);
74
+ try {
75
+ if (fs.existsSync(inTreePath)) {
76
+ schema = JSON.parse(fs.readFileSync(inTreePath, 'utf8'));
77
+ }
78
+ } catch (_) {}
79
+ }
80
+
81
+ // 3. Fall back to plugin-installed schemas (production mode)
82
+ // store-cli.cjs lives at $FORGE_ROOT/tools/, so __dirname/../schemas/
83
+ // resolves to $FORGE_ROOT/schemas/ — always correct for installed plugins.
84
+ if (!schema) {
85
+ const pluginPath = path.join(pluginDir, schemaFile);
86
+ try {
87
+ if (fs.existsSync(pluginPath)) {
88
+ schema = JSON.parse(fs.readFileSync(pluginPath, 'utf8'));
89
+ }
90
+ } catch (_) {}
91
+ }
92
+
93
+ if (schema) {
94
+ schemas[type] = schema;
95
+ } else {
96
+ console.error(`WARN: schema file ${schemaFile} not found, using minimal fallback`);
97
+ schemas[type] = { type: 'object', required: MINIMAL_REQUIRED[type] || [], properties: {} };
98
+ }
99
+ }
100
+
101
+ _schemas = schemas;
102
+ return _schemas;
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Schema loading — same resolution as validate-store.cjs
107
+ // ---------------------------------------------------------------------------
108
+
109
+ const ENTITY_TYPES = ['sprint', 'task', 'bug', 'event', 'feature'];
110
+
111
+ const MINIMAL_REQUIRED = {
112
+ sprint: ['sprintId', 'title', 'status', 'taskIds', 'createdAt'],
113
+ task: ['taskId', 'sprintId', 'title', 'status', 'path'],
114
+ bug: ['bugId', 'title', 'severity', 'status', 'path', 'reportedAt'],
115
+ event: ['eventId', 'taskId', 'sprintId', 'role', 'action', 'phase', 'iteration', 'startTimestamp', 'endTimestamp', 'durationMinutes', 'model'],
116
+ feature: ['id', 'title', 'status', 'created_at']
117
+ };
118
+
119
+ // Shared validator + nullable-field set live in ./lib/validate.js so the
120
+ // write-boundary hook can reuse the exact same validation logic as tool writes.
121
+ const { validateRecord, NULLABLE_FIELDS } = require('./lib/validate.js');
122
+
123
+ // Valid phase keys for summaries (dot-delimited → underscore in JSON key)
124
+ const VALID_SUMMARY_PHASES = new Set(['plan', 'review_plan', 'implementation', 'code_review', 'validation']);
125
+
126
+ // Schema for a single phase summary (used by set-summary / set-bug-summary)
127
+ const PHASE_SUMMARY_SCHEMA = {
128
+ type: 'object',
129
+ required: ['objective', 'written_at'],
130
+ properties: {
131
+ objective: { type: 'string', maxLength: 280 },
132
+ key_changes: { type: 'array', items: { type: 'string', maxLength: 200 }, maxItems: 12 },
133
+ findings: { type: 'array', items: { type: 'string', maxLength: 200 }, maxItems: 12 },
134
+ verdict: { type: 'string', enum: ['approved', 'revision', 'n/a'] },
135
+ written_at: { type: 'string' },
136
+ artifact_ref:{ type: 'string' }
137
+ },
138
+ additionalProperties: false
139
+ };
140
+
141
+ // validateRecord imported from ./lib/validate.js above.
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Transition tables
145
+ // ---------------------------------------------------------------------------
146
+
147
+ const TASK_TRANSITIONS = {
148
+ draft: ['planned', 'plan-revision-required', 'code-revision-required', 'blocked', 'escalated', 'abandoned'],
149
+ planned: ['plan-approved', 'plan-revision-required', 'code-revision-required', 'blocked', 'escalated', 'abandoned'],
150
+ 'plan-approved': ['implementing', 'plan-revision-required', 'code-revision-required', 'blocked', 'escalated', 'abandoned'],
151
+ implementing: ['implemented', 'plan-revision-required', 'code-revision-required', 'blocked', 'escalated', 'abandoned'],
152
+ implemented: ['review-approved', 'plan-revision-required', 'code-revision-required', 'blocked', 'escalated', 'abandoned'],
153
+ 'review-approved': ['approved', 'plan-revision-required', 'code-revision-required', 'blocked', 'escalated', 'abandoned'],
154
+ approved: ['committed', 'plan-revision-required', 'code-revision-required', 'blocked', 'escalated', 'abandoned'],
155
+ // Terminal: committed, abandoned — no transitions out
156
+ };
157
+
158
+ const SPRINT_TRANSITIONS = {
159
+ planning: ['active', 'blocked', 'partially-completed', 'abandoned'],
160
+ active: ['completed', 'blocked', 'partially-completed', 'abandoned'],
161
+ completed: ['retrospective-done', 'blocked', 'partially-completed', 'abandoned'],
162
+ // Terminal: retrospective-done, abandoned
163
+ };
164
+
165
+ const BUG_TRANSITIONS = {
166
+ reported: ['triaged'],
167
+ triaged: ['in-progress'],
168
+ 'in-progress': ['fixed'],
169
+ fixed: ['verified'],
170
+ // Terminal: verified
171
+ };
172
+
173
+ const FEATURE_TRANSITIONS = {
174
+ draft: ['active'],
175
+ active: ['shipped', 'retired'],
176
+ // Terminal: shipped, retired
177
+ };
178
+
179
+ const TRANSITION_MAP = {
180
+ task: TASK_TRANSITIONS,
181
+ sprint: SPRINT_TRANSITIONS,
182
+ bug: BUG_TRANSITIONS,
183
+ feature: FEATURE_TRANSITIONS
184
+ };
185
+
186
+ const TERMINAL_STATES = new Set([
187
+ 'committed', 'abandoned', // task
188
+ 'retrospective-done', // sprint
189
+ 'verified', // bug
190
+ 'shipped', 'retired' // feature
191
+ ]);
192
+
193
+ // Failed states that may be entered from any non-terminal state
194
+ const FAILED_STATES = new Set([
195
+ 'plan-revision-required', 'code-revision-required', 'blocked', 'escalated', 'abandoned', // task
196
+ 'blocked', 'partially-completed' // sprint
197
+ ]);
198
+
199
+ function isLegalTransition(entityType, field, currentValue, newValue) {
200
+ if (currentValue === newValue) return true; // no-op
201
+
202
+ const table = TRANSITION_MAP[entityType];
203
+ if (!table) return true; // no transition rules for this entity type
204
+
205
+ // Terminal states cannot transition out
206
+ if (TERMINAL_STATES.has(currentValue)) return false;
207
+
208
+ // Failed states may be entered from any non-terminal state
209
+ if (FAILED_STATES.has(newValue)) return true;
210
+
211
+ // Check the explicit transition table
212
+ const allowed = table[currentValue];
213
+ if (!allowed) return false; // current state not in table (unknown state)
214
+
215
+ return allowed.includes(newValue);
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Bug timestamp normalization helpers
220
+ // ---------------------------------------------------------------------------
221
+
222
+ // Returns true if the string is a date-only value (YYYY-MM-DD without time).
223
+ // These are rejected by the date-time format validator but agents commonly
224
+ // supply them for reportedAt/resolvedAt.
225
+ function _isDateOnly(ts) {
226
+ return typeof ts === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(ts);
227
+ }
228
+
229
+ // Convert a date-only string (YYYY-MM-DD) into a full ISO datetime by
230
+ // appending the current time-of-day in UTC. The date portion is preserved
231
+ // from the input; only the time component is auto-populated.
232
+ function _dateOnlyToISO(dateStr) {
233
+ const now = new Date();
234
+ const timePart = now.toISOString().slice(10); // e.g. "T14:32:07.123Z"
235
+ return dateStr + timePart;
236
+ }
237
+
238
+ // Normalize bug datetime fields before writing. When agents supply date-only
239
+ // values (YYYY-MM-DD) for reportedAt or resolvedAt, auto-populates the time
240
+ // component from the current time-of-day so the value passes date-time format
241
+ // validation. Full ISO datetimes are left untouched.
242
+ function _normalizeBugTimestamps(data) {
243
+ if (_isDateOnly(data.reportedAt)) data.reportedAt = _dateOnlyToISO(data.reportedAt);
244
+ if (_isDateOnly(data.resolvedAt)) data.resolvedAt = _dateOnlyToISO(data.resolvedAt);
245
+ return data;
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // Model discovery
250
+ // ---------------------------------------------------------------------------
251
+
252
+ // Deterministic model discovery — probes environment variables in priority
253
+ // order to resolve the actual runtime model identifier. Returns "unknown"
254
+ // when no signal is available instead of guessing an Anthropic model name.
255
+ function discoverModel() {
256
+ const candidates = [
257
+ process.env.CLAUDE_CODE_SUBAGENT_MODEL,
258
+ process.env.ANTHROPIC_MODEL,
259
+ process.env.CLAUDE_MODEL,
260
+ ];
261
+ for (const val of candidates) {
262
+ if (val && val.trim()) return val.trim();
263
+ }
264
+ return 'unknown';
265
+ }
266
+
267
+ module.exports = { isLegalTransition, validateRecord, TRANSITION_MAP, TERMINAL_STATES, FAILED_STATES, ENTITY_TYPES, MINIMAL_REQUIRED, NULLABLE_FIELDS, VALID_SUMMARY_PHASES, PHASE_SUMMARY_SCHEMA, _isDateOnly, _dateOnlyToISO, _normalizeBugTimestamps, discoverModel };
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // CLI
271
+ // ---------------------------------------------------------------------------
272
+ if (require.main === module) {
273
+
274
+ process.on('uncaughtException', (error) => {
275
+ console.error('Fatal store-cli error:', error);
276
+ process.exit(1);
277
+ });
278
+
279
+ try {
280
+ const fs = require('fs');
281
+ const path = require('path');
282
+ const store = _getStore();
283
+ const schemas = _getSchemas();
284
+
285
+ const DRY_RUN = process.argv.includes('--dry-run');
286
+ const VERBOSE = process.argv.includes('--verbose');
287
+
288
+ // ---------------------------------------------------------------------------
289
+ // Entity ID field mapping
290
+ // ---------------------------------------------------------------------------
291
+
292
+ const ENTITY_ID_FIELD = {
293
+ sprint: 'sprintId',
294
+ task: 'taskId',
295
+ bug: 'bugId',
296
+ event: 'eventId',
297
+ feature: 'id'
298
+ };
299
+
300
+ // ---------------------------------------------------------------------------
301
+ // Store accessor mapping
302
+ // ---------------------------------------------------------------------------
303
+
304
+ function getEntity(entity, id) {
305
+ switch (entity) {
306
+ case 'sprint': return store.getSprint(id);
307
+ case 'task': return store.getTask(id);
308
+ case 'bug': return store.getBug(id);
309
+ case 'event': return store.getEvent(id, null); // needs sprintId separately
310
+ case 'feature': return store.getFeature(id);
311
+ default: return null;
312
+ }
313
+ }
314
+
315
+ function writeEntity(entity, data) {
316
+ switch (entity) {
317
+ case 'sprint': return store.writeSprint(data);
318
+ case 'task': return store.writeTask(data);
319
+ case 'bug': return store.writeBug(data);
320
+ case 'event': return store.writeEvent(data.sprintId, data);
321
+ case 'feature': return store.writeFeature(data);
322
+ }
323
+ }
324
+
325
+ function deleteEntity(entity, id) {
326
+ switch (entity) {
327
+ case 'sprint': return store.deleteSprint(id);
328
+ case 'task': return store.deleteTask(id);
329
+ case 'bug': return store.deleteBug(id);
330
+ case 'feature': return store.deleteFeature(id);
331
+ default:
332
+ console.error(`Unknown entity type: ${entity}`);
333
+ process.exit(1);
334
+ }
335
+ }
336
+
337
+ function listEntities(entity, filter) {
338
+ switch (entity) {
339
+ case 'sprint': return store.listSprints(filter);
340
+ case 'task': return store.listTasks(filter);
341
+ case 'bug': return store.listBugs(filter);
342
+ case 'feature': return store.listFeatures(filter);
343
+ default:
344
+ console.error(`Unknown entity type: ${entity}`);
345
+ process.exit(1);
346
+ }
347
+ }
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // Sidecar handling
351
+ // ---------------------------------------------------------------------------
352
+
353
+ // Canonical event schema token fields
354
+ const CANONICAL_TOKEN_FIELDS = [
355
+ 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens',
356
+ 'estimatedCostUSD', 'model', 'durationMinutes', 'startTimestamp', 'endTimestamp',
357
+ 'tokenSource'
358
+ ];
359
+
360
+ // Accepted sidecar fields (includes aliases)
361
+ const SIDECAR_ACCEPTED_FIELDS = new Set([
362
+ 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens',
363
+ 'estimatedCostUSD', 'model', 'cost', 'durationMinutes',
364
+ 'startTimestamp', 'endTimestamp', 'cacheCreationTokens',
365
+ 'tokenSource'
366
+ ]);
367
+
368
+ // Alias mapping for sidecar → canonical event
369
+ const SIDECAR_ALIASES = {
370
+ 'cacheCreationTokens': 'cacheWriteTokens',
371
+ 'cost': 'estimatedCostUSD'
372
+ };
373
+
374
+ function resolveSidecarDir(sprintId) {
375
+ const storeRoot = store.impl.storeRoot;
376
+ return path.join(storeRoot, 'events', sprintId);
377
+ }
378
+
379
+ function sidecarPath(sprintId, eventId) {
380
+ return path.join(resolveSidecarDir(sprintId), `_${eventId}_usage.json`);
381
+ }
382
+
383
+ function canonicalEventPath(sprintId, eventId) {
384
+ return path.join(resolveSidecarDir(sprintId), `${eventId}.json`);
385
+ }
386
+
387
+ // ---------------------------------------------------------------------------
388
+ // CLI argument parsing
389
+ // ---------------------------------------------------------------------------
390
+
391
+ const args = process.argv.slice(2);
392
+
393
+ if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
394
+ console.log(`Forge Store Custodian CLI
395
+
396
+ Usage: node store-cli.cjs <command> <args> [--dry-run]
397
+
398
+ Commands:
399
+ write <entity> '<json>' Write a full entity record
400
+ read <entity> <id> [--json] Read an entity record
401
+ list <entity> [key=value ...] List entities with optional filter
402
+ delete <entity> <id> Delete an entity record
403
+ update-status <entity> <id> <field> <value> [--force]
404
+ Update status/enum field with transition check
405
+ emit <sprintId> '<json>' [--sidecar] Write an event (or sidecar)
406
+ merge-sidecar <sprintId> <eventId> Merge sidecar into canonical event
407
+ record-usage <sprintId> <eventId> [flags] Write a token-usage sidecar
408
+ purge-events <sprintId> Delete all events for a sprint
409
+ progress <sprintOrBugId> <agentName> <bannerKey> <status> [detail]
410
+ Append a progress entry to the log
411
+ progress-clear <sprintOrBugId> Clear (truncate) the progress log
412
+ write-collation-state '<json>' Write COLLATION_STATE.json
413
+ validate <entity> '<json>' Validate against schema without writing
414
+ set-summary <taskId> <phase> <jsonFile> Set a phase summary on a task record
415
+ set-bug-summary <bugId> <phase> <jsonFile> Set a phase summary on a bug record
416
+
417
+ Entities: sprint, task, bug, event, feature
418
+ Phases (summaries): plan, review_plan, implementation, code_review, validation
419
+
420
+ Flags:
421
+ --dry-run Validate and preview without writing (applies to all write commands)
422
+ --force Bypass transition check on update-status (emits warning)
423
+ --json Output raw JSON on read (no pretty-print)
424
+ --sidecar Write as sidecar file on emit (ephemeral _-prefixed)
425
+
426
+ Exit codes: 0 on success, 1 on failure`);
427
+ process.exit(0);
428
+ }
429
+
430
+ const command = args[0];
431
+
432
+ // ---------------------------------------------------------------------------
433
+ // Command implementations
434
+ // ---------------------------------------------------------------------------
435
+
436
+ function cmdWrite() {
437
+ const entity = args[1];
438
+ const jsonStr = args[2];
439
+
440
+ if (!entity || !jsonStr) {
441
+ console.error('Usage: store-cli.cjs write <entity> \'<json>\'');
442
+ process.exit(1);
443
+ }
444
+
445
+ if (!ENTITY_TYPES.includes(entity)) {
446
+ console.error(`Unknown entity type: ${entity}`);
447
+ process.exit(1);
448
+ }
449
+
450
+ let data;
451
+ try {
452
+ data = JSON.parse(jsonStr);
453
+ } catch (e) {
454
+ console.error(`Invalid JSON: ${e.message}`);
455
+ process.exit(1);
456
+ }
457
+
458
+ // Auto-populate date-only YYYY-MM-DD values in bug datetime fields.
459
+ // Agents commonly supply date-only values for reportedAt/resolvedAt;
460
+ // this normalizes them to full ISO datetimes before schema validation.
461
+ if (entity === 'bug') {
462
+ _normalizeBugTimestamps(data);
463
+ }
464
+
465
+ const errors = validateRecord(data, schemas[entity]);
466
+ if (errors.length > 0) {
467
+ for (const e of errors) console.error(e);
468
+ process.exit(1);
469
+ }
470
+
471
+ if (DRY_RUN) {
472
+ console.log(`[dry-run] would write ${entity} ${data[ENTITY_ID_FIELD[entity]]}`);
473
+ } else {
474
+ writeEntity(entity, data);
475
+ }
476
+ if (VERBOSE) console.log(JSON.stringify({ ok: true, entity, id: data[ENTITY_ID_FIELD[entity]], dryRun: DRY_RUN }));
477
+ }
478
+
479
+ function cmdRead() {
480
+ const entity = args[1];
481
+ const id = args[2];
482
+ const asJson = args.includes('--json');
483
+
484
+ if (!entity || !id) {
485
+ console.error('Usage: store-cli.cjs read <entity> <id> [--json]');
486
+ process.exit(1);
487
+ }
488
+
489
+ if (!ENTITY_TYPES.includes(entity)) {
490
+ console.error(`Unknown entity type: ${entity}`);
491
+ process.exit(1);
492
+ }
493
+
494
+ // Events need sprintId for lookup — read by eventId with sprintId resolution
495
+ let record;
496
+ if (entity === 'event') {
497
+ // For events, try to find by scanning sprint directories
498
+ const sprints = store.listSprints();
499
+ record = null;
500
+ for (const sprint of sprints) {
501
+ if (!sprint) continue;
502
+ const found = store.getEvent(id, sprint.sprintId);
503
+ if (found) { record = found; break; }
504
+ }
505
+ } else {
506
+ record = getEntity(entity, id);
507
+ }
508
+
509
+ if (!record) {
510
+ console.error(`Entity not found: ${entity} ${id}`);
511
+ process.exit(1);
512
+ }
513
+
514
+ if (asJson) {
515
+ console.log(JSON.stringify(record));
516
+ } else {
517
+ console.log(JSON.stringify(record, null, 2));
518
+ }
519
+ }
520
+
521
+ function cmdList() {
522
+ const entity = args[1];
523
+
524
+ if (!entity) {
525
+ console.error('Usage: store-cli.cjs list <entity> [key=value ...]');
526
+ process.exit(1);
527
+ }
528
+
529
+ if (!ENTITY_TYPES.includes(entity)) {
530
+ console.error(`Unknown entity type: ${entity}`);
531
+ process.exit(1);
532
+ }
533
+
534
+ // Parse key=value filter pairs from remaining args
535
+ const filter = {};
536
+ for (let i = 2; i < args.length; i++) {
537
+ const eqIdx = args[i].indexOf('=');
538
+ if (eqIdx > 0) {
539
+ const key = args[i].slice(0, eqIdx);
540
+ const val = args[i].slice(eqIdx + 1);
541
+ // Try to parse numeric values
542
+ const num = Number(val);
543
+ filter[key] = (val !== '' && !isNaN(num) && val === String(num)) ? num : val;
544
+ }
545
+ }
546
+
547
+ const records = listEntities(entity, Object.keys(filter).length > 0 ? filter : undefined);
548
+ console.log(JSON.stringify(records, null, 2));
549
+ }
550
+
551
+ function cmdDelete() {
552
+ const entity = args[1];
553
+ const id = args[2];
554
+
555
+ if (!entity || !id) {
556
+ console.error('Usage: store-cli.cjs delete <entity> <id>');
557
+ process.exit(1);
558
+ }
559
+
560
+ if (!ENTITY_TYPES.includes(entity)) {
561
+ console.error(`Unknown entity type: ${entity}`);
562
+ process.exit(1);
563
+ }
564
+
565
+ deleteEntity(entity, id);
566
+ if (VERBOSE) console.log(JSON.stringify({ ok: true, deleted: `${entity}/${id}` }));
567
+ }
568
+
569
+ function cmdUpdateStatus() {
570
+ const entity = args[1];
571
+ const id = args[2];
572
+ const field = args[3];
573
+ const value = args[4];
574
+ const force = args.includes('--force');
575
+
576
+ if (!entity || !id || !field || !value) {
577
+ console.error('Usage: store-cli.cjs update-status <entity> <id> <field> <value> [--force]');
578
+ process.exit(1);
579
+ }
580
+
581
+ if (!TRANSITION_MAP[entity]) {
582
+ console.error(`No transition rules for entity type: ${entity}`);
583
+ process.exit(1);
584
+ }
585
+
586
+ // Read current record
587
+ const record = getEntity(entity, id);
588
+ if (!record) {
589
+ console.error(`Entity not found: ${entity} ${id}`);
590
+ process.exit(1);
591
+ }
592
+
593
+ const currentValue = record[field];
594
+ if (currentValue === undefined) {
595
+ console.error(`Field "${field}" not found on ${entity} ${id}`);
596
+ process.exit(1);
597
+ }
598
+
599
+ // Check transition legality
600
+ if (!force && !isLegalTransition(entity, field, currentValue, value)) {
601
+ console.error(`Illegal transition: ${entity} ${id} ${field}: ${currentValue} → ${value}`);
602
+ process.exit(1);
603
+ }
604
+
605
+ if (force && !isLegalTransition(entity, field, currentValue, value)) {
606
+ console.error(`WARN: --force bypassing illegal transition: ${entity} ${id} ${field}: ${currentValue} → ${value}`);
607
+ }
608
+
609
+ // Apply update and write back
610
+ if (DRY_RUN) {
611
+ console.log(`[dry-run] would update ${entity} ${id} ${field}: ${currentValue} → ${value}`);
612
+ } else {
613
+ record[field] = value;
614
+ writeEntity(entity, record);
615
+ }
616
+ if (VERBOSE) console.log(JSON.stringify({ ok: true, entity, id, field, from: currentValue, to: value, force, dryRun: DRY_RUN }));
617
+ }
618
+
619
+ // ---------------------------------------------------------------------------
620
+ // Timestamp normalization helpers (#56)
621
+ // ---------------------------------------------------------------------------
622
+
623
+ // Returns true if the timestamp string has a zeroed time component (T00:00:00),
624
+ // which indicates the caller provided a date-only value instead of a real
625
+ // time-of-day. Midnight UTC is treated as "not set" for event timing purposes.
626
+ function _isZeroedTimestamp(ts) {
627
+ if (typeof ts !== 'string') return true; // null / missing → normalize
628
+ return /T00:00:00/.test(ts);
629
+ }
630
+
631
+ // Normalize event timestamps before writing. Replaces any zeroed or absent
632
+ // startTimestamp / endTimestamp with the current real time, then recomputes
633
+ // durationMinutes from the two timestamps so cost reports are accurate.
634
+ function _normalizeEventTimestamps(data) {
635
+ const now = new Date().toISOString();
636
+
637
+ if (_isZeroedTimestamp(data.startTimestamp)) data.startTimestamp = now;
638
+ if (_isZeroedTimestamp(data.endTimestamp)) data.endTimestamp = now;
639
+
640
+ // Recompute durationMinutes whenever both timestamps are present.
641
+ if (data.startTimestamp && data.endTimestamp) {
642
+ const diffMs = new Date(data.endTimestamp) - new Date(data.startTimestamp);
643
+ data.durationMinutes = Math.max(0, diffMs / 60000);
644
+ }
645
+
646
+ return data;
647
+ }
648
+
649
+ function cmdEmit() {
650
+ const sprintId = args[1];
651
+ const jsonStr = args[2];
652
+ const isSidecar = args.includes('--sidecar');
653
+
654
+ if (!sprintId || !jsonStr) {
655
+ console.error('Usage: store-cli.cjs emit <sprintId> \'<json>\' [--sidecar]');
656
+ process.exit(1);
657
+ }
658
+
659
+ let data;
660
+ try {
661
+ data = JSON.parse(jsonStr);
662
+ } catch (e) {
663
+ console.error(`Invalid JSON: ${e.message}`);
664
+ process.exit(1);
665
+ }
666
+
667
+ if (isSidecar) {
668
+ // Write sidecar file — validate against the sidecar schema (eventId +
669
+ // optional token/cost fields). Full event-shape enforcement happens
670
+ // on merge into the canonical event.
671
+ const sidecarErrors = validateRecord(data, schemas['event-sidecar']);
672
+ if (sidecarErrors.length > 0) {
673
+ for (const e of sidecarErrors) console.error(e);
674
+ process.exit(1);
675
+ }
676
+
677
+ const sidecarDir = resolveSidecarDir(sprintId);
678
+ const filePath = sidecarPath(sprintId, data.eventId);
679
+
680
+ if (DRY_RUN) {
681
+ console.log(`[dry-run] would write sidecar _${data.eventId}_usage.json`);
682
+ } else {
683
+ // Ensure directory exists
684
+ if (!fs.existsSync(sidecarDir)) {
685
+ fs.mkdirSync(sidecarDir, { recursive: true });
686
+ }
687
+
688
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
689
+ }
690
+ if (VERBOSE) console.log(JSON.stringify({ ok: true, sidecar: true, eventId: data.eventId, sprintId, dryRun: DRY_RUN }));
691
+ } else {
692
+ // Normalize zeroed timestamps before validation so agents that provide
693
+ // date-only values (T00:00:00Z) get real time-of-day stamped in (#56).
694
+ _normalizeEventTimestamps(data);
695
+
696
+ // Auto-populate model from environment when missing or empty (FORGE-S12-T06).
697
+ // Callers who set model explicitly take priority — discoverModel() is only
698
+ // used as a fallback so we never silently record a wrong model name.
699
+ if (!data.model || !data.model.trim()) {
700
+ data.model = discoverModel();
701
+ }
702
+
703
+ // Validate as event entity
704
+ const errors = validateRecord(data, schemas.event);
705
+ if (errors.length > 0) {
706
+ for (const e of errors) console.error(e);
707
+ process.exit(1);
708
+ }
709
+
710
+ if (DRY_RUN) {
711
+ console.log(`[dry-run] would emit event ${data.eventId}`);
712
+ } else {
713
+ store.writeEvent(sprintId, data);
714
+ }
715
+ if (VERBOSE) console.log(JSON.stringify({ ok: true, event: true, eventId: data.eventId, sprintId, dryRun: DRY_RUN }));
716
+ }
717
+ }
718
+
719
+ function cmdMergeSidecar() {
720
+ const sprintId = args[1];
721
+ const eventId = args[2];
722
+
723
+ if (!sprintId || !eventId) {
724
+ console.error('Usage: store-cli.cjs merge-sidecar <sprintId> <eventId>');
725
+ process.exit(1);
726
+ }
727
+
728
+ // Read sidecar
729
+ const scPath = sidecarPath(sprintId, eventId);
730
+ if (!fs.existsSync(scPath)) {
731
+ console.error(`Sidecar not found: ${scPath}`);
732
+ process.exit(1);
733
+ }
734
+
735
+ let sidecar;
736
+ try {
737
+ sidecar = JSON.parse(fs.readFileSync(scPath, 'utf8'));
738
+ } catch (e) {
739
+ console.error(`Invalid sidecar JSON: ${e.message}`);
740
+ process.exit(1);
741
+ }
742
+
743
+ // Read canonical event
744
+ const canPath = canonicalEventPath(sprintId, eventId);
745
+ if (!fs.existsSync(canPath)) {
746
+ console.error(`Canonical event not found: ${canPath}`);
747
+ process.exit(1);
748
+ }
749
+
750
+ let event;
751
+ try {
752
+ event = JSON.parse(fs.readFileSync(canPath, 'utf8'));
753
+ } catch (e) {
754
+ console.error(`Invalid canonical event JSON: ${e.message}`);
755
+ process.exit(1);
756
+ }
757
+
758
+ // Merge token fields from sidecar into event
759
+ for (const [key, value] of Object.entries(sidecar)) {
760
+ // Resolve aliases
761
+ const canonicalKey = SIDECAR_ALIASES[key] || key;
762
+ if (CANONICAL_TOKEN_FIELDS.includes(canonicalKey)) {
763
+ event[canonicalKey] = value;
764
+ }
765
+ }
766
+
767
+ // Re-validate the merged canonical event against the event schema. Catches
768
+ // the case where a sidecar's token field is present but the canonical event
769
+ // was already malformed — we do not want to silently persist invalid data.
770
+ const mergedErrors = validateRecord(event, schemas.event);
771
+ if (mergedErrors.length > 0) {
772
+ console.error(`Merged event ${eventId} failed schema validation:`);
773
+ for (const e of mergedErrors) console.error(` ${e}`);
774
+ process.exit(1);
775
+ }
776
+
777
+ if (DRY_RUN) {
778
+ console.log(`[dry-run] would merge sidecar into ${eventId} and delete sidecar`);
779
+ } else {
780
+ // Write updated event via facade (handles ghost-file logic)
781
+ store.writeEvent(sprintId, event);
782
+
783
+ // Delete sidecar
784
+ fs.unlinkSync(scPath);
785
+ }
786
+
787
+ if (VERBOSE) console.log(JSON.stringify({ ok: true, merged: true, eventId, sprintId, dryRun: DRY_RUN }));
788
+ }
789
+
790
+ function cmdRecordUsage() {
791
+ const sprintId = args[1];
792
+ const eventId = args[2];
793
+
794
+ if (!sprintId || !eventId) {
795
+ console.error('Usage: store-cli.cjs record-usage <sprintId> <eventId> [flags]');
796
+ console.error(' Flags:');
797
+ console.error(' --input-tokens <n> Input token count');
798
+ console.error(' --output-tokens <n> Output token count');
799
+ console.error(' --cache-read-tokens <n> Cache read token count');
800
+ console.error(' --cache-write-tokens <n> Cache write token count');
801
+ console.error(' --estimated-cost-usd <n> Estimated cost in USD');
802
+ console.error(' --token-source <src> reported | estimated');
803
+ console.error(' --model <model> Model identifier');
804
+ console.error(' --duration-minutes <n> Duration in minutes');
805
+ process.exit(1);
806
+ }
807
+
808
+ // Parse flag arguments from remaining args
809
+ const flagArgs = args.slice(3);
810
+ const sidecar = { eventId };
811
+
812
+ for (let i = 0; i < flagArgs.length; i++) {
813
+ const arg = flagArgs[i];
814
+ if (arg === '--input-tokens' && flagArgs[i + 1]) {
815
+ sidecar.inputTokens = parseInt(flagArgs[++i], 10);
816
+ } else if (arg === '--output-tokens' && flagArgs[i + 1]) {
817
+ sidecar.outputTokens = parseInt(flagArgs[++i], 10);
818
+ } else if (arg === '--cache-read-tokens' && flagArgs[i + 1]) {
819
+ sidecar.cacheReadTokens = parseInt(flagArgs[++i], 10);
820
+ } else if (arg === '--cache-write-tokens' && flagArgs[i + 1]) {
821
+ sidecar.cacheWriteTokens = parseInt(flagArgs[++i], 10);
822
+ } else if (arg === '--estimated-cost-usd' && flagArgs[i + 1]) {
823
+ sidecar.estimatedCostUSD = parseFloat(flagArgs[++i]);
824
+ } else if (arg === '--token-source' && flagArgs[i + 1]) {
825
+ sidecar.tokenSource = flagArgs[++i];
826
+ } else if (arg === '--model' && flagArgs[i + 1]) {
827
+ sidecar.model = flagArgs[++i];
828
+ } else if (arg === '--duration-minutes' && flagArgs[i + 1]) {
829
+ sidecar.durationMinutes = parseFloat(flagArgs[++i]);
830
+ }
831
+ }
832
+
833
+ // Auto-populate model from environment when --model flag not provided (FORGE-S12-T06).
834
+ // An explicit --model flag takes priority — discoverModel() is only a fallback.
835
+ if (!sidecar.model) {
836
+ sidecar.model = discoverModel();
837
+ }
838
+
839
+ // Validate against sidecar schema
840
+ const sidecarErrors = validateRecord(sidecar, schemas['event-sidecar']);
841
+ if (sidecarErrors.length > 0) {
842
+ for (const e of sidecarErrors) console.error(e);
843
+ process.exit(1);
844
+ }
845
+
846
+ const sidecarDir = resolveSidecarDir(sprintId);
847
+ const filePath = sidecarPath(sprintId, eventId);
848
+
849
+ if (DRY_RUN) {
850
+ console.log(`[dry-run] would write sidecar _${eventId}_usage.json`);
851
+ } else {
852
+ if (!fs.existsSync(sidecarDir)) {
853
+ fs.mkdirSync(sidecarDir, { recursive: true });
854
+ }
855
+ fs.writeFileSync(filePath, JSON.stringify(sidecar, null, 2) + '\n', 'utf8');
856
+ }
857
+ if (VERBOSE) console.log(JSON.stringify({ ok: true, sidecar: true, eventId, sprintId, dryRun: DRY_RUN }));
858
+ }
859
+
860
+ function cmdPurgeEvents() {
861
+ const sprintId = args[1];
862
+
863
+ if (!sprintId) {
864
+ console.error('Usage: store-cli.cjs purge-events <sprintId>');
865
+ process.exit(1);
866
+ }
867
+
868
+ const result = store.purgeEvents(sprintId, { dryRun: DRY_RUN });
869
+ if (DRY_RUN && !result.purged) {
870
+ console.log(`[dry-run] would purge ${result.fileCount} event(s) for ${sprintId}`);
871
+ }
872
+ if (VERBOSE) console.log(JSON.stringify(result, null, 2));
873
+ }
874
+
875
+ function cmdWriteCollationState() {
876
+ const jsonStr = args[1];
877
+
878
+ if (!jsonStr) {
879
+ console.error('Usage: store-cli.cjs write-collation-state \'<json>\'');
880
+ process.exit(1);
881
+ }
882
+
883
+ let data;
884
+ try {
885
+ data = JSON.parse(jsonStr);
886
+ } catch (e) {
887
+ console.error(`Invalid JSON: ${e.message}`);
888
+ process.exit(1);
889
+ }
890
+
891
+ const csErrors = validateRecord(data, schemas['collation-state']);
892
+ if (csErrors.length > 0) {
893
+ for (const e of csErrors) console.error(e);
894
+ process.exit(1);
895
+ }
896
+
897
+ if (DRY_RUN) {
898
+ console.log('[dry-run] would write COLLATION_STATE.json');
899
+ } else {
900
+ store.writeCollationState(data);
901
+ }
902
+ if (VERBOSE) console.log(JSON.stringify({ ok: true, dryRun: DRY_RUN }));
903
+ }
904
+
905
+ function cmdProgress() {
906
+ const sprintOrBugId = args[1];
907
+ const agentName = args[2];
908
+ const bannerKey = args[3];
909
+ const status = args[4];
910
+ const detail = args.slice(5).join(' ');
911
+
912
+ if (!sprintOrBugId || !agentName || !bannerKey || !status) {
913
+ console.error('Usage: store-cli.cjs progress <sprintOrBugId> <agentName> <bannerKey> <status> [detail]');
914
+ console.error(' status: start | progress | done | error');
915
+ process.exit(1);
916
+ }
917
+
918
+ const timestamp = new Date().toISOString();
919
+
920
+ const progressErrors = validateRecord(
921
+ { timestamp, agentName, bannerKey, status, detail: detail || '' },
922
+ schemas['progress-entry']
923
+ );
924
+ if (progressErrors.length > 0) {
925
+ for (const e of progressErrors) console.error(e);
926
+ process.exit(1);
927
+ }
928
+
929
+ const line = `${timestamp}|${agentName}|${bannerKey}|${status}|${detail}\n`;
930
+
931
+ const dir = _resolveEventsDir(sprintOrBugId);
932
+ if (!fs.existsSync(dir)) {
933
+ fs.mkdirSync(dir, { recursive: true });
934
+ }
935
+
936
+ const logPath = path.join(dir, 'progress.log');
937
+ fs.appendFileSync(logPath, line, 'utf8');
938
+
939
+ // Emit human-readable summary to stdout
940
+ let banners;
941
+ try { banners = require('./banners.cjs'); } catch { banners = null; }
942
+ let emoji = bannerKey;
943
+ if (banners && typeof banners.mark === 'function') {
944
+ try { emoji = banners.mark(bannerKey); } catch { emoji = bannerKey; }
945
+ }
946
+ const summary = `${emoji} ${agentName} [${status}]${detail ? ' ' + detail : ''}`;
947
+ if (VERBOSE) process.stdout.write(summary + '\n');
948
+ }
949
+
950
+ function cmdProgressClear() {
951
+ const sprintOrBugId = args[1];
952
+
953
+ if (!sprintOrBugId) {
954
+ console.error('Usage: store-cli.cjs progress-clear <sprintOrBugId>');
955
+ process.exit(1);
956
+ }
957
+
958
+ const dir = _resolveEventsDir(sprintOrBugId);
959
+ if (!fs.existsSync(dir)) {
960
+ fs.mkdirSync(dir, { recursive: true });
961
+ }
962
+
963
+ const logPath = path.join(dir, 'progress.log');
964
+ fs.writeFileSync(logPath, '', 'utf8');
965
+ if (VERBOSE) console.log(`Cleared ${logPath}`);
966
+ }
967
+
968
+ function cmdValidate() {
969
+ const entity = args[1];
970
+ const jsonStr = args[2];
971
+
972
+ if (!entity || !jsonStr) {
973
+ console.error('Usage: store-cli.cjs validate <entity> \'<json>\'');
974
+ process.exit(1);
975
+ }
976
+
977
+ if (!ENTITY_TYPES.includes(entity)) {
978
+ console.error(`Unknown entity type: ${entity}`);
979
+ process.exit(1);
980
+ }
981
+
982
+ let data;
983
+ try {
984
+ data = JSON.parse(jsonStr);
985
+ } catch (e) {
986
+ console.error(`Invalid JSON: ${e.message}`);
987
+ process.exit(1);
988
+ }
989
+
990
+ const errors = validateRecord(data, schemas[entity]);
991
+ if (errors.length > 0) {
992
+ for (const e of errors) console.error(e);
993
+ process.exit(1);
994
+ }
995
+
996
+ if (VERBOSE) console.log(JSON.stringify({ ok: true, entity, valid: true }));
997
+ }
998
+
999
+ function _setSummaryOnEntity(entityKind, entityId, phase, summaryFilePath) {
1000
+ if (!VALID_SUMMARY_PHASES.has(phase)) {
1001
+ console.error(`Unknown phase "${phase}". Valid phases: ${[...VALID_SUMMARY_PHASES].join(', ')}`);
1002
+ process.exit(1);
1003
+ }
1004
+
1005
+ // Read and validate summary JSON
1006
+ if (!fs.existsSync(summaryFilePath)) {
1007
+ console.error(`Summary file not found: ${summaryFilePath}`);
1008
+ process.exit(1);
1009
+ }
1010
+
1011
+ let summary;
1012
+ try {
1013
+ summary = JSON.parse(fs.readFileSync(summaryFilePath, 'utf8'));
1014
+ } catch (e) {
1015
+ console.error(`Invalid JSON in summary file: ${e.message}`);
1016
+ process.exit(1);
1017
+ }
1018
+
1019
+ const errors = validateRecord(summary, PHASE_SUMMARY_SCHEMA);
1020
+ if (errors.length > 0) {
1021
+ for (const e of errors) console.error(e);
1022
+ process.exit(1);
1023
+ }
1024
+
1025
+ // Load entity
1026
+ const record = entityKind === 'task' ? store.getTask(entityId) : store.getBug(entityId);
1027
+ if (!record) {
1028
+ console.error(`${entityKind} not found: ${entityId}`);
1029
+ process.exit(1);
1030
+ }
1031
+
1032
+ // Merge summary
1033
+ if (!record.summaries) record.summaries = {};
1034
+ record.summaries[phase] = summary;
1035
+
1036
+ // Atomic write: tmp + rename
1037
+ const entityDirKey = entityKind === 'task' ? 'tasks' : 'bugs';
1038
+ const idField = entityKind === 'task' ? record.taskId : record.bugId;
1039
+ const storeRoot = store.impl.storeRoot;
1040
+ const filePath = path.join(storeRoot, entityDirKey, `${idField}.json`);
1041
+ const tmpPath = filePath + '.tmp';
1042
+
1043
+ if (DRY_RUN) {
1044
+ console.log(`[dry-run] would set ${entityKind} ${entityId} summaries.${phase}`);
1045
+ } else {
1046
+ fs.writeFileSync(tmpPath, JSON.stringify(record, null, 2) + '\n', 'utf8');
1047
+ fs.renameSync(tmpPath, filePath);
1048
+ }
1049
+
1050
+ if (VERBOSE) console.log(JSON.stringify({ ok: true, entityKind, id: entityId, phase, dryRun: DRY_RUN }));
1051
+ }
1052
+
1053
+ function cmdSetSummary() {
1054
+ const taskId = args[1];
1055
+ const phase = args[2];
1056
+ const summaryFile = args[3];
1057
+
1058
+ if (!taskId || !phase || !summaryFile) {
1059
+ console.error('Usage: store-cli.cjs set-summary <taskId> <phase> <jsonFile>');
1060
+ process.exit(1);
1061
+ }
1062
+
1063
+ _setSummaryOnEntity('task', taskId, phase, summaryFile);
1064
+ }
1065
+
1066
+ function cmdSetBugSummary() {
1067
+ const bugId = args[1];
1068
+ const phase = args[2];
1069
+ const summaryFile = args[3];
1070
+
1071
+ if (!bugId || !phase || !summaryFile) {
1072
+ console.error('Usage: store-cli.cjs set-bug-summary <bugId> <phase> <jsonFile>');
1073
+ process.exit(1);
1074
+ }
1075
+
1076
+ _setSummaryOnEntity('bug', bugId, phase, summaryFile);
1077
+ }
1078
+
1079
+ // ---------------------------------------------------------------------------
1080
+ // Command dispatch
1081
+ // ---------------------------------------------------------------------------
1082
+
1083
+ switch (command) {
1084
+ case 'write': cmdWrite(); break;
1085
+ case 'read': cmdRead(); break;
1086
+ case 'list': cmdList(); break;
1087
+ case 'delete': cmdDelete(); break;
1088
+ case 'update-status': cmdUpdateStatus(); break;
1089
+ case 'emit': cmdEmit(); break;
1090
+ case 'merge-sidecar': cmdMergeSidecar(); break;
1091
+ case 'record-usage': cmdRecordUsage(); break;
1092
+ case 'purge-events': cmdPurgeEvents(); break;
1093
+ case 'write-collation-state': cmdWriteCollationState(); break;
1094
+ case 'validate': cmdValidate(); break;
1095
+ case 'progress': cmdProgress(); break;
1096
+ case 'progress-clear': cmdProgressClear(); break;
1097
+ case 'set-summary': cmdSetSummary(); break;
1098
+ case 'set-bug-summary': cmdSetBugSummary(); break;
1099
+ case 'query':
1100
+ case 'nlp':
1101
+ case 'schema': {
1102
+ // Delegate to store-query.cjs — query engine lives there
1103
+ const { spawnSync } = require('child_process');
1104
+ const queryBin = path.join(__dirname, 'store-query.cjs');
1105
+ const result = spawnSync(process.execPath, [queryBin, command, ...args.slice(1)], {
1106
+ stdio: 'inherit',
1107
+ cwd: process.cwd(),
1108
+ });
1109
+ process.exit(result.status ?? 1);
1110
+ break;
1111
+ }
1112
+ default:
1113
+ console.error(`Unknown command: ${command}`);
1114
+ console.error('Run with --help for usage information.');
1115
+ process.exit(1);
1116
+ }
1117
+
1118
+ } catch (err) {
1119
+ console.error(err.message);
1120
+ process.exit(1);
1121
+ }
1122
+
1123
+ } // end if (require.main === module)