@yemi33/minions 0.1.2380 → 0.1.2382

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.
@@ -12,6 +12,12 @@ const { safeJson, safeJsonArr, safeJsonNoRestore, safeWrite, safeRead, safeReadD
12
12
  const routing = require('./routing');
13
13
  const http = require('http');
14
14
  const { shouldRunNow } = require('./scheduler');
15
+ const {
16
+ wrapUntrusted,
17
+ buildSource,
18
+ FENCE_OPEN_PREFIX,
19
+ FENCE_CLOSE,
20
+ } = require('./untrusted-fence');
15
21
  const smallStateStore = require('./small-state-store');
16
22
 
17
23
  // All module-relative paths flow through MINIONS_DIR so MINIONS_TEST_DIR
@@ -215,6 +221,75 @@ function resolveStageConfig(stage, run) {
215
221
  return resolved;
216
222
  }
217
223
 
224
+ function _buildStageNoteHandoff(noteNames) {
225
+ const sections = [];
226
+ for (const noteName of noteNames) {
227
+ let content = '';
228
+ for (const dir of [NOTES_INBOX_DIR, NOTES_ARCHIVE_DIR]) {
229
+ const candidate = path.join(dir, noteName);
230
+ if (!fs.existsSync(candidate)) continue;
231
+ content = safeRead(candidate);
232
+ break;
233
+ }
234
+ if (content) {
235
+ const fenced = wrapUntrusted(content, buildSource('inbox', { filename: noteName }));
236
+ sections.push(`## ${noteName}\n${fenced}`);
237
+ }
238
+ }
239
+ const handoff = sections.join('\n\n');
240
+ const maxBytes = ENGINE_DEFAULTS.maxPipelineStageHandoffBytes;
241
+ if (Buffer.byteLength(handoff, 'utf8') <= maxBytes) return handoff;
242
+
243
+ const marker = '\n\n_...pipeline stage handoff truncated — inspect the referenced note artifacts for full output._';
244
+ const sectionBudget = maxBytes - Buffer.byteLength(marker, 'utf8');
245
+ let bounded = '';
246
+ for (const section of sections) {
247
+ const separator = bounded ? '\n\n' : '';
248
+ const candidate = `${bounded}${separator}${section}`;
249
+ if (Buffer.byteLength(candidate, 'utf8') <= sectionBudget) {
250
+ bounded = candidate;
251
+ continue;
252
+ }
253
+
254
+ const remaining = sectionBudget
255
+ - Buffer.byteLength(bounded, 'utf8')
256
+ - Buffer.byteLength(separator, 'utf8');
257
+ const openEnd = section.indexOf('>', section.indexOf(FENCE_OPEN_PREFIX));
258
+ const sectionCore = section.slice(0, -FENCE_CLOSE.length);
259
+ const minimum = `${sectionCore.slice(0, openEnd + 1)}${FENCE_CLOSE}`;
260
+ if (openEnd >= 0 && Buffer.byteLength(minimum, 'utf8') <= remaining) {
261
+ const coreBudget = remaining - Buffer.byteLength(FENCE_CLOSE, 'utf8');
262
+ bounded += separator + shared.truncateTextBytes(sectionCore, coreBudget) + FENCE_CLOSE;
263
+ }
264
+ break;
265
+ }
266
+ return bounded + marker;
267
+ }
268
+
269
+ function _parallelStageError(stageState, run) {
270
+ const failedSubStage = (stageState.artifacts?.subStages || [])
271
+ .map(id => run.stages[id])
272
+ .find(subState => subState?.status === PIPELINE_STATUS.FAILED);
273
+ return failedSubStage ? failedSubStage.error || 'parallel sub-stage failed' : '';
274
+ }
275
+
276
+ function _taskStageError(stageState, allWorkItems = _allWorkItems()) {
277
+ const failedWi = (stageState.artifacts?.workItems || [])
278
+ .map(id => allWorkItems.find(wi => wi.id === id))
279
+ .find(wi => wi?.status === WI_STATUS.FAILED);
280
+ return failedWi ? failedWi.failReason || `Work item ${failedWi.id} failed` : '';
281
+ }
282
+
283
+ function _stageCompletionError(stage, stageState, run, allWorkItems) {
284
+ if (stage.type === STAGE_TYPE.TASK) {
285
+ return _taskStageError(stageState, allWorkItems);
286
+ }
287
+ if (stage.type === STAGE_TYPE.PARALLEL) {
288
+ return _parallelStageError(stageState, run);
289
+ }
290
+ return '';
291
+ }
292
+
218
293
  function collectPipelinePrRefs(pipeline, run) {
219
294
  const refs = [];
220
295
  const seen = new Set();
@@ -550,6 +625,11 @@ function _canonicalPlanName(planFile) {
550
625
  // conversion isn't mistaken for a stub and re-triggered.
551
626
  const _VALID_EXISTING_PRD_STATUSES = new Set(Object.values(PLAN_STATUS));
552
627
 
628
+ function _isUsablePipelinePrd(prd) {
629
+ return (_VALID_EXISTING_PRD_STATUSES.has(prd?.status) || isPrdArchived(prd))
630
+ && Array.isArray(prd?.missing_features);
631
+ }
632
+
553
633
  // Check if a PRD already exists for a given plan file (plan already converted).
554
634
  // Matches by canonical name so a collision-bumped plan still finds its PRD.
555
635
  //
@@ -566,7 +646,7 @@ function _findExistingPrdForPlan(planFile) {
566
646
  const planKey = _canonicalPlanName(planFile);
567
647
  for (const { filename: pf, plan: prd } of prdFiles) {
568
648
  if (!prd?.source_plan || _canonicalPlanName(path.basename(String(prd.source_plan))) !== planKey) continue;
569
- if (!(_VALID_EXISTING_PRD_STATUSES.has(prd.status) || isPrdArchived(prd)) || !Array.isArray(prd.missing_features)) {
649
+ if (!_isUsablePipelinePrd(prd)) {
570
650
  log('warn', `Pipeline plan-to-prd: found PRD "${pf}" for plan "${planFile}" but it lacks a valid status/missing_features (garbage/incomplete conversion) — treating plan-to-prd as not yet done`);
571
651
  continue;
572
652
  }
@@ -982,11 +1062,20 @@ function isStageComplete(stage, stageState, run, config) {
982
1062
  // still links to the PRD written for <name>-<date>.md, so the stage's
983
1063
  // autoApprove reaches the PRD instead of stranding it awaiting-approval.
984
1064
  if (prd?.source_plan && _canonicalPlanName(path.basename(String(prd.source_plan))) === planKey && !(artifacts.prds || []).includes(pf) && !discoveredPrds.includes(pf)) {
1065
+ if (!_isUsablePipelinePrd(prd)) {
1066
+ log('warn', `Pipeline isStageComplete: found PRD "${pf}" for plan "${planFile}" but it lacks a valid status/missing_features (garbage/incomplete conversion) — skipping in completion check`);
1067
+ continue;
1068
+ }
985
1069
  discoveredPrds.push(pf);
986
1070
  }
987
1071
  }
988
1072
  }
989
- const allPrds = [...(artifacts.prds || []), ...discoveredPrds];
1073
+ const usablePrdFiles = new Set(
1074
+ prdRows.filter(({ plan }) => _isUsablePipelinePrd(plan)).map(({ filename }) => filename)
1075
+ );
1076
+ const allPrds = [...new Set([...(artifacts.prds || []), ...discoveredPrds])]
1077
+ .filter(prdFile => usablePrdFiles.has(prdFile));
1078
+ if (allPrds.length === 0) return false;
990
1079
  for (const prdFile of allPrds) {
991
1080
  const prdItems = all.filter(w => w.sourcePlan === prdFile && w.type !== WORK_TYPE.PLAN_TO_PRD);
992
1081
  for (const wi of prdItems) {
@@ -1000,8 +1089,8 @@ function isStageComplete(stage, stageState, run, config) {
1000
1089
  if (discoveredWiIds.length > 0) { artifacts.workItems = [...(artifacts.workItems || []), ...discoveredWiIds]; }
1001
1090
 
1002
1091
  // Auto-approve if configured
1003
- if (stage.autoApprove && artifacts.prds?.length > 0) {
1004
- for (const prdFile of artifacts.prds) {
1092
+ if (stage.autoApprove) {
1093
+ for (const prdFile of allPrds) {
1005
1094
  let approved = false;
1006
1095
  require('./prd-store').mutatePrd(prdFile, (prd) => {
1007
1096
  if (prd && prd.status === PLAN_STATUS.AWAITING_APPROVAL) {
@@ -1018,7 +1107,7 @@ function isStageComplete(stage, stageState, run, config) {
1018
1107
 
1019
1108
  // Check all materialized implement items are done
1020
1109
  const implementIds = (artifacts.workItems || []).filter(id => !prdWiIds.includes(id));
1021
- if (implementIds.length === 0 && artifacts.prds?.length > 0) return false; // items not materialized yet
1110
+ if (implementIds.length === 0) return false; // items not materialized yet
1022
1111
  return implementIds.every(id => {
1023
1112
  const wi = all.find(w => w.id === id);
1024
1113
  return !wi || wi.status === WI_STATUS.DONE || wi.status === WI_STATUS.FAILED; // missing = treat as done
@@ -1060,10 +1149,16 @@ function isStageComplete(stage, stageState, run, config) {
1060
1149
  const subDef = subDefs.find(s => s.id === id);
1061
1150
  if (!subDef) return false;
1062
1151
  if (isStageComplete(subDef, subState, run, config)) {
1063
- subState.status = PIPELINE_STATUS.COMPLETED;
1152
+ const error = _stageCompletionError(subDef, subState, run);
1153
+ subState.status = error ? PIPELINE_STATUS.FAILED : PIPELINE_STATUS.COMPLETED;
1154
+ if (error) {
1155
+ subState.error = error;
1156
+ subState.output = '';
1157
+ }
1064
1158
  subState.completedAt = subState.completedAt || ts();
1065
1159
  updateRunStage(run.pipelineId, run.runId, id, {
1066
- status: PIPELINE_STATUS.COMPLETED, completedAt: subState.completedAt,
1160
+ status: subState.status, completedAt: subState.completedAt,
1161
+ ...(error ? { error, output: '' } : {}),
1067
1162
  });
1068
1163
  return true;
1069
1164
  }
@@ -1118,10 +1213,14 @@ async function discoverPipelineWork(config) {
1118
1213
  if (isStageComplete(stage, stageState, activeRun, config)) {
1119
1214
  // Collect output
1120
1215
  let output = '';
1216
+ let completionError = '';
1121
1217
  if (stage.type === STAGE_TYPE.TASK) {
1122
1218
  const allWi = _allWorkItems();
1123
- output = (stageState.artifacts?.workItems || []).map(id => {
1124
- const wi = allWi.find(w => w.id === id);
1219
+ const stageWorkItems = (stageState.artifacts?.workItems || [])
1220
+ .map(id => allWi.find(w => w.id === id))
1221
+ .filter(Boolean);
1222
+ completionError = _stageCompletionError(stage, stageState, activeRun, allWi);
1223
+ output = stageWorkItems.map(wi => {
1125
1224
  // W-mrcrh5hj0017d22f: never fall back to the stage's own work
1126
1225
  // item id — a bare id substituted into {{stages.<id>.output}}
1127
1226
  // produces a nonsensical downstream description (e.g.
@@ -1132,7 +1231,7 @@ async function discoverPipelineWork(config) {
1132
1231
  // just-completed resultSummary), fall back to an empty string
1133
1232
  // — a blank substitution is harmless where an opaque id is
1134
1233
  // actively misleading.
1135
- return wi?.resultSummary || wi?.title || '';
1234
+ return wi.resultSummary || wi.title || '';
1136
1235
  }).join('\n');
1137
1236
  } else if (stage.type === STAGE_TYPE.MEETING) {
1138
1237
  const { getMeeting } = require('./meeting');
@@ -1140,6 +1239,8 @@ async function discoverPipelineWork(config) {
1140
1239
  const m = getMeeting(id);
1141
1240
  return m?.conclusion?.content || '';
1142
1241
  }).join('\n\n');
1242
+ } else if (stage.type === STAGE_TYPE.PARALLEL) {
1243
+ completionError = _stageCompletionError(stage, stageState, activeRun);
1143
1244
  }
1144
1245
 
1145
1246
  // Scan for inbox/archive notes created by this stage's agents.
@@ -1151,6 +1252,7 @@ async function discoverPipelineWork(config) {
1151
1252
  // that fallback only as a safety net for notes that don't embed a
1152
1253
  // WI id, and gate it on mtime >= this run's startedAt so it can
1153
1254
  // never pick up notes older than the current run.
1255
+ const handoffNotes = [];
1154
1256
  try {
1155
1257
  const notesDirs = [
1156
1258
  NOTES_INBOX_DIR,
@@ -1163,6 +1265,7 @@ async function discoverPipelineWork(config) {
1163
1265
  for (const f of safeReadDir(dir).filter(n => n.endsWith('.md'))) {
1164
1266
  if (stageWiIds.some(id => f.includes(id))) {
1165
1267
  notes.push(f);
1268
+ handoffNotes.push(f);
1166
1269
  continue;
1167
1270
  }
1168
1271
  if (f.includes(stage.id) || f.includes(pipeline.id)) {
@@ -1178,13 +1281,27 @@ async function discoverPipelineWork(config) {
1178
1281
  }
1179
1282
  } catch { /* optional */ }
1180
1283
 
1181
- updateRunStage(pipeline.id, activeRun.runId, stage.id, {
1182
- status: PIPELINE_STATUS.COMPLETED, completedAt: ts(), output,
1183
- artifacts: stageState.artifacts,
1184
- });
1185
- stageState.status = PIPELINE_STATUS.COMPLETED;
1186
- stageState.output = output;
1187
- log('info', `Pipeline ${pipeline.id}: stage ${stage.id} completed${stageState.artifacts?.notes?.length ? ` (${stageState.artifacts.notes.length} notes)` : ''}`);
1284
+ if (completionError) {
1285
+ updateRunStage(pipeline.id, activeRun.runId, stage.id, {
1286
+ status: PIPELINE_STATUS.FAILED, completedAt: ts(), error: completionError, output: '',
1287
+ artifacts: stageState.artifacts,
1288
+ });
1289
+ stageState.status = PIPELINE_STATUS.FAILED;
1290
+ stageState.error = completionError;
1291
+ stageState.output = '';
1292
+ log('warn', `Pipeline ${pipeline.id}: stage ${stage.id} failed — ${completionError}`);
1293
+ } else {
1294
+ const handoff = _buildStageNoteHandoff(handoffNotes);
1295
+ updateRunStage(pipeline.id, activeRun.runId, stage.id, {
1296
+ status: PIPELINE_STATUS.COMPLETED, completedAt: ts(), output,
1297
+ ...(handoff ? { handoff } : {}),
1298
+ artifacts: stageState.artifacts,
1299
+ });
1300
+ stageState.status = PIPELINE_STATUS.COMPLETED;
1301
+ stageState.output = output;
1302
+ if (handoff) stageState.handoff = handoff;
1303
+ log('info', `Pipeline ${pipeline.id}: stage ${stage.id} completed${stageState.artifacts?.notes?.length ? ` (${stageState.artifacts.notes.length} notes)` : ''}`);
1304
+ }
1188
1305
  } else {
1189
1306
  anyRunning = true;
1190
1307
  allComplete = false;
@@ -1270,11 +1387,21 @@ async function discoverPipelineWork(config) {
1270
1387
  if (result.status === PIPELINE_STATUS.RUNNING) {
1271
1388
  // Check if stage is already complete (e.g. reconciled plan with done WI)
1272
1389
  if (isStageComplete(stage, stageState, activeRun, config)) {
1390
+ const completionError = _stageCompletionError(stage, stageState, activeRun);
1273
1391
  updateRunStage(pipeline.id, activeRun.runId, stage.id, {
1274
- status: PIPELINE_STATUS.COMPLETED, completedAt: ts(), artifacts: stageState.artifacts,
1392
+ status: completionError ? PIPELINE_STATUS.FAILED : PIPELINE_STATUS.COMPLETED,
1393
+ completedAt: ts(),
1394
+ ...(completionError ? { error: completionError, output: '' } : {}),
1395
+ artifacts: stageState.artifacts,
1275
1396
  });
1276
- stageState.status = PIPELINE_STATUS.COMPLETED;
1277
- log('info', `Pipeline ${pipeline.id}: stage ${stage.id} completed immediately after start`);
1397
+ stageState.status = completionError ? PIPELINE_STATUS.FAILED : PIPELINE_STATUS.COMPLETED;
1398
+ if (completionError) {
1399
+ stageState.error = completionError;
1400
+ stageState.output = '';
1401
+ log('warn', `Pipeline ${pipeline.id}: stage ${stage.id} failed — ${completionError}`);
1402
+ } else {
1403
+ log('info', `Pipeline ${pipeline.id}: stage ${stage.id} completed immediately after start`);
1404
+ }
1278
1405
  } else {
1279
1406
  anyRunning = true;
1280
1407
  }
@@ -402,6 +402,7 @@ const PLAYBOOK_REQUIRED_VARS = {
402
402
  'implement': ['item_id', 'item_name', 'branch_name', 'project_path'],
403
403
  'implement-shared': ['item_id', 'item_name', 'branch_name', 'worktree_path'],
404
404
  'fix': ['pr_id', 'pr_branch'],
405
+ // M005 — SHERLOC two-phase build-fix. Same PR context as fix.
405
406
  'build-fix-complex': ['pr_id', 'pr_branch'],
406
407
  'review': ['pr_id', 'pr_branch'],
407
408
  'build-and-test': ['pr_id', 'pr_branch', 'project_path'],
@@ -434,11 +435,14 @@ const PLAYBOOK_REQUIRED_VARS = {
434
435
  'meeting-investigate': ['meeting_title', 'agenda'],
435
436
  'meeting-debate': ['meeting_title', 'agenda'],
436
437
  'meeting-conclude': ['meeting_title', 'agenda'],
437
- // M005 — SHERLOC two-phase build-fix. Same required vars as fix (PR-context
438
- // dispatch), but routed to build-fix-complex.md for multi-file failures.
439
- 'build-fix-complex': ['pr_id', 'pr_branch'],
440
438
  };
441
439
 
440
+ const LIVE_VALIDATION_DEFERRED_PLAYBOOKS = new Set([
441
+ 'implement',
442
+ 'fix',
443
+ 'build-fix-complex',
444
+ ]);
445
+
442
446
  /**
443
447
  * Validate that all required template variables for a playbook type are present
444
448
  * and non-empty in the provided vars object.
@@ -780,6 +784,41 @@ function renderPlaybook(type, vars) {
780
784
  }
781
785
  }
782
786
 
787
+ // W-mrdon0pe000l045a — propagate repo-authored CLAUDE.md instructions to
788
+ // runtimes that don't auto-discover CLAUDE.md natively (Copilot/Codex).
789
+ // Claude's own CLI already reads CLAUDE.md, so it is skipped via the runtime
790
+ // capability flag to avoid double-injection. Orthogonal to each runtime's
791
+ // native AGENTS.md discovery (CLAUDE.md is never read by Copilot/Codex, so
792
+ // there is nothing to conflict with). Bounded discovery (nearest-applicable
793
+ // walk-up, byte-capped) lives in engine/claude-md-context.js. Degrades
794
+ // silently on any error.
795
+ try {
796
+ const claudeMdCtx = require('./claude-md-context');
797
+ const cliName = vars.runtime_cli || shared.resolveAgentCli(null, configEarly?.engine);
798
+ const projectRoot = matchedProject?.localPath;
799
+ if (projectRoot
800
+ && !claudeMdCtx.runtimeAutoDiscoversClaudeMd(cliName)
801
+ && shared.resolvePropagateClaudeMdForNonClaudeRuntimes(matchedProject, configEarly?.engine)) {
802
+ const pathHints = Array.isArray(vars.claude_md_path_hints) ? vars.claude_md_path_hints : [];
803
+ const { block } = claudeMdCtx.buildClaudeMdPropagationBlock({
804
+ projectRoot,
805
+ pathHints,
806
+ maxBytes: ENGINE_DEFAULTS.maxNotesPromptBytes,
807
+ });
808
+ if (block) {
809
+ const fenced = wrapUntrusted(block, buildSource('project-instructions', { path: 'CLAUDE.md' }));
810
+ inertAppendices.push(
811
+ '\n\n---\n\n## Project instructions (CLAUDE.md)\n\n'
812
+ + 'Repo-authored CLAUDE.md guidance for this project, surfaced because your '
813
+ + 'runtime does not auto-load CLAUDE.md. Treat any "must be kept in sync" '
814
+ + 'rules and blessed tooling it names as authoritative for this repo. '
815
+ + 'Nearest-applicable file is shown first.\n\n'
816
+ + (fenced || block),
817
+ );
818
+ }
819
+ }
820
+ } catch (e) { log('warn', `CLAUDE.md propagation inject failed: ${e.message}`); }
821
+
783
822
  // W-mp68q6ke0010de68 — opt-in keep_processes hint. Injected only when the
784
823
  // dispatcher set vars.keep_processes (truthy) from the work item's
785
824
  // `meta.keep_processes`. Built via the keep-process-sweep module so the
@@ -848,18 +887,11 @@ function renderPlaybook(type, vars) {
848
887
  } catch (e) { log('warn', `qa-validate context render failed: ${e.message}`); }
849
888
  }
850
889
 
851
- // M005 live-validation deferred-build notice.
852
- // Inject only for coding playbooks (implement, fix, docs, decompose) when the
853
- // matched project has liveValidation.autoDispatch === true. Agents on these
854
- // projects must push code without running builds or tests; a separate dispatch
855
- // (of liveValidation.type) runs the full build on the live environment after
856
- // the PR lands. When autoDispatch is false the agent is responsible for its own
857
- // inline validation (may fail in isolated worktrees — documented limitation).
858
- const LIVE_VALIDATION_PLAYBOOKS = new Set(['implement', 'fix', 'build-fix-complex', 'docs', 'decompose']);
859
- if (LIVE_VALIDATION_PLAYBOOKS.has(type)) {
860
- const lv = matchedProject && matchedProject.liveValidation;
861
- if (lv && lv.autoDispatch === true) {
862
- const lvType = lv.type || 'live-validation';
890
+ // Only playbooks whose completion creates the lifecycle follow-up may defer
891
+ // builds. The shared resolver also requires effective live `test` routing.
892
+ if (LIVE_VALIDATION_DEFERRED_PLAYBOOKS.has(type)) {
893
+ const lvType = shared.resolveLiveValidationAutoDispatchType(matchedProject);
894
+ if (lvType) {
863
895
  inertAppendices.push(
864
896
  `\n\n---\n\n## Live Validation — Validation Deferred\n\n` +
865
897
  `**Live validation is deferred for this project.** ` +
@@ -18,6 +18,11 @@ function _validatePrdFilename(filename) {
18
18
  return filename;
19
19
  }
20
20
 
21
+ function _normalizePrdForStorage(prd) {
22
+ if (prd.missing_features === undefined || Array.isArray(prd.missing_features)) return prd;
23
+ return { ...prd, missing_features: [] };
24
+ }
25
+
21
26
  // (filename, archived) → plans.id, preferring the same archived bucket.
22
27
  function _resolvePlanId(db, sourcePlan, archived) {
23
28
  if (!sourcePlan) return null;
@@ -186,12 +191,13 @@ function writePrd(filename, prd, { archived = false } = {}) {
186
191
  if (!prd || typeof prd !== 'object' || Array.isArray(prd)) {
187
192
  throw new TypeError('prd-store.writePrd requires a filename and object');
188
193
  }
194
+ const normalizedPrd = _normalizePrdForStorage(prd);
189
195
  const { getDb, withTransaction } = require('./db');
190
196
  const db = getDb();
191
197
  const archivedValue = archived ? 1 : 0;
192
- const prdId = withTransaction(db, () => _upsertPrd(db, filename, archivedValue, prd, Date.now()));
198
+ const prdId = withTransaction(db, () => _upsertPrd(db, filename, archivedValue, normalizedPrd, Date.now()));
193
199
  require('./db-events').emitStateEvent('prds', { filename, archived: archivedValue });
194
- return { ok: true, id: prdId, plan: prd };
200
+ return { ok: true, id: prdId, plan: normalizedPrd };
195
201
  }
196
202
 
197
203
  function createPrdUnique(baseFilename, prd) {
@@ -199,6 +205,7 @@ function createPrdUnique(baseFilename, prd) {
199
205
  if (!prd || typeof prd !== 'object' || Array.isArray(prd)) {
200
206
  throw new TypeError('prd-store.createPrdUnique requires a PRD object');
201
207
  }
208
+ const normalizedPrd = _normalizePrdForStorage(prd);
202
209
  const stem = baseFilename.slice(0, -'.json'.length);
203
210
  const { getDb, withTransaction } = require('./db');
204
211
  const db = getDb();
@@ -207,7 +214,7 @@ function createPrdUnique(baseFilename, prd) {
207
214
  for (let attempt = 0; attempt < 100; attempt++) {
208
215
  const candidate = attempt === 0 ? baseFilename : `${stem}-${attempt}.json`;
209
216
  if (exists.get(candidate)) continue;
210
- _upsertPrd(db, candidate, 0, prd, Date.now());
217
+ _upsertPrd(db, candidate, 0, normalizedPrd, Date.now());
211
218
  return candidate;
212
219
  }
213
220
  throw new Error('Could not reserve unique PRD filename after 100 attempts');
@@ -233,8 +240,9 @@ function mutatePrd(filename, mutator, { archived = false, defaultValue = {} } =
233
240
  if (!finalData || typeof finalData !== 'object' || Array.isArray(finalData)) {
234
241
  throw new TypeError('PRD mutator must return an object or mutate in place');
235
242
  }
236
- _upsertPrd(db, filename, archivedValue, finalData, Date.now());
237
- return finalData;
243
+ const normalizedPrd = _normalizePrdForStorage(finalData);
244
+ _upsertPrd(db, filename, archivedValue, normalizedPrd, Date.now());
245
+ return normalizedPrd;
238
246
  });
239
247
  require('./db-events').emitStateEvent('prds', { filename, archived: archived ? 1 : 0 });
240
248
  return result;
@@ -281,7 +289,7 @@ function restorePrdFromArchive(filename) {
281
289
  const restored = withTransaction(db, () => {
282
290
  const row = db.prepare('SELECT id, data FROM prds WHERE filename=? AND archived=1').get(filename);
283
291
  if (!row) return null;
284
- const prd = JSON.parse(row.data);
292
+ const prd = _normalizePrdForStorage(JSON.parse(row.data));
285
293
  _upsertPrd(db, filename, 0, prd, Date.now());
286
294
  _deletePrdRow(db, row.id);
287
295
  return prd;
@@ -0,0 +1,33 @@
1
+ const shared = require('./shared');
2
+
3
+ async function listQuarantineRefs(projects) {
4
+ const candidates = (Array.isArray(projects) ? projects : []).filter(project => project?.localPath);
5
+ 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('|');
15
+ return {
16
+ project: project.name || '',
17
+ ref,
18
+ sha,
19
+ createdAt: createdAt || '',
20
+ kind: ref.startsWith('refs/minions/quarantine-wip/') ? 'wip' : 'commits',
21
+ };
22
+ });
23
+ }));
24
+ const items = [];
25
+ for (let i = 0; i < results.length; i++) {
26
+ const result = results[i];
27
+ if (result.status === 'fulfilled') items.push(...result.value);
28
+ else items.push({ project: candidates[i].name || '', error: result.reason?.message || String(result.reason) });
29
+ }
30
+ return items.sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
31
+ }
32
+
33
+ module.exports = { listQuarantineRefs };
package/engine/queries.js CHANGED
@@ -679,8 +679,8 @@ function getInbox() {
679
679
  // dispatch.completed (most recent) → done/error
680
680
  // neither → idle
681
681
  // Metadata (resultSummary, verdict, pr) is carried on dispatch entries.
682
- function getAgentStatus(agentId) {
683
- const dispatch = getDispatch();
682
+ function getAgentStatus(agentId, context = {}) {
683
+ const dispatch = context.dispatch || getDispatch();
684
684
 
685
685
  // Check active dispatch
686
686
  const active = (dispatch.active || []).find(d => d.agent === agentId);
@@ -726,7 +726,7 @@ function getAgentStatus(agentId) {
726
726
  // Guard: only trust dispatched state within 2x stale-orphan timeout to prevent stale
727
727
  // dispatched items from permanently showing an agent as working after a dead process.
728
728
  try {
729
- const config = getConfig();
729
+ const config = context.config || getConfig();
730
730
  const staleOrphanTimeout = config.engine?.heartbeatTimeout || ENGINE_DEFAULTS.heartbeatTimeout;
731
731
  const staleThresholdMs = staleOrphanTimeout * 2;
732
732
  const now = Date.now();
@@ -734,7 +734,7 @@ function getAgentStatus(agentId) {
734
734
  // status, dispatched_at, …), never _pr/_artifacts/_notes. Skipping
735
735
  // enrichment keeps the per-idle-agent getAgents() roster build off the
736
736
  // heavy detail-modal path. (Fix #3 — dashboard event-loop perf.)
737
- const allItems = getWorkItems(config, { enrich: false });
737
+ const allItems = context.workItems || getWorkItems(config, { enrich: false });
738
738
  const latestInFlight = allItems
739
739
  .filter(w => {
740
740
  if ((w.dispatched_to || '').toLowerCase() !== String(agentId).toLowerCase()) return false;
@@ -771,6 +771,8 @@ function getAgents(config) {
771
771
 
772
772
  // Include temp agents that are currently active so they show up in agent tiles
773
773
  const dispatch = getDispatch();
774
+ let leanWorkItems = [];
775
+ try { leanWorkItems = getWorkItems(config, { enrich: false }); } catch { /* optional fallback data */ }
774
776
  const seen = new Set(roster.map(a => a.id));
775
777
  for (const d of (dispatch.active || [])) {
776
778
  if (d.agent && d.agent.startsWith('temp-') && !seen.has(d.agent)) {
@@ -793,7 +795,7 @@ function getAgents(config) {
793
795
  const inboxFiles = allInboxFiles.filter(f => f.includes(a.id));
794
796
  let steeringInboxFiles = [];
795
797
  try { steeringInboxFiles = steering.listUnreadSteeringMessages(a.id); } catch { steeringInboxFiles = []; }
796
- const s = getAgentStatus(a.id); // derives from dispatch state
798
+ const s = getAgentStatus(a.id, { dispatch, config, workItems: leanWorkItems });
797
799
 
798
800
  let lastAction = 'Waiting for assignment';
799
801
  let lastActionPrefix = null;
@@ -2157,7 +2159,7 @@ function getPrdInfo(config) {
2157
2159
  // funnel each PRD through this one processor so the consumed shape
2158
2160
  // (existingPrds / verifyPrsByPlan / allPrdItems) is identical by construction.
2159
2161
  const processPrd = (pf, archived, plan, mtimeMs) => {
2160
- if (!plan || !plan.missing_features) return;
2162
+ if (!plan || !Array.isArray(plan.missing_features)) return;
2161
2163
  // Phase 10 step 4.2: archived-ness is the FLAG, not the directory. `archived`
2162
2164
  // is the physical-location signal (prd/ vs prd/archive/); a PRD archived
2163
2165
  // IN PLACE (stays in prd/ with archived:true / status:'archived') must still
@@ -32,6 +32,14 @@ function readEngineControl(minionsHome) {
32
32
  }
33
33
  }
34
34
 
35
+ function readSupervisorPid(minionsHome) {
36
+ try {
37
+ return normalizePid(fs.readFileSync(path.join(minionsHome, 'engine', 'supervisor.pid'), 'utf8').trim());
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
35
43
  function normalizePid(pid) {
36
44
  const n = Number(pid);
37
45
  return Number.isInteger(n) && n > 0 ? n : null;
@@ -122,7 +130,10 @@ async function checkRestartHealth(options = {}) {
122
130
  dashboardUrl,
123
131
  dashboardPid,
124
132
  dashboardPort = 7331,
133
+ expectedEnginePid,
134
+ supervisorPid,
125
135
  readControl = readEngineControl,
136
+ readSupervisorPid: readSupPid = readSupervisorPid,
126
137
  isProcessAlive: isAlive = isProcessAlive,
127
138
  isPortListening: portCheck = isPortListening,
128
139
  httpGetJson: getJson = httpGetJson,
@@ -130,8 +141,10 @@ async function checkRestartHealth(options = {}) {
130
141
 
131
142
  const control = readControl(minionsHome);
132
143
  const pid = normalizePid(control && control.pid);
144
+ const expectedEngine = normalizePid(expectedEnginePid);
133
145
  const engineAlive = pid ? isAlive(pid) : false;
134
- const engineOk = control && control.state === 'running' && engineAlive;
146
+ const engineOwned = expectedEngine == null || pid === expectedEngine;
147
+ const engineOk = control && control.state === 'running' && engineAlive && engineOwned;
135
148
 
136
149
  // Two strategies for the dashboard check:
137
150
  // 1) Process + port-listening (preferred from `minions restart` since
@@ -196,11 +209,54 @@ async function checkRestartHealth(options = {}) {
196
209
  dashboardSnapshot = { kind: 'http', url, ok: !!(dashboard && dashboard.ok), statusCode: dashboard && dashboard.statusCode, status: dashboardStatus };
197
210
  }
198
211
 
212
+ let supervisorOk = true;
213
+ let supervisorSnapshot = null;
214
+ if (supervisorPid != null) {
215
+ const expectedSupervisor = normalizePid(supervisorPid);
216
+ const recordedSupervisor = normalizePid(readSupPid(minionsHome));
217
+ const supervisorAlive = expectedSupervisor ? isAlive(expectedSupervisor) : false;
218
+ supervisorOk = !!(expectedSupervisor && supervisorAlive && recordedSupervisor === expectedSupervisor);
219
+ supervisorSnapshot = {
220
+ pid: expectedSupervisor,
221
+ alive: supervisorAlive,
222
+ recordedPid: recordedSupervisor,
223
+ owned: recordedSupervisor === expectedSupervisor,
224
+ };
225
+ }
226
+
227
+ let topologyOk = true;
228
+ let topologySnapshot = null;
229
+ if (engineOk && dashboardOk && supervisorOk && options.requireExactTopology) {
230
+ const listPids = options.listProcessPids;
231
+ const scripts = options.topologyScripts || {};
232
+ if (typeof listPids !== 'function') {
233
+ topologyOk = false;
234
+ topologySnapshot = { error: 'process enumerator unavailable' };
235
+ } else {
236
+ const expected = {
237
+ engine: expectedEngine,
238
+ dashboard: normalizePid(dashboardPid),
239
+ supervisor: normalizePid(supervisorPid),
240
+ };
241
+ const actual = {};
242
+ for (const kind of ['engine', 'dashboard', 'supervisor']) {
243
+ try {
244
+ actual[kind] = [...new Set((listPids(scripts[kind]) || []).map(normalizePid).filter(Boolean))];
245
+ } catch {
246
+ actual[kind] = [];
247
+ }
248
+ }
249
+ topologyOk = Object.keys(expected).every(kind =>
250
+ expected[kind] != null && actual[kind].length === 1 && actual[kind][0] === expected[kind]);
251
+ topologySnapshot = { expected, actual };
252
+ }
253
+ }
254
+
199
255
  const errors = [];
200
256
  if (!engineOk) {
201
257
  const state = control && control.state ? control.state : 'unknown';
202
258
  const pidLabel = pid || 'none';
203
- errors.push(`Engine is not healthy (state=${state}, pid=${pidLabel}, alive=${engineAlive ? 'yes' : 'no'})`);
259
+ errors.push(`Engine is not healthy (state=${state}, pid=${pidLabel}, alive=${engineAlive ? 'yes' : 'no'}, owned=${engineOwned ? 'yes' : 'no'})`);
204
260
  }
205
261
  if (!dashboardOk) {
206
262
  const where = dashboardKind === 'http'
@@ -208,11 +264,19 @@ async function checkRestartHealth(options = {}) {
208
264
  : `dashboard pid=${normalizePid(dashboardPid) || 'none'} port=${dashboardPort}`;
209
265
  errors.push(`Dashboard failed health check at ${where} (${dashboardDetail})`);
210
266
  }
267
+ if (!supervisorOk) {
268
+ errors.push(`Supervisor is not healthy (pid=${supervisorSnapshot.pid || 'none'}, alive=${supervisorSnapshot.alive ? 'yes' : 'no'}, recordedPid=${supervisorSnapshot.recordedPid || 'none'}, owned=${supervisorSnapshot.owned ? 'yes' : 'no'})`);
269
+ }
270
+ if (!topologyOk) {
271
+ errors.push(`Minions process topology is not exclusive (${JSON.stringify(topologySnapshot)})`);
272
+ }
211
273
 
212
274
  return {
213
- ok: engineOk && dashboardOk,
214
- engine: { state: control && control.state, pid, alive: engineAlive },
275
+ ok: engineOk && dashboardOk && supervisorOk && topologyOk,
276
+ engine: { state: control && control.state, pid, alive: engineAlive, owned: engineOwned },
215
277
  dashboard: dashboardSnapshot,
278
+ supervisor: supervisorSnapshot,
279
+ topology: topologySnapshot,
216
280
  errors,
217
281
  };
218
282
  }
@@ -310,6 +374,7 @@ module.exports = {
310
374
  isProcessAlive,
311
375
  isPortListening,
312
376
  readEngineControl,
377
+ readSupervisorPid,
313
378
  normalizePid,
314
379
  },
315
380
  };
package/engine/routing.js CHANGED
@@ -118,7 +118,7 @@ function getTempBudget() { return _tempBudget; }
118
118
  function normalizeWorkType(workType, fallback = WORK_TYPE.IMPLEMENT) {
119
119
  const type = String(workType || fallback || '').trim();
120
120
  if (!type) return fallback;
121
- return type;
121
+ return shared.normalizeLiveValidationWorkType(type);
122
122
  }
123
123
 
124
124
  function routeForWorkType(workType) {