@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,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
+ };