@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.
- package/bin/minions.js +41 -2
- package/dashboard/js/refresh.js +8 -2
- package/dashboard/js/render-other.js +38 -0
- package/dashboard/js/render-work-items.js +61 -20
- package/dashboard/js/settings.js +7 -8
- package/dashboard/pages/engine.html +6 -0
- package/dashboard.js +84 -15
- package/docs/README.md +1 -0
- package/docs/claude-md-propagation.md +118 -0
- package/docs/engine-restart.md +10 -0
- package/docs/worktree-lifecycle.md +9 -0
- package/engine/claude-md-context.js +195 -0
- package/engine/consolidation.js +47 -3
- package/engine/db/migrations/026-pre-dispatch-rejections.js +55 -0
- package/engine/dispatch.js +73 -21
- package/engine/lifecycle.js +70 -12
- package/engine/pipeline.js +147 -20
- package/engine/playbook.js +35 -0
- package/engine/prd-store.js +14 -6
- package/engine/quarantine-refs.js +33 -0
- package/engine/queries.js +8 -6
- package/engine/restart-health.js +69 -4
- package/engine/runtimes/claude.js +6 -0
- package/engine/runtimes/codex.js +18 -0
- package/engine/runtimes/copilot.js +7 -0
- package/engine/shared.js +63 -44
- package/engine/supervisor.js +72 -16
- package/engine/timeout.js +87 -16
- package/engine/untrusted-fence.js +2 -1
- package/engine.js +251 -16
- package/package.json +1 -1
package/engine/lifecycle.js
CHANGED
|
@@ -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
|
|
4582
|
-
if (!completion || typeof completion !== 'object') return
|
|
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
|
-
|
|
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
|
|
5486
|
-
|
|
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 =
|
|
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,
|
package/engine/pipeline.js
CHANGED
|
@@ -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 (!(
|
|
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
|
|
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
|
|
1004
|
-
for (const prdFile of
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
1124
|
-
|
|
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
|
|
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
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
}
|
package/engine/playbook.js
CHANGED
|
@@ -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
|
package/engine/prd-store.js
CHANGED
|
@@ -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,
|
|
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:
|
|
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,
|
|
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
|
-
|
|
237
|
-
|
|
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
|
|
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
|