@magnusekdahl/parallix 1.2.1 → 1.3.0

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