@yemi33/minions 0.1.2287 → 0.1.2289

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/dashboard.js CHANGED
@@ -1842,11 +1842,16 @@ function _archivePrdPostProcess({ planFile, archivePath, planPath, plansDir, _mu
1842
1842
  let plan = {};
1843
1843
  let archivedSource = null;
1844
1844
 
1845
- // (a) Flip status + archivedAt.
1845
+ // (a) Flip the archived FLAG + status + archivedAt. Phase 10 step 4.2b:
1846
+ // archived-ness is the flag now (not the directory), so set `archived:true`
1847
+ // explicitly. The mutate dual-writes the change into SQL via the chokepoint.
1848
+ // When archivePath === planPath (in-place archive) this flags the PRD where it
1849
+ // sits; when they differ (.md-cascade / legacy move) it flags the moved copy.
1846
1850
  try {
1847
1851
  plan = mutate(archivePath, (data) => {
1848
1852
  if (!data || Array.isArray(data) || typeof data !== 'object') data = {};
1849
1853
  data.status = 'archived';
1854
+ data.archived = true;
1850
1855
  data.archivedAt = new Date().toISOString();
1851
1856
  return data;
1852
1857
  }, { defaultValue: {} }) || {};
@@ -1866,30 +1871,38 @@ function _archivePrdPostProcess({ planFile, archivePath, planPath, plansDir, _mu
1866
1871
  } catch { /* readObj fallback is best-effort */ }
1867
1872
  }
1868
1873
 
1869
- // (b) Neutralize the .backup sidecar so safeJson auto-restore does not
1870
- // resurrect the pre-completion snapshot on engine restart (regression of #f28162b0).
1871
- try {
1872
- const backupCleanup = shared.neutralizeJsonBackupSidecar(planPath);
1873
- if (!backupCleanup.ok) {
1874
- const warning = `Archive backup cleanup failed for ${planFile}: unlink failed (${backupCleanup.unlinkError}); fallback neutralize failed (${backupCleanup.writeError})`;
1874
+ // (b) Neutralize the .backup sidecar at the OLD live path ONLY when the file
1875
+ // actually moved (archivePath !== planPath) a stale prd/<f>.json.backup left
1876
+ // behind by a move is resurrection fuel. For an in-place archive the file (and
1877
+ // its current .backup) stay put and are valid, so there is nothing to neutralize.
1878
+ if (planPath !== archivePath) {
1879
+ try {
1880
+ const backupCleanup = shared.neutralizeJsonBackupSidecar(planPath);
1881
+ if (!backupCleanup.ok) {
1882
+ const warning = `Archive backup cleanup failed for ${planFile}: unlink failed (${backupCleanup.unlinkError}); fallback neutralize failed (${backupCleanup.writeError})`;
1883
+ archiveWarnings.push(warning);
1884
+ console.warn(warning);
1885
+ }
1886
+ } catch (e) {
1887
+ const warning = `Archive backup cleanup failed for ${planFile}: ${e.message}`;
1875
1888
  archiveWarnings.push(warning);
1876
1889
  console.warn(warning);
1877
1890
  }
1878
- } catch (e) {
1879
- const warning = `Archive backup cleanup failed for ${planFile}: ${e.message}`;
1880
- archiveWarnings.push(warning);
1881
- console.warn(warning);
1882
1891
  }
1883
1892
 
1884
1893
  // (c) Move the source plan markdown into plans/archive/.
1885
1894
  if (plan.source_plan) {
1886
1895
  try {
1887
- const mdPath = path.join(plansDir, plan.source_plan);
1896
+ // Canonical key: a `plans/`-prefixed source_plan would otherwise join to
1897
+ // plansDir/plans/x.md and silently miss, leaving the plan un-archived
1898
+ // while the PRD archived — the plan↔PRD desync. (W-? archive sync.)
1899
+ const planKey = shared.sourcePlanKey(plan.source_plan);
1900
+ const mdPath = path.join(plansDir, planKey);
1888
1901
  if (fs.existsSync(mdPath)) {
1889
1902
  // DATA-LOSS GUARD: moveFileNoClobber dedupes + retries so archiving a
1890
1903
  // source plan can't overwrite a previously-archived plan of the same
1891
1904
  // basename (and survives transient Windows file locks).
1892
- const mdDest = shared.moveFileNoClobber(mdPath, path.join(plansDir, 'archive'), plan.source_plan);
1905
+ const mdDest = shared.moveFileNoClobber(mdPath, path.join(plansDir, 'archive'), planKey);
1893
1906
  archivedSource = path.basename(mdDest);
1894
1907
  }
1895
1908
  } catch (e) {
@@ -8393,13 +8406,20 @@ const server = http.createServer(async (req, res) => {
8393
8406
  const planPath = resolvePlanPath(body.file);
8394
8407
  if (!fs.existsSync(planPath)) return jsonReply(res, 404, { error: 'plan not found' });
8395
8408
 
8396
- const archiveDir = isPrd ? path.join(PRD_DIR, 'archive') : path.join(PLANS_DIR, 'archive');
8397
- // DATA-LOSS GUARD: moveFileNoClobber dedupes the destination so a same-
8398
- // basename collision (e.g. re-archiving a project's canonical
8399
- // <project>-<date> name, or the W-mq8qdai6 live↔archive collision class)
8400
- // can't silently destroy a previously-archived plan/PRD + its completed
8401
- // work-item history. Bumps to -2/-3 instead of overwriting.
8402
- const archivePath = shared.moveFileNoClobber(planPath, archiveDir, body.file);
8409
+ // Phase 10 step 4.2b: a PRD is archived IN PLACE (flag flip via
8410
+ // _archivePrdPostProcess the json stays in prd/, no move, so the
8411
+ // live↔archive basename-collision class / footgun #7 is gone). Only the
8412
+ // .md plan still physically moves to plans/archive/.
8413
+ let archivePath;
8414
+ if (isPrd) {
8415
+ archivePath = planPath; // in-place — do not move the PRD json
8416
+ } else {
8417
+ const archiveDir = path.join(PLANS_DIR, 'archive');
8418
+ // DATA-LOSS GUARD: moveFileNoClobber dedupes the destination so a same-
8419
+ // basename collision can't silently destroy a previously-archived plan +
8420
+ // its completed work-item history. Bumps to -2/-3 instead of overwriting.
8421
+ archivePath = shared.moveFileNoClobber(planPath, archiveDir, body.file);
8422
+ }
8403
8423
 
8404
8424
  let archivedSource = null;
8405
8425
  let plan = {};
@@ -8451,33 +8471,19 @@ const server = http.createServer(async (req, res) => {
8451
8471
  console.warn(warning);
8452
8472
  continue;
8453
8473
  }
8454
- if (!prd || prd.source_plan !== body.file) continue;
8455
-
8456
- const prdArchiveDir = path.join(PRD_DIR, 'archive');
8457
- try {
8458
- if (!fs.existsSync(prdArchiveDir)) fs.mkdirSync(prdArchiveDir, { recursive: true });
8459
- } catch (e) {
8460
- const warning = `Archive could not create PRD archive dir for ${prdFile}: ${e.message}`;
8461
- archiveWarnings.push(warning);
8462
- console.warn(warning);
8463
- continue;
8464
- }
8465
- let prdArchivePath;
8466
- try {
8467
- // DATA-LOSS GUARD: dedupe + retry so the cascade can't clobber a
8468
- // previously-archived PRD of the same basename.
8469
- prdArchivePath = shared.moveFileNoClobber(prdLivePath, prdArchiveDir, prdFile);
8470
- } catch (e) {
8471
- const warning = `Archive could not move PRD ${prdFile}: ${e.message}`;
8472
- archiveWarnings.push(warning);
8473
- console.warn(warning);
8474
- continue;
8475
- }
8476
- // Delegate status-flip + sidecar cleanup + (no-op) source-plan move
8477
- // to the shared helper so both branches stay in lockstep.
8474
+ // Canonical match (prefix/dir-tolerant): a raw `prd.source_plan !==
8475
+ // body.file` skipped PRDs whose source_plan carried the legacy
8476
+ // `plans/` prefix, archiving the plan but NOT its PRD — the plan↔PRD
8477
+ // status desync behind the "old PRDs came back" resurrection.
8478
+ if (!prd || !shared.prdMatchesSourcePlan(prd.source_plan, body.file)) continue;
8479
+
8480
+ // Phase 10 step 4.2b: flag the PRD archived IN PLACE (no move). The
8481
+ // outer handler already moved the .md to plans/archive/, so the
8482
+ // helper's (c) source-plan move is a no-op; (a) flips the flag (dual-
8483
+ // writing SQL) and (b) skips backup-neutralize since the file stayed.
8478
8484
  const cascadeResult = _archivePrdPostProcess({
8479
8485
  planFile: prdFile,
8480
- archivePath: prdArchivePath,
8486
+ archivePath: prdLivePath,
8481
8487
  planPath: prdLivePath,
8482
8488
  plansDir: PLANS_DIR,
8483
8489
  });
@@ -8530,11 +8536,36 @@ const server = http.createServer(async (req, res) => {
8530
8536
  const isJson = body.file.endsWith('.json');
8531
8537
  const targetDir = isJson ? PRD_DIR : PLANS_DIR;
8532
8538
  const archivePath = path.join(targetDir, 'archive', body.file);
8533
- if (!fs.existsSync(archivePath)) return jsonReply(res, 404, { error: 'File not found in archive' });
8534
- // DATA-LOSS GUARD: restoring from archive must not clobber a LIVE plan/PRD
8535
- // of the same basename (renameSync overwrites). moveFileNoClobber bumps
8536
- // the restored name instead so the existing live file survives.
8537
- const liveDest = shared.moveFileNoClobber(archivePath, targetDir, body.file);
8539
+ const livePath = path.join(targetDir, body.file);
8540
+ // Phase 10 step 4.2b: a PRD can be archived two ways now — legacy
8541
+ // (physically in <dir>/archive/) or in-place (in <dir>/ with the archived
8542
+ // flag set). Restore from whichever it is.
8543
+ let liveDest;
8544
+ if (fs.existsSync(archivePath)) {
8545
+ // DATA-LOSS GUARD: restoring must not clobber a LIVE plan/PRD of the same
8546
+ // basename (renameSync overwrites). moveFileNoClobber bumps the restored
8547
+ // name instead so the existing live file survives.
8548
+ liveDest = shared.moveFileNoClobber(archivePath, targetDir, body.file);
8549
+ } else if (isJson && fs.existsSync(livePath) && shared.isPrdArchived(safeJsonNoRestore(livePath))) {
8550
+ liveDest = livePath; // in-place flag-archived — already in prd/, just clear the flag below
8551
+ } else {
8552
+ return jsonReply(res, 404, { error: 'File not found in archive' });
8553
+ }
8554
+ // Clear the archived flag/status so the unarchived PRD renders LIVE again.
8555
+ // Archived-ness is the FLAG now (4.2a), so moving the file back is no longer
8556
+ // enough — a legacy PRD carries status='archived' and would still render
8557
+ // archived without this. Dual-writes the cleared state into SQL.
8558
+ if (isJson) {
8559
+ try {
8560
+ mutateJsonFileLocked(liveDest, (d) => {
8561
+ if (!d || typeof d !== 'object' || Array.isArray(d)) return d;
8562
+ if (d.status === 'archived') d.status = 'active';
8563
+ delete d.archived;
8564
+ delete d.archivedAt;
8565
+ return d;
8566
+ }, { skipWriteIfUnchanged: true });
8567
+ } catch (e) { console.warn(`Unarchive flag-clear failed for ${body.file}: ${e.message}`); }
8568
+ }
8538
8569
 
8539
8570
  // Also unarchive linked source plan
8540
8571
  let unarchivedSource = null;
@@ -3,10 +3,26 @@
3
3
  "id": "agent-config-skills-field",
4
4
  "description": "Legacy per-agent descriptive-metadata array `agents.<id>.skills` in config.json, renamed to `agents.<id>.expertise` to remove the name collision with executable runtime/harness skills (SKILL.md). The field is metadata only (capability tags like `architecture`, `bug-fixes`); nothing in the dispatch path reads it for behavior. A read-compat shim honors the old key so operator configs still carrying `skills` (and no `expertise`) keep working.",
5
5
  "code": [
6
- { "file": "engine/playbook.js", "lines": "950", "note": "buildSystemPrompt reads `agent.expertise ?? agent.skills ?? []` for the `Expertise:` identity line" },
7
- { "file": "engine/lifecycle.js", "lines": "4620-4621", "note": "pickReReviewAgentHints reads `agent.expertise` with an `agent.skills` array fallback" },
8
- { "file": "engine/queries.js", "lines": "731", "note": "getAgents normalizes `expertise: a.expertise ?? a.skills ?? []` so the dashboard/settings UI always receives `expertise`" },
9
- { "file": "dashboard.js", "lines": "10708-10716", "note": "settings POST accepts a legacy `updates.skills` key, persists as `config.agents[id].expertise`, and deletes the old `skills` key" }
6
+ {
7
+ "file": "engine/playbook.js",
8
+ "lines": "950",
9
+ "note": "buildSystemPrompt reads `agent.expertise ?? agent.skills ?? []` for the `Expertise:` identity line"
10
+ },
11
+ {
12
+ "file": "engine/lifecycle.js",
13
+ "lines": "4620-4621",
14
+ "note": "pickReReviewAgentHints reads `agent.expertise` with an `agent.skills` array fallback"
15
+ },
16
+ {
17
+ "file": "engine/queries.js",
18
+ "lines": "731",
19
+ "note": "getAgents normalizes `expertise: a.expertise ?? a.skills ?? []` so the dashboard/settings UI always receives `expertise`"
20
+ },
21
+ {
22
+ "file": "dashboard.js",
23
+ "lines": "10708-10716",
24
+ "note": "settings POST accepts a legacy `updates.skills` key, persists as `config.agents[id].expertise`, and deletes the old `skills` key"
25
+ }
10
26
  ],
11
27
  "removalGate": "Telemetry / a config sweep across all known engines must show no persisted `config.agents.<id>.skills` key (only `expertise`) for >=30 consecutive days, confirming every operator config has been re-saved through the dashboard (which drops the legacy key) or hand-migrated.",
12
28
  "targetRemovalDate": "2026-09-17",
@@ -31,7 +47,10 @@
31
47
  {
32
48
  "id": "legacy-done-aliases",
33
49
  "location": "engine/cleanup.js:1165-1166",
34
- "constants": ["LEGACY_DONE_ALIASES", "LEGACY_NEEDS_REVIEW_STATUS"],
50
+ "constants": [
51
+ "LEGACY_DONE_ALIASES",
52
+ "LEGACY_NEEDS_REVIEW_STATUS"
53
+ ],
35
54
  "reason": "Read-side tolerance: cleanup sweep auto-migrates four obsolete work-item / PRD status strings ('in-pr', 'implemented', 'complete', 'needs-human-review') to the canonical 'done' / 'failed' values. The aliases are no longer written anywhere in the engine; the constants exist only to repair stale on-disk values from old engine versions.",
36
55
  "targetRemovalDate": null,
37
56
  "notes": "Keep indefinitely until telemetry / a sweep log shows zero migrations performed for 30 consecutive days across all known projects (work-items.json + prd/*.json). At that point the constants and both _migrateLegacyItem branches in engine/cleanup.js (definitions at :1165-1166; usage at :1168-1183 for work items and :1269-1272 for PRD missing_features) can be deleted. Total cost on disk today: 4 strings."
@@ -40,9 +59,21 @@
40
59
  "id": "config-claude-binary-override",
41
60
  "description": "Legacy `config.claude.binary` runtime-resolution override. Older `minions init` versions persisted a `config.claude.binary` field that pointed the Claude runtime at a specific binary path. The runtime adapter still honors this override on every Claude spawn; the engine emits a `deprecated-config-claude` warning at config-load time but does NOT delete the override, so the override branch in claude.js is load-bearing for any install that still carries a non-default value.",
42
61
  "code": [
43
- { "file": "engine/runtimes/claude.js", "lines": "82-86", "note": "resolveBinary() respects config.claude.binary on every Claude spawn (probes npm package dir or direct binary path)" },
44
- { "file": "engine/shared.js", "lines": "2482-2496", "note": "warnings.push({ id: 'deprecated-config-claude' }) — surface-only; never deletes the override" },
45
- { "file": "engine/shared.js", "lines": "3120-3124", "note": "DEFAULT_CLAUDE.binary baseline that the warning + prune logic compares against" }
62
+ {
63
+ "file": "engine/runtimes/claude.js",
64
+ "lines": "82-86",
65
+ "note": "resolveBinary() respects config.claude.binary on every Claude spawn (probes npm package dir or direct binary path)"
66
+ },
67
+ {
68
+ "file": "engine/shared.js",
69
+ "lines": "2482-2496",
70
+ "note": "warnings.push({ id: 'deprecated-config-claude' }) — surface-only; never deletes the override"
71
+ },
72
+ {
73
+ "file": "engine/shared.js",
74
+ "lines": "3120-3124",
75
+ "note": "DEFAULT_CLAUDE.binary baseline that the warning + prune logic compares against"
76
+ }
46
77
  ],
47
78
  "removalGate": "Telemetry: the `deprecated-config-claude` warning emitted at engine/shared.js:2492-2495 must report zero hits across all known engines for >=30 consecutive days, AND a sweep of every persisted config.json must show no `config.claude.binary` value that diverges from DEFAULT_CLAUDE.binary. Only then is the override branch in resolveBinary() (engine/runtimes/claude.js:82-86) removable, along with the `_deprecatedConfigClaudeFields` membership for `binary` and the warning emitter at engine/shared.js:2482-2496.",
48
79
  "targetRemovalDate": null,
@@ -52,13 +83,41 @@
52
83
  "id": "legacy-cc-model-migration",
53
84
  "description": "applyLegacyCcModelMigration: in-memory shim that promotes legacy `engine.ccModel` to `engine.defaultModel` when defaultModel is unset, so single-model installs keep working after the runtime fleet refactor (P-3b8e5f1d). No on-disk rewrite — the persisted config.json continues to carry the legacy `ccModel` field. Called unconditionally on every engine boot from cli.start().",
54
85
  "code": [
55
- { "file": "engine/shared.js", "lines": "2407", "note": "applyLegacyCcModelMigration definition (function signature + once-per-process flag via _resetLegacyCcModelMigrationFlag)" },
56
- { "file": "engine/cli.js", "lines": "477", "note": "Boot call site inside start(); wrapped in try/catch so a migration failure cannot block startup" },
57
- { "file": "CLAUDE.md", "lines": "316", "note": "Architectural documentation calling out the in-memory promotion contract" },
58
- { "file": "docs/slim-ux/concepts.md", "lines": "671", "note": "Surface-level concepts doc cross-reference" },
59
- { "file": "test/unit.test.js", "lines": "19801", "note": "Source-inspection test pinning the CLAUDE.md description against the function name" },
60
- { "file": "test/unit/runtime-fleet-helpers.test.js", "lines": "209-254", "note": "Behavioural unit tests (promotion, no-op when defaultModel set, no-op when ccModel unset, empty-string handling, once-only logging, null-safety)" },
61
- { "file": "test/unit/runtime-fleet-helpers.test.js", "lines": "500-505", "note": "Source-inspection test pinning the cli.js boot call site" }
86
+ {
87
+ "file": "engine/shared.js",
88
+ "lines": "2407",
89
+ "note": "applyLegacyCcModelMigration definition (function signature + once-per-process flag via _resetLegacyCcModelMigrationFlag)"
90
+ },
91
+ {
92
+ "file": "engine/cli.js",
93
+ "lines": "477",
94
+ "note": "Boot call site inside start(); wrapped in try/catch so a migration failure cannot block startup"
95
+ },
96
+ {
97
+ "file": "CLAUDE.md",
98
+ "lines": "316",
99
+ "note": "Architectural documentation calling out the in-memory promotion contract"
100
+ },
101
+ {
102
+ "file": "docs/slim-ux/concepts.md",
103
+ "lines": "671",
104
+ "note": "Surface-level concepts doc cross-reference"
105
+ },
106
+ {
107
+ "file": "test/unit.test.js",
108
+ "lines": "19801",
109
+ "note": "Source-inspection test pinning the CLAUDE.md description against the function name"
110
+ },
111
+ {
112
+ "file": "test/unit/runtime-fleet-helpers.test.js",
113
+ "lines": "209-254",
114
+ "note": "Behavioural unit tests (promotion, no-op when defaultModel set, no-op when ccModel unset, empty-string handling, once-only logging, null-safety)"
115
+ },
116
+ {
117
+ "file": "test/unit/runtime-fleet-helpers.test.js",
118
+ "lines": "500-505",
119
+ "note": "Source-inspection test pinning the cli.js boot call site"
120
+ }
62
121
  ],
63
122
  "removalGate": "Telemetry: the once-per-boot deprecation log line emitted by applyLegacyCcModelMigration (via the injected logger at engine/shared.js:2407) must show zero promotion events across all known engines for >=30 consecutive days, AND a sweep of every persisted config.json must confirm no `engine.ccModel` field remains. Once both conditions hold, removal deletes the function + _resetLegacyCcModelMigrationFlag export at engine/shared.js:4977, the boot call at engine/cli.js:477, the CLAUDE.md:316 paragraph and docs/slim-ux/concepts.md:671 reference, and the tests at runtime-fleet-helpers.test.js:209-254 + :500-505 + unit.test.js:19801.",
64
123
  "targetRemovalDate": null,
@@ -68,14 +127,39 @@
68
127
  "id": "sql-state-json-mirrors",
69
128
  "description": "Phase X.5 follow-up to the SQL state migration (commits 62bd6a2c..1111cf54, phases 0–7). Every engine state file that previously used mutateJsonFileLocked now routes through a SQL store, but each store still writes a JSON dual-write mirror after every mutation because a handful of direct-readers (a few unit tests + a couple of inline safeJson calls) have not been migrated to the SQL read path. Once those readers are confirmed routed through the SQL store (or rewritten to use the store's read helper), the mirror writers can be deleted and the JSON files retired.",
70
129
  "code": [
71
- { "file": "engine/dispatch-store.js", "note": "_mirrorJsonFromSql + _readDispatchJsonFallback — used when SQL is empty AND JSON has content (test seeding + first-time hydrate)" },
72
- { "file": "engine/work-items-store.js", "note": "_mirrorJsonFromSql + _readJsonArrayFallback (per scope) — same fallback contract as dispatch-store" },
73
- { "file": "engine/pull-requests-store.js", "note": "_mirrorJsonFromSql + _readJsonArrayFallback (per scope)" },
74
- { "file": "engine/logs-store.js", "note": "engine/log.json mirror written by shared._flushLogBuffer's byJsonPath loop — Phase 4.5 will retire" },
75
- { "file": "engine/metrics-store.js", "note": "_mirrorJsonFromSql + _readJsonObjectFallback" },
76
- { "file": "engine/watches-store.js", "note": "_mirrorJsonFromSql + _readJsonArrayFallback" },
77
- { "file": "engine/small-state-store.js", "note": "_mirrorScheduleRunsJson, _mirrorPipelineRunsJson, _mirrorManagedProcessesJson, _mirrorWorktreePoolJson + each store's _readJson fallback path" },
78
- { "file": "CLAUDE.md", "lines": "47-66, 240-265", "note": "State Files + Concurrency sections still describe JSON files as the source of truth; they describe a layered SQLite-then-mirror reality in places but the headline contract still reads as JSON-primary. Rewrite these sections to make SQL-as-source-of-truth the headline and the JSON mirrors a transitional compatibility detail." }
130
+ {
131
+ "file": "engine/dispatch-store.js",
132
+ "note": "_mirrorJsonFromSql + _readDispatchJsonFallback — used when SQL is empty AND JSON has content (test seeding + first-time hydrate)"
133
+ },
134
+ {
135
+ "file": "engine/work-items-store.js",
136
+ "note": "_mirrorJsonFromSql + _readJsonArrayFallback (per scope) same fallback contract as dispatch-store"
137
+ },
138
+ {
139
+ "file": "engine/pull-requests-store.js",
140
+ "note": "_mirrorJsonFromSql + _readJsonArrayFallback (per scope)"
141
+ },
142
+ {
143
+ "file": "engine/logs-store.js",
144
+ "note": "engine/log.json mirror written by shared._flushLogBuffer's byJsonPath loop — Phase 4.5 will retire"
145
+ },
146
+ {
147
+ "file": "engine/metrics-store.js",
148
+ "note": "_mirrorJsonFromSql + _readJsonObjectFallback"
149
+ },
150
+ {
151
+ "file": "engine/watches-store.js",
152
+ "note": "_mirrorJsonFromSql + _readJsonArrayFallback"
153
+ },
154
+ {
155
+ "file": "engine/small-state-store.js",
156
+ "note": "_mirrorScheduleRunsJson, _mirrorPipelineRunsJson, _mirrorManagedProcessesJson, _mirrorWorktreePoolJson + each store's _readJson fallback path"
157
+ },
158
+ {
159
+ "file": "CLAUDE.md",
160
+ "lines": "47-66, 240-265",
161
+ "note": "State Files + Concurrency sections still describe JSON files as the source of truth; they describe a layered SQLite-then-mirror reality in places but the headline contract still reads as JSON-primary. Rewrite these sections to make SQL-as-source-of-truth the headline and the JSON mirrors a transitional compatibility detail."
162
+ }
79
163
  ],
80
164
  "removalGate": "All direct-readers of the mirror JSON files must be confirmed routed through their respective SQL store's read helper. Specifically: (a) grep the codebase for `safeJson`, `safeJsonArr`, `safeJsonObj`, `readFileSync(...work-items.json|pull-requests.json|metrics.json|watches.json|schedule-runs.json|pipeline-runs.json|managed-processes.json|worktree-pool.json|log.json|dispatch.json...)` and confirm every hit is either (i) a test fixture that can move to the SQL helper, or (ii) intentionally documented as bypassing SQL. (b) Run the full test suite with each store's _mirrorJsonFromSql temporarily neutered (returning early before safeWrite) and confirm 0 failures — that proves no production code path depends on the mirror. Once both conditions hold, removal deletes each store's _mirrorJsonFromSql call site in shared.js (mutateWorkItems/mutatePullRequests/etc.), the corresponding _readJsonArrayFallback paths, and the JSON file gitignore entries. CLAUDE.md update can ship independently as soon as someone has bandwidth.",
81
165
  "targetRemovalDate": null,
@@ -85,15 +169,51 @@
85
169
  "id": "prune-default-claude-config",
86
170
  "description": "pruneDefaultClaudeConfig: active sanitizer that strips generated `config.claude.{binary,outputFormat,allowedTools,permissionMode}` defaults from persisted config.json so the `deprecated-config-claude` warning stops tripping on stale defaults left by older `minions init` versions. Sub-cluster of `config-claude-binary-override` — the prune deliberately preserves non-default user overrides (binary/allowedTools), which is what keeps the override branch in engine/runtimes/claude.js load-bearing.",
87
171
  "code": [
88
- { "file": "engine/shared.js", "lines": "3126", "note": "pruneDefaultClaudeConfig definition: preserves non-default binary/allowedTools, always strips permissionMode + outputFormat" },
89
- { "file": "engine/shared.js", "lines": "5673", "note": "Module export entry" },
90
- { "file": "dashboard.js", "lines": "202", "note": "Called when loading config for the dashboard UI" },
91
- { "file": "dashboard.js", "lines": "9116", "note": "Called during first config save handler" },
92
- { "file": "dashboard.js", "lines": "9331", "note": "Called during second config save path" },
93
- { "file": "dashboard.js", "lines": "9450", "note": "Called during third config save path" },
94
- { "file": "minions.js", "lines": "385", "note": "Called during CLI init/update flow" },
95
- { "file": "test/unit.test.js", "lines": "2260-2303", "note": "Behavioural unit tests (default strip, override preservation, outputFormat unconditional strip) + dashboard call-site source pin" },
96
- { "file": "test/unit/runtime-fleet-helpers.test.js", "lines": "546", "note": "Source-inspection test pinning the dashboard handler call site" }
172
+ {
173
+ "file": "engine/shared.js",
174
+ "lines": "3126",
175
+ "note": "pruneDefaultClaudeConfig definition: preserves non-default binary/allowedTools, always strips permissionMode + outputFormat"
176
+ },
177
+ {
178
+ "file": "engine/shared.js",
179
+ "lines": "5673",
180
+ "note": "Module export entry"
181
+ },
182
+ {
183
+ "file": "dashboard.js",
184
+ "lines": "202",
185
+ "note": "Called when loading config for the dashboard UI"
186
+ },
187
+ {
188
+ "file": "dashboard.js",
189
+ "lines": "9116",
190
+ "note": "Called during first config save handler"
191
+ },
192
+ {
193
+ "file": "dashboard.js",
194
+ "lines": "9331",
195
+ "note": "Called during second config save path"
196
+ },
197
+ {
198
+ "file": "dashboard.js",
199
+ "lines": "9450",
200
+ "note": "Called during third config save path"
201
+ },
202
+ {
203
+ "file": "minions.js",
204
+ "lines": "385",
205
+ "note": "Called during CLI init/update flow"
206
+ },
207
+ {
208
+ "file": "test/unit.test.js",
209
+ "lines": "2260-2303",
210
+ "note": "Behavioural unit tests (default strip, override preservation, outputFormat unconditional strip) + dashboard call-site source pin"
211
+ },
212
+ {
213
+ "file": "test/unit/runtime-fleet-helpers.test.js",
214
+ "lines": "546",
215
+ "note": "Source-inspection test pinning the dashboard handler call site"
216
+ }
97
217
  ],
98
218
  "removalGate": "Telemetry: pruneDefaultClaudeConfig must return false (no mutation) for every call across all known engines for >=30 consecutive days (add an `_engine.pruneDefaultClaudeConfigStrips` counter if needed to observe this), AND the parent `config-claude-binary-override` entry must have already cleared its own gate. The dependency is strict: removing the prune while users still rely on the override branch would surface the `deprecated-config-claude` warning on every stale generated default. Once both conditions hold, removal is the function definition (engine/shared.js:3126), the export at :5673, all 5 call sites (dashboard.js:202, :9116, :9331, :9450; minions.js:385), and the tests at unit.test.js:2260-2303 + runtime-fleet-helpers.test.js:546.",
99
219
  "targetRemovalDate": null,
@@ -106,7 +226,10 @@
106
226
  "status": "removed",
107
227
  "removedDate": "2026-06-25",
108
228
  "code": [
109
- { "file": "engine/ado.js", "note": "isAdoThrottled() arg-less branch and the global-OR fold over the per-org Map. Single call site to migrate: shared.getAdoOrgBase(project) is already in scope at every consumer." }
229
+ {
230
+ "file": "engine/ado.js",
231
+ "note": "isAdoThrottled() arg-less branch and the global-OR fold over the per-org Map. Single call site to migrate: shared.getAdoOrgBase(project) is already in scope at every consumer."
232
+ }
110
233
  ],
111
234
  "removalGate": "Two conditions must hold simultaneously: (a) grep `engine/ado.js` for `isAdoThrottled\\s*\\(\\s*\\)` and confirm zero arg-less call sites remain across the engine — every caller passes a concrete `orgBase` resolved via `shared.getAdoOrgBase(project)`; (b) `GET /api/diagnostics/ado-throttle` on a live engine has been observed for >=2 consecutive weeks reporting per-org keys (proves the per-org Map is populated under load and the global-OR isn't masking a regression). Once both hold, removal deletes the arg-less branch in isAdoThrottled and the global-OR fold; callers that still pass no argument become an immediate, surfaced bug rather than a silent over-throttle.",
112
235
  "targetRemovalDate": "2026-08-03",
@@ -114,16 +237,18 @@
114
237
  },
115
238
  {
116
239
  "id": "pr-link-autoObserve-body-param",
117
- "description": "Legacy `autoObserve` body parameter on `POST /api/pull-requests/link`. Replaced by canonical `contextOnly` body param (inverse boolean: `autoObserve: false` ⇔ `contextOnly: true`). This is a READ-BRIDGE on the input side only — the handler reads `body.contextOnly` first and falls back to `!body.autoObserve` for callers not yet migrated. No underscore alias is written onto the record (the record-field aliases already shipped out — see the three paired entries); the only thing kept alive is the input fallback.",
240
+ "status": "removed",
241
+ "removedDate": "2026-06-25",
242
+ "description": "Legacy `autoObserve` body parameter on `POST /api/pull-requests/link`. Replaced by canonical `contextOnly` body param (inverse boolean: `autoObserve: false` ⇔ `contextOnly: true`). This was a READ-BRIDGE on the input side only — the handler read `body.contextOnly` first and fell back to `!body.autoObserve` for callers not yet migrated. Removed by M001 (work/M001).",
118
243
  "code": [
119
244
  {
120
245
  "file": "dashboard.js",
121
- "note": "linkPullRequestForTracking resolves `contextOnly` from `body.contextOnly` when boolean, else `autoObserve === undefined ? false : !autoObserve` (dashboard.js:1055-1057). Route registry params string still lists `autoObserve?` (dashboard.js:12680)."
246
+ "note": "Removed: linkPullRequestForTracking no longer accepts autoObserve in the destructure; resolvedContextOnly is now simply typeof contextOnly === 'boolean' ? contextOnly : false. Route registry params string no longer lists autoObserve?."
122
247
  }
123
248
  ],
124
249
  "deprecated": "2026-06-08",
125
250
  "targetRemovalDate": "2026-06-25",
126
- "notes": "Unlike the three record-field aliases, nothing is written here — this is purely an input read-fallback. Removal scope (DEFERRED — gated on confirming no client still POSTs `autoObserve`, not on the expired calendar date): drop the `!body.autoObserve` fallback in the link handler in dashboard.js, drop `autoObserve?` from the route registry `params` string, and update any client (dashboard JS, ops scripts) that still POSTs `autoObserve`. After removal, callers that still send `autoObserve` will see their value silently ignored. On-disk sweep confirmed CLEAN on 2026-06-18: grep of every live projects/*/pull-requests.json (budget-test-project, constellation, minions-opg, office-bohemia) for the `_contextOnly`/`_autoObserve`/`_manual` record keys returned zero matches, so the deferral gate is now cleared and a deliberate, human-reviewed removal can be scheduled (this calendar bump 2026-06-16 2026-06-25 is the docs-only consistency half; the actual code removal stays a separate human-reviewed PR)."
251
+ "notes": "Removed on 2026-06-25. On-disk sweep confirmed CLEAN on 2026-06-18. autoObserve parameter silently ignored by server after this removal callers that still send it will see the value dropped with contextOnly defaulting to false."
127
252
  },
128
253
  {
129
254
  "id": "worktreemode-field-rename",
@@ -150,10 +275,23 @@
150
275
  "id": "pr-observe-observe-body-param",
151
276
  "description": "Legacy `observe` body parameter on `POST /api/pull-requests/observe`. The W-mq5s5ttx000j7ab8 endpoint sub-WI introduces canonical `contextOnly` as the inverse (`observe: false` ⇔ `contextOnly: true`) and keeps `observe` accepted for backward compat. Registering the deprecation here so the alias has a documented removal path; the WI explicitly notes this entry is the implementer's call (it is kept for backward compat and may live longer than the underscore-prefixed record fields).",
152
277
  "code": [
153
- { "file": "dashboard.js", "note": "POST /api/pull-requests/observe handler reads `body.contextOnly` first, then falls back to `!body.observe` for backwards compat." }
278
+ {
279
+ "file": "dashboard.js",
280
+ "note": "POST /api/pull-requests/observe handler reads `body.contextOnly` first, then falls back to `!body.observe` for backwards compat."
281
+ }
154
282
  ],
155
283
  "deprecated": "2026-06-08",
156
284
  "targetRemovalDate": null,
157
285
  "notes": "targetRemovalDate intentionally null — unlike the record-field aliases (`_contextOnly`, `_autoObserve`, `_manual`) which carry a 7-day clock, the `observe` body param is documented as a longer-lived back-compat alias. Set targetRemovalDate to a concrete future date once the dashboard UI + any client scripts are confirmed to POST `contextOnly` exclusively. Removal scope when the date is set: drop the `body.observe` fallback in dashboard.js, drop `observe` from the route registry params, and update any client still POSTing `observe`."
286
+ },
287
+ {
288
+ "id": "discover-review-skills-shim",
289
+ "description": "engine/discover-review-skills.js is a 25-line re-export shim for engine/discover-project-skills.js. Zero production callers; only test files (discover-review-skills.test.js, discover-project-skills.test.js:706) reference it.",
290
+ "addedDate": "2026-06-24",
291
+ "targetRemovalDate": "2026-09-01",
292
+ "removalGate": "No external callers import discover-review-skills.js; test files updated to import from discover-project-skills.js directly.",
293
+ "removalScope": "Delete engine/discover-review-skills.js, delete test/unit/discover-review-skills.test.js, update test/unit/discover-project-skills.test.js:706 to import from discover-project-skills.js.",
294
+ "autoRemoveSafe": false,
295
+ "notes": "gate: zero test references to discover-review-skills; must update tests to point to discover-project-skills.js before file can be deleted"
158
296
  }
159
297
  ]
@@ -43,6 +43,23 @@ The engine never calls `git reset --hard`, `git clean -fd`, `git stash`, or any
43
43
 
44
44
  A thrown error from `prepareLiveCheckout` — a required-arg/ref-validation guard, or a transient `git status`/`rev-parse`/`checkout`/`symbolic-ref` failure — is **not** proof that the operator tree is dirty. `spawnAgent`'s `catch` block therefore completes the dispatch with the **separate** `FAILURE_CLASS.LIVE_CHECKOUT_FAILED` (`'live-checkout-failed'`), which is deliberately **excluded** from `engine/dispatch.js`'s `neverRetry` set. The dispatcher auto-retries with bounded backoff up to `ENGINE_DEFAULTS.maxRetries`, so racy branch-lock handoff, a just-finished sibling dispatch, or transient git state recovers on the next attempt **without a manual `/api/work-items/retry`**. Genuinely terminal underlying reasons (auth, validation) still short-circuit via the reason-string check in `isRetryableFailureReason`. No dirty-files inbox alert is written and no `_pendingReason: 'live_checkout_dirty'` stamp is applied for this path — those belong to the confirmed-dirty result above.
45
45
 
46
+ > **`git status --porcelain` maxBuffer (W-mqvejug6000eeb20).** All read-only git probes in `prepareLiveCheckout` / `restoreLiveCheckoutAtDispatchEnd` run with a 50 MB `maxBuffer` (`LIVE_CHECKOUT_GIT_MAX_BUFFER`, threaded through `baseOpts`). A live tree with thousands of dirty/untracked paths could otherwise overflow `execFile`'s 1 MB default and reject with `ERR_CHILD_PROCESS_STDOUT_MAXBUFFER` — a *thrown* error that mis-classified as a transient retryable `LIVE_CHECKOUT_FAILED` (per 2a) and retry-stormed to the cap instead of being surfaced as the dirty tree it actually was. A caller-supplied `gitOpts.maxBuffer` still wins (spread after the default).
47
+
48
+ #### 2b. Opt-in auto-reset on dirty (`liveCheckoutAutoReset`, W-mqvejug6000eeb20)
49
+
50
+ By default the dirty tree refusal (Guarantee 2) is terminal — the engine never touches the operator's uncommitted work. **Opt in** to automatic recovery with `liveCheckoutAutoReset`. When enabled, a dirty live tree is **force-reset to the remote** instead of refusing the dispatch:
51
+
52
+ 1. `prepareLiveCheckout` detects the dirty tree (same porcelain preflight as Guarantee 2).
53
+ 2. It resolves the effective flag via `shared.resolveLiveCheckoutAutoReset(project, engine)` — **per-project `project.liveCheckoutAutoReset` (boolean) wins**, else the fleet-wide `engine.liveCheckoutAutoReset`, else `false`. (engine.js does not thread project/engine config into the helper, so the production path self-resolves by loading `config.json` and matching the project by `localPath`; it **fails closed** to `false` on any config read error so a glitch can never silently trigger a destructive reset.)
54
+ 3. If ON: `git fetch origin` + `git reset --hard origin/<branch>`, then **re-run the porcelain preflight once**. If the tree is now clean, dispatch proceeds normally. If the reset failed or the tree is *still* dirty, it falls back to the safe `{ ok:false, reason:'dirty' }` refusal — the engine never dispatches onto an unexpected tree.
55
+ 4. On a successful reset it writes a single `live-checkout-autoreset-<wiId>` inbox note listing **exactly which paths were discarded**, so the operator can recover them from `git reflog` / `git fsck --lost-found`.
56
+
57
+ This is **DESTRUCTIVE** — it permanently discards the operator's uncommitted changes in the live checkout — which is why it is **OFF by default** and strictly opt-in. Use it only for unattended/CI-style live checkouts where the tree is expected to track the remote and any local drift is disposable.
58
+
59
+ - **Fleet-wide:** Dashboard → Settings → `Live-checkout auto-reset (fleet-wide)` (`engine.liveCheckoutAutoReset`).
60
+ - **Per-project override:** set `liveCheckoutAutoReset: true|false` on the project object in `config.json` (see the config snippet under *Enabling live mode*). A per-project boolean overrides the fleet-wide default for that project. *(Per-project Settings-UI persistence is deferred — the per-project override is config.json-only for now.)*
61
+
62
+
46
63
  ### 3. No auto-pull, no `--force`, no fast-forward
47
64
 
48
65
  After the clean-tree check, `prepareLiveCheckout`:
@@ -109,6 +126,10 @@ The core invariant holds end-to-end through restore: **the engine only ever swit
109
126
  "name": "android-aosp",
110
127
  "localPath": "/home/yemi/aosp",
111
128
  "checkoutMode": "live",
129
+ // "liveCheckoutAutoReset": true, // OPT-IN, DESTRUCTIVE — on a dirty tree,
130
+ // git fetch origin + git reset --hard origin/<branch> instead of refusing
131
+ // (discards uncommitted operator changes; logs a live-checkout-autoreset
132
+ // inbox note). Overrides the fleet-wide engine.liveCheckoutAutoReset.
112
133
  // …
113
134
  }]
114
135
  }
@@ -118,7 +139,7 @@ Absent / `null` / `''` reads as `'worktree'` (the default) — explicit is prefe
118
139
 
119
140
  ### Recovering from `live_checkout_dirty` refusal
120
141
 
121
- When dispatch is blocked by a dirty tree, the dashboard shows the work item as pending with `_pendingReason: 'live_checkout_dirty'` and an inbox alert lists the dirty files. From the project checkout:
142
+ When dispatch is blocked by a dirty tree, the dashboard shows the work item as pending with `_pendingReason: 'live_checkout_dirty'` and an inbox alert lists the dirty files. (To make the engine *auto-recover* from this instead of refusing — at the cost of discarding the dirty changes — enable [opt-in auto-reset](#2b-opt-in-auto-reset-on-dirty-livecheckoutautoreset-w-mqvejug6000eeb20).) From the project checkout:
122
143
 
123
144
  ```bash
124
145
  # Option A — preserve work for later
@@ -211,7 +232,7 @@ To opt back out, clear `liveValidation` from the project config (or set to `null
211
232
  Live-checkout mode is deliberately small. These are NOT supported and will not be added:
212
233
 
213
234
  - **No `auto` mode.** The choice between `worktree` and `live` is per-project and operator-set. The engine will not auto-detect submodules / `repo` workspaces and silently switch modes.
214
- - **No auto-stash on dirty refusal.** The engine refuses and exits; it never `git stash`es to "make room" for a dispatch. Stashes silently mutate the operator's tree and conflate engine state with operator state.
235
+ - **No auto-stash on dirty refusal.** The engine refuses and exits; it never `git stash`es to "make room" for a dispatch. Stashes silently mutate the operator's tree and conflate engine state with operator state. (The opt-in `liveCheckoutAutoReset` is the one sanctioned escape hatch — but it **discards** rather than stashes, is OFF by default, and is gated on explicit per-project / fleet-wide opt-in. See [§2b](#2b-opt-in-auto-reset-on-dirty-livecheckoutautoreset-w-mqvejug6000eeb20).)
215
236
  - **No concurrent dispatches per project.** The cap is 1; raising it would require per-WI subdirectories, which live mode explicitly does not provide.
216
237
  - **No per-WI subdirectory isolation.** Live mode is one-checkout-per-project by design. If you need isolation, use `checkoutMode: 'worktree'` (the default).
217
238
  - **No per-WI override.** `checkoutMode` is per-project only. There is no `meta.checkoutMode` on a work item that overrides the project setting.
@@ -223,8 +244,9 @@ Live-checkout mode is deliberately small. These are NOT supported and will not b
223
244
  | File | Purpose |
224
245
  |---|---|
225
246
  | `engine/shared.js` — `CHECKOUT_MODES`, `validateCheckoutMode`, `resolveCheckoutMode`, `isLiveCheckoutProject` | Enum + validator + back-compat resolver (P-a3f9b201; consolidated W-mqiaw974). |
247
+ | `engine/shared.js` — `resolveLiveCheckoutAutoReset` + `ENGINE_DEFAULTS.liveCheckoutAutoReset` | Pure precedence resolver (per-project boolean > fleet-wide engine default > false) + the fleet-wide default (OFF). Gates the opt-in dirty-tree auto-reset in `prepareLiveCheckout` (W-mqvejug6000eeb20). |
226
248
  | `engine/shared.js` — `resolveSpawnPaths` | Returns `{ cwd: localPath, worktreeRootDir: null, liveMode: true }` for live projects (P-a3f9b202). |
227
- | `engine/live-checkout.js` — `prepareLiveCheckout` | Pure helper: dirty check, mid-operation / detached-HEAD preflight (incl. `BISECT_LOG`; throw-on-git-dir-failure; exit-1-only detached), original-ref capture, **already-on-branch fast path**, `refs/heads/<branch>` existence check, branch resolution from HEAD (no fetch — issue #226), **no-half-switch + `blob-fetch` classification** for partial-clone hydration failures (P-a3f9b203; preflight + capture P-b2e8d4a6; hardening PL-live-checkout-reliability-hardening). |
249
+ | `engine/live-checkout.js` — `prepareLiveCheckout` | Pure helper: dirty check, mid-operation / detached-HEAD preflight (incl. `BISECT_LOG`; throw-on-git-dir-failure; exit-1-only detached), original-ref capture, **already-on-branch fast path**, `refs/heads/<branch>` existence check, branch resolution from HEAD (no fetch — issue #226), **no-half-switch + `blob-fetch` classification** for partial-clone hydration failures, **opt-in dirty auto-reset** (`git fetch origin` + `reset --hard origin/<branch>` + re-check + `live-checkout-autoreset-<wiId>` note when `liveCheckoutAutoReset` is on), **50 MB git maxBuffer** (P-a3f9b203; preflight + capture P-b2e8d4a6; hardening PL-live-checkout-reliability-hardening; auto-reset + maxBuffer W-mqvejug6000eeb20). |
228
250
  | `engine/live-checkout.js` — `restoreLiveCheckoutAtDispatchEnd` | Dispatch-end auto-restore (plain `git checkout <originalRef>`, never `--force`/reset/clean/stash, best-effort) + **self-healing dirty recovery** (auto-commit agent WIP onto the agent branch) + `live-checkout-failed-<dispatchId>` terminal-failure alert + `live-checkout-branch-<dispatchId>` fallback notify (now also on unexpected restore errors) (P-d9e6b2c4; self-heal PL-live-checkout-reliability-hardening). |
229
251
  | `engine/live-checkout.js` — `maybeRestoreLiveCheckoutFromRecord` | Shared wrapper that fires the dispatch-end restore from a persisted dispatch record; used by `cli.js` + both `timeout.js` reaping paths so a restart-spanning live dispatch is never stranded (PL-live-checkout-reliability-hardening). |
230
252
  | `engine.js` — `spawnAgent` live-mode block | Calls `prepareLiveCheckout`, handles dirty / throw branches, gates `git worktree add` on `!liveMode` (P-a3f9b204). |
@@ -237,7 +259,7 @@ Live-checkout mode is deliberately small. These are NOT supported and will not b
237
259
  | `engine.js` — worktree-pool / orphan-GC short-circuits | `worktreePath===null` no-ops in live mode (P-a3f9b206). |
238
260
  | `engine/cleanup.js` — `runPeriodicWorktreeSweep` live filter | Excludes live-checkout projects from the registry-derived periodic worktree GC so the operator's primary checkout never enters the GC decision surface (PL-live-checkout-reliability-hardening). |
239
261
  | `engine/create-pr-worktree.js` — `prepareCreatePrWorktree` step-4 restore | `reset --hard HEAD` (not the index-leaking `checkout -- .`) + retried untracked removal + `liveTreeDirty` surfaced + `shared.removeWorktree` teardown (PL-live-checkout-reliability-hardening). |
240
- | `dashboard/js/settings.js` — checkoutMode dropdown + chip | Operator-facing UI (P-a3f9b207). |
262
+ | `dashboard/js/settings.js` — checkoutMode dropdown + chip + `set-liveCheckoutAutoReset` fleet toggle | Operator-facing UI; the fleet-wide auto-reset toggle persists to `engine.liveCheckoutAutoReset` (per-project UI deferred — config.json only) (P-a3f9b207; auto-reset toggle W-mqvejug6000eeb20). |
241
263
  | `test/unit/{resolve-spawn-paths-live-mode,prepare-live-checkout,spawn-agent-live-mode-wiring}.test.js` | Wiring and contract tests (P-a3f9b208). |
242
264
  | `engine/shared.js` — `FAILURE_CLASS.LIVE_CHECKOUT_DIRTY` | Non-retryable refusal class. |
243
265
  | `engine/shared.js` — `FAILURE_CLASS.LIVE_CHECKOUT_MID_OPERATION` | Non-retryable refusal class for a mid-operation / detached-HEAD operator tree (in-progress merge/rebase/cherry-pick/revert/bisect or detached HEAD), distinct from the dirty-tree class. Emitted by `spawnAgent`'s mid-op / detached-HEAD refusal block (P-a7f3c1d9; wired P-c5a1f3b8). |
package/engine/ado.js CHANGED
@@ -2224,6 +2224,13 @@ async function reconcilePrs(config) {
2224
2224
  // are human-authored and should not be auto-tracked or auto-reviewed.
2225
2225
  if (!confirmedItemId) continue;
2226
2226
 
2227
+ // Only auto-link when the work item was dispatched to a configured Minions
2228
+ // agent. PRs from human coworkers or external Copilot agents who happen to
2229
+ // use Minions-style branch naming (work/W-xxx) must not enter the review loop.
2230
+ const configuredAgentIds = new Set(Object.keys(config.agents || {}));
2231
+ const dispatchedTo = String(linkedItem?.dispatched_to || '').toLowerCase();
2232
+ if (!dispatchedTo || !configuredAgentIds.has(dispatchedTo)) continue;
2233
+
2227
2234
  const entry = {
2228
2235
  id: prId,
2229
2236
  prNumber: adoPr.pullRequestId,
@@ -2512,10 +2519,12 @@ async function checkLiveBuildAndConflict(pr, project) {
2512
2519
  }
2513
2520
  }
2514
2521
 
2515
- async function fetchAdoPrMetadata(prNum, adoOrg, adoProj, adoRepo) {
2522
+ async function fetchAdoPrMetadata(prNum, adoOrg, adoProj, adoRepo, project) {
2516
2523
  const token = await getAdoToken();
2517
2524
  if (!token) return null;
2518
- const orgBase = getAdoOrgBase({ adoOrg });
2525
+ // Use full project config when provided so visualstudio.com prUrlBase
2526
+ // resolves correctly via getAdoOrgBase; fall back to bare { adoOrg } stub.
2527
+ const orgBase = getAdoOrgBase(project && project.prUrlBase ? project : { adoOrg });
2519
2528
  const url = `${orgBase}/${encodeURIComponent(adoProj)}/_apis/git/repositories/${encodeURIComponent(adoRepo)}/pullrequests/${encodeURIComponent(String(prNum))}?api-version=7.1`;
2520
2529
  const pr = await adoFetch(url, token);
2521
2530
  if (!pr) return null;
package/engine/github.js CHANGED
@@ -1498,6 +1498,13 @@ async function reconcilePrs(config) {
1498
1498
  const authorLogin = String(ghPr.user?.login || '').trim().toLowerCase();
1499
1499
  if (!confirmedItemId || !configuredAuthorLogins.has(authorLogin)) continue;
1500
1500
 
1501
+ // Only auto-link when the work item was dispatched to a configured Minions
1502
+ // agent. PRs from human coworkers or external Copilot agents who happen to
1503
+ // use Minions-style branch naming (work/W-xxx) must not enter the review loop.
1504
+ const configuredAgentIds = new Set(Object.keys(config.agents || {}));
1505
+ const dispatchedTo = String(linkedItem?.dispatched_to || '').toLowerCase();
1506
+ if (!dispatchedTo || !configuredAgentIds.has(dispatchedTo)) continue;
1507
+
1501
1508
  const entry = {
1502
1509
  id: prId,
1503
1510
  prNumber: ghPr.number,