@yemi33/minions 0.1.2352 → 0.1.2354

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/engine/cli.js CHANGED
@@ -1653,6 +1653,10 @@ const commands = {
1653
1653
  return;
1654
1654
  }
1655
1655
  const targetProject = target?.project || (projects.length > 0 ? projects[0] : null);
1656
+ if (!targetProject) {
1657
+ console.log('No projects configured. Add a project before running work.');
1658
+ return;
1659
+ }
1656
1660
  const wiPath = projectWorkItemsPath(targetProject);
1657
1661
  const archivePath = wiPath.replace(/\.json$/, '-archive.json');
1658
1662
 
@@ -8,7 +8,7 @@ const path = require('path');
8
8
  const os = require('os');
9
9
  const shared = require('./shared');
10
10
  const { safeRead, safeJson, safeJsonArr, safeJsonNoRestore, safeWrite, mutateJsonFileLocked, mutateWorkItems, execAsync, projectPrPath, getPrLinks,
11
- log, ts, dateStamp, WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PR_STATUS, REVIEW_STATUS, RETRY_DELAY_MS, DISPATCH_RESULT,
11
+ log, ts, dateStamp, WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, REVIEW_STATUS, RETRY_DELAY_MS, DISPATCH_RESULT,
12
12
  ENGINE_DEFAULTS, DEFAULT_AGENT_METRICS, FAILURE_CLASS } = shared;
13
13
  const { resolveRuntime } = require('./runtimes');
14
14
  const adoGitAuth = require('./ado-git-auth');
@@ -38,27 +38,6 @@ function checkPlanCompletion(meta, config) {
38
38
  // Collect work items from ALL projects + central (PRD items can be in either)
39
39
  const allWorkItems = queries.getWorkItems(config);
40
40
  const planItems = allWorkItems.filter(w => w.sourcePlan === planFile && w.itemType !== 'pr' && w.itemType !== 'verify');
41
- if (planItems.length === 0) return;
42
-
43
- // W-mqk5ld3p: verify-creation must be a pure function of work-item state,
44
- // independent of who/what set `status: completed`. We do NOT short-circuit on
45
- // the raw `_completionNotified` boolean — a PRD can land on disk pre-completed
46
- // with the flag already set (out-of-band write by the plan-to-prd / pipeline
47
- // path), and the legacy top-of-function `if (_completionNotified) return` then
48
- // permanently skipped the aggregate build/test verify gate. Instead the flag
49
- // gates ONLY the one-shot completion summary (below); control always falls
50
- // through to the verify-creation block, which re-checks for an existing verify
51
- // WI under the file lock and is therefore safe to reach every scan. The one
52
- // exception is the REOPEN sub-path (terminal verify → re-open): that is a
53
- // plan-modification concern (the dashboard clears `_completionNotified` when it
54
- // re-opens a plan), so it is gated on `!alreadyNotified` to avoid bouncing an
55
- // already-done verify on every steady-state scan.
56
- const alreadyNotified = plan.status === PLAN_STATUS.COMPLETED && !!plan._completionNotified;
57
-
58
- // Hard completion gate: every PRD feature ID must have a corresponding work item in a terminal state.
59
- const planFeatureIds = new Set((plan.missing_features || []).map(f => f.id).filter(Boolean));
60
- const workItemById = {};
61
- for (const w of planItems) { if (w.id) workItemById[w.id] = w; }
62
41
 
63
42
  // Self-heal stale completion. checkPlanCompletion only ever ADVANCED a plan to
64
43
  // `completed` — it never reverted. So a PRD that was completed and then gained
@@ -71,6 +50,8 @@ function checkPlanCompletion(meta, config) {
71
50
  // materializer recreates the missing WI and the normal completion flow resumes.
72
51
  // Idempotent: once reverted (status active, flag cleared) the guard no-ops, so
73
52
  // it cannot flap on subsequent scans while the item is genuinely incomplete.
53
+ // Defined BEFORE the `planItems.length === 0` early return (below) so the
54
+ // zero-materialized-items dead end (W-mrboq1g900096f04) can call it too.
74
55
  const reopenStaleCompletedPlan = (reason) => {
75
56
  if (plan.status !== PLAN_STATUS.COMPLETED && !plan._completionNotified) return false;
76
57
  log('warn', `Plan ${planFile}: re-opening stale completed PRD — ${reason}`);
@@ -83,6 +64,50 @@ function checkPlanCompletion(meta, config) {
83
64
  return true;
84
65
  };
85
66
 
67
+ if (planItems.length === 0) {
68
+ // W-mrboq1g900096f04: a PRD that reaches `completed` with ZERO materialized
69
+ // work items (agent authoring bug, race, or manual/API misuse writing
70
+ // status:'completed' directly instead of 'awaiting-approval') was a
71
+ // permanent, silent dead end — this function used to hard-return here
72
+ // before ever reaching reopenStaleCompletedPlan above, and
73
+ // materializePlansAsWorkItems unconditionally skips COMPLETED plans, so
74
+ // nothing ever re-triggered materialization. Self-heal: if the PRD is
75
+ // COMPLETED and still has feature IDs stuck at a non-terminal PRD-level
76
+ // status ('missing'/'updated', i.e. PRD_MATERIALIZABLE) with no
77
+ // corresponding work item, reopen it so the materializer picks it up.
78
+ // Do NOT reopen a genuinely-complete PRD (no missing_features, or every
79
+ // feature already at a terminal PRD-level status such as 'done').
80
+ if (plan.status === PLAN_STATUS.COMPLETED) {
81
+ const stuckIds = (plan.missing_features || [])
82
+ .filter(f => f?.id && PRD_MATERIALIZABLE.has(f.status))
83
+ .map(f => f.id);
84
+ if (stuckIds.length > 0) {
85
+ reopenStaleCompletedPlan(`${stuckIds.length} item(s) stuck at PRD-level status with zero materialized work items: ${stuckIds.join(', ')}`);
86
+ }
87
+ }
88
+ return;
89
+ }
90
+
91
+ // W-mqk5ld3p: verify-creation must be a pure function of work-item state,
92
+ // independent of who/what set `status: completed`. We do NOT short-circuit on
93
+ // the raw `_completionNotified` boolean — a PRD can land on disk pre-completed
94
+ // with the flag already set (out-of-band write by the plan-to-prd / pipeline
95
+ // path), and the legacy top-of-function `if (_completionNotified) return` then
96
+ // permanently skipped the aggregate build/test verify gate. Instead the flag
97
+ // gates ONLY the one-shot completion summary (below); control always falls
98
+ // through to the verify-creation block, which re-checks for an existing verify
99
+ // WI under the file lock and is therefore safe to reach every scan. The one
100
+ // exception is the REOPEN sub-path (terminal verify → re-open): that is a
101
+ // plan-modification concern (the dashboard clears `_completionNotified` when it
102
+ // re-opens a plan), so it is gated on `!alreadyNotified` to avoid bouncing an
103
+ // already-done verify on every steady-state scan.
104
+ const alreadyNotified = plan.status === PLAN_STATUS.COMPLETED && !!plan._completionNotified;
105
+
106
+ // Hard completion gate: every PRD feature ID must have a corresponding work item in a terminal state.
107
+ const planFeatureIds = new Set((plan.missing_features || []).map(f => f.id).filter(Boolean));
108
+ const workItemById = {};
109
+ for (const w of planItems) { if (w.id) workItemById[w.id] = w; }
110
+
86
111
  // Check 1: every feature must have a work item (materialized)
87
112
  // Fallback: also accept features marked done directly in the PRD JSON (resolved externally)
88
113
  const unmaterialized = [...planFeatureIds].filter(id => {
@@ -5647,9 +5672,16 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5647
5672
  const w = data.find(i => i.id === meta.item.id);
5648
5673
  if (!w) return data;
5649
5674
  const retries = w._retryCount || 0;
5675
+ // #893 gate failure reason — surfaced into the next dispatch's prompt via
5676
+ // buildWorkItemDispatchVars()'s retry_context var (W-mrbq53ra000lf2e5) so the
5677
+ // retried agent knows the PRD file was never found instead of silently
5678
+ // repeating the same mistake.
5679
+ const gateReason = `Validation gate (#893): no PRD JSON file was found on disk for planFile "${meta.item.planFile || w.planFile || '(unknown)'}" (expected "${expectedFile || '(unset)'}") after the previous attempt completed. Write the PRD JSON to the exact path specified in the playbook before finishing.`;
5650
5680
  if (retries < ENGINE_DEFAULTS.maxRetries) {
5651
5681
  w.status = WI_STATUS.PENDING;
5652
5682
  w._retryCount = retries + 1;
5683
+ w._lastRetryReason = gateReason;
5684
+ w._lastRetryAt = ts();
5653
5685
  // W-mpmwxn1j — bump per-agent counter so a planner that never
5654
5686
  // writes the PRD gets reassigned after maxRetriesPerAgent hits.
5655
5687
  const failedAgent = meta?._agentId || w.dispatched_to;
@@ -5661,6 +5693,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5661
5693
  w.status = WI_STATUS.FAILED;
5662
5694
  w.failReason = 'PRD file not written after ' + ENGINE_DEFAULTS.maxRetries + ' attempts';
5663
5695
  w.failedAt = ts();
5696
+ w._lastRetryReason = gateReason;
5664
5697
  log('warn', `plan-to-prd ${meta.item.id} failed — no PRD file after ${ENGINE_DEFAULTS.maxRetries} retries`);
5665
5698
  }
5666
5699
  return data;
@@ -1168,6 +1168,40 @@ async function prepareLiveCheckout(opts = {}) {
1168
1168
  // auth-less GVFS blob fetches that fail on partial clones in headless mode.
1169
1169
  const failedCreate = await runCheckout(['checkout', '-b', branchName], { creating: true });
1170
1170
  if (failedCreate) return failedCreate;
1171
+ // ── originalRef contamination invariant (P-mrb8yg9x001gf984). ───────────
1172
+ // Defense-in-depth mirror of the existing-branch (4b) invariant above
1173
+ // (W-mrb6an8300058fd8). Ordinarily the WRONG-BASE GUARD earlier in this
1174
+ // path (Step 4d-i) already normalizes originalRef to mainRef whenever HEAD
1175
+ // started off a non-mainRef branch and !allowNonMainBase — but that
1176
+ // normalization only runs inside the `headBranch !== mainRef` branch of
1177
+ // that guard. If some future change to that guard (or to the
1178
+ // auto-base-repair / STALE-BASE divergence logic sitting between it and
1179
+ // here) leaves originalRef still pointing at a leftover engine-managed
1180
+ // branch instead of a sane restore target, it would otherwise flow straight
1181
+ // through to the return below and propagate into
1182
+ // restoreLiveCheckoutAtDispatchEnd at dispatch end — the exact
1183
+ // contamination class W-mrb6an8300058fd8 fixed on the 4b path. Apply the
1184
+ // SAME `_looksLikeAgentBranch` check here (same `!allowNonMainBase` gate —
1185
+ // an explicit PR-continuation / shared-branch dispatch still intentionally
1186
+ // bypasses this, exactly as it does on the 4b path) so the new-branch-fork
1187
+ // path can never silently regress that invariant.
1188
+ if (!allowNonMainBase && originalRefType === 'branch' && originalRef && originalRef !== mainRef &&
1189
+ _looksLikeAgentBranch(originalRef)) {
1190
+ logFn(
1191
+ `[live-checkout] originalRef anomaly: HEAD's captured originalRef '${originalRef}' still looks like an engine-managed branch after forking '${branchName}' — this looks like leftover contamination from an earlier dispatch that should have been corrected to '${mainRef}' already. Attempting to correct it now.`,
1192
+ 'warn'
1193
+ );
1194
+ const corrected = await _resolveMainRefForRestore({ git, baseOpts, mainRef, autoBaseRepair, _resolveAutoBaseRepair, localPath, logFn });
1195
+ if (corrected.ref) {
1196
+ originalRef = corrected.ref;
1197
+ originalRefType = corrected.type;
1198
+ } else {
1199
+ logFn(
1200
+ `[live-checkout] originalRef anomaly: could not resolve '${mainRef}' as a corrected restore target (no local ref, auto-base-repair disabled, or 'origin/${mainRef}' missing) — leaving originalRef as the contaminated '${originalRef}'; a human should verify the operator checkout manually.`,
1201
+ 'warn'
1202
+ );
1203
+ }
1204
+ }
1171
1205
  return { ok: true, branch: branchName, created: true, originalRef, originalRefType };
1172
1206
  }
1173
1207
 
@@ -1451,7 +1485,10 @@ async function restoreLiveCheckoutAtDispatchEnd(opts = {}) {
1451
1485
  * and the per-agent cli/model override pattern):
1452
1486
  * 1. If `project.liveCheckoutAutoStash` is an explicit boolean, use it.
1453
1487
  * 2. Else if `engine.liveCheckoutAutoStash` is an explicit boolean, use it.
1454
- * 3. Else default false.
1488
+ * 3. Else fall back to `shared.ENGINE_DEFAULTS.liveCheckoutAutoStash`
1489
+ * (default true as of W-mrawiym8000c42c9 — non-destructive, recoverable
1490
+ * via `git stash pop`, and now the first line of defense in the
1491
+ * dirty-tree recovery flow now that `liveCheckoutAutoReset` defaults off).
1455
1492
  *
1456
1493
  * Pure — no I/O. Tested in test/unit/live-checkout-auto-stash.test.js.
1457
1494
  *
@@ -1466,7 +1503,7 @@ function resolveLiveCheckoutAutoStash(opts = {}) {
1466
1503
  if (engine && typeof engine.liveCheckoutAutoStash === 'boolean') {
1467
1504
  return engine.liveCheckoutAutoStash;
1468
1505
  }
1469
- return false;
1506
+ return shared.ENGINE_DEFAULTS.liveCheckoutAutoStash;
1470
1507
  }
1471
1508
 
1472
1509
  /**
@@ -1536,11 +1573,29 @@ async function performLiveCheckoutAutoStash(opts = {}) {
1536
1573
  *
1537
1574
  * Returns a discriminated outcome the caller acts on:
1538
1575
  * - `{ outcome:'unchanged', liveResult }` — not dirty, disabled, or stash failed
1539
- * (caller falls through to the existing retry-once-then-fail dirty handling).
1576
+ * (caller falls through to the existing retry-once-then-fail dirty handling,
1577
+ * or — W-mrawgw4q000a6bff — to an explicit `liveCheckoutAutoReset` fallback
1578
+ * when that policy also resolves true; see engine.js spawnAgent).
1540
1579
  * - `{ outcome:'stashed', liveResult }` — stashed + re-preflighted; proceed.
1541
1580
  * - `{ outcome:'threw', error }` — the re-preflight threw; caller must
1542
1581
  * fail the dispatch as LIVE_CHECKOUT_FAILED (engine-specific completion).
1543
1582
  *
1583
+ * W-mrawgw4q000a6bff — stash-before-reset ordering + no auto-sync-to-origin.
1584
+ * The re-preflight (step 2) is called with `autoReset:false` so a still-dirty
1585
+ * tree after a successful stash NEVER silently falls through to a destructive
1586
+ * reset here — reset is only ever triggered explicitly by the caller as a
1587
+ * documented last-resort fallback. Also, by design, this function does NOT
1588
+ * follow up a successful stash with a `git merge --ff-only origin/<mainRef>`
1589
+ * to re-sync the branch: `liveCheckoutAutoStash`'s contract (see
1590
+ * `resolveLiveCheckoutAutoStash` above) has only ever been "park the dirty
1591
+ * changes so dispatch can proceed", never "keep the branch in sync with
1592
+ * origin" — that stronger guarantee is what `liveCheckoutAutoReset` is for,
1593
+ * and it is still available as an explicit opt-in for dispatches that need it
1594
+ * (with stash getting first crack at the tree, non-destructively, per the new
1595
+ * ordering). Adding an implicit ff-only sync here would blur that line and
1596
+ * risk a "successful" stash silently leaving a merge commit or a still-dirty
1597
+ * tree if the ff fails — so it was deliberately left out.
1598
+ *
1544
1599
  * @param {object} opts
1545
1600
  * @returns {Promise<{outcome:'unchanged'|'stashed'|'threw', liveResult?:object, error?:Error}>}
1546
1601
  */
@@ -1549,6 +1604,7 @@ async function applyLiveCheckoutAutoStash(opts = {}) {
1549
1604
  liveResult, project, engine, localPath, branchName, mainRef,
1550
1605
  gitOpts, dispatchId, wiId, log,
1551
1606
  clearDirtyStamp, writeStashNote,
1607
+ _git, // private injection for testing — forwarded to performLiveCheckoutAutoStash + the re-preflight prepareLiveCheckout call
1552
1608
  } = opts;
1553
1609
  const logFn = (typeof log === 'function') ? log : () => {};
1554
1610
  const reason = (liveResult && liveResult.ok === false) ? liveResult.reason : null;
@@ -1557,7 +1613,7 @@ async function applyLiveCheckoutAutoStash(opts = {}) {
1557
1613
  }
1558
1614
  let stashResult;
1559
1615
  try {
1560
- stashResult = await performLiveCheckoutAutoStash({ localPath, dispatchId, gitOpts, log });
1616
+ stashResult = await performLiveCheckoutAutoStash({ localPath, dispatchId, gitOpts, log, _git });
1561
1617
  } catch (stashErr) {
1562
1618
  stashResult = { ok: false, error: stashErr && stashErr.message };
1563
1619
  }
@@ -1567,7 +1623,10 @@ async function applyLiveCheckoutAutoStash(opts = {}) {
1567
1623
  }
1568
1624
  let newLive;
1569
1625
  try {
1570
- newLive = await prepareLiveCheckout({ localPath, branchName, mainRef, gitOpts, dispatchId, wiId, log });
1626
+ // autoReset:false see the function doc above; a still-dirty tree after a
1627
+ // successful stash must never silently fall through to a destructive reset
1628
+ // inside this re-preflight.
1629
+ newLive = await prepareLiveCheckout({ localPath, branchName, mainRef, gitOpts, dispatchId, wiId, log, autoReset: false, _git });
1571
1630
  } catch (stashLiveErr) {
1572
1631
  return { outcome: 'threw', error: stashLiveErr };
1573
1632
  }
@@ -307,6 +307,7 @@ const PLAYBOOK_OPTIONAL_VARS = new Set([
307
307
  'references', // only set when item.references has entries
308
308
  'acceptance_criteria', // only set when item.acceptanceCriteria has entries
309
309
  'checkpoint_context', // only set when resuming from a prior timeout
310
+ 'retry_context', // only set when retrying after a validation-gate failure (#893)
310
311
  // Host-specific identifiers — always one of {ado_*, github_*} is empty
311
312
  // (project is either ADO or GitHub, never both). Keeping them as warns
312
313
  // produces ~96 noise lines per day on a single-host project.
@@ -85,16 +85,20 @@ const PR_ACTION_FOLLOWUPS = Object.freeze([
85
85
  { kind: 'track-auto-fix', label: 'Track for auto-fix' },
86
86
  ]);
87
87
 
88
- /** Canonical `host:slug#number` id for a record/ref, or '' when unknowable. */
88
+ /**
89
+ * Canonical `host:slug#number` id for a record/ref, or '' when unknowable.
90
+ *
91
+ * Thin wrapper over prResolve.prIdFromRef: normalizes the heterogeneous
92
+ * record shapes buildPrActionFollowups is called with — an explicit
93
+ * `record.prId`, a `record.ref` ref object, or a bare ref-shaped object
94
+ * (`{host, slug, number}`/`{id}`) — into the ref shape prIdFromRef expects,
95
+ * then delegates so the `host:slug#number` template literal has one owner.
96
+ */
89
97
  function _prIdForRecord(record) {
90
98
  if (!record) return '';
91
99
  if (record.prId) return record.prId;
92
100
  const ref = record.ref || record;
93
- if (ref && ref.id) return ref.id;
94
- if (ref && ref.host && ref.slug && (ref.number || ref.number === 0)) {
95
- return `${ref.host}:${ref.slug}#${ref.number}`;
96
- }
97
- return '';
101
+ return prResolve.prIdFromRef(ref) || '';
98
102
  }
99
103
 
100
104
  /**
@@ -364,7 +368,8 @@ function buildPrActionPrompt(action, payload) {
364
368
  number: payload && payload.number,
365
369
  id: payload && payload.prId,
366
370
  };
367
- const prId = ref.id || (ref.host && ref.slug && ref.number ? `${ref.host}:${ref.slug}#${ref.number}` : (payload && payload.slug ? `${payload.slug}#${payload.number}` : 'the pull request'));
371
+ const prId = prResolve.prIdFromRef(ref)
372
+ || (payload && payload.slug ? `${payload.slug}#${payload.number}` : 'the pull request');
368
373
  const guidance = PR_ACTION_GUIDANCE[act] || PR_ACTION_GUIDANCE.review;
369
374
 
370
375
  const sections = [