@magnusekdahl/parallix 1.1.0 → 1.2.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 (68) hide show
  1. package/config/integration-pipelines.json +9 -0
  2. package/config/workflow.config.schema.json +12 -0
  3. package/docs/adr/0032-mission-refinement-state-and-usage-budget-signals.md +10 -8
  4. package/docs/adr/0036-mission-sizing-and-dependency-wave-heuristics.md +14 -12
  5. package/docs/adr/0041-integration-pipeline-gates.md +1 -1
  6. package/docs/adr/0047-per-mission-change-size-budget.md +161 -0
  7. package/docs/adr/index.md +1 -0
  8. package/docs/use-cases.md +44 -0
  9. package/lib/agents/agents.js +149 -100
  10. package/lib/agents/claude-telemetry.js +14 -10
  11. package/lib/agents/claude.js +32 -11
  12. package/lib/agents/codex-telemetry.js +32 -19
  13. package/lib/agents/codex.js +36 -11
  14. package/lib/agents/limit-hit.js +42 -25
  15. package/lib/agents/mistral-telemetry.js +1 -1
  16. package/lib/agents/mistral.js +13 -3
  17. package/lib/agents/opencode-export.js +11 -5
  18. package/lib/agents/opencode-telemetry.js +75 -90
  19. package/lib/agents/opencode.js +86 -29
  20. package/lib/agents/stage-telemetry.js +3 -7
  21. package/lib/commands/active.js +139 -94
  22. package/lib/commands/checkpoint.js +3 -1
  23. package/lib/commands/config.js +5 -3
  24. package/lib/commands/coverage-gate.js +17 -10
  25. package/lib/commands/diff.js +11 -5
  26. package/lib/commands/draft.js +95 -55
  27. package/lib/commands/handoff.js +253 -65
  28. package/lib/commands/integrate.js +288 -188
  29. package/lib/commands/mission-start.js +39 -34
  30. package/lib/commands/rebase.js +32 -21
  31. package/lib/commands/repair-handoff.js +21 -18
  32. package/lib/commands/resolve-conflict.js +5 -2
  33. package/lib/commands/review.js +1 -0
  34. package/lib/commands/setup-review.js +1 -0
  35. package/lib/commands/stats-backfill.js +50 -41
  36. package/lib/commands/stats.js +653 -204
  37. package/lib/commands/status.js +33 -28
  38. package/lib/core/fmt.js +58 -15
  39. package/lib/core/git.js +7 -5
  40. package/lib/core/gitignore.js +6 -4
  41. package/lib/core/mission-utils.js +161 -88
  42. package/lib/core/nels.js +199 -0
  43. package/lib/core/persistent-data-migration.js +55 -23
  44. package/lib/core/product-config.js +44 -23
  45. package/lib/core/runtime-matrix.js +15 -2
  46. package/lib/core/spawn-tee.js +40 -13
  47. package/lib/core/state-map.js +32 -13
  48. package/lib/core/storage.js +19 -5
  49. package/lib/core/subagent-limit.js +28 -0
  50. package/lib/core/verification.js +29 -12
  51. package/lib/review/rebase.js +11 -3
  52. package/lib/review/review-adapter.js +93 -6
  53. package/lib/review/review-artifacts.js +89 -23
  54. package/lib/review/review-commands.js +139 -45
  55. package/lib/review/review-events.js +103 -52
  56. package/lib/review/review-loop.js +117 -87
  57. package/lib/review/review-polling.js +25 -5
  58. package/lib/review/review-prompts.js +36 -5
  59. package/lib/review/review-state.js +39 -15
  60. package/lib/tools/backlog.js +162 -71
  61. package/lib/tools/forgejo.js +290 -129
  62. package/lib/tools/gatekeeper.js +15 -0
  63. package/lib/tools/redgreen.js +220 -0
  64. package/lib/tools/sessions.js +12 -5
  65. package/lib/tools/setup-review.js +170 -70
  66. package/package.json +11 -3
  67. package/prompts/draft.md +6 -0
  68. package/templates/mission-scaffold.md +1 -1
@@ -14,6 +14,11 @@ const DEFAULT_GATEKEEPER_USER = 'forgejo-gatekeeper';
14
14
  // reviewer cycle. Returns { ok, missing, skipped, posted } so callers can
15
15
  // surface a clear log line without needing to re-derive state.
16
16
 
17
+ /**
18
+ * @param {string} slug
19
+ * @param {{rootDir?: string, findMissionDirFn?: Function, findCheckpointsFn?: Function, resolveTaskFileFn?: Function}} [options]
20
+ * @returns {{ok: boolean, missing: string[]}}
21
+ */
17
22
  function checkMandatoryFiles(slug, options = {}) {
18
23
  const {
19
24
  rootDir = process.cwd(),
@@ -52,6 +57,11 @@ function checkMandatoryFiles(slug, options = {}) {
52
57
  return { ok: missing.length === 0, missing };
53
58
  }
54
59
 
60
+ /**
61
+ * @param {string} slug
62
+ * @param {string[]} missing
63
+ * @returns {string}
64
+ */
55
65
  function buildPushbackBody(slug, missing) {
56
66
  const bullets = missing.map(item => `- ${item}`).join('\n');
57
67
  return [
@@ -65,6 +75,11 @@ function buildPushbackBody(slug, missing) {
65
75
  ].join('\n');
66
76
  }
67
77
 
78
+ /**
79
+ * @param {string} slug
80
+ * @param {{rootDir?: string, branch?: string, user?: string, log?: Function, readTokenFn?: Function, postReviewFn?: Function, checkFn?: Function}} [options]
81
+ * @returns {{ok: boolean, missing: string[], skipped: boolean, posted: boolean}}
82
+ */
68
83
  function runGatekeeper(slug, options = {}) {
69
84
  const {
70
85
  rootDir = process.cwd(),
@@ -0,0 +1,220 @@
1
+ const fs = require('fs');
2
+ const os = require('os');
3
+ const path = require('path');
4
+ const fmt = require('../core/fmt');
5
+ const { findMissionDir, findCheckpoints, missionBranchName, resolveMissionBaseBranch } = require('../core/mission-utils');
6
+ const { git, run } = require('../core/git');
7
+
8
+ const REPRO_TEST_MARKER = /^Reproduction-Test:\s*(.+?)\s*$/m;
9
+
10
+ /**
11
+ * Locate the reproduction test path from a `Reproduction-Test: <path>` line in
12
+ * MISSION.md or any checkpoint document. Returns null when none is declared.
13
+ */
14
+ function findReproTestPath(slug, rootDir, options = {}) {
15
+ const {
16
+ findMissionDirFn = findMissionDir,
17
+ findCheckpointsFn = findCheckpoints
18
+ } = options;
19
+
20
+ const missionDir = findMissionDirFn(slug, rootDir);
21
+ if (!missionDir) {return null;}
22
+
23
+ const candidates = [path.join(missionDir, 'MISSION.md')];
24
+ try {
25
+ candidates.push(...findCheckpointsFn(missionDir));
26
+ } catch (_) {
27
+ // missionDir may not be readable; MISSION.md alone is enough to try
28
+ }
29
+
30
+ for (const file of candidates) {
31
+ if (!file || !fs.existsSync(file)) {continue;}
32
+ const match = fs.readFileSync(file, 'utf8').match(REPRO_TEST_MARKER);
33
+ if (match) {return match[1].trim();}
34
+ }
35
+ return null;
36
+ }
37
+
38
+ /**
39
+ * Resolve the commit where the mission branch diverged from its base branch.
40
+ * Uses merge-base so a rebased/advanced base still yields the true fork point.
41
+ */
42
+ function resolveMissionParentCommit(slug, rootDir, options = {}) {
43
+ const { gitFn = null } = options;
44
+ const runner = gitFn || git;
45
+ const branch = options.branch || missionBranchName(slug, rootDir);
46
+ let base;
47
+ try {
48
+ base = resolveMissionBaseBranch(slug, rootDir, { gitFn: runner });
49
+ } catch (_) {
50
+ return null;
51
+ }
52
+ const result = runner(['-C', rootDir, 'merge-base', base, branch]);
53
+ const sha = ((result && result.stdout) || '').trim();
54
+ return result && result.status === 0 && sha ? sha : null;
55
+ }
56
+
57
+ /**
58
+ * Run a test against the source at `ref`, overlaying the test file from
59
+ * `headRef` so the test exists even at the parent commit (where it was not
60
+ * yet authored). Runs inside a throwaway detached worktree so the live
61
+ * worktree is never mutated.
62
+ */
63
+ function runReproAtRef(ref, testPath, options = {}) {
64
+ const {
65
+ rootDir = process.cwd(),
66
+ headRef = 'HEAD',
67
+ gitFn = null,
68
+ runCommandFn = null
69
+ } = options;
70
+ const gitRunner = gitFn || git;
71
+ const cmdRunner = runCommandFn || run;
72
+
73
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'redgreen-'));
74
+ const worktreePath = path.join(tmp, 'wt');
75
+ try {
76
+ const add = gitRunner(['-C', rootDir, 'worktree', 'add', '--detach', worktreePath, ref]);
77
+ if (!add || add.status !== 0) {
78
+ return { status: null, skipped: true, reason: 'worktree-add-failed' };
79
+ }
80
+
81
+ // Overlay the reproduction test from the branch HEAD so the parent ref runs
82
+ // the same test against pre-fix source.
83
+ const show = gitRunner(['-C', rootDir, 'show', `${headRef}:${testPath}`]);
84
+ if (show && show.status === 0) {
85
+ const dest = path.join(worktreePath, testPath);
86
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
87
+ fs.writeFileSync(dest, show.stdout);
88
+ }
89
+
90
+ const result = cmdRunner('node', ['--test', testPath], { cwd: worktreePath });
91
+ return { status: result && typeof result.status === 'number' ? result.status : 1 };
92
+ } finally {
93
+ try { gitRunner(['-C', rootDir, 'worktree', 'remove', '--force', worktreePath]); } catch (_) {}
94
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {}
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Verify the red→green proof for a mission that declares a reproduction test.
100
+ * Returns:
101
+ * { ok: true, skipped: true } — no usable test runner
102
+ * { ok: true, skipped: false } — repro failed at parent and passed at HEAD
103
+ * { ok: false, ... , error } — repro missing, passed at parent, or failed at HEAD
104
+ */
105
+ function verifyRedGreenProof(slug, options = {}) {
106
+ const {
107
+ rootDir = process.cwd(),
108
+ log = fmt.log.plain,
109
+ headRef = 'HEAD',
110
+ findReproTestPathFn = findReproTestPath,
111
+ resolveMissionParentCommitFn = resolveMissionParentCommit,
112
+ runReproAtRefFn = runReproAtRef,
113
+ branch: branchOverride
114
+ } = options;
115
+ const branch = branchOverride || missionBranchName(slug, rootDir);
116
+
117
+ // Strip injected-function keys from options before passing to child calls
118
+ const childOpts = { ...options };
119
+ delete childOpts.findReproTestPathFn;
120
+ delete childOpts.resolveMissionParentCommitFn;
121
+ delete childOpts.runReproAtRefFn;
122
+ delete childOpts.branch;
123
+
124
+ // 1. Locate the reproduction test declared by the mission.
125
+ const testPath = findReproTestPathFn(slug, rootDir, childOpts);
126
+ if (!testPath) {
127
+ const errMsg = `Mission ${slug} declares no \`Reproduction-Test:\` line in MISSION.md or any checkpoint document.`;
128
+ log(fmt.status('FAIL', errMsg));
129
+ return { ok: false, skipped: false, reason: 'repro-not-declared', error: errMsg };
130
+ }
131
+
132
+ // 2. Resolve the parent commit (fork point) of the mission branch.
133
+ const parentCommit = resolveMissionParentCommitFn(slug, rootDir, { branch, ...childOpts });
134
+ if (!parentCommit) {
135
+ const errMsg = `Could not resolve the parent commit for mission ${slug} (ambiguous or missing merge base).`;
136
+ log(fmt.status('FAIL', errMsg));
137
+ return { ok: false, skipped: false, reason: 'parent-unresolved', error: errMsg };
138
+ }
139
+
140
+ // 3. Red: the reproduction test must FAIL at the parent commit.
141
+ const red = runReproAtRefFn(parentCommit, testPath, { rootDir, headRef, ...childOpts });
142
+ if (red && red.skipped) {
143
+ log(fmt.status('WARN', `Red→green gate: no usable test runner for ${slug} (${red.reason || 'unknown'}); skipping.`));
144
+ return { ok: true, skipped: true, reason: red.reason || 'no-test-runner' };
145
+ }
146
+ if (red.status === 0) {
147
+ const errMsg = `Reproduction test ${testPath} PASSED at parent commit ${parentCommit.slice(0, 12)} — it must fail (red) to lock the bug.`;
148
+ log(fmt.status('FAIL', errMsg));
149
+ return { ok: false, skipped: false, reason: 'not-red', error: errMsg, testPath, parentCommit };
150
+ }
151
+
152
+ // 4. Green: the reproduction test must PASS at HEAD.
153
+ const green = runReproAtRefFn(headRef, testPath, { rootDir, headRef, ...childOpts });
154
+ if (green && green.skipped) {
155
+ log(fmt.status('WARN', `Red→green gate: could not run repro at HEAD for ${slug} (${green.reason || 'unknown'}); skipping.`));
156
+ return { ok: true, skipped: true, reason: green.reason || 'no-test-runner' };
157
+ }
158
+ if (green.status !== 0) {
159
+ const errMsg = `Reproduction test ${testPath} FAILED at HEAD — the fix must make it pass (green).`;
160
+ log(fmt.status('FAIL', errMsg));
161
+ return { ok: false, skipped: false, reason: 'not-green', error: errMsg, testPath, parentCommit };
162
+ }
163
+
164
+ log(fmt.status('INFO', `Red→green gate: ${slug} repro ${testPath} red at ${parentCommit.slice(0, 12)}, green at HEAD.`));
165
+ return { ok: true, skipped: false, reason: 'red-green-verified', testPath, parentCommit };
166
+ }
167
+
168
+ /**
169
+ * CLI entry point: px verify-repro --test <path> --slug <slug>
170
+ * Standalone command for verifying red→green proof outside the handoff flow.
171
+ */
172
+ function main() {
173
+ const args = process.argv.slice(2);
174
+ let slug = null;
175
+ let testPath = null;
176
+ let rootDir = process.cwd();
177
+
178
+ for (let i = 0; i < args.length; i++) {
179
+ if (args[i] === '--slug' && args[i + 1]) { slug = args[++i]; }
180
+ else if (args[i] === '--test' && args[i + 1]) { testPath = args[++i]; }
181
+ else if (args[i] === '--root' && args[i + 1]) { rootDir = args[++i]; }
182
+ }
183
+
184
+ if (!slug) {
185
+ process.stderr.write('Usage: node lib/tools/redgreen.js --slug <slug> --test <path>\n');
186
+ process.exit(1);
187
+ }
188
+
189
+ if (!testPath) {
190
+ // Try to find it from MISSION.md
191
+ testPath = findReproTestPath(slug, rootDir);
192
+ if (!testPath) {
193
+ process.stderr.write(`No --test specified and no Reproduction-Test: line found for ${slug}\n`);
194
+ process.exit(1);
195
+ }
196
+ }
197
+
198
+ const result = verifyRedGreenProof(slug, { rootDir, log: process.stdout.write.bind(process.stdout), testPath });
199
+ if (!result.ok) {
200
+ process.stderr.write(`FAIL: ${result.error}\n`);
201
+ process.exit(1);
202
+ }
203
+ if (result.skipped) {
204
+ process.stdout.write(`SKIP: ${result.reason}\n`);
205
+ process.exit(0);
206
+ }
207
+ process.stdout.write(`PASS: ${result.reason} (test: ${result.testPath})\n`);
208
+ process.exit(0);
209
+ }
210
+
211
+ if (require.main === module) {
212
+ main();
213
+ }
214
+
215
+ module.exports = {
216
+ findReproTestPath,
217
+ resolveMissionParentCommit,
218
+ runReproAtRef,
219
+ verifyRedGreenProof
220
+ };
@@ -6,27 +6,31 @@ const path = require('path');
6
6
  // .workflow/sessions/ which is already gitignored, so markers live with the
7
7
  // mission worktree and survive harness restarts but never travel through git.
8
8
 
9
+ /** @param {string} worktree */
9
10
  function sessionsDir(worktree) {
10
11
  return path.join(worktree, '.workflow', 'sessions');
11
12
  }
12
13
 
14
+ /** @param {string} worktree @param {string} slug @param {string} role */
13
15
  function sessionFile(worktree, slug, role) {
14
16
  return path.join(sessionsDir(worktree), `${slug}-${role}.json`);
15
17
  }
16
18
 
19
+ /** @param {string} worktree @param {string} slug @param {string} role */
17
20
  function readSession(worktree, slug, role) {
18
- if (!worktree || !slug || !role) return null;
21
+ if (!worktree || !slug || !role) {return null;}
19
22
  const file = sessionFile(worktree, slug, role);
20
- if (!fs.existsSync(file)) return null;
23
+ if (!fs.existsSync(file)) {return null;}
21
24
  try {
22
25
  const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
23
- if (parsed && typeof parsed.agent === 'string') return parsed;
26
+ if (parsed && typeof parsed.agent === 'string') {return parsed;}
24
27
  return null;
25
28
  } catch (_) {
26
29
  return null;
27
30
  }
28
31
  }
29
32
 
33
+ /** @param {string} worktree @param {string} slug @param {string} role @param {{agent: string, lastLaunched?: string, sessionId?: string|null}} payload */
30
34
  function writeSession(worktree, slug, role, payload) {
31
35
  if (!worktree || !slug || !role || !payload || typeof payload.agent !== 'string') {
32
36
  return false;
@@ -44,21 +48,24 @@ function writeSession(worktree, slug, role, payload) {
44
48
  // Resume only when the previous launch in this (slug, role) used the same
45
49
  // agent family. A fallback to a different family invalidates the marker —
46
50
  // the new family has no prior session to continue.
51
+ /** @param {string} worktree @param {string} slug @param {string} role @param {string} agent */
47
52
  function shouldResume(worktree, slug, role, agent) {
48
53
  const prev = readSession(worktree, slug, role);
49
54
  return Boolean(prev && prev.agent === agent);
50
55
  }
51
56
 
52
57
  // Return the session ID from the marker, if one was persisted.
58
+ /** @param {string} worktree @param {string} slug @param {string} role */
53
59
  function getSessionId(worktree, slug, role) {
54
60
  const prev = readSession(worktree, slug, role);
55
61
  return prev && prev.sessionId ? prev.sessionId : null;
56
62
  }
57
63
 
64
+ /** @param {string} worktree @param {string} slug @param {string} role */
58
65
  function clearSession(worktree, slug, role) {
59
- if (!worktree || !slug || !role) return false;
66
+ if (!worktree || !slug || !role) {return false;}
60
67
  const file = sessionFile(worktree, slug, role);
61
- if (!fs.existsSync(file)) return false;
68
+ if (!fs.existsSync(file)) {return false;}
62
69
  fs.unlinkSync(file);
63
70
  return true;
64
71
  }