@magnusekdahl/parallix 1.2.1 → 1.3.1

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