@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,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Review Polling Module
|
|
3
|
+
* Extracted from parallix/lib/review.js for task-1201
|
|
4
|
+
* Handles polling for review outcomes and disposition comments.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fmt = require('../core/fmt');
|
|
8
|
+
const { getLatestReviewForPr, getLatestDispositionForPr } = require('./review-adapter');
|
|
9
|
+
|
|
10
|
+
const DEFAULT_POLL_INTERVAL_MS = 10_000;
|
|
11
|
+
const DEFAULT_POLL_MAX_WAIT_MS = 600_000;
|
|
12
|
+
const POLL_PROGRESS_EVERY_MS = 30_000;
|
|
13
|
+
|
|
14
|
+
const POLL_TIMEOUT = Object.freeze({ __isPollTimeout: true });
|
|
15
|
+
|
|
16
|
+
function delay(ms) {
|
|
17
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function resolvePollIntervalMs() {
|
|
21
|
+
const raw = process.env.AUTONOMOUS_REVIEW_POLL_INTERVAL_MS;
|
|
22
|
+
const parsed = raw ? parseInt(raw, 10) : NaN;
|
|
23
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_POLL_INTERVAL_MS;
|
|
24
|
+
return parsed;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function resolvePollTimeoutMs(explicitSeconds) {
|
|
28
|
+
if (Number.isFinite(explicitSeconds) && explicitSeconds > 0) {
|
|
29
|
+
return explicitSeconds * 1000;
|
|
30
|
+
}
|
|
31
|
+
const raw = process.env.AUTONOMOUS_REVIEW_POLL_TIMEOUT_MS;
|
|
32
|
+
const parsed = raw ? parseInt(raw, 10) : NaN;
|
|
33
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_POLL_MAX_WAIT_MS;
|
|
34
|
+
return parsed;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function formatElapsed(startMs) {
|
|
38
|
+
const secs = Math.round((Date.now() - startMs) / 1000);
|
|
39
|
+
return `${secs}s`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isPollTimeout(result) {
|
|
43
|
+
return result === POLL_TIMEOUT;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function pollForReview(prNumber, reviewerUser, sinceIso, token, options = {}) {
|
|
47
|
+
const {
|
|
48
|
+
getLatestReviewForPrFn = getLatestReviewForPr,
|
|
49
|
+
sleepFn = delay,
|
|
50
|
+
intervalMs = resolvePollIntervalMs(),
|
|
51
|
+
timeoutMs = resolvePollTimeoutMs(),
|
|
52
|
+
verbose = false,
|
|
53
|
+
label = 'review',
|
|
54
|
+
retryCount = 0,
|
|
55
|
+
log = fmt.log.plain
|
|
56
|
+
} = options;
|
|
57
|
+
if (!token) {
|
|
58
|
+
log(fmt.status('WARN', 'No Forgejo token — skipping review-outcome poll (manual handoff required).'));
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const start = Date.now();
|
|
63
|
+
const deadline = start + timeoutMs;
|
|
64
|
+
let lastProgressAt = start;
|
|
65
|
+
log(fmt.status('INFO', `Polling Forgejo for ${label} by ${reviewerUser} on PR #${prNumber} (timeout ${Math.round(timeoutMs / 1000)}s)...`));
|
|
66
|
+
|
|
67
|
+
while (Date.now() < deadline) {
|
|
68
|
+
const review = await getLatestReviewForPrFn(prNumber, reviewerUser, sinceIso, token);
|
|
69
|
+
if (review) {
|
|
70
|
+
if (verbose) log(fmt.status('INFO', `${label}: reviewer state=${review.state} after ${formatElapsed(start)}.`));
|
|
71
|
+
return review.state;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
if (verbose || now - lastProgressAt >= POLL_PROGRESS_EVERY_MS) {
|
|
76
|
+
log(fmt.status('INFO', `${label}: still waiting for ${reviewerUser}'s review... (${formatElapsed(start)} elapsed)`));
|
|
77
|
+
lastProgressAt = now;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
await sleepFn(intervalMs);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const isRetry = retryCount > 0;
|
|
84
|
+
const logLevel = isRetry ? 'INFO' : 'WARN';
|
|
85
|
+
log(fmt.status(logLevel, `${label}: no review posted by ${reviewerUser} within ${Math.round(timeoutMs / 1000)}s.`));
|
|
86
|
+
return POLL_TIMEOUT;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function pollForDisposition(prNumber, implementerUser, sinceIso, token, options = {}) {
|
|
90
|
+
const {
|
|
91
|
+
getLatestDispositionForPrFn = getLatestDispositionForPr,
|
|
92
|
+
sleepFn = delay,
|
|
93
|
+
intervalMs = resolvePollIntervalMs(),
|
|
94
|
+
timeoutMs = resolvePollTimeoutMs(),
|
|
95
|
+
verbose = false,
|
|
96
|
+
label = 'disposition',
|
|
97
|
+
retryCount = 0,
|
|
98
|
+
log = fmt.log.plain
|
|
99
|
+
} = options;
|
|
100
|
+
if (!token) {
|
|
101
|
+
log(fmt.status('WARN', 'No Forgejo token — skipping disposition poll (manual handoff required).'));
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const start = Date.now();
|
|
106
|
+
const deadline = start + timeoutMs;
|
|
107
|
+
let lastProgressAt = start;
|
|
108
|
+
log(fmt.status('INFO', `Polling Forgejo for ${label} by ${implementerUser} on PR #${prNumber} (timeout ${Math.round(timeoutMs / 1000)}s)...`));
|
|
109
|
+
|
|
110
|
+
while (Date.now() < deadline) {
|
|
111
|
+
const disposition = await getLatestDispositionForPrFn(prNumber, implementerUser, sinceIso, token);
|
|
112
|
+
if (disposition) {
|
|
113
|
+
if (verbose) log(fmt.status('INFO', `${label}: disposition=${disposition} after ${formatElapsed(start)}.`));
|
|
114
|
+
return disposition;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const now = Date.now();
|
|
118
|
+
if (verbose || now - lastProgressAt >= POLL_PROGRESS_EVERY_MS) {
|
|
119
|
+
log(fmt.status('INFO', `${label}: still waiting for ${implementerUser}'s disposition comment... (${formatElapsed(start)} elapsed)`));
|
|
120
|
+
lastProgressAt = now;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
await sleepFn(intervalMs);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const isRetry = retryCount > 0;
|
|
127
|
+
const logLevel = isRetry ? 'INFO' : 'WARN';
|
|
128
|
+
log(fmt.status(logLevel, `${label}: no disposition comment posted by ${implementerUser} within ${Math.round(timeoutMs / 1000)}s.`));
|
|
129
|
+
return POLL_TIMEOUT;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = {
|
|
133
|
+
POLL_TIMEOUT,
|
|
134
|
+
delay,
|
|
135
|
+
resolvePollIntervalMs,
|
|
136
|
+
resolvePollTimeoutMs,
|
|
137
|
+
formatElapsed,
|
|
138
|
+
isPollTimeout,
|
|
139
|
+
pollForReview,
|
|
140
|
+
pollForDisposition
|
|
141
|
+
};
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assemble review and act-on-review prompts for the autonomous review loop.
|
|
3
|
+
* Owned by the Node workflow harness (ADR 0037 / task-089).
|
|
4
|
+
*
|
|
5
|
+
* Node-invoked agent prompts read from parallix/prompts/*.md templates
|
|
6
|
+
* (same pattern as active.js / draft.js). Full verbose prompts are kept
|
|
7
|
+
* for --dry-run diagnostics and human/manual invocation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { getMissionYear, getPrimaryBranch, missionPathForSlug } = require('../core/mission-utils');
|
|
13
|
+
const { resolveArtifactDir } = require('./review-artifacts');
|
|
14
|
+
|
|
15
|
+
const REVIEW_PROMPT_PATH = path.join(__dirname, '..', '..', 'prompts', 'review.md');
|
|
16
|
+
const ACT_ON_REVIEW_PROMPT_PATH = path.join(__dirname, '..', '..', 'prompts', 'act-on-review.md');
|
|
17
|
+
const REVIEW_VERBOSE_PROMPT_PATH = path.join(__dirname, '..', '..', 'prompts', 'review-verbose.md');
|
|
18
|
+
const ACT_ON_REVIEW_VERBOSE_PROMPT_PATH = path.join(__dirname, '..', '..', 'prompts', 'act-on-review-verbose.md');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Slash-command / entrypoint used to invoke the review skill for each agent family.
|
|
22
|
+
* Moved from scripts/autonomous-review.sh prompt_entrypoint_for() into Node.
|
|
23
|
+
*/
|
|
24
|
+
const PROMPT_ENTRYPOINTS = {
|
|
25
|
+
codex: { review: '$review all', actOnReview: '$act-on-review' },
|
|
26
|
+
claude: { review: '/review all', actOnReview: '/act-on-review' },
|
|
27
|
+
mistral: { review: '$review all', actOnReview: '$act-on-review' },
|
|
28
|
+
qwen: { review: '$review all', actOnReview: '$act-on-review' },
|
|
29
|
+
autonomous: { review: '$review all', actOnReview: '$act-on-review' }
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function reviewEntrypoint(agent) {
|
|
33
|
+
const entry = PROMPT_ENTRYPOINTS[agent];
|
|
34
|
+
if (!entry) throw new Error(`Unknown agent family for review entrypoint: ${agent}`);
|
|
35
|
+
return entry.review;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function actOnReviewEntrypoint(agent) {
|
|
39
|
+
const entry = PROMPT_ENTRYPOINTS[agent];
|
|
40
|
+
if (!entry) throw new Error(`Unknown agent family for act-on-review entrypoint: ${agent}`);
|
|
41
|
+
return entry.actOnReview;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build the prompt sent to the reviewer agent for a given round.
|
|
46
|
+
*
|
|
47
|
+
* @param {object} opts
|
|
48
|
+
* @param {string} opts.reviewer - Reviewer agent family (codex|claude)
|
|
49
|
+
* @param {string} opts.branch - Mission branch name (e.g. 'mission/task-089')
|
|
50
|
+
* @param {string} opts.implementer - Implementer agent family
|
|
51
|
+
* @param {string} opts.focus - Review focus (default: 'all')
|
|
52
|
+
* @param {number} opts.attempt - Round number (1-based)
|
|
53
|
+
* @param {string} [opts.repoRoot] - Absolute repo root path
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
function resolveMissionPath(slug, repoRoot, missionPathOverride) {
|
|
57
|
+
// `--mission <path>` override: when the caller points the review loop at a
|
|
58
|
+
// mission contract in a non-standard location, agents must read that file
|
|
59
|
+
// instead of the slug-derived standard path (task-1272 SC7).
|
|
60
|
+
if (missionPathOverride) return missionPathOverride;
|
|
61
|
+
const root = repoRoot || process.cwd();
|
|
62
|
+
try {
|
|
63
|
+
return missionPathForSlug(root, slug);
|
|
64
|
+
} catch (_) {
|
|
65
|
+
const year = getMissionYear(slug, root);
|
|
66
|
+
return path.join(root, 'docs', 'missions', String(year), slug, 'MISSION.md');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function resolvePrimaryBranch(repoRoot) {
|
|
71
|
+
try {
|
|
72
|
+
return getPrimaryBranch(repoRoot || process.cwd());
|
|
73
|
+
} catch (_) {
|
|
74
|
+
return 'main';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildReviewPrompt({ reviewer, branch, implementer, focus = 'all', attempt, repoRoot = '', missionPath: missionPathOverride }) {
|
|
79
|
+
const entrypoint = reviewEntrypoint(reviewer);
|
|
80
|
+
const repoLine = repoRoot ? `\noperate from repo root: ${repoRoot}` : '';
|
|
81
|
+
const slug = branch.replace(/^mission\//, '');
|
|
82
|
+
const year = getMissionYear(slug, repoRoot || process.cwd());
|
|
83
|
+
const missionPath = resolveMissionPath(slug, repoRoot, missionPathOverride);
|
|
84
|
+
const artifactDir = resolveArtifactDir(repoRoot || process.cwd());
|
|
85
|
+
|
|
86
|
+
const template = fs.readFileSync(REVIEW_VERBOSE_PROMPT_PATH, 'utf8');
|
|
87
|
+
return template
|
|
88
|
+
.replaceAll('{{branch}}', branch)
|
|
89
|
+
.replaceAll('{{reviewer}}', reviewer)
|
|
90
|
+
.replaceAll('{{implementer}}', implementer)
|
|
91
|
+
.replaceAll('{{focus}}', focus)
|
|
92
|
+
.replaceAll('{{attempt}}', String(attempt))
|
|
93
|
+
.replaceAll('{{slug}}', slug)
|
|
94
|
+
.replaceAll('{{missionPath}}', missionPath)
|
|
95
|
+
.replaceAll('{{artifactDir}}', artifactDir)
|
|
96
|
+
.replaceAll('{{primaryBranch}}', resolvePrimaryBranch(repoRoot))
|
|
97
|
+
.replaceAll('{{review_entrypoint}}', entrypoint)
|
|
98
|
+
.replaceAll('YYYY', year)
|
|
99
|
+
.replaceAll('{{repo_line}}', repoLine ? repoLine.trim() + '\n- ' : '');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Build the prompt sent back to the implementer agent after a review round.
|
|
104
|
+
*
|
|
105
|
+
* @param {object} opts
|
|
106
|
+
* @param {string} opts.implementer - Implementer agent family
|
|
107
|
+
* @param {string} opts.branch - Mission branch name
|
|
108
|
+
* @param {number} opts.attempt - Round number (1-based)
|
|
109
|
+
* @param {string} [opts.repoRoot] - Absolute repo root path
|
|
110
|
+
* @returns {string}
|
|
111
|
+
*/
|
|
112
|
+
function buildActOnReviewPrompt({ implementer, branch, attempt, repoRoot = '', missionPath: missionPathOverride }) {
|
|
113
|
+
const entrypoint = actOnReviewEntrypoint(implementer);
|
|
114
|
+
const repoLine = repoRoot ? `\noperate from repo root: ${repoRoot}` : '';
|
|
115
|
+
const slug = branch.replace(/^mission\//, '');
|
|
116
|
+
const year = getMissionYear(slug, repoRoot || process.cwd());
|
|
117
|
+
const missionPath = resolveMissionPath(slug, repoRoot, missionPathOverride);
|
|
118
|
+
const artifactDir = resolveArtifactDir(repoRoot || process.cwd());
|
|
119
|
+
|
|
120
|
+
const template = fs.readFileSync(ACT_ON_REVIEW_VERBOSE_PROMPT_PATH, 'utf8');
|
|
121
|
+
return template
|
|
122
|
+
.replaceAll('{{branch}}', branch)
|
|
123
|
+
.replaceAll('{{implementer}}', implementer)
|
|
124
|
+
.replaceAll('{{attempt}}', String(attempt))
|
|
125
|
+
.replaceAll('{{slug}}', slug)
|
|
126
|
+
.replaceAll('{{missionPath}}', missionPath)
|
|
127
|
+
.replaceAll('{{artifactDir}}', artifactDir)
|
|
128
|
+
.replaceAll('{{primaryBranch}}', resolvePrimaryBranch(repoRoot))
|
|
129
|
+
.replaceAll('{{act_on_review_entrypoint}}', entrypoint)
|
|
130
|
+
.replaceAll('YYYY', year)
|
|
131
|
+
.replaceAll('{{repo_line}}', repoLine ? repoLine.trim() + '\n- ' : '');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Node-invoked reviewer prompt built from parallix/prompts/review.md template.
|
|
137
|
+
* Uses the same template-substitution pattern as active.js (execute.md).
|
|
138
|
+
*
|
|
139
|
+
* @param {object} opts
|
|
140
|
+
* @param {string} opts.reviewer - Reviewer agent family
|
|
141
|
+
* @param {string} opts.branch - Mission branch name
|
|
142
|
+
* @param {string} opts.implementer - Implementer agent family
|
|
143
|
+
* @param {string} opts.focus - Review focus (default: 'all')
|
|
144
|
+
* @param {number} opts.attempt - Round number (1-based)
|
|
145
|
+
* @param {string} [opts.repoRoot] - Absolute repo root path (unused but kept for API symmetry)
|
|
146
|
+
* @param {string} [opts.actualReviewer] - The actual agent family launched (overrides reviewer for identity text, TASK-1051)
|
|
147
|
+
* @returns {string}
|
|
148
|
+
*/
|
|
149
|
+
function buildCompactReviewPrompt({ reviewer, branch, implementer, focus = 'all', attempt, actualReviewer, repoRoot = '', missionPath: missionPathOverride }) {
|
|
150
|
+
const slug = branch.replace(/^mission\//, '');
|
|
151
|
+
const finalReviewer = actualReviewer || reviewer;
|
|
152
|
+
const year = getMissionYear(slug, repoRoot || process.cwd());
|
|
153
|
+
const missionPath = resolveMissionPath(slug, repoRoot, missionPathOverride);
|
|
154
|
+
const primaryBranch = resolvePrimaryBranch(repoRoot);
|
|
155
|
+
const artifactDir = resolveArtifactDir(repoRoot || process.cwd());
|
|
156
|
+
const template = fs.readFileSync(REVIEW_PROMPT_PATH, 'utf8');
|
|
157
|
+
return template
|
|
158
|
+
.replaceAll('{{branch}}', branch)
|
|
159
|
+
.replaceAll('{{reviewer}}', finalReviewer)
|
|
160
|
+
.replaceAll('{{implementer}}', implementer)
|
|
161
|
+
.replaceAll('{{focus}}', focus)
|
|
162
|
+
.replaceAll('{{attempt}}', String(attempt))
|
|
163
|
+
.replaceAll('{{slug}}', slug)
|
|
164
|
+
.replaceAll('{{missionPath}}', missionPath)
|
|
165
|
+
.replaceAll('{{artifactDir}}', artifactDir)
|
|
166
|
+
.replaceAll('{{primaryBranch}}', primaryBranch)
|
|
167
|
+
.replaceAll('YYYY', year)
|
|
168
|
+
.replaceAll('{{review_entrypoint}}', reviewEntrypoint(finalReviewer));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Node-invoked act-on-review prompt built from parallix/prompts/act-on-review.md template.
|
|
173
|
+
*
|
|
174
|
+
* @param {object} opts
|
|
175
|
+
* @param {string} opts.implementer - Implementer agent family
|
|
176
|
+
* @param {string} opts.branch - Mission branch name
|
|
177
|
+
* @param {number} opts.attempt - Round number (1-based)
|
|
178
|
+
* @param {string} [opts.reviewOutcome] - Outcome of latest review (e.g. 'REQUEST_CHANGES')
|
|
179
|
+
* @param {string} [opts.repoRoot] - Absolute repo root path
|
|
180
|
+
* @param {string} [opts.actualImplementer] - The actual agent family launched (overrides implementer for identity text, TASK-1051)
|
|
181
|
+
* @returns {string}
|
|
182
|
+
*/
|
|
183
|
+
function buildCompactActOnReviewPrompt({ implementer, branch, attempt, reviewOutcome = '?', actualImplementer, repoRoot = '', missionPath: missionPathOverride }) {
|
|
184
|
+
const slug = branch.replace(/^mission\//, '');
|
|
185
|
+
const finalImplementer = actualImplementer || implementer;
|
|
186
|
+
const year = getMissionYear(slug, repoRoot || process.cwd());
|
|
187
|
+
const missionPath = resolveMissionPath(slug, repoRoot, missionPathOverride);
|
|
188
|
+
const primaryBranch = resolvePrimaryBranch(repoRoot);
|
|
189
|
+
const artifactDir = resolveArtifactDir(repoRoot || process.cwd());
|
|
190
|
+
const template = fs.readFileSync(ACT_ON_REVIEW_PROMPT_PATH, 'utf8');
|
|
191
|
+
return template
|
|
192
|
+
.replaceAll('{{branch}}', branch)
|
|
193
|
+
.replaceAll('{{implementer}}', finalImplementer)
|
|
194
|
+
.replaceAll('{{attempt}}', String(attempt))
|
|
195
|
+
.replaceAll('{{slug}}', slug)
|
|
196
|
+
.replaceAll('{{missionPath}}', missionPath)
|
|
197
|
+
.replaceAll('{{artifactDir}}', artifactDir)
|
|
198
|
+
.replaceAll('{{primaryBranch}}', primaryBranch)
|
|
199
|
+
.replaceAll('{{review_outcome}}', reviewOutcome)
|
|
200
|
+
.replaceAll('YYYY', year)
|
|
201
|
+
.replaceAll('{{act_on_review_entrypoint}}', actOnReviewEntrypoint(finalImplementer));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = {
|
|
205
|
+
PROMPT_ENTRYPOINTS,
|
|
206
|
+
reviewEntrypoint,
|
|
207
|
+
actOnReviewEntrypoint,
|
|
208
|
+
buildReviewPrompt,
|
|
209
|
+
buildActOnReviewPrompt,
|
|
210
|
+
buildCompactReviewPrompt,
|
|
211
|
+
buildCompactActOnReviewPrompt
|
|
212
|
+
};
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reviewer-family persistence for the autonomous review loop.
|
|
3
|
+
*
|
|
4
|
+
* State is stored under the adapter-resolved mission directory on the mission branch.
|
|
5
|
+
* Writing and committing state before each round ensures session-restart safety.
|
|
6
|
+
*
|
|
7
|
+
* Owned by the Node workflow harness (ADR 0037 / task-089).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { git } = require('../core/git');
|
|
13
|
+
const { findMissionDir, resolveWorktree } = require('../core/mission-utils');
|
|
14
|
+
const fmt = require('../core/fmt');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Return the path to the review-state file for a given slug.
|
|
18
|
+
* Resolves using the same mission-dir discovery as other workflow commands.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} slug
|
|
21
|
+
* @param {string} [rootDir]
|
|
22
|
+
* @returns {string|null} Absolute path, or null if mission dir not found
|
|
23
|
+
*/
|
|
24
|
+
function reviewStateFile(slug, rootDir = process.cwd()) {
|
|
25
|
+
const missionDir = findMissionDir(slug, rootDir);
|
|
26
|
+
if (!missionDir) return null;
|
|
27
|
+
return path.join(missionDir, 'review-state.json');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read the persisted review state for a mission.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} slug
|
|
34
|
+
* @param {string} [rootDir] Directory to resolve the mission from (defaults to process.cwd())
|
|
35
|
+
* @returns {ReviewState|null}
|
|
36
|
+
*/
|
|
37
|
+
function readReviewState(slug, rootDir = process.cwd()) {
|
|
38
|
+
const statePath = reviewStateFile(slug, rootDir);
|
|
39
|
+
if (!statePath || !fs.existsSync(statePath)) return null;
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const raw = fs.readFileSync(statePath, 'utf8');
|
|
43
|
+
const parsed = JSON.parse(raw);
|
|
44
|
+
if (parsed && parsed.reviewer && parsed.implementer) {
|
|
45
|
+
return new ReviewState(slug, parsed);
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
} catch (_) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve the review-provider identity for a review workflow path.
|
|
55
|
+
*
|
|
56
|
+
* The normal contract is review-state-backed so standard review/handoff/rebase
|
|
57
|
+
* paths do not require a provider identity env var to be exported.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} slug
|
|
60
|
+
* @param {string} [rootDir]
|
|
61
|
+
* @param {object} [options]
|
|
62
|
+
* @returns {{ identityUser: string|null, commentIdentityUser: string|null, forgejoUser: string|null, commentForgejoUser: string|null, reviewState: ReviewState|null, source: 'review-state'|null }}
|
|
63
|
+
*/
|
|
64
|
+
function resolveReviewIdentity(slug, rootDir = process.cwd(), options = {}) {
|
|
65
|
+
const readReviewStateFn = options.readReviewStateFn || readReviewState;
|
|
66
|
+
const reviewState = readReviewStateFn(slug, rootDir);
|
|
67
|
+
const reviewerUser = reviewState ? (reviewState.reviewer || reviewState.implementer || null) : null;
|
|
68
|
+
const implementerUser = reviewState ? (reviewState.implementer || reviewState.reviewer || null) : null;
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
identityUser: reviewerUser || null,
|
|
72
|
+
commentIdentityUser: implementerUser || null,
|
|
73
|
+
// Legacy aliases kept for compatibility while generic review orchestration
|
|
74
|
+
// moves to provider-neutral option names.
|
|
75
|
+
forgejoUser: reviewerUser || null,
|
|
76
|
+
commentForgejoUser: implementerUser || null,
|
|
77
|
+
reviewState,
|
|
78
|
+
source: reviewerUser ? 'review-state' : null
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Manager class for mission review state.
|
|
84
|
+
* Expanded to include phase, disposition, and metadata for canonical state ownership.
|
|
85
|
+
*/
|
|
86
|
+
const VALID_PHASES = ['reviewing', 'fixing', 'pending-approval', 'approved'];
|
|
87
|
+
const PHASE_ALIASES = new Map([
|
|
88
|
+
['review', 'reviewing'],
|
|
89
|
+
['rewiewing', 'reviewing'],
|
|
90
|
+
['fix', 'fixing'],
|
|
91
|
+
['pending_approval', 'pending-approval'],
|
|
92
|
+
['pending approval', 'pending-approval']
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
const PHASE_TRANSITIONS = {
|
|
96
|
+
'reviewing': ['fixing', 'approved'],
|
|
97
|
+
'fixing': ['reviewing', 'pending-approval'],
|
|
98
|
+
'pending-approval': ['reviewing'],
|
|
99
|
+
'approved': []
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
function inferPhaseFromDisposition(disposition) {
|
|
103
|
+
switch (String(disposition || '').trim().toUpperCase()) {
|
|
104
|
+
case 'APPROVED':
|
|
105
|
+
return 'approved';
|
|
106
|
+
case 'REQUEST_CHANGES':
|
|
107
|
+
case 'COMMENT':
|
|
108
|
+
case 'PUSHBACK_ALL':
|
|
109
|
+
case 'BLOCKED':
|
|
110
|
+
case 'PARKED':
|
|
111
|
+
return 'fixing';
|
|
112
|
+
case 'CHANGES_MADE':
|
|
113
|
+
return 'reviewing';
|
|
114
|
+
default:
|
|
115
|
+
return 'reviewing';
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function normalizeReviewPhase(phase, disposition) {
|
|
120
|
+
if (!phase) {
|
|
121
|
+
return { phase: 'reviewing', original: null, normalized: false };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const raw = String(phase).trim();
|
|
125
|
+
const canonical = raw.toLowerCase().replace(/[_\s]+/g, '-');
|
|
126
|
+
if (VALID_PHASES.includes(canonical)) {
|
|
127
|
+
return { phase: canonical, original: raw, normalized: canonical !== raw };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const alias = PHASE_ALIASES.get(raw.toLowerCase()) || PHASE_ALIASES.get(canonical);
|
|
131
|
+
if (alias) {
|
|
132
|
+
return { phase: alias, original: raw, normalized: true };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
phase: inferPhaseFromDisposition(disposition),
|
|
137
|
+
original: raw,
|
|
138
|
+
normalized: true
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
class ReviewState {
|
|
143
|
+
constructor(slug, data = {}) {
|
|
144
|
+
const phaseInfo = normalizeReviewPhase(data.phase, data.disposition);
|
|
145
|
+
this.slug = slug;
|
|
146
|
+
this.reviewer = data.reviewer;
|
|
147
|
+
this.implementer = data.implementer;
|
|
148
|
+
this.round = data.round || 1;
|
|
149
|
+
this.startedAt = data.startedAt || new Date().toISOString();
|
|
150
|
+
this.phase = phaseInfo.phase;
|
|
151
|
+
this.disposition = data.disposition || null;
|
|
152
|
+
this.reviewerRetryCount = data.reviewerRetryCount || 0;
|
|
153
|
+
this.implementerRetryCount = data.implementerRetryCount || 0;
|
|
154
|
+
this.metadata = data.metadata || {};
|
|
155
|
+
this.phaseOriginal = phaseInfo.normalized ? phaseInfo.original : null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
static from(slug, dataOrInstance) {
|
|
159
|
+
if (dataOrInstance instanceof ReviewState) {
|
|
160
|
+
if (dataOrInstance.slug !== slug) {
|
|
161
|
+
throw new Error(`ReviewState slug mismatch: expected "${slug}", got "${dataOrInstance.slug}"`);
|
|
162
|
+
}
|
|
163
|
+
return dataOrInstance;
|
|
164
|
+
}
|
|
165
|
+
return new ReviewState(slug, dataOrInstance || {});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
transitionTo(nextPhase) {
|
|
169
|
+
if (!VALID_PHASES.includes(nextPhase)) {
|
|
170
|
+
throw new Error(`Invalid phase: "${nextPhase}". Valid: ${VALID_PHASES.join(', ')}`);
|
|
171
|
+
}
|
|
172
|
+
const allowed = PHASE_TRANSITIONS[this.phase];
|
|
173
|
+
if (!allowed || !allowed.includes(nextPhase)) {
|
|
174
|
+
throw new Error(`Cannot transition from "${this.phase}" to "${nextPhase}". Allowed: ${(allowed || []).join(', ') || 'none'}`);
|
|
175
|
+
}
|
|
176
|
+
this.phase = nextPhase;
|
|
177
|
+
return this;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
advanceRound() {
|
|
181
|
+
this.round += 1;
|
|
182
|
+
this.phase = 'reviewing';
|
|
183
|
+
this.disposition = null;
|
|
184
|
+
this.reviewerRetryCount = 0;
|
|
185
|
+
this.implementerRetryCount = 0;
|
|
186
|
+
this.startedAt = new Date().toISOString();
|
|
187
|
+
return this;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
toJSON() {
|
|
191
|
+
const payload = {
|
|
192
|
+
reviewer: this.reviewer,
|
|
193
|
+
implementer: this.implementer,
|
|
194
|
+
round: this.round,
|
|
195
|
+
startedAt: this.startedAt,
|
|
196
|
+
phase: this.phase,
|
|
197
|
+
disposition: this.disposition
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
if (this.reviewerRetryCount > 0) payload.reviewerRetryCount = this.reviewerRetryCount;
|
|
201
|
+
if (this.implementerRetryCount > 0) payload.implementerRetryCount = this.implementerRetryCount;
|
|
202
|
+
if (Object.keys(this.metadata).length > 0) payload.metadata = this.metadata;
|
|
203
|
+
|
|
204
|
+
return payload;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
save(worktree = resolveWorktree(this.slug) || process.cwd(), gitFn = git) {
|
|
208
|
+
const statePath = reviewStateFile(this.slug, worktree);
|
|
209
|
+
if (!statePath) return false;
|
|
210
|
+
|
|
211
|
+
const payload = this.toJSON();
|
|
212
|
+
fs.writeFileSync(statePath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
|
213
|
+
|
|
214
|
+
const relPath = path.relative(worktree, statePath);
|
|
215
|
+
gitFn(['-C', worktree, 'add', relPath]);
|
|
216
|
+
const msg = `review-state(${this.slug}): round ${this.round} (${this.phase}) [${this.reviewer} -> ${this.implementer}]${this.disposition ? ` disposition=${this.disposition}` : ''}`;
|
|
217
|
+
const result = gitFn(['-C', worktree, 'commit', '-m', msg]);
|
|
218
|
+
|
|
219
|
+
if (result.status !== 0) {
|
|
220
|
+
const statusResult = gitFn(['-C', worktree, 'status', '--porcelain', relPath]);
|
|
221
|
+
if (statusResult.stdout.trim() === '') {
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
fmt.log.warn(`Failed to commit review state update: ${result.stderr}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Write and commit the review state for a mission.
|
|
233
|
+
* Commits to the current branch so the state is visible after session restarts.
|
|
234
|
+
*
|
|
235
|
+
* @param {string} slug
|
|
236
|
+
* @param {ReviewState|object} state
|
|
237
|
+
* @returns {boolean} true if written successfully
|
|
238
|
+
*/
|
|
239
|
+
function writeReviewState(slug, state, worktree = resolveWorktree(slug) || process.cwd(), gitFn = git) {
|
|
240
|
+
const instance = state instanceof ReviewState ? state : new ReviewState(slug, state);
|
|
241
|
+
return instance.save(worktree, gitFn);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Delete the review state file for a mission (used by --reset).
|
|
246
|
+
* Commits the deletion if the file was tracked.
|
|
247
|
+
*
|
|
248
|
+
* @param {string} slug
|
|
249
|
+
* @returns {boolean} true if a file was deleted
|
|
250
|
+
*/
|
|
251
|
+
function resetReviewState(slug, worktree = resolveWorktree(slug) || process.cwd(), gitFn = git) {
|
|
252
|
+
const statePath = reviewStateFile(slug, worktree);
|
|
253
|
+
if (!statePath || !fs.existsSync(statePath)) return false;
|
|
254
|
+
|
|
255
|
+
fs.unlinkSync(statePath);
|
|
256
|
+
|
|
257
|
+
const relPath = path.relative(worktree, statePath);
|
|
258
|
+
try {
|
|
259
|
+
gitFn(['-C', worktree, 'rm', '--cached', relPath]);
|
|
260
|
+
const result = gitFn(['-C', worktree, 'commit', '-m', `review-state(${slug}): reset (--reset flag)`]);
|
|
261
|
+
if (result.status !== 0) {
|
|
262
|
+
fmt.log.warn(`Failed to commit review state reset: ${result.stderr}`);
|
|
263
|
+
}
|
|
264
|
+
} catch (_) {
|
|
265
|
+
// file may not have been tracked yet
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
module.exports = {
|
|
272
|
+
ReviewState,
|
|
273
|
+
VALID_PHASES,
|
|
274
|
+
normalizeReviewPhase,
|
|
275
|
+
reviewStateFile,
|
|
276
|
+
readReviewState,
|
|
277
|
+
resolveReviewIdentity,
|
|
278
|
+
writeReviewState,
|
|
279
|
+
resetReviewState
|
|
280
|
+
};
|