@yemi33/minions 0.1.2381 → 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.
@@ -4578,8 +4578,8 @@ function parseCompletionBoolean(value) {
4578
4578
  // detected, or null otherwise. The status must be a successful terminal state
4579
4579
  // — a failed/partial/in-progress completion that also claims `noop` is a
4580
4580
  // contradiction and falls through to the normal contract.
4581
- function parseCompletionNoop(completion) {
4582
- if (!completion || typeof completion !== 'object') return null;
4581
+ function completionDeclaresNoop(completion) {
4582
+ if (!completion || typeof completion !== 'object') return false;
4583
4583
  const explicit = parseCompletionBoolean(completion.noop);
4584
4584
  let resultIsNoop = false;
4585
4585
  if (typeof completion.result === 'string') {
@@ -4587,7 +4587,11 @@ function parseCompletionNoop(completion) {
4587
4587
  } else if (completion.result && typeof completion.result === 'object') {
4588
4588
  resultIsNoop = String(completion.result.type || '').trim().toLowerCase() === 'noop';
4589
4589
  }
4590
- if (explicit !== true && !resultIsNoop) return null;
4590
+ return explicit === true || resultIsNoop;
4591
+ }
4592
+
4593
+ function parseCompletionNoop(completion) {
4594
+ if (!completionDeclaresNoop(completion)) return null;
4591
4595
  const status = normalizeCompletionStatus(completion.status);
4592
4596
  if (status && status !== 'success' && status !== 'done' && status !== 'complete') return null;
4593
4597
  const reason = String(
@@ -4600,6 +4604,53 @@ function parseCompletionNoop(completion) {
4600
4604
  return reason || 'no-op completion';
4601
4605
  }
4602
4606
 
4607
+ function classifyBenignZeroTargetOutcome(dispatchItem, completion, resultSummary) {
4608
+ const item = dispatchItem?.meta?.item;
4609
+ if (!item?._scheduleId || item.createdBy !== 'scheduler') return null;
4610
+ const type = dispatchItem?.type || item.type;
4611
+ if (![WORK_TYPE.SETUP, WORK_TYPE.EXPLORE, WORK_TYPE.REVIEW].includes(type)) return null;
4612
+ if (!completionDeclaresNoop(completion)) return null;
4613
+ if (parseCompletionBoolean(completion?.needs_rerun ?? completion?.needsRerun) === true) return null;
4614
+ if (parseCompletionBoolean(completion?.retryable) === true) return null;
4615
+ if (completion?.securityFlags?.injectionAttempt === true) return null;
4616
+ const failureClass = String(completion?.failure_class || '').trim().toLowerCase();
4617
+ const nonceUnavailable = failureClass === 'completion-nonce-unavailable'
4618
+ || failureClass === 'nonce-unavailable';
4619
+ if (hasActionableFailureClass(failureClass) && !nonceUnavailable) return null;
4620
+ const status = normalizeCompletionStatus(completion?.status);
4621
+ const successfulStatus = status === 'success' || status === 'done' || status === 'complete';
4622
+ const nonceTransportFailure = nonceUnavailable && (status.startsWith('fail') || status === 'error');
4623
+ if (!successfulStatus && !nonceTransportFailure) return null;
4624
+
4625
+ const evidence = [...new Set([
4626
+ completion?.summary,
4627
+ completion?.noopReason,
4628
+ completion?.noop_reason,
4629
+ resultSummary,
4630
+ ].filter(Boolean).map(value => String(value).trim()))].join(' ').trim();
4631
+ if (!evidence) return null;
4632
+
4633
+ const mutationOccurred = [
4634
+ /\b(?:a|one|[1-9]\d*)\s+(?:new\s+)?(?:work items?|reviews?|pull requests?|prs?|issues?)\s+(?:(?:was|were)\s+)?(?:created|enrolled|dispatched|posted|deleted|removed)\b/i,
4635
+ /\b(?:created|enrolled|dispatched|posted|deleted|removed)\s+[1-9]\d*\b/i,
4636
+ ].some(pattern => pattern.test(evidence));
4637
+ if (mutationOccurred) return null;
4638
+
4639
+ const zeroTargets = [
4640
+ /\bno\s+[^.!?\n]{0,120}\b(?:needed|required|eligible|qualif(?:y|ied)|found|remain(?:ed|ing)?|to\s+(?:review|remove|dispatch|enroll|process|merge|fix))\b/i,
4641
+ /\bno\s+(?:open\s+issues?|eligible|qualifying|matching|context-only|merged|abandoned)\b/i,
4642
+ /\b(?:0|zero)\s+(?:eligible|qualifying|matching|context-only|open)\b/i,
4643
+ ].some(pattern => pattern.test(evidence));
4644
+ if (!zeroTargets) return null;
4645
+
4646
+ const noMutation = [
4647
+ /\bno\s+(?:api\s+)?(?:post|mutations?|writes?|actions?|changes?|modifications?|updates?|delete requests?|work items?|review work items?)\s+(?:(?:was|were)\s+)?(?:made|performed|taken|sent|created)?\b/i,
4648
+ /\b(?:0|zero)\s+(?:were\s+)?(?:created|enrolled|dispatched|posted|deleted|removed)\b/i,
4649
+ /\b(?:created|enrolled|dispatched|posted|deleted|removed)\s+(?:0|zero)\b/i,
4650
+ ].some(pattern => pattern.test(evidence));
4651
+ return noMutation ? evidence : null;
4652
+ }
4653
+
4603
4654
  function normalizeReviewVerdict(verdict) {
4604
4655
  const value = String(verdict || '').trim().toLowerCase().replace(/[\s-]+/g, '_');
4605
4656
  if (value === 'approve' || value === 'approved') return REVIEW_STATUS.APPROVED;
@@ -5482,11 +5533,15 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5482
5533
  // P-d2a8f6c1: a nonce mismatch always counts as an agent-reported failure
5483
5534
  // (regardless of what the discarded report claimed). This forces both the
5484
5535
  // effectiveSuccess gate below and the dispatch result in engine.js to ERROR.
5485
- const agentReportedFailure = !!nonceMismatch
5486
- || completionStatus.startsWith('fail')
5536
+ const benignNoop = nonceMismatch
5537
+ ? null
5538
+ : classifyBenignZeroTargetOutcome(dispatchItem, structuredCompletion, completionGateSummary);
5539
+ const agentReportedFailure = !!nonceMismatch || (!benignNoop && (
5540
+ completionStatus.startsWith('fail')
5487
5541
  || completionStatus === 'error'
5488
5542
  || hasActionableFailureClass(structuredCompletion?.failure_class)
5489
- || agentNeedsRerun;
5543
+ || agentNeedsRerun
5544
+ ));
5490
5545
  // Untrusted completions cannot be honored as "retryable" by the agent — its
5491
5546
  // retryable claim was discarded with the rest of the report. Force false.
5492
5547
  const agentRetryable = nonceMismatch ? false : parseCompletionBoolean(structuredCompletion?.retryable);
@@ -5499,7 +5554,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5499
5554
  if (autoRecovered) {
5500
5555
  log('info', `Auto-recovery: agent failed but created ${prsCreatedCount} PR(s) — upgrading ${meta.item.id} to done`);
5501
5556
  }
5502
- const effectiveSuccess = !nonceMismatch && ((isSuccess && !agentReportedFailure) || autoRecovered);
5557
+ const effectiveSuccess = !nonceMismatch && (benignNoop || (isSuccess && !agentReportedFailure) || autoRecovered);
5503
5558
  if (!nonceMismatch) {
5504
5559
  const episodeOutcome = effectiveSuccess
5505
5560
  ? (autoRecovered || completionStatus.startsWith('partial') ? 'partial' : 'success')
@@ -5571,7 +5626,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5571
5626
  // Without this, the second run produces no VERDICT, _retryCount increments,
5572
5627
  // and after 3 such bailouts the WI flips to status=failed even though the
5573
5628
  // original review was posted on the first run.
5574
- if (effectiveSuccess && type === WORK_TYPE.REVIEW && meta?.item?.id) {
5629
+ if (effectiveSuccess && type === WORK_TYPE.REVIEW && meta?.item?.id && !benignNoop) {
5575
5630
  const verdict = reviewVerdictFromCompletion(structuredCompletion) || parseReviewVerdict(resultSummary);
5576
5631
  if (!verdict && isReviewBailout(resultSummary)) {
5577
5632
  log('info', `Review ${meta.item.id} bailed out (review already posted) — treating as DONE without retry`);
@@ -5721,7 +5776,9 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5721
5776
 
5722
5777
  let completionContractFailure = null;
5723
5778
  if (effectiveSuccess && meta?.item?.id && !skipDoneStatus) {
5724
- const nonTerminalCompletion = detectNonTerminalResultSummary(completionGateSummary, structuredCompletion, reportCompletion, { detectPhantom });
5779
+ const nonTerminalCompletion = benignNoop
5780
+ ? null
5781
+ : detectNonTerminalResultSummary(completionGateSummary, structuredCompletion, reportCompletion, { detectPhantom });
5725
5782
  if (nonTerminalCompletion) {
5726
5783
  const isPhantomDetection = nonTerminalCompletion.phrase === 'phantom-completion';
5727
5784
  // P-e0b4f7a5 — before deferring a phantom retry, attempt to recover
@@ -5753,7 +5810,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5753
5810
  // shipped, dispatch premise wrong, self-authored review, etc.). Skip the PR
5754
5811
  // attachment contract — a missing PR is intentional, not a silent failure.
5755
5812
  const noopRationale = (effectiveSuccess && meta?.item?.id && !skipDoneStatus)
5756
- ? parseCompletionNoop(structuredCompletion)
5813
+ ? (parseCompletionNoop(structuredCompletion) || benignNoop)
5757
5814
  : null;
5758
5815
  if (noopRationale) {
5759
5816
  log('info', `No-op completion for ${meta.item.id}: ${noopRationale.slice(0, 200)}`);
@@ -6133,7 +6190,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
6133
6190
  const hardContractFail = completionContractFailure?.severity === 'hard'
6134
6191
  || completionContractFailure?.nonTerminal === true;
6135
6192
  const finalResult = hardContractFail ? DISPATCH_RESULT.ERROR : (effectiveSuccess ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR);
6136
- if (type === WORK_TYPE.REVIEW && finalResult === DISPATCH_RESULT.SUCCESS && !skipDoneStatus) {
6193
+ if (type === WORK_TYPE.REVIEW && finalResult === DISPATCH_RESULT.SUCCESS && !skipDoneStatus && !benignNoop) {
6137
6194
  await updatePrAfterReview(agentId, meta?.pr, meta?.project, config, resultSummary, structuredCompletion, dispatchItem);
6138
6195
  } else if (type === WORK_TYPE.REVIEW) {
6139
6196
  log('warn', `Skipping PR review metadata update for ${meta?.pr?.id || meta?.pr?.url || '(unknown PR)'} because review dispatch ${dispatchItem.id} did not complete cleanly`);
@@ -6232,7 +6289,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
6232
6289
  const metricsResult = isAutoRetry ? 'retry' : finalResult;
6233
6290
  updateMetrics(agentId, dispatchItem, metricsResult, taskUsage, prsCreatedCount, model);
6234
6291
 
6235
- return { resultSummary, taskUsage, autoRecovered, structuredCompletion, completionContractFailure, agentReportedFailure, agentRetryable, nonceMismatch };
6292
+ return { resultSummary, taskUsage, autoRecovered, structuredCompletion, completionContractFailure, agentReportedFailure, agentRetryable, nonceMismatch, benignNoop };
6236
6293
  }
6237
6294
 
6238
6295
  // ─── PR → PRD Status Sync ─────────────────────────────────────────────────────
@@ -6986,6 +7043,7 @@ module.exports = {
6986
7043
  parseReviewVerdict,
6987
7044
  isReviewBailout,
6988
7045
  parseCompletionNoop,
7046
+ classifyBenignZeroTargetOutcome, // exported for testing
6989
7047
  detectNonTerminalResultSummary,
6990
7048
  deferNonTerminalCompletion,
6991
7049
  deferPhantomCompletion,
@@ -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
  }
@@ -784,6 +784,41 @@ function renderPlaybook(type, vars) {
784
784
  }
785
785
  }
786
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
+
787
822
  // W-mp68q6ke0010de68 — opt-in keep_processes hint. Injected only when the
788
823
  // dispatcher set vars.keep_processes (truthy) from the work item's
789
824
  // `meta.keep_processes`. Built via the keep-process-sweep module so the
@@ -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