@magnusekdahl/parallix 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/LICENSE +661 -0
  3. package/README.md +196 -0
  4. package/config/agents.json +25 -0
  5. package/config/agents.local.json.template +8 -0
  6. package/config/state-map.json +4 -0
  7. package/config/state-map.json.template +31 -0
  8. package/config/workflow.config.schema.json +98 -0
  9. package/data/.gitkeep +0 -0
  10. package/docs/adr/0031-ai-agent-instruction-boundary-and-command-floor.md +114 -0
  11. package/docs/adr/0032-mission-refinement-state-and-usage-budget-signals.md +135 -0
  12. package/docs/adr/0034-module-and-skill-invocation-model.md +202 -0
  13. package/docs/adr/0036-mission-sizing-and-dependency-wave-heuristics.md +79 -0
  14. package/docs/adr/0037-ai-workflow-coordination-architecture.md +162 -0
  15. package/docs/adr/0041-integration-pipeline-gates.md +165 -0
  16. package/docs/adr/0042-workflow-cli-color-rendering-approach.md +106 -0
  17. package/docs/adr/0043-git-target-resolution-strategy.md +185 -0
  18. package/docs/adr/0044-workflow-distribution-model.md +277 -0
  19. package/docs/adr/0045-parallax-branch-model.md +182 -0
  20. package/docs/adr/0046-npm-publish-process-and-security.md +138 -0
  21. package/docs/adr/index.md +20 -0
  22. package/docs/agents.md +212 -0
  23. package/docs/authority-reference.md +298 -0
  24. package/docs/forgejo-setup.md +31 -0
  25. package/docs/migration/extraction.md +61 -0
  26. package/docs/migration/task-classification.md +36 -0
  27. package/docs/operator-setup.md +76 -0
  28. package/docs/readme-rewrite-benchmark.md +188 -0
  29. package/docs/use-cases.md +105 -0
  30. package/examples/README.md +62 -0
  31. package/examples/run-enterprise-tarball-workflow-smoke.sh +257 -0
  32. package/examples/run-verify-env-smoke.sh +40 -0
  33. package/index.js +250 -0
  34. package/lib/README.md +13 -0
  35. package/lib/agents/agents.js +867 -0
  36. package/lib/agents/claude-telemetry.js +233 -0
  37. package/lib/agents/claude.js +139 -0
  38. package/lib/agents/codex-telemetry.js +202 -0
  39. package/lib/agents/codex.js +219 -0
  40. package/lib/agents/limit-hit.js +252 -0
  41. package/lib/agents/mistral-telemetry.js +44 -0
  42. package/lib/agents/mistral.js +68 -0
  43. package/lib/agents/opencode-export.js +110 -0
  44. package/lib/agents/opencode-telemetry.js +356 -0
  45. package/lib/agents/opencode.js +218 -0
  46. package/lib/agents/stage-telemetry.js +37 -0
  47. package/lib/commands/active.js +625 -0
  48. package/lib/commands/checkpoint.js +76 -0
  49. package/lib/commands/config.js +39 -0
  50. package/lib/commands/coverage-gate.js +358 -0
  51. package/lib/commands/diff.js +119 -0
  52. package/lib/commands/draft.js +854 -0
  53. package/lib/commands/handoff.js +501 -0
  54. package/lib/commands/integrate.js +1528 -0
  55. package/lib/commands/mission-start.js +246 -0
  56. package/lib/commands/rebase.js +597 -0
  57. package/lib/commands/repair-handoff.js +227 -0
  58. package/lib/commands/resolve-conflict.js +109 -0
  59. package/lib/commands/review.js +13 -0
  60. package/lib/commands/setup-review.js +13 -0
  61. package/lib/commands/setup.js +3 -0
  62. package/lib/commands/stats-backfill.js +395 -0
  63. package/lib/commands/stats.js +1601 -0
  64. package/lib/commands/status.js +183 -0
  65. package/lib/commands/verify.js +1 -0
  66. package/lib/core/fmt.js +202 -0
  67. package/lib/core/git.js +73 -0
  68. package/lib/core/gitignore.js +110 -0
  69. package/lib/core/mission-utils.js +1017 -0
  70. package/lib/core/persistent-data-migration.js +201 -0
  71. package/lib/core/product-config.js +508 -0
  72. package/lib/core/runtime-matrix.js +82 -0
  73. package/lib/core/spawn-tee.js +173 -0
  74. package/lib/core/state-map.js +89 -0
  75. package/lib/core/storage.js +165 -0
  76. package/lib/core/verification.js +149 -0
  77. package/lib/index.js +77 -0
  78. package/lib/review/rebase.js +163 -0
  79. package/lib/review/review-adapter.js +135 -0
  80. package/lib/review/review-artifacts.js +619 -0
  81. package/lib/review/review-commands.js +1375 -0
  82. package/lib/review/review-events.js +1007 -0
  83. package/lib/review/review-loop.js +1004 -0
  84. package/lib/review/review-polling.js +141 -0
  85. package/lib/review/review-prompts.js +212 -0
  86. package/lib/review/review-state.js +280 -0
  87. package/lib/review/review.js +96 -0
  88. package/lib/tools/backlog.js +680 -0
  89. package/lib/tools/forgejo.js +1585 -0
  90. package/lib/tools/gatekeeper.js +106 -0
  91. package/lib/tools/sessions.js +74 -0
  92. package/lib/tools/setup-review.js +1053 -0
  93. package/package.json +56 -0
  94. package/prompts/act-on-review-verbose.md +20 -0
  95. package/prompts/act-on-review.md +22 -0
  96. package/prompts/draft.md +20 -0
  97. package/prompts/execute.md +24 -0
  98. package/prompts/portfolio.md +30 -0
  99. package/prompts/review-verbose.md +20 -0
  100. package/prompts/review.md +17 -0
  101. package/px.js +236 -0
  102. package/templates/AGENTS-snippet.md +14 -0
  103. package/templates/AGENTS.md.template +34 -0
  104. package/templates/CLAUDE.md.template +27 -0
  105. package/templates/CODEX.md.template +38 -0
  106. package/templates/MISTRAL.md.template +24 -0
  107. package/templates/claude-commands/act-on-review.md +3 -0
  108. package/templates/claude-commands/area-review.md +3 -0
  109. package/templates/claude-commands/draft.md +6 -0
  110. package/templates/claude-commands/execute.md +6 -0
  111. package/templates/claude-commands/integrate.md +4 -0
  112. package/templates/claude-commands/portfolio.md +5 -0
  113. package/templates/claude-commands/review.md +4 -0
  114. package/templates/codex/config.toml +6 -0
  115. package/templates/mission-scaffold.md +39 -0
  116. package/templates/vibe/skills/act-on-review/SKILL.md +16 -0
  117. package/templates/vibe/skills/area-review/SKILL.md +16 -0
  118. package/templates/vibe/skills/draft/SKILL.md +16 -0
  119. package/templates/vibe/skills/execute/SKILL.md +16 -0
  120. package/templates/vibe/skills/integrate/SKILL.md +16 -0
  121. package/templates/vibe/skills/portfolio/SKILL.md +21 -0
  122. package/templates/vibe/skills/review/SKILL.md +16 -0
  123. package/tools/setup-forgejo-docker.sh +84 -0
@@ -0,0 +1,619 @@
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
+ const fs = require('fs');
8
+ const os = require('os');
9
+ const path = require('path');
10
+ const fmt = require('../core/fmt');
11
+ const { missionBranchName, resolveWorktree } = require('../core/mission-utils');
12
+ const { readReviewState, writeReviewState, reviewStateFile, ReviewState, resolveReviewIdentity } = require('./review-state');
13
+ const { readToken, postComment, postReview, getComments, getPrAuthor, isEnabled, resolveArtifactDir: resolveConfiguredArtifactDir } = require('./review-adapter');
14
+ const { createEvent, consumeHumanNotes, VALID_EVENT_TYPES } = require('./review-events');
15
+
16
+ // ============================================================================
17
+ // Metadata Footer
18
+ // ============================================================================
19
+
20
+ function buildMetadataFooter(slug, rootDir) {
21
+ const state = readReviewState(slug, rootDir);
22
+ if (!state) return '';
23
+ return `\n\n---\n\`[workflow-round:${state.round}, workflow-phase:${state.phase}]\``;
24
+ }
25
+
26
+ // ============================================================================
27
+ // Artifact Path Utilities
28
+ // ============================================================================
29
+
30
+ function reviewArtifactPath(slug, artifactName, tmpDir = os.tmpdir()) {
31
+ return path.join(tmpDir, `${slug}-${artifactName}`);
32
+ }
33
+
34
+ // Agents are instructed by parallix/prompts/*.md to write review/act-on-review
35
+ // artifacts to the resolved artifact dir ({{artifactDir}}). The consumers read
36
+ // from os.tmpdir() by default, which diverges from /tmp whenever TMPDIR is set
37
+ // (CI runners, sandboxes); under the OLD prompts that hardcoded /tmp this left
38
+ // artifacts unseen and the review loop reported "no formal review outcome".
39
+ // Resolve from the configured tmpDir first, then fall back to the /tmp path the
40
+ // old prompts advertised. The fallback is normally skipped when a caller passes
41
+ // an explicit tmpDir, so unit tests that point at their own scratch dir are
42
+ // unaffected — but the review loop opts back in via `fallbackToTmp: true` so the
43
+ // /tmp safety net still protects pre-existing/old-prompt artifacts even though
44
+ // the loop passes the resolved default dir explicitly. This is defense-in-depth:
45
+ // once the prompts substitute {{artifactDir}}, producer and consumer share one
46
+ // dir by construction and this fallback is rarely exercised.
47
+ function resolveArtifactRead(slug, artifactName, { tmpDir, explicitTmpDir, fallbackToTmp, readArtifactFn }) {
48
+ const primaryPath = reviewArtifactPath(slug, artifactName, tmpDir);
49
+ const primaryValue = readArtifactFn(primaryPath);
50
+ const allowTmpFallback = (!explicitTmpDir || fallbackToTmp) && tmpDir !== '/tmp';
51
+ if (primaryValue !== null || !allowTmpFallback) {
52
+ return { path: primaryPath, value: primaryValue };
53
+ }
54
+ const fallbackPath = reviewArtifactPath(slug, artifactName, '/tmp');
55
+ const fallbackValue = readArtifactFn(fallbackPath);
56
+ if (fallbackValue !== null) {
57
+ return { path: fallbackPath, value: fallbackValue };
58
+ }
59
+ return { path: primaryPath, value: null };
60
+ }
61
+
62
+ /**
63
+ * Resolve the directory where review artifacts (review-findings.md,
64
+ * review-outcome.md, review-verdict.txt, round-resolution.md,
65
+ * review-disposition.txt) are written and consumed.
66
+ *
67
+ * Reads `adapters.review.tmpDir` from workflow.config.json when present so a
68
+ * downstream repo can pin a single, portable artifact location that the review
69
+ * prompt and the consumer agree on. A relative configured value is resolved
70
+ * against the repo root. Falls back to `os.tmpdir()` when unconfigured, which
71
+ * preserves the historical default for every existing caller.
72
+ *
73
+ * @param {string} [rootDir]
74
+ * @returns {string} absolute artifact directory
75
+ */
76
+ function resolveArtifactDir(rootDir = process.cwd()) {
77
+ return resolveConfiguredArtifactDir(rootDir);
78
+ }
79
+
80
+ function readArtifactFile(filePath, readFileSync = fs.readFileSync) {
81
+ try {
82
+ const value = readFileSync(filePath, 'utf8');
83
+ return typeof value === 'string' ? value.trim() : '';
84
+ } catch (_) {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ function deleteArtifactFile(filePath, unlinkSync = fs.unlinkSync) {
90
+ try {
91
+ unlinkSync(filePath);
92
+ } catch (_) {
93
+ // Best-effort cleanup only.
94
+ }
95
+ }
96
+
97
+ // ============================================================================
98
+ // Normalization Utilities
99
+ // ============================================================================
100
+
101
+ function normalizeReviewVerdict(value) {
102
+ const normalized = String(value || '').trim().toLowerCase();
103
+ return ['approve', 'request-changes', 'comment'].includes(normalized) ? normalized : null;
104
+ }
105
+
106
+ function normalizeDisposition(value) {
107
+ const normalized = String(value || '').trim().toUpperCase();
108
+ return ['CHANGES_MADE', 'PUSHBACK_ALL', 'PARKED', 'BLOCKED'].includes(normalized) ? normalized : null;
109
+ }
110
+
111
+ // ============================================================================
112
+ // Workflow Comment/Review Posting
113
+ // ============================================================================
114
+
115
+ function postWorkflowComment(slug, message, options = {}) {
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
+ if (!result.ok) {
138
+ error(fmt.status('FAIL', `Could not post comment: ${result.error || 'API error'}`));
139
+ return { ok: false, error: result.error || 'API error' };
140
+ }
141
+
142
+ log(fmt.status('PASS', `Comment posted on PR for ${branch}.`));
143
+ return { ok: true };
144
+ }
145
+
146
+ // Build a diagnosable error string from a failed provider API result, surfacing
147
+ // the real HTTP status and response body (e.g. `422 approve your own pull is not
148
+ // allowed`) instead of masking it behind a generic "API error". Falls back to
149
+ // the result's own error/raw fields, then "API error" when nothing is present.
150
+ function describeProviderFailure(result) {
151
+ if (!result || typeof result !== 'object') return 'API error';
152
+ // Prefer the HTTP status code (statusCode) over curl's process status.
153
+ const httpStatus = (result.statusCode != null && Number(result.statusCode) > 0)
154
+ ? result.statusCode
155
+ : (result.status != null && Number(result.status) >= 100 ? result.status : null);
156
+ let body = null;
157
+ if (result.data && typeof result.data === 'object') {
158
+ body = result.data.message || JSON.stringify(result.data);
159
+ } else if (typeof result.data === 'string' && result.data) {
160
+ body = result.data;
161
+ }
162
+ const parts = [];
163
+ if (httpStatus != null) parts.push(String(httpStatus));
164
+ if (body) parts.push(body);
165
+ if (parts.length) return parts.join(' ');
166
+ return result.error || result.raw || 'API error';
167
+ }
168
+
169
+ // Record a review verdict in local review-state.json / review-events without
170
+ // posting to the review provider. Used by the self-author skip path so the verdict is still
171
+ // captured even though the formal provider approval is intentionally not posted.
172
+ function recordLocalReviewVerdict(slug, outcome, options = {}) {
173
+ const worktree = options.worktree || resolveWorktree(slug) || process.cwd();
174
+ const writeReviewStateFn = options.writeReviewStateFn || writeReviewState;
175
+ const createEventFn = options.createEventFn || createEvent;
176
+ const readReviewStateFn = options.readReviewStateFn || readReviewState;
177
+ const log = options.log || fmt.log.plain;
178
+ const error = options.error || fmt.log.plainError;
179
+
180
+ const existing = readReviewStateFn(slug, worktree);
181
+ const state = existing instanceof ReviewState
182
+ ? existing
183
+ : new ReviewState(slug, existing || {
184
+ reviewer: options.reviewer,
185
+ implementer: options.reviewer,
186
+ round: 1,
187
+ phase: 'reviewing',
188
+ });
189
+ if (outcome === 'approve') {
190
+ state.disposition = 'APPROVED';
191
+ try { state.transitionTo('approved'); } catch (_) {}
192
+ } else if (outcome === 'request-changes') {
193
+ state.disposition = 'REQUEST_CHANGES';
194
+ try { state.transitionTo('fixing'); } catch (_) {}
195
+ }
196
+ writeReviewStateFn(slug, state, worktree);
197
+
198
+ // Verdict values (approve/request-changes/comment) map 1:1 to outcome values.
199
+ createEventFn(slug, VALID_EVENT_TYPES.REVIEWER_OUTCOME, { verdict: outcome }, { worktree, log, error });
200
+ }
201
+
202
+ function postWorkflowReview(slug, outcome, message, options = {}) {
203
+ const log = options.log || fmt.log.plain;
204
+ const error = options.error || fmt.log.plainError;
205
+ const readTokenFn = options.readTokenFn || readToken;
206
+ const postReviewFn = options.postReviewFn || postReview;
207
+ const buildMetadataFooterFn = options.buildMetadataFooterFn || buildMetadataFooter;
208
+ const readReviewStateFn = options.readReviewStateFn || readReviewState;
209
+ const worktree = options.worktree || resolveWorktree(slug) || process.cwd();
210
+ const branch = missionBranchName(slug, worktree);
211
+ const reviewIdentity = options.reviewIdentity
212
+ || options.forgejoUser
213
+ || resolveReviewIdentity(slug, worktree, { readReviewStateFn }).identityUser
214
+ || 'human';
215
+ const token = readTokenFn(reviewIdentity, { rootDir: worktree });
216
+ if (!token) {
217
+ error(fmt.status('FAIL', `No Forgejo token found for user "${reviewIdentity}". Cannot submit review.`));
218
+ return { ok: false };
219
+ }
220
+
221
+ // Self-approval guard: Forgejo rejects a formal review when the reviewer is
222
+ // also the PR author (HTTP 422 "approve your own pull is not allowed"). This
223
+ // arises under the legitimate same-agent-reviewer fallback. Detect it up front
224
+ // and skip the POST — recording the verdict locally instead of 422-ing — and
225
+ // WARN that a different agent or human must post the formal approval. Degrade
226
+ // safely: if the author cannot be resolved, treat it as "not self" and POST.
227
+ const getPrAuthorFn = options.getPrAuthorFn || getPrAuthor;
228
+ let prAuthor = null;
229
+ try {
230
+ prAuthor = getPrAuthorFn(branch, token, { forgejoUser: reviewIdentity, reviewIdentity, rootDir: worktree });
231
+ } catch (_) {
232
+ prAuthor = null;
233
+ }
234
+ if (prAuthor && prAuthor === reviewIdentity) {
235
+ 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.`));
236
+ recordLocalReviewVerdict(slug, outcome, {
237
+ worktree,
238
+ reviewer: reviewIdentity,
239
+ writeReviewStateFn: options.writeReviewStateFn,
240
+ createEventFn: options.createEventFn,
241
+ readReviewStateFn,
242
+ log,
243
+ error,
244
+ });
245
+ return { ok: true, skipped: true, reason: 'self-author', prAuthor };
246
+ }
247
+
248
+ const taggedMessage = message + buildMetadataFooterFn(slug, worktree);
249
+ log(fmt.status('INFO', `Submitting review outcome "${outcome}" on ${branch} as ${reviewIdentity}...`));
250
+ const result = postReviewFn(branch, token, outcome, taggedMessage, { forgejoUser: reviewIdentity, reviewIdentity });
251
+ if (!result.ok) {
252
+ const failureDetail = describeProviderFailure(result);
253
+ error(fmt.status('FAIL', `Could not submit review: ${failureDetail}`));
254
+ return { ok: false, error: failureDetail };
255
+ }
256
+
257
+ log(fmt.status('PASS', `Review outcome "${outcome}" posted on PR for ${branch}.`));
258
+ return { ok: true };
259
+ }
260
+
261
+ // ============================================================================
262
+ // Artifact Consumption
263
+ // ============================================================================
264
+
265
+ async function consumeReviewerArtifacts(slug, reviewer, options = {}) {
266
+ const log = options.log || fmt.log.plain;
267
+ const error = options.error || fmt.log.plainError;
268
+ const readArtifactFn = options.readArtifactFn || readArtifactFile;
269
+ const deleteArtifactFn = options.deleteArtifactFn || deleteArtifactFile;
270
+ const explicitTmpDir = options.tmpDir != null;
271
+ const fallbackToTmp = options.fallbackToTmp === true;
272
+ const worktree = options.worktree || resolveWorktree(slug) || process.cwd();
273
+ const tmpDir = options.tmpDir || resolveArtifactDir(worktree);
274
+ const providerEnabled = options.providerEnabled != null
275
+ ? options.providerEnabled
276
+ : (options.forgejoEnabled != null ? options.forgejoEnabled : isEnabled(worktree));
277
+ const reviewStatePath = reviewStateFile(slug, worktree);
278
+
279
+ const findingsResolved = resolveArtifactRead(slug, 'review-findings.md', { tmpDir, explicitTmpDir, fallbackToTmp, readArtifactFn });
280
+ const outcomeResolved = resolveArtifactRead(slug, 'review-outcome.md', { tmpDir, explicitTmpDir, fallbackToTmp, readArtifactFn });
281
+ const verdictResolved = resolveArtifactRead(slug, 'review-verdict.txt', { tmpDir, explicitTmpDir, fallbackToTmp, readArtifactFn });
282
+ const findingsPath = findingsResolved.path;
283
+ const outcomePath = outcomeResolved.path;
284
+ const verdictPath = verdictResolved.path;
285
+
286
+ const findings = findingsResolved.value;
287
+ const outcomeMessage = outcomeResolved.value;
288
+ const verdictRaw = verdictResolved.value;
289
+
290
+ // Extract verdict from outcome message if review-verdict.txt is not provided
291
+ // This allows a single review-outcome.md file to contain both the outcome and verdict
292
+ let verdict = normalizeReviewVerdict(verdictRaw);
293
+ if (!verdict && outcomeMessage) {
294
+ // Try to extract verdict from outcome content (e.g., "Verdict: approve" or frontmatter)
295
+ const outcomeVerdictMatch = outcomeMessage.match(/^verdict:\s*(approve|request-changes|comment)/im) ||
296
+ outcomeMessage.match(/Verdict:\s*(approve|request-changes|comment)/i);
297
+ if (outcomeVerdictMatch) {
298
+ verdict = normalizeReviewVerdict(outcomeVerdictMatch[1]);
299
+ }
300
+ }
301
+
302
+ const hasAny = findings !== null || outcomeMessage !== null || verdictRaw !== null;
303
+
304
+ if (!hasAny) {
305
+ return { consumed: false };
306
+ }
307
+ if (!findings || !outcomeMessage) {
308
+ if (!providerEnabled) {
309
+ const statePathStr = reviewStatePath ? ` local review state at ${reviewStatePath}; ` : ' ';
310
+ 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\`.`));
311
+ } else {
312
+ error(fmt.status('FAIL', `Incomplete reviewer artifacts for ${slug}. Expected ${findingsPath} and ${outcomePath}.`));
313
+ }
314
+ return { consumed: true, ok: false };
315
+ }
316
+ if (!verdict) {
317
+ if (!providerEnabled) {
318
+ const statePathStr = reviewStatePath ? ` local review state at ${reviewStatePath}; ` : ' ';
319
+ 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\`.`));
320
+ } else {
321
+ error(fmt.status('FAIL', `Reviewer artifacts for ${slug} missing verdict. Expected in ${verdictPath} or in ${outcomePath} content.`));
322
+ }
323
+ return { consumed: true, ok: false };
324
+ }
325
+
326
+ // Get current review state for round/phase metadata
327
+ const currentState = readReviewState(slug, worktree);
328
+ const round = currentState ? currentState.round : 1;
329
+ const phase = currentState ? currentState.phase : 'reviewing';
330
+
331
+ // SC 2: Persist to repo-owned store BEFORE provider mirroring
332
+ // This ensures durable storage even if provider publication fails
333
+ const createEventFn = options.createEventFn || createEvent;
334
+ const findingsEventResult = createEventFn(slug, VALID_EVENT_TYPES.REVIEWER_FINDINGS, {
335
+ content: findings,
336
+ round,
337
+ phase,
338
+ actor: reviewer
339
+ }, {
340
+ worktree,
341
+ skipGit: true, // Let the caller handle git; we just need the file persisted
342
+ log,
343
+ error
344
+ });
345
+
346
+ if (!findingsEventResult.ok) {
347
+ error(fmt.status('FAIL', `Failed to persist reviewer findings to repo store: ${findingsEventResult.error}`));
348
+ return { consumed: true, ok: false };
349
+ }
350
+
351
+ const outcomeEventResult = createEventFn(slug, VALID_EVENT_TYPES.REVIEWER_OUTCOME, {
352
+ content: outcomeMessage,
353
+ round,
354
+ phase,
355
+ actor: reviewer,
356
+ verdict
357
+ }, {
358
+ worktree,
359
+ skipGit: true,
360
+ log,
361
+ error
362
+ });
363
+
364
+ if (!outcomeEventResult.ok) {
365
+ error(fmt.status('FAIL', `Failed to persist reviewer outcome to repo store: ${outcomeEventResult.error}`));
366
+ return { consumed: true, ok: false };
367
+ }
368
+
369
+ log(fmt.status('INFO', `Persisted reviewer artifacts to repo store: ${findingsEventResult.path}, ${outcomeEventResult.path}`));
370
+
371
+ // SC 6: Classify human comments before mirroring (best-effort)
372
+ // Only read provider comments when a provider is enabled.
373
+ if (providerEnabled && options.readTokenFn && options.getCommentsFn) {
374
+ await consumeHumanNotes(slug, reviewer, {
375
+ getCommentsFn: options.getCommentsFn,
376
+ createEventFn,
377
+ readTokenFn: options.readTokenFn,
378
+ reviewIdentity: reviewer,
379
+ worktree,
380
+ log,
381
+ error
382
+ });
383
+ }
384
+
385
+ // Mirror to the configured review provider (SC 4) when enabled.
386
+ if (providerEnabled) {
387
+ // Both reviewer_findings and reviewer_outcome are mirrored
388
+ const commentResult = postWorkflowComment(slug, findings, {
389
+ rootDir: worktree,
390
+ reviewIdentity: reviewer,
391
+ readTokenFn: options.readTokenFn,
392
+ postCommentFn: options.postCommentFn,
393
+ buildMetadataFooterFn: options.buildMetadataFooterFn,
394
+ log,
395
+ error
396
+ });
397
+ if (!commentResult.ok) {
398
+ return { consumed: true, ok: false };
399
+ }
400
+
401
+ const reviewResult = postWorkflowReview(slug, verdict, outcomeMessage, {
402
+ worktree,
403
+ reviewIdentity: reviewer,
404
+ readTokenFn: options.readTokenFn,
405
+ postReviewFn: options.postReviewFn,
406
+ buildMetadataFooterFn: options.buildMetadataFooterFn,
407
+ log,
408
+ error
409
+ });
410
+ if (!reviewResult.ok) {
411
+ return { consumed: true, ok: false };
412
+ }
413
+ } else {
414
+ log(fmt.status('INFO', `Review provider disabled; skipping PR mirroring for ${slug}`));
415
+ }
416
+
417
+ deleteArtifactFn(findingsPath);
418
+ deleteArtifactFn(outcomePath);
419
+ deleteArtifactFn(verdictPath);
420
+
421
+ if (verdict === 'approve') return { consumed: true, ok: true, reviewState: 'APPROVED' };
422
+ if (verdict === 'request-changes') return { consumed: true, ok: true, reviewState: 'REQUEST_CHANGES' };
423
+ // Defensive fallback: a stray `comment` verdict (prompts no longer offer it) cannot be
424
+ // resolved via provider polling when the provider is disabled, so normalize it to the
425
+ // disposition format and pass it through. inferPhaseFromDisposition() maps COMMENT -> fixing.
426
+ if (!providerEnabled) {
427
+ const reviewState = verdict.toUpperCase().replace(/-/g, '_');
428
+ log(fmt.status('INFO', `Reviewer ${reviewer} produced verdict "${verdict}" with the provider disabled; normalizing to ${reviewState} for loop control.`));
429
+ return { consumed: true, ok: true, reviewState };
430
+ }
431
+ log(fmt.status('WARN', `Reviewer ${reviewer} produced verdict "${verdict}". Falling back to provider polling for loop control.`));
432
+ return { consumed: true, ok: true, reviewState: null };
433
+ }
434
+
435
+ async function consumeImplementerArtifacts(slug, implementer, options = {}) {
436
+ const log = options.log || fmt.log.plain;
437
+ const error = options.error || fmt.log.plainError;
438
+ const readArtifactFn = options.readArtifactFn || readArtifactFile;
439
+ const deleteArtifactFn = options.deleteArtifactFn || deleteArtifactFile;
440
+ const explicitTmpDir = options.tmpDir != null;
441
+ const fallbackToTmp = options.fallbackToTmp === true;
442
+ const worktree = options.worktree || resolveWorktree(slug) || process.cwd();
443
+ const tmpDir = options.tmpDir || resolveArtifactDir(worktree);
444
+ const providerEnabled = options.providerEnabled != null
445
+ ? options.providerEnabled
446
+ : (options.forgejoEnabled != null ? options.forgejoEnabled : isEnabled(worktree));
447
+ const reviewStatePath = reviewStateFile(slug, worktree);
448
+
449
+ const resolutionResolved = resolveArtifactRead(slug, 'round-resolution.md', { tmpDir, explicitTmpDir, fallbackToTmp, readArtifactFn });
450
+ const dispositionResolved = resolveArtifactRead(slug, 'review-disposition.txt', { tmpDir, explicitTmpDir, fallbackToTmp, readArtifactFn });
451
+ const resolutionPath = resolutionResolved.path;
452
+ const dispositionPath = dispositionResolved.path;
453
+
454
+ const resolution = resolutionResolved.value;
455
+ const dispositionRaw = dispositionResolved.value;
456
+ const disposition = normalizeDisposition(dispositionRaw);
457
+ const hasAny = resolution !== null || dispositionRaw !== null;
458
+
459
+ if (!hasAny) {
460
+ return { consumed: false };
461
+ }
462
+ if (!resolution || !disposition) {
463
+ if (!providerEnabled) {
464
+ const statePathStr = reviewStatePath ? ` local review state at ${reviewStatePath}; ` : ' ';
465
+ 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\`.`));
466
+ } else {
467
+ error(fmt.status('FAIL', `Incomplete implementer artifacts for ${slug}. Expected ${resolutionPath} and ${dispositionPath}.`));
468
+ }
469
+ return { consumed: true, ok: false };
470
+ }
471
+
472
+ // Get current review state for round/phase metadata
473
+ const currentState = readReviewState(slug, worktree);
474
+ const round = currentState ? currentState.round : 1;
475
+ const phase = currentState ? currentState.phase : 'fixing';
476
+
477
+ // SC 2: Persist to repo-owned store BEFORE provider mirroring
478
+ // This ensures durable storage even if provider publication fails
479
+ const createEventFn = options.createEventFn || createEvent;
480
+
481
+ // Parse resolution content to extract structured fields if present
482
+ // SC 5: Validate mirrored content includes required fields
483
+ let fixedItems = [];
484
+ let pushedBackItems = [];
485
+ let parkedItems = [];
486
+ let blockedReason = null;
487
+
488
+ try {
489
+ // Try to extract from frontmatter or structured content
490
+ const fixedMatch = resolution.match(/fixed_items:\s*(\[[^\]]*\])/i);
491
+ const pushedMatch = resolution.match(/pushed_back_items:\s*(\[[^\]]*\])/i);
492
+ const parkedMatch = resolution.match(/parked_items:\s*(\[[^\]]*\])/i);
493
+ const blockedMatch = resolution.match(/blocked_reason:\s*"([^"]*)"/i);
494
+
495
+ if (fixedMatch) fixedItems = JSON.parse(fixedMatch[1]);
496
+ if (pushedMatch) pushedBackItems = JSON.parse(pushedMatch[1]);
497
+ if (parkedMatch) parkedItems = JSON.parse(parkedMatch[1]);
498
+ if (blockedMatch) blockedReason = blockedMatch[1];
499
+ } catch (_) {
500
+ // If parsing fails, use empty arrays
501
+ fixedItems = [];
502
+ pushedBackItems = [];
503
+ parkedItems = [];
504
+ }
505
+
506
+ // For BLOCKED disposition, blockedReason is required
507
+ if (disposition === 'BLOCKED' && !blockedReason) {
508
+ // Check if it's in the resolution content
509
+ const match = resolution.match(/blocked.*?:\s*(.+)/i);
510
+ if (match) blockedReason = match[1].trim();
511
+ }
512
+
513
+ const summaryEventResult = createEventFn(slug, VALID_EVENT_TYPES.IMPLEMENTER_ROUND_SUMMARY, {
514
+ content: resolution,
515
+ round,
516
+ phase,
517
+ actor: implementer,
518
+ fixedItems,
519
+ pushedBackItems,
520
+ parkedItems,
521
+ ...(disposition === 'BLOCKED' && blockedReason ? { blockedReason } : {})
522
+ }, {
523
+ worktree,
524
+ skipGit: true,
525
+ log,
526
+ error
527
+ });
528
+
529
+ if (!summaryEventResult.ok) {
530
+ error(fmt.status('FAIL', `Failed to persist implementer round summary to repo store: ${summaryEventResult.error}`));
531
+ return { consumed: true, ok: false };
532
+ }
533
+
534
+ const dispositionEventResult = createEventFn(slug, VALID_EVENT_TYPES.IMPLEMENTER_DISPOSITION, {
535
+ content: `Autonomous review disposition: ${disposition}`,
536
+ round,
537
+ phase,
538
+ actor: implementer,
539
+ disposition
540
+ }, {
541
+ worktree,
542
+ skipGit: true,
543
+ log,
544
+ error
545
+ });
546
+
547
+ if (!dispositionEventResult.ok) {
548
+ error(fmt.status('FAIL', `Failed to persist implementer disposition to repo store: ${dispositionEventResult.error}`));
549
+ return { consumed: true, ok: false };
550
+ }
551
+
552
+ log(fmt.status('INFO', `Persisted implementer artifacts to repo store: ${summaryEventResult.path}, ${dispositionEventResult.path}`));
553
+
554
+ // SC 6: Classify human comments before mirroring (best-effort)
555
+ // Only read provider comments when a provider is enabled.
556
+ if (providerEnabled && options.readTokenFn && options.getCommentsFn) {
557
+ await consumeHumanNotes(slug, implementer, {
558
+ getCommentsFn: options.getCommentsFn,
559
+ createEventFn,
560
+ readTokenFn: options.readTokenFn,
561
+ reviewIdentity: implementer,
562
+ worktree,
563
+ log,
564
+ error
565
+ });
566
+ }
567
+
568
+ // Mirror to the configured review provider (SC 4) when enabled.
569
+ if (providerEnabled) {
570
+ // Both implementer_round_summary and implementer_disposition are mirrored
571
+ const resolutionResult = postWorkflowComment(slug, resolution, {
572
+ rootDir: worktree,
573
+ reviewIdentity: implementer,
574
+ readTokenFn: options.readTokenFn,
575
+ postCommentFn: options.postCommentFn,
576
+ buildMetadataFooterFn: options.buildMetadataFooterFn,
577
+ log,
578
+ error
579
+ });
580
+ if (!resolutionResult.ok) {
581
+ return { consumed: true, ok: false };
582
+ }
583
+
584
+ const dispositionResult = postWorkflowComment(slug, `Autonomous review disposition: ${disposition}`, {
585
+ rootDir: worktree,
586
+ reviewIdentity: implementer,
587
+ readTokenFn: options.readTokenFn,
588
+ postCommentFn: options.postCommentFn,
589
+ buildMetadataFooterFn: options.buildMetadataFooterFn,
590
+ log,
591
+ error
592
+ });
593
+ if (!dispositionResult.ok) {
594
+ return { consumed: true, ok: false };
595
+ }
596
+ } else {
597
+ log(fmt.status('INFO', `Review provider disabled; skipping PR mirroring for ${slug}`));
598
+ }
599
+
600
+ deleteArtifactFn(resolutionPath);
601
+ deleteArtifactFn(dispositionPath);
602
+
603
+ return { consumed: true, ok: true, disposition };
604
+ }
605
+
606
+ // Module exports
607
+ module.exports = {
608
+ buildMetadataFooter,
609
+ reviewArtifactPath,
610
+ resolveArtifactDir,
611
+ readArtifactFile,
612
+ deleteArtifactFile,
613
+ normalizeReviewVerdict,
614
+ normalizeDisposition,
615
+ postWorkflowComment,
616
+ postWorkflowReview,
617
+ consumeReviewerArtifacts,
618
+ consumeImplementerArtifacts
619
+ };