@yemi33/minions 0.1.2380 → 0.1.2381

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/shared.js CHANGED
@@ -2481,6 +2481,80 @@ function classifyInboxItem(name, content) {
2481
2481
  // config.json keep working without an on-disk rewrite. Tracked in
2482
2482
  // docs/deprecated.json (id: worktreemode-field-rename).
2483
2483
  const CHECKOUT_MODES = Object.freeze({ WORKTREE: 'worktree', LIVE: 'live' });
2484
+ const LEGACY_LIVE_VALIDATION_TYPE_ALIASES = Object.freeze({
2485
+ 'build-and-test': 'test',
2486
+ });
2487
+
2488
+ function normalizeLiveValidationWorkType(value) {
2489
+ if (typeof value !== 'string') return '';
2490
+ const type = value.trim();
2491
+ return LEGACY_LIVE_VALIDATION_TYPE_ALIASES[type] || type;
2492
+ }
2493
+
2494
+ function inspectLiveValidation(value) {
2495
+ if (value === undefined || value === null || value === '') {
2496
+ return { configured: false, types: [], normalizedType: undefined, error: null };
2497
+ }
2498
+ if (typeof value !== 'object' || Array.isArray(value)) {
2499
+ return {
2500
+ configured: true,
2501
+ types: [],
2502
+ normalizedType: undefined,
2503
+ error: 'Invalid liveValidation: must be an object { type, autoDispatch }.',
2504
+ };
2505
+ }
2506
+
2507
+ const rawType = value.type;
2508
+ const rawTypes = Array.isArray(rawType) ? rawType : [rawType];
2509
+ if (
2510
+ (typeof rawType !== 'string' && !Array.isArray(rawType))
2511
+ || rawTypes.length === 0
2512
+ || !rawTypes.every(type => typeof type === 'string' && type.trim())
2513
+ ) {
2514
+ const accepted = [...LIVE_VALIDATION_WORK_TYPES].sort().join(', ');
2515
+ return {
2516
+ configured: true,
2517
+ types: [],
2518
+ normalizedType: undefined,
2519
+ error: `Invalid liveValidation: "type" is required and must be a non-empty string, or a non-empty array of live-capable work-item types. Accepted values: ${accepted}.`,
2520
+ };
2521
+ }
2522
+
2523
+ const normalizedTypes = [...new Set(rawTypes.map(normalizeLiveValidationWorkType))];
2524
+ const types = normalizedTypes.filter(type => LIVE_VALIDATION_WORK_TYPES.has(type));
2525
+ const invalidTypes = normalizedTypes.filter(type => !LIVE_VALIDATION_WORK_TYPES.has(type));
2526
+ let error = null;
2527
+ if (invalidTypes.length > 0) {
2528
+ const accepted = [...LIVE_VALIDATION_WORK_TYPES].sort().join(', ');
2529
+ error = `Invalid liveValidation type(s): ${invalidTypes.join(', ')}. Accepted values: ${accepted}.`;
2530
+ }
2531
+ if (!error && Object.prototype.hasOwnProperty.call(value, 'autoDispatch')) {
2532
+ if (typeof value.autoDispatch !== 'boolean') {
2533
+ error = 'Invalid liveValidation: "autoDispatch" must be a boolean.';
2534
+ } else if (value.autoDispatch && !normalizedTypes.includes(WORK_TYPE.TEST)) {
2535
+ error = 'Invalid liveValidation: autoDispatch requires "test" in liveValidation.type so the generated validation work item runs on the live checkout.';
2536
+ }
2537
+ }
2538
+ return {
2539
+ configured: true,
2540
+ types,
2541
+ normalizedType: Array.isArray(rawType) ? normalizedTypes : normalizedTypes[0],
2542
+ error,
2543
+ };
2544
+ }
2545
+
2546
+ // Return the canonical, live-capable work-item types configured for a hybrid
2547
+ // project. Raw config is read defensively because config.json can predate the
2548
+ // validator; invalid entries are ignored rather than turning hybrid into full
2549
+ // live mode. `build-and-test` is retained as a read-compat alias for `test`.
2550
+ function resolveLiveValidationTypes(project) {
2551
+ return inspectLiveValidation(project && project.liveValidation).types;
2552
+ }
2553
+
2554
+ function isLiveValidationConfigInvalid(project) {
2555
+ const inspection = inspectLiveValidation(project && project.liveValidation);
2556
+ return inspection.configured && !!inspection.error;
2557
+ }
2484
2558
 
2485
2559
  // Resolve a project's effective checkout mode, honoring the legacy
2486
2560
  // `worktreeMode` field for back-compat. Reading order:
@@ -2492,37 +2566,37 @@ const CHECKOUT_MODES = Object.freeze({ WORKTREE: 'worktree', LIVE: 'live' });
2492
2566
  //
2493
2567
  // Optional second argument `workItemType` enables liveValidation routing:
2494
2568
  // If checkoutMode === 'live' AND project.liveValidation is set:
2495
- // - workItemType matches liveValidation.type → 'live' (validation serialized)
2496
- // - otherwise → 'worktree' (coding escapes the live cap)
2569
+ // - workItemType matches liveValidation.type → 'live'
2570
+ // - otherwise → 'worktree'
2497
2571
  // liveValidation without checkoutMode: 'live' is ignored with a warn log.
2498
2572
  function resolveCheckoutMode(project, workItemType) {
2499
2573
  if (!project || typeof project !== 'object') return CHECKOUT_MODES.WORKTREE;
2500
2574
  const canonical = project.checkoutMode;
2575
+ const legacy = project.worktreeMode;
2576
+ let effectiveMode = CHECKOUT_MODES.WORKTREE;
2577
+ if (canonical === CHECKOUT_MODES.LIVE || canonical === CHECKOUT_MODES.WORKTREE) {
2578
+ effectiveMode = canonical;
2579
+ } else if (legacy === 'live') {
2580
+ effectiveMode = CHECKOUT_MODES.LIVE;
2581
+ }
2501
2582
  // liveValidation without checkoutMode: 'live' is a misconfiguration — warn and ignore.
2502
- if (project.liveValidation && canonical !== CHECKOUT_MODES.LIVE) {
2583
+ if (project.liveValidation && effectiveMode !== CHECKOUT_MODES.LIVE) {
2503
2584
  log('warn', 'resolveCheckoutMode: liveValidation is set but checkoutMode is not "live" — liveValidation ignored',
2504
2585
  { projectName: project.name });
2505
2586
  }
2506
- if (canonical === CHECKOUT_MODES.LIVE) {
2587
+ if (effectiveMode === CHECKOUT_MODES.LIVE) {
2507
2588
  // Apply liveValidation routing when the block is present and workItemType is provided.
2508
2589
  // liveValidation.type may be a single string (legacy / back-compat) or an
2509
2590
  // array of strings (W-mr2m1ute000a9c01, multi-select hybrid types) —
2510
2591
  // normalize to an array and do a membership check so either shape works.
2511
2592
  if (project.liveValidation && workItemType !== undefined) {
2512
- const lvTypes = Array.isArray(project.liveValidation.type)
2513
- ? project.liveValidation.type
2514
- : [project.liveValidation.type];
2515
- return lvTypes.includes(workItemType)
2593
+ const lvTypes = resolveLiveValidationTypes(project);
2594
+ return lvTypes.includes(normalizeLiveValidationWorkType(workItemType))
2516
2595
  ? CHECKOUT_MODES.LIVE
2517
2596
  : CHECKOUT_MODES.WORKTREE;
2518
2597
  }
2519
2598
  return CHECKOUT_MODES.LIVE;
2520
2599
  }
2521
- if (canonical === CHECKOUT_MODES.WORKTREE) return CHECKOUT_MODES.WORKTREE;
2522
- // Legacy field fallback (only consulted when checkoutMode is absent/unknown).
2523
- const legacy = project.worktreeMode;
2524
- if (legacy === 'live') return CHECKOUT_MODES.LIVE;
2525
- // legacy 'isolated' (and anything else) → the default worktree behavior.
2526
2600
  return CHECKOUT_MODES.WORKTREE;
2527
2601
  }
2528
2602
 
@@ -2538,9 +2612,10 @@ function isLiveCheckoutProject(project) {
2538
2612
  // anything else (undefined / null / string) is treated as unset so the next
2539
2613
  // tier (or the `false` default) decides. When ON and a live-checkout tree is
2540
2614
  // dirty, `engine/live-checkout.js#prepareLiveCheckout` runs
2541
- // `git fetch origin` + `git reset --hard origin/<branch>` instead of refusing
2542
- // with LIVE_CHECKOUT_DIRTY (the reset DISCARDS the operator's uncommitted work,
2543
- // so it is opt-in). Sibling of `resolveCheckoutMode` — pure, no I/O.
2615
+ // `git reset --hard HEAD` + `git clean -fd` instead of refusing with
2616
+ // LIVE_CHECKOUT_DIRTY. This discards uncommitted tracked and untracked work but
2617
+ // preserves the current branch and committed history, so it is opt-in. Sibling
2618
+ // of `resolveCheckoutMode` — pure, no I/O.
2544
2619
  function resolveLiveCheckoutAutoReset(project, engine) {
2545
2620
  if (project && typeof project === 'object' && typeof project.liveCheckoutAutoReset === 'boolean') {
2546
2621
  return project.liveCheckoutAutoReset;
@@ -2605,16 +2680,14 @@ function validateCheckoutMode(value) {
2605
2680
  }
2606
2681
 
2607
2682
  // Validate + normalize a per-project `liveValidation` block (hybrid mode).
2608
- // Hybrid = checkoutMode:'live' + liveValidation:{ type, autoDispatch }: coding
2609
- // work items author in isolated worktrees (escaping the live cap) while only
2610
- // work items whose type is IN liveValidation.type run in-place on the live
2611
- // checkout. See resolveCheckoutMode (which routes per work-item type) and
2612
- // docs/live-checkout-mode.md.
2683
+ // Hybrid = checkoutMode:'live' + liveValidation:{ type, autoDispatch }: only
2684
+ // work items whose type is IN liveValidation.type run in-place; all other types
2685
+ // use isolated worktrees. See resolveCheckoutMode and docs/live-checkout-mode.md.
2613
2686
  //
2614
2687
  // `type` may be a single work-item-type string (legacy / back-compat) OR a
2615
2688
  // non-empty array of non-empty-string work-item types
2616
2689
  // (W-mr2m1ute000a9c01 — multi-select hybrid types, e.g. both "implement" and
2617
- // "fix" validate live while other types stay in worktrees). Every reader of
2690
+ // "fix" run live while other types stay in worktrees). Every reader of
2618
2691
  // liveValidation.type (resolveCheckoutMode, lifecycle.autoDispatchLiveValidationWi,
2619
2692
  // dashboard project mapper / pill / CC label) must handle BOTH shapes —
2620
2693
  // existing config.json entries with a plain string `type` continue to work
@@ -2623,10 +2696,8 @@ function validateCheckoutMode(value) {
2623
2696
  // Returns:
2624
2697
  // - undefined → caller should clear the field (empty / null / '' input)
2625
2698
  // - { type, autoDispatch? } normalized object on success, where `type` is
2626
- // whatever shape the caller passed in (string stays a string; array stays
2627
- // a deduped array) we intentionally do NOT force single-string input
2628
- // into an array so already-persisted single-type configs round-trip
2629
- // byte-identical through a Settings save that doesn't touch this field.
2699
+ // the caller's input shape (string stays a string; array stays an array),
2700
+ // with values trimmed, normalized, and deduplicated.
2630
2701
  // Throws HTTP 400 (via _httpError) on malformed input, or when the effective
2631
2702
  // checkout mode is not 'live' (liveValidation is meaningless without it — it is
2632
2703
  // silently ignored at resolve time, so we refuse to persist a misconfiguration).
@@ -2636,45 +2707,15 @@ function validateCheckoutMode(value) {
2636
2707
  // applied so the gate sees the intended final mode.
2637
2708
  function validateLiveValidation(value, opts) {
2638
2709
  if (value === undefined || value === null || value === '') return undefined;
2639
- if (typeof value !== 'object' || Array.isArray(value)) {
2640
- throw _httpError(400, 'Invalid liveValidation: must be an object { type, autoDispatch }.');
2641
- }
2710
+ const inspection = inspectLiveValidation(value);
2711
+ if (inspection.error) throw _httpError(400, inspection.error);
2642
2712
  const checkoutMode = opts && opts.checkoutMode;
2643
2713
  if (checkoutMode !== CHECKOUT_MODES.LIVE) {
2644
2714
  throw _httpError(400, 'liveValidation requires checkoutMode "live" (hybrid mode). Set checkoutMode to "live" first, or clear liveValidation.');
2645
2715
  }
2646
- const typeErrorMsg = 'Invalid liveValidation: "type" is required and must be a non-empty string, or a non-empty array of non-empty-string work-item types — the work-item type(s) that run on the live checkout (e.g. "build-and-test" or ["implement", "fix"]).';
2647
- const rawType = value.type;
2648
- let out;
2649
- if (typeof rawType === 'string') {
2650
- const trimmed = rawType.trim();
2651
- if (!trimmed) throw _httpError(400, typeErrorMsg);
2652
- out = { type: trimmed };
2653
- } else if (Array.isArray(rawType)) {
2654
- if (!rawType.every(t => typeof t === 'string' && t.trim().length > 0)) {
2655
- throw _httpError(400, typeErrorMsg);
2656
- }
2657
- // Dedupe (preserving first-seen order) and cap at the number of distinct
2658
- // work-item types the engine recognizes — a longer array can only be
2659
- // full of duplicates or invented types.
2660
- const deduped = [...new Set(rawType.map(t => t.trim()))];
2661
- if (deduped.length === 0) throw _httpError(400, typeErrorMsg);
2662
- if (deduped.length > VALID_WORK_TYPES.size) {
2663
- throw _httpError(400, `Invalid liveValidation: "type" array has ${deduped.length} entries, more than the ${VALID_WORK_TYPES.size} distinct work-item types the engine recognizes — remove duplicates or invalid types.`);
2664
- }
2665
- out = { type: deduped };
2666
- } else {
2667
- throw _httpError(400, typeErrorMsg);
2668
- }
2669
- // autoDispatch (optional): when true, lifecycle auto-creates a SINGLE
2670
- // validation WI after each coding WI completes with a PR
2671
- // (engine/lifecycle.js autoDispatchLiveValidationWi). Even when `type` is a
2672
- // multi-entry array (which drives checkout ROUTING — the types that run live),
2673
- // exactly one validation WI is filed, of the canonical `test` type when
2674
- // configured else the first configured live type. Coerce to a strict
2675
- // boolean; absent leaves it off so the operator opts in explicitly.
2716
+ const out = { type: inspection.normalizedType };
2676
2717
  if (Object.prototype.hasOwnProperty.call(value, 'autoDispatch')) {
2677
- out.autoDispatch = !!value.autoDispatch;
2718
+ out.autoDispatch = value.autoDispatch;
2678
2719
  }
2679
2720
  return out;
2680
2721
  }
@@ -3902,6 +3943,15 @@ const WORK_TYPE = {
3902
3943
  BUILD_FIX_COMPLEX: 'build-fix-complex',
3903
3944
  };
3904
3945
 
3946
+ const FIX_LIKE_WORK_TYPES = new Set([
3947
+ WORK_TYPE.FIX,
3948
+ WORK_TYPE.BUILD_FIX_COMPLEX,
3949
+ ]);
3950
+
3951
+ function isFixLikeWorkType(type) {
3952
+ return FIX_LIKE_WORK_TYPES.has(type);
3953
+ }
3954
+
3905
3955
  // Work types whose dispatch path requires a per-project git worktree. The
3906
3956
  // engine's spawnAgent uses the project's `localPath` as the worktree root —
3907
3957
  // without an owning project the rootDir falls back to MINIONS_DIR's parent,
@@ -3940,6 +3990,21 @@ const WORKTREE_REQUIRING_TYPES = new Set([
3940
3990
  WORK_TYPE.DOCS,
3941
3991
  ]);
3942
3992
 
3993
+ // Hybrid routing is meaningful only for work types that would otherwise need
3994
+ // an isolated project worktree. Read-only root tasks already run from the
3995
+ // project/root path and therefore gain nothing from a live-validation entry.
3996
+ const LIVE_VALIDATION_WORK_TYPES = new Set(WORKTREE_REQUIRING_TYPES);
3997
+
3998
+ function resolveLiveValidationAutoDispatchType(project) {
3999
+ if (!project?.liveValidation || project.liveValidation.autoDispatch !== true) return null;
4000
+ if (resolveCheckoutMode(project) !== CHECKOUT_MODES.LIVE) return null;
4001
+ const inspection = inspectLiveValidation(project.liveValidation);
4002
+ if (inspection.error) return null;
4003
+ return inspection.types.includes(WORK_TYPE.TEST)
4004
+ ? WORK_TYPE.TEST
4005
+ : null;
4006
+ }
4007
+
3943
4008
  // W-mpxqkkn300121d21 — Valid `type` strings the materializer will honor when a
3944
4009
  // PRD missing_feature carries one. Excludes orchestration-only types that the
3945
4010
  // plan→PRD→materializer pipeline never produces (PLAN, PLAN_TO_PRD, MEETING)
@@ -7295,7 +7360,12 @@ function classifyPrRefForVerification(prRef, project = null) {
7295
7360
  // { skipped: true, reason } — not a pr-requiring type / no ref / no URL / upsert error
7296
7361
  // { alreadyEnrolled: true, id } — PR already tracked
7297
7362
  // { enrolled: true, id, scope } — newly enrolled
7298
- const _PR_REQUIRING_TYPES = new Set([WORK_TYPE.FIX, WORK_TYPE.REVIEW, WORK_TYPE.TEST]);
7363
+ const _PR_REQUIRING_TYPES = new Set([
7364
+ WORK_TYPE.FIX,
7365
+ WORK_TYPE.BUILD_FIX_COMPLEX,
7366
+ WORK_TYPE.REVIEW,
7367
+ WORK_TYPE.TEST,
7368
+ ]);
7299
7369
  function autoEnrollPrFromWorkItem(item, project, minionsDir) {
7300
7370
  if (!item || !_PR_REQUIRING_TYPES.has(item.type)) return { skipped: true, reason: 'not-pr-requiring-type' };
7301
7371
  const prRef = extractStructuredWorkItemPrRef(item);
@@ -8426,7 +8496,7 @@ module.exports = {
8426
8496
  runtimeConfigWarnings,
8427
8497
  projectWorkSourceWarnings,
8428
8498
  backfillProjectWorkSourceDefaults,
8429
- WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, isPrdArchived, isDefunctPrd, WORK_TYPE, WORKTREE_REQUIRING_TYPES, VALID_WORK_TYPES, resolveWorkItemTypeFromPrdItem, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, PR_PENDING_REASON, BUILD_STATUS, REVIEW_STATUS, FETCH_TIMEOUT_MS, RETRY_DELAY_MS, ADO_TOKEN_REFRESH_MAX_RETRIES, DISPATCH_RESULT, mutateMetrics, mutateWatches, mutateScheduleRuns, mutatePipelineRuns, mutateManagedProcesses, mutateWorktreePool, mutateQaRuns, mutateQaSessions, trackReviewMetric, queuePlanToPrd, extractPlanDeclaredProject, extractPlanTargetProjects,
8499
+ WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, isPrdArchived, isDefunctPrd, WORK_TYPE, isFixLikeWorkType, WORKTREE_REQUIRING_TYPES, LIVE_VALIDATION_WORK_TYPES, VALID_WORK_TYPES, resolveWorkItemTypeFromPrdItem, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, PR_PENDING_REASON, BUILD_STATUS, REVIEW_STATUS, FETCH_TIMEOUT_MS, RETRY_DELAY_MS, ADO_TOKEN_REFRESH_MAX_RETRIES, DISPATCH_RESULT, mutateMetrics, mutateWatches, mutateScheduleRuns, mutatePipelineRuns, mutateManagedProcesses, mutateWorktreePool, mutateQaRuns, mutateQaSessions, trackReviewMetric, queuePlanToPrd, extractPlanDeclaredProject, extractPlanTargetProjects,
8430
8500
  WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS, WATCH_ACTION_TYPE,
8431
8501
  WATCH_STALLED_DEFAULT_TICKS, WATCH_STUCK_STAGE_DEFAULT_TICKS,
8432
8502
  PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, AGENT_STATUS,
@@ -8546,6 +8616,10 @@ module.exports = {
8546
8616
  CHECKOUT_MODES,
8547
8617
  validateCheckoutMode,
8548
8618
  validateLiveValidation,
8619
+ normalizeLiveValidationWorkType,
8620
+ resolveLiveValidationTypes,
8621
+ isLiveValidationConfigInvalid,
8622
+ resolveLiveValidationAutoDispatchType,
8549
8623
  resolveCheckoutMode,
8550
8624
  isLiveCheckoutProject,
8551
8625
  resolveLiveCheckoutAutoReset,
package/engine.js CHANGED
@@ -2711,7 +2711,7 @@ async function spawnAgent(dispatchItem, config) {
2711
2711
  // auto-stash is disabled, or because `applyLiveCheckoutAutoStash` itself
2712
2712
  // reported `outcome:'unchanged'` (the `git stash push` call failed) — and
2713
2713
  // the fleet/project auto-reset policy resolves true, NOW perform the
2714
- // destructive `fetch` + `reset --hard origin/<ref>` as a fallback, reusing
2714
+ // destructive `reset --hard HEAD` + `clean -fd` WIP cleanup as a fallback, reusing
2715
2715
  // prepareLiveCheckout's existing reset logic by re-invoking it with an
2716
2716
  // explicit `autoReset: true` (bypassing config resolution, which we've
2717
2717
  // already evaluated). This only fires when the first prepareLiveCheckout
@@ -2761,6 +2761,27 @@ async function spawnAgent(dispatchItem, config) {
2761
2761
  if (_liveResult && _liveResult.ok === false && _liveResult.reason === 'dirty') {
2762
2762
  const _dirtyFiles = Array.isArray(_liveResult.dirtyFiles) ? _liveResult.dirtyFiles : [];
2763
2763
  const _branchInfo = typeof _liveResult.branchInfo === 'string' ? _liveResult.branchInfo : '';
2764
+ const _autoResetAttempt = _liveResult.autoResetAttempt;
2765
+ const _resetAuditSlug = typeof _autoResetAttempt?.auditSlug === 'string'
2766
+ ? _autoResetAttempt.auditSlug
2767
+ : `live-checkout-autoreset-${id}`;
2768
+ let _cleanAttemptStatus = 'not run';
2769
+ let _statusCheckStatus = 'failed';
2770
+ if (_autoResetAttempt?.cleanAttempted) {
2771
+ _cleanAttemptStatus = _autoResetAttempt.cleanSucceeded ? 'succeeded' : 'failed or partially applied';
2772
+ }
2773
+ if (_autoResetAttempt?.recheckSucceeded) {
2774
+ _statusCheckStatus = `${_dirtyFiles.length} dirty path(s) remain`;
2775
+ }
2776
+ const _resetAttemptSummary = _autoResetAttempt
2777
+ ? [
2778
+ 'Automatic reset cleanup was attempted before this refusal:',
2779
+ `- \`git reset --hard HEAD\`: ${_autoResetAttempt.resetSucceeded ? 'succeeded' : 'failed'}`,
2780
+ `- \`git clean -fd\`: ${_cleanAttemptStatus}`,
2781
+ `- post-cleanup status check: ${_statusCheckStatus}`,
2782
+ `See the \`${_resetAuditSlug}\` inbox note for the full before/after audit.`,
2783
+ ].join('\n')
2784
+ : 'No automatic reset/clean recovery was attempted before this refusal.';
2764
2785
  // #329 + PL-live-checkout-reliability-hardening: two-strike dirty guard.
2765
2786
  // First dirty failure is retried once (a transient or engine-owned dirty
2766
2787
  // state — e.g. an agent's own leftover WIP, which dispatch-end restore now
@@ -2800,7 +2821,7 @@ async function spawnAgent(dispatchItem, config) {
2800
2821
  `**Dispatch:** ${id}`,
2801
2822
  ...(_branchInfo ? ['', `**Current checkout:** \`${_branchInfo}\``] : []),
2802
2823
  '',
2803
- `The engine refused to spawn the agent because \`${cwd}\` has uncommitted changes. Live-checkout mode runs in your project directly — the engine never \`git reset\` or \`git clean\` your tree.`,
2824
+ `The engine refused to spawn the agent because \`${cwd}\` has uncommitted changes. Live-checkout mode runs in your project directly.`,
2804
2825
  '',
2805
2826
  '## Dirty files (`git status --porcelain=v1 -b`)',
2806
2827
  '',
@@ -2808,6 +2829,8 @@ async function spawnAgent(dispatchItem, config) {
2808
2829
  ...(_dirtyFiles.length > 0 ? _liveCheckout.capDirtyFileLines(_dirtyFiles) : ['(none reported)']),
2809
2830
  '```',
2810
2831
  '',
2832
+ _resetAttemptSummary,
2833
+ '',
2811
2834
  _autoCleanupEnabled
2812
2835
  ? 'Auto-cleanup (`liveCheckoutAutoReset`/`liveCheckoutAutoStash`) is active — the engine re-cleans the tree on each attempt, so this dispatch will be retried automatically (subject to the normal retry cap).'
2813
2836
  : (_alreadyDirtyFailed
@@ -4394,7 +4417,7 @@ async function spawnAgent(dispatchItem, config) {
4394
4417
  // agent never gets a chance to push over the rebased tip.
4395
4418
  // Read-only and non-fix dispatches are out of scope — implement tasks cut
4396
4419
  // their own branch from main, and review/verify don't push.
4397
- if (type === WORK_TYPE.FIX && branchName && worktreePath && cwd === worktreePath) {
4420
+ if (shared.isFixLikeWorkType(type) && branchName && worktreePath && cwd === worktreePath) {
4398
4421
  _phaseT.staleHeadStart = Date.now();
4399
4422
  try {
4400
4423
  const guard = await assertStaleHeadOk({
@@ -8681,7 +8704,7 @@ function discoverFromWorkItems(config, project) {
8681
8704
  return b && b > 0 && getMonthlySpend(id) >= b && isAgentIdle(id);
8682
8705
  });
8683
8706
  if (!agentId) {
8684
- if (!budgetBlocked && !hardPinRequested && workType !== WORK_TYPE.FIX) {
8707
+ if (!budgetBlocked && !hardPinRequested && !shared.isFixLikeWorkType(workType)) {
8685
8708
  reservedAgentId = resolveAgentReservation(workType, config, { agentHints });
8686
8709
  agentId = reservedAgentId && !hasAgentHints ? routing.ANY_AGENT : reservedAgentId;
8687
8710
  }
@@ -8723,7 +8746,7 @@ function discoverFromWorkItems(config, project) {
8723
8746
  const linkedPr = item.skipPr ? null : resolveWorkItemPrRecord(item, project);
8724
8747
  const promptItem = linkedPr ? withWorkItemPrContext(item, linkedPr) : item;
8725
8748
  const prBranch = linkedPr?.branch || '';
8726
- const isPrTargeted = !!(linkedPr && (workType === WORK_TYPE.FIX || workType === WORK_TYPE.REVIEW || workType === WORK_TYPE.TEST));
8749
+ const isPrTargeted = !!(linkedPr && (shared.isFixLikeWorkType(workType) || workType === WORK_TYPE.REVIEW || workType === WORK_TYPE.TEST));
8727
8750
  // Issue #607 — `linkedPr` above resolves through the LOOSE
8728
8751
  // extractWorkItemPrRef (title/first-paragraph-of-description text scan),
8729
8752
  // which is fine for prompt-context enrichment (promptItem, above) that
@@ -8745,7 +8768,7 @@ function discoverFromWorkItems(config, project) {
8745
8768
  // create-time stamp path keeps using the loose form — gate uses
8746
8769
  // structured-only; stamp uses loose. See engine/shared.js
8747
8770
  // extractStructuredWorkItemPrRef for the structured-source list.
8748
- if (!linkedPr && getStructuredWorkItemPrRef(item) && (workType === WORK_TYPE.FIX || workType === WORK_TYPE.REVIEW || workType === WORK_TYPE.TEST)) {
8771
+ if (!linkedPr && getStructuredWorkItemPrRef(item) && (shared.isFixLikeWorkType(workType) || workType === WORK_TYPE.REVIEW || workType === WORK_TYPE.TEST)) {
8749
8772
  if (item._pendingReason !== 'pr_not_found') { item._pendingReason = 'pr_not_found'; needsWrite = true; }
8750
8773
  log('warn', `Work item ${item.id} references PR ${getStructuredWorkItemPrRef(item)} but no tracked PR record was found`);
8751
8774
  continue;
@@ -9048,7 +9071,7 @@ function buildWorkItemDispatchVars(item, vars, config, options = {}) {
9048
9071
  // SHERLOC (P-mqyp0008v022w3x4): inject prior explore context for fix dispatches.
9049
9072
  // When a completed explore WI has a reference to this fix WI, surface its
9050
9073
  // resultSummary so the fix agent starts with pre-localized fault context.
9051
- if (workType === WORK_TYPE.FIX) {
9074
+ if (shared.isFixLikeWorkType(workType)) {
9052
9075
  vars.prior_explore_context = resolvePriorExploreContext(item, config);
9053
9076
  }
9054
9077
 
@@ -9375,7 +9398,7 @@ function discoverCentralWorkItems(config) {
9375
9398
  const hardPinRequested = !!hardPinnedAgent;
9376
9399
  const agentId = hardPinnedAgent
9377
9400
  || resolveAgent(workType, config, { agentHints })
9378
- || (!hardPinRequested && workType !== WORK_TYPE.FIX ? resolveAgentReservation(workType, config, { agentHints }) : null);
9401
+ || (!hardPinRequested && !shared.isFixLikeWorkType(workType) ? resolveAgentReservation(workType, config, { agentHints }) : null);
9379
9402
  if (!agentId) continue;
9380
9403
 
9381
9404
  const agentName = config.agents[agentId]?.name || agentId;
@@ -9757,7 +9780,7 @@ async function discoverWork(config) {
9757
9780
  // type-only filter below would silently start gating those
9758
9781
  // previously-ungated sources too. Scope the gate back down to
9759
9782
  // `meta.source === 'pr'` to preserve the original behavior exactly.
9760
- const isGateEligible = (w) => w.meta?.source === 'pr' && (w.type === WORK_TYPE.FIX || w.type === WORK_TYPE.REVIEW);
9783
+ const isGateEligible = (w) => w.meta?.source === 'pr' && (shared.isFixLikeWorkType(w.type) || w.type === WORK_TYPE.REVIEW);
9761
9784
  const gated = items.filter(isGateEligible);
9762
9785
  if (gated.length > 0) {
9763
9786
  log('info', `Gating ${gated.length} fixes/reviews — implement items in progress and no free slots`);
@@ -9799,7 +9822,7 @@ async function discoverWork(config) {
9799
9822
  }));
9800
9823
  }
9801
9824
  for (const w of toDispatch) {
9802
- if (w.type === WORK_TYPE.FIX) persistedFixes++;
9825
+ if (shared.isFixLikeWorkType(w.type)) persistedFixes++;
9803
9826
  else if (w.type === WORK_TYPE.REVIEW) persistedReviews++;
9804
9827
  else persistedOther++;
9805
9828
  }
@@ -9818,7 +9841,7 @@ async function discoverWork(config) {
9818
9841
  if (config.engine?.prDiscoveryEnabled !== false) {
9819
9842
  const prWork = await discoverFromPrs(config, project);
9820
9843
  projectWork.push(...prWork.filter(w =>
9821
- w.type === WORK_TYPE.FIX || w.type === WORK_TYPE.REVIEW || w.type === WORK_TYPE.TEST));
9844
+ shared.isFixLikeWorkType(w.type) || w.type === WORK_TYPE.REVIEW || w.type === WORK_TYPE.TEST));
9822
9845
  }
9823
9846
 
9824
9847
  // Side-effect: specs → work items (picked up below)
@@ -10038,7 +10061,7 @@ function persistPendingDispatchAgent(item) {
10038
10061
  }
10039
10062
 
10040
10063
  function isSoftFixDispatch(item) {
10041
- return item?.type === WORK_TYPE.FIX && !routing.isAgentHardPinned(item.meta?.item);
10064
+ return shared.isFixLikeWorkType(item?.type) && !routing.isAgentHardPinned(item.meta?.item);
10042
10065
  }
10043
10066
 
10044
10067
  function resolveMaxConcurrent(config) {
@@ -10945,7 +10968,15 @@ async function tickInner() {
10945
10968
  skipDirtyCheck: !!_precheckProjCfg.skipLiveCheckoutDirtyCheck,
10946
10969
  });
10947
10970
  } catch (e) { log('warn', `live-checkout precheck (dirty) failed for ${item.id}: ${e.message}`); }
10948
- if (_dirtyResult && _dirtyResult.dirty) {
10971
+ const _autoStashEnabled = _liveCheckoutPrecheck.resolveLiveCheckoutAutoStash({
10972
+ project: _precheckProjCfg,
10973
+ engine: config.engine || {},
10974
+ });
10975
+ const _autoResetEnabled = shared.resolveLiveCheckoutAutoReset(
10976
+ _precheckProjCfg,
10977
+ config.engine || {},
10978
+ );
10979
+ if (_dirtyResult && _dirtyResult.dirty && !_autoStashEnabled && !_autoResetEnabled) {
10949
10980
  _persistPendingReason('live_checkout_dirty');
10950
10981
  try {
10951
10982
  const _alertBody = [
@@ -11131,11 +11162,12 @@ async function tickInner() {
11131
11162
  // post-dispatch active list so the annotation loop's reason is consistent
11132
11163
  // with whatever actually got spawned this tick.
11133
11164
  const postLiveProjectsInUse = new Set();
11165
+ const postProjects = shared.getProjects(config);
11134
11166
  for (const d of (postDispatch.active || [])) {
11135
11167
  if (READ_ONLY_ROOT_TASK_TYPES.has(d.type)) continue;
11136
11168
  const projName = d.project || d.meta?.project?.name || null;
11137
11169
  if (!projName) continue;
11138
- const projCfg = shared.findProjectByName(shared.getProjects(config), projName);
11170
+ const projCfg = shared.findProjectByName(postProjects, projName);
11139
11171
  if (shared.resolveCheckoutMode(projCfg, d.type) === 'live') {
11140
11172
  postLiveProjectsInUse.add(projName);
11141
11173
  }
@@ -11162,7 +11194,10 @@ async function tickInner() {
11162
11194
  && !READ_ONLY_ROOT_TASK_TYPES.has(item.type)
11163
11195
  && postLiveProjectsInUse.has(itemProjName)
11164
11196
  ) {
11165
- reason = 'live_checkout_busy';
11197
+ const projCfg = shared.findProjectByName(postProjects, itemProjName);
11198
+ if (shared.resolveCheckoutMode(projCfg, item.type) === 'live') {
11199
+ reason = 'live_checkout_busy';
11200
+ }
11166
11201
  }
11167
11202
  }
11168
11203
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2380",
3
+ "version": "0.1.2381",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"
@@ -92,7 +92,7 @@ When genuinely in doubt about the size, delegate — agents have isolated worktr
92
92
  ### HARD STOP — never edit files yourself in a live/hybrid project
93
93
  This overrides everything above, **including the direct-handling override**. For any project whose effective checkout mode is `live` (or `hybrid` for a code-authoring work-item type — `implement`/`fix`/`docs`/`decompose`), you MUST NOT use `Edit`/`Write`/`Bash` (or any tool) to mutate files inside that project's `localPath`, **regardless of task size** — not even a Small 1-line change, and not even when the human explicitly says to do it yourself. The Step-3 "do it yourself" allowance and the Step-1 direct-handling override do **not** apply to writes inside a live/hybrid checkout.
94
94
 
95
- Why: live-mode projects have no worktree isolation — the engine runs agents in-place inside `localPath`, caps dispatch to one mutating operation at a time, and refuses to dispatch on a dirty tree (`live-checkout-dirty`, non-retryable). A stray CC edit leaves the checkout dirty in a way the dispatch bookkeeping does not track, and that dirty state then **blocks every subsequent live-mode dispatch** on the project until a human manually cleans it up. It is NOT covered by the engine's dispatch-time auto-stash/auto-reset recovery those only run as part of an actual dispatch preflight, never for stray CC edits.
95
+ Why: live-mode projects have no worktree isolation — the engine runs agents in-place inside `localPath` and caps dispatch to one mutating operation at a time. A stray CC edit is outside dispatch ownership: with dirty recovery disabled it causes a `live-checkout-dirty` refusal that blocks live work, while the default auto-stash policy can silently park that edit before the next dispatch, so the requested change is no longer present for the agent to validate or commit. Auto-stash/reset is a dispatch recovery mechanism, not ownership bookkeeping for CC edits.
96
96
 
97
97
  Instead, when a change is needed in a live/hybrid project, **dispatch a normal work item** (`POST /api/work-items`, `type: "implement"` or `"fix"` as usual) and let the engine own the live-checkout dispatch path (dirty-tree refusal, single-mutating-dispatch cap, auto-stash/auto-reset). Do not try to shortcut it with your own edit.
98
98
 
@@ -333,23 +333,23 @@ The `X-CC-Turn-Id` header is the audit trail — the handler stamps it onto the
333
333
  Every configured project has an effective **checkout mode** — surfaced in your state snapshot next to the project as `[worktree]`, `[live]`, or `[hybrid (live-validation: <type>)]`. It controls where dispatched agents run:
334
334
 
335
335
  - **`worktree`** (default) — each dispatch gets its own isolated `git worktree`. Agents run fully in parallel; nothing touches the operator's working tree. Use for normal repos.
336
- - **`live`** — agents run **in-place** inside the project's `localPath` (no worktree). The engine caps this to **one mutating dispatch at a time** per project and **refuses on a dirty tree**. Use only when worktrees are unworkable (e.g. Android `repo`, submodules, deep Windows paths, emulators that bind the real checkout).
337
- - **`hybrid`** — `live` **plus** a `liveValidation: { type, autoDispatch }` block. Coding work items (`implement`/`fix`/`docs`/`decompose`) author in **isolated worktrees** (full parallelism), while **only** work items whose type matches `liveValidation.type` (e.g. `build-and-test`) run **in-place on the live checkout**. This is the best of both: parallel code authoring + a real on-disk build/validation that can't run in a worktree. `type` accepts a single string (e.g. `"build-and-test"`) or an array of strings (e.g. `["implement","fix"]`) to route multiple work-item types to the live checkout at once.
336
+ - **`live`** — agents run **in-place** inside the project's `localPath` (no worktree). The engine caps this to **one mutating dispatch at a time** per project. Dirty-tree behavior follows the configured auto-stash/reset policy (auto-stash defaults on); with recovery disabled, the item remains pending until the tree is clean. Use only when worktrees are unworkable.
337
+ - **`hybrid`** — `live` **plus** a `liveValidation: { type, autoDispatch }` block. Work items not listed in `type` use isolated worktrees; listed live-capable canonical work-item types run in-place. The normal validation type is `test`. `build-and-test` is a playbook name, not a work-item type. `type` accepts one string or an array. When `autoDispatch:true`, `test` must be included; the engine creates one PR-targeted `test` WI with the `build-and-test` playbook after each eligible coding WI completes.
338
338
 
339
- **CC never writes into a live/hybrid checkout itself.** Because `live` (and the code-authoring surface of `hybrid`) runs in-place with no worktree isolation, a one-off CC edit inside such a project's `localPath` leaves the tree dirty and blocks every future live-mode dispatch (the engine refuses on a dirty tree, `live-checkout-dirty`, non-retryable — and it does not track or auto-recover stray CC edits). So for any needed change in a `live`/`hybrid` project you **dispatch a work item** and let the engine own the live-checkout path; you never `Edit`/`Write`/`Bash`-mutate files there yourself, regardless of task size or a direct-handling override. Read-only inspection remains fine. This is the same rule stated up top under "HARD STOP — never edit files yourself in a live/hybrid project"; the two sections agree by design.
339
+ **CC never writes into a live/hybrid checkout itself.** A one-off CC edit leaves untracked operator dirt that the next dispatch may unexpectedly stash or may block on when recovery is disabled. For any needed change, **dispatch a work item** and let the engine own the live-checkout path; never `Edit`/`Write`/`Bash`-mutate files there yourself. Read-only inspection remains fine.
340
340
 
341
341
  **When to recommend hybrid:** the project's build/test/validation genuinely cannot run in an isolated worktree (it needs the real checkout — submodules, a `repo`-managed tree, an emulator/dev-server bound to `localPath`, deep-path tooling), **but** you still want coding agents to work in parallel rather than serialize through the single live checkout. If the *whole* workflow must run on the real tree, use plain `live`. If nothing needs the real tree, stay on `worktree`.
342
342
 
343
343
  **Configuring it** (via the projects array on `POST /api/settings` — never hand-edit `config.json`):
344
344
  ```bash
345
- # Switch a project to hybrid mode (live checkout + deferred build-and-test validation)
345
+ # Switch a project to hybrid mode (live checkout + deferred test validation)
346
346
  curl -s -X POST http://localhost:{{dashboard_port}}/api/settings \
347
347
  -H 'Content-Type: application/json' -H 'X-CC-Turn-Id: {{cc_turn_id}}' \
348
- -d '{"projects":[{"name":"<project>","checkoutMode":"live","liveValidation":{"type":"build-and-test","autoDispatch":true}}]}'
348
+ -d '{"projects":[{"name":"<project>","checkoutMode":"live","liveValidation":{"type":"test","autoDispatch":true}}]}'
349
349
  # Clear hybrid (back to plain live): pass "liveValidation": null
350
350
  # Clear live entirely (back to worktree): pass "checkoutMode": "worktree" (also clear liveValidation)
351
351
  ```
352
- The server validates: `liveValidation` requires `checkoutMode:"live"` (400 otherwise) and a non-empty `type`. With `autoDispatch:true`, the engine auto-creates a `<type>` validation WI on the live checkout after each coding WI completes with a PR. **Always confirm the mode switch with the user before applying it** — it changes how every future dispatch on that project runs.
352
+ The server validates: `liveValidation` requires `checkoutMode:"live"`, canonical live-capable work-item type(s), and a boolean `autoDispatch`; automatic validation additionally requires `test` in `type`. **Always confirm the mode switch with the user before applying it** — it changes how future dispatches run.
353
353
 
354
354
  ## GitHub auth
355
355