@yemi33/minions 0.1.2379 → 0.1.2381

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 (100) hide show
  1. package/bin/minions.js +19 -9
  2. package/dashboard/js/refresh.js +3 -4
  3. package/dashboard/js/render-other.js +43 -23
  4. package/dashboard/js/render-prd.js +1 -1
  5. package/dashboard/js/render-work-items.js +13 -6
  6. package/dashboard/js/settings.js +40 -17
  7. package/dashboard.js +435 -768
  8. package/docs/architecture-review-2026-07-09.md +2 -4
  9. package/docs/auto-discovery.md +36 -49
  10. package/docs/blog-first-successful-dispatch.md +1 -1
  11. package/docs/branch-derivation.md +2 -2
  12. package/docs/command-center.md +1 -1
  13. package/docs/completion-reports.md +14 -4
  14. package/docs/constellation-bridge.md +59 -10
  15. package/docs/constellation-style-telemetry.md +6 -6
  16. package/docs/cooldown-merge-semantics.md +4 -0
  17. package/docs/copilot-cli-schema.md +3 -3
  18. package/docs/cross-repo-plans.md +17 -17
  19. package/docs/deprecated.json +2 -2
  20. package/docs/design-state-storage.md +1 -1
  21. package/docs/documentation-audit-2026-07-09.md +2 -2
  22. package/docs/engine-restart.md +20 -8
  23. package/docs/harness-mode.md +1 -1
  24. package/docs/live-checkout-mode.md +45 -26
  25. package/docs/managed-spawn.md +4 -4
  26. package/docs/onboarding.md +1 -2
  27. package/docs/pr-comment-followup.md +3 -3
  28. package/docs/pr-review-fix-loop.md +1 -1
  29. package/docs/proposals/repo-pool-for-live-checkout.md +1 -2
  30. package/docs/qa-runbook-lifecycle.md +4 -4
  31. package/docs/qa-runbooks.md +2 -2
  32. package/docs/rfc-completion-json.md +4 -1
  33. package/docs/runtime-adapters.md +1 -1
  34. package/docs/self-improvement.md +4 -5
  35. package/docs/shared-lifecycle-module-map.md +3 -1
  36. package/docs/slim-ux/architecture-suggestions.md +5 -6
  37. package/docs/slim-ux/concepts.md +23 -25
  38. package/docs/watches.md +7 -7
  39. package/docs/workspace-manifests.md +1 -1
  40. package/docs/worktree-lifecycle.md +1 -1
  41. package/engine/abandoned-pr-reconciliation.js +4 -5
  42. package/engine/ado-status.js +5 -5
  43. package/engine/ado.js +20 -25
  44. package/engine/agent-worker-pool.js +58 -1
  45. package/engine/bridge.js +260 -5
  46. package/engine/cleanup.js +48 -131
  47. package/engine/cli.js +125 -83
  48. package/engine/cooldown.js +9 -16
  49. package/engine/db/index.js +22 -9
  50. package/engine/db/migrations/009-qa.js +1 -1
  51. package/engine/db/migrations/020-qa-session-scopes.js +23 -0
  52. package/engine/db/migrations/021-archived-work-items.js +72 -0
  53. package/engine/db/migrations/022-global-cc-session.js +31 -0
  54. package/engine/db/migrations/023-engine-state.js +30 -0
  55. package/engine/db/migrations/024-prd-ghost-collisions.js +53 -0
  56. package/engine/db/migrations/025-malformed-work-item-phantoms.js +33 -0
  57. package/engine/dispatch-store.js +2 -7
  58. package/engine/dispatch.js +39 -44
  59. package/engine/github.js +20 -27
  60. package/engine/lifecycle.js +279 -354
  61. package/engine/live-checkout.js +193 -149
  62. package/engine/llm.js +1 -1
  63. package/engine/logs-store.js +2 -2
  64. package/engine/managed-spawn.js +2 -23
  65. package/engine/meeting.js +6 -6
  66. package/engine/metrics-store.js +2 -2
  67. package/engine/note-link-backfill.js +6 -11
  68. package/engine/pipeline.js +18 -36
  69. package/engine/playbook.js +13 -16
  70. package/engine/prd-store.js +73 -54
  71. package/engine/preflight.js +2 -5
  72. package/engine/projects.js +15 -62
  73. package/engine/pull-requests-store.js +0 -17
  74. package/engine/qa-runbooks.js +2 -2
  75. package/engine/qa-runs.js +1 -8
  76. package/engine/qa-sessions.js +41 -64
  77. package/engine/queries.js +120 -219
  78. package/engine/routing.js +4 -6
  79. package/engine/scheduler.js +0 -4
  80. package/engine/shared-branch-pr-reconcile.js +2 -3
  81. package/engine/shared.js +268 -699
  82. package/engine/small-state-store.js +89 -10
  83. package/engine/state-operations.js +16 -4
  84. package/engine/stdio-timestamps.js +1 -1
  85. package/engine/timeout.js +5 -12
  86. package/engine/watch-actions.js +20 -22
  87. package/engine/watches-store.js +1 -1
  88. package/engine/watches.js +6 -10
  89. package/engine/work-item-validation.js +52 -0
  90. package/engine/work-items-store.js +127 -29
  91. package/engine/worktree-gc.js +2 -2
  92. package/engine/worktree-pool.js +8 -18
  93. package/engine.js +197 -358
  94. package/minions.js +2 -2
  95. package/package.json +1 -1
  96. package/playbooks/plan-to-prd.md +2 -2
  97. package/playbooks/shared-rules.md +3 -3
  98. package/playbooks/templates/followup-dispatch.md +1 -1
  99. package/playbooks/verify.md +1 -1
  100. package/prompts/cc-system.md +9 -9
@@ -110,9 +110,11 @@
110
110
  * MOSTLY PURE — does NOT call completeDispatch. It does NOT mutate the tree or
111
111
  * write inbox alerts on any default path. The ONE opt-in exception is auto-reset
112
112
  * (W-mqvejug6000eeb20): when `liveCheckoutAutoReset` is enabled AND the tree is
113
- * dirty, it runs `git fetch origin` + `git reset --hard origin/<branch>` and
114
- * writes a single `live-checkout-autoreset-<wiId>` inbox note. Both the reset
115
- * git ops and the note writer are injectable (`_git`, `_writeInboxNote`) and the
113
+ * dirty, it runs `git reset --hard HEAD` + `git clean -fd` and writes a single
114
+ * `live-checkout-autoreset-<wiId>` inbox note. This preserves the current branch
115
+ * and committed history while explicitly discarding tracked and untracked WIP.
116
+ * Both destructive git ops and the note writer are injectable (`_git`,
117
+ * `_writeInboxNote`) and the
116
118
  * decision is injectable (`autoReset` / `_resolveAutoReset`), so unit tests stay
117
119
  * filesystem-free. Default (auto-reset off) keeps the helper free of side
118
120
  * effects — the dirty/blocked translation belongs in spawnAgent (engine.js).
@@ -568,6 +570,63 @@ async function _maybeAutoFreshenLocalMain(opts = {}) {
568
570
  return { freshened: true, before: beforeSha, after: afterSha, behind };
569
571
  }
570
572
 
573
+ async function _inspectLiveCheckoutHead({ git, baseOpts, localPath, exists }) {
574
+ let gitDir;
575
+ try {
576
+ const gitDirRaw = await git(['rev-parse', '--git-dir'], baseOpts);
577
+ const gitDirStr = (typeof gitDirRaw === 'string' ? gitDirRaw : '').trim();
578
+ if (!gitDirStr) throw new Error('git rev-parse --git-dir returned empty output');
579
+ gitDir = path.isAbsolute(gitDirStr) ? gitDirStr : path.join(localPath, gitDirStr);
580
+ } catch (e) {
581
+ throw new Error('prepareLiveCheckout: could not resolve git dir for ' + localPath + ' — ' + (e && e.message));
582
+ }
583
+
584
+ const midOperationSentinels = [
585
+ { name: 'MERGE_HEAD', op: 'merge' },
586
+ { name: 'rebase-merge', op: 'rebase' },
587
+ { name: 'rebase-apply', op: 'rebase' },
588
+ { name: 'CHERRY_PICK_HEAD', op: 'cherry-pick' },
589
+ { name: 'REVERT_HEAD', op: 'revert' },
590
+ { name: 'BISECT_LOG', op: 'bisect' },
591
+ ];
592
+ for (const sentinel of midOperationSentinels) {
593
+ const sentinelPath = path.join(gitDir, sentinel.name);
594
+ if (exists(sentinelPath)) {
595
+ return { ok: false, reason: 'mid-operation', op: sentinel.op, details: sentinelPath };
596
+ }
597
+ }
598
+
599
+ try {
600
+ await git(['symbolic-ref', '-q', 'HEAD'], baseOpts);
601
+ } catch (symErr) {
602
+ if (!symErr || symErr.code !== 1) throw symErr;
603
+ let sha = '';
604
+ try {
605
+ const shaRaw = await git(['rev-parse', 'HEAD'], baseOpts);
606
+ sha = (typeof shaRaw === 'string' ? shaRaw : '').trim();
607
+ } catch {
608
+ // Best-effort diagnostic only.
609
+ }
610
+ return { ok: false, reason: 'detached-head', sha };
611
+ }
612
+
613
+ let originalRef = '';
614
+ let originalRefType = 'branch';
615
+ try {
616
+ const refRaw = await git(['symbolic-ref', '--short', 'HEAD'], baseOpts);
617
+ originalRef = (typeof refRaw === 'string' ? refRaw : '').trim();
618
+ } catch {
619
+ originalRefType = 'detached';
620
+ try {
621
+ const refRaw = await git(['rev-parse', 'HEAD'], baseOpts);
622
+ originalRef = (typeof refRaw === 'string' ? refRaw : '').trim();
623
+ } catch {
624
+ originalRef = '';
625
+ }
626
+ }
627
+ return { ok: true, originalRef, originalRefType };
628
+ }
629
+
571
630
  async function prepareLiveCheckout(opts = {}) {
572
631
  const {
573
632
  localPath,
@@ -655,6 +714,19 @@ async function prepareLiveCheckout(opts = {}) {
655
714
  // currently-running git process is never removed out from under it.
656
715
  shared.removeStaleIndexLock(localPath, { log: (level, msg) => logFn(msg, level) });
657
716
 
717
+ let originalRef = '';
718
+ let originalRefType = 'branch';
719
+ let headInspected = false;
720
+ const ensureHeadInspected = async () => {
721
+ if (headInspected) return null;
722
+ const state = await _inspectLiveCheckoutHead({ git, baseOpts, localPath, exists });
723
+ if (!state.ok) return state;
724
+ originalRef = state.originalRef;
725
+ originalRefType = state.originalRefType;
726
+ headInspected = true;
727
+ return null;
728
+ };
729
+
658
730
  // ── Step 1: git status --porcelain=v1 -b. Bail early on dirty tree. ─────
659
731
  // Skipped when `skipDirtyCheck:true` (issue #522): GVFS/VFS-for-Git repos
660
732
  // report all un-hydrated virtual files as modified — this is normal GVFS
@@ -726,6 +798,8 @@ async function prepareLiveCheckout(opts = {}) {
726
798
  if (dirtyFiles.length > 0) {
727
799
  const strayBranch = _extractBranchFromPorcelainHeader(branchInfo);
728
800
  if (strayBranch && strayBranch !== branchName && _looksLikeAgentBranch(strayBranch)) {
801
+ const blocked = await ensureHeadInspected();
802
+ if (blocked) return blocked;
729
803
  const commitRes = await _commitAgentWipWithRetry(
730
804
  git, baseOpts,
731
805
  `minions: auto-save orphaned WIP from ${strayBranch} (dispatch ${dispatchId || '(unknown)'})`,
@@ -737,8 +811,19 @@ async function prepareLiveCheckout(opts = {}) {
737
811
  `, switching back to '${mainRef}'`, 'info');
738
812
  try {
739
813
  await git(['checkout', mainRef], baseOpts);
814
+ originalRef = mainRef;
815
+ originalRefType = 'branch';
740
816
  } catch (backErr) {
741
817
  logFn(`[live-checkout] could not switch back to '${mainRef}' after self-healing '${strayBranch}': ${backErr && backErr.message}`, 'warn');
818
+ return {
819
+ ok: false,
820
+ reason: 'wrong-base',
821
+ headBranch: strayBranch,
822
+ expectedBase: mainRef,
823
+ switchError: String((backErr && backErr.message) || '').slice(0, 500),
824
+ originalRef,
825
+ originalRefType,
826
+ };
742
827
  }
743
828
  // Re-run the dirty check. Either the tree is now clean (self-heal
744
829
  // succeeded end-to-end) or something new appeared (rare) — either
@@ -751,13 +836,9 @@ async function prepareLiveCheckout(opts = {}) {
751
836
  }
752
837
  }
753
838
  if (dirtyFiles.length > 0) {
754
- // W-mqvejug6000eeb20 — opt-in auto-reset. Default behavior is to bail with
755
- // reason:'dirty' (spawnAgent translates to non-retryable LIVE_CHECKOUT_DIRTY
756
- // and alerts the operator). When auto-reset is enabled either the caller
757
- // passed an explicit `autoReset` boolean, or the config-based resolver says
758
- // so — we DISCARD the dirty state via `git fetch origin` + `git reset --hard
759
- // origin/<branch>` and continue. This is destructive (the operator’s
760
- // uncommitted work is gone), which is why it is strictly opt-in.
839
+ // W-mqvejug6000eeb20 — opt-in destructive recovery. It may discard
840
+ // tracked and untracked working-tree changes, but must never repoint the
841
+ // operator's current branch or discard committed history.
761
842
  let wantAutoReset = false;
762
843
  if (typeof autoReset === 'boolean') {
763
844
  wantAutoReset = autoReset;
@@ -769,176 +850,126 @@ async function prepareLiveCheckout(opts = {}) {
769
850
  return { ok: false, reason: 'dirty', dirtyFiles, branchInfo };
770
851
  }
771
852
 
772
- // W-mrav3u0c0008ce38 live-checkout WI types that never push a branch
773
- // (setup/test/verify live-validation dispatches on checkoutMode:'live'
774
- // or hybrid liveValidation projects) leave `origin/<branchName>`
775
- // unresolvable, so a bare `reset --hard origin/<branchName>` fails every
776
- // time with "ambiguous argument … unknown revision" and the tree stays
777
- // dirty forever. Probe `origin/<branchName>` first (after the fetch, so
778
- // it reflects the latest remote state); only fall back to
779
- // `origin/<mainRef>` when that branch genuinely doesn't exist upstream.
780
- // PR-continuation flows (fix/implement WIs resuming an existing pushed
781
- // branch) keep resetting to `origin/<branchName>` exactly as before.
782
- let resetOk = true;
783
- let resetTarget = `origin/${branchName}`;
853
+ const blocked = await ensureHeadInspected();
854
+ if (blocked) return blocked;
855
+ const autoResetAttempt = {
856
+ resetSucceeded: false,
857
+ cleanAttempted: false,
858
+ cleanSucceeded: false,
859
+ recheckSucceeded: false,
860
+ errors: [],
861
+ };
784
862
  try {
785
- await git(['fetch', 'origin'], baseOpts);
786
- try {
787
- await git(['rev-parse', '--verify', resetTarget], baseOpts);
788
- } catch {
789
- resetTarget = `origin/${mainRef}`;
790
- if (typeof log === 'function') {
791
- log(`live-checkout auto-reset: origin/${branchName} does not exist (never pushed) — falling back to '${resetTarget}'`);
792
- }
793
- }
794
863
  if (typeof log === 'function') {
795
- log(`live-checkout auto-reset: discarding ${dirtyFiles.length} dirty path(s) on '${branchName}' via fetch + reset --hard ${resetTarget}`);
864
+ log(`live-checkout auto-reset: discarding ${dirtyFiles.length} dirty path(s) without changing the current branch or HEAD`);
796
865
  }
797
- await git(['reset', '--hard', resetTarget], baseOpts);
866
+ await git(['reset', '--hard', 'HEAD'], baseOpts);
867
+ autoResetAttempt.resetSucceeded = true;
798
868
  } catch (e) {
799
- resetOk = false;
869
+ const error = e && e.message ? e.message : String(e);
870
+ autoResetAttempt.errors.push(`reset --hard HEAD: ${error}`);
800
871
  if (typeof log === 'function') {
801
- log(`live-checkout auto-reset FAILED (fetch/reset): ${e && e.message ? e.message : e}`);
872
+ log(`live-checkout auto-reset FAILED (reset --hard HEAD): ${error}`);
802
873
  }
803
874
  }
804
-
805
- // Re-run the porcelain preflight once. If the tree is now clean we proceed;
806
- // otherwise (reset failed, or something is still dirty) we fall back to the
807
- // safe dirty refusal so we never dispatch onto an unexpected tree.
808
- let stillDirty = dirtyFiles;
809
- if (resetOk) {
875
+ if (autoResetAttempt.resetSucceeded) {
876
+ autoResetAttempt.cleanAttempted = true;
810
877
  try {
811
- const recheckRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
812
- const recheckStr = typeof recheckRaw === 'string' ? recheckRaw : '';
813
- stillDirty = recheckStr
814
- .split(/\r?\n/)
815
- .map((line) => line.replace(/\s+$/, ''))
816
- .filter((line) => line.length > 0 && !line.startsWith('## '));
878
+ await git(['clean', '-fd'], baseOpts);
879
+ autoResetAttempt.cleanSucceeded = true;
817
880
  } catch (e) {
818
- resetOk = false;
881
+ const error = e && e.message ? e.message : String(e);
882
+ autoResetAttempt.errors.push(`clean -fd: ${error}`);
819
883
  if (typeof log === 'function') {
820
- log(`live-checkout auto-reset re-check FAILED: ${e && e.message ? e.message : e}`);
884
+ log(`live-checkout auto-reset FAILED (clean -fd): ${error}`);
821
885
  }
822
886
  }
823
887
  }
824
888
 
825
- if (!resetOk || stillDirty.length > 0) {
826
- return { ok: false, reason: 'dirty', dirtyFiles: stillDirty, branchInfo };
889
+ // A failed git command may still have changed part of the tree. Always
890
+ // re-run porcelain after the destructive attempt so both the refusal and
891
+ // audit note report what actually remains.
892
+ let stillDirty = dirtyFiles;
893
+ try {
894
+ const recheckRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
895
+ const rechecked = parsePorcelain(recheckRaw);
896
+ stillDirty = rechecked.dirtyFiles;
897
+ branchInfo = rechecked.branchInfo || branchInfo;
898
+ autoResetAttempt.recheckSucceeded = true;
899
+ } catch (e) {
900
+ const error = e && e.message ? e.message : String(e);
901
+ autoResetAttempt.errors.push(`status re-check: ${error}`);
902
+ if (typeof log === 'function') {
903
+ log(`live-checkout auto-reset re-check FAILED: ${error}`);
904
+ }
827
905
  }
828
906
 
829
- // Audit trail: record the discarded paths so the operator can recover from
830
- // reflog / understand why their tree changed. Best-effort, never throws.
907
+ const recoveryVerified = autoResetAttempt.recheckSucceeded && stillDirty.length === 0;
908
+ // Audit every destructive attempt, including partial failures. A git
909
+ // command can mutate some paths before returning an error.
831
910
  try {
832
911
  const writeNote = (typeof _writeInboxNote === 'function') ? _writeInboxNote : _defaultWriteInboxNote;
833
- const slug = `live-checkout-autoreset-${wiId || dispatchId || 'unknown'}`;
912
+ const slug = `live-checkout-autoreset-${shared.uid()}-${dispatchId || wiId || 'unknown'}`;
913
+ autoResetAttempt.auditSlug = slug;
914
+ const resetOutcome = autoResetAttempt.resetSucceeded ? 'succeeded' : 'FAILED';
915
+ let cleanOutcome = 'not run because reset failed';
916
+ if (autoResetAttempt.cleanAttempted) {
917
+ cleanOutcome = autoResetAttempt.cleanSucceeded ? 'succeeded' : 'FAILED or partially applied';
918
+ }
919
+ let verifyOutcome = 'FAILED — remaining tree state is unknown';
920
+ if (autoResetAttempt.recheckSucceeded) {
921
+ verifyOutcome = stillDirty.length === 0 ? 'clean' : `${stillDirty.length} dirty path(s) remain`;
922
+ }
834
923
  const body = [
835
- `# Live-checkout auto-reset on '${branchName}'`,
924
+ `# Live-checkout auto-reset ${recoveryVerified ? 'completed' : 'incomplete'} before '${branchName}'`,
925
+ '',
926
+ `The live-checkout tree at \`${localPath}\` was dirty and \`liveCheckoutAutoReset\``,
927
+ 'attempted destructive WIP cleanup. The current branch and committed history were preserved.',
928
+ '',
929
+ '## Outcome',
836
930
  '',
837
- `⚠️ The live-checkout tree at \`${localPath}\` was dirty at dispatch time and`,
838
- '`liveCheckoutAutoReset` is enabled, so it was force-reset to',
839
- `\`${resetTarget}\`. **The following uncommitted changes were DISCARDED**`,
840
- '(recover from `git reflog` / `git fsck --lost-found` if needed):',
931
+ `- \`git reset --hard HEAD\`: ${resetOutcome}`,
932
+ `- \`git clean -fd\`: ${cleanOutcome}`,
933
+ `- post-cleanup status check: ${verifyOutcome}`,
934
+ ...(autoResetAttempt.errors.length > 0
935
+ ? ['', 'Errors:', ...autoResetAttempt.errors.map(error => `- ${error}`)]
936
+ : []),
937
+ '',
938
+ '## Changes present before the attempt',
841
939
  '',
842
940
  '```',
843
941
  ...dirtyFiles,
844
942
  '```',
943
+ ...(autoResetAttempt.recheckSucceeded
944
+ ? [
945
+ '',
946
+ '## Changes remaining after the attempt',
947
+ '',
948
+ '```',
949
+ ...(stillDirty.length > 0 ? stillDirty : ['(clean)']),
950
+ '```',
951
+ ]
952
+ : []),
845
953
  ].join('\n');
846
954
  writeNote(slug, body);
847
955
  } catch { /* best-effort audit note */ }
956
+
957
+ if (!recoveryVerified) {
958
+ return {
959
+ ok: false,
960
+ reason: 'dirty',
961
+ dirtyFiles: stillDirty,
962
+ branchInfo,
963
+ autoResetAttempt,
964
+ };
965
+ }
848
966
  // Fall through — tree is now clean, continue with normal preparation.
849
967
  }
850
968
  }
851
969
  }
852
970
 
853
- // ── Step 2: mid-operation / detached-HEAD preflight (P-b2e8d4a6). ──────
854
- // Runs AFTER the dirty bail and BEFORE branch resolution — never mutates.
855
- // Resolve the git dir via rev-parse (NOT localPath/.git) so submodule /
856
- // gitdir-file / repo-managed trees resolve correctly. A FAILURE here is a
857
- // real git-health problem (and on the very submodule / gitdir-file /
858
- // repo-managed trees live mode targets, <localPath>/.git is NOT a directory,
859
- // so the old fabricated-path fallback made every sentinel probe miss and
860
- // silently skipped mid-operation detection). Throw so the caller classifies
861
- // it as a retryable LIVE_CHECKOUT_FAILED instead of barging into a branch
862
- // switch on top of a half-finished operation we couldn't see.
863
- let gitDir;
864
- try {
865
- const gitDirRaw = await git(['rev-parse', '--git-dir'], baseOpts);
866
- const gitDirStr = (typeof gitDirRaw === 'string' ? gitDirRaw : '').trim();
867
- if (!gitDirStr) {
868
- throw new Error('git rev-parse --git-dir returned empty output');
869
- }
870
- gitDir = path.isAbsolute(gitDirStr) ? gitDirStr : path.join(localPath, gitDirStr);
871
- } catch (e) {
872
- throw new Error('prepareLiveCheckout: could not resolve git dir for ' + localPath + ' — ' + (e && e.message));
873
- }
874
-
875
- // Probe sentinel paths under the resolved git dir. First hit wins; map each
876
- // to its in-progress operation. rebase-merge/ (interactive/merge rebase) and
877
- // rebase-apply/ (am-based rebase) both mean a rebase is underway. BISECT_LOG
878
- // covers an in-progress `git bisect` (bisect state is HEAD-keyed, so a branch
879
- // switch would silently destroy it — the doc + header imply bisect coverage).
880
- const MID_OP_SENTINELS = [
881
- { name: 'MERGE_HEAD', op: 'merge' },
882
- { name: 'rebase-merge', op: 'rebase' },
883
- { name: 'rebase-apply', op: 'rebase' },
884
- { name: 'CHERRY_PICK_HEAD', op: 'cherry-pick' },
885
- { name: 'REVERT_HEAD', op: 'revert' },
886
- { name: 'BISECT_LOG', op: 'bisect' },
887
- ];
888
- for (const sentinel of MID_OP_SENTINELS) {
889
- const sentinelPath = path.join(gitDir, sentinel.name);
890
- if (exists(sentinelPath)) {
891
- return { ok: false, reason: 'mid-operation', op: sentinel.op, details: sentinelPath };
892
- }
893
- }
894
-
895
- // Detached HEAD: `git symbolic-ref -q HEAD` exits 1 when HEAD does not point
896
- // at a branch. Branching off a detached HEAD would strand the operator's
897
- // anonymous commits, so refuse. But distinguish a REAL detachment (git ran
898
- // and returned exit code 1) from a TRANSIENT failure (spawn error / timeout /
899
- // ENOENT) — the latter is not proof of detachment and must NOT permanently
900
- // refuse an on-a-branch tree; rethrow so it becomes a retryable
901
- // LIVE_CHECKOUT_FAILED instead.
902
- let detached = false;
903
- try {
904
- await git(['symbolic-ref', '-q', 'HEAD'], baseOpts);
905
- } catch (symErr) {
906
- if (symErr && symErr.code === 1) {
907
- detached = true; // git ran, HEAD is not a symbolic ref → genuinely detached
908
- } else {
909
- throw symErr; // spawn failure / timeout / other → transient, not detachment
910
- }
911
- }
912
- if (detached) {
913
- let sha = '';
914
- try {
915
- const shaRaw = await git(['rev-parse', 'HEAD'], baseOpts);
916
- sha = (typeof shaRaw === 'string' ? shaRaw : '').trim();
917
- } catch {
918
- // best-effort sha; leave empty if even rev-parse fails
919
- }
920
- return { ok: false, reason: 'detached-head', sha };
921
- }
922
-
923
- // ── Step 3: original-ref capture (P-b2e8d4a6). ────────────────────────
924
- // Capture the operator's starting ref BEFORE any checkout so the
925
- // dispatch-end auto-restore can put the tree back. Prefer the symbolic
926
- // branch name; fall back to the raw HEAD sha when symbolic-ref fails.
927
- let originalRef = '';
928
- let originalRefType = 'branch';
929
- try {
930
- const refRaw = await git(['symbolic-ref', '--short', 'HEAD'], baseOpts);
931
- originalRef = (typeof refRaw === 'string' ? refRaw : '').trim();
932
- originalRefType = 'branch';
933
- } catch {
934
- originalRefType = 'detached';
935
- try {
936
- const refRaw = await git(['rev-parse', 'HEAD'], baseOpts);
937
- originalRef = (typeof refRaw === 'string' ? refRaw : '').trim();
938
- } catch {
939
- originalRef = '';
940
- }
941
- }
971
+ const blocked = await ensureHeadInspected();
972
+ if (blocked) return blocked;
942
973
 
943
974
  // ── Step 4: branch resolution + checkout. ─────────────────────────────
944
975
  // NO `git fetch` (issue #226): the operator's existing HEAD is the
@@ -1195,6 +1226,7 @@ async function prepareLiveCheckout(opts = {}) {
1195
1226
  }
1196
1227
  logFn('info',
1197
1228
  `live-checkout: HEAD was on '${headBranch}' (not base '${mainRef}') — switched to local '${mainRef}' before forking '${branchName}' (no fetch, issue #226 preserved)`);
1229
+ curBranch = mainRef;
1198
1230
  // ── originalRef correction (W-mrb6an8300058fd8). ─────────────────────
1199
1231
  // originalRef/originalRefType were captured at Step 3 from the STALE
1200
1232
  // headBranch, BEFORE this self-heal ran. The tree has now been switched
@@ -1883,6 +1915,8 @@ async function performLiveCheckoutAutoStash(opts = {}) {
1883
1915
  * or — W-mrawgw4q000a6bff — to an explicit `liveCheckoutAutoReset` fallback
1884
1916
  * when that policy also resolves true; see engine.js spawnAgent).
1885
1917
  * - `{ outcome:'stashed', liveResult }` — stashed + re-preflighted; proceed.
1918
+ * - `{ outcome:'blocked', liveResult }` — mutation preflight found an
1919
+ * in-progress repository operation or detached HEAD; caller must refuse.
1886
1920
  * - `{ outcome:'threw', error }` — the re-preflight threw; caller must
1887
1921
  * fail the dispatch as LIVE_CHECKOUT_FAILED (engine-specific completion).
1888
1922
  *
@@ -1903,7 +1937,7 @@ async function performLiveCheckoutAutoStash(opts = {}) {
1903
1937
  * tree if the ff fails — so it was deliberately left out.
1904
1938
  *
1905
1939
  * @param {object} opts
1906
- * @returns {Promise<{outcome:'unchanged'|'stashed'|'threw', liveResult?:object, error?:Error}>}
1940
+ * @returns {Promise<{outcome:'unchanged'|'blocked'|'stashed'|'threw', liveResult?:object, error?:Error}>}
1907
1941
  */
1908
1942
  async function applyLiveCheckoutAutoStash(opts = {}) {
1909
1943
  const {
@@ -1911,12 +1945,22 @@ async function applyLiveCheckoutAutoStash(opts = {}) {
1911
1945
  gitOpts, dispatchId, wiId, log,
1912
1946
  clearDirtyStamp, writeStashNote,
1913
1947
  _git, // private injection for testing — forwarded to performLiveCheckoutAutoStash + the re-preflight prepareLiveCheckout call
1948
+ _exists,
1914
1949
  } = opts;
1915
1950
  const logFn = (typeof log === 'function') ? log : () => {};
1916
1951
  const reason = (liveResult && liveResult.ok === false) ? liveResult.reason : null;
1917
1952
  if (reason !== 'dirty' || !resolveLiveCheckoutAutoStash({ project, engine })) {
1918
1953
  return { outcome: 'unchanged', liveResult };
1919
1954
  }
1955
+ try {
1956
+ const git = (typeof _git === 'function') ? _git : shared.shellSafeGit;
1957
+ const exists = (typeof _exists === 'function') ? _exists : fs.existsSync;
1958
+ const baseOpts = { cwd: localPath, maxBuffer: LIVE_CHECKOUT_GIT_MAX_BUFFER, ...(gitOpts || {}) };
1959
+ const state = await _inspectLiveCheckoutHead({ git, baseOpts, localPath, exists });
1960
+ if (!state.ok) return { outcome: 'blocked', liveResult: state };
1961
+ } catch (preflightErr) {
1962
+ return { outcome: 'threw', error: preflightErr };
1963
+ }
1920
1964
  let stashResult;
1921
1965
  try {
1922
1966
  stashResult = await performLiveCheckoutAutoStash({ localPath, dispatchId, gitOpts, log, _git });
package/engine/llm.js CHANGED
@@ -33,7 +33,7 @@ const EXIT_DRAIN_FALLBACK_MS = 100;
33
33
  //
34
34
  // Updates accumulate in an in-memory buffer and flush every
35
35
  // metricsFlushIntervalMs (default 10s). Replaces the per-call mutateJsonFileLocked
36
- // that was both serializing the LLM hot path and bumping metrics.json mtime
36
+ // that was serializing the LLM hot path
37
37
  // on every call — defeating the dashboard fast-state mtime-based early-exit.
38
38
 
39
39
  let _pendingMetrics = { engine: Object.create(null), daily: Object.create(null) };
@@ -1,4 +1,4 @@
1
- // engine/logs-store.js — SQL-backed implementation of engine/log.json.
1
+ // engine/logs-store.js — SQL-backed engine logs.
2
2
  //
3
3
  // Logs differ from work_items / pull_requests / dispatches: they are
4
4
  // pure append-only with a tail-cap, so the store exposes a different
@@ -168,7 +168,7 @@ function readRecentLogs(n, opts) {
168
168
  return out;
169
169
  }
170
170
 
171
- // Convenience: same shape as the legacy `JSON.parse(log.json).slice(-N)`
171
+ // Convenience: return the latest N entries in chronological order.
172
172
  // — chronological order (oldest first). Inverts the readRecentLogs DESC
173
173
  // output so existing callers can drop in without re-sorting.
174
174
  function readRecentLogsChronological(n, opts) {
@@ -28,7 +28,6 @@ const queries = require('./queries');
28
28
  const { log, ENGINE_DEFAULTS } = shared;
29
29
 
30
30
  const MANAGED_SPAWN_FILENAME = 'managed-spawn.json';
31
- const MANAGED_PROCESSES_STATE_FILE = 'managed-processes.json';
32
31
  const MANAGED_LOGS_DIR = 'managed-logs';
33
32
  const INVALID_WORKDIR_REASON_PREFIX = 'invalid-workdir: ';
34
33
 
@@ -760,7 +759,7 @@ function buildManagedSpawnHint(opts) {
760
759
  // spawnDashboard detached-spawn pattern.
761
760
  // Returns {pid, started_at, log_path}.
762
761
  // 3. recordManagedSpec(spec, → writes one entry to
763
- // runtime, ctx) engine/managed-processes.json via
762
+ // runtime, ctx) SQL managed-process state via
764
763
  // mutateJsonFileLocked. Replaces any
765
764
  // existing entry with the same name
766
765
  // (idempotent under retry).
@@ -777,16 +776,6 @@ function buildManagedSpawnHint(opts) {
777
776
  // (playbook injection) + items 3/7
778
777
  // (sweep + boot reconcile).
779
778
  //
780
- // All state writes go through mutateJsonFileLocked per the repo convention
781
- // ('Key conventions' in copilot-instructions.md). Callbacks stay synchronous
782
- // and fast — no kill / no spawn inside the lock callback. Healthcheck loops,
783
- // dispatch ERROR gating on healthcheck failure, per-tick sweep, boot
784
- // reconcile, and dashboard endpoints are deferred to items 3/4/5/7.
785
-
786
- function _getStatePath() {
787
- return path.join(shared.MINIONS_DIR, 'engine', MANAGED_PROCESSES_STATE_FILE);
788
- }
789
-
790
779
  function _getLogsDir() {
791
780
  return path.join(shared.MINIONS_DIR, 'engine', MANAGED_LOGS_DIR);
792
781
  }
@@ -906,7 +895,6 @@ function _toStateRecord(spec, runtime, ctx) {
906
895
 
907
896
  function recordManagedSpec(spec, runtime, ctx) {
908
897
  if (!spec || !spec.name) throw new Error('recordManagedSpec: spec.name required');
909
- const statePath = _getStatePath();
910
898
  shared.mutateManagedProcesses((data) => {
911
899
  if (!data || typeof data !== 'object' || Array.isArray(data) || !Array.isArray(data.specs)) {
912
900
  data = _initialStateShape();
@@ -921,7 +909,6 @@ function recordManagedSpec(spec, runtime, ctx) {
921
909
 
922
910
  function recordManagedBatch(items, ctx) {
923
911
  if (!Array.isArray(items) || items.length === 0) return;
924
- const statePath = _getStatePath();
925
912
  shared.mutateManagedProcesses((data) => {
926
913
  if (!data || typeof data !== 'object' || Array.isArray(data) || !Array.isArray(data.specs)) {
927
914
  data = _initialStateShape();
@@ -942,7 +929,6 @@ function recordManagedBatch(items, ctx) {
942
929
  function removeManagedSpec(name) {
943
930
  if (typeof name !== 'string' || name.length === 0) return;
944
931
  let killPid = null;
945
- const statePath = _getStatePath();
946
932
  shared.mutateManagedProcesses((data) => {
947
933
  if (!data || !Array.isArray(data.specs)) return data;
948
934
  const idx = data.specs.findIndex(s => s && s.name === name);
@@ -968,8 +954,7 @@ function removeManagedSpec(name) {
968
954
 
969
955
  function listManagedSpecs(opts) {
970
956
  opts = opts || {};
971
- const statePath = _getStatePath();
972
- const parsed = shared.safeJsonObj(statePath);
957
+ const parsed = require('./small-state-store').readManagedProcesses();
973
958
  const specs = (parsed && Array.isArray(parsed.specs)) ? parsed.specs : [];
974
959
  if (opts.project) return specs.filter(s => s && s.owner_project === opts.project);
975
960
  return specs.slice();
@@ -1090,7 +1075,6 @@ async function runHealthcheck(spec) {
1090
1075
  // waitForFirstHealth on the first pass. Callers MUST be outside any other
1091
1076
  // lock — this acquires its own.
1092
1077
  function _markHealthy(name, now) {
1093
- const statePath = _getStatePath();
1094
1078
  shared.mutateManagedProcesses((data) => {
1095
1079
  if (!data || !Array.isArray(data.specs)) return data;
1096
1080
  const rec = data.specs.find(s => s && s.name === name);
@@ -1404,7 +1388,6 @@ function _runManagedReconcile(opts) {
1404
1388
  rotatedLogs: 0,
1405
1389
  malformed: 0,
1406
1390
  };
1407
- const statePath = _getStatePath();
1408
1391
  const ttlPidsToKill = [];
1409
1392
  const survivors = []; // [{name, log_path}] post-mutation, used for log rotation + bootReconcile probes
1410
1393
  shared.mutateManagedProcesses((data) => {
@@ -1459,7 +1442,6 @@ function sweepManagedSpawn(opts) {
1459
1442
  // rely on the per-spec loop to refresh post-success, or item 7 boot reconcile
1460
1443
  // to (re)establish initial truth on engine restart).
1461
1444
  function _markBootProbe(name, result) {
1462
- const statePath = _getStatePath();
1463
1445
  shared.mutateManagedProcesses((data) => {
1464
1446
  if (!data || !Array.isArray(data.specs)) return data;
1465
1447
  const rec = data.specs.find(s => s && s.name === name);
@@ -1510,7 +1492,6 @@ function removeManagedSpecsForProject(projectName) {
1510
1492
  if (typeof projectName !== 'string' || projectName.length === 0) {
1511
1493
  return { killed: 0, unlinked: 0, scanned: 0 };
1512
1494
  }
1513
- const statePath = _getStatePath();
1514
1495
  let toKill = [];
1515
1496
  let logPaths = [];
1516
1497
  shared.mutateManagedProcesses((data) => {
@@ -1546,7 +1527,6 @@ function removeManagedSpecsForProject(projectName) {
1546
1527
 
1547
1528
  module.exports = {
1548
1529
  MANAGED_SPAWN_FILENAME: MANAGED_SPAWN_FILENAME,
1549
- MANAGED_PROCESSES_STATE_FILE: MANAGED_PROCESSES_STATE_FILE,
1550
1530
  MANAGED_LOGS_DIR: MANAGED_LOGS_DIR,
1551
1531
  INVALID_WORKDIR_REASON_PREFIX: INVALID_WORKDIR_REASON_PREFIX,
1552
1532
  HEALTHCHECK_TYPES: HEALTHCHECK_TYPES,
@@ -1561,7 +1541,6 @@ module.exports = {
1561
1541
  recordManagedBatch: recordManagedBatch,
1562
1542
  removeManagedSpec: removeManagedSpec,
1563
1543
  listManagedSpecs: listManagedSpecs,
1564
- getStatePath: _getStatePath,
1565
1544
  // Item 3 (P-9c1f47a6): healthcheck implementations + first-pass waiter.
1566
1545
  runHealthcheck: runHealthcheck,
1567
1546
  waitForFirstHealth: waitForFirstHealth,
package/engine/meeting.js CHANGED
@@ -10,6 +10,7 @@ const { safeJson, uid, log, ts, ENGINE_DEFAULTS, WORK_TYPE, DISPATCH_RESULT, MEE
10
10
  const queries = require('./queries');
11
11
  const { getDispatch, getConfig } = queries;
12
12
  const { renderPlaybook } = require('./playbook');
13
+ const dispatchModule = require('./dispatch');
13
14
 
14
15
  /** Patterns that indicate an agent returned no meaningful output */
15
16
  const EMPTY_OUTPUT_PATTERNS = ['(no output)', '(no findings)', '(no response)'];
@@ -514,7 +515,7 @@ function discoverMeetingWork(config) {
514
515
 
515
516
  // Pre-flight validation: meetings missing required template vars (agenda)
516
517
  // would otherwise fail playbook rendering on every tick (~1/min), spamming
517
- // log.json with the same "missing required template variables: agenda"
518
+ // the engine log with the same "missing required template variables: agenda"
518
519
  // error. Skip them silently here; emit one structured warning per meeting
519
520
  // ID per process so the operator still has signal without the spam.
520
521
  if (!meeting.agenda || !String(meeting.agenda).trim()) {
@@ -757,12 +758,11 @@ function addMeetingNote(meetingId, note) {
757
758
 
758
759
  function _killMeetingDispatches(meetingId) {
759
760
  try {
760
- const DISPATCH_PATH = path.join(shared.MINIONS_DIR, 'engine', 'dispatch.json');
761
761
  const tmpDir = path.join(shared.MINIONS_DIR, 'engine', 'tmp');
762
762
  const entriesToStop = [];
763
763
  const filesToDelete = [];
764
764
  const dispatchDirsToRemove = [];
765
- shared.mutateJsonFileLocked(DISPATCH_PATH, (dp) => {
765
+ dispatchModule.mutateDispatch((dp) => {
766
766
  dp.pending = Array.isArray(dp.pending) ? dp.pending : [];
767
767
  dp.active = Array.isArray(dp.active) ? dp.active : [];
768
768
  dp.completed = Array.isArray(dp.completed) ? dp.completed : [];
@@ -828,7 +828,7 @@ function advanceMeetingRound(meetingId) {
828
828
  if (!existing || existing.status === MEETING_STATUS.COMPLETED || existing.status === MEETING_STATUS.ARCHIVED) return null;
829
829
 
830
830
  // CRITICAL: kill BEFORE acquiring the meeting lock. _killMeetingDispatches
831
- // takes the dispatch.json lock and shells out to kill processes — never
831
+ // mutates dispatch state and shells out to kill processes — never
832
832
  // run that under the meeting lock (per CLAUDE.md, lock callbacks must
833
833
  // stay fast and never spawn / kill / await).
834
834
  _killMeetingDispatches(meetingId);
@@ -923,13 +923,13 @@ function checkMeetingTimeouts(config) {
923
923
  // BUG-H11: when a hard timeout will fire, kill in-flight meeting
924
924
  // dispatches BEFORE entering the meeting lock so they stop consuming
925
925
  // worker slots / LLM tokens immediately rather than draining over the
926
- // next tens of minutes. _killMeetingDispatches takes the dispatch.json
926
+ // next tens of minutes. _killMeetingDispatches takes the dispatch
927
927
  // lock and shells out to kill processes — per CLAUDE.md and the
928
928
  // advanceMeetingRound/endMeeting/deleteMeeting pattern at L834, that
929
929
  // MUST run outside the meeting lock. We pre-evaluate eligibility from
930
930
  // the snapshot using the same predicate the mutate callback below
931
931
  // re-checks under lock; if the timeout has already advanced/cleared,
932
- // the kill is a no-op on dispatch.json (no matching meetingId entries).
932
+ // the kill is a no-op in dispatch state (no matching meetingId entries).
933
933
  const snapshotRoundName = snapshot.status === 'investigating'
934
934
  ? 'investigate'
935
935
  : snapshot.status === 'debating'