@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
@@ -33,7 +33,7 @@ Watches, etc. It is the primary human-to-fleet interface.
33
33
 
34
34
  **Where it lives.**
35
35
  - System prompt: `prompts/cc-system.md`
36
- - Per-tab session state: `engine/cc-sessions.json` (also legacy `engine/cc-session.json`).
36
+ - Per-tab and global session state: `engine/state.db` (`cc_sessions`).
37
37
  - Direct CLI invocation: `engine/llm.js` `callLLM({ direct: true })` — bypasses
38
38
  `spawn-agent.js` and runs the runtime CLI itself.
39
39
 
@@ -70,10 +70,8 @@ Plan, Note, Schedule, Watch, Knowledge entry, Pinned note). Per-call cap is
70
70
  the home page eventually reduce to a Work Item.
71
71
 
72
72
  **Where it lives.**
73
- - Central queue: `work-items.json` (root).
74
- - Per-project queue: `projects/<name>/work-items.json` (`projectWorkItemsPath`,
75
- `engine/shared.js` line ~1530).
76
- - In-flight copy: `engine/dispatch.json` (`pending[] / active[] / completed[]`).
73
+ - Durable records: `engine/state.db` `work_items`, keyed by central/project scope.
74
+ - In-flight dispatch rows: `engine/state.db` `dispatches`.
77
75
 
78
76
  **Backing classes / functions.**
79
77
  - Status constants: `engine/shared.js` line 1348 (`WI_STATUS`).
@@ -327,7 +325,7 @@ engine drops a Work Item into the queue.
327
325
 
328
326
  **Where it lives.**
329
327
  - Definitions: `config.json` `schedules: []` (3-field cron `min hour dow`).
330
- - Last-run history: `engine/schedule-runs.json`.
328
+ - Last-run history: `engine/state.db` `schedule_runs`.
331
329
 
332
330
  **Backing classes / functions.**
333
331
  - Cron parser: `engine/scheduler.js`
@@ -361,7 +359,7 @@ analogous to **Watch** (event-driven instead of time-driven).
361
359
  (merged, build passes, build fails, status changes, new comments, vote changes). Watches are
362
360
  event-driven, schedules are time-driven.
363
361
 
364
- **Where it lives.** `engine/watches.json` (`engine/watches.js` line 17 `_watchesPath`).
362
+ **Where it lives.** `engine/state.db` `watches`.
365
363
 
366
364
  **Backing classes / functions.**
367
365
  - Status constants: `engine/shared.js` line 1380 (`WATCH_STATUS`).
@@ -389,11 +387,11 @@ A merged-PR watch is the canonical "tell me when X ships" primitive.
389
387
 
390
388
  **What it is.** A multi-stage workflow. Stages can be a Work Item (`task`), a `meeting`, or a
391
389
  `plan`. Stages can declare dependencies on previous stages. Pipelines run via triggers (manual,
392
- schedule, watch) and persist their state in `pipeline-runs.json`.
390
+ schedule, watch) and persist run state in SQLite.
393
391
 
394
392
  **Where it lives.**
395
393
  - Definitions: `pipelines/*.json` (one file per pipeline).
396
- - Run state: `engine/pipeline-runs.json`.
394
+ - Run state: `engine/state.db` `pipeline_runs`.
397
395
 
398
396
  **Backing classes / functions.**
399
397
  - Dirs: `engine/pipeline.js` lines 19–24 (`PIPELINES_DIR`, `PIPELINE_RUNS_PATH`, `PLANS_DIR`,
@@ -427,7 +425,7 @@ unrelated to ADO/GitHub Actions pipelines, even though the word collides.
427
425
  dispatch holds the agent's PID, prompt sidecar path, worktree path, and current status until the
428
426
  process exits.
429
427
 
430
- **Where it lives.** Inside `engine/dispatch.json`'s `active[]` array.
428
+ **Where it lives.** Active rows in `engine/state.db` `dispatches`.
431
429
 
432
430
  **Backing classes / functions.**
433
431
  - `mutateDispatch`: `engine/dispatch.js` line 60 (the only safe RMW path).
@@ -450,7 +448,7 @@ tick. Read shape via `/api/status` (line 6838).
450
448
 
451
449
  ## 13. Dispatch Queue
452
450
 
453
- **What it is.** The three-bucket structure inside `engine/dispatch.json`:
451
+ **What it is.** The sectioned view reconstructed from SQL dispatch rows:
454
452
 
455
453
  - `pending[]` — admitted Work Items waiting on agent / dependency / cooldown / budget.
456
454
  - `active[]` — currently spawned.
@@ -460,7 +458,7 @@ The queue's invariants are: max concurrent (default 5), per-PR / per-branch lock
460
458
  agents fighting on the same branch, dependency-aware spawning (`depends_on` IDs must be done
461
459
  first), retry on failure up to `ENGINE_DEFAULTS.maxRetries`.
462
460
 
463
- **Where it lives.** `engine/dispatch.json`.
461
+ **Where it lives.** `engine/state.db` `dispatches`.
464
462
 
465
463
  **Backing classes / functions.** All in `engine/dispatch.js`. Mutation only via `mutateDispatch`
466
464
  line 60. Queue dedupe keys: `getDispatchProjectKey` line 80, `getPrDispatchTargetKey` line 85,
@@ -502,12 +500,11 @@ record). It also feeds **Pull Request** sync (`pr` field).
502
500
 
503
501
  ## 15. Pull Request (as tracked in minions)
504
502
 
505
- **What it is.** A row in `projects/<name>/pull-requests.json`. Tracks status, vote tally, build
503
+ **What it is.** A project-scoped row in the SQL PR store. Tracks status, vote tally, build
506
504
  state, review verdict, and the work item that created it. *Not* the GitHub/ADO PR itself —
507
505
  minions polls the host and writes a sanitized snapshot here.
508
506
 
509
- **Where it lives.** `projects/<name>/pull-requests.json` (`projectPrPath`, `engine/shared.js`
510
- line 1546). Cross-project links also tracked in `engine/pr-links.json`.
507
+ **Where it lives.** `engine/state.db` `pull_requests`; cross-project links use `pr_links`.
511
508
 
512
509
  **Backing classes / functions.**
513
510
  - Status / pollable constants: `engine/shared.js` line 1372 (`PR_STATUS`,
@@ -541,7 +538,7 @@ Examples: Ripley (architect / explorer), Dallas (engineer), Lambert (analyst), R
541
538
  - Default roster: `engine/shared.js` line 1486 (`DEFAULT_AGENTS`).
542
539
  - Charter: `agents/<id>/charter.md` — read by `getAgentCharter` (`engine/queries.js` line 505).
543
540
  - Per-agent config (cli, model, budget, skills): `config.json` `agents.<id>`.
544
- - Metrics: `engine/metrics.json` (per-agent token / cost / quality / runtime).
541
+ - Metrics: `engine/state.db` `metrics` (per-agent token / cost / quality / runtime).
545
542
 
546
543
  **Backing classes / functions.**
547
544
  - Charter loader: `engine/queries.js` line 505.
@@ -569,14 +566,14 @@ gets which work type.
569
566
 
570
567
  **Where it lives.**
571
568
  - Definitions: `config.json` `projects: []`.
572
- - State: `projects/<name>/{work-items.json, pull-requests.json}`.
569
+ - Runtime records: project-scoped rows in `engine/state.db`.
573
570
  - Removal artifact: `projects/.archived/<name>-YYYYMMDD/`.
574
571
 
575
572
  **Backing classes / functions.**
576
573
  - Helpers: `engine/shared.js`
577
- - `getProjects` (~line 1502)
578
- - `projectStateDir`, `projectWorkItemsPath` (~line 1530)
579
- - `projectPrPath` (line 1546).
574
+ - `getProjects`
575
+ - `stateScope`, `readWorkItems`, `readPullRequests`
576
+ - `projectStateDir` for file-backed project definitions/artifacts.
580
577
  - Removal: `engine/projects.js` `removeProject` (canonical teardown — never edit `config.json`
581
578
  directly).
582
579
 
@@ -658,13 +655,14 @@ consolidates the **Inbox**, and so on. It's also the gatekeeper for control stat
658
655
 
659
656
  **Where it lives.**
660
657
  - Process: `engine.js` (root).
661
- - State: `engine/control.json` (mode), `engine/log.json` (audit ring buffer, max 2500 trimmed
662
- to 2000), `engine/metrics.json` (counters), `engine/dispatch.json` (queue).
658
+ - State: `engine/state.db` (logs, metrics, queues, watches, schedules, PRDs) plus
659
+ `engine/control.json` for process coordination.
663
660
 
664
661
  **Backing classes / functions.**
665
662
  - Tick: `engine.js` (top-level `tick()`).
666
663
  - Defaults: `engine/shared.js` line 860 (`ENGINE_DEFAULTS`).
667
- - Concurrency caps: `mutateJsonFileLocked` for all shared-JSON RMW.
664
+ - Concurrency: SQLite transactions for runtime records; file locks only for intentionally
665
+ file-backed definitions and coordination sidecars.
668
666
  - Restart re-attach: PID files in `engine/tmp/pid-<id>.pid` + `live-output.log` mtimes,
669
667
  20-min grace.
670
668
 
@@ -839,6 +837,6 @@ Pipeline (stages: task / meeting / plan) ──► spawns Work Items / Meetings
839
837
  Schedule (cron) ──► spawns Work Items
840
838
  Watch (event) ──► spawns notifications / Work Items / pipeline-continuations
841
839
 
842
- Engine reads: routing.md, config.json, all state JSON
843
- Engine writes: control.json, log.json, metrics.json, dispatch.json, project state files
840
+ Engine reads: routing.md, config.json, definitions, engine/state.db
841
+ Engine writes: control.json, artifacts, engine/state.db
844
842
  ```
package/docs/watches.md CHANGED
@@ -4,7 +4,7 @@ Persistent monitoring jobs that fire inbox notifications and follow-up actions w
4
4
 
5
5
  ## What a Watch Is
6
6
 
7
- A watch is a record persisted in `engine/state.db`; `engine/watches.json` is a passive diagnostic mirror. It binds:
7
+ A watch is a record persisted in `engine/state.db`. It binds:
8
8
 
9
9
  | Field | Purpose |
10
10
  |--------------|-------------------------------------------------------------------------|
@@ -130,7 +130,7 @@ module.exports = {
130
130
 
131
131
  Resolution is `path.join(shared.MINIONS_DIR, 'watches.d')` so it works in both dev checkouts and installed `~/.minions/` layouts. Missing folder is silent; per-file failures are isolated (load errors log a `WARN` line, don't abort boot). Tests can re-scan via the exported `_loadPluginTargetTypes()`.
132
132
 
133
- **Canonical plugin example:** [`watches.d/http.js`](../watches.d/http.js) (W-mp7i22mu00191b07) ships a generic `http` target type — point a watch at any URL or `{ url, method, headers, body, extract, expected, regex, timeoutMs }` config and the plugin exposes `status-change`, `value-change`, `value-equals`, `value-matches`, and `http-error` conditions. Auth headers support `{{env.GH_TOKEN}}`-style substitution at fetch time (secrets are never stored in `watches.json`). All conditions are change-based (`absoluteConditions: []`).
133
+ **Canonical plugin example:** [`watches.d/http.js`](../watches.d/http.js) (W-mp7i22mu00191b07) ships a generic `http` target type — point a watch at any URL or `{ url, method, headers, body, extract, expected, regex, timeoutMs }` config and the plugin exposes `status-change`, `value-change`, `value-equals`, `value-matches`, and `http-error` conditions. Auth headers support `{{env.GH_TOKEN}}`-style substitution at fetch time (secrets are never persisted in watch records). All conditions are change-based (`absoluteConditions: []`).
134
134
 
135
135
  ## Tick Integration
136
136
 
@@ -222,10 +222,10 @@ CC will discover the endpoints from `GET /api/routes` and `POST /api/watches` wi
222
222
 
223
223
  When a watch fires:
224
224
 
225
- - `engine/watches.json` entry gets `triggerCount++`, `last_triggered=<ISO>`, `_lastTriggerMessage=<message>`, and a refreshed `_lastState`.
225
+ - The SQL watch record gets `triggerCount++`, `last_triggered=<ISO>`, `_lastTriggerMessage=<message>`, and a refreshed `_lastState`.
226
226
  - An inbox file lands at `notes/inbox/<owner>/watch-<watchId>-<n>.md` (when `notify === 'inbox'`).
227
227
  - If `action` is set, a follow-up runs (work item, webhook POST, plan archive, ...) and writes `_lastActionResult` (`{ type, ok, summary, dispatchedItemId?, at }`) back onto the watch.
228
- - `engine.log.json` gets `Watch triggered: <id> — <message>` and `Watch <id> action <type>: <summary>`.
228
+ - The SQL engine log gets `Watch triggered: <id> — <message>` and `Watch <id> action <type>: <summary>`.
229
229
 
230
230
  Absolute conditions firing under `stopAfter === 0` flip `status` to `expired`; `stopAfter > 0` flips it to `expired` once `triggerCount >= stopAfter`.
231
231
 
@@ -236,14 +236,14 @@ Absolute conditions firing under `stopAfter === 0` flip `status` to `expired`; `
236
236
  | Watch never fires | Check `status === 'active'`; check `last_checked` advancing each cycle; confirm engine tick is running and `interval` isn't longer than your test window |
237
237
  | `evaluateWatch` returns `"<label> <target> not found"` | `fetchEntity` got nothing back — wrong `target` (e.g. PR display id vs canonical id), the target type isn't loaded, or the underlying file (PR cache, plan PRD) doesn't exist |
238
238
  | `"Unknown target type"` / `"Unknown condition"` | The registry doesn't recognise the value. Check `GET /api/watches/target-types` to see what's registered server-side; condition must be in that target type's `conditions[]` |
239
- | Change condition fires immediately on first tick | Won't happen — baseline `_lastState` is captured on the first check before `evaluate` runs *(source: `engine/watches.js:434`)*. If you see this, suspect manual edits to `watches.json` |
239
+ | Change condition fires immediately on first tick | Won't happen — baseline `_lastState` is captured on the first check before `evaluate` runs *(source: `engine/watches.js`)*. If you see this, inspect the persisted watch through the API |
240
240
  | Absolute watch fires forever instead of once | `stopAfter` is set to a non-zero value; only `stopAfter === 0` triggers fire-once expiration |
241
241
  | Action runs but inbox notification doesn't | `notify` field isn't `'inbox'`, or `owner` is empty. `notify` and `action` are independent — both can fire, or only one |
242
242
  | `_lastActionResult.ok === false` with `"unknown action type"` | The `action.type` isn't registered. List with `listActionTypes()` / `GET /api/watches/action-types` |
243
243
  | Webhook action returns `"only http/https allowed"` | URLs must use `http://` or `https://` schemes; other protocols are rejected by design *(source: `engine/watch-actions.js` `WEBHOOK` handler)* |
244
- | Trigger fires but follow-up `dispatch-work-item` is missing | Check the engine log for `Watch <id> action <type>: <summary>`. Common reasons: missing `title`, the project's `work-items.json` couldn't be written, or the WI landed in central `work-items.json` because no project was specified |
244
+ | Trigger fires but follow-up `dispatch-work-item` is missing | Check the engine log for `Watch <id> action <type>: <summary>`. Common reasons: missing `title`, a project-scope write failed, or the WI landed in the central scope because no project was specified |
245
245
  | Watch `_lastActionResult` shows `"timeout"` for webhook | Webhooks have a 10s safety timeout to keep the watches tick fast *(source: `engine/watch-actions.js:482-484`)* |
246
- | `checkWatches` block crashes silently | Wrapped in `safe('checkWatches', ...)` so one failure doesn't abort the tick *(source: `engine.js:7443`)*. Inspect `engine/log.json` for `Watch check error (<id>)` lines. Regression #1088: the block must use `getProjects(config)`, never the long-removed `PROJECTS` constant |
246
+ | `checkWatches` block crashes silently | Wrapped in `safe('checkWatches', ...)` so one failure doesn't abort the tick. Inspect the engine log for `Watch check error (<id>)` lines. Regression #1088: the block must use `getProjects(config)`, never the long-removed `PROJECTS` constant |
247
247
 
248
248
  ## See Also
249
249
 
@@ -84,7 +84,7 @@ const { MEMORY_SCOPES, WORKSPACE_MANIFEST_DEFAULTS,
84
84
 
85
85
  ## Audit trail
86
86
 
87
- Because rejections route through the standard `completeDispatch(... ERROR ...)` path, the existing dispatch + inbox + dashboard timeline machinery already surfaces every manifest rejection. No new state file or event topic was added. Per-agent audit of what each minion actually touched can be derived from existing `engine/dispatch.json` records filtered by `failure_class === 'workspace-manifest-repo-forbidden'`.
87
+ Because rejections route through the standard `completeDispatch(... ERROR ...)` path, the existing dispatch + inbox + dashboard timeline machinery already surfaces every manifest rejection. No new table or event topic was added. Per-agent audit can be derived from SQL dispatch records filtered by `failure_class === 'workspace-manifest-repo-forbidden'`.
88
88
 
89
89
  ## Rollout guidance
90
90
 
@@ -45,7 +45,7 @@ recycle worktree dirs across branches.
45
45
  - **Return** in `onAgentClose` BEFORE `completeDispatch`:
46
46
  `git reset --hard HEAD` → `git clean -fd` → `git fetch origin <main>`
47
47
  → `git checkout --detach origin/<main>` → mark IDLE.
48
- - **State** at `engine/worktree-pool.json`; git ops outside any lock.
48
+ - **State** in SQLite (`worktree_pool`); git ops outside the transaction.
49
49
 
50
50
  ## Ownership marker is never "dirty" (#284)
51
51
 
@@ -20,7 +20,7 @@
20
20
  * The version-gated one-shot model gives us exactly what we need: re-probe
21
21
  * once per release that bumps `ENGINE_DEFAULTS.abandonedReconciliationVersion`,
22
22
  * then never again until the next bump. First boot after this lands cleans up
23
- * the historical false-flips; subsequent boots are a single state.json read +
23
+ * the historical false-flips; subsequent boots are a single state-marker read +
24
24
  * version compare → no-op.
25
25
  *
26
26
  * ## Why a separate WI from W-mp5trwh60008386d?
@@ -37,14 +37,13 @@
37
37
  * Errors here must NOT block boot — the catch in cli.js logs and continues.
38
38
  */
39
39
 
40
- const path = require('path');
41
40
  const shared = require('./shared');
42
41
 
43
42
  const { ENGINE_DEFAULTS, log, mutateEngineState, readEngineState } = shared;
44
43
 
45
44
  /**
46
45
  * Run the version-gated startup reconciliation pass. Idempotent across reruns
47
- * at the same code version: state.json gates re-execution.
46
+ * at the same code version: persisted engine state gates re-execution.
48
47
  *
49
48
  * @param {object} config — engine config (used to enumerate projects)
50
49
  * @param {object} [options]
@@ -82,7 +81,7 @@ async function runStartupReconciliation(config, options = {}) {
82
81
  const projects = shared.getProjects(config);
83
82
  projectCount = projects.length;
84
83
  for (const p of projects) {
85
- const prs = shared.safeJsonArr(shared.projectPrPath(p));
84
+ const prs = shared.readPullRequests(p);
86
85
  preCount += prs.filter(pr =>
87
86
  pr.status === shared.PR_STATUS.ABANDONED && !pr._reconciliation404Confirmed
88
87
  ).length;
@@ -128,7 +127,7 @@ async function runStartupReconciliation(config, options = {}) {
128
127
  return state;
129
128
  });
130
129
  } catch (err) {
131
- // State.json write failure is unfortunate but non-fatal: next boot will
130
+ // Marker persistence failure is non-fatal: next boot will
132
131
  // re-run the pass, which is idempotent (already-flipped PRs aren't
133
132
  // abandoned anymore, confirmed-404s carry the marker). Log and continue.
134
133
  log('warn', `Abandoned PR reconciliation: failed to persist version marker: ${err.message}`);
@@ -97,9 +97,10 @@ const WARM_MAX_CONCURRENT = 3;
97
97
  // W-mqid8uev). resolveBinary() resolves the real JS entry
98
98
  // (node_modules/@github/copilot/index.js) and reports whether to spawn it
99
99
  // directly (native) or under the current Node process (shim).
100
- function spawnAcp({ cwd } = {}) {
100
+ function spawnAcp({ cwd, env } = {}) {
101
101
  const copilot = require('./runtimes/copilot');
102
- const resolved = copilot.resolveBinary({ env: process.env });
102
+ const workerEnv = env || process.env;
103
+ const resolved = copilot.resolveBinary({ env: workerEnv });
103
104
  if (!resolved || !resolved.bin) {
104
105
  throw new Error(
105
106
  `copilot binary not resolvable -- ${copilot.installHint || 'install the GitHub Copilot CLI'}`
@@ -114,6 +115,7 @@ function spawnAcp({ cwd } = {}) {
114
115
  return spawn(execBin, execArgs, {
115
116
  stdio: ['pipe', 'pipe', 'pipe'],
116
117
  cwd: cwd || process.cwd(),
118
+ env: workerEnv,
117
119
  windowsHide: true,
118
120
  // Don't pass shell:true — the native/leadingArgs handling above already
119
121
  // produces a directly-spawnable argv, and a shell wrapper would swallow
@@ -139,6 +141,33 @@ function hashMcpServers(mcpServers) {
139
141
  return crypto.createHash('sha256').update(json).digest('hex');
140
142
  }
141
143
 
144
+ function _stableObject(value) {
145
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
146
+ return Object.fromEntries(
147
+ Object.entries(value)
148
+ .filter(([, entry]) => entry !== undefined)
149
+ .sort(([a], [b]) => a.localeCompare(b))
150
+ );
151
+ }
152
+
153
+ function hashProcessContext(processEnv) {
154
+ return crypto.createHash('sha256')
155
+ .update(JSON.stringify(_stableObject(processEnv)))
156
+ .digest('hex');
157
+ }
158
+
159
+ function buildTrustedSessionContext(sessionContext) {
160
+ const context = _stableObject(sessionContext);
161
+ if (Object.keys(context).length === 0) return '';
162
+ const json = JSON.stringify(context, null, 2).replace(/<\/system/gi, '<\\/system');
163
+ return [
164
+ 'Trusted pooled-dispatch context (session-scoped):',
165
+ 'These values are the authoritative equivalents of the same-named environment variables for this ACP session.',
166
+ 'Use them directly; do not try to recover them from the reusable worker process environment.',
167
+ json,
168
+ ].join('\n');
169
+ }
170
+
142
171
  // Bug B (issue #2479): `model` and `effort` were stored on the Worker but
143
172
  // never forwarded to ACP `session/new`, so any model override was silently
144
173
  // dropped. Build the params object here and only include fields that are
@@ -216,12 +245,17 @@ function mapAcpToolCallToToolUse(update) {
216
245
  * logger (default no-op); consumers wire their own env-gated trace prefix.
217
246
  */
218
247
  class Worker {
219
- constructor({ id, model, effort, mcpServers, mcpServersHash, systemPromptHash, cwd, internals, trace }) {
248
+ constructor({
249
+ id, model, effort, mcpServers, mcpServersHash, processEnv, processContextHash,
250
+ systemPromptHash, cwd, internals, trace,
251
+ }) {
220
252
  this.id = id;
221
253
  this.model = model;
222
254
  this.effort = effort;
223
255
  this.mcpServers = mcpServers || [];
224
256
  this.mcpServersHash = mcpServersHash;
257
+ this.processEnv = processEnv;
258
+ this.processContextHash = processContextHash;
225
259
  this.systemPromptHash = systemPromptHash;
226
260
  this.cwd = cwd || process.cwd();
227
261
  this._internals = internals;
@@ -249,7 +283,7 @@ class Worker {
249
283
  async _spawnAndInit() {
250
284
  let proc;
251
285
  try {
252
- proc = this._internals.spawnAcp({ cwd: this.cwd });
286
+ proc = this._internals.spawnAcp({ cwd: this.cwd, env: this.processEnv });
253
287
  } catch (err) {
254
288
  // spawn() threw synchronously — typically ENOENT (copilot binary not
255
289
  // on PATH) or EACCES. Surface as worker-spawn-failed so the consumer
@@ -440,7 +474,10 @@ class Worker {
440
474
 
441
475
  // ── Stream a single turn ───────────────────────────────────────────────
442
476
  stream(promptText, opts = {}) {
443
- const { onChunk, onToolUse, onToolUpdate, onDone, onError, signal, systemPromptText } = opts;
477
+ const {
478
+ onChunk, onToolUse, onToolUpdate, onDone, onError, signal,
479
+ systemPromptText, trustedSessionContext, deferSystemPrompt,
480
+ } = opts;
444
481
  if (this.killed) {
445
482
  const err = new Error('acp-transport: worker is closed');
446
483
  if (onError) try { onError(err); } catch { /* swallow */ }
@@ -458,10 +495,12 @@ class Worker {
458
495
  // adapter pattern of <system>...</system> prepended to the first user
459
496
  // message remains correct.
460
497
  let prompt = promptText;
461
- if (!this.firstSystemPromptSent && systemPromptText) {
462
- prompt = `<system>\n${systemPromptText}\n</system>\n\n${promptText}`;
498
+ const trustedContextText = buildTrustedSessionContext(trustedSessionContext);
499
+ const trustedSystemText = [systemPromptText, trustedContextText].filter(Boolean).join('\n\n');
500
+ if (!this.firstSystemPromptSent && trustedSystemText) {
501
+ prompt = `<system>\n${trustedSystemText}\n</system>\n\n${promptText}`;
463
502
  }
464
- this.firstSystemPromptSent = true;
503
+ if (!deferSystemPrompt) this.firstSystemPromptSent = true;
465
504
 
466
505
  const id = this.nextReqId++;
467
506
  const inflight = {
@@ -586,6 +625,8 @@ module.exports = {
586
625
  spawnAcp,
587
626
  killImmediate,
588
627
  hashMcpServers,
628
+ hashProcessContext,
629
+ buildTrustedSessionContext,
589
630
  buildSessionNewParams,
590
631
  extractChunkText,
591
632
  mapAcpToolCallToToolUse,
@@ -14,7 +14,7 @@
14
14
  *
15
15
  * Output: JSON to stdout. Exit 0 on success, 1 on error/not-found.
16
16
  *
17
- * Cached (default): reads pull-requests.json maintained by the engine (~3 min stale).
17
+ * Cached (default): reads engine-maintained SQL state (~3 min stale).
18
18
  * Live (--live): makes fresh ADO API calls — use when engine isn't running or you
19
19
  * just pushed and need up-to-the-moment build status.
20
20
  */
@@ -23,7 +23,7 @@
23
23
 
24
24
  const path = require('path');
25
25
  const shared = require('./shared');
26
- const { safeJson, safeJsonArr, projectPrPath, getProjects, MINIONS_DIR } = shared;
26
+ const { safeJson, readPullRequests, getProjects, MINIONS_DIR } = shared;
27
27
 
28
28
  // ── Arg parsing ──────────────────────────────────────────────────────────────
29
29
 
@@ -56,7 +56,7 @@ const prNumber = parseInt(prNumberArg, 10);
56
56
 
57
57
  function findInCache(projects) {
58
58
  for (const project of projects) {
59
- const prs = safeJsonArr(projectPrPath(project));
59
+ const prs = readPullRequests(project);
60
60
  shared.normalizePrRecords(prs, project);
61
61
  const pr = prs.find(p => p.prNumber === prNumber || p.id === shared.getCanonicalPrId(project, prNumber) || shared.getPrDisplayId(p) === `PR-${prNumber}`);
62
62
  if (!pr) continue;
@@ -104,7 +104,7 @@ async function main() {
104
104
  if (!live) {
105
105
  const result = findInCache(projects);
106
106
  if (result) { console.log(JSON.stringify(result, null, 2)); return; }
107
- console.error(`PR #${prNumber} not found in cached pull-requests.json`);
107
+ console.error(`PR #${prNumber} not found in the cached PR store`);
108
108
  console.error('Tip: try --live for a fresh ADO API call, or check the PR number is correct.');
109
109
  process.exit(1);
110
110
  }
@@ -133,7 +133,7 @@ async function main() {
133
133
  return;
134
134
  }
135
135
 
136
- console.error(`PR #${prNumber} not found (tried live ADO API and cached pull-requests.json).`);
136
+ console.error(`PR #${prNumber} not found (tried live ADO API and the cached PR store).`);
137
137
  process.exit(1);
138
138
  }
139
139
 
package/engine/ado.js CHANGED
@@ -279,7 +279,7 @@ function repairAdoProjectConfig(project, purpose, prs = null) {
279
279
  const missingBefore = getMissingAdoProjectConfigFields(project);
280
280
  if (missingBefore.length === 0) return false;
281
281
 
282
- const trackedPrs = prs || shared.safeJsonArr(shared.projectPrPath(project));
282
+ const trackedPrs = prs || shared.readPullRequests(project);
283
283
  for (const candidate of collectAdoRepairCandidates(project, trackedPrs)) {
284
284
  const repairs = {};
285
285
  if (!project.adoOrg && candidate.adoOrg) repairs.adoOrg = candidate.adoOrg;
@@ -1199,7 +1199,7 @@ async function forEachActivePr(config, token, callback) {
1199
1199
  }
1200
1200
 
1201
1201
  if (projectUpdated > 0) {
1202
- mutateJsonFileLocked(shared.projectPrPath(project), (currentPrs) => {
1202
+ shared.mutatePullRequests(project, (currentPrs) => {
1203
1203
  // Merge back only fields changed by callbacks; preserve concurrent disk updates.
1204
1204
  for (const { before, after } of updatedRecords) {
1205
1205
  const updatedPrNumber = shared.getPrNumber(after);
@@ -1244,9 +1244,9 @@ async function forEachActivePr(config, token, callback) {
1244
1244
  }
1245
1245
  }
1246
1246
 
1247
- // BUG-C2 — Also poll manually-linked ADO PRs from central pull-requests.json.
1247
+ // BUG-C2 — Also poll manually linked ADO PRs from the central PR store.
1248
1248
  // Mirror of engine/github.js:540-633. Without this block, any ADO PR enrolled
1249
- // into central pull-requests.json via the dashboard attach UI or
1249
+ // into the central PR store via the dashboard attach UI or
1250
1250
  // lifecycle.enrollPrFromCanonicalId is never polled (status, build, review,
1251
1251
  // comments, abandoned-reconciliation all freeze permanently).
1252
1252
  //
@@ -1272,8 +1272,7 @@ async function forEachActivePr(config, token, callback) {
1272
1272
  // in that project's per-project file, so the project loop will really see
1273
1273
  // them. Blindly skipping all project-compatible IDs caused a permanent
1274
1274
  // orphan when the record lived in the central file (#328).
1275
- const centralPath = path.join(shared.MINIONS_DIR, 'pull-requests.json');
1276
- const centralPrs = shared.safeJsonArr(centralPath);
1275
+ const centralPrs = shared.readPullRequests('central');
1277
1276
  const configuredAdoProjects = projects.filter(p => !isGitHubProject(p) && p.adoOrg && p.adoProject);
1278
1277
 
1279
1278
  const activeCentral = centralPrs.filter(pr => {
@@ -1287,7 +1286,7 @@ async function forEachActivePr(config, token, callback) {
1287
1286
  // polled (fix for #328).
1288
1287
  const owningProject = configuredAdoProjects.find(p => shared.isPrCompatibleWithProject(p, { id: pr.id }, ''));
1289
1288
  if (owningProject) {
1290
- const projectPrs = shared.safeJsonArr(shared.projectPrPath(owningProject));
1289
+ const projectPrs = shared.readPullRequests(owningProject);
1291
1290
  if (projectPrs.some(p => p.id === pr.id)) return false; // present in per-project file → project loop handles it
1292
1291
  }
1293
1292
  return true;
@@ -1353,7 +1352,7 @@ async function forEachActivePr(config, token, callback) {
1353
1352
  }
1354
1353
 
1355
1354
  if (centralUpdated > 0) {
1356
- mutateJsonFileLocked(centralPath, (currentPrs) => {
1355
+ shared.mutatePullRequests('central', (currentPrs) => {
1357
1356
  for (const { before, after } of updatedCentralRecords) {
1358
1357
  const idx = currentPrs.findIndex(p => p.id === after.id);
1359
1358
  if (idx >= 0) {
@@ -1457,7 +1456,7 @@ async function pollPrStatus(config) {
1457
1456
  }
1458
1457
  // F6 (P-f6commentedit): drop the comment-edit dedup map on merge/abandon
1459
1458
  // — no further re-dispatch path can fire from this PR, so keeping it
1460
- // around would only bloat pull-requests.json.
1459
+ // around would only bloat the PR store.
1461
1460
  if (pr.humanFeedback && pr.humanFeedback.editsSeen) {
1462
1461
  delete pr.humanFeedback.editsSeen;
1463
1462
  }
@@ -1911,7 +1910,7 @@ async function pollPrStatus(config) {
1911
1910
  // review status flipped, etc.) is dropped via clearPrNoOpFixAttempt. The
1912
1911
  // dispatch gate already ignores these stale records (isPrNoOpFixCausePaused
1913
1912
  // re-validates the fingerprint), but without this sweep they linger in
1914
- // pull-requests.json forever — bloating the file and confusing the
1913
+ // PR store forever — bloating the registry and confusing the
1915
1914
  // dashboard `pr-paused-chip`. Runs AFTER all live-field updates so the
1916
1915
  // fingerprint comparison uses post-poll PR state.
1917
1916
  try {
@@ -2160,7 +2159,7 @@ async function pollPrHumanComments(config) {
2160
2159
 
2161
2160
  /**
2162
2161
  * Reconcile PRs: find active ADO PRs created by the minions that aren't tracked
2163
- * in pull-requests.json, and add them. Matches PRs to work items by branch name.
2162
+ * in the PR store, and add them. Matches PRs to work items by branch name.
2164
2163
  */
2165
2164
  async function reconcilePrs(config) {
2166
2165
  // #3758 — inert when no project actually uses ADO.
@@ -2207,18 +2206,15 @@ async function reconcilePrs(config) {
2207
2206
 
2208
2207
  if (adoPrs.length === 0) continue;
2209
2208
 
2210
- const prPath = shared.projectPrPath(project);
2211
- const existingPrs = shared.safeJsonArr(prPath);
2209
+ const existingPrs = shared.readPullRequests(project);
2212
2210
  shared.normalizePrRecords(existingPrs, project);
2213
2211
  const existingIds = new Set(existingPrs.map(p => p.id));
2214
2212
  let projectAdded = 0;
2215
2213
  let metadataUpdated = 0;
2216
2214
 
2217
2215
  // Load work items to match branches to work item IDs
2218
- const wiPath = shared.projectWorkItemsPath(project);
2219
- const workItems = shared.safeJsonArr(wiPath);
2220
- const centralWiPath = path.join(shared.MINIONS_DIR, 'work-items.json');
2221
- const centralItems = shared.safeJsonArr(centralWiPath);
2216
+ const workItems = shared.readWorkItems(project);
2217
+ const centralItems = shared.readWorkItems('central');
2222
2218
  const allItems = [...workItems, ...centralItems];
2223
2219
 
2224
2220
  let projectUpdated = 0;
@@ -2251,9 +2247,9 @@ async function reconcilePrs(config) {
2251
2247
  log('warn', `ADO: invalid sourceRefName "${branch.slice(0, 64)}" for PR ${prId}: ${refErr.message}`);
2252
2248
  }
2253
2249
  }
2254
- // PR already tracked — write link to pr-links.json if we can extract an ID
2250
+ // PR already tracked — write a PR link if we can extract an ID
2255
2251
  if (confirmedItemId) {
2256
- shared.upsertPullRequestRecord(prPath, existing || {
2252
+ shared.upsertPullRequestRecord(project, existing || {
2257
2253
  id: prId,
2258
2254
  prNumber: adoPr.pullRequestId,
2259
2255
  title: (adoPr.title || `PR #${adoPr.pullRequestId}`).slice(0, 120),
@@ -2312,7 +2308,7 @@ async function reconcilePrs(config) {
2312
2308
  sourcePlan: linkedItem?.sourcePlan || '',
2313
2309
  itemType: linkedItem?.itemType || '',
2314
2310
  };
2315
- const upserted = shared.upsertPullRequestRecord(prPath, entry, { project, itemId: confirmedItemId });
2311
+ const upserted = shared.upsertPullRequestRecord(project, entry, { project, itemId: confirmedItemId });
2316
2312
  existingPrs.push(upserted.record || entry);
2317
2313
  existingIds.add(prId);
2318
2314
  projectAdded++;
@@ -2331,7 +2327,7 @@ async function reconcilePrs(config) {
2331
2327
  const backfilled = shared.backfillPrPrdItems(existingPrs, shared.getPrLinks());
2332
2328
 
2333
2329
  if (projectAdded > 0 || projectUpdated > 0 || backfilled > 0 || metadataUpdated > 0) {
2334
- mutateJsonFileLocked(prPath, (currentPrs) => {
2330
+ shared.mutatePullRequests(project, (currentPrs) => {
2335
2331
  // Merge reconciled PRs into the locked copy by ID
2336
2332
  for (const pr of existingPrs) {
2337
2333
  const idx = currentPrs.findIndex(p => p.id === pr.id);
@@ -2905,7 +2901,7 @@ async function reconcileAbandonedPrs(config) {
2905
2901
  // log line is honest about what we left behind.
2906
2902
  let pending = 0;
2907
2903
  for (const project of projects) {
2908
- const prs = shared.safeJsonArr(shared.projectPrPath(project));
2904
+ const prs = shared.readPullRequests(project);
2909
2905
  pending += prs.filter(pr =>
2910
2906
  pr.status === shared.PR_STATUS.ABANDONED && !pr._reconciliation404Confirmed
2911
2907
  ).length;
@@ -2926,8 +2922,7 @@ async function reconcileAbandonedPrs(config) {
2926
2922
  continue;
2927
2923
  }
2928
2924
 
2929
- const prPath = shared.projectPrPath(project);
2930
- const prs = shared.safeJsonArr(prPath);
2925
+ const prs = shared.readPullRequests(project);
2931
2926
  const abandonedPrs = prs.filter(pr =>
2932
2927
  pr.status === shared.PR_STATUS.ABANDONED
2933
2928
  && !pr._reconciliation404Confirmed
@@ -3010,7 +3005,7 @@ async function reconcileAbandonedPrs(config) {
3010
3005
 
3011
3006
  if (updates.length > 0) {
3012
3007
  const reconciledAt = ts();
3013
- shared.mutatePullRequests(prPath, (currentPrs) => {
3008
+ shared.mutatePullRequests(project, (currentPrs) => {
3014
3009
  for (const upd of updates) {
3015
3010
  const pr = currentPrs.find(p => shared.getPrNumber(p) === upd.prNumber);
3016
3011
  if (!pr) continue;