@yemi33/minions 0.1.2378 → 0.1.2380

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.
Files changed (100) hide show
  1. package/bin/minions.js +19 -9
  2. package/dashboard/js/refresh.js +2 -3
  3. package/dashboard/js/render-prd.js +1 -1
  4. package/dashboard/js/render-work-items.js +13 -6
  5. package/dashboard.js +393 -721
  6. package/docs/README.md +1 -0
  7. package/docs/architecture-review-2026-07-09.md +2 -4
  8. package/docs/auto-discovery.md +36 -49
  9. package/docs/blog-first-successful-dispatch.md +1 -1
  10. package/docs/branch-derivation.md +2 -2
  11. package/docs/command-center.md +1 -1
  12. package/docs/completion-reports.md +14 -4
  13. package/docs/constellation-bridge.md +59 -10
  14. package/docs/constellation-style-telemetry.md +6 -6
  15. package/docs/cooldown-merge-semantics.md +4 -0
  16. package/docs/copilot-cli-schema.md +3 -3
  17. package/docs/cross-repo-plans.md +17 -17
  18. package/docs/deprecated.json +2 -2
  19. package/docs/design-inbox-entries-schema.md +31 -39
  20. package/docs/design-state-storage.md +1 -1
  21. package/docs/documentation-audit-2026-07-09.md +2 -2
  22. package/docs/engine-restart.md +20 -8
  23. package/docs/harness-mode.md +1 -1
  24. package/docs/managed-spawn.md +4 -4
  25. package/docs/onboarding.md +1 -2
  26. package/docs/pr-comment-followup.md +3 -3
  27. package/docs/pr-review-fix-loop.md +1 -1
  28. package/docs/proposals/repo-pool-for-live-checkout.md +1 -2
  29. package/docs/qa-runbook-lifecycle.md +4 -4
  30. package/docs/qa-runbooks.md +2 -2
  31. package/docs/rfc-completion-json.md +4 -1
  32. package/docs/runtime-adapters.md +1 -1
  33. package/docs/self-improvement.md +4 -5
  34. package/docs/shared-lifecycle-module-map.md +3 -1
  35. package/docs/slim-ux/architecture-suggestions.md +5 -6
  36. package/docs/slim-ux/concepts.md +23 -25
  37. package/docs/watches.md +7 -7
  38. package/docs/workspace-manifests.md +1 -1
  39. package/docs/worktree-lifecycle.md +1 -1
  40. package/engine/abandoned-pr-reconciliation.js +4 -5
  41. package/engine/acp-transport.js +49 -8
  42. package/engine/ado-status.js +5 -5
  43. package/engine/ado.js +20 -25
  44. package/engine/agent-worker-pool.js +124 -15
  45. package/engine/bridge.js +260 -5
  46. package/engine/cleanup.js +48 -131
  47. package/engine/cli.js +125 -83
  48. package/engine/cooldown.js +9 -16
  49. package/engine/db/index.js +22 -9
  50. package/engine/db/migrations/009-qa.js +1 -1
  51. package/engine/db/migrations/020-qa-session-scopes.js +23 -0
  52. package/engine/db/migrations/021-archived-work-items.js +72 -0
  53. package/engine/db/migrations/022-global-cc-session.js +31 -0
  54. package/engine/db/migrations/023-engine-state.js +30 -0
  55. package/engine/db/migrations/024-prd-ghost-collisions.js +53 -0
  56. package/engine/db/migrations/025-malformed-work-item-phantoms.js +33 -0
  57. package/engine/dispatch-store.js +2 -7
  58. package/engine/dispatch.js +34 -41
  59. package/engine/github.js +20 -27
  60. package/engine/lifecycle.js +221 -283
  61. package/engine/llm.js +1 -1
  62. package/engine/logs-store.js +2 -2
  63. package/engine/managed-spawn.js +2 -23
  64. package/engine/meeting.js +6 -6
  65. package/engine/metrics-store.js +2 -2
  66. package/engine/note-link-backfill.js +6 -11
  67. package/engine/pipeline.js +18 -36
  68. package/engine/playbook.js +1 -1
  69. package/engine/pooled-agent-process.js +46 -26
  70. package/engine/prd-store.js +73 -54
  71. package/engine/preflight.js +2 -5
  72. package/engine/projects.js +15 -62
  73. package/engine/pull-requests-store.js +0 -17
  74. package/engine/qa-runbooks.js +2 -2
  75. package/engine/qa-runs.js +1 -8
  76. package/engine/qa-sessions.js +41 -64
  77. package/engine/queries.js +120 -219
  78. package/engine/routing.js +3 -5
  79. package/engine/scheduler.js +0 -4
  80. package/engine/shared-branch-pr-reconcile.js +2 -3
  81. package/engine/shared.js +132 -637
  82. package/engine/small-state-store.js +89 -10
  83. package/engine/state-operations.js +16 -4
  84. package/engine/stdio-timestamps.js +1 -1
  85. package/engine/timeout.js +5 -12
  86. package/engine/watch-actions.js +20 -22
  87. package/engine/watches-store.js +1 -1
  88. package/engine/watches.js +6 -10
  89. package/engine/work-item-validation.js +52 -0
  90. package/engine/work-items-store.js +127 -29
  91. package/engine/worktree-gc.js +2 -2
  92. package/engine/worktree-pool.js +8 -18
  93. package/engine.js +167 -349
  94. package/minions.js +2 -2
  95. package/package.json +1 -1
  96. package/playbooks/plan-to-prd.md +2 -2
  97. package/playbooks/shared-rules.md +3 -3
  98. package/playbooks/templates/followup-dispatch.md +1 -1
  99. package/playbooks/verify.md +1 -1
  100. package/prompts/cc-system.md +2 -2
package/engine/llm.js CHANGED
@@ -33,7 +33,7 @@ const EXIT_DRAIN_FALLBACK_MS = 100;
33
33
  //
34
34
  // Updates accumulate in an in-memory buffer and flush every
35
35
  // metricsFlushIntervalMs (default 10s). Replaces the per-call mutateJsonFileLocked
36
- // that was both serializing the LLM hot path and bumping metrics.json mtime
36
+ // that was serializing the LLM hot path
37
37
  // on every call — defeating the dashboard fast-state mtime-based early-exit.
38
38
 
39
39
  let _pendingMetrics = { engine: Object.create(null), daily: Object.create(null) };
@@ -1,4 +1,4 @@
1
- // engine/logs-store.js — SQL-backed implementation of engine/log.json.
1
+ // engine/logs-store.js — SQL-backed engine logs.
2
2
  //
3
3
  // Logs differ from work_items / pull_requests / dispatches: they are
4
4
  // pure append-only with a tail-cap, so the store exposes a different
@@ -168,7 +168,7 @@ function readRecentLogs(n, opts) {
168
168
  return out;
169
169
  }
170
170
 
171
- // Convenience: same shape as the legacy `JSON.parse(log.json).slice(-N)`
171
+ // Convenience: return the latest N entries in chronological order.
172
172
  // — chronological order (oldest first). Inverts the readRecentLogs DESC
173
173
  // output so existing callers can drop in without re-sorting.
174
174
  function readRecentLogsChronological(n, opts) {
@@ -28,7 +28,6 @@ const queries = require('./queries');
28
28
  const { log, ENGINE_DEFAULTS } = shared;
29
29
 
30
30
  const MANAGED_SPAWN_FILENAME = 'managed-spawn.json';
31
- const MANAGED_PROCESSES_STATE_FILE = 'managed-processes.json';
32
31
  const MANAGED_LOGS_DIR = 'managed-logs';
33
32
  const INVALID_WORKDIR_REASON_PREFIX = 'invalid-workdir: ';
34
33
 
@@ -760,7 +759,7 @@ function buildManagedSpawnHint(opts) {
760
759
  // spawnDashboard detached-spawn pattern.
761
760
  // Returns {pid, started_at, log_path}.
762
761
  // 3. recordManagedSpec(spec, → writes one entry to
763
- // runtime, ctx) engine/managed-processes.json via
762
+ // runtime, ctx) SQL managed-process state via
764
763
  // mutateJsonFileLocked. Replaces any
765
764
  // existing entry with the same name
766
765
  // (idempotent under retry).
@@ -777,16 +776,6 @@ function buildManagedSpawnHint(opts) {
777
776
  // (playbook injection) + items 3/7
778
777
  // (sweep + boot reconcile).
779
778
  //
780
- // All state writes go through mutateJsonFileLocked per the repo convention
781
- // ('Key conventions' in copilot-instructions.md). Callbacks stay synchronous
782
- // and fast — no kill / no spawn inside the lock callback. Healthcheck loops,
783
- // dispatch ERROR gating on healthcheck failure, per-tick sweep, boot
784
- // reconcile, and dashboard endpoints are deferred to items 3/4/5/7.
785
-
786
- function _getStatePath() {
787
- return path.join(shared.MINIONS_DIR, 'engine', MANAGED_PROCESSES_STATE_FILE);
788
- }
789
-
790
779
  function _getLogsDir() {
791
780
  return path.join(shared.MINIONS_DIR, 'engine', MANAGED_LOGS_DIR);
792
781
  }
@@ -906,7 +895,6 @@ function _toStateRecord(spec, runtime, ctx) {
906
895
 
907
896
  function recordManagedSpec(spec, runtime, ctx) {
908
897
  if (!spec || !spec.name) throw new Error('recordManagedSpec: spec.name required');
909
- const statePath = _getStatePath();
910
898
  shared.mutateManagedProcesses((data) => {
911
899
  if (!data || typeof data !== 'object' || Array.isArray(data) || !Array.isArray(data.specs)) {
912
900
  data = _initialStateShape();
@@ -921,7 +909,6 @@ function recordManagedSpec(spec, runtime, ctx) {
921
909
 
922
910
  function recordManagedBatch(items, ctx) {
923
911
  if (!Array.isArray(items) || items.length === 0) return;
924
- const statePath = _getStatePath();
925
912
  shared.mutateManagedProcesses((data) => {
926
913
  if (!data || typeof data !== 'object' || Array.isArray(data) || !Array.isArray(data.specs)) {
927
914
  data = _initialStateShape();
@@ -942,7 +929,6 @@ function recordManagedBatch(items, ctx) {
942
929
  function removeManagedSpec(name) {
943
930
  if (typeof name !== 'string' || name.length === 0) return;
944
931
  let killPid = null;
945
- const statePath = _getStatePath();
946
932
  shared.mutateManagedProcesses((data) => {
947
933
  if (!data || !Array.isArray(data.specs)) return data;
948
934
  const idx = data.specs.findIndex(s => s && s.name === name);
@@ -968,8 +954,7 @@ function removeManagedSpec(name) {
968
954
 
969
955
  function listManagedSpecs(opts) {
970
956
  opts = opts || {};
971
- const statePath = _getStatePath();
972
- const parsed = shared.safeJsonObj(statePath);
957
+ const parsed = require('./small-state-store').readManagedProcesses();
973
958
  const specs = (parsed && Array.isArray(parsed.specs)) ? parsed.specs : [];
974
959
  if (opts.project) return specs.filter(s => s && s.owner_project === opts.project);
975
960
  return specs.slice();
@@ -1090,7 +1075,6 @@ async function runHealthcheck(spec) {
1090
1075
  // waitForFirstHealth on the first pass. Callers MUST be outside any other
1091
1076
  // lock — this acquires its own.
1092
1077
  function _markHealthy(name, now) {
1093
- const statePath = _getStatePath();
1094
1078
  shared.mutateManagedProcesses((data) => {
1095
1079
  if (!data || !Array.isArray(data.specs)) return data;
1096
1080
  const rec = data.specs.find(s => s && s.name === name);
@@ -1404,7 +1388,6 @@ function _runManagedReconcile(opts) {
1404
1388
  rotatedLogs: 0,
1405
1389
  malformed: 0,
1406
1390
  };
1407
- const statePath = _getStatePath();
1408
1391
  const ttlPidsToKill = [];
1409
1392
  const survivors = []; // [{name, log_path}] post-mutation, used for log rotation + bootReconcile probes
1410
1393
  shared.mutateManagedProcesses((data) => {
@@ -1459,7 +1442,6 @@ function sweepManagedSpawn(opts) {
1459
1442
  // rely on the per-spec loop to refresh post-success, or item 7 boot reconcile
1460
1443
  // to (re)establish initial truth on engine restart).
1461
1444
  function _markBootProbe(name, result) {
1462
- const statePath = _getStatePath();
1463
1445
  shared.mutateManagedProcesses((data) => {
1464
1446
  if (!data || !Array.isArray(data.specs)) return data;
1465
1447
  const rec = data.specs.find(s => s && s.name === name);
@@ -1510,7 +1492,6 @@ function removeManagedSpecsForProject(projectName) {
1510
1492
  if (typeof projectName !== 'string' || projectName.length === 0) {
1511
1493
  return { killed: 0, unlinked: 0, scanned: 0 };
1512
1494
  }
1513
- const statePath = _getStatePath();
1514
1495
  let toKill = [];
1515
1496
  let logPaths = [];
1516
1497
  shared.mutateManagedProcesses((data) => {
@@ -1546,7 +1527,6 @@ function removeManagedSpecsForProject(projectName) {
1546
1527
 
1547
1528
  module.exports = {
1548
1529
  MANAGED_SPAWN_FILENAME: MANAGED_SPAWN_FILENAME,
1549
- MANAGED_PROCESSES_STATE_FILE: MANAGED_PROCESSES_STATE_FILE,
1550
1530
  MANAGED_LOGS_DIR: MANAGED_LOGS_DIR,
1551
1531
  INVALID_WORKDIR_REASON_PREFIX: INVALID_WORKDIR_REASON_PREFIX,
1552
1532
  HEALTHCHECK_TYPES: HEALTHCHECK_TYPES,
@@ -1561,7 +1541,6 @@ module.exports = {
1561
1541
  recordManagedBatch: recordManagedBatch,
1562
1542
  removeManagedSpec: removeManagedSpec,
1563
1543
  listManagedSpecs: listManagedSpecs,
1564
- getStatePath: _getStatePath,
1565
1544
  // Item 3 (P-9c1f47a6): healthcheck implementations + first-pass waiter.
1566
1545
  runHealthcheck: runHealthcheck,
1567
1546
  waitForFirstHealth: waitForFirstHealth,
package/engine/meeting.js CHANGED
@@ -10,6 +10,7 @@ const { safeJson, uid, log, ts, ENGINE_DEFAULTS, WORK_TYPE, DISPATCH_RESULT, MEE
10
10
  const queries = require('./queries');
11
11
  const { getDispatch, getConfig } = queries;
12
12
  const { renderPlaybook } = require('./playbook');
13
+ const dispatchModule = require('./dispatch');
13
14
 
14
15
  /** Patterns that indicate an agent returned no meaningful output */
15
16
  const EMPTY_OUTPUT_PATTERNS = ['(no output)', '(no findings)', '(no response)'];
@@ -514,7 +515,7 @@ function discoverMeetingWork(config) {
514
515
 
515
516
  // Pre-flight validation: meetings missing required template vars (agenda)
516
517
  // would otherwise fail playbook rendering on every tick (~1/min), spamming
517
- // log.json with the same "missing required template variables: agenda"
518
+ // the engine log with the same "missing required template variables: agenda"
518
519
  // error. Skip them silently here; emit one structured warning per meeting
519
520
  // ID per process so the operator still has signal without the spam.
520
521
  if (!meeting.agenda || !String(meeting.agenda).trim()) {
@@ -757,12 +758,11 @@ function addMeetingNote(meetingId, note) {
757
758
 
758
759
  function _killMeetingDispatches(meetingId) {
759
760
  try {
760
- const DISPATCH_PATH = path.join(shared.MINIONS_DIR, 'engine', 'dispatch.json');
761
761
  const tmpDir = path.join(shared.MINIONS_DIR, 'engine', 'tmp');
762
762
  const entriesToStop = [];
763
763
  const filesToDelete = [];
764
764
  const dispatchDirsToRemove = [];
765
- shared.mutateJsonFileLocked(DISPATCH_PATH, (dp) => {
765
+ dispatchModule.mutateDispatch((dp) => {
766
766
  dp.pending = Array.isArray(dp.pending) ? dp.pending : [];
767
767
  dp.active = Array.isArray(dp.active) ? dp.active : [];
768
768
  dp.completed = Array.isArray(dp.completed) ? dp.completed : [];
@@ -828,7 +828,7 @@ function advanceMeetingRound(meetingId) {
828
828
  if (!existing || existing.status === MEETING_STATUS.COMPLETED || existing.status === MEETING_STATUS.ARCHIVED) return null;
829
829
 
830
830
  // CRITICAL: kill BEFORE acquiring the meeting lock. _killMeetingDispatches
831
- // takes the dispatch.json lock and shells out to kill processes — never
831
+ // mutates dispatch state and shells out to kill processes — never
832
832
  // run that under the meeting lock (per CLAUDE.md, lock callbacks must
833
833
  // stay fast and never spawn / kill / await).
834
834
  _killMeetingDispatches(meetingId);
@@ -923,13 +923,13 @@ function checkMeetingTimeouts(config) {
923
923
  // BUG-H11: when a hard timeout will fire, kill in-flight meeting
924
924
  // dispatches BEFORE entering the meeting lock so they stop consuming
925
925
  // worker slots / LLM tokens immediately rather than draining over the
926
- // next tens of minutes. _killMeetingDispatches takes the dispatch.json
926
+ // next tens of minutes. _killMeetingDispatches takes the dispatch
927
927
  // lock and shells out to kill processes — per CLAUDE.md and the
928
928
  // advanceMeetingRound/endMeeting/deleteMeeting pattern at L834, that
929
929
  // MUST run outside the meeting lock. We pre-evaluate eligibility from
930
930
  // the snapshot using the same predicate the mutate callback below
931
931
  // re-checks under lock; if the timeout has already advanced/cleared,
932
- // the kill is a no-op on dispatch.json (no matching meetingId entries).
932
+ // the kill is a no-op in dispatch state (no matching meetingId entries).
933
933
  const snapshotRoundName = snapshot.status === 'investigating'
934
934
  ? 'investigate'
935
935
  : snapshot.status === 'debating'
@@ -1,6 +1,6 @@
1
- // engine/metrics-store.js — SQL-backed implementation of engine/metrics.json.
1
+ // engine/metrics-store.js — SQL-backed engine metrics.
2
2
  //
3
- // metrics.json is a single flat object whose top-level keys mix
3
+ // Metrics expose a flat object whose top-level keys mix
4
4
  // per-agent counters with reserved keys (`_engine`, `_daily`,
5
5
  // `_contextPressure`). The store reconstructs that legacy shape for
6
6
  // reads, and reverses it for writes — each (kind, key) row holds one
@@ -148,12 +148,7 @@ function repointItemNoteLinks(item, resolve) {
148
148
  return fixed;
149
149
  }
150
150
 
151
- // Repair broken note links across every work-items file (each project's plus
152
- // the central one), routed through shared.mutateWorkItems so the canonical SQL
153
- // store AND its JSON mirror stay consistent — the same write path the live
154
- // rewriter uses. Idempotent: only touches links that are provably broken AND
155
- // resolvable, so once healed a re-run writes nothing (skipWriteIfUnchanged).
156
- // Safe to call on every engine boot. Per-file try/catch isolates a bad scope.
151
+ // Repair broken note links across every project scope plus central.
157
152
  function runNoteLinkBackfill(opts = {}) {
158
153
  const shared = require('./shared');
159
154
  const minionsDir = opts.minionsDir || shared.MINIONS_DIR;
@@ -172,14 +167,14 @@ function runNoteLinkBackfill(opts = {}) {
172
167
  const projects = Array.isArray(config.projects) ? config.projects : [];
173
168
  const candidates = [];
174
169
  for (const p of projects) {
175
- if (p && p.name) candidates.push(path.join(minionsDir, 'projects', p.name, 'work-items.json'));
170
+ if (p && p.name) candidates.push(p);
176
171
  }
177
- candidates.push(path.join(minionsDir, 'work-items.json'));
172
+ candidates.push('central');
178
173
 
179
174
  let scanned = 0, fixedItems = 0, fixedLinks = 0;
180
- for (const wiPath of candidates) {
175
+ for (const scope of candidates) {
181
176
  try {
182
- shared.mutateWorkItems(wiPath, (items) => {
177
+ shared.mutateWorkItems(scope, (items) => {
183
178
  if (!Array.isArray(items)) return items;
184
179
  for (const item of items) {
185
180
  if (!item) continue;
@@ -191,7 +186,7 @@ function runNoteLinkBackfill(opts = {}) {
191
186
  }
192
187
  return items;
193
188
  });
194
- } catch { /* per-file isolation — one bad scope can't block the others */ }
189
+ } catch { /* one bad scope cannot block the others */ }
195
190
  }
196
191
 
197
192
  return { scanned, fixedItems, fixedLinks };
@@ -8,20 +8,18 @@ const fs = require('fs');
8
8
  const path = require('path');
9
9
  const shared = require('./shared');
10
10
  const queries = require('./queries');
11
- const { safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, mutatePipelineRuns, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, READ_ONLY_ROOT_TASK_TYPES, ENGINE_DEFAULTS, MINIONS_DIR, isPrdArchived } = shared;
11
+ const { safeJson, safeJsonArr, safeJsonNoRestore, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, mutatePipelineRuns, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, READ_ONLY_ROOT_TASK_TYPES, ENGINE_DEFAULTS, MINIONS_DIR, isPrdArchived } = shared;
12
12
  const routing = require('./routing');
13
13
  const http = require('http');
14
14
  const { shouldRunNow } = require('./scheduler');
15
+ const smallStateStore = require('./small-state-store');
15
16
 
16
17
  // All module-relative paths flow through MINIONS_DIR so MINIONS_TEST_DIR
17
18
  // (set by test/unit.test.js createTestMinionsDir) consistently redirects
18
19
  // pipeline writes into the temp root instead of the live runtime root.
19
20
  const PIPELINES_DIR = path.join(MINIONS_DIR, 'pipelines');
20
- const PIPELINE_RUNS_PATH = path.join(MINIONS_DIR, 'engine', 'pipeline-runs.json');
21
- const CENTRAL_WI_PATH = path.join(MINIONS_DIR, 'work-items.json');
22
21
  const MEETINGS_DIR = path.join(MINIONS_DIR, 'meetings');
23
22
  const PLANS_DIR = path.join(MINIONS_DIR, 'plans');
24
- const PRD_DIR = path.join(MINIONS_DIR, 'prd');
25
23
  const NOTES_INBOX_DIR = path.join(MINIONS_DIR, 'notes', 'inbox');
26
24
  const NOTES_ARCHIVE_DIR = path.join(MINIONS_DIR, 'notes', 'archive');
27
25
  const CONFIG_PATH = path.join(MINIONS_DIR, 'config.json');
@@ -74,22 +72,11 @@ function deletePipeline(id) {
74
72
  // ── Run State ────────────────────────────────────────────────────────────────
75
73
 
76
74
  function getPipelineRuns() {
77
- return safeJsonObj(PIPELINE_RUNS_PATH);
75
+ return smallStateStore.readPipelineRuns();
78
76
  }
79
77
 
80
- // W-mrcrh5hj0017d22f: SQL is the canonical (and only) work-items store post
81
- // Phase 9.4 the JSON files (central work-items.json + each
82
- // projects/<name>/work-items.json) are passive, best-effort mirrors that can
83
- // legitimately lag behind a just-completed write (mirror failures are
84
- // swallowed by design so they never block the SQL write; see
85
- // work-items-store.js's `_mirrorJsonFromSql` comments). Reading those JSON
86
- // files directly (via safeJson/safeJsonArr) can therefore observe a work
87
- // item whose `status` mirrored to DONE but whose `resultSummary` write
88
- // hasn't landed in the mirror yet — which silently degraded stage-output
89
- // template substitution ({{stages.<id>.output}}) to the stage's own work
90
- // item id instead of its real completion text. Route every work-item read
91
- // in this module through the SQL-backed store so pipeline state always sees
92
- // the authoritative record.
78
+ // Always read work items from the authoritative SQL store so stage-output
79
+ // template substitution sees the latest completion metadata.
93
80
  function _allWorkItems() {
94
81
  return require('./work-items-store').readAllWorkItems();
95
82
  }
@@ -319,8 +306,8 @@ function _pipelineProjectSlug(project) {
319
306
  return project ? shared.safeSlugComponent(project.name || 'project', 32).toLowerCase() : 'central';
320
307
  }
321
308
 
322
- function _pipelineWorkItemsPath(project) {
323
- return project ? shared.projectWorkItemsPath(project) : CENTRAL_WI_PATH;
309
+ function _pipelineWorkItemScope(project) {
310
+ return project || 'central';
324
311
  }
325
312
 
326
313
  // ── Condition Evaluation ─────────────────────────────────────────────────────
@@ -435,7 +422,7 @@ function executeTaskStage(stage, stageState, run, config, pipeline = {}) {
435
422
  for (const project of projectResolution.projects) {
436
423
  const projectSlug = _pipelineProjectSlug(project);
437
424
  const id = `PL-${run.runId.slice(4, 12)}-${stage.id}-${i}${projectResolution.projects.length > 1 || project ? '-' + projectSlug : ''}`;
438
- const wiPath = _pipelineWorkItemsPath(project);
425
+ const wiScope = _pipelineWorkItemScope(project);
439
426
  const wiType = routing.normalizeWorkType(item.type || stage.taskType, WORK_TYPE.EXPLORE);
440
427
  // W-mp8ho6w500034a58: read-only stages don't commit, so a pipeline
441
428
  // branch label is meaningless to them — omit it entirely so the
@@ -443,7 +430,7 @@ function executeTaskStage(stage, stageState, run, config, pipeline = {}) {
443
430
  const wiBranch = READ_ONLY_ROOT_TASK_TYPES.has(wiType)
444
431
  ? null
445
432
  : `pipeline/${run.pipelineId}/${stage.id}`;
446
- mutateWorkItems(wiPath, workItems => {
433
+ mutateWorkItems(wiScope, workItems => {
447
434
  if (workItems.some(w => w.id === id)) { createdIds.push(id); return workItems; }
448
435
  workItems.push({
449
436
  id,
@@ -574,12 +561,10 @@ const _VALID_EXISTING_PRD_STATUSES = new Set(Object.values(PLAN_STATUS));
574
561
  // permanently skip re-running plan-to-prd for a plan that never actually got
575
562
  // a usable PRD. Require both a recognized top-level status AND an array
576
563
  // `missing_features` field before counting the PRD as an existing conversion.
577
- function _findExistingPrdForPlan(planFile, prdDir) {
564
+ function _findExistingPrdForPlan(planFile) {
578
565
  const prdFiles = require('./prd-store').listPrdRows().filter(row => !row.archived);
579
566
  const planKey = _canonicalPlanName(planFile);
580
567
  for (const { filename: pf, plan: prd } of prdFiles) {
581
- // safeJsonNoRestore: PRDs are terminal artifacts — never restore archived
582
- // PRDs from a stale .backup sidecar (W-mouptdh1000h9f39).
583
568
  if (!prd?.source_plan || _canonicalPlanName(path.basename(String(prd.source_plan))) !== planKey) continue;
584
569
  if (!(_VALID_EXISTING_PRD_STATUSES.has(prd.status) || isPrdArchived(prd)) || !Array.isArray(prd.missing_features)) {
585
570
  log('warn', `Pipeline plan-to-prd: found PRD "${pf}" for plan "${planFile}" but it lacks a valid status/missing_features (garbage/incomplete conversion) — treating plan-to-prd as not yet done`);
@@ -623,8 +608,7 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
623
608
  log('info', `Pipeline ${run.pipelineId}: reconciling plan stage — adopting existing plan "${existingPlanFile}"`);
624
609
 
625
610
  // Check if a PRD already exists for this plan (skip plan-to-prd entirely)
626
- const prdDir = PRD_DIR;
627
- const existingPrdFile = _findExistingPrdForPlan(existingPlanFile, prdDir);
611
+ const existingPrdFile = _findExistingPrdForPlan(existingPlanFile);
628
612
  if (existingPrdFile) {
629
613
  log('info', `Pipeline ${run.pipelineId}: PRD "${existingPrdFile}" already exists for plan "${existingPlanFile}" — skipping plan-to-prd`);
630
614
  return {
@@ -636,10 +620,10 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
636
620
  // Adopt or create plan-to-prd WIs atomically under lock for each target project.
637
621
  const adoptedWiIds = [];
638
622
  for (const project of targetProjects) {
639
- const wiPath = _pipelineWorkItemsPath(project);
623
+ const wiScope = _pipelineWorkItemScope(project);
640
624
  const wiId = wiIdForProject(project);
641
625
  let adoptedWiId = wiId;
642
- mutateWorkItems(wiPath, workItems => {
626
+ mutateWorkItems(wiScope, workItems => {
643
627
  const existing = workItems.find(w => w.type === WORK_TYPE.PLAN_TO_PRD && w.planFile === existingPlanFile);
644
628
  if (existing) {
645
629
  existing._pipelineRun = run.runId;
@@ -744,9 +728,9 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
744
728
 
745
729
  const createdWiIds = [];
746
730
  for (const project of targetProjects) {
747
- const wiPath = _pipelineWorkItemsPath(project);
731
+ const wiScope = _pipelineWorkItemScope(project);
748
732
  const wiId = wiIdForProject(project);
749
- mutateWorkItems(wiPath, workItems => {
733
+ mutateWorkItems(wiScope, workItems => {
750
734
  if (!workItems.some(w => w.id === wiId)) {
751
735
  workItems.push({
752
736
  id: wiId,
@@ -986,7 +970,6 @@ function isStageComplete(stage, stageState, run, config) {
986
970
  if (!prdDone) return false;
987
971
 
988
972
  // Discover PRDs and their work items — collect into local arrays, then merge into artifacts
989
- const prdDir = PRD_DIR;
990
973
  const plans = artifacts.plans || [];
991
974
  const discoveredPrds = [];
992
975
  const discoveredWiIds = [];
@@ -1019,9 +1002,8 @@ function isStageComplete(stage, stageState, run, config) {
1019
1002
  // Auto-approve if configured
1020
1003
  if (stage.autoApprove && artifacts.prds?.length > 0) {
1021
1004
  for (const prdFile of artifacts.prds) {
1022
- const prdPath = path.join(prdDir, prdFile);
1023
1005
  let approved = false;
1024
- mutateJsonFileLocked(prdPath, (prd) => {
1006
+ require('./prd-store').mutatePrd(prdFile, (prd) => {
1025
1007
  if (prd && prd.status === PLAN_STATUS.AWAITING_APPROVAL) {
1026
1008
  prd.status = PLAN_STATUS.APPROVED;
1027
1009
  prd.approvedAt = ts();
@@ -1029,7 +1011,7 @@ function isStageComplete(stage, stageState, run, config) {
1029
1011
  approved = true;
1030
1012
  }
1031
1013
  return prd;
1032
- }, { skipWriteIfUnchanged: true });
1014
+ });
1033
1015
  if (approved) log('info', `Pipeline ${run.pipelineId}: auto-approved PRD ${prdFile}`);
1034
1016
  }
1035
1017
  }
@@ -1047,7 +1029,7 @@ function isStageComplete(stage, stageState, run, config) {
1047
1029
  if (prIds.length === 0) return true; // nothing to merge
1048
1030
  const projects = shared.getProjects(config);
1049
1031
  for (const project of projects) {
1050
- const prs = safeJsonArr(shared.projectPrPath(project));
1032
+ const prs = shared.readPullRequests(project);
1051
1033
  for (const prId of prIds) {
1052
1034
  const pr = shared.findPrRecord(prs, prId, project);
1053
1035
  if (pr && pr.status !== PR_STATUS.MERGED && pr.status !== PR_STATUS.ABANDONED) return false;
@@ -1077,7 +1077,7 @@ function buildSystemPrompt(agentId, config, project) {
1077
1077
  prompt += `2. ${getRepoHostToolRule(project)}\n`;
1078
1078
  prompt += `3. Follow the project conventions in CLAUDE.md if present\n`;
1079
1079
  prompt += `4. Write learnings to the path specified in the task prompt (format: \`notes/inbox/{agent}-{work-item-id}-{date}-{time}.md\`)\n`;
1080
- prompt += `5. Agent status is managed by the engine via dispatch.json — agents do not need to track their own status\n\n`;
1080
+ prompt += `5. Agent status is managed by the engine — agents do not need to track their own status\n\n`;
1081
1081
 
1082
1082
  // The lean system prompt (identity + rules) does not contain task-dispatch
1083
1083
  // sections (## Your Task, ## Tools, ## Constraints) by design — those live
@@ -45,9 +45,15 @@ class PooledAgentProcess extends EventEmitter {
45
45
  * @param {string} [opts.effort]
46
46
  * @param {Array} [opts.mcpServers]
47
47
  * @param {Array<string>} [opts.hermeticDirs]
48
- * @param {string} opts.promptText - fully-built prompt (runtime.buildPrompt output)
48
+ * @param {object} [opts.processEnv] - worker-process environment
49
+ * @param {object} [opts.sessionContext] - trusted values scoped to this lease
50
+ * @param {string} [opts.systemPromptText] - trusted system instructions
51
+ * @param {string} opts.promptText - task/user prompt only
49
52
  */
50
- constructor({ pool, dispatchId, cwd, model, effort, mcpServers, hermeticDirs, promptText }) {
53
+ constructor({
54
+ pool, dispatchId, cwd, model, effort, mcpServers, hermeticDirs,
55
+ processEnv, sessionContext, systemPromptText, promptText,
56
+ }) {
51
57
  super();
52
58
  this.pid = null;
53
59
  this.killed = false;
@@ -63,14 +69,21 @@ class PooledAgentProcess extends EventEmitter {
63
69
  // Fire-and-forget, mirroring child_process.spawn()'s async nature — the
64
70
  // facade is usable (event listeners can be attached) immediately, before
65
71
  // the underlying ACP worker has actually been acquired.
66
- this._start({ cwd, model, effort, mcpServers, hermeticDirs, promptText });
72
+ this._start({
73
+ cwd, model, effort, mcpServers, hermeticDirs,
74
+ processEnv, sessionContext, systemPromptText, promptText,
75
+ });
67
76
  }
68
77
 
69
- async _start({ cwd, model, effort, mcpServers, hermeticDirs, promptText }) {
78
+ async _start({
79
+ cwd, model, effort, mcpServers, hermeticDirs,
80
+ processEnv, sessionContext, systemPromptText, promptText,
81
+ }) {
70
82
  let handle;
71
83
  try {
72
84
  handle = await this._pool.acquireWorker({
73
85
  dispatchId: this._dispatchId, cwd, model, effort, mcpServers, hermeticDirs,
86
+ processEnv, sessionContext,
74
87
  });
75
88
  } catch (err) {
76
89
  // Mirrors child_process's 'error' event (spawn failed) — engine.js's
@@ -95,28 +108,35 @@ class PooledAgentProcess extends EventEmitter {
95
108
  this.emit('acquired');
96
109
 
97
110
  let sawError = false;
98
- await handle.stream(promptText, {
99
- onChunk: (text) => {
100
- if (!text) return;
101
- this._writeStdoutEvent({ type: 'assistant.message_delta', data: { deltaContent: text } });
102
- },
103
- onDone: (result) => {
104
- this._writeStdoutEvent({
105
- type: 'result',
106
- sessionId: handle.sessionId || null,
107
- usage: {
108
- totalApiDurationMs: Date.now() - this._startedAt,
109
- stopReason: (result && result.stopReason) || null,
110
- },
111
- });
112
- },
113
- onError: (err) => {
114
- sawError = true;
115
- try { this.stderr.write(String((err && err.message) || err) + '\n'); } catch { /* best-effort */ }
116
- },
117
- });
118
-
119
- try { handle.release(); } catch { /* best-effort */ }
111
+ try {
112
+ await handle.stream(promptText, {
113
+ systemPromptText,
114
+ onChunk: (text) => {
115
+ if (!text) return;
116
+ this._writeStdoutEvent({ type: 'assistant.message_delta', data: { deltaContent: text } });
117
+ },
118
+ onDone: (result) => {
119
+ this._writeStdoutEvent({
120
+ type: 'result',
121
+ sessionId: handle.sessionId || null,
122
+ usage: {
123
+ totalApiDurationMs: Date.now() - this._startedAt,
124
+ stopReason: (result && result.stopReason) || null,
125
+ },
126
+ });
127
+ },
128
+ onError: (err) => {
129
+ sawError = true;
130
+ try { this.stderr.write(String((err && err.message) || err) + '\n'); } catch { /* best-effort */ }
131
+ },
132
+ });
133
+ } catch (err) {
134
+ sawError = true;
135
+ try { this.stderr.write(String((err && err.message) || err) + '\n'); } catch { /* best-effort */ }
136
+ } finally {
137
+ try { handle.release(); } catch { /* best-effort */ }
138
+ this._handle = null;
139
+ }
120
140
  this._finish(sawError ? 1 : 0);
121
141
  }
122
142