@yemi33/minions 0.1.2424 → 0.1.2425

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 (182) hide show
  1. package/bin/install-internal-minions.js +1209 -0
  2. package/bin/minions.js +75 -9
  3. package/dashboard/docs/typography.md +27 -12
  4. package/dashboard/js/command-center.js +13 -11
  5. package/dashboard/js/live-stream.js +1 -1
  6. package/dashboard/js/memory-search.js +388 -43
  7. package/dashboard/js/modal-qa.js +5 -5
  8. package/dashboard/js/qa.js +39 -26
  9. package/dashboard/js/refresh.js +37 -18
  10. package/dashboard/js/render-agents.js +14 -18
  11. package/dashboard/js/render-dispatch.js +14 -74
  12. package/dashboard/js/render-inbox.js +3 -3
  13. package/dashboard/js/render-meetings.js +8 -8
  14. package/dashboard/js/render-other.js +45 -20
  15. package/dashboard/js/render-pipelines.js +6 -6
  16. package/dashboard/js/render-plans.js +14 -14
  17. package/dashboard/js/render-prd.js +46 -46
  18. package/dashboard/js/render-prs.js +9 -65
  19. package/dashboard/js/render-schedules.js +5 -5
  20. package/dashboard/js/render-skills.js +6 -6
  21. package/dashboard/js/render-utils.js +4 -4
  22. package/dashboard/js/render-watches.js +4 -16
  23. package/dashboard/js/render-work-items.js +95 -107
  24. package/dashboard/js/settings.js +175 -74
  25. package/dashboard/layout.html +3 -2
  26. package/dashboard/pages/inbox.html +1 -1
  27. package/dashboard/pages/work.html +1 -1
  28. package/dashboard/shared/model-display.js +9 -0
  29. package/dashboard/shared/watches-source.js +43 -0
  30. package/dashboard/shared/welcome-popup.js +259 -0
  31. package/dashboard/slim/body.html +6 -6
  32. package/dashboard/slim/js/chat.js +4 -2
  33. package/dashboard/slim/js/history.js +10 -3
  34. package/dashboard/slim/js/modals-tiles.js +85 -16
  35. package/dashboard/slim/js/status.js +12 -16
  36. package/dashboard/slim/styles.css +24 -4
  37. package/dashboard/styles.css +62 -27
  38. package/dashboard-build.js +8 -2
  39. package/dashboard.js +3854 -2514
  40. package/docs/README.md +6 -4
  41. package/docs/api-errors.md +144 -0
  42. package/docs/auto-discovery.md +83 -61
  43. package/docs/capture-demos.js +173 -30
  44. package/docs/command-center.md +5 -1
  45. package/docs/completion-reports.md +127 -11
  46. package/docs/constants.md +15 -3
  47. package/docs/constellation-style-telemetry.md +2 -2
  48. package/docs/copilot-cli-schema.md +32 -22
  49. package/docs/cross-repo-plans.md +30 -30
  50. package/docs/demo/memory-system.html +1 -1
  51. package/docs/deprecated.json +132 -18
  52. package/docs/diagnostics-memory.md +8 -4
  53. package/docs/human-vs-automated.md +2 -2
  54. package/docs/internal-install.md +190 -0
  55. package/docs/kb-pr3223-cascade-archiving.md +16 -0
  56. package/docs/kb-pr696-merge-conflict-docs.md +23 -0
  57. package/docs/kb-sweep.md +2 -2
  58. package/docs/keep-processes.md +8 -1
  59. package/docs/live-checkout-mode.md +1 -1
  60. package/docs/managed-spawn.md +21 -3
  61. package/docs/named-agents.md +3 -2
  62. package/docs/plan-lifecycle.md +32 -32
  63. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
  64. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
  65. package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
  66. package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
  67. package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
  68. package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
  69. package/docs/qa-runbook-lifecycle.md +67 -51
  70. package/docs/qa-runbooks.md +2 -2
  71. package/docs/runtime-adapters.md +101 -50
  72. package/docs/security.md +28 -0
  73. package/docs/self-improvement.md +47 -13
  74. package/docs/skills.md +29 -0
  75. package/docs/slim-ux/concepts.md +2 -2
  76. package/docs/specs/agent-configurability.md +43 -41
  77. package/docs/specs/agent-rename.md +13 -13
  78. package/docs/team-memory.md +283 -14
  79. package/docs/tutorials/03-plan-driven-feature.md +3 -3
  80. package/docs/tutorials/04-runtimes-and-harness.md +18 -21
  81. package/docs/tutorials/08-operations-and-recovery.md +3 -2
  82. package/docs/tutorials/README.md +4 -3
  83. package/docs/visual-evidence-ci.md +103 -0
  84. package/docs/watches.md +35 -30
  85. package/docs/worktree-lifecycle.md +151 -13
  86. package/engine/acp-transport.js +1 -0
  87. package/engine/ado.js +80 -33
  88. package/engine/agent-api-validation.js +571 -0
  89. package/engine/agent-worker-pool.js +7 -6
  90. package/engine/api-contracts/agent-content.js +761 -0
  91. package/engine/api-contracts/cc-ops.js +219 -0
  92. package/engine/api-contracts/config-runtime.js +419 -0
  93. package/engine/api-contracts/core.js +138 -0
  94. package/engine/api-contracts/index.js +506 -0
  95. package/engine/api-contracts/orchestration.js +602 -0
  96. package/engine/api-contracts/pull-requests.js +323 -0
  97. package/engine/api-contracts/qa-process.js +764 -0
  98. package/engine/api-contracts/work-plan-prd.js +494 -0
  99. package/engine/api-validation.js +543 -0
  100. package/engine/cc-api-validation.js +586 -0
  101. package/engine/cc-worker-pool.js +9 -1
  102. package/engine/cleanup.js +393 -271
  103. package/engine/cli.js +65 -7
  104. package/engine/comment-classifier.js +70 -5
  105. package/engine/consolidation.js +123 -5
  106. package/engine/content-api-validation.js +606 -0
  107. package/engine/create-pr-worktree.js +22 -1
  108. package/engine/db/migrations/027-review-learning-lifecycle.js +162 -0
  109. package/engine/dispatch.js +33 -0
  110. package/engine/distribution.js +189 -0
  111. package/engine/execution-model.js +59 -0
  112. package/engine/features.js +12 -0
  113. package/engine/github.js +67 -25
  114. package/engine/harness-context.js +313 -0
  115. package/engine/inbox-store.js +1 -1
  116. package/engine/kb-sweep.js +161 -38
  117. package/engine/keep-process-sweep.js +43 -3
  118. package/engine/lifecycle.js +1066 -165
  119. package/engine/llm.js +4 -0
  120. package/engine/managed-spawn.js +463 -48
  121. package/engine/meeting.js +197 -34
  122. package/engine/memory-retrieval.js +280 -0
  123. package/engine/memory-store.js +821 -38
  124. package/engine/model-discovery.js +17 -3
  125. package/engine/pipeline.js +802 -14
  126. package/engine/plan-prd-validation.js +745 -0
  127. package/engine/playbook.js +206 -17
  128. package/engine/pooled-agent-process.js +6 -1
  129. package/engine/pr-action.js +1 -1
  130. package/engine/pr-issue-validation.js +653 -0
  131. package/engine/pr-resolve.js +75 -13
  132. package/engine/prd-store.js +39 -6
  133. package/engine/preflight.js +7 -2
  134. package/engine/process-utils.js +79 -31
  135. package/engine/projects.js +198 -12
  136. package/engine/promotion.js +371 -0
  137. package/engine/qa-from-prd.js +3 -0
  138. package/engine/qa-process-validation.js +572 -0
  139. package/engine/qa-runbooks.js +41 -37
  140. package/engine/qa-runners/maestro.js +11 -7
  141. package/engine/qa-runners/playwright.js +37 -14
  142. package/engine/qa-runners.js +9 -10
  143. package/engine/qa-runs.js +124 -11
  144. package/engine/qa-sessions.js +139 -40
  145. package/engine/quarantine-refs.js +81 -11
  146. package/engine/queries.js +163 -9
  147. package/engine/review-learning-backfill.js +482 -0
  148. package/engine/review-learning.js +1236 -0
  149. package/engine/runtimes/claude.js +60 -2
  150. package/engine/runtimes/codex.js +57 -16
  151. package/engine/runtimes/contract.js +21 -0
  152. package/engine/runtimes/copilot.js +51 -3
  153. package/engine/runtimes/index.js +1 -0
  154. package/engine/scheduler.js +201 -32
  155. package/engine/settings-validation.js +909 -0
  156. package/engine/shared.js +705 -208
  157. package/engine/spawn-agent.js +48 -21
  158. package/engine/steering-constraints.js +31 -0
  159. package/engine/steering-store.js +10 -2
  160. package/engine/steering.js +14 -2
  161. package/engine/timeout.js +404 -111
  162. package/engine/untrusted-fence.js +24 -10
  163. package/engine/watch-actions.js +7 -2
  164. package/engine/watches.js +353 -51
  165. package/engine/work-item-validation.js +561 -0
  166. package/engine/work-items-store.js +61 -1
  167. package/engine/worktree-gc.js +656 -37
  168. package/engine/worktree-preflight.js +154 -0
  169. package/engine.js +2068 -342
  170. package/package.json +1 -1
  171. package/playbooks/_pr-description-audit.md +88 -41
  172. package/playbooks/build-fix-complex.md +3 -3
  173. package/playbooks/docs.md +2 -2
  174. package/playbooks/fix.md +18 -18
  175. package/playbooks/implement.md +18 -18
  176. package/playbooks/qa-session-draft.md +10 -12
  177. package/playbooks/qa-session-execute.md +16 -12
  178. package/playbooks/qa-validate.md +8 -7
  179. package/playbooks/shared-rules.md +45 -0
  180. package/playbooks/test.md +4 -0
  181. package/prompts/cc-system.md +1 -1
  182. package/skills/check-self-authored-review-comment/SKILL.md +2 -2
@@ -14,8 +14,8 @@
14
14
  * session parks at `awaiting-approval` for human review; in
15
15
  * `auto` mode it auto-chains EXECUTE.
16
16
  * EXECUTE → runs the drafted test against the managed-spawn target, writes
17
- * agents/<id>/qa-run-result.json. The existing qa-runs lifecycle
18
- * hook (engine/lifecycle.js:4340) ingests the sidecar; this
17
+ * the engine-injected absolute qa-run-result.json path. The
18
+ * qa-runs lifecycle hook ingests the run-correlated sidecar; this
19
19
  * module then transitions the session done/failed based on the
20
20
  * resulting qa-run terminal status.
21
21
  *
@@ -263,7 +263,7 @@ function validateSpec(spec) {
263
263
  errors.push(`flowsRaw exceeds ${LIMITS.flowsMax} chars`);
264
264
  }
265
265
 
266
- const mode = spec.mode || 'confirm';
266
+ const mode = spec.mode === undefined ? 'confirm' : spec.mode;
267
267
  if (!VALID_MODES.has(mode)) {
268
268
  errors.push('mode must be one of: ' + [...VALID_MODES].join(', '));
269
269
  }
@@ -301,17 +301,18 @@ function validateSpec(spec) {
301
301
  errors.push(`projects exceeds ${LIMITS.projectsMax} entries`);
302
302
  } else {
303
303
  const seen = new Set();
304
- for (const p of spec.projects) {
304
+ for (let index = 0; index < spec.projects.length; index += 1) {
305
+ const p = spec.projects[index];
305
306
  if (typeof p !== 'string' || p.length === 0) {
306
- errors.push('projects entries must be non-empty strings');
307
+ errors.push(`projects[${index}] must be a non-empty string`);
307
308
  break;
308
309
  }
309
310
  if (p.length > LIMITS.projectMax) {
310
- errors.push(`projects entry exceeds ${LIMITS.projectMax} chars`);
311
+ errors.push(`projects[${index}] exceeds ${LIMITS.projectMax} chars`);
311
312
  break;
312
313
  }
313
314
  if (seen.has(p)) {
314
- errors.push(`projects contains duplicate entry: ${p}`);
315
+ errors.push(`projects[${index}] duplicates an earlier entry: ${p}`);
315
316
  break;
316
317
  }
317
318
  seen.add(p);
@@ -378,7 +379,7 @@ function createSession(spec) {
378
379
  spec: {
379
380
  target: { ...spec.target },
380
381
  flowsRaw: spec.flowsRaw,
381
- mode: spec.mode || 'confirm',
382
+ mode: spec.mode === undefined ? 'confirm' : spec.mode,
382
383
  capture: {
383
384
  video: !!(spec.capture && spec.capture.video),
384
385
  screenshots: !!(spec.capture && spec.capture.screenshots),
@@ -611,7 +612,7 @@ function setSessionQaRunId(id, qaRunId) {
611
612
  * @returns {object} updated session
612
613
  * @throws Error on unknown id, unsafe id, or illegal transition
613
614
  */
614
- function transitionSession(id, toState, patch = {}) {
615
+ function transitionSession(id, toState, patch = {}, options = {}) {
615
616
  if (!_isSafeSessionId(id)) throw new Error('qa-sessions: unsafe sessionId: ' + id);
616
617
  if (!isValidState(toState)) throw new Error('qa-sessions: invalid target state: ' + toState);
617
618
 
@@ -621,6 +622,12 @@ function transitionSession(id, toState, patch = {}) {
621
622
  if (!Array.isArray(sessions)) sessions = [];
622
623
  const session = sessions.find(s => s && s.id === id);
623
624
  if (!session) { transitionError = new Error(`qa-sessions: session not found: ${id}`); return sessions; }
625
+ if (options.expectedState && session.state !== options.expectedState) {
626
+ transitionError = new Error(
627
+ `qa-sessions: ${options.action || 'transition'} requires state ${options.expectedState}, got ${session.state}`,
628
+ );
629
+ return sessions;
630
+ }
624
631
  try { validateTransition(session.state, toState); }
625
632
  catch (e) { transitionError = e; return sessions; }
626
633
 
@@ -790,6 +797,8 @@ function buildSetupWorkItem(session, { project, primary, coServices, primaryProj
790
797
  * @param {string} [opts.feedback] - natural-language feedback from /edit, threaded into the prompt as steering
791
798
  */
792
799
  function buildDraftWorkItem(session, { project, feedback } = {}) {
800
+ const managedSpawnLookupPath = `/api/managed-processes/by-name/${encodeURIComponent(session.managedSpawnName)}`;
801
+ const testDir = qaTestsDirForSession(session.id);
793
802
  const lines = [
794
803
  `QA Session ${session.id} — DRAFT phase.`,
795
804
  '',
@@ -797,8 +806,8 @@ function buildDraftWorkItem(session, { project, feedback } = {}) {
797
806
  `Runner: ${session.spec.runner || '(auto-detected)'}`,
798
807
  `Mode: ${session.spec.mode}`,
799
808
  '',
800
- `Managed-spawn target: \`${session.managedSpawnName}\` (live; query /api/managed-processes/by-name).`,
801
- `Write the test file to \`engine/qa-tests/${session.id}/test.<ext>\` using the runner's native format.`,
809
+ `Managed-spawn target: \`${session.managedSpawnName}\` (live; query ${managedSpawnLookupPath}).`,
810
+ `Write the test file under \`${testDir}\` using the runner's native format.`,
802
811
  'See `playbooks/qa-session-draft.md` for the full contract.',
803
812
  ];
804
813
  if (feedback) {
@@ -813,10 +822,10 @@ function buildDraftWorkItem(session, { project, feedback } = {}) {
813
822
 
814
823
  /**
815
824
  * Build the EXECUTE work item. The agent invokes the runner against the live
816
- * spawn, captures artifacts per `capture`, and writes
817
- * agents/<id>/qa-run-result.json. The existing qaRunId hook at
818
- * engine/lifecycle.js:4340 ingests the sidecar; our own session hook below
819
- * transitions done/failed based on the resulting qa-run terminal status.
825
+ * spawn, captures artifacts per `capture`, and writes the injected absolute
826
+ * result sidecar. The qaRunId hook in engine/lifecycle.js ingests it; our own
827
+ * session hook below transitions done/failed based on the resulting qa-run
828
+ * terminal status.
820
829
  *
821
830
  * @param {object} session
822
831
  * @param {object} opts
@@ -832,12 +841,12 @@ function buildExecuteWorkItem(session, { qaRunId, project } = {}) {
832
841
  description: [
833
842
  `QA Session ${session.id} — EXECUTE phase.`,
834
843
  '',
835
- `Run \`engine/qa-tests/${session.id}/${session.testFile || 'test.<ext>'}\` against \`${session.managedSpawnName}\`.`,
844
+ `Run \`${path.join(qaTestsDirForSession(session.id), session.testFile || 'test.<ext>')}\` against \`${session.managedSpawnName}\`.`,
836
845
  `qaRunId: ${qaRunId}`,
837
846
  '',
838
- 'Capture artifacts per session.spec.capture. Write a qa-run-result.json',
839
- 'sidecar to your agent dir (the engine ingests it and marks the linked',
840
- 'qa-runs record terminal). See `playbooks/qa-session-execute.md`.',
847
+ 'Capture artifacts per session.spec.capture. Use the engine-injected',
848
+ 'absolute artifact and qa-run-result.json paths so the linked qa-runs',
849
+ 'record can ingest them. See `playbooks/qa-session-execute.md`.',
841
850
  ].join('\n'),
842
851
  project,
843
852
  });
@@ -872,18 +881,26 @@ function _summarizeTarget(target) {
872
881
  // dispatch + work-items are lazy-required inside _queueWorkItem to keep
873
882
  // `require('./qa-sessions')` cycle-safe at the top of lifecycle.js.
874
883
 
875
- function _queueWorkItem(wi, scope) {
876
- shared.mutateWorkItems(scope, (items) => {
877
- if (!Array.isArray(items)) items = [];
878
- if (!items.some(i => i && i.id === wi.id)) items.push(wi);
879
- return items;
880
- });
884
+ function _dispatchQueuedWorkItem(wi) {
881
885
  const dispatch = require('./dispatch');
882
886
  dispatch.addToDispatch({
883
887
  type: wi.type,
884
888
  agent: wi.agent || null,
885
889
  meta: { item: wi, playbook: wi.meta.playbook },
886
890
  });
891
+ }
892
+
893
+ function _persistWorkItem(wi, scope) {
894
+ shared.mutateWorkItems(scope, (items) => {
895
+ if (!Array.isArray(items)) items = [];
896
+ if (!items.some(item => item && item.id === wi.id)) items.push(wi);
897
+ return items;
898
+ });
899
+ }
900
+
901
+ function _queueWorkItem(wi, scope) {
902
+ _persistWorkItem(wi, scope);
903
+ _dispatchQueuedWorkItem(wi);
887
904
  return wi.id;
888
905
  }
889
906
 
@@ -1105,6 +1122,60 @@ function handleSetupComplete(sessionId, opts = {}) {
1105
1122
  return wi.id;
1106
1123
  }
1107
1124
 
1125
+ function _setupManagedSpawnRequirement(session, project) {
1126
+ const projects = Array.isArray(session?.spec?.projects) ? session.spec.projects : [];
1127
+ const isMulti = projects.length > 1;
1128
+ const isPrimary = !isMulti || project === session.primaryProject;
1129
+ const key = project || '__central__';
1130
+ return {
1131
+ name: session.managedSpawnName + (isPrimary ? '' : '-' + project),
1132
+ wiId: isMulti
1133
+ ? (session.setupStatus?.[key]?.wiId || '')
1134
+ : (session.workItems?.setup || ''),
1135
+ };
1136
+ }
1137
+
1138
+ function managedSpawnNamesForSession(session) {
1139
+ if (!session || !_isNonEmptyString(session.managedSpawnName)) return [];
1140
+ const projects = Array.isArray(session.spec && session.spec.projects)
1141
+ ? session.spec.projects.filter(_isNonEmptyString)
1142
+ : [];
1143
+ if (projects.length <= 1) return [session.managedSpawnName];
1144
+ const primary = session.primaryProject || projects[0];
1145
+ return projects.map(project => (
1146
+ project === primary ? session.managedSpawnName : `${session.managedSpawnName}-${project}`
1147
+ ));
1148
+ }
1149
+
1150
+ function handleSetupCompleteAfterManagedSpawn(sessionId, opts = {}) {
1151
+ const session = getSession(sessionId);
1152
+ if (!session) throw new Error('qa-sessions: session not found: ' + sessionId);
1153
+ if (!opts.success) return handleSetupComplete(sessionId, opts);
1154
+
1155
+ const requirement = _setupManagedSpawnRequirement(session, opts.project || null);
1156
+ const record = require('./managed-spawn').getManagedSpecByName(requirement.name);
1157
+ const hasExpectedOwner = _isNonEmptyString(requirement.wiId);
1158
+ const ownerMatches = hasExpectedOwner && record?.owner_wi === requirement.wiId;
1159
+ if (!record || record.alive !== true || record.healthy !== true || !ownerMatches) {
1160
+ let state = 'not registered';
1161
+ if (!hasExpectedOwner) {
1162
+ state = 'missing its expected SETUP work-item owner';
1163
+ } else if (record && !ownerMatches) {
1164
+ state = `owned by work item ${record.owner_wi || 'unknown'} instead of ${requirement.wiId}`;
1165
+ } else if (record) {
1166
+ state = `registered but ${record.alive !== true ? 'not alive' : 'not healthy'}`;
1167
+ }
1168
+ return handleSetupComplete(sessionId, {
1169
+ ...opts,
1170
+ success: false,
1171
+ failureClass: shared.FAILURE_CLASS.INVALID_MANAGED_SPAWN,
1172
+ reason: `managed_spawn target ${requirement.name} is ${state}; QA SETUP cannot advance to DRAFT`,
1173
+ });
1174
+ }
1175
+
1176
+ return handleSetupComplete(sessionId, opts);
1177
+ }
1178
+
1108
1179
  /**
1109
1180
  * Lifecycle hook: DRAFT WI completed. On success we either park at
1110
1181
  * awaiting-approval (confirm mode — user must call /approve) or auto-chain
@@ -1202,28 +1273,52 @@ function handleExecuteComplete(sessionId, opts = {}) {
1202
1273
  // ── User-initiated actions (called by dashboard endpoints) ─────────────────
1203
1274
 
1204
1275
  /**
1205
- * POST /api/qa/sessions/<id>/approve awaiting-approval executing, queues
1206
- * the EXECUTE WI. Caller creates the qa-runs record and passes its id.
1276
+ * Atomically reserve an approval before its run or work item is created.
1207
1277
  */
1208
- function approveDraft(sessionId, { scope, qaRunId, project } = {}) {
1278
+ function beginApproveDraft(sessionId, { qaRunId } = {}) {
1279
+ if (!_isNonEmptyString(qaRunId)) throw new Error('qa-sessions: beginApproveDraft requires qaRunId');
1280
+ return transitionSession(sessionId, QA_SESSION_STATE.EXECUTING, { qaRunId }, {
1281
+ expectedState: QA_SESSION_STATE.AWAITING_APPROVAL,
1282
+ action: 'approveDraft',
1283
+ });
1284
+ }
1285
+
1286
+ /** Queue EXECUTE after beginApproveDraft has committed its state transition. */
1287
+ function queueApprovedDraft(sessionId, { scope, qaRunId, project } = {}) {
1209
1288
  if (!_isNonEmptyString(scope)) throw new Error('qa-sessions: approveDraft requires scope');
1210
1289
  if (!_isNonEmptyString(qaRunId)) throw new Error('qa-sessions: approveDraft requires qaRunId');
1211
- const session = getSession(sessionId);
1212
- if (!session) throw new Error('qa-sessions: session not found: ' + sessionId);
1213
- if (session.state !== QA_SESSION_STATE.AWAITING_APPROVAL) {
1214
- throw new Error(`qa-sessions: approveDraft requires state awaiting-approval, got ${session.state}`);
1215
- }
1216
- markExecuting(sessionId, { qaRunId });
1217
- const updated = getSession(sessionId);
1218
- const wi = buildExecuteWorkItem(updated, {
1219
- qaRunId,
1220
- project: project || updated.spec.project || null,
1290
+ if (!_isSafeSessionId(sessionId)) throw new Error('qa-sessions: unsafe sessionId: ' + sessionId);
1291
+ let wi = null;
1292
+ mutateQaSessions((sessions) => {
1293
+ const session = sessions.find(item => item && item.id === sessionId);
1294
+ if (!session) throw new Error('qa-sessions: session not found: ' + sessionId);
1295
+ if (session.state !== QA_SESSION_STATE.EXECUTING || session.qaRunId !== qaRunId) {
1296
+ throw new Error(
1297
+ `qa-sessions: queueApprovedDraft requires state executing with matching qaRunId, got ${session.state}`,
1298
+ );
1299
+ }
1300
+ wi = buildExecuteWorkItem(session, {
1301
+ qaRunId,
1302
+ project: project || session.spec.project || null,
1303
+ });
1304
+ _persistWorkItem(wi, scope);
1305
+ if (!session.workItems || typeof session.workItems !== 'object') {
1306
+ session.workItems = { setup: null, draft: null, execute: null };
1307
+ }
1308
+ session.workItems[SESSION_PHASE.EXECUTE] = wi.id;
1309
+ session.updatedAt = ts();
1310
+ _dispatchQueuedWorkItem(wi);
1311
+ return sessions;
1221
1312
  });
1222
- _queueWorkItem(wi, scope);
1223
- setSessionWorkItem(sessionId, SESSION_PHASE.EXECUTE, wi.id);
1224
1313
  return wi.id;
1225
1314
  }
1226
1315
 
1316
+ /** Back-compatible one-shot wrapper for non-HTTP callers. */
1317
+ function approveDraft(sessionId, options = {}) {
1318
+ beginApproveDraft(sessionId, options);
1319
+ return queueApprovedDraft(sessionId, options);
1320
+ }
1321
+
1227
1322
  /**
1228
1323
  * POST /api/qa/sessions/<id>/edit — awaiting-approval → drafting, re-queue the
1229
1324
  * DRAFT WI with the user's natural-language feedback threaded into the prompt.
@@ -1372,14 +1467,18 @@ module.exports = {
1372
1467
  // Chain helpers (impure — call dispatch + work-items)
1373
1468
  queueSetup,
1374
1469
  handleSetupComplete,
1470
+ handleSetupCompleteAfterManagedSpawn,
1375
1471
  handleDraftComplete,
1376
1472
  handleExecuteComplete,
1377
1473
  // User actions
1474
+ beginApproveDraft,
1475
+ queueApprovedDraft,
1378
1476
  approveDraft,
1379
1477
  editDraft,
1380
1478
  cancelSession,
1381
1479
  killSession,
1382
1480
  dismissSession,
1481
+ managedSpawnNamesForSession,
1383
1482
  // Status
1384
1483
  summarizeSessionsForStatus,
1385
1484
  summarizeActiveSessionsForStatus,
@@ -1,22 +1,43 @@
1
1
  const shared = require('./shared');
2
2
 
3
+ const QUARANTINE_REF_RETENTION_MS = 10 * 24 * 60 * 60 * 1000;
4
+ const DELETE_CONCURRENCY = 8;
5
+
6
+ function quarantineRefCreatedAtMs(ref, fallbackCreatedAt) {
7
+ const timestamp = String(ref || '').split('/').pop();
8
+ const fromRef = Number(timestamp);
9
+ if (Number.isFinite(fromRef) && fromRef > 0) return fromRef;
10
+ const fallback = Date.parse(fallbackCreatedAt || '');
11
+ return Number.isFinite(fallback) ? fallback : null;
12
+ }
13
+
14
+ async function _readProjectQuarantineRefs(project) {
15
+ const output = await shared.shellSafeGit([
16
+ 'for-each-ref',
17
+ '--format=%(refname)|%(objectname)|%(creatordate:iso-strict)',
18
+ 'refs/minions/quarantine/',
19
+ 'refs/minions/quarantine-wip/',
20
+ ], { cwd: project.localPath, timeout: 15000 });
21
+ const seenRefs = new Set();
22
+ return String(output || '').split(/\r?\n/).filter(Boolean).map(line => {
23
+ const [ref, sha, fallbackCreatedAt] = line.split('|');
24
+ if (!ref || seenRefs.has(ref)) return null;
25
+ seenRefs.add(ref);
26
+ const createdAtMs = quarantineRefCreatedAtMs(ref, fallbackCreatedAt);
27
+ return { ref, sha, fallbackCreatedAt, createdAtMs };
28
+ }).filter(Boolean);
29
+ }
30
+
3
31
  async function listQuarantineRefs(projects) {
4
32
  const candidates = (Array.isArray(projects) ? projects : []).filter(project => project?.localPath);
5
33
  const results = await Promise.allSettled(candidates.map(async project => {
6
- const localPath = project?.localPath;
7
- const output = await shared.shellSafeGit([
8
- 'for-each-ref',
9
- '--format=%(refname)|%(objectname)|%(creatordate:iso-strict)',
10
- 'refs/minions/quarantine',
11
- 'refs/minions/quarantine-wip',
12
- ], { cwd: localPath, timeout: 15000 });
13
- return String(output || '').split(/\r?\n/).filter(Boolean).map(line => {
14
- const [ref, sha, createdAt] = line.split('|');
34
+ const refs = await _readProjectQuarantineRefs(project);
35
+ return refs.map(({ ref, sha, fallbackCreatedAt, createdAtMs }) => {
15
36
  return {
16
37
  project: project.name || '',
17
38
  ref,
18
39
  sha,
19
- createdAt: createdAt || '',
40
+ createdAt: createdAtMs ? new Date(createdAtMs).toISOString() : (fallbackCreatedAt || ''),
20
41
  kind: ref.startsWith('refs/minions/quarantine-wip/') ? 'wip' : 'commits',
21
42
  };
22
43
  });
@@ -30,4 +51,53 @@ async function listQuarantineRefs(projects) {
30
51
  return items.sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
31
52
  }
32
53
 
33
- module.exports = { listQuarantineRefs };
54
+ async function _mapWithConcurrency(items, limit, worker) {
55
+ let next = 0;
56
+ const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
57
+ while (next < items.length) {
58
+ const item = items[next++];
59
+ await worker(item);
60
+ }
61
+ });
62
+ await Promise.all(runners);
63
+ }
64
+
65
+ async function pruneExpiredQuarantineRefs(projects, {
66
+ now = Date.now(),
67
+ retentionMs = QUARANTINE_REF_RETENTION_MS,
68
+ } = {}) {
69
+ const candidates = (Array.isArray(projects) ? projects : []).filter(project => project?.localPath);
70
+ const results = await Promise.allSettled(candidates.map(async project => {
71
+ const refs = await _readProjectQuarantineRefs(project);
72
+ const expired = refs.filter(item => item.createdAtMs !== null && now - item.createdAtMs >= retentionMs);
73
+
74
+ let removed = 0;
75
+ await _mapWithConcurrency(expired, DELETE_CONCURRENCY, async item => {
76
+ await shared.shellSafeGit(['update-ref', '-d', item.ref, item.sha], {
77
+ cwd: project.localPath,
78
+ timeout: 15000,
79
+ });
80
+ removed++;
81
+ });
82
+ return removed;
83
+ }));
84
+
85
+ let removed = 0;
86
+ const errors = [];
87
+ for (let i = 0; i < results.length; i++) {
88
+ const result = results[i];
89
+ if (result.status === 'fulfilled') removed += result.value;
90
+ else errors.push({
91
+ project: candidates[i]?.name || '',
92
+ error: result.reason?.message || String(result.reason),
93
+ });
94
+ }
95
+ return { removed, errors };
96
+ }
97
+
98
+ module.exports = {
99
+ QUARANTINE_REF_RETENTION_MS,
100
+ quarantineRefCreatedAtMs,
101
+ listQuarantineRefs,
102
+ pruneExpiredQuarantineRefs,
103
+ };
package/engine/queries.js CHANGED
@@ -10,6 +10,7 @@ const path = require('path');
10
10
  const os = require('os');
11
11
  const shared = require('./shared');
12
12
  const steering = require('./steering');
13
+ const { normalizeExecutionModel, resolveWorkItemModel } = require('./execution-model');
13
14
 
14
15
  const { safeRead, safeReadDir, safeJson, safeJsonObj, safeWrite, getProjects, mutateJsonFileLocked,
15
16
  parseSkillFrontmatter, KB_CATEGORIES,
@@ -427,6 +428,10 @@ function getMetrics() {
427
428
  // SQL is the canonical (and only) metrics store after Phase 9.
428
429
  const store = require('./metrics-store');
429
430
  const metrics = store.readMetrics() || {};
431
+ const config = getConfig();
432
+ const engineConfig = config.engine || {};
433
+ const configuredAgents = config.agents || {};
434
+ const { resolveRuntime } = require('./runtimes');
430
435
 
431
436
  for (const [agentId, m] of Object.entries(metrics)) {
432
437
  if (agentId.startsWith('_')) continue;
@@ -493,6 +498,13 @@ function getMetrics() {
493
498
  m.totalRuntimeMs = runtimeByAgent[agentId];
494
499
  m.timedTasks = runtimeCountByAgent[agentId];
495
500
  }
501
+ const runtimeName = shared.resolveAgentCli(configuredAgents[agentId], engineConfig);
502
+ m.runtime = runtimeName;
503
+ try {
504
+ m.costTracking = resolveRuntime(runtimeName).capabilities?.costTracking === true;
505
+ } catch {
506
+ m.costTracking = false;
507
+ }
496
508
  }
497
509
 
498
510
  return metrics;
@@ -679,6 +691,112 @@ function getInbox() {
679
691
  // dispatch.completed (most recent) → done/error
680
692
  // neither → idle
681
693
  // Metadata (resultSummary, verdict, pr) is carried on dispatch entries.
694
+ function _runtimeModelEvidenceKey(runtime, requestedModel, agentId) {
695
+ return `${runtime}\0${requestedModel || ''}\0${agentId || ''}`;
696
+ }
697
+
698
+ function buildRuntimeModelEvidence(dispatch) {
699
+ const evidence = new Map();
700
+ for (const section of ['active', 'completed']) {
701
+ for (const item of (dispatch?.[section] || [])) {
702
+ const runtime = typeof item?.runtimeName === 'string' ? item.runtimeName.trim() : '';
703
+ const reported = normalizeExecutionModel(item?.executionModel);
704
+ const requested = normalizeExecutionModel(item?.requestedModel);
705
+ if (!runtime || !reported || (requested && requested.toLowerCase() !== 'auto')) continue;
706
+ const startedAt = Date.parse(item?.started_at || item?.created_at || item?.created || '');
707
+ const recency = Number.isFinite(startedAt) ? startedAt : 0;
708
+ for (const agentId of [item?.agent || '', '']) {
709
+ const key = _runtimeModelEvidenceKey(runtime, requested?.toLowerCase() || '', agentId);
710
+ const current = evidence.get(key);
711
+ if (!current || recency >= current.recency) evidence.set(key, { model: reported, recency });
712
+ }
713
+ }
714
+ }
715
+ return evidence;
716
+ }
717
+
718
+ function resolveRuntimeModelDisplay({ runtime, configuredModel, agentId, dispatch, evidence } = {}) {
719
+ const runtimeName = typeof runtime === 'string' ? runtime.trim() : '';
720
+ const configured = normalizeExecutionModel(configuredModel);
721
+ const autoSelected = configured && configured.toLowerCase() === 'auto';
722
+ if (configured && !autoSelected) return { model: configured, source: 'configured' };
723
+ if (!runtimeName) return { model: null, source: 'unobserved' };
724
+
725
+ const index = evidence || buildRuntimeModelEvidence(dispatch || getDispatch());
726
+ const requested = autoSelected ? 'auto' : '';
727
+ const latest = index.get(_runtimeModelEvidenceKey(runtimeName, requested, agentId));
728
+ return latest
729
+ ? { model: latest.model, source: 'runtime-reported' }
730
+ : { model: null, source: 'unobserved' };
731
+ }
732
+
733
+ /**
734
+ * W-ms0ytfdd000d5a26 — Index each work item's owning dispatch entry so callers
735
+ * can report the model that ACTUALLY ran rather than re-deriving it from
736
+ * current config (which would rewrite history whenever an operator repins a
737
+ * model). An active dispatch is the live run and always beats a completed one;
738
+ * among entries of the same kind the most recently started wins.
739
+ *
740
+ * Note `dispatch.completed` is capped (engine/dispatch.js), so an old work item
741
+ * can legitimately have no entry here — callers must handle that.
742
+ */
743
+ function buildWorkItemDispatchIndex(dispatch) {
744
+ const index = new Map();
745
+ const startedAtMs = (entry) => Date.parse(entry?.started_at || entry?.created_at || entry?.created || '') || 0;
746
+ const consider = (entry, active) => {
747
+ const key = _dispatchWorkItemIdentityKey(entry);
748
+ if (!key) return;
749
+ const current = index.get(key);
750
+ if (!current
751
+ || (active && !current.active)
752
+ || (active === current.active && startedAtMs(entry) >= startedAtMs(current.entry))) {
753
+ index.set(key, { entry, active });
754
+ }
755
+ };
756
+ for (const entry of (dispatch?.completed || [])) consider(entry, false);
757
+ for (const entry of (dispatch?.active || [])) consider(entry, true);
758
+ return index;
759
+ }
760
+
761
+ /**
762
+ * Current-config runtime + model for an agent, used only as the projection for
763
+ * work items that have never been dispatched. Shorthands are normalized through
764
+ * the adapter's own `resolveModel` so the projection matches what dispatch
765
+ * would actually send — capability-based, never a runtime-name branch.
766
+ */
767
+ function resolveConfiguredAgentModel(config, agentId, cache) {
768
+ const key = agentId || '';
769
+ if (cache && cache.has(key)) return cache.get(key);
770
+ const engine = config?.engine || {};
771
+ const agentConfig = (agentId && config?.agents?.[agentId]) || null;
772
+ const runtimeName = shared.resolveAgentCli(agentConfig, engine);
773
+ const configured = shared.resolveAgentModel(agentConfig, engine) || null;
774
+ let model = configured;
775
+ try {
776
+ const runtime = require('./runtimes').resolveRuntime(runtimeName);
777
+ if (typeof runtime?.resolveModel === 'function') model = runtime.resolveModel(configured) || configured;
778
+ } catch { /* registry unavailable — fall back to the raw configured value */ }
779
+ const resolved = { runtimeName, model };
780
+ if (cache) cache.set(key, resolved);
781
+ return resolved;
782
+ }
783
+
784
+ /**
785
+ * Effective-model descriptor for a single work item, shaped like the
786
+ * `runtime`/`model`/`modelSource` triple getAgents() already publishes:
787
+ * `{ model, label, source, runtime, persisted }`.
788
+ */
789
+ function resolveWorkItemModelInfo(item, { config, dispatchIndex, configCache } = {}) {
790
+ const found = dispatchIndex ? dispatchIndex.get(_workItemIdentityKey(item)) : null;
791
+ const agentId = item?.dispatched_to || item?.agent || '';
792
+ const configured = resolveConfiguredAgentModel(config, agentId, configCache);
793
+ return resolveWorkItemModel({
794
+ dispatchEntry: found ? found.entry : null,
795
+ configuredRuntime: configured.runtimeName,
796
+ configuredModel: configured.model,
797
+ });
798
+ }
799
+
682
800
  function getAgentStatus(agentId, context = {}) {
683
801
  const dispatch = context.dispatch || getDispatch();
684
802
 
@@ -771,6 +889,7 @@ function getAgents(config) {
771
889
 
772
890
  // Include temp agents that are currently active so they show up in agent tiles
773
891
  const dispatch = getDispatch();
892
+ const runtimeModelEvidence = buildRuntimeModelEvidence(dispatch);
774
893
  let leanWorkItems = [];
775
894
  try { leanWorkItems = getWorkItems(config, { enrich: false }); } catch { /* optional fallback data */ }
776
895
  const seen = new Set(roster.map(a => a.id));
@@ -787,11 +906,16 @@ function getAgents(config) {
787
906
  // → engine.defaultCli → 'copilot'. Surfaced so the dashboard can show a
788
907
  // runtime tag next to the agent name.
789
908
  const runtime = shared.resolveAgentCli(a, config.engine || {});
790
- // Resolve the model alongside the runtime so the dashboard can render a
791
- // sibling chip (W-mpmwxk4y00053271). `resolveAgentModel` returns
792
- // `undefined` when neither the per-agent override nor `engine.defaultModel`
793
- // is set, which the renderer treats as "runtime default" and omits.
909
+ // Keep the configured model separate from the display model. When no model
910
+ // is pinned, the runtime-reported execution model is authoritative.
794
911
  const model = shared.resolveAgentModel(a, config.engine || {}) || null;
912
+ const modelDisplay = resolveRuntimeModelDisplay({
913
+ runtime,
914
+ configuredModel: model,
915
+ agentId: a.id,
916
+ dispatch,
917
+ evidence: runtimeModelEvidence,
918
+ });
795
919
  const inboxFiles = allInboxFiles.filter(f => f.includes(a.id));
796
920
  let steeringInboxFiles = [];
797
921
  try { steeringInboxFiles = steering.listUnreadSteeringMessages(a.id); } catch { steeringInboxFiles = []; }
@@ -826,7 +950,15 @@ function getAgents(config) {
826
950
  const chartered = fs.existsSync(path.join(AGENTS_DIR, a.id, 'charter.md'));
827
951
  if (lastAction.length > 120) lastAction = lastAction.slice(0, 120) + '...';
828
952
  return {
829
- ...a, runtime, model, status: s.status, lastAction, lastActionPrefix, lastActionAt,
953
+ ...a,
954
+ runtime,
955
+ model,
956
+ displayModel: modelDisplay.model,
957
+ modelSource: modelDisplay.source,
958
+ status: s.status,
959
+ lastAction,
960
+ lastActionPrefix,
961
+ lastActionAt,
830
962
  // Descriptive capability tags; tolerate legacy `skills` configs by
831
963
  // normalizing to `expertise` for the dashboard/settings UI.
832
964
  expertise: a.expertise ?? a.skills ?? [],
@@ -1896,10 +2028,14 @@ function getWorkItems(config, opts) {
1896
2028
  }
1897
2029
  }
1898
2030
 
1899
- // Cross-reference with dispatch pending entries to surface skipReason + blockedBy (#617)
2031
+ // Cross-reference with dispatch pending entries to surface allocation gates
2032
+ // and skipReason + blockedBy (#617).
1900
2033
  const pendingByWiId = new Map();
1901
2034
  for (const d of (dispatch.pending || [])) {
1902
- if (d.meta?.item?.id && d.skipReason) {
2035
+ if (
2036
+ d.meta?.item?.id
2037
+ && (d.skipReason || d._pendingReason || d._worktreeBranchHeld)
2038
+ ) {
1903
2039
  pendingByWiId.set(_dispatchWorkItemIdentityKey(d), d);
1904
2040
  }
1905
2041
  }
@@ -1912,7 +2048,11 @@ function getWorkItems(config, opts) {
1912
2048
  for (const item of allItems) {
1913
2049
  const pendingEntry = pendingByWiId.get(_workItemIdentityKey(item));
1914
2050
  if (!pendingEntry) continue;
1915
- item._skipReason = pendingEntry.skipReason;
2051
+ if (pendingEntry.skipReason) item._skipReason = pendingEntry.skipReason;
2052
+ if (pendingEntry._pendingReason) item._pendingReason = pendingEntry._pendingReason;
2053
+ if (pendingEntry._worktreeBranchHeld) {
2054
+ item._worktreeBranchHeld = pendingEntry._worktreeBranchHeld;
2055
+ }
1916
2056
  if (pendingEntry.skipReason === 'branch_locked' && pendingEntry.meta?.branch) {
1917
2057
  const blocker = branchToAgent.get(pendingEntry.meta.branch);
1918
2058
  if (blocker) item._blockedBy = blocker;
@@ -1972,6 +2112,9 @@ function getWorkItems(config, opts) {
1972
2112
  if (key) dispatchByWiId[key] = d.id;
1973
2113
  }
1974
2114
  const _agentDirCache = {};
2115
+ // W-ms0ytfdd000d5a26 — effective-model resolution inputs, built once per call.
2116
+ const _wiDispatchIndex = buildWorkItemDispatchIndex(dispatch);
2117
+ const _wiModelConfigCache = new Map();
1975
2118
  const _inboxFiles = safeReadDir(INBOX_DIR);
1976
2119
  const _archiveFiles = safeReadDir(ARCHIVE_DIR);
1977
2120
  // Use snapshot — sync access; cold start before any async warm returns [].
@@ -1999,6 +2142,14 @@ function getWorkItems(config, opts) {
1999
2142
  for (const item of allItems) {
2000
2143
  const arts = shared.isPlainObject(item._artifacts) ? { ...item._artifacts } : {};
2001
2144
  const agentId = item.dispatched_to || item.agent;
2145
+ // W-ms0ytfdd000d5a26 — the model this item actually ran on (or, for items
2146
+ // that never ran, the projection from current config). Persisted dispatch
2147
+ // evidence wins so repinning a model later cannot rewrite history.
2148
+ item._model = resolveWorkItemModelInfo(item, {
2149
+ config,
2150
+ dispatchIndex: _wiDispatchIndex,
2151
+ configCache: _wiModelConfigCache,
2152
+ });
2002
2153
  if (agentId) {
2003
2154
  // Output log — match by dispatch ID (output-{dispatchId}.log)
2004
2155
  const dispatchId = dispatchByWiId[_workItemIdentityKey(item)];
@@ -3268,7 +3419,10 @@ module.exports = {
3268
3419
  _buildNotesByWiMap, // exported for testing
3269
3420
 
3270
3421
  // Agents
3271
- getAgentStatus, getAgents, getAgentDetail,
3422
+ getAgentStatus, getAgents, getAgentDetail, buildRuntimeModelEvidence, resolveRuntimeModelDisplay,
3423
+
3424
+ // Effective work-item model (W-ms0ytfdd000d5a26)
3425
+ buildWorkItemDispatchIndex, resolveConfiguredAgentModel, resolveWorkItemModelInfo,
3272
3426
 
3273
3427
  // Pull requests
3274
3428
  getPrs, getPullRequests,