@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.
- package/CHANGELOG.md +140 -0
- package/LICENSE +661 -0
- package/README.md +196 -0
- package/config/agents.json +25 -0
- package/config/agents.local.json.template +8 -0
- package/config/state-map.json +4 -0
- package/config/state-map.json.template +31 -0
- package/config/workflow.config.schema.json +98 -0
- package/data/.gitkeep +0 -0
- package/docs/adr/0031-ai-agent-instruction-boundary-and-command-floor.md +114 -0
- package/docs/adr/0032-mission-refinement-state-and-usage-budget-signals.md +135 -0
- package/docs/adr/0034-module-and-skill-invocation-model.md +202 -0
- package/docs/adr/0036-mission-sizing-and-dependency-wave-heuristics.md +79 -0
- package/docs/adr/0037-ai-workflow-coordination-architecture.md +162 -0
- package/docs/adr/0041-integration-pipeline-gates.md +165 -0
- package/docs/adr/0042-workflow-cli-color-rendering-approach.md +106 -0
- package/docs/adr/0043-git-target-resolution-strategy.md +185 -0
- package/docs/adr/0044-workflow-distribution-model.md +277 -0
- package/docs/adr/0045-parallax-branch-model.md +182 -0
- package/docs/adr/0046-npm-publish-process-and-security.md +138 -0
- package/docs/adr/index.md +20 -0
- package/docs/agents.md +212 -0
- package/docs/authority-reference.md +298 -0
- package/docs/forgejo-setup.md +31 -0
- package/docs/migration/extraction.md +61 -0
- package/docs/migration/task-classification.md +36 -0
- package/docs/operator-setup.md +76 -0
- package/docs/readme-rewrite-benchmark.md +188 -0
- package/docs/use-cases.md +105 -0
- package/examples/README.md +62 -0
- package/examples/run-enterprise-tarball-workflow-smoke.sh +257 -0
- package/examples/run-verify-env-smoke.sh +40 -0
- package/index.js +250 -0
- package/lib/README.md +13 -0
- package/lib/agents/agents.js +867 -0
- package/lib/agents/claude-telemetry.js +233 -0
- package/lib/agents/claude.js +139 -0
- package/lib/agents/codex-telemetry.js +202 -0
- package/lib/agents/codex.js +219 -0
- package/lib/agents/limit-hit.js +252 -0
- package/lib/agents/mistral-telemetry.js +44 -0
- package/lib/agents/mistral.js +68 -0
- package/lib/agents/opencode-export.js +110 -0
- package/lib/agents/opencode-telemetry.js +356 -0
- package/lib/agents/opencode.js +218 -0
- package/lib/agents/stage-telemetry.js +37 -0
- package/lib/commands/active.js +625 -0
- package/lib/commands/checkpoint.js +76 -0
- package/lib/commands/config.js +39 -0
- package/lib/commands/coverage-gate.js +358 -0
- package/lib/commands/diff.js +119 -0
- package/lib/commands/draft.js +854 -0
- package/lib/commands/handoff.js +501 -0
- package/lib/commands/integrate.js +1528 -0
- package/lib/commands/mission-start.js +246 -0
- package/lib/commands/rebase.js +597 -0
- package/lib/commands/repair-handoff.js +227 -0
- package/lib/commands/resolve-conflict.js +109 -0
- package/lib/commands/review.js +13 -0
- package/lib/commands/setup-review.js +13 -0
- package/lib/commands/setup.js +3 -0
- package/lib/commands/stats-backfill.js +395 -0
- package/lib/commands/stats.js +1601 -0
- package/lib/commands/status.js +183 -0
- package/lib/commands/verify.js +1 -0
- package/lib/core/fmt.js +202 -0
- package/lib/core/git.js +73 -0
- package/lib/core/gitignore.js +110 -0
- package/lib/core/mission-utils.js +1017 -0
- package/lib/core/persistent-data-migration.js +201 -0
- package/lib/core/product-config.js +508 -0
- package/lib/core/runtime-matrix.js +82 -0
- package/lib/core/spawn-tee.js +173 -0
- package/lib/core/state-map.js +89 -0
- package/lib/core/storage.js +165 -0
- package/lib/core/verification.js +149 -0
- package/lib/index.js +77 -0
- package/lib/review/rebase.js +163 -0
- package/lib/review/review-adapter.js +135 -0
- package/lib/review/review-artifacts.js +619 -0
- package/lib/review/review-commands.js +1375 -0
- package/lib/review/review-events.js +1007 -0
- package/lib/review/review-loop.js +1004 -0
- package/lib/review/review-polling.js +141 -0
- package/lib/review/review-prompts.js +212 -0
- package/lib/review/review-state.js +280 -0
- package/lib/review/review.js +96 -0
- package/lib/tools/backlog.js +680 -0
- package/lib/tools/forgejo.js +1585 -0
- package/lib/tools/gatekeeper.js +106 -0
- package/lib/tools/sessions.js +74 -0
- package/lib/tools/setup-review.js +1053 -0
- package/package.json +56 -0
- package/prompts/act-on-review-verbose.md +20 -0
- package/prompts/act-on-review.md +22 -0
- package/prompts/draft.md +20 -0
- package/prompts/execute.md +24 -0
- package/prompts/portfolio.md +30 -0
- package/prompts/review-verbose.md +20 -0
- package/prompts/review.md +17 -0
- package/px.js +236 -0
- package/templates/AGENTS-snippet.md +14 -0
- package/templates/AGENTS.md.template +34 -0
- package/templates/CLAUDE.md.template +27 -0
- package/templates/CODEX.md.template +38 -0
- package/templates/MISTRAL.md.template +24 -0
- package/templates/claude-commands/act-on-review.md +3 -0
- package/templates/claude-commands/area-review.md +3 -0
- package/templates/claude-commands/draft.md +6 -0
- package/templates/claude-commands/execute.md +6 -0
- package/templates/claude-commands/integrate.md +4 -0
- package/templates/claude-commands/portfolio.md +5 -0
- package/templates/claude-commands/review.md +4 -0
- package/templates/codex/config.toml +6 -0
- package/templates/mission-scaffold.md +39 -0
- package/templates/vibe/skills/act-on-review/SKILL.md +16 -0
- package/templates/vibe/skills/area-review/SKILL.md +16 -0
- package/templates/vibe/skills/draft/SKILL.md +16 -0
- package/templates/vibe/skills/execute/SKILL.md +16 -0
- package/templates/vibe/skills/integrate/SKILL.md +16 -0
- package/templates/vibe/skills/portfolio/SKILL.md +21 -0
- package/templates/vibe/skills/review/SKILL.md +16 -0
- 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
|
+
};
|