@magnusekdahl/parallix 1.0.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 (123) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/LICENSE +661 -0
  3. package/README.md +196 -0
  4. package/config/agents.json +25 -0
  5. package/config/agents.local.json.template +8 -0
  6. package/config/state-map.json +4 -0
  7. package/config/state-map.json.template +31 -0
  8. package/config/workflow.config.schema.json +98 -0
  9. package/data/.gitkeep +0 -0
  10. package/docs/adr/0031-ai-agent-instruction-boundary-and-command-floor.md +114 -0
  11. package/docs/adr/0032-mission-refinement-state-and-usage-budget-signals.md +135 -0
  12. package/docs/adr/0034-module-and-skill-invocation-model.md +202 -0
  13. package/docs/adr/0036-mission-sizing-and-dependency-wave-heuristics.md +79 -0
  14. package/docs/adr/0037-ai-workflow-coordination-architecture.md +162 -0
  15. package/docs/adr/0041-integration-pipeline-gates.md +165 -0
  16. package/docs/adr/0042-workflow-cli-color-rendering-approach.md +106 -0
  17. package/docs/adr/0043-git-target-resolution-strategy.md +185 -0
  18. package/docs/adr/0044-workflow-distribution-model.md +277 -0
  19. package/docs/adr/0045-parallax-branch-model.md +182 -0
  20. package/docs/adr/0046-npm-publish-process-and-security.md +138 -0
  21. package/docs/adr/index.md +20 -0
  22. package/docs/agents.md +212 -0
  23. package/docs/authority-reference.md +298 -0
  24. package/docs/forgejo-setup.md +31 -0
  25. package/docs/migration/extraction.md +61 -0
  26. package/docs/migration/task-classification.md +36 -0
  27. package/docs/operator-setup.md +76 -0
  28. package/docs/readme-rewrite-benchmark.md +188 -0
  29. package/docs/use-cases.md +105 -0
  30. package/examples/README.md +62 -0
  31. package/examples/run-enterprise-tarball-workflow-smoke.sh +257 -0
  32. package/examples/run-verify-env-smoke.sh +40 -0
  33. package/index.js +250 -0
  34. package/lib/README.md +13 -0
  35. package/lib/agents/agents.js +867 -0
  36. package/lib/agents/claude-telemetry.js +233 -0
  37. package/lib/agents/claude.js +139 -0
  38. package/lib/agents/codex-telemetry.js +202 -0
  39. package/lib/agents/codex.js +219 -0
  40. package/lib/agents/limit-hit.js +252 -0
  41. package/lib/agents/mistral-telemetry.js +44 -0
  42. package/lib/agents/mistral.js +68 -0
  43. package/lib/agents/opencode-export.js +110 -0
  44. package/lib/agents/opencode-telemetry.js +356 -0
  45. package/lib/agents/opencode.js +218 -0
  46. package/lib/agents/stage-telemetry.js +37 -0
  47. package/lib/commands/active.js +625 -0
  48. package/lib/commands/checkpoint.js +76 -0
  49. package/lib/commands/config.js +39 -0
  50. package/lib/commands/coverage-gate.js +358 -0
  51. package/lib/commands/diff.js +119 -0
  52. package/lib/commands/draft.js +854 -0
  53. package/lib/commands/handoff.js +501 -0
  54. package/lib/commands/integrate.js +1528 -0
  55. package/lib/commands/mission-start.js +246 -0
  56. package/lib/commands/rebase.js +597 -0
  57. package/lib/commands/repair-handoff.js +227 -0
  58. package/lib/commands/resolve-conflict.js +109 -0
  59. package/lib/commands/review.js +13 -0
  60. package/lib/commands/setup-review.js +13 -0
  61. package/lib/commands/setup.js +3 -0
  62. package/lib/commands/stats-backfill.js +395 -0
  63. package/lib/commands/stats.js +1601 -0
  64. package/lib/commands/status.js +183 -0
  65. package/lib/commands/verify.js +1 -0
  66. package/lib/core/fmt.js +202 -0
  67. package/lib/core/git.js +73 -0
  68. package/lib/core/gitignore.js +110 -0
  69. package/lib/core/mission-utils.js +1017 -0
  70. package/lib/core/persistent-data-migration.js +201 -0
  71. package/lib/core/product-config.js +508 -0
  72. package/lib/core/runtime-matrix.js +82 -0
  73. package/lib/core/spawn-tee.js +173 -0
  74. package/lib/core/state-map.js +89 -0
  75. package/lib/core/storage.js +165 -0
  76. package/lib/core/verification.js +149 -0
  77. package/lib/index.js +77 -0
  78. package/lib/review/rebase.js +163 -0
  79. package/lib/review/review-adapter.js +135 -0
  80. package/lib/review/review-artifacts.js +619 -0
  81. package/lib/review/review-commands.js +1375 -0
  82. package/lib/review/review-events.js +1007 -0
  83. package/lib/review/review-loop.js +1004 -0
  84. package/lib/review/review-polling.js +141 -0
  85. package/lib/review/review-prompts.js +212 -0
  86. package/lib/review/review-state.js +280 -0
  87. package/lib/review/review.js +96 -0
  88. package/lib/tools/backlog.js +680 -0
  89. package/lib/tools/forgejo.js +1585 -0
  90. package/lib/tools/gatekeeper.js +106 -0
  91. package/lib/tools/sessions.js +74 -0
  92. package/lib/tools/setup-review.js +1053 -0
  93. package/package.json +56 -0
  94. package/prompts/act-on-review-verbose.md +20 -0
  95. package/prompts/act-on-review.md +22 -0
  96. package/prompts/draft.md +20 -0
  97. package/prompts/execute.md +24 -0
  98. package/prompts/portfolio.md +30 -0
  99. package/prompts/review-verbose.md +20 -0
  100. package/prompts/review.md +17 -0
  101. package/px.js +236 -0
  102. package/templates/AGENTS-snippet.md +14 -0
  103. package/templates/AGENTS.md.template +34 -0
  104. package/templates/CLAUDE.md.template +27 -0
  105. package/templates/CODEX.md.template +38 -0
  106. package/templates/MISTRAL.md.template +24 -0
  107. package/templates/claude-commands/act-on-review.md +3 -0
  108. package/templates/claude-commands/area-review.md +3 -0
  109. package/templates/claude-commands/draft.md +6 -0
  110. package/templates/claude-commands/execute.md +6 -0
  111. package/templates/claude-commands/integrate.md +4 -0
  112. package/templates/claude-commands/portfolio.md +5 -0
  113. package/templates/claude-commands/review.md +4 -0
  114. package/templates/codex/config.toml +6 -0
  115. package/templates/mission-scaffold.md +39 -0
  116. package/templates/vibe/skills/act-on-review/SKILL.md +16 -0
  117. package/templates/vibe/skills/area-review/SKILL.md +16 -0
  118. package/templates/vibe/skills/draft/SKILL.md +16 -0
  119. package/templates/vibe/skills/execute/SKILL.md +16 -0
  120. package/templates/vibe/skills/integrate/SKILL.md +16 -0
  121. package/templates/vibe/skills/portfolio/SKILL.md +21 -0
  122. package/templates/vibe/skills/review/SKILL.md +16 -0
  123. package/tools/setup-forgejo-docker.sh +84 -0
@@ -0,0 +1,1007 @@
1
+ /**
2
+ * Review event persistence for autonomous review rounds.
3
+ *
4
+ * Stores classified review events under mission-local directories:
5
+ * missions/<slug>/review-events/ by default, or the configured legacy layout
6
+ *
7
+ * Each event is a structured markdown file with bounded fields, not full
8
+ * transient agent context (to avoid noisy diffs per ADR 0039).
9
+ *
10
+ * Owned by the Node workflow harness (task-1145).
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const os = require('os');
16
+ const { git } = require('../core/git');
17
+ const { findMissionDir, missionBranchName, resolveWorktree } = require('../core/mission-utils');
18
+ const { readReviewState } = require('./review-state');
19
+ const fmt = require('../core/fmt');
20
+
21
+ // -------- Event Taxonomy --------
22
+
23
+ /**
24
+ * Valid event classification labels.
25
+ * These are the canonical types that can be stored and (optionally) mirrored to the review provider.
26
+ */
27
+ const VALID_EVENT_TYPES = Object.freeze({
28
+ // Reviewer-produced events
29
+ REVIEWER_FINDINGS: 'reviewer_findings',
30
+ REVIEWER_OUTCOME: 'reviewer_outcome',
31
+
32
+ // Implementer-produced events
33
+ IMPLEMENTER_ROUND_SUMMARY: 'implementer_round_summary',
34
+ IMPLEMENTER_DISPOSITION: 'implementer_disposition',
35
+
36
+ // Neutral events
37
+ NEUTRAL_DISCUSSION: 'neutral_discussion',
38
+ HUMAN_NOTE: 'human_note',
39
+
40
+ // Workflow-internal events (not mirrored to the review provider by default)
41
+ BLOCKED_PUBLICATION: 'blocked_publication',
42
+ PARKED_FOLLOWUP: 'parked_followup'
43
+ });
44
+
45
+ /**
46
+ * All valid event type values as an array for validation.
47
+ */
48
+ const ALL_EVENT_TYPES = Object.freeze(Object.values(VALID_EVENT_TYPES));
49
+
50
+ /**
51
+ * Event types that should be mirrored to the review provider.
52
+ * Reviewer findings, reviewer outcome, implementer round summary, implementer disposition,
53
+ * and round-affecting human notes are mirrored.
54
+ * Raw draft artifacts, retry bookkeeping, classification metadata, and local diagnostics
55
+ * remain workflow-local by default.
56
+ */
57
+ const MIRRORED_EVENT_TYPES = Object.freeze(new Set([
58
+ VALID_EVENT_TYPES.REVIEWER_FINDINGS,
59
+ VALID_EVENT_TYPES.REVIEWER_OUTCOME,
60
+ VALID_EVENT_TYPES.IMPLEMENTER_ROUND_SUMMARY,
61
+ VALID_EVENT_TYPES.IMPLEMENTER_DISPOSITION,
62
+ VALID_EVENT_TYPES.HUMAN_NOTE
63
+ ]));
64
+
65
+ /**
66
+ * Valid disposition values for implementer disposition events.
67
+ */
68
+ const VALID_DISPOSITIONS = Object.freeze([
69
+ 'CHANGES_MADE',
70
+ 'PUSHBACK_ALL',
71
+ 'PARKED',
72
+ 'BLOCKED'
73
+ ]);
74
+
75
+ /**
76
+ * Valid review verdict values for reviewer outcome events.
77
+ */
78
+ const VALID_VERDICTS = Object.freeze([
79
+ 'approve',
80
+ 'request-changes',
81
+ 'comment'
82
+ ]);
83
+
84
+ // -------- Path Resolution --------
85
+
86
+ /**
87
+ * Returns the absolute path to the mission-local review-events directory.
88
+ * Creates the directory if it does not exist.
89
+ *
90
+ * @param {string} slug - Mission slug
91
+ * @param {string} [rootDir] - Root directory to resolve from (defaults to process.cwd())
92
+ * @returns {string|null} - Absolute path to review-events dir, or null if mission dir not found
93
+ */
94
+ function reviewEventsDir(slug, rootDir = process.cwd()) {
95
+ const missionDir = findMissionDir(slug, rootDir);
96
+ if (!missionDir) return null;
97
+ const dir = path.join(missionDir, 'review-events');
98
+ if (!fs.existsSync(dir)) {
99
+ fs.mkdirSync(dir, { recursive: true });
100
+ }
101
+ return dir;
102
+ }
103
+
104
+ /**
105
+ * Returns the path for a specific event file in the mission-local store.
106
+ *
107
+ * Filename pattern: <timestamp>-<classification>-<round>-<actor>.md
108
+ * timestamp: ISO 8601 date-time without colons (e.g., 2026-05-25T143022)
109
+ * classification: event type from VALID_EVENT_TYPES
110
+ * round: review round number
111
+ * actor: agent family name or 'human'
112
+ *
113
+ * @param {string} slug - Mission slug
114
+ * @param {string} eventType - One of VALID_EVENT_TYPES
115
+ * @param {number} round - Review round number
116
+ * @param {string} actor - Agent family or 'human'
117
+ * @param {string} [timestamp] - Optional ISO timestamp; defaults to now
118
+ * @param {string} [rootDir] - Root directory to resolve from
119
+ * @returns {string|null} - Absolute file path, or null if mission dir not found
120
+ */
121
+ function eventFilePath(slug, eventType, round, actor, timestamp = null, rootDir = process.cwd()) {
122
+ const eventsDir = reviewEventsDir(slug, rootDir);
123
+ if (!eventsDir) return null;
124
+
125
+ const ts = timestamp || generateEventTimestamp();
126
+ const sanitizedActor = sanitizeFilename(actor);
127
+ const filename = `${ts}-${eventType}-${round}-${sanitizedActor}.md`;
128
+ return path.join(eventsDir, filename);
129
+ }
130
+
131
+ /**
132
+ * Returns the legacy /tmp/ artifact path for compatibility.
133
+ *
134
+ * @param {string} slug - Mission slug
135
+ * @param {string} artifactName - e.g., 'review-findings.md'
136
+ * @param {string} [tmpDir] - Temp directory, defaults to os.tmpdir()
137
+ * @returns {string} - Full path to the /tmp/ artifact
138
+ */
139
+ function legacyArtifactPath(slug, artifactName, tmpDir = os.tmpdir()) {
140
+ return path.join(tmpDir, `${slug}-${artifactName}`);
141
+ }
142
+
143
+ // -------- Timestamp & Sanitization --------
144
+
145
+ /**
146
+ * Generate a filesystem-safe ISO timestamp without colons.
147
+ * @returns {string} - e.g., '2026-05-25T143022'
148
+ */
149
+ function generateEventTimestamp() {
150
+ const now = new Date();
151
+ const datePart = now.toISOString().split('T')[0];
152
+ const timePart = now.toISOString().split('T')[1].split('.')[0];
153
+ // Replace colons with nothing for filesystem safety
154
+ return `${datePart}T${timePart.replace(/:/g, '')}`;
155
+ }
156
+
157
+ /**
158
+ * Sanitize a string for use in a filename.
159
+ * Removes all non-alphanumeric characters except hyphens and underscores.
160
+ * @param {string} str - Input string
161
+ * @returns {string} - Filesystem-safe string
162
+ */
163
+ function sanitizeFilename(str) {
164
+ if (!str) return 'unknown';
165
+ return String(str)
166
+ .toLowerCase()
167
+ // Replace @ and . with empty string first (common in emails/agent names)
168
+ .replace(/[@.]/g, '')
169
+ // Then replace any remaining non-alphanumeric/underscore/hyphen with hyphen
170
+ .replace(/[^a-z0-9_-]/g, '-')
171
+ .replace(/-+/g, '-')
172
+ .replace(/^-|-$/g, '');
173
+ }
174
+
175
+ // -------- Event Validation --------
176
+
177
+ /**
178
+ * Validate an event type against the taxonomy.
179
+ * @param {string} eventType - The type to validate
180
+ * @returns {boolean} - True if valid
181
+ */
182
+ function isValidEventType(eventType) {
183
+ return ALL_EVENT_TYPES.includes(eventType);
184
+ }
185
+
186
+ /**
187
+ * Validate a disposition value.
188
+ * @param {string} disposition - The disposition to validate
189
+ * @returns {boolean} - True if valid
190
+ */
191
+ function isValidDisposition(disposition) {
192
+ return VALID_DISPOSITIONS.includes(disposition);
193
+ }
194
+
195
+ /**
196
+ * Validate a verdict value.
197
+ * @param {string} verdict - The verdict to validate
198
+ * @returns {boolean} - True if valid
199
+ */
200
+ function isValidVerdict(verdict) {
201
+ return VALID_VERDICTS.includes(verdict);
202
+ }
203
+
204
+ /**
205
+ * Check if an event type should be mirrored to the review provider.
206
+ * @param {string} eventType - The event type
207
+ * @returns {boolean} - True if mirrored
208
+ */
209
+ function shouldMirrorToProvider(eventType) {
210
+ return MIRRORED_EVENT_TYPES.has(eventType);
211
+ }
212
+
213
+ // -------- Event Structure --------
214
+
215
+ /**
216
+ * Normalize an event payload into a structured format.
217
+ * Extracts metadata from content and ensures required fields are present.
218
+ *
219
+ * @param {string} content - The raw event content (markdown)
220
+ * @param {string} eventType - The event classification
221
+ * @param {Object} [metadata] - Additional metadata (round, phase, actor, etc.)
222
+ * @returns {Object} - Normalized event structure
223
+ */
224
+ function normalizeEventContent(content, eventType, metadata = {}) {
225
+ const payload = { ...metadata };
226
+
227
+ // Ensure we have the base metadata
228
+ payload.eventType = eventType;
229
+ payload.timestamp = payload.timestamp || new Date().toISOString();
230
+
231
+ // Add content
232
+ payload.content = content;
233
+
234
+ return payload;
235
+ }
236
+
237
+ /**
238
+ * Build the frontmatter for an event file.
239
+ * Uses YAML-style frontmatter for structured metadata.
240
+ *
241
+ * @param {Object} event - Normalized event structure
242
+ * @returns {string} - Frontmatter block
243
+ */
244
+ function buildEventFrontmatter(event) {
245
+ const lines = [
246
+ '---',
247
+ `event_type: ${event.eventType}`,
248
+ `timestamp: ${event.timestamp}`,
249
+ ];
250
+
251
+ if (event.round !== undefined) {
252
+ lines.push(`round: ${event.round}`);
253
+ }
254
+ if (event.phase !== undefined) {
255
+ lines.push(`phase: ${event.phase}`);
256
+ }
257
+ if (event.actor !== undefined) {
258
+ lines.push(`actor: ${event.actor}`);
259
+ }
260
+ if (event.slug !== undefined) {
261
+ lines.push(`slug: ${event.slug}`);
262
+ }
263
+
264
+ // Add event-specific fields
265
+ if (event.disposition !== undefined) {
266
+ lines.push(`disposition: ${event.disposition}`);
267
+ }
268
+ if (event.verdict !== undefined) {
269
+ lines.push(`verdict: ${event.verdict}`);
270
+ }
271
+ if (event.fixedItems !== undefined) {
272
+ lines.push(`fixed_items: ${JSON.stringify(event.fixedItems)}`);
273
+ }
274
+ if (event.pushedBackItems !== undefined) {
275
+ lines.push(`pushed_back_items: ${JSON.stringify(event.pushedBackItems)}`);
276
+ }
277
+ if (event.parkedItems !== undefined) {
278
+ lines.push(`parked_items: ${JSON.stringify(event.parkedItems)}`);
279
+ }
280
+ if (event.blockedReason !== undefined) {
281
+ lines.push(`blocked_reason: "${event.blockedReason.replace(/"/g, '\\"')}"`);
282
+ }
283
+ if (event.followUpReference !== undefined) {
284
+ lines.push(`followup_reference: ${event.followUpReference}`);
285
+ }
286
+
287
+ lines.push('---');
288
+ return lines.join('\n');
289
+ }
290
+
291
+ /**
292
+ * Build the workflow metadata footer for an event.
293
+ * Matches the existing pattern in review.js:793-797
294
+ *
295
+ * @param {string} slug - Mission slug
296
+ * @param {number} round - Review round
297
+ * @param {string} phase - Current phase
298
+ * @returns {string} - Metadata footer
299
+ */
300
+ function buildEventFooter(slug, round, phase) {
301
+ return `\n\n---\n\`[workflow-round:${round}, workflow-phase:${phase}]\``;
302
+ }
303
+
304
+ /**
305
+ * Check if a comment body contains the workflow metadata footer.
306
+ * Comments with this footer are workflow-generated; comments without are human.
307
+ *
308
+ * @param {string} body - Comment body text
309
+ * @returns {boolean} - true if comment has workflow footer
310
+ */
311
+ function hasWorkflowFooter(body) {
312
+ return /\`\[workflow-round:\d+, workflow-phase:[^\]]+\]\`/.test(body);
313
+ }
314
+
315
+ /**
316
+ * Classify a comment as either workflow-generated or human.
317
+ * Workflow-generated comments have the metadata footer; human comments do not.
318
+ *
319
+ * @param {Object} comment - Comment object with body field
320
+ * @returns {string|null} - 'human_note' if human, null if workflow-generated
321
+ */
322
+ function classifyComment(comment) {
323
+ const body = comment.body || '';
324
+ // Workflow-generated comments have the metadata footer
325
+ if (hasWorkflowFooter(body)) {
326
+ return null; // Workflow-generated, not a human note
327
+ }
328
+ // Human comments (no workflow footer) are classified as human_note
329
+ return VALID_EVENT_TYPES.HUMAN_NOTE;
330
+ }
331
+
332
+ /**
333
+ * Consume human notes from provider PR comments.
334
+ * Reads PR comments, classifies human (non-workflow) comments, and creates
335
+ * human_note events for them. Human comments do not have the workflow metadata footer.
336
+ *
337
+ * This function satisfies SC 6: Human provider comments are classified as human_note
338
+ * or ignored as non-round-affecting input by an explicit workflow rule; neither path
339
+ * can mutate review-state.json without a workflow command that records a repo-owned event.
340
+ *
341
+ * @param {string} slug - Mission slug
342
+ * @param {string} actor - Actor family (e.g., 'codex', 'mistral') or 'human'
343
+ * @param {Object} [options] - Options
344
+ * @param {Function} [options.getCommentsFn] - Function to get provider comments
345
+ * @param {Function} [options.createEventFn] - Function to create events (default: createEvent)
346
+ * @param {Function} [options.readTokenFn] - Function to read provider token
347
+ * @param {string} [options.worktree] - Working tree directory
348
+ * @param {Function} [options.log] - Log function
349
+ * @param {Function} [options.error] - Error function
350
+ * @returns {Object} - { ok: boolean, created: Array, skipped: Array }
351
+ */
352
+ async function consumeHumanNotes(slug, actor, options = {}) {
353
+ const {
354
+ getCommentsFn,
355
+ createEventFn = createEvent,
356
+ readTokenFn,
357
+ reviewIdentity = null,
358
+ forgejoUser = null,
359
+ worktree,
360
+ log = fmt.log.plain,
361
+ error = fmt.log.plainError
362
+ } = options;
363
+
364
+ const actorIdentity = reviewIdentity || forgejoUser;
365
+ if (!actorIdentity) {
366
+ error(fmt.status('FAIL', 'Review identity is required for consumeHumanNotes.'));
367
+ return { ok: false, created: [], skipped: [], error: 'reviewIdentity required' };
368
+ }
369
+
370
+ // In sandbox/Codex environments, the provider may be unreachable from Node subprocesses.
371
+ // This is acceptable - human note classification is best-effort
372
+ if (!getCommentsFn) {
373
+ log(fmt.status('INFO', 'consumeHumanNotes: no getCommentsFn provided; skipping human note classification.'));
374
+ return { ok: true, created: [], skipped: [] };
375
+ }
376
+
377
+ const token = readTokenFn ? readTokenFn(actorIdentity) : null;
378
+ if (!token) {
379
+ log(fmt.status('INFO', 'consumeHumanNotes: no provider token found; skipping human note classification.'));
380
+ return { ok: true, created: [], skipped: [] };
381
+ }
382
+
383
+ const branch = missionBranchName(slug, worktree || process.cwd());
384
+ const comments = await getCommentsFn(branch, token);
385
+
386
+ if (!comments || !Array.isArray(comments)) {
387
+ log(fmt.status('INFO', 'consumeHumanNotes: no comments retrieved; skipping classification.'));
388
+ return { ok: true, created: [], skipped: [] };
389
+ }
390
+
391
+ const created = [];
392
+ const skipped = [];
393
+
394
+ // Get current review state for round/phase metadata
395
+ const currentState = readReviewState(slug, worktree || process.cwd());
396
+ const round = currentState ? currentState.round : 1;
397
+ const phase = currentState ? currentState.phase : 'reviewing';
398
+
399
+ for (const comment of comments) {
400
+ // Skip workflow-generated comments (have the metadata footer)
401
+ if (hasWorkflowFooter(comment.body || '')) {
402
+ skipped.push({ user: comment.user, created: comment.created, reason: 'workflow-generated' });
403
+ continue;
404
+ }
405
+
406
+ // Classify as human_note
407
+ const classification = classifyComment(comment);
408
+ if (!classification) {
409
+ skipped.push({ user: comment.user, created: comment.created, reason: 'already-classified' });
410
+ continue;
411
+ }
412
+
413
+ // Create human_note event
414
+ const result = createEventFn(slug, classification, {
415
+ content: comment.body || '',
416
+ round,
417
+ phase,
418
+ actor: actor || comment.user || 'human'
419
+ }, {
420
+ worktree: worktree || process.cwd(),
421
+ skipGit: true,
422
+ log,
423
+ error
424
+ });
425
+
426
+ if (result.ok) {
427
+ created.push({ path: result.path, user: comment.user, created: comment.created });
428
+ } else {
429
+ error(fmt.status('WARN', `Failed to create human_note event for comment by ${comment.user}: ${result.error}`));
430
+ }
431
+ }
432
+
433
+ log(fmt.status('INFO', `consumeHumanNotes: created ${created.length} human_note events, skipped ${skipped.length} workflow comments`));
434
+ return { ok: true, created, skipped };
435
+ }
436
+
437
+ /**
438
+ * Render a complete event file content.
439
+ *
440
+ * @param {Object} event - Normalized event structure
441
+ * @returns {string} - Complete file content
442
+ */
443
+ function renderEventFile(event) {
444
+ const frontmatter = buildEventFrontmatter(event);
445
+ const content = event.content || '';
446
+
447
+ // If the content already has a metadata footer, don't add another
448
+ const hasFooter = /\n\n---\n`\[workflow-round:\d+, workflow-phase:[^\]]+\]`/.test(content);
449
+
450
+ if (hasFooter) {
451
+ return `${frontmatter}\n\n${content}`;
452
+ }
453
+
454
+ // Add footer if we have round and phase
455
+ const footer = event.round !== undefined && event.phase !== undefined
456
+ ? buildEventFooter(event.slug, event.round, event.phase)
457
+ : '';
458
+
459
+ return `${frontmatter}\n\n${content}${footer}`;
460
+ }
461
+
462
+ // -------- Event Creation --------
463
+
464
+ /**
465
+ * Create and persist a classified review event.
466
+ *
467
+ * @param {string} slug - Mission slug
468
+ * @param {string} eventType - One of VALID_EVENT_TYPES
469
+ * @param {Object} params - Event parameters
470
+ * @param {string} params.content - The event content (markdown)
471
+ * @param {number} [params.round] - Review round number (from state)
472
+ * @param {string} [params.phase] - Current phase (from state)
473
+ * @param {string} [params.actor] - Agent family or 'human'
474
+ * @param {string} [params.disposition] - For implementer disposition events
475
+ * @param {string} [params.verdict] - For reviewer outcome events
476
+ * @param {Array} [params.fixedItems] - For implementer round summary
477
+ * @param {Array} [params.pushedBackItems] - For implementer round summary
478
+ * @param {Array} [params.parkedItems] - For implementer round summary
479
+ * @param {string} [params.blockedReason] - For BLOCKED events
480
+ * @param {string} [params.followUpReference] - For PARKED_FOLLOWUP events
481
+ * @param {string} [params.timestamp] - Custom timestamp
482
+ * @param {string} [rootDir] - Root directory
483
+ * @param {Object} [options] - Additional options
484
+ * @param {boolean} [options.skipGit] - Skip git add/commit
485
+ * @param {Function} [options.gitFn] - Git function override for testing
486
+ * @returns {Object} - { ok: boolean, path: string|null, error: string|null }
487
+ */
488
+ function createEvent(slug, eventType, params, options = {}) {
489
+ const {
490
+ skipGit = false,
491
+ gitFn = git,
492
+ log = fmt.log.plain,
493
+ error = fmt.log.plainError,
494
+ worktree,
495
+ allowMissingRequiredFields = false
496
+ } = options;
497
+
498
+ const rootDir = worktree || resolveWorktree(slug) || process.cwd();
499
+
500
+ // Validate event type
501
+ if (!isValidEventType(eventType)) {
502
+ error(fmt.status('FAIL', `Invalid event type "${eventType}". Valid types: ${ALL_EVENT_TYPES.join(', ')}`));
503
+ return { ok: false, path: null, error: `Invalid event type: ${eventType}` };
504
+ }
505
+
506
+ // Validate disposition if provided
507
+ if (params.disposition !== undefined && !isValidDisposition(params.disposition)) {
508
+ error(fmt.status('FAIL', `Invalid disposition "${params.disposition}". Valid: ${VALID_DISPOSITIONS.join(', ')}`));
509
+ return { ok: false, path: null, error: `Invalid disposition: ${params.disposition}` };
510
+ }
511
+
512
+ // Validate verdict if provided
513
+ if (params.verdict !== undefined && !isValidVerdict(params.verdict)) {
514
+ error(fmt.status('FAIL', `Invalid verdict "${params.verdict}". Valid: ${VALID_VERDICTS.join(', ')}`));
515
+ return { ok: false, path: null, error: `Invalid verdict: ${params.verdict}` };
516
+ }
517
+
518
+ // Validate required fields for specific event types
519
+ // Skip validation for legacy imports which may not have all required fields
520
+ if (!allowMissingRequiredFields) {
521
+ if (eventType === VALID_EVENT_TYPES.REVIEWER_OUTCOME && !params.verdict) {
522
+ error(fmt.status('FAIL', `reviewer_outcome event requires --verdict`));
523
+ return { ok: false, path: null, error: 'reviewer_outcome event requires verdict' };
524
+ }
525
+ if (eventType === VALID_EVENT_TYPES.IMPLEMENTER_DISPOSITION && !params.disposition) {
526
+ error(fmt.status('FAIL', `implementer_disposition event requires --disposition`));
527
+ return { ok: false, path: null, error: 'implementer_disposition event requires disposition' };
528
+ }
529
+ }
530
+
531
+ // Get current review state for round/phase if not provided
532
+ let state;
533
+ if (params.round === undefined || params.phase === undefined) {
534
+ state = readReviewState(slug, rootDir);
535
+ }
536
+
537
+ const round = params.round !== undefined ? params.round : (state ? state.round : 1);
538
+ const phase = params.phase !== undefined ? params.phase : (state ? state.phase : 'reviewing');
539
+ const actor = params.actor || 'unknown';
540
+
541
+ // Build event structure
542
+ const event = normalizeEventContent(params.content || '', eventType, {
543
+ slug,
544
+ round,
545
+ phase,
546
+ actor,
547
+ eventType,
548
+ timestamp: params.timestamp,
549
+ disposition: params.disposition,
550
+ verdict: params.verdict,
551
+ fixedItems: params.fixedItems,
552
+ pushedBackItems: params.pushedBackItems,
553
+ parkedItems: params.parkedItems,
554
+ blockedReason: params.blockedReason,
555
+ followUpReference: params.followUpReference
556
+ });
557
+
558
+ // Determine file path
559
+ const filePath = eventFilePath(slug, eventType, round, actor, params.timestamp, rootDir);
560
+ if (!filePath) {
561
+ error(fmt.status('FAIL', `Cannot resolve mission directory for slug "${slug}"`));
562
+ return { ok: false, path: null, error: `Mission directory not found` };
563
+ }
564
+
565
+ // Ensure parent directory exists
566
+ const dir = path.dirname(filePath);
567
+ if (!fs.existsSync(dir)) {
568
+ fs.mkdirSync(dir, { recursive: true });
569
+ }
570
+
571
+ // Render and write
572
+ const content = renderEventFile(event);
573
+ fs.writeFileSync(filePath, content, 'utf8');
574
+
575
+ log(fmt.status('PASS', `Created review event: ${path.basename(filePath)}`));
576
+
577
+ // Git add and commit
578
+ if (!skipGit) {
579
+ const relPath = path.relative(rootDir, filePath);
580
+ const result = gitFn(['-C', rootDir, 'add', relPath]);
581
+ if (result.status !== 0) {
582
+ error(fmt.status('WARN', `Failed to git add event file: ${result.stderr}`));
583
+ } else {
584
+ const commitMsg = `review-event(${slug}): ${eventType} round ${round} (${phase}) [${actor}]`;
585
+ const commitResult = gitFn(['-C', rootDir, 'commit', '-m', commitMsg, '--allow-empty']);
586
+ if (commitResult.status !== 0) {
587
+ error(fmt.status('WARN', `Failed to commit event file: ${commitResult.stderr}`));
588
+ } else {
589
+ log(fmt.status('PASS', `Committed review event to git`));
590
+ }
591
+ }
592
+ }
593
+
594
+ return { ok: true, path: filePath, event };
595
+ }
596
+
597
+ /**
598
+ * Import an event from a legacy /tmp/ artifact file.
599
+ * Normalizes the content and stores it in the repo-owned mission store.
600
+ *
601
+ * @param {string} slug - Mission slug
602
+ * @param {string} artifactName - e.g., 'review-findings.md', 'round-resolution.md'
603
+ * @param {string} eventType - The classification to assign
604
+ * @param {Object} [params] - Additional parameters (round, phase, actor, etc.)
605
+ * @param {Object} [options] - Options (same as createEvent)
606
+ * @returns {Object} - { ok: boolean, path: string|null, importedFrom: string|null, error: string|null }
607
+ */
608
+ function importLegacyArtifact(slug, artifactName, eventType, params = {}, options = {}) {
609
+ const {
610
+ tmpDir = os.tmpdir(),
611
+ log = fmt.log.plain,
612
+ error = fmt.log.plainError,
613
+ worktree
614
+ } = options;
615
+
616
+ const rootDir = worktree || resolveWorktree(slug) || process.cwd();
617
+ const legacyPath = legacyArtifactPath(slug, artifactName, tmpDir);
618
+
619
+ // Check if legacy file exists
620
+ if (!fs.existsSync(legacyPath)) {
621
+ log(fmt.status('INFO', `Legacy artifact not found: ${legacyPath}`));
622
+ return { ok: true, path: null, importedFrom: legacyPath, skipped: true };
623
+ }
624
+
625
+ // Read legacy content
626
+ let content;
627
+ try {
628
+ content = fs.readFileSync(legacyPath, 'utf8');
629
+ } catch (err) {
630
+ error(fmt.status('FAIL', `Failed to read legacy artifact: ${err.message}`));
631
+ return { ok: false, path: null, importedFrom: legacyPath, error: err.message };
632
+ }
633
+
634
+ // Create the normalized event
635
+ // Allow missing required fields for legacy imports (they may not have verdict/disposition)
636
+ const result = createEvent(slug, eventType, {
637
+ content,
638
+ ...params,
639
+ // Default actor from artifact name
640
+ actor: params.actor || inferActorFromArtifact(artifactName)
641
+ }, { ...options, worktree: rootDir, allowMissingRequiredFields: true });
642
+
643
+ if (!result.ok) {
644
+ return { ok: false, path: null, importedFrom: legacyPath, error: result.error };
645
+ }
646
+
647
+ log(fmt.status('PASS', `Imported legacy artifact ${artifactName} to ${path.basename(result.path)}`));
648
+
649
+ return {
650
+ ok: true,
651
+ path: result.path,
652
+ importedFrom: legacyPath,
653
+ event: result.event,
654
+ legacyPath
655
+ };
656
+ }
657
+
658
+ /**
659
+ * Infer actor from legacy artifact name (best guess).
660
+ * In practice, the actor should be passed explicitly from the workflow context.
661
+ */
662
+ function inferActorFromArtifact(artifactName) {
663
+ // Can't reliably infer from artifact name alone
664
+ return 'unknown';
665
+ }
666
+
667
+ /**
668
+ * Map legacy artifact names to event types.
669
+ */
670
+ const LEGACY_ARTIFACT_TO_EVENT_TYPE = Object.freeze({
671
+ 'review-findings.md': VALID_EVENT_TYPES.REVIEWER_FINDINGS,
672
+ 'review-outcome.md': VALID_EVENT_TYPES.REVIEWER_OUTCOME,
673
+ 'review-verdict.txt': VALID_EVENT_TYPES.REVIEWER_OUTCOME,
674
+ 'round-resolution.md': VALID_EVENT_TYPES.IMPLEMENTER_ROUND_SUMMARY,
675
+ 'review-disposition.txt': VALID_EVENT_TYPES.IMPLEMENTER_DISPOSITION,
676
+ });
677
+
678
+ /**
679
+ * Import all legacy /tmp/ artifacts for a mission.
680
+ * Used during the transition to repo-owned storage.
681
+ *
682
+ * Special handling: review-verdict.txt is treated as metadata (verdict field)
683
+ * for review-outcome.md, not as a standalone reviewer_outcome event.
684
+ * This prevents duplicate reviewer_outcome events.
685
+ *
686
+ * @param {string} slug - Mission slug
687
+ * @param {Object} [options] - Options
688
+ * @returns {Object} - { ok: boolean, imported: Array, errors: Array }
689
+ */
690
+ function importAllLegacyArtifacts(slug, options = {}) {
691
+ const {
692
+ tmpDir = os.tmpdir(),
693
+ log = fmt.log.plain,
694
+ error = fmt.log.plainError,
695
+ worktree
696
+ } = options;
697
+
698
+ const rootDir = worktree || resolveWorktree(slug) || process.cwd();
699
+ const imported = [];
700
+ const errors = [];
701
+
702
+ // Get current review state for round/phase
703
+ const state = readReviewState(slug, rootDir);
704
+ const round = state ? state.round : 1;
705
+ const phase = state ? state.phase : 'reviewing';
706
+
707
+ // Track which legacy files exist
708
+ const legacyFiles = {};
709
+ for (const artifactName of Object.keys(LEGACY_ARTIFACT_TO_EVENT_TYPE)) {
710
+ const legacyPath = legacyArtifactPath(slug, artifactName, tmpDir);
711
+ legacyFiles[artifactName] = fs.existsSync(legacyPath) ? legacyPath : null;
712
+ }
713
+
714
+ // Special case: handle review-outcome.md and review-verdict.txt together
715
+ // review-verdict.txt contains the verdict metadata for review-outcome.md
716
+ const outcomePath = legacyFiles['review-outcome.md'];
717
+ const verdictPath = legacyFiles['review-verdict.txt'];
718
+
719
+ if (outcomePath && fs.existsSync(outcomePath)) {
720
+ // Read verdict content if it exists
721
+ let verdictContent = null;
722
+ if (verdictPath) {
723
+ try {
724
+ if (fs.existsSync(verdictPath)) {
725
+ verdictContent = fs.readFileSync(verdictPath, 'utf8').trim();
726
+ }
727
+ } catch (err) {
728
+ error(fmt.status('WARN', `Failed to read verdict file: ${err.message}`));
729
+ }
730
+ }
731
+
732
+ // Import review-outcome.md with verdict as metadata (only include verdict if valid)
733
+ const outcomeParams = { round, phase, actor: 'legacy' };
734
+ if (verdictContent && isValidVerdict(verdictContent)) {
735
+ outcomeParams.verdict = verdictContent;
736
+ }
737
+
738
+ const result = importLegacyArtifact(slug, 'review-outcome.md', VALID_EVENT_TYPES.REVIEWER_OUTCOME,
739
+ outcomeParams,
740
+ { ...options, worktree: rootDir });
741
+
742
+ if (result.ok) {
743
+ if (result.path) {
744
+ imported.push({ artifactName: 'review-outcome.md', eventType: VALID_EVENT_TYPES.REVIEWER_OUTCOME, path: result.path, legacyPath: result.legacyPath });
745
+ // Mark review-verdict.txt as imported (as metadata) even though it wasn't a separate event
746
+ if (verdictPath && fs.existsSync(verdictPath)) {
747
+ imported.push({ artifactName: 'review-verdict.txt', eventType: VALID_EVENT_TYPES.REVIEWER_OUTCOME, path: result.path, legacyPath: verdictPath, asMetadata: true });
748
+ }
749
+ }
750
+ } else {
751
+ errors.push({ artifactName: 'review-outcome.md', eventType: VALID_EVENT_TYPES.REVIEWER_OUTCOME, error: result.error });
752
+ // If outcome import fails and verdict exists separately, still try to import it
753
+ if (verdictPath && fs.existsSync(verdictPath)) {
754
+ const verdictResult = importLegacyArtifact(slug, 'review-verdict.txt', VALID_EVENT_TYPES.REVIEWER_OUTCOME,
755
+ { round, phase, actor: 'legacy' },
756
+ { ...options, worktree: rootDir });
757
+ if (verdictResult.ok && verdictResult.path) {
758
+ imported.push({ artifactName: 'review-verdict.txt', eventType: VALID_EVENT_TYPES.REVIEWER_OUTCOME, path: verdictResult.path, legacyPath: verdictResult.legacyPath });
759
+ } else if (!verdictResult.ok) {
760
+ errors.push({ artifactName: 'review-verdict.txt', eventType: VALID_EVENT_TYPES.REVIEWER_OUTCOME, error: verdictResult.error });
761
+ }
762
+ }
763
+ }
764
+ } else if (verdictPath && fs.existsSync(verdictPath)) {
765
+ // Only review-verdict.txt exists (no review-outcome.md) - import it as a standalone event
766
+ // This is a fallback for incomplete artifact sets
767
+ // Read the verdict content from the file to use as the verdict param
768
+ let verdictContent = null;
769
+ try {
770
+ verdictContent = fs.readFileSync(verdictPath, 'utf8').trim();
771
+ } catch (err) {
772
+ error(fmt.status('WARN', `Failed to read verdict file: ${err.message}`));
773
+ }
774
+
775
+ const outcomeParams = { round, phase, actor: 'legacy' };
776
+ if (verdictContent && isValidVerdict(verdictContent)) {
777
+ outcomeParams.verdict = verdictContent;
778
+ }
779
+
780
+ const result = importLegacyArtifact(slug, 'review-verdict.txt', VALID_EVENT_TYPES.REVIEWER_OUTCOME,
781
+ outcomeParams,
782
+ { ...options, worktree: rootDir });
783
+
784
+ if (result.ok) {
785
+ if (result.path) {
786
+ imported.push({ artifactName: 'review-verdict.txt', eventType: VALID_EVENT_TYPES.REVIEWER_OUTCOME, path: result.path, legacyPath: result.legacyPath });
787
+ }
788
+ } else {
789
+ errors.push({ artifactName: 'review-verdict.txt', eventType: VALID_EVENT_TYPES.REVIEWER_OUTCOME, error: result.error });
790
+ }
791
+ }
792
+
793
+ // Import all other artifacts normally
794
+ for (const artifactName of Object.keys(LEGACY_ARTIFACT_TO_EVENT_TYPE)) {
795
+ // Skip review-outcome.md and review-verdict.txt as they were handled above
796
+ if (artifactName === 'review-outcome.md' || artifactName === 'review-verdict.txt') {
797
+ continue;
798
+ }
799
+
800
+ const result = importLegacyArtifact(slug, artifactName, LEGACY_ARTIFACT_TO_EVENT_TYPE[artifactName],
801
+ { round, phase, actor: 'legacy' },
802
+ { ...options, worktree: rootDir });
803
+
804
+ if (result.ok) {
805
+ if (result.path) {
806
+ imported.push({ artifactName, eventType: LEGACY_ARTIFACT_TO_EVENT_TYPE[artifactName], path: result.path, legacyPath: result.legacyPath });
807
+ }
808
+ } else {
809
+ errors.push({ artifactName, eventType: LEGACY_ARTIFACT_TO_EVENT_TYPE[artifactName], error: result.error });
810
+ }
811
+ }
812
+
813
+ return { ok: errors.length === 0, imported, errors };
814
+ }
815
+
816
+ // -------- Event Reading --------
817
+
818
+ /**
819
+ * Read all review events for a mission.
820
+ *
821
+ * @param {string} slug - Mission slug
822
+ * @param {Object} [options] - Options
823
+ * @param {string} [options.rootDir] - Root directory
824
+ * @param {Function} [options.readdirSync] - readdirSync override for testing
825
+ * @param {Function} [options.readFileSync] - readFileSync override for testing
826
+ * @returns {Array} - Array of parsed event objects
827
+ */
828
+ function readAllEvents(slug, options = {}) {
829
+ const {
830
+ rootDir = process.cwd(),
831
+ readdirSync = fs.readdirSync,
832
+ readFileSync = fs.readFileSync,
833
+ log = fmt.log.plain,
834
+ error = fmt.log.plainError
835
+ } = options;
836
+
837
+ const eventsDir = reviewEventsDir(slug, rootDir);
838
+ if (!eventsDir || !fs.existsSync(eventsDir)) {
839
+ return [];
840
+ }
841
+
842
+ const events = [];
843
+ let files;
844
+
845
+ try {
846
+ files = readdirSync(eventsDir);
847
+ } catch (err) {
848
+ error(fmt.status('WARN', `Failed to read review-events directory: ${err.message}`));
849
+ return [];
850
+ }
851
+
852
+ for (const filename of files) {
853
+ if (!filename.endsWith('.md')) continue;
854
+
855
+ const filePath = path.join(eventsDir, filename);
856
+ let content;
857
+
858
+ try {
859
+ content = readFileSync(filePath, 'utf8');
860
+ } catch (err) {
861
+ error(fmt.status('WARN', `Failed to read event file ${filename}: ${err.message}`));
862
+ continue;
863
+ }
864
+
865
+ const event = parseEventFile(content, filePath);
866
+ if (event) {
867
+ events.push(event);
868
+ }
869
+ }
870
+
871
+ // Sort by timestamp descending (newest first)
872
+ events.sort((a, b) => {
873
+ const aTime = a.timestamp || a.fileCreated || '';
874
+ const bTime = b.timestamp || b.fileCreated || '';
875
+ return bTime.localeCompare(aTime);
876
+ });
877
+
878
+ return events;
879
+ }
880
+
881
+ /**
882
+ * Parse an event file into a structured object.
883
+ * Extracts frontmatter metadata and content body.
884
+ *
885
+ * @param {string} content - File content
886
+ * @param {string} filePath - File path (for fallback metadata)
887
+ * @returns {Object|null} - Parsed event or null
888
+ */
889
+ function parseEventFile(content, filePath) {
890
+ // Try to parse frontmatter
891
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
892
+
893
+ const metadata = {};
894
+ let body = content;
895
+
896
+ if (frontmatterMatch) {
897
+ const frontmatterText = frontmatterMatch[1];
898
+ body = content.slice(frontmatterMatch[0].length);
899
+
900
+ // Parse YAML-like frontmatter
901
+ const lines = frontmatterText.split('\n');
902
+ for (const line of lines) {
903
+ if (!line.trim()) continue;
904
+ const colonIdx = line.indexOf(':');
905
+ if (colonIdx === -1) continue;
906
+
907
+ const key = line.slice(0, colonIdx).trim().toLowerCase();
908
+ const value = line.slice(colonIdx + 1).trim();
909
+
910
+ // Handle quoted strings
911
+ if ((value.startsWith('"') && value.endsWith('"')) ||
912
+ (value.startsWith("'") && value.endsWith("'"))) {
913
+ metadata[key] = value.slice(1, -1);
914
+ }
915
+ // Handle JSON arrays
916
+ else if (value.startsWith('[') && value.endsWith(']')) {
917
+ try {
918
+ metadata[key] = JSON.parse(value);
919
+ } catch (_) {
920
+ metadata[key] = value;
921
+ }
922
+ }
923
+ // Handle booleans and numbers
924
+ else if (value === 'true') metadata[key] = true;
925
+ else if (value === 'false') metadata[key] = false;
926
+ else if (!isNaN(value)) metadata[key] = Number(value);
927
+ else metadata[key] = value;
928
+ }
929
+ }
930
+
931
+ // Extract from filename if frontmatter missing
932
+ if (!metadata.event_type && filePath) {
933
+ const basename = path.basename(filePath, '.md');
934
+ // Pattern: <timestamp>-<eventType>-<round>-<actor>
935
+ const parts = basename.split('-');
936
+ if (parts.length >= 4) {
937
+ // eventType is typically the second part after timestamp
938
+ const possibleType = parts.slice(1, -2).join('-');
939
+ if (isValidEventType(possibleType)) {
940
+ metadata.event_type = possibleType;
941
+ }
942
+ }
943
+ }
944
+
945
+ // Add file metadata
946
+ const stat = fs.statSync(filePath);
947
+
948
+ return {
949
+ ...metadata,
950
+ content: body.trim(),
951
+ filePath,
952
+ fileCreated: stat.birthtime.toISOString(),
953
+ fileModified: stat.mtime.toISOString()
954
+ };
955
+ }
956
+
957
+ const shouldMirrorToForgejo = shouldMirrorToProvider;
958
+
959
+ // -------- Module Exports --------
960
+
961
+ module.exports = {
962
+ // Taxonomy constants
963
+ VALID_EVENT_TYPES,
964
+ ALL_EVENT_TYPES,
965
+ MIRRORED_EVENT_TYPES,
966
+ VALID_DISPOSITIONS,
967
+ VALID_VERDICTS,
968
+
969
+ // Validation
970
+ isValidEventType,
971
+ isValidDisposition,
972
+ isValidVerdict,
973
+ shouldMirrorToProvider,
974
+ shouldMirrorToForgejo,
975
+
976
+ // Path resolution
977
+ reviewEventsDir,
978
+ eventFilePath,
979
+ legacyArtifactPath,
980
+
981
+ // Event creation
982
+ createEvent,
983
+ normalizeEventContent,
984
+
985
+ // Legacy compatibility
986
+ importLegacyArtifact,
987
+ importAllLegacyArtifacts,
988
+ LEGACY_ARTIFACT_TO_EVENT_TYPE,
989
+
990
+ // Event reading
991
+ readAllEvents,
992
+ parseEventFile,
993
+
994
+ // Rendering
995
+ buildEventFrontmatter,
996
+ buildEventFooter,
997
+ renderEventFile,
998
+
999
+ // Classification
1000
+ hasWorkflowFooter,
1001
+ classifyComment,
1002
+ consumeHumanNotes,
1003
+
1004
+ // Utilities
1005
+ generateEventTimestamp,
1006
+ sanitizeFilename,
1007
+ };