@magnusekdahl/parallix 1.0.4 → 1.1.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/lib/core/git.js CHANGED
@@ -50,6 +50,51 @@ function getUncommittedCount(cwd = process.cwd()) {
50
50
  return result.stdout.trim().split('\n').length;
51
51
  }
52
52
 
53
+ function parseUnmergedFiles(output = '') {
54
+ return Array.from(new Set(
55
+ output
56
+ .split('\n')
57
+ .map(line => line.trim())
58
+ .filter(Boolean)
59
+ .map(line => line.split('\t')[1])
60
+ .filter(Boolean)
61
+ ));
62
+ }
63
+
64
+ function detectRebaseState(cwd = process.cwd(), { gitRunner = git, fsModule = require('fs'), pathModule = require('path') } = {}) {
65
+ const gitDirResult = gitRunner(['-C', cwd, 'rev-parse', '--git-dir']);
66
+ const resolvedGitDir = gitDirResult.status === 0
67
+ ? gitDirResult.stdout.trim()
68
+ : '.git';
69
+ const gitDir = pathModule.isAbsolute(resolvedGitDir)
70
+ ? resolvedGitDir
71
+ : pathModule.join(cwd, resolvedGitDir);
72
+ const rebaseMergeDir = pathModule.join(gitDir, 'rebase-merge');
73
+ const rebaseApplyDir = pathModule.join(gitDir, 'rebase-apply');
74
+ const rebaseDir = fsModule.existsSync(rebaseMergeDir)
75
+ ? rebaseMergeDir
76
+ : (fsModule.existsSync(rebaseApplyDir) ? rebaseApplyDir : null);
77
+
78
+ const headResult = gitRunner(['-C', cwd, 'symbolic-ref', '--quiet', '--short', 'HEAD']);
79
+ const detached = headResult.status !== 0;
80
+
81
+ const showCurrentResult = gitRunner(['-C', cwd, 'rebase', '--show-current']);
82
+ const rebaseHead = showCurrentResult.status === 0 ? showCurrentResult.stdout.trim() : '';
83
+
84
+ const unmergedResult = gitRunner(['-C', cwd, 'ls-files', '-u']);
85
+ const unmergedFiles = unmergedResult.status === 0 ? parseUnmergedFiles(unmergedResult.stdout) : [];
86
+
87
+ const inProgress = Boolean(rebaseDir || rebaseHead || unmergedFiles.length > 0);
88
+
89
+ return {
90
+ inProgress,
91
+ rebaseHead,
92
+ detached,
93
+ unmergedFiles,
94
+ rebaseDir
95
+ };
96
+ }
97
+
53
98
  function getLastCommit() {
54
99
  const result = git(['log', '-1', '--format=%H|%ad|%s']);
55
100
  const [sha, date, subject] = result.stdout.trim().split('|');
@@ -68,6 +113,7 @@ module.exports = {
68
113
  getWorktreeStatus,
69
114
  isDirty,
70
115
  getUncommittedCount,
116
+ detectRebaseState,
71
117
  getLastCommit,
72
118
  getLastThreeCommits
73
119
  };
@@ -110,8 +110,16 @@ function serializeStatsRows(rows) {
110
110
  function migrateStats(options = {}) {
111
111
  const sourcePaths = options.sourcePaths || [options.sourcePath];
112
112
  const destinationPath = options.destinationPath || storage.resolveStatsPath({ ensureDir: true });
113
- const destinationRows = readStatsRows(destinationPath, options);
114
113
  const sourceRows = sourcePaths.flatMap(sourcePath => readStatsRows(sourcePath, options));
114
+
115
+ // Fresh-install guard: when no source file exists or all sources are empty,
116
+ // there is no telemetry to import. Returning early prevents writing a
117
+ // header-only destination file that would masquerade as valid stats.
118
+ if (sourceRows.length === 0) {
119
+ return { destinationPath, imported: 0, rows: 0, warn: 'no source data available' };
120
+ }
121
+
122
+ const destinationRows = readStatsRows(destinationPath, options);
115
123
  const rows = [];
116
124
  const seen = new Set();
117
125
 
@@ -20,6 +20,7 @@ const { createEvent, consumeHumanNotes, VALID_EVENT_TYPES } = require('./review-
20
20
  const { workflowLauncherStatus, startAgent, eligibleAgentsForStep, selectAgent } = require('../agents/agents');
21
21
  const { performHandoff } = require('../commands/handoff');
22
22
  const { commitSafeMissionArtifacts, rebaseBeforeReviewRound } = require('./rebase');
23
+ const { resolveAgentModel } = require('../core/product-config');
23
24
 
24
25
  // Import from review-polling module
25
26
  const { POLL_TIMEOUT, delay, resolvePollIntervalMs, resolvePollTimeoutMs, formatElapsed, isPollTimeout, pollForReview, pollForDisposition } = require('./review-polling');
@@ -36,7 +37,7 @@ const { resolveStageTelemetry } = require('../agents/stage-telemetry');
36
37
  // Codex writes a fresh-counter rollout per launch, so we sum total_token_usage
37
38
  // across every rollout since the FIRST launch for this stored mission phase and
38
39
  // agent family. For non-Codex families we accumulate launcher-attached telemetry
39
- // one launch at a time, de-duped via review-state metadata, so Claude/Qwen rows
40
+ // one launch at a time, de-duped via review-state metadata, so Claude/custom rows
40
41
  // are cumulative too. A family switch creates a separate row instead of mixing
41
42
  // agents in one record.
42
43
  function stageLaunchFingerprint(agentFamily, result) {
@@ -71,7 +72,7 @@ function markStageLaunchRecorded(state, { stage, agentFamily, result, slug, work
71
72
  return true;
72
73
  }
73
74
 
74
- function recordStageStatsSafe(kind, { stage, slug, rootDir, worktree, implementer, reviewer, result, sinceMs, log, error, state, writeReviewStateFn = writeReviewState }) {
75
+ function recordStageStatsSafe(kind, { stage, slug, rootDir, worktree, implementer, reviewer, result, sinceMs, log, error, state, writeReviewStateFn = writeReviewState, model = null }) {
75
76
  let durationMinutes = 0;
76
77
  if (result && result.startedAt && result.endedAt) {
77
78
  durationMinutes = (Date.parse(result.endedAt) - Date.parse(result.startedAt)) / 60000;
@@ -93,7 +94,7 @@ function recordStageStatsSafe(kind, { stage, slug, rootDir, worktree, implemente
93
94
  })) {
94
95
  return;
95
96
  }
96
- stats.accumulateStageStats({ stage, slug, rootDir, implementer, reviewer, telemetry, durationMinutes });
97
+ stats.accumulateStageStats({ stage, slug, rootDir, implementer, reviewer, telemetry, durationMinutes, model });
97
98
  } catch (err) {
98
99
  // Best-effort: never escalate to the fatal `error` channel.
99
100
  log(fmt.status('WARN', `Could not record ${kind} stats for ${slug}: ${err.message}`));
@@ -593,10 +594,19 @@ async function startReviewLoop(slug, opts = {}) {
593
594
 
594
595
  log(fmt.status('INFO', `Selected reviewer: ${reviewer} (${reviewerSource})`));
595
596
 
596
- // Build the canonical ReviewState instance — either from persisted state or fresh
597
- const state = persisted && persisted.reviewer === reviewer && persisted.implementer === implementer
598
- ? ReviewState.from(slug, persisted)
599
- : new ReviewState(slug, { reviewer, implementer });
597
+ // Build the canonical ReviewState instance. When persisted state exists we
598
+ // resume from it — preserving round, startedAt, phase, disposition, retry
599
+ // counts, and metadata — and only overwrite the identities with the ones
600
+ // selected for this launch (which may differ after a reviewer/implementer
601
+ // fallback). Constructing fresh would silently reset all persisted progress.
602
+ let state;
603
+ if (persisted) {
604
+ state = ReviewState.from(slug, persisted);
605
+ state.reviewer = reviewer;
606
+ state.implementer = implementer;
607
+ } else {
608
+ state = new ReviewState(slug, { reviewer, implementer });
609
+ }
600
610
  persistNormalizedPhaseRepair(slug, state, worktree, { log, writeReviewStateFn });
601
611
 
602
612
  if (persisted && state.round > 1 && !dryRun) {
@@ -703,7 +713,8 @@ async function startReviewLoop(slug, opts = {}) {
703
713
  recordStageStatsSafeFn('review', {
704
714
  stage: 'review', slug, rootDir: worktree, worktree, reviewer, implementer,
705
715
  result: reviewerLaunchResult && reviewerLaunchResult.result,
706
- sinceMs: reviewSinceMs, log, error, state, writeReviewStateFn
716
+ sinceMs: reviewSinceMs, log, error, state, writeReviewStateFn,
717
+ model: resolveAgentModel(reviewer, worktree),
707
718
  });
708
719
  }
709
720
 
@@ -920,7 +931,8 @@ async function startReviewLoop(slug, opts = {}) {
920
931
  recordStageStatsSafeFn('active', {
921
932
  stage: 'follow-up', slug, rootDir: worktree, worktree, implementer, reviewer,
922
933
  result: implementerLaunchResult && implementerLaunchResult.result,
923
- sinceMs: followUpSinceMs, log, error, state, writeReviewStateFn
934
+ sinceMs: followUpSinceMs, log, error, state, writeReviewStateFn,
935
+ model: resolveAgentModel(implementer, worktree),
924
936
  });
925
937
  }
926
938
 
@@ -25,7 +25,7 @@ const PROMPT_ENTRYPOINTS = {
25
25
  codex: { review: '$review all', actOnReview: '$act-on-review' },
26
26
  claude: { review: '/review all', actOnReview: '/act-on-review' },
27
27
  mistral: { review: '$review all', actOnReview: '$act-on-review' },
28
- qwen: { review: '$review all', actOnReview: '$act-on-review' },
28
+ custom: { review: '$review all', actOnReview: '$act-on-review' },
29
29
  autonomous: { review: '$review all', actOnReview: '$act-on-review' }
30
30
  };
31
31
 
@@ -70,6 +70,7 @@ module.exports.consumeReviewerArtifacts = consumeReviewerArtifacts;
70
70
  module.exports.consumeImplementerArtifacts = consumeImplementerArtifacts;
71
71
 
72
72
  // From review-loop
73
+ module.exports.recordStageStatsSafe = recordStageStatsSafe;
73
74
  module.exports.maybeUpdateGraphifyBeforeReview = maybeUpdateGraphifyBeforeReview;
74
75
  module.exports.commitSafeMissionArtifacts = commitSafeMissionArtifacts;
75
76
  module.exports.rebaseBeforeReviewRound = rebaseBeforeReviewRound;
@@ -441,6 +441,17 @@ function createPr(branch, user, token, options = {}) {
441
441
  return { ok: false, error: `failed to sync primary baseline: ${syncResult.error || syncResult.stderr}` };
442
442
  }
443
443
 
444
+ // 1b. Feature-branch missions target a non-primary PR base that must exist on
445
+ // the review remote before the PR is opened. syncPrimaryBaseline only mirrors
446
+ // the primary branch, so push the feature base branch too.
447
+ if (prBase !== primaryBranch) {
448
+ log(`Mirroring PR base branch ${prBase} to review remote...`);
449
+ const baseSync = ensureRemoteBaseBranch(prBase, gitUser, gitToken, rootDir);
450
+ if (!baseSync.ok) {
451
+ return { ok: false, error: `failed to sync PR base branch ${prBase}: ${baseSync.error || baseSync.stderr}` };
452
+ }
453
+ }
454
+
444
455
  // 2. Push the branch using authenticated URL
445
456
  const remoteUrl = authenticatedReviewUrl(gitUser, gitToken, rootDir);
446
457
  log(`Pushing ${branch} as Forgejo user ${gitUser}${force || forceWithLease ? ' (force-with-lease)' : ''}...`);
@@ -762,6 +773,35 @@ function syncPrimaryBaseline(user, token, rootDir = process.cwd(), {
762
773
  };
763
774
  }
764
775
 
776
+ function ensureRemoteBaseBranch(baseBranch, user, token, rootDir = process.cwd(), {
777
+ gitRunner = git.git
778
+ } = {}) {
779
+ // A feature-branch mission opens its PR against a non-primary base branch.
780
+ // syncPrimaryBaseline only mirrors the primary branch, so the PR base would
781
+ // not exist on the review remote and PR creation would fail server-side.
782
+ // Mirror the local feature base branch here before the PR is opened.
783
+ const exists = gitRunner(['-C', rootDir, 'show-ref', '--verify', '--quiet', `refs/heads/${baseBranch}`]);
784
+ if (exists.status !== 0) {
785
+ return { ok: false, error: `PR base branch ${baseBranch} does not exist locally` };
786
+ }
787
+
788
+ const remoteUrl = authenticatedReviewUrl(user, token, rootDir);
789
+ // Force-push for the same reason syncPrimaryBaseline force-pushes the primary
790
+ // branch: the review remote's copy is an intentional server-side mirror of our
791
+ // local branch. A diverged remote (e.g. from a prior sub-mission integration)
792
+ // would otherwise reject a fast-forward push and break PR creation.
793
+ const result = gitRunner(['-C', rootDir, 'push', '--force', remoteUrl, `${baseBranch}:${baseBranch}`], {
794
+ stdio: 'pipe'
795
+ });
796
+
797
+ return {
798
+ ok: result.status === 0,
799
+ status: result.status,
800
+ stderr: result.stderr,
801
+ error: result.status === 0 ? null : `${result.stderr || 'git push failed'}${result.error ? ` (${codexSandboxHint()})` : ''}`
802
+ };
803
+ }
804
+
765
805
  function pushReviewRef(sourceRef, destinationRef, rootDir = process.cwd(), {
766
806
  force = false,
767
807
  forceWithLease = false,
@@ -1245,6 +1285,7 @@ function syncMerged(branch, mergedCommit, options = {}) {
1245
1285
  forgejoUser,
1246
1286
  rootDir = process.cwd(),
1247
1287
  token: providedToken,
1288
+ baseBranch = null,
1248
1289
  apiCall = forgejoApi,
1249
1290
  resolvePrNumber = getPrNumber,
1250
1291
  gitPush = pushReviewRef,
@@ -1315,11 +1356,17 @@ function syncMerged(branch, mergedCommit, options = {}) {
1315
1356
  };
1316
1357
  };
1317
1358
 
1318
- let primaryBranch = 'main';
1319
- try {
1320
- primaryBranch = getPrimaryBranch(rootDir);
1321
- } catch (_) {
1322
- primaryBranch = 'main';
1359
+ // The mission lands back onto its recorded base branch, which is the primary
1360
+ // branch (main) for normal missions but a feature branch (e.g. skunkworks) for
1361
+ // missions started from a feature branch. Pushing the squash commit anywhere
1362
+ // other than that base branch is a non-fast-forward and is rejected.
1363
+ let primaryBranch = baseBranch;
1364
+ if (!primaryBranch) {
1365
+ try {
1366
+ primaryBranch = getPrimaryBranch(rootDir);
1367
+ } catch (_) {
1368
+ primaryBranch = 'main';
1369
+ }
1323
1370
  }
1324
1371
  log(`PR #${prNumber} (${branch}): pushing landed commit ${mergedCommit} to Forgejo ${primaryBranch}...`);
1325
1372
  const pushMasterResult = gitPush(mergedCommit, `refs/heads/${primaryBranch}`, rootDir, { user: forgejoUser, token });
@@ -1578,6 +1625,7 @@ module.exports = {
1578
1625
  reviewRemoteUrl,
1579
1626
  authenticatedReviewUrl,
1580
1627
  syncPrimaryBaseline,
1628
+ ensureRemoteBaseBranch,
1581
1629
  createPr,
1582
1630
  getCommentsSync,
1583
1631
  getComments,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magnusekdahl/parallix",
3
- "version": "1.0.4",
3
+ "version": "1.1.0",
4
4
  "description": "AI mission workflow toolkit with a px CLI — local-first, human-in-the-loop multi-agent development",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "private": false,
package/prompts/draft.md CHANGED
@@ -1,10 +1,22 @@
1
- Mode: draft. No execution.
1
+ Mode: draft. Do not implement the mission — produce the mission contract document only.
2
2
  Mission slug: {{slug}}
3
3
  Mission path: {{missionPath}}
4
4
  Backlog task: {{taskPath}}
5
5
 
6
6
  The harness has already created the mission branch, worktree, scaffolded `{{missionPath}}`, and ensured the backlog task exists. Your job is to read the user's intent from `{{taskPath}}` and fill `{{missionPath}}` with a real mission contract.
7
7
 
8
+ Allowed actions:
9
+ - read files (backlog task, MISSION.md scaffold, graphify index if present)
10
+ - write/edit files (MISSION.md, backlog task labels)
11
+ - run graphify queries and updates
12
+ - run `{{verifyCmd}}` to verify the draft
13
+
14
+ Forbidden actions:
15
+ - implement any feature or fix described in the mission
16
+ - modify source code outside MISSION.md and the backlog task file
17
+ - run tests beyond the single `{{verifyCmd}}` gate
18
+ - start a review, execute, or integrate phase
19
+
8
20
  Drafting requirements:
9
21
  - fill every scaffolded section in `{{missionPath}}` with concrete, non-generic content (no placeholders, no "TBD")
10
22
  - include a Goal, Why now, Scope, Out of scope, Success criteria, Risks/assumptions, Checkpoints, Gates, Restricted areas, and Stop rules
@@ -16,5 +16,21 @@ Requirements:
16
16
  - write the formal review message to `{{artifactDir}}/{{slug}}-review-outcome.md`
17
17
  - write the formal review verdict (`approve` or `request-changes`) to `{{artifactDir}}/{{slug}}-review-verdict.txt`; `comment` is not a valid outcome — if you have findings but the criteria pass, use `request-changes`
18
18
  - do not post to Forgejo directly; `px` will consume the artifact files, publish them, and advance review state
19
- - do not edit repo files
20
19
  - stop once the artifact files are written
20
+
21
+ Separation of duties — you are the reviewer, not the implementer. Stay in review-only mode:
22
+
23
+ You MUST NOT:
24
+ - edit, create, or delete any repo source, config, test, or doc file to fix a problem — report it as a finding instead of touching the file
25
+ - fix bugs, refactor, complete unfinished work, or otherwise "improve" the diff under review; reviewing is not implementing
26
+ - run branch-history operations: no rebase, squash, amend, `git reset`, force-push, or branch deletion
27
+ - run merge or PR operations: no merge, push, opening/closing/merging PRs, or posting to Forgejo directly
28
+ - mutate workflow state: do not write or edit checkpoint documents, mission artifacts, act-on-review files, or any review-loop/review-state files
29
+
30
+ You MUST:
31
+ - review the full mission diff, confirm the final checkpoint's goal-check evidence, and write the findings, outcome, and verdict artifacts
32
+ - report any inconsistency (workflow state, prompts, PR history) as a finding rather than resolving it yourself
33
+
34
+ You MAY (these writes are the sole exceptions to "no repo edits"):
35
+ - write to the artifact directory `{{artifactDir}}` (findings, outcome, verdict)
36
+ - create temporary diagnostic files under `/tmp`
package/prompts/review.md CHANGED
@@ -1,17 +1,50 @@
1
- Mode: review. No code changes, no repo-state edits.
1
+ Mode: review. No code changes, commits, repo-state edits, or implementer behavior.
2
2
  Mission: {{missionPath}}
3
3
  Attempt: {{attempt}}. Focus: {{focus}}.
4
-
5
4
  Entrypoint: {{review_entrypoint}}
6
5
 
6
+ Load before reviewing:
7
+ - `AGENTS.md`
8
+ - locked mission at `{{missionPath}}`
9
+ - final checkpoint document, if present
10
+ - diff: `git diff {{primaryBranch}}..HEAD`
11
+
7
12
  Minimum loop contract:
8
13
  - Load the locked mission at `{{missionPath}}` and `AGENTS.md` before reviewing.
9
14
  - Run `px review {{slug}} --verify`.
10
- - Review the diff with `git diff {{primaryBranch}}..HEAD`. Do a detailed review, since agents tend to miss stuff and just check off boxes in the checkpoints, which is not the intent here.
15
+ - Review as an independent senior engineer. Approve only if the mission is satisfied, verification is credible for the risk level, and the diff is safe to integrate.
16
+ - Request changes for actionable issues introduced or materially worsened by this mission.
11
17
  - Confirm the final checkpoint document in the mission directory contains a Goal Check table citing real evidence (file:line, test names).
18
+ Check:
19
+ - mission scope and acceptance criteria
20
+ - final checkpoint claims vs actual diff
21
+ - correctness and regressions
22
+ - tests / gates / verification evidence
23
+ - security and unsafe operations
24
+ - integration with existing code, config, APIs, schemas, docs, or workflows
25
+ - maintainability issues that materially affect future work
26
+
12
27
  - Write findings to `{{artifactDir}}/{{slug}}-review-findings.md`.
13
28
  - Write the formal outcome to `{{artifactDir}}/{{slug}}-review-outcome.md` and the legacy verdict (`approve` | `request-changes`) to `{{artifactDir}}/{{slug}}-review-verdict.txt`. `comment` is not a valid outcome: if you have findings but the criteria pass, use `request-changes`.
29
+ - Do not call px directly, the workflow will do that for you
14
30
  - Do not post to Forgejo directly; `px review {{slug}} --start` or `--submit` publishes the artifacts.
15
31
  - Do not edit repo files; do not switch into implementer behavior.
16
32
  - If workflow state, prompts, or PR history are inconsistent, report that inconsistency as a finding rather than fixing it.
17
33
  - Graphify-first: before reviewing, check if `graphify-out/graph.json` exists. If it does, run `graphify query "review {{slug}} for correctness and completeness"` to get a graph-based view of the mission scope before examining the diff.
34
+
35
+ Separation of duties — you are the reviewer, not the implementer. Stay in review-only mode:
36
+
37
+ You MUST NOT:
38
+ - Edit, create, or delete any repo source, config, test, or doc file to fix a problem — report it as a finding instead of touching the file
39
+ - Fix bugs, refactor, complete unfinished work, or otherwise "improve" the diff under review; reviewing is not implementing
40
+ - Run branch-history operations: no rebase, squash, amend, `git reset`, force-push, or branch deletion
41
+ - Run merge or PR operations: no merge, push, opening/closing/merging PRs, or posting to Forgejo directly
42
+ - Mutate workflow state: do not write or edit checkpoint documents, mission artifacts, act-on-review files, or any review-loop/review-state files
43
+
44
+ You MUST:
45
+ - Review the full mission diff, confirm the final checkpoint's goal-check evidence, and write the findings, outcome, and verdict artifacts
46
+ - Report any inconsistency (workflow state, prompts, PR history) as a finding rather than resolving it yourself
47
+
48
+ You MAY (these writes are the sole exceptions to "no repo edits"):
49
+ - Write to the artifact directory `{{artifactDir}}` (findings, outcome, verdict)
50
+ - Create temporary diagnostic files under `/tmp`.
@@ -67,8 +67,11 @@ cat <<EOF
67
67
  [INFO] Next steps:
68
68
  [INFO] 1. docker compose -f forgejo/docker-compose.yml up -d
69
69
  [INFO] 2. Open http://localhost:\${FORGEJO_PORT:-3300}
70
- [INFO] 3. Read parallix/docs/forgejo-setup.md for token and remote wiring
71
- [INFO] 4. Run px setup after export to finish config and review wiring
70
+ [INFO] 3. Create the agent accounts (owner + codex/claude/custom/mistral) with
71
+ [INFO] 'forgejo admin user create' — px setup mints tokens but does not create
72
+ [INFO] accounts. See parallix/docs/forgejo-setup.md ("Create the agent accounts").
73
+ [INFO] 4. Read parallix/docs/forgejo-setup.md for token and remote wiring
74
+ [INFO] 5. Run px setup after export to finish config and review wiring
72
75
  EOF
73
76
 
74
77
  if [[ ${START} -eq 1 ]]; then