@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
@@ -0,0 +1,622 @@
1
+ /**
2
+ * Review Artifacts Module - Utility Functions
3
+ * Extracted from parallix/lib/review.js for task-1201
4
+ * Handles artifact path resolution, file I/O, and normalization.
5
+ */
6
+
7
+ import * as fs from 'fs';
8
+ import * as os from 'os';
9
+ import * as path from 'path';
10
+ import * as fmt from '../core/fmt.js';
11
+ import { missionBranchName, resolveWorktree } from '../core/mission-utils.js';
12
+ import { readReviewState, writeReviewState, reviewStateFile, ReviewState, resolveReviewIdentity } from './review-state.js';
13
+ import { readToken, postComment, postReview, getPrAuthor, isEnabled, resolveArtifactDir as resolveConfiguredArtifactDir } from './review-adapter.js';
14
+ import { createEvent, consumeHumanNotes, VALID_EVENT_TYPES, CreateEventParams, CreateEventOptions, CreateEventResult } from './review-events.js';
15
+
16
+ type CreateResult = { ok: boolean; path: string | null; error?: string | null; event?: Record<string, unknown> };
17
+
18
+ // ============================================================================
19
+ // Metadata Footer
20
+ // ============================================================================
21
+
22
+ function buildMetadataFooter(slug: string, rootDir = process.cwd()): string {
23
+ const state = readReviewState(slug, rootDir);
24
+ if (!state) { return ''; }
25
+ return `\n\n---\n\`[workflow-round:${state.round}, workflow-phase:${state.phase}]\``;
26
+ }
27
+
28
+ // ============================================================================
29
+ // Artifact Path Utilities
30
+ // ============================================================================
31
+
32
+ function reviewArtifactPath(slug: string, artifactName: string, tmpDir = os.tmpdir()): string {
33
+ return path.join(tmpDir, `${slug}-${artifactName}`);
34
+ }
35
+
36
+ /**
37
+ * Resolve artifact read path with optional /tmp fallback.
38
+ */
39
+ function resolveArtifactRead(
40
+ slug: string,
41
+ artifactName: string,
42
+ opts: { tmpDir: string; explicitTmpDir?: boolean; fallbackToTmp?: boolean; readArtifactFn: typeof readArtifactFile }
43
+ ): { path: string; value: string | null } {
44
+ const { tmpDir, explicitTmpDir, fallbackToTmp, readArtifactFn } = opts;
45
+ const primaryPath = reviewArtifactPath(slug, artifactName, tmpDir);
46
+ const primaryValue = readArtifactFn(primaryPath);
47
+ const allowTmpFallback = (!explicitTmpDir || fallbackToTmp) && tmpDir !== '/tmp';
48
+ if (primaryValue !== null || !allowTmpFallback) {
49
+ return { path: primaryPath, value: primaryValue };
50
+ }
51
+ const fallbackPath = reviewArtifactPath(slug, artifactName, '/tmp');
52
+ const fallbackValue = readArtifactFn(fallbackPath);
53
+ if (fallbackValue !== null) {
54
+ return { path: fallbackPath, value: fallbackValue };
55
+ }
56
+ return { path: primaryPath, value: null };
57
+ }
58
+
59
+ /**
60
+ * Resolve the directory where review artifacts are written and consumed.
61
+ */
62
+ function resolveArtifactDir(rootDir = process.cwd()): string {
63
+ return resolveConfiguredArtifactDir(rootDir);
64
+ }
65
+
66
+ function readArtifactFile(filePath: string, readFileSync = fs.readFileSync): string | null {
67
+ try {
68
+ const value = readFileSync(filePath, 'utf8');
69
+ return typeof value === 'string' ? value.trim() : '';
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ function deleteArtifactFile(filePath: string, unlinkSync = fs.unlinkSync): void {
76
+ try {
77
+ unlinkSync(filePath);
78
+ } catch {
79
+ // Best-effort cleanup only.
80
+ }
81
+ }
82
+
83
+ // ============================================================================
84
+ // Normalization Utilities
85
+ // ============================================================================
86
+
87
+ function normalizeReviewVerdict(value: string): string | null {
88
+ const normalized = String(value || '').trim().toLowerCase();
89
+ return ['approve', 'request-changes', 'comment'].includes(normalized) ? normalized : null;
90
+ }
91
+
92
+ function normalizeDisposition(value: string): string | null {
93
+ const normalized = String(value || '').trim().toUpperCase();
94
+ return ['CHANGES_MADE', 'PUSHBACK_ALL', 'PARKED', 'BLOCKED'].includes(normalized) ? normalized : null;
95
+ }
96
+
97
+ // ============================================================================
98
+ // Workflow Comment/Review Posting
99
+ // ============================================================================
100
+
101
+ function postWorkflowComment(
102
+ slug: string,
103
+ message: string,
104
+ options: {
105
+ log?: (msg: string) => void;
106
+ error?: (msg: string) => void;
107
+ readTokenFn?: (user: string, opts: { rootDir?: string }) => string | null;
108
+ postCommentFn?: (branch: string, token: string, body: string, opts?: Record<string, unknown>) => unknown;
109
+ buildMetadataFooterFn?: (s: string, r?: string) => string;
110
+ readReviewStateFn?: (s: string, r?: string) => any;
111
+ rootDir?: string;
112
+ reviewIdentity?: string;
113
+ forgejoUser?: string;
114
+ } = {}
115
+ ): { ok: boolean; error?: string } {
116
+ const log = options.log || fmt.log.plain;
117
+ const error = options.error || fmt.log.plainError;
118
+ const readTokenFn = options.readTokenFn || readToken;
119
+ const postCommentFn = options.postCommentFn || postComment;
120
+ const buildMetadataFooterFn = options.buildMetadataFooterFn || buildMetadataFooter;
121
+ const readReviewStateFn = options.readReviewStateFn || readReviewState;
122
+ const rootDir = options.rootDir || resolveWorktree(slug) || process.cwd();
123
+ const branch = missionBranchName(slug, rootDir);
124
+ const reviewIdentity = options.reviewIdentity
125
+ || options.forgejoUser
126
+ || resolveReviewIdentity(slug, rootDir, { readReviewStateFn }).commentIdentityUser
127
+ || 'human';
128
+ const token = readTokenFn(reviewIdentity, { rootDir });
129
+ if (!token) {
130
+ error(fmt.status('FAIL', `No Forgejo token found for user "${reviewIdentity}". Cannot post comment.`));
131
+ return { ok: false };
132
+ }
133
+
134
+ const taggedMessage = message + buildMetadataFooterFn(slug, rootDir);
135
+ log(fmt.status('INFO', `Posting PR comment on ${branch} as ${reviewIdentity}...`));
136
+ const result = postCommentFn(branch, token, taggedMessage, { forgejoUser: reviewIdentity, reviewIdentity });
137
+ const r = result as Record<string, unknown>;
138
+ if (!r.ok) {
139
+ error(fmt.status('FAIL', `Could not post comment: ${(r.error as string) || 'API error'}`));
140
+ return { ok: false, error: (r.error as string) || 'API error' };
141
+ }
142
+
143
+ log(fmt.status('PASS', `Comment posted on PR for ${branch}.`));
144
+ return { ok: true };
145
+ }
146
+
147
+ /** @param {*} result */
148
+ function describeProviderFailure(result: unknown): string {
149
+ if (!result || typeof result !== 'object') { return 'API error'; }
150
+ const r = result as Record<string, unknown>;
151
+ const httpStatus = (r.statusCode !== null && Number(r.statusCode) > 0)
152
+ ? r.statusCode
153
+ : (r.status !== null && Number(r.status) >= 100 ? r.status : null);
154
+ let body: string | null = null;
155
+ if (r.data && typeof r.data === 'object') {
156
+ body = (r.data as { message?: string }).message || JSON.stringify(r.data);
157
+ } else if (typeof r.data === 'string' && r.data) {
158
+ body = r.data as string;
159
+ }
160
+ const parts: string[] = [];
161
+ if (httpStatus !== null) { parts.push(String(httpStatus)); }
162
+ if (body) { parts.push(body); }
163
+ if (parts.length) { return parts.join(' '); }
164
+ return (r.error as string) || (r.raw as string) || 'API error';
165
+ }
166
+
167
+ /**
168
+ * Record a review verdict in local review-state.json / review-events.
169
+ */
170
+ function recordLocalReviewVerdict(
171
+ slug: string,
172
+ outcome: string,
173
+ options: {
174
+ worktree?: string;
175
+ writeReviewStateFn?: (s: string, st: any, w: string) => boolean;
176
+ createEventFn?: (s: string, t: string, p: Record<string, unknown>, o: Record<string, unknown>) => CreateResult;
177
+ readReviewStateFn?: (s: string, r?: string) => any;
178
+ log?: (msg: string) => void;
179
+ error?: (msg: string) => void;
180
+ reviewer?: string;
181
+ } = {}
182
+ ): void {
183
+ const worktree = options.worktree || resolveWorktree(slug) || process.cwd();
184
+ const writeReviewStateFn = options.writeReviewStateFn || writeReviewState;
185
+ const createEventFn = options.createEventFn || createEvent;
186
+ const readReviewStateFn = options.readReviewStateFn || readReviewState;
187
+ const log = options.log || fmt.log.plain;
188
+ const error = options.error || fmt.log.plainError;
189
+
190
+ const existing = readReviewStateFn(slug, worktree);
191
+ const state = existing instanceof ReviewState
192
+ ? existing
193
+ : new ReviewState(slug, existing || {
194
+ reviewer: options.reviewer,
195
+ implementer: options.reviewer,
196
+ round: 1,
197
+ phase: 'reviewing',
198
+ });
199
+ if (outcome === 'approve') {
200
+ state.disposition = 'APPROVED';
201
+ try { state.transitionTo('approved'); } catch { /* ignore */ }
202
+ } else if (outcome === 'request-changes') {
203
+ state.disposition = 'REQUEST_CHANGES';
204
+ try { state.transitionTo('fixing'); } catch { /* ignore */ }
205
+ }
206
+ writeReviewStateFn(slug, state, worktree);
207
+
208
+ createEventFn(slug, VALID_EVENT_TYPES.REVIEWER_OUTCOME, { verdict: outcome, content: `Review verdict: ${outcome}` }, { worktree, log: log, error });
209
+ }
210
+
211
+ /**
212
+ * Submit a review outcome to the provider.
213
+ */
214
+ function postWorkflowReview(
215
+ slug: string,
216
+ outcome: string,
217
+ message: string,
218
+ options: {
219
+ log?: (msg: string) => void;
220
+ error?: (msg: string) => void;
221
+ readTokenFn?: (user: string, opts: { rootDir?: string }) => string | null;
222
+ postReviewFn?: (branch: string, token: string, outcome: string, body: string, opts?: Record<string, unknown>) => unknown;
223
+ buildMetadataFooterFn?: (s: string, r?: string) => string;
224
+ readReviewStateFn?: (s: string, r?: string) => any;
225
+ worktree?: string;
226
+ reviewIdentity?: string;
227
+ forgejoUser?: string;
228
+ getPrAuthorFn?: (branch: string, token: string, opts?: Record<string, unknown>) => unknown;
229
+ writeReviewStateFn?: (s: string, st: any, w: string) => boolean;
230
+ createEventFn?: (s: string, t: string, p: Record<string, unknown>, o: Record<string, unknown>) => CreateResult;
231
+ } = {}
232
+ ): { ok: boolean; error?: string; skipped?: boolean; reason?: string; prAuthor?: unknown } {
233
+ const log = options.log || fmt.log.plain;
234
+ const error = options.error || fmt.log.plainError;
235
+ const readTokenFn = options.readTokenFn || readToken;
236
+ const postReviewFn = options.postReviewFn || postReview;
237
+ const buildMetadataFooterFn = options.buildMetadataFooterFn || buildMetadataFooter;
238
+ const readReviewStateFn = options.readReviewStateFn || readReviewState;
239
+ const worktree = options.worktree || resolveWorktree(slug) || process.cwd();
240
+ const branch = missionBranchName(slug, worktree);
241
+ const reviewIdentity = options.reviewIdentity
242
+ || options.forgejoUser
243
+ || resolveReviewIdentity(slug, worktree, { readReviewStateFn }).identityUser
244
+ || 'human';
245
+ const token = readTokenFn(reviewIdentity, { rootDir: worktree });
246
+ if (!token) {
247
+ error(fmt.status('FAIL', `No Forgejo token found for user "${reviewIdentity}". Cannot submit review.`));
248
+ return { ok: false };
249
+ }
250
+
251
+ const getPrAuthorFn = options.getPrAuthorFn || getPrAuthor;
252
+ let prAuthor: unknown = null;
253
+ try {
254
+ prAuthor = getPrAuthorFn(branch, token, { forgejoUser: reviewIdentity, reviewIdentity, rootDir: worktree });
255
+ } catch {
256
+ prAuthor = null;
257
+ }
258
+ if (prAuthor && prAuthor === reviewIdentity) {
259
+ log(fmt.status('WARN', `Reviewer "${reviewIdentity}" is the PR author for ${branch}; skipping the provider review POST to avoid a self-approval (Forgejo rejects "approve your own pull is not allowed" with HTTP 422). Recording the "${outcome}" verdict locally in review-state.json / review-events; a different agent or a human must post the formal approval.`));
260
+ recordLocalReviewVerdict(slug, outcome, {
261
+ worktree,
262
+ reviewer: reviewIdentity,
263
+ writeReviewStateFn: options.writeReviewStateFn,
264
+ createEventFn: options.createEventFn,
265
+ readReviewStateFn,
266
+ log: log,
267
+ error,
268
+ });
269
+ return { ok: true, skipped: true, reason: 'self-author', prAuthor };
270
+ }
271
+
272
+ const taggedMessage = message + buildMetadataFooterFn(slug, worktree);
273
+ log(fmt.status('INFO', `Submitting review outcome "${outcome}" on ${branch} as ${reviewIdentity}...`));
274
+ const result = postReviewFn(branch, token, outcome, taggedMessage, { forgejoUser: reviewIdentity, reviewIdentity });
275
+ const r = result as Record<string, unknown>;
276
+ if (!r.ok) {
277
+ const failureDetail = describeProviderFailure(result);
278
+ error(fmt.status('FAIL', `Could not submit review: ${failureDetail}`));
279
+ return { ok: false, error: failureDetail };
280
+ }
281
+
282
+ log(fmt.status('PASS', `Review outcome "${outcome}" posted on PR for ${branch}.`));
283
+ return { ok: true };
284
+ }
285
+
286
+ // ============================================================================
287
+ // Artifact Consumption
288
+ // ============================================================================
289
+
290
+
291
+ async function consumeReviewerArtifacts(
292
+ slug: string,
293
+ reviewer: string,
294
+ options: {
295
+ log?: (msg: string) => void;
296
+ error?: (msg: string) => void;
297
+ readArtifactFn?: typeof readArtifactFile;
298
+ deleteArtifactFn?: typeof deleteArtifactFile;
299
+ tmpDir?: string | null;
300
+ fallbackToTmp?: boolean;
301
+ worktree?: string;
302
+ providerEnabled?: boolean | null;
303
+ forgejoEnabled?: boolean | null;
304
+ readTokenFn?: (user: string, opts?: Record<string, unknown>) => string | null;
305
+ getCommentsFn?: (branch: string, token: string) => Promise<unknown[]>;
306
+ postCommentFn?: (branch: string, token: string, body: string, opts?: Record<string, unknown>) => unknown;
307
+ postReviewFn?: (branch: string, token: string, outcome: string, body: string, opts?: Record<string, unknown>) => unknown;
308
+ buildMetadataFooterFn?: (s: string, r?: string) => string;
309
+ createEventFn?: (s: string, t: string, p: CreateEventParams, o: CreateEventOptions) => CreateEventResult;
310
+ } = {}
311
+ ): Promise<{ consumed: boolean; ok?: boolean; reviewState?: string | null }> {
312
+ const log = options.log || fmt.log.plain;
313
+ const error = options.error || fmt.log.plainError;
314
+ const readArtifactFn = options.readArtifactFn || readArtifactFile;
315
+ const deleteArtifactFn = options.deleteArtifactFn || deleteArtifactFile;
316
+ const explicitTmpDir = options.tmpDir !== null && options.tmpDir !== undefined;
317
+ const fallbackToTmp = options.fallbackToTmp === true;
318
+ const worktree = options.worktree || resolveWorktree(slug) || process.cwd();
319
+ const tmpDir = options.tmpDir || resolveArtifactDir(worktree);
320
+ const providerEnabled = options.providerEnabled !== null && options.providerEnabled !== undefined
321
+ ? options.providerEnabled
322
+ : (options.forgejoEnabled !== null && options.forgejoEnabled !== undefined ? options.forgejoEnabled : isEnabled(worktree));
323
+ const reviewStatePath = reviewStateFile(slug, worktree);
324
+
325
+ const findingsResolved = resolveArtifactRead(slug, 'review-findings.md', { tmpDir, explicitTmpDir, fallbackToTmp, readArtifactFn });
326
+ const outcomeResolved = resolveArtifactRead(slug, 'review-outcome.md', { tmpDir, explicitTmpDir, fallbackToTmp, readArtifactFn });
327
+ const verdictResolved = resolveArtifactRead(slug, 'review-verdict.txt', { tmpDir, explicitTmpDir, fallbackToTmp, readArtifactFn });
328
+ const findingsPath = findingsResolved.path;
329
+ const outcomePath = outcomeResolved.path;
330
+ const verdictPath = verdictResolved.path;
331
+
332
+ const findings = findingsResolved.value;
333
+ const outcomeMessage = outcomeResolved.value;
334
+ const verdictRaw = verdictResolved.value;
335
+
336
+ let verdict = normalizeReviewVerdict(verdictRaw || '');
337
+ if (!verdict && outcomeMessage) {
338
+ const outcomeVerdictMatch = outcomeMessage.match(/^verdict:\s*(approve|request-changes|comment)/im) ||
339
+ outcomeMessage.match(/Verdict:\s*(approve|request-changes|comment)/i);
340
+ if (outcomeVerdictMatch) {
341
+ verdict = normalizeReviewVerdict(outcomeVerdictMatch[1]);
342
+ }
343
+ }
344
+
345
+ const hasAny = findings !== null || outcomeMessage !== null || verdictRaw !== null;
346
+
347
+ if (!hasAny) {
348
+ return { consumed: false };
349
+ }
350
+ if (!findings || !outcomeMessage) {
351
+ if (!providerEnabled) {
352
+ const statePathStr = reviewStatePath ? ` local review state at ${reviewStatePath}; ` : ' ';
353
+ error(fmt.status('FAIL', `Incomplete reviewer artifacts for ${slug}. Expected ${findingsPath} and ${outcomePath}.${statePathStr}No provider review posted (provider=none); add a review-outcome.md with a Verdict line or use \`node parallix review <slug> --submit-review approve\`.`));
354
+ } else {
355
+ error(fmt.status('FAIL', `Incomplete reviewer artifacts for ${slug}. Expected ${findingsPath} and ${outcomePath}.`));
356
+ }
357
+ return { consumed: true, ok: false };
358
+ }
359
+ if (!verdict) {
360
+ if (!providerEnabled) {
361
+ const statePathStr = reviewStatePath ? ` local review state at ${reviewStatePath}; ` : ' ';
362
+ error(fmt.status('FAIL', `Reviewer artifacts for ${slug} missing verdict. Expected in ${verdictPath} or in ${outcomePath} content.${statePathStr}No provider review posted (provider=none); add a review-outcome.md with a Verdict line or use \`node parallix review <slug> --submit-review approve\`.`));
363
+ } else {
364
+ error(fmt.status('FAIL', `Reviewer artifacts for ${slug} missing verdict. Expected in ${verdictPath} or in ${outcomePath} content.`));
365
+ }
366
+ return { consumed: true, ok: false };
367
+ }
368
+
369
+ const currentState = readReviewState(slug, worktree);
370
+ const round = currentState ? currentState.round : 1;
371
+ const phase = currentState ? currentState.phase : 'reviewing';
372
+
373
+ const createEventFn = options.createEventFn || createEvent;
374
+ const findingsEventResult = createEventFn(slug, VALID_EVENT_TYPES.REVIEWER_FINDINGS, {
375
+ content: findings, round, phase, actor: reviewer
376
+ }, { worktree, skipGit: true, log: log, error });
377
+
378
+ if (!findingsEventResult.ok) {
379
+ error(fmt.status('FAIL', `Failed to persist reviewer findings to repo store: ${(findingsEventResult as { error?: string }).error}`));
380
+ return { consumed: true, ok: false };
381
+ }
382
+
383
+ const outcomeEventResult = createEventFn(slug, VALID_EVENT_TYPES.REVIEWER_OUTCOME, {
384
+ content: outcomeMessage, round, phase, actor: reviewer, verdict
385
+ }, { worktree, skipGit: true, log: log, error });
386
+
387
+ if (!outcomeEventResult.ok) {
388
+ error(fmt.status('FAIL', `Failed to persist reviewer outcome to repo store: ${(outcomeEventResult as { error?: string }).error}`));
389
+ return { consumed: true, ok: false };
390
+ }
391
+
392
+ log(fmt.status('INFO', `Persisted reviewer artifacts to repo store: ${(findingsEventResult as { path?: string }).path}, ${(outcomeEventResult as { path?: string }).path}`));
393
+
394
+ if (providerEnabled && options.readTokenFn && options.getCommentsFn) {
395
+ await consumeHumanNotes(slug, reviewer, {
396
+ getCommentsFn: options.getCommentsFn,
397
+ createEventFn: createEventFn as any,
398
+ readTokenFn: options.readTokenFn,
399
+ reviewIdentity: reviewer,
400
+ worktree,
401
+ log: log,
402
+ error
403
+ });
404
+ }
405
+
406
+ if (providerEnabled) {
407
+ const commentResult = postWorkflowComment(slug, findings, {
408
+ rootDir: worktree,
409
+ reviewIdentity: reviewer,
410
+ readTokenFn: options.readTokenFn,
411
+ postCommentFn: options.postCommentFn,
412
+ buildMetadataFooterFn: options.buildMetadataFooterFn,
413
+ log: log,
414
+ error
415
+ });
416
+ if (!commentResult.ok) {
417
+ return { consumed: true, ok: false };
418
+ }
419
+
420
+ const reviewResult = postWorkflowReview(slug, verdict, outcomeMessage, {
421
+ worktree,
422
+ reviewIdentity: reviewer,
423
+ readTokenFn: options.readTokenFn,
424
+ postReviewFn: options.postReviewFn,
425
+ buildMetadataFooterFn: options.buildMetadataFooterFn,
426
+ log: log,
427
+ error
428
+ });
429
+ if (!reviewResult.ok) {
430
+ return { consumed: true, ok: false };
431
+ }
432
+ } else {
433
+ log(fmt.status('INFO', `Review provider disabled; skipping PR mirroring for ${slug}`));
434
+ }
435
+
436
+ deleteArtifactFn(findingsPath);
437
+ deleteArtifactFn(outcomePath);
438
+ deleteArtifactFn(verdictPath);
439
+
440
+ if (verdict === 'approve') { return { consumed: true, ok: true, reviewState: 'APPROVED' }; }
441
+ if (verdict === 'request-changes') { return { consumed: true, ok: true, reviewState: 'REQUEST_CHANGES' }; }
442
+ if (!providerEnabled) {
443
+ const reviewState = verdict.toUpperCase().replace(/-/g, '_');
444
+ log(fmt.status('INFO', `Reviewer ${reviewer} produced verdict "${verdict}" with the provider disabled; normalizing to ${reviewState} for loop control.`));
445
+ return { consumed: true, ok: true, reviewState };
446
+ }
447
+ log(fmt.status('WARN', `Reviewer ${reviewer} produced verdict "${verdict}". Falling back to provider polling for loop control.`));
448
+ return { consumed: true, ok: true, reviewState: null };
449
+ }
450
+
451
+ async function consumeImplementerArtifacts(
452
+ slug: string,
453
+ implementer: string,
454
+ options: {
455
+ log?: (msg: string) => void;
456
+ error?: (msg: string) => void;
457
+ readArtifactFn?: typeof readArtifactFile;
458
+ deleteArtifactFn?: typeof deleteArtifactFile;
459
+ tmpDir?: string | null;
460
+ fallbackToTmp?: boolean;
461
+ worktree?: string;
462
+ providerEnabled?: boolean | null;
463
+ forgejoEnabled?: boolean | null;
464
+ readTokenFn?: (user: string, opts?: Record<string, unknown>) => string | null;
465
+ getCommentsFn?: (branch: string, token: string) => Promise<unknown[]>;
466
+ postCommentFn?: (branch: string, token: string, body: string, opts?: Record<string, unknown>) => unknown;
467
+ buildMetadataFooterFn?: (s: string, r?: string) => string;
468
+ createEventFn?: (s: string, t: string, p: CreateEventParams, o: CreateEventOptions) => CreateEventResult;
469
+ } = {}
470
+ ): Promise<{ consumed: boolean; ok?: boolean; disposition?: string | null }> {
471
+ const log = options.log || fmt.log.plain;
472
+ const error = options.error || fmt.log.plainError;
473
+ const readArtifactFn = options.readArtifactFn || readArtifactFile;
474
+ const deleteArtifactFn = options.deleteArtifactFn || deleteArtifactFile;
475
+ const explicitTmpDir = options.tmpDir !== null && options.tmpDir !== undefined;
476
+ const fallbackToTmp = options.fallbackToTmp === true;
477
+ const worktree = options.worktree || resolveWorktree(slug) || process.cwd();
478
+ const tmpDir = options.tmpDir || resolveArtifactDir(worktree);
479
+ const providerEnabled = options.providerEnabled !== null && options.providerEnabled !== undefined
480
+ ? options.providerEnabled
481
+ : (options.forgejoEnabled !== null && options.forgejoEnabled !== undefined ? options.forgejoEnabled : isEnabled(worktree));
482
+ const reviewStatePath = reviewStateFile(slug, worktree);
483
+
484
+ const resolutionResolved = resolveArtifactRead(slug, 'round-resolution.md', { tmpDir, explicitTmpDir, fallbackToTmp, readArtifactFn });
485
+ const dispositionResolved = resolveArtifactRead(slug, 'review-disposition.txt', { tmpDir, explicitTmpDir, fallbackToTmp, readArtifactFn });
486
+ const resolutionPath = resolutionResolved.path;
487
+ const dispositionPath = dispositionResolved.path;
488
+
489
+ const resolution = resolutionResolved.value;
490
+ const dispositionRaw = dispositionResolved.value;
491
+ const disposition = normalizeDisposition(dispositionRaw || '');
492
+ const hasAny = resolution !== null || dispositionRaw !== null;
493
+
494
+ if (!hasAny) {
495
+ return { consumed: false };
496
+ }
497
+ if (!resolution || !disposition) {
498
+ if (!providerEnabled) {
499
+ const statePathStr = reviewStatePath ? ` local review state at ${reviewStatePath}; ` : ' ';
500
+ error(fmt.status('FAIL', `Incomplete implementer artifacts for ${slug}. Expected ${resolutionPath} and ${dispositionPath}.${statePathStr}No provider review posted (provider=none); add review-disposition.txt and a round resolution, or use \`node parallix review <slug> --submit-review approve\`.`));
501
+ } else {
502
+ error(fmt.status('FAIL', `Incomplete implementer artifacts for ${slug}. Expected ${resolutionPath} and ${dispositionPath}.`));
503
+ }
504
+ return { consumed: true, ok: false };
505
+ }
506
+
507
+ const currentState = readReviewState(slug, worktree);
508
+ const round = currentState ? currentState.round : 1;
509
+ const phase = currentState ? currentState.phase : 'fixing';
510
+
511
+ const createEventFn = options.createEventFn || createEvent;
512
+
513
+ let fixedItems: unknown[] = [];
514
+ let pushedBackItems: unknown[] = [];
515
+ let parkedItems: unknown[] = [];
516
+ let blockedReason: string | null = null;
517
+
518
+ try {
519
+ const fixedMatch = resolution.match(/fixed_items:\s*(\[[^\]]*\])/i);
520
+ const pushedMatch = resolution.match(/pushed_back_items:\s*(\[[^\]]*\])/i);
521
+ const parkedMatch = resolution.match(/parked_items:\s*(\[[^\]]*\])/i);
522
+ const blockedMatch = resolution.match(/blocked_reason:\s*"([^"]*)"/i);
523
+
524
+ if (fixedMatch) { fixedItems = JSON.parse(fixedMatch[1]); }
525
+ if (pushedMatch) { pushedBackItems = JSON.parse(pushedMatch[1]); }
526
+ if (parkedMatch) { parkedItems = JSON.parse(parkedMatch[1]); }
527
+ if (blockedMatch) { blockedReason = blockedMatch[1]; }
528
+ } catch {
529
+ fixedItems = [];
530
+ pushedBackItems = [];
531
+ parkedItems = [];
532
+ }
533
+
534
+ if (disposition === 'BLOCKED' && !blockedReason) {
535
+ const match = resolution.match(/blocked.*?:\s*(.+)/i);
536
+ if (match) { blockedReason = match[1].trim(); }
537
+ }
538
+
539
+ const summaryEventResult = createEventFn(slug, VALID_EVENT_TYPES.IMPLEMENTER_ROUND_SUMMARY, {
540
+ content: resolution, round, phase, actor: implementer,
541
+ fixedItems, pushedBackItems, parkedItems,
542
+ ...(disposition === 'BLOCKED' && blockedReason ? { blockedReason } : {})
543
+ }, { worktree, skipGit: true, log: log, error });
544
+
545
+ if (!summaryEventResult.ok) {
546
+ error(fmt.status('FAIL', `Failed to persist implementer round summary to repo store: ${(summaryEventResult as { error?: string }).error}`));
547
+ return { consumed: true, ok: false };
548
+ }
549
+
550
+ const dispositionEventResult = createEventFn(slug, VALID_EVENT_TYPES.IMPLEMENTER_DISPOSITION, {
551
+ content: `Autonomous review disposition: ${disposition}`, round, phase, actor: implementer, disposition
552
+ }, { worktree, skipGit: true, log: log, error });
553
+
554
+ if (!dispositionEventResult.ok) {
555
+ error(fmt.status('FAIL', `Failed to persist implementer disposition to repo store: ${(dispositionEventResult as { error?: string }).error}`));
556
+ return { consumed: true, ok: false };
557
+ }
558
+
559
+ log(fmt.status('INFO', `Persisted implementer artifacts to repo store: ${(summaryEventResult as { path?: string }).path}, ${(dispositionEventResult as { path?: string }).path}`));
560
+
561
+ if (providerEnabled && options.readTokenFn && options.getCommentsFn) {
562
+ await consumeHumanNotes(slug, implementer, {
563
+ getCommentsFn: options.getCommentsFn,
564
+ createEventFn: createEventFn as any,
565
+ readTokenFn: options.readTokenFn,
566
+ reviewIdentity: implementer,
567
+ worktree,
568
+ log: log,
569
+ error
570
+ });
571
+ }
572
+
573
+ if (providerEnabled) {
574
+ const resolutionResult = postWorkflowComment(slug, resolution, {
575
+ rootDir: worktree,
576
+ reviewIdentity: implementer,
577
+ readTokenFn: options.readTokenFn,
578
+ postCommentFn: options.postCommentFn,
579
+ buildMetadataFooterFn: options.buildMetadataFooterFn,
580
+ log: log,
581
+ error
582
+ });
583
+ if (!resolutionResult.ok) {
584
+ return { consumed: true, ok: false };
585
+ }
586
+
587
+ const dispositionResult = postWorkflowComment(slug, `Autonomous review disposition: ${disposition}`, {
588
+ rootDir: worktree,
589
+ reviewIdentity: implementer,
590
+ readTokenFn: options.readTokenFn,
591
+ postCommentFn: options.postCommentFn,
592
+ buildMetadataFooterFn: options.buildMetadataFooterFn,
593
+ log: log,
594
+ error
595
+ });
596
+ if (!dispositionResult.ok) {
597
+ return { consumed: true, ok: false };
598
+ }
599
+ } else {
600
+ log(fmt.status('INFO', `Review provider disabled; skipping PR mirroring for ${slug}`));
601
+ }
602
+
603
+ deleteArtifactFn(resolutionPath);
604
+ deleteArtifactFn(dispositionPath);
605
+
606
+ return { consumed: true, ok: true, disposition };
607
+ }
608
+
609
+ // Module exports
610
+ export {
611
+ buildMetadataFooter,
612
+ reviewArtifactPath,
613
+ resolveArtifactDir,
614
+ readArtifactFile,
615
+ deleteArtifactFile,
616
+ normalizeReviewVerdict,
617
+ normalizeDisposition,
618
+ postWorkflowComment,
619
+ postWorkflowReview,
620
+ consumeReviewerArtifacts,
621
+ consumeImplementerArtifacts
622
+ };