@yemi33/minions 0.1.2286 → 0.1.2287

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
@@ -37,6 +37,7 @@ const _dashboardVersion = {
37
37
  };
38
38
  const shared = require('./engine/shared');
39
39
  const queries = require('./engine/queries');
40
+ const prdStore = require('./engine/prd-store');
40
41
  const ado = require('./engine/ado');
41
42
  const gh = require('./engine/github');
42
43
  const ghToken = require('./engine/gh-token');
@@ -429,6 +430,21 @@ function mergeSettingsConfigUpdate(current, candidate, body, patch = {}) {
429
430
  // `worktreeMode`. Drop any stale legacy key on every settings save so a
430
431
  // migrated project never carries both fields.
431
432
  delete currentProject.worktreeMode;
433
+ // M006 — mirror liveValidation: check both candidateProject (modified
434
+ // in-memory config) and the original body update. When handleSettingsUpdate
435
+ // deletes the field (null or empty type sent), candidateProject won't have
436
+ // it but the body update will — mirror the deletion to disk.
437
+ if (Object.prototype.hasOwnProperty.call(candidateProject, 'liveValidation')) {
438
+ if (candidateProject.liveValidation && typeof candidateProject.liveValidation === 'object' && candidateProject.liveValidation.type) {
439
+ currentProject.liveValidation = candidateProject.liveValidation;
440
+ } else {
441
+ delete currentProject.liveValidation;
442
+ }
443
+ } else if (Object.prototype.hasOwnProperty.call(update, 'liveValidation')) {
444
+ // Body sent liveValidation key but handleSettingsUpdate deleted it from
445
+ // the in-memory config (null / empty type) — propagate to disk.
446
+ delete currentProject.liveValidation;
447
+ }
432
448
  }
433
449
  }
434
450
  shared.pruneDefaultClaudeConfig(current);
@@ -1299,7 +1315,7 @@ function collectArchivedWorkItems(minionsDir = MINIONS_DIR, projects = PROJECTS)
1299
1315
  }
1300
1316
  return archived;
1301
1317
  }
1302
- function linkPullRequestForTracking({ url, title, project: projectName, contextOnly, autoObserve, context, workItemId }, config = CONFIG, options = {}) {
1318
+ function linkPullRequestForTracking({ url, title, project: projectName, contextOnly, context, workItemId }, config = CONFIG, options = {}) {
1303
1319
  if (!url) {
1304
1320
  const err = new Error('url required');
1305
1321
  err.statusCode = 400;
@@ -1315,12 +1331,7 @@ function linkPullRequestForTracking({ url, title, project: projectName, contextO
1315
1331
  const linkedWorkItemId = getWorkItemIdFromPrLinkContext(context, workItemId);
1316
1332
  const contextText = typeof context === 'string' ? context : (context == null ? '' : JSON.stringify(context));
1317
1333
  const metadata = normalizePrMetadata(options.metadata);
1318
- // W-mq5s5ttx000j7ab8-c: canonical `contextOnly` field with `autoObserve`
1319
- // deprecated alias. Explicit canonical wins; otherwise fall back to the
1320
- // alias (autoObserve:true → contextOnly:false); otherwise default false.
1321
- const resolvedContextOnly = typeof contextOnly === 'boolean'
1322
- ? contextOnly
1323
- : (autoObserve === undefined ? false : !autoObserve);
1334
+ const resolvedContextOnly = typeof contextOnly === 'boolean' ? contextOnly : false;
1324
1335
  const result = shared.upsertPullRequestRecord(prPath, {
1325
1336
  id: prId,
1326
1337
  prNumber: parseInt(prNum, 10) || null,
@@ -6974,7 +6985,11 @@ const server = http.createServer(async (req, res) => {
6974
6985
  if (!fs.existsSync(PRD_DIR)) fs.mkdirSync(PRD_DIR, { recursive: true });
6975
6986
 
6976
6987
  const planFile = 'manual-' + shared.uid() + '.json';
6977
- safeWrite(path.join(PRD_DIR, planFile), manualPrd.plan);
6988
+ const manualPrdPath = path.join(PRD_DIR, planFile);
6989
+ safeWrite(manualPrdPath, manualPrd.plan);
6990
+ // Phase 10 step 2 — this create uses safeWrite (not mutateJsonFileLocked),
6991
+ // so mirror to SQL explicitly. Best-effort.
6992
+ prdStore.mirrorPrdToSql(manualPrdPath, manualPrd.plan);
6978
6993
  invalidatePlansCache();
6979
6994
  return jsonReply(res, 200, { ok: true, id: manualPrd.id, file: planFile });
6980
6995
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
@@ -8303,6 +8318,10 @@ const server = http.createServer(async (req, res) => {
8303
8318
  cleanupPlanWorktrees(body.file, planObj || {}, PROJECTS, getConfig());
8304
8319
  } catch (e) { console.error('plan worktree cleanup:', e.message); }
8305
8320
  safeUnlink(planPath);
8321
+ // Phase 10 step 2 — a deleted PRD must also leave SQL, or the read-flip
8322
+ // (step 3) would resurrect it from a stale mirror row. Best-effort; no-op
8323
+ // for non-PRD paths (parsePrdPath returns null for plan .md deletes).
8324
+ if (body.file.endsWith('.json')) prdStore.removePrdFromSql(planPath);
8306
8325
  // Neutralize the `.backup` sidecar so `safeJson` auto-restore can't
8307
8326
  // RESURRECT the PRD we just deleted (the live file is gone, but a stray
8308
8327
  // `prd/<plan>.json.backup` would be auto-restored on the next safeJson read
@@ -9858,6 +9877,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9858
9877
  _emitTimingLog(_lifecycle, _tSessionReady, Date.now(), 'cancelled-pre-stream');
9859
9878
  return resolveResult({ text: accumulated, sessionId: sessionHandle.sessionId, code: 0, usage: {}, raw: accumulated, stderr: '' });
9860
9879
  }
9880
+ let poolSegmentId = 0;
9881
+ let segmentStart = 0;
9861
9882
  await sessionHandle.stream(prompt, {
9862
9883
  systemPromptText: systemPrompt,
9863
9884
  onChunk: (delta) => {
@@ -9866,7 +9887,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9866
9887
  accumulated += delta;
9867
9888
  _touchCcLiveStream(liveState);
9868
9889
  liveState.text = accumulated;
9869
- if (liveState.writer) liveState.writer({ type: 'chunk', text: accumulated });
9890
+ const segmentText = accumulated.slice(segmentStart);
9891
+ if (liveState.writer) liveState.writer({ type: 'chunk', text: segmentText, segmentId: poolSegmentId });
9870
9892
  },
9871
9893
  onToolUse: (name, input, toolCallId) => {
9872
9894
  if (_tFirstTool == null) _tFirstTool = Date.now();
@@ -9877,6 +9899,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9877
9899
  if (Array.isArray(toolUses)) toolUses.push(entry);
9878
9900
  if (Array.isArray(liveState.tools)) liveState.tools.push(entry);
9879
9901
  if (liveState.writer) liveState.writer({ type: 'tool', name, input: _lightToolInput(safeInput), id: toolCallId || null });
9902
+ // Advance segment tracking so the next onChunk creates a fresh segment
9903
+ poolSegmentId++;
9904
+ segmentStart = accumulated.length;
9880
9905
  },
9881
9906
  onToolUpdate: (toolCallId, status) => {
9882
9907
  _touchCcLiveStream(liveState);
@@ -10865,6 +10890,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10865
10890
  // the per-project dropdown. resolveCheckoutMode honors the legacy
10866
10891
  // worktreeMode field; 'worktree' (default) or 'live'.
10867
10892
  checkoutMode: shared.resolveCheckoutMode(p),
10893
+ // M006 — surface liveValidation so the Settings UI can pre-fill
10894
+ // the type input and autoDispatch toggle. Null when not configured.
10895
+ liveValidation: (p.liveValidation && typeof p.liveValidation === 'object' && p.liveValidation.type)
10896
+ ? { type: p.liveValidation.type, autoDispatch: !!p.liveValidation.autoDispatch }
10897
+ : null,
10868
10898
  workSources: {
10869
10899
  pullRequests: { enabled: p.workSources?.pullRequests?.enabled !== false, cooldownMinutes: p.workSources?.pullRequests?.cooldownMinutes ?? 30 },
10870
10900
  workItems: { enabled: p.workSources?.workItems?.enabled !== false, cooldownMinutes: p.workSources?.workItems?.cooldownMinutes ?? 0 }
@@ -11289,10 +11319,19 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11289
11319
  // Drop the legacy field so a migrated project never carries both.
11290
11320
  delete proj.worktreeMode;
11291
11321
  }
11322
+ // M006 — per-project liveValidation: { type, autoDispatch }.
11323
+ // Only meaningful when checkoutMode is 'live'. Null / missing type
11324
+ // clears the field; explicit object with a non-empty type persists it.
11325
+ if (Object.prototype.hasOwnProperty.call(update, 'liveValidation')) {
11326
+ const lv = update.liveValidation;
11327
+ if (!lv || typeof lv !== 'object' || !lv.type || typeof lv.type !== 'string' || !lv.type.trim()) {
11328
+ delete proj.liveValidation;
11329
+ } else {
11330
+ proj.liveValidation = { type: lv.type.trim(), autoDispatch: !!lv.autoDispatch };
11331
+ }
11332
+ }
11292
11333
  }
11293
11334
  }
11294
-
11295
- shared.pruneDefaultClaudeConfig(config);
11296
11335
  mutateDashboardConfig(current => mergeSettingsConfigUpdate(current, config, body, _configPatch));
11297
11336
  // Refresh in-memory CONFIG so subsequent reads see the update
11298
11337
  reloadConfig();
@@ -13357,7 +13396,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13357
13396
  // /api/prd/regenerate removed — use /api/plans/approve which does diff-aware update
13358
13397
 
13359
13398
  // Agents
13360
- { method: 'POST', path: '/api/pull-requests/link', desc: 'Manually link an external PR for tracking', params: 'url, title?, project?, contextOnly?, autoObserve? (deprecated alias for !contextOnly), context?, workItemId?', handler: async (req, res) => {
13399
+ { method: 'POST', path: '/api/pull-requests/link', desc: 'Manually link an external PR for tracking', params: 'url, title?, project?, contextOnly?, context?, workItemId?', handler: async (req, res) => {
13361
13400
  const body = await readBody(req);
13362
13401
  const { url } = body;
13363
13402
  if (!url) return jsonReply(res, 400, { error: 'url required' });
@@ -3,26 +3,10 @@
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
- {
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
- }
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" }
26
10
  ],
27
11
  "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.",
28
12
  "targetRemovalDate": "2026-09-17",
@@ -47,10 +31,7 @@
47
31
  {
48
32
  "id": "legacy-done-aliases",
49
33
  "location": "engine/cleanup.js:1165-1166",
50
- "constants": [
51
- "LEGACY_DONE_ALIASES",
52
- "LEGACY_NEEDS_REVIEW_STATUS"
53
- ],
34
+ "constants": ["LEGACY_DONE_ALIASES", "LEGACY_NEEDS_REVIEW_STATUS"],
54
35
  "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.",
55
36
  "targetRemovalDate": null,
56
37
  "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."
@@ -59,21 +40,9 @@
59
40
  "id": "config-claude-binary-override",
60
41
  "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.",
61
42
  "code": [
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
- }
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" }
77
46
  ],
78
47
  "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.",
79
48
  "targetRemovalDate": null,
@@ -83,41 +52,13 @@
83
52
  "id": "legacy-cc-model-migration",
84
53
  "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().",
85
54
  "code": [
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
- }
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" }
121
62
  ],
122
63
  "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.",
123
64
  "targetRemovalDate": null,
@@ -127,39 +68,14 @@
127
68
  "id": "sql-state-json-mirrors",
128
69
  "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.",
129
70
  "code": [
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
- }
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." }
163
79
  ],
164
80
  "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.",
165
81
  "targetRemovalDate": null,
@@ -169,51 +85,15 @@
169
85
  "id": "prune-default-claude-config",
170
86
  "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.",
171
87
  "code": [
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
- }
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" }
217
97
  ],
218
98
  "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.",
219
99
  "targetRemovalDate": null,
@@ -226,10 +106,7 @@
226
106
  "status": "removed",
227
107
  "removedDate": "2026-06-25",
228
108
  "code": [
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
- }
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." }
233
110
  ],
234
111
  "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.",
235
112
  "targetRemovalDate": "2026-08-03",
@@ -273,13 +150,10 @@
273
150
  "id": "pr-observe-observe-body-param",
274
151
  "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).",
275
152
  "code": [
276
- {
277
- "file": "dashboard.js",
278
- "note": "POST /api/pull-requests/observe handler reads `body.contextOnly` first, then falls back to `!body.observe` for backwards compat."
279
- }
153
+ { "file": "dashboard.js", "note": "POST /api/pull-requests/observe handler reads `body.contextOnly` first, then falls back to `!body.observe` for backwards compat." }
280
154
  ],
281
155
  "deprecated": "2026-06-08",
282
156
  "targetRemovalDate": null,
283
157
  "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`."
284
158
  }
285
- ]
159
+ ]
@@ -152,6 +152,60 @@ git for-each-ref --format '%(refname:short) %(upstream:track)' refs/heads \
152
152
 
153
153
  The engine has no opinion about local branches; this hygiene is the operator's responsibility in live mode.
154
154
 
155
+ ## Hybrid mode / deferred validation
156
+
157
+ > **Requires `checkoutMode: 'live'`** — `liveValidation` is ignored when `checkoutMode` is `'worktree'` (the default).
158
+
159
+ Hybrid mode lets you run *coding* work items (implement, fix, docs, …) in isolated worktrees while keeping *validation* work items (build-and-test, test, verify, …) serialized in live checkout — the setup where build caches, native toolchains, and test infrastructure only exist in one canonical checkout.
160
+
161
+ ### Full flow
162
+
163
+ 1. **Coding WI dispatched** — because `liveValidation.type` is set and this WI's type is *not* the validation type, `resolveCheckoutMode(project, workItem.type)` returns `'worktree'`. The engine creates an isolated worktree as normal, runs the agent, and pushes a PR branch.
164
+ 2. **Coding WI completes** — if `liveValidation.autoDispatch: true`, the lifecycle hook (`engine/lifecycle.js`) auto-creates a validation WI of type `liveValidation.type` targeting the same PR branch.
165
+ 3. **Validation WI dispatched** — `resolveCheckoutMode(project, 'build-and-test')` returns `'live'` (matches `liveValidation.type`). The engine runs `prepareLiveCheckout` in the operator's canonical checkout, which checks out the coding WI's branch in-place and runs the validation agent.
166
+ 4. **Validation WI completes** — `restoreLiveCheckoutAtDispatchEnd` checks the operator's tree back to the original ref.
167
+
168
+ ### Config snippet
169
+
170
+ ```jsonc
171
+ {
172
+ "projects": [{
173
+ "name": "android-aosp",
174
+ "localPath": "/home/yemi/aosp",
175
+ "checkoutMode": "live",
176
+ "liveValidation": {
177
+ "type": "build-and-test",
178
+ "autoDispatch": true
179
+ }
180
+ }]
181
+ }
182
+ ```
183
+
184
+ Configure via Dashboard → Settings → Projects → **Live validation (deferred build/test)** section. The section is greyed-out when `checkoutMode` is not `'live'`.
185
+
186
+ ### Agent behavior contract
187
+
188
+ When a coding agent is dispatched on a project with `liveValidation.autoDispatch: true`, the agent's playbook includes a note that inline build/test runs are not required:
189
+
190
+ > Skip inline build and test verification steps — a separate validation dispatch will run these in the live checkout after this coding WI completes. Push the branch and report success; the validation WI handles build/test confirmation.
191
+
192
+ This prevents coding agents (running in isolated worktrees) from attempting builds that may fail due to missing native toolchains, caches, or environment variables only present in the canonical checkout.
193
+
194
+ ### Migration guidance
195
+
196
+ If your project is on `checkoutMode: live` and you want parallel coding WIs, add:
197
+
198
+ ```jsonc
199
+ "liveValidation": { "type": "build-and-test", "autoDispatch": true }
200
+ ```
201
+
202
+ No other config changes needed. The engine automatically:
203
+ - Dispatches coding WIs into isolated worktrees (escaping the live-checkout cap of 1)
204
+ - Dispatches validation WIs serially in live checkout (preserving your build environment)
205
+ - Auto-creates validation WIs after each coding WI's PR is pushed
206
+
207
+ To opt back out, clear `liveValidation` from the project config (or set to `null` via Dashboard → Settings).
208
+
155
209
  ## Non-goals
156
210
 
157
211
  Live-checkout mode is deliberately small. These are NOT supported and will not be added:
@@ -13,7 +13,6 @@ This document is the contract every playbook author and project teammate needs t
13
13
  | `engine/discover-project-skills.js` | Bounded filesystem walk + classification heuristic. The crash-safe primitive. |
14
14
  | `engine/playbook-intents.js` | Single source of truth for the playbook → intent set mapping. |
15
15
  | `engine/playbook.js` | Calls discovery + filter at render time, injects `{{project_skills_block}}` into the rendered prompt. |
16
- | `engine/discover-review-skills.js` | Thin re-export shim. Backward-compat only — new code uses `discover-project-skills.js`. |
17
16
 
18
17
  ## What gets discovered
19
18
 
package/engine/cleanup.js CHANGED
@@ -458,6 +458,40 @@ function reapAgentScratch(worktreeRoot) {
458
458
  return reaped;
459
459
  }
460
460
 
461
+ // Disposable agent-deliverable scratch that piles up in knowledge/: per-batch
462
+ // cleanup implementation/analysis notes and the regenerable consolidated digests.
463
+ // These are gitignored working notes nothing reads back — emitted by the
464
+ // simplify/KB-cleanup sweeps at daily cadence, they accumulate forever otherwise.
465
+ // The pattern is intentionally NARROW: curated KB entries and the per-agent
466
+ // memory files (knowledge/agents/<id>.md) never match it and are never touched.
467
+ const KNOWLEDGE_SCRATCH_RE = /(?:kb-cleanup-batch|consolidated-digest|consolidated-ci-health)/i;
468
+ const KNOWLEDGE_SCRATCH_TTL_MS = 48 * 60 * 60 * 1000; // 48h — keep a couple days for reference, reap older
469
+ const KNOWLEDGE_SCRATCH_MAX_SCAN = 5000;
470
+
471
+ function reapKnowledgeScratch(minionsDir = MINIONS_DIR) {
472
+ const dir = path.join(minionsDir, 'knowledge');
473
+ let names;
474
+ try { names = fs.readdirSync(dir); } catch { return 0; } // no knowledge dir → nothing to do
475
+ const cutoff = Date.now() - KNOWLEDGE_SCRATCH_TTL_MS;
476
+ let reaped = 0;
477
+ const limit = Math.min(names.length, KNOWLEDGE_SCRATCH_MAX_SCAN);
478
+ for (let i = 0; i < limit; i++) {
479
+ const name = names[i];
480
+ // Only top-level .md files whose name matches the disposable-scratch pattern.
481
+ if (!name.endsWith('.md') || !KNOWLEDGE_SCRATCH_RE.test(name)) continue;
482
+ const full = path.join(dir, name);
483
+ let stat;
484
+ try { stat = fs.statSync(full); } catch { continue; }
485
+ if (!stat.isFile() || stat.mtimeMs >= cutoff) continue; // recent — keep for reference
486
+ try { fs.unlinkSync(full); reaped++; }
487
+ catch { /* locked / in use — leave it for a later pass */ }
488
+ }
489
+ if (reaped > 0) {
490
+ log('info', `Reaped ${reaped} stale knowledge-scratch notes (>${Math.round(KNOWLEDGE_SCRATCH_TTL_MS / 3600000)}h) from ${dir}`);
491
+ }
492
+ return reaped;
493
+ }
494
+
461
495
  // ─── Cleanup Orchestrator ────────────────────────────────────────────────────
462
496
 
463
497
  async function runCleanup(config, verbose = false) {
@@ -742,7 +776,11 @@ async function runCleanup(config, verbose = false) {
742
776
  }
743
777
 
744
778
  // Get PRs for this project
745
- const prs = safeJson(projectPrPath(project)) || [];
779
+ // Array.isArray guard: safeJson(p) || [] only substitutes [] for falsy values;
780
+ // a truthy non-array (e.g. {}, 42, true) bypasses the fallback and causes a
781
+ // TypeError in the for-of loop below. Array.isArray covers that case.
782
+ const _prsRaw = safeJson(projectPrPath(project));
783
+ const prs = Array.isArray(_prsRaw) ? _prsRaw : [];
746
784
  const mergedBranches = new Set();
747
785
  for (const pr of prs) {
748
786
  if (pr.status === shared.PR_STATUS.MERGED || pr.status === shared.PR_STATUS.ABANDONED) {
@@ -960,7 +998,10 @@ async function runCleanup(config, verbose = false) {
960
998
  // Remove all marked worktrees
961
999
  // Re-read PR status immediately before deletion — a PR can be reopened between
962
1000
  // the initial status check and the actual deletion (Bug #15: TOCTOU race)
963
- const freshPrs = safeJson(projectPrPath(project)) || [];
1001
+ // Array.isArray guard: same as the prs read above — non-array truthy JSON
1002
+ // bypasses || [] and would throw inside the try-catch, silently skipping deletions.
1003
+ const _freshPrsRaw = safeJson(projectPrPath(project));
1004
+ const freshPrs = Array.isArray(_freshPrsRaw) ? _freshPrsRaw : [];
964
1005
  const freshMergedBranches = new Set();
965
1006
  const freshMergedPrByBranch = new Map();
966
1007
  for (const pr of freshPrs) {
@@ -1585,6 +1626,13 @@ async function runCleanup(config, verbose = false) {
1585
1626
  }
1586
1627
  } catch (e) { log('warn', `pruneStaleBackupSidecars: ${e.message}`); }
1587
1628
 
1629
+ // 17. Reap stale gitignored knowledge-scratch notes (kb-cleanup-batch / digests)
1630
+ // that the simplify/KB-cleanup sweeps emit into knowledge/ and never read back.
1631
+ cleaned.knowledgeScratch = 0;
1632
+ try {
1633
+ cleaned.knowledgeScratch = reapKnowledgeScratch();
1634
+ } catch (e) { log('warn', `reapKnowledgeScratch: ${e.message}`); }
1635
+
1588
1636
  return cleaned;
1589
1637
  }
1590
1638
 
@@ -1738,4 +1786,5 @@ module.exports = {
1738
1786
  cleanupMergedPrLocalBranch, // exported for lifecycle cleanup and testing
1739
1787
  collectPhantomBranchesForProject, // P-e0b4f7a5 — exported for testing
1740
1788
  reapAgentScratch, // exported for testing
1789
+ reapKnowledgeScratch, // exported for testing
1741
1790
  };
@@ -78,6 +78,20 @@ const FEATURES = {
78
78
  addedIn: '0.1.1916',
79
79
  requiredCcRuntime: 'copilot',
80
80
  },
81
+ // prdReadsFromSql — Phase 10 step 3 read-flip. When ON, getPrdInfo sources its
82
+ // PRD list (existingPrds / verifyPrsByPlan / allPrdItems) from the SQL mirror
83
+ // (prds/prd_items/prd_verify_prs) after reconciling it from disk, instead of
84
+ // scanning prd/*.json directly. JSON stays canonical and the dual-write keeps
85
+ // SQL in sync; reconciliation catches PRDs written outside the chokepoint. The
86
+ // two code paths are proven output-equivalent by db-phase10-read-flip.test.js.
87
+ // Reversible: set `features.prdReadsFromSql: false` to fall back to the file
88
+ // scan instantly. Temporary migration gate — remove once the read-flip soaks.
89
+ 'prdReadsFromSql': {
90
+ description: 'Source the dashboard PRD/plan read (getPrdInfo) from the SQL mirror instead of scanning prd/*.json. Reversible; the two paths are output-equivalent (Phase 10 step 3).',
91
+ default: true,
92
+ addedIn: '0.1.2090',
93
+ expires: '2026-12-01',
94
+ },
81
95
  };
82
96
 
83
97
  const ENV_TRUTHY = new Set(['1', 'true', 'on', 'yes']);
package/engine/llm.js CHANGED
@@ -405,6 +405,7 @@ function _spawnProcess(promptText, sysPromptText, callOpts) {
405
405
  maxBudget, bare, fallbackModel,
406
406
  stream, disableBuiltinMcps, suppressAgentsMd, reasoningSummaries,
407
407
  images,
408
+ tmpDir: llmTmpDir,
408
409
  };
409
410
  // Capability-gate per-flag opts before prompt construction so adapters can
410
411
  // make resume-aware prompt decisions from the same opts used for argv.
@@ -1007,11 +1007,11 @@ function renderPlaybook(type, vars) {
1007
1007
  // ─── Playbook Section Validator ──────────────────────────────────────────────
1008
1008
 
1009
1009
  // Required structural section patterns — warn (do not throw) when absent.
1010
- // Pluralisation: /^## Tools?\b/m matches both '## Tool' and '## Tools'.
1010
+ // NOTE: '## Tools' / '## Constraints' are NOT included because none of the
1011
+ // production playbooks use those headers. Only '## Your Task' is universally
1012
+ // present — it is injected by renderPlaybook via the playbook template.
1011
1013
  const _REQUIRED_PROMPT_SECTIONS = [
1012
- { pattern: /^## Your Task\b/m, label: '## Your Task' },
1013
- { pattern: /^## Tools?\b/m, label: '## Tools' },
1014
- { pattern: /^## Constraints\b/m, label: '## Constraints' },
1014
+ { pattern: /^## Your Task\b/m, label: '## Your Task' },
1015
1015
  ];
1016
1016
 
1017
1017
  /**