@yemi33/minions 0.1.2305 → 0.1.2306

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.
@@ -85,6 +85,7 @@ Do **not** invent, regenerate, or share the nonce across dispatches — each spa
85
85
  | `tests` | string | `pass`, `fail`, `skipped`, `N/A`, or a free-form note like `skipped — relying on PR pipeline`. |
86
86
  | `pending` | string | Any remaining work, or `none`. |
87
87
  | `followups` | array | Optional. PR-comment follow-up work items the agent dispatched via `POST /api/work-items` with `meta.pr_followup` set. Each entry: `{wi_id, title, reason, parent_comment_id}`. See [PR-comment follow-ups](#pr-comment-follow-ups). |
88
+ | `invalidates` | string[] | Optional. List of work-item IDs (e.g. `["W-abc123"]`) whose goals are superseded by this completion. The engine cancels each listed WI that is currently in `pending` or `queued` status, stamping `cancellationReason: "invalidated-by:<source-wi-id>"`. WIs in any other status (dispatched, done, failed, cancelled) are skipped with a warning — they are NOT cancelled. Missing IDs also log a warning and are skipped. Only processed on successful (`effectiveSuccess`) completions. See [Goal invalidation](#goal-invalidation). |
88
89
  | `meta.review` | object | Optional, review tasks only. Records project-local review-skill outcome — see [Review skill outcomes](#review-skill-outcomes). Aliased by the generalized `meta.skill` (W-mq1cczi90006b21f). |
89
90
  | `meta.skill` | object | Optional. Records project-local skill outcome for ANY playbook type that surfaces a `## Project skills` block (implement / fix / plan / review / etc.). See [Project skill outcomes](#project-skill-outcomes). |
90
91
  | `meta.descriptionAudit` | object | Optional, `fix` / `implement` dispatches that push commits. Records the PR description audit + screenshot-refresh outcome — see [PR description audit](#pr-description-audit). |
@@ -191,6 +192,30 @@ Record the outcome under `meta.descriptionAudit`. All fields are optional and ba
191
192
 
192
193
  Dispatchers can suppress the audit entirely by setting `meta.skipDescriptionAudit: true` on the work item — the playbook then renders an explicit "audit suppressed" notice instead of the audit steps. Use this for skill-meta updates, doc-only fixes whose PR description text won't be affected by the diff, and follow-up dispatches that explicitly own the description themselves.
193
194
 
195
+ ## Goal invalidation
196
+
197
+ P-mqyp0009y025z6a7. An optional `invalidates: string[]` field in the completion report lets a completing work item cancel the goals of sibling or downstream WIs that are no longer needed.
198
+
199
+ ```json
200
+ {
201
+ "status": "success",
202
+ "summary": "Implemented approach A; approach B work items are no longer needed.",
203
+ "invalidates": ["W-approach-b-001", "W-approach-b-002"]
204
+ }
205
+ ```
206
+
207
+ **Semantics:**
208
+
209
+ - `invalidates` is an array of work-item ID strings (same format as WI IDs: `W-xxx`).
210
+ - The field is **optional and additive** — existing completion reports without it are unaffected.
211
+ - The engine processes `invalidates[]` only on **successful** completions (code 0, no `agentReportedFailure`, no `skipDoneStatus`).
212
+ - Each listed WI is cancelled only if it is currently in `pending` or `queued` status. WIs in any other status (`dispatched`, `done`, `failed`, `cancelled`, …) are **not** cancelled; a warning is logged instead.
213
+ - Missing WI IDs (IDs not found in any project's work-items) produce a warning but do **not** throw — the rest of the `invalidates[]` list is still processed.
214
+ - Each cancellation stamps `cancellationReason: "invalidated-by:<source-wi-id>"` and `cancelledAt` on the cancelled WI.
215
+ - A `work_items` state event is emitted for each successful cancellation so the dashboard reflects it.
216
+
217
+ **Implementation:** `engine/lifecycle.js#applyGoalInvalidation`, called from `runPostCompletionHooks` after the source WI is marked done.
218
+
194
219
  ## Harness usage (`harnessUsed`)
195
220
 
196
221
  P-a8f3c2d1. Optional self-report of the harness affordances the agent actually
@@ -26,6 +26,7 @@
26
26
  | `capabilities.bareMode` | **`false`** | No `--bare`. Closest equivalent is `--no-custom-instructions` (suppresses AGENTS.md only, not all auto-discovery). |
27
27
  | `capabilities.fallbackModel` | **`false`** | No `--fallback-model` flag. |
28
28
  | `capabilities.sessionPersistenceControl` | **`false`** | Copilot manages session state internally in `~/.copilot/session-state/`. Engine cannot opt out without `--config-dir`. |
29
+ | `capabilities.imageInput` | **`true`** | Base64 image payloads are materialized to temp files and passed as `--attachment <path>` (repeatable flag, W-mqv7324u0021db5d). See §10a. |
29
30
 
30
31
  | Default | Value |
31
32
  |---|---|
@@ -182,6 +183,7 @@ Empirically confirmed flags for non-interactive Copilot invocations:
182
183
  | `--stream on` / `--stream off` | optional | Default is `on`. See §4. |
183
184
  | `--enable-reasoning-summaries` | optional | Maps from `opts.reasoningSummaries`; only Anthropic models populate `assistant.reasoning_delta`. |
184
185
  | `--add-dir <path>` | injected by spawn-agent | Same role as on the Claude path — registers extra read-allowed dirs (skill discovery). |
186
+ | `--attachment <path>` | injected for image inputs | Passes an image file to the model. Repeatable. The adapter writes each base64 image payload to a temp file, emits one `--attachment` per file, and deletes temps after spawn (W-mqv7324u0021db5d). Gated by `capabilities.imageInput`. |
185
187
  | `-v` / `--verbose` | **never emit** | Does not exist on Copilot. The Claude adapter emits `--verbose`; the Copilot adapter MUST NOT. |
186
188
 
187
189
  ### 3.1 `--autopilot` vs single-shot
@@ -602,7 +604,8 @@ When implementing `engine/runtimes/copilot.js`:
602
604
  3. `buildArgs(opts)` always emits:
603
605
  `--output-format json -s --allow-all --no-ask-user --autopilot --log-level error`
604
606
  plus the conditional flags from §3, plus `--no-custom-instructions` /
605
- `--disable-builtin-mcps` per `opts.suppressAgentsMd` / `opts.disableBuiltinMcps`.
607
+ `--disable-builtin-mcps` per `opts.suppressAgentsMd` / `opts.disableBuiltinMcps`,
608
+ plus `--attachment <path>` (repeatable) for each image in `opts.images` (§10a).
606
609
  **Never** emit `--verbose`.
607
610
  4. `buildPrompt()` injects `<system>...</system>\n\n` block when sysprompt is
608
611
  non-empty; passthrough otherwise (§2).
@@ -643,6 +646,33 @@ When the spike's findings disagree with the plan text, **this document wins**
643
646
 
644
647
  ---
645
648
 
649
+ ## 10a. Image Attachments — `--attachment` (W-mqv7324u0021db5d)
650
+
651
+ `capabilities.imageInput: true`. The Copilot CLI accepts image files via
652
+ `--attachment <path>` (the flag is repeatable). The adapter's `buildArgs(opts)`
653
+ materializes each base64 payload from `opts.images` to a temp file in
654
+ `opts.tmpDir`, appends `--attachment <path>` per file, and arranges cleanup after
655
+ `spawn` returns.
656
+
657
+ ```js
658
+ // Simplified adapter flow (see engine/runtimes/copilot.js _buildAttachmentArgs)
659
+ for (const img of opts.images ?? []) {
660
+ const ext = MIME_TO_EXT[img.mimeType] ?? 'bin';
661
+ const filePath = path.join(opts.tmpDir, `attachment-${i}.${ext}`);
662
+ fs.writeFileSync(filePath, Buffer.from(img.dataBase64, 'base64'));
663
+ args.push('--attachment', filePath);
664
+ }
665
+ ```
666
+
667
+ **Supported MIME types** (empirically confirmed against Copilot v1.0.36):
668
+ `image/jpeg`, `image/png`, `image/gif`, `image/webp`.
669
+
670
+ When `capabilities.imageInput` is false (e.g., Codex adapter), the engine's
671
+ `_resolveImageOpts` returns a typed `model-unavailable` error before spawn so
672
+ callers get a clean rejection instead of a silent drop.
673
+
674
+ ---
675
+
646
676
  ## Provenance
647
677
 
648
678
  - Test host: Windows 11, PowerShell 7+, `copilot.exe` 1.0.36 from WinGet.
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Author: Rebecca (Architect) | Date: 2026-04-07 | Status: **Accepted — implementation in progress**
4
4
 
5
- > **Implementation status (as of 2026-06):** The `node:sqlite` recommendation in §3 has been adopted ahead of schedule. Phases 0–9 have shipped (events, dispatches, work_items, pull_requests, logs, metrics, watches, schedule_runs + pipeline_runs + managed_processes + worktree_pool, qa_runs + qa_sessions, pr_links, cooldowns + pending_rebases + cc_sessions + doc_sessions, and steering_deliveries — see `CHANGELOG.md` and `engine/db/migrations/`). The SQLite schema lives under `engine/db/migrations/` and the singleton opens `engine/state.db` in WAL mode. Phase 8 added the first opt-out toggle for the JSON sidecars (`engine.qaDualWriteJson`, default true). Phase 9.4 went further and deleted the silent SQL-unavailable JSON fallbacks in the engine — SQL is now the only reader/writer for everything migrated; the JSON mirror layer is dual-written as a passive mirror and slated for deletion in Phase 9.5. The "Phase 2: estimated Node 26 LTS" timeline in §3 is now historical context; treat sections 1–3 as design rationale rather than a forward plan.
5
+ > **Implementation status (as of 2026-06):** The `node:sqlite` recommendation in §3 has been adopted ahead of schedule. Phases 0–10 have shipped (events, dispatches, work_items, pull_requests, logs, metrics, watches, schedule_runs + pipeline_runs + managed_processes + worktree_pool, qa_runs + qa_sessions, pr_links, cooldowns + pending_rebases + cc_sessions + doc_sessions, and steering_deliveries (Phases 0–9); plans + prds + prd_items + prd_verify_prs (Phase 10, migration `015-plans-prds.js`) — see `CHANGELOG.md` and `engine/db/migrations/`). The SQLite schema lives under `engine/db/migrations/` and the singleton opens `engine/state.db` in WAL mode. Phase 8 added the first opt-out toggle for the JSON sidecars (`engine.qaDualWriteJson`, default true). Phase 9.4 went further and deleted the silent SQL-unavailable JSON fallbacks in the engine — SQL is now the only reader/writer for everything migrated; the JSON mirror layer is dual-written as a passive mirror and slated for deletion in Phase 9.5. Phase 10 dual-writes PRD state to SQL (`engine/prd-store.js`) and flips reads to SQL when the `prdReadsFromSql` feature flag is ON (default ON — reversible). The "Phase 2: estimated Node 26 LTS" timeline in §3 is now historical context; treat sections 1–3 as design rationale rather than a forward plan.
6
6
 
7
7
  ## Executive Summary
8
8
 
@@ -54,7 +54,7 @@ By default the dirty tree refusal (Guarantee 2) is terminal — the engine never
54
54
  3. If ON: `git fetch origin` + `git reset --hard origin/<branch>`, then **re-run the porcelain preflight once**. If the tree is now clean, dispatch proceeds normally. If the reset failed or the tree is *still* dirty, it falls back to the safe `{ ok:false, reason:'dirty' }` refusal — the engine never dispatches onto an unexpected tree.
55
55
  4. On a successful reset it writes a single `live-checkout-autoreset-<wiId>` inbox note listing **exactly which paths were discarded**, so the operator can recover them from `git reflog` / `git fsck --lost-found`.
56
56
 
57
- This is **DESTRUCTIVE** — it permanently discards the operator's uncommitted changes in the live checkout — which is why it is **OFF by default** and strictly opt-in. Use it only for unattended/CI-style live checkouts where the tree is expected to track the remote and any local drift is disposable.
57
+ This is **DESTRUCTIVE** — it permanently discards the operator's uncommitted changes in the live checkout. As of W-mqzbbhn2 it is **ON by default**: discarding disposable dirt via reset-to-remote keeps live-checkout branches aligned with origin instead of accumulating `minions: auto-save agent WIP` commits on every dirty exit. Set `liveCheckoutAutoReset: false` (per-project or fleet-wide) to restore the terminal dirty-tree refusal for live checkouts whose local drift you want preserved.
58
58
 
59
59
  - **Fleet-wide:** Dashboard → Settings → `Live-checkout auto-reset (fleet-wide)` (`engine.liveCheckoutAutoReset`).
60
60
  - **Per-project override:** set `liveCheckoutAutoReset: true|false` on the project object in `config.json` (see the config snippet under *Enabling live mode*). A per-project boolean overrides the fleet-wide default for that project. *(Per-project Settings-UI persistence is deferred — the per-project override is config.json-only for now.)*
@@ -103,6 +103,8 @@ Live-mode agents run **in-place** in the operator's checkout, so when a dispatch
103
103
 
104
104
  - **Original-ref capture.** `prepareLiveCheckout` records the operator's starting ref *before* the first checkout: `git symbolic-ref --short HEAD` → `{ originalRef:<branch>, originalRefType:'branch' }`, falling back to `git rev-parse HEAD` → `{ originalRef:<sha>, originalRefType:'detached' }`. `spawnAgent` persists `originalRef` / `originalRefType` onto the dispatch record via `mutateDispatch`, so the restore survives an engine restart, where the in-memory spawn closure is gone and only the persisted record remains.
105
105
  - **Self-healing dirty recovery (PL-live-checkout-reliability-hardening).** Before the plain checkout, if the tree is **dirty AND HEAD is on the agent branch** (and that branch differs from `originalRef`), the dirt is provably **agent-authored** — the engine created that branch and verified the tree clean before switching to it. The leftovers are committed onto the **agent branch** (`git add -A` + `git commit --no-verify -m "minions: auto-save agent WIP (dispatch …)"`), so the plain `git checkout <originalRef>` then succeeds and the operator tree returns clean. This is the fix for the *"no recovery from dirty checkout"* deadlock, where an agent's crash-leftover WIP refused the restore, stranded the tree dirty on the agent branch, and then made **every future WI** for that project fail `LIVE_CHECKOUT_DIRTY` until a human cleaned it. It only ever mutates the engine-created branch, never the operator's branch, never discards (the WIP lands as a visible, revertable commit / on its PR), and gitignored artifacts are never staged (so a tree dirty only with ignored build output never reaches here). Best-effort: a failed auto-commit falls through to the manual-recovery alert below.
106
+ - **Retry-with-backoff on the WIP auto-save commit (#608).** The `git add -A` + `git commit` above (and the equivalent self-heal commit described below) go through `_commitAgentWipWithRetry`: a bounded retry (3 attempts, linear backoff) that fires only when the commit fails with an `index.lock` / "another git process" message — i.e. a transient collision with a concurrent git invocation — never on a genuine failure like "nothing to commit". Previously a single failed attempt (e.g. a `.git/index.lock` race) silently aborted the auto-save, leaving the tree dirty on the agent branch and letting the pollution below take hold.
107
+ - **Orphaned-agent-branch self-heal at dispatch start (#608).** If dispatch-end restore is ever skipped or interrupted (crash, forced kill, an engine restart racing the restore), the *next* dispatch for that project can start with HEAD already sitting on a stray `work/<wi-id>` branch from the previous run — and, before this fix, `prepareLiveCheckout`'s dirty-tree check ran *before* `originalRef` was even captured, so the function returned `{ reason: 'dirty' }` immediately and the branch was never noticed or cleaned, silently repeating for every subsequent dispatch. `prepareLiveCheckout` now recognizes this case: if the tree is still dirty after the existing auto-clean-artifacts recheck, and the current branch (read from the already-fetched `git status --porcelain -b` header, no extra git call) matches the `work/<id>` naming convention (`_looksLikeAgentBranch`) and differs from this dispatch's own target branch, the WIP is committed onto that stray branch (again via `_commitAgentWipWithRetry`) and HEAD is switched back to `mainRef` before the normal flow continues. This only ever touches a branch matching the engine's own naming convention — an operator's own feature branch (e.g. `feature/x`) is never mistaken for engine litter and is left untouched.
106
108
  - **AUTO-RESTORE (best-effort, never `--force`).** At dispatch-end `restoreLiveCheckoutAtDispatchEnd` issues a **plain** `git checkout <originalRef>` — no `--force`, no `-B`, no reset, no clean, no stash. It no-ops when there is nothing to restore: no captured `originalRef`, the agent branch *is* the original ref, or HEAD already sits on the original ref (matched against the branch name *or* the raw sha so the detached-HEAD case is recognized). It is strictly best-effort: every error is swallowed and logged, and a restore never alters the dispatch result.
107
109
  - **Fallback notify (only when a safe switch is impossible).** If git declines the plain checkout — most likely because the agent left uncommitted changes a checkout would overwrite — the refusal is **honored**: the tree is left exactly as the agent left it and a deduped `live-checkout-branch-<dispatchId>` inbox alert tells the operator how to switch back manually (`git -C <localPath> checkout <originalRef>`). The engine never forces the switch. An **unexpected** restore error (git missing, repo corruption, a GVFS blob fetch on the switch-back) now also writes this alert, so a non-refusal failure never silently strands the tree.
108
110
  - **Terminal-failure alert.** When the dispatch ends in a non-success terminal state, a deduped `live-checkout-failed-<dispatchId>` inbox alert is written so the operator knows a live-mode run failed inside their own checkout (where any partial work is visible). This is independent of the restore and fires even when the restore itself succeeds.
@@ -178,7 +180,7 @@ The engine has no opinion about local branches; this hygiene is the operator's r
178
180
  Live-checkout mode is deliberately small. These are NOT supported and will not be added:
179
181
 
180
182
  - **No `auto` mode.** The choice between `worktree` and `live` is per-project and operator-set. The engine will not auto-detect submodules / `repo` workspaces and silently switch modes.
181
- - **No auto-stash on dirty refusal.** The engine refuses and exits; it never `git stash`es to "make room" for a dispatch. Stashes silently mutate the operator's tree and conflate engine state with operator state. (The opt-in `liveCheckoutAutoReset` is the one sanctioned escape hatch — but it **discards** rather than stashes, is OFF by default, and is gated on explicit per-project / fleet-wide opt-in. See [§2b](#2b-opt-in-auto-reset-on-dirty-livecheckoutautoreset-w-mqvejug6000eeb20).)
183
+ - **No auto-stash on dirty refusal.** The engine refuses and exits; it never `git stash`es to "make room" for a dispatch. Stashes silently mutate the operator's tree and conflate engine state with operator state. (The `liveCheckoutAutoReset` escape hatch — ON by default as of W-mqzbbhn2 — **discards** rather than stashes; set it to `false` per-project / fleet-wide to keep the terminal dirty-tree refusal. See [§2b](#2b-opt-in-auto-reset-on-dirty-livecheckoutautoreset-w-mqvejug6000eeb20).)
182
184
  - **No concurrent dispatches per project.** The cap is 1; raising it would require per-WI subdirectories, which live mode explicitly does not provide.
183
185
  - **No per-WI subdirectory isolation.** Live mode is one-checkout-per-project by design. If you need isolation, use `checkoutMode: 'worktree'` (the default).
184
186
  - **No per-WI override.** `checkoutMode` is per-project only. There is no `meta.checkoutMode` on a work item that overrides the project setting.
@@ -190,7 +192,7 @@ Live-checkout mode is deliberately small. These are NOT supported and will not b
190
192
  | File | Purpose |
191
193
  |---|---|
192
194
  | `engine/shared.js` — `CHECKOUT_MODES`, `validateCheckoutMode`, `resolveCheckoutMode`, `isLiveCheckoutProject` | Enum + validator + back-compat resolver (P-a3f9b201; consolidated W-mqiaw974). |
193
- | `engine/shared.js` — `resolveLiveCheckoutAutoReset` + `ENGINE_DEFAULTS.liveCheckoutAutoReset` | Pure precedence resolver (per-project boolean > fleet-wide engine default > false) + the fleet-wide default (OFF). Gates the opt-in dirty-tree auto-reset in `prepareLiveCheckout` (W-mqvejug6000eeb20). |
195
+ | `engine/shared.js` — `resolveLiveCheckoutAutoReset` + `ENGINE_DEFAULTS.liveCheckoutAutoReset` | Pure precedence resolver (per-project boolean > fleet-wide engine default > false) + the fleet-wide default (ON as of W-mqzbbhn2). Gates the dirty-tree auto-reset in `prepareLiveCheckout` (W-mqvejug6000eeb20). |
194
196
  | `engine/shared.js` — `resolveSpawnPaths` | Returns `{ cwd: localPath, worktreeRootDir: null, liveMode: true }` for live projects (P-a3f9b202). |
195
197
  | `engine/live-checkout.js` — `prepareLiveCheckout` | Pure helper: dirty check, mid-operation / detached-HEAD preflight (incl. `BISECT_LOG`; throw-on-git-dir-failure; exit-1-only detached), original-ref capture, **already-on-branch fast path**, `refs/heads/<branch>` existence check, branch resolution from HEAD (no fetch — issue #226), **no-half-switch + `blob-fetch` classification** for partial-clone hydration failures, **opt-in dirty auto-reset** (`git fetch origin` + `reset --hard origin/<branch>` + re-check + `live-checkout-autoreset-<wiId>` note when `liveCheckoutAutoReset` is on), **50 MB git maxBuffer** (P-a3f9b203; preflight + capture P-b2e8d4a6; hardening PL-live-checkout-reliability-hardening; auto-reset + maxBuffer W-mqvejug6000eeb20). |
196
198
  | `engine/live-checkout.js` — `restoreLiveCheckoutAtDispatchEnd` | Dispatch-end auto-restore (plain `git checkout <originalRef>`, never `--force`/reset/clean/stash, best-effort) + **self-healing dirty recovery** (auto-commit agent WIP onto the agent branch) + `live-checkout-failed-<dispatchId>` terminal-failure alert + `live-checkout-branch-<dispatchId>` fallback notify (now also on unexpected restore errors) (P-d9e6b2c4; self-heal PL-live-checkout-reliability-hardening). |
package/engine/ado.js CHANGED
@@ -42,6 +42,23 @@ function isGitHubProject(project) {
42
42
  return String(project?.repoHost || '').toLowerCase() === 'github';
43
43
  }
44
44
 
45
+ // #3758 — True when at least one configured project is NOT a GitHub project
46
+ // (i.e., could plausibly need ADO). Tick-loop entry points
47
+ // (pollPrStatus/pollPrHumanComments/reconcilePrs) use this to stay fully
48
+ // inert on a GitHub-only install: without it, a config with adoPollEnabled
49
+ // left at its default attempts az/azureauth token acquisition every poll for
50
+ // no reason, producing a permanent, noisy — and, per the failure this
51
+ // closes, potentially fatal — failure loop.
52
+ //
53
+ // Deliberately does NOT require adoOrg/adoProject to already be populated:
54
+ // several legitimate ADO projects start with those fields blank and get them
55
+ // repaired in-place (from a tracked PR URL, canonical PR id, or `git remote`)
56
+ // by repairAdoProjectConfig() inside forEachActivePr/reconcilePrs itself —
57
+ // gating on adoOrg/adoProject here would skip that repair before it ever ran.
58
+ function hasAdoProjectConfigured(config) {
59
+ return shared.getProjects(config).some(p => !isGitHubProject(p));
60
+ }
61
+
45
62
  function isAdoGuid(value) {
46
63
  return ADO_GUID_RE.test(String(value || '').trim());
47
64
  }
@@ -1165,6 +1182,13 @@ async function forEachActivePr(config, token, callback) {
1165
1182
  if (currentPrs[idx].reviewStatus === REVIEW_STATUS.APPROVED && after.reviewStatus !== REVIEW_STATUS.APPROVED) {
1166
1183
  after.reviewStatus = REVIEW_STATUS.APPROVED;
1167
1184
  }
1185
+ // Never downgrade status from 'merged' — permanent terminal state. A
1186
+ // stale/out-of-order poll response could otherwise overwrite a concurrent
1187
+ // success/merge write-back (mirrors github.js:564-570 and the central
1188
+ // ADO write-back guard at ado.js:1327-1330).
1189
+ if (currentPrs[idx].status === PR_STATUS.MERGED && after.status !== PR_STATUS.MERGED) {
1190
+ after.status = PR_STATUS.MERGED;
1191
+ }
1168
1192
  shared.applyPrFieldDelta(currentPrs[idx], before, after);
1169
1193
  }
1170
1194
  // Don't push if not found — it was deleted by another writer, respect that
@@ -1328,6 +1352,10 @@ async function forEachActivePr(config, token, callback) {
1328
1352
  async function pollPrStatus(config) {
1329
1353
  _adoPollHadAuthFailure = false; // reset before polling — set again if errors recur
1330
1354
 
1355
+ // #3758 — inert when no project actually uses ADO. Skip token acquisition
1356
+ // entirely rather than spawning az/azureauth for a GitHub-only install.
1357
+ if (!hasAdoProjectConfigured(config)) return;
1358
+
1331
1359
  const token = await getAdoToken();
1332
1360
  if (!token) {
1333
1361
  const w = consumeNoAdoTokenWarning();
@@ -1882,6 +1910,9 @@ async function pollPrStatus(config) {
1882
1910
  // ─── Poll Human Comments on PRs ──────────────────────────────────────────────
1883
1911
 
1884
1912
  async function pollPrHumanComments(config) {
1913
+ // #3758 — inert when no project actually uses ADO.
1914
+ if (!hasAdoProjectConfigured(config)) return;
1915
+
1885
1916
  const token = await getAdoToken();
1886
1917
  if (!token) return;
1887
1918
 
@@ -2100,6 +2131,9 @@ async function pollPrHumanComments(config) {
2100
2131
  * in pull-requests.json, and add them. Matches PRs to work items by branch name.
2101
2132
  */
2102
2133
  async function reconcilePrs(config) {
2134
+ // #3758 — inert when no project actually uses ADO.
2135
+ if (!hasAdoProjectConfigured(config)) return;
2136
+
2103
2137
  const token = await getAdoToken();
2104
2138
  if (!token) {
2105
2139
  const w = consumeNoAdoTokenWarning();
@@ -2984,4 +3018,7 @@ module.exports = {
2984
3018
  isPrValidationBuild,
2985
3019
  shouldRequeueStaleBuild,
2986
3020
  PR_BUILD_REQUEUE_INTERVAL_MS,
3021
+ // #3758 — exported for unit tests of GitHub-only-config inertness.
3022
+ isGitHubProject,
3023
+ hasAdoProjectConfigured,
2987
3024
  };
@@ -1136,6 +1136,52 @@ function consolidateWithRegex(items, files, config) {
1136
1136
 
1137
1137
  // ─── Knowledge Base Classification ───────────────────────────────────────────
1138
1138
 
1139
+ // Matches the reusable-knowledge section headers agents conventionally use in
1140
+ // their inbox findings notes ("## Patterns / Conventions", "## Bugs & Gotchas",
1141
+ // "## Dependencies", etc. — see notes.md consolidation history). Presence
1142
+ // signals the note carries generalizable knowledge worth a full-body KB copy;
1143
+ // absence means the note is most likely a narrow, one-off deliverable (a
1144
+ // single PR review, a task status update, a one-time plan) that should get a
1145
+ // condensed stub instead of a full-body duplicate (issue #604).
1146
+ // The keyword doesn't have to open the heading — "#### Bugs & Gotchas" (the
1147
+ // exact header this module itself generates for regex-fallback consolidation
1148
+ // entries, see catLabels above) puts "Bugs" before "Gotchas". Allow up to a
1149
+ // few leading heading words (bounded to avoid ReDoS) before the keyword.
1150
+ const REUSABLE_SIGNAL_RE = /^#{1,4}\s*(?:[\w&/-]+\s+){0,4}(patterns?(\s*(&|and|\/)\s*conventions?)?|conventions?|gotchas?|dependencies|best practices?)\b/im;
1151
+
1152
+ function hasReusableSignal(content) {
1153
+ return REUSABLE_SIGNAL_RE.test(content || '');
1154
+ }
1155
+
1156
+ /**
1157
+ * Build a condensed KB stub for narrowly-scoped, one-off notes instead of
1158
+ * duplicating the full body (issue #604 — classifyToKnowledgeBase was copying
1159
+ * every consolidated inbox note's ENTIRE body into knowledge/<category>/,
1160
+ * doubling disk usage and paying a recurring context/token cost on every
1161
+ * future dispatch that primes from knowledge/ before notes/inbox/).
1162
+ *
1163
+ * Deterministic and LLM-free: keeps the note's H1 title (if any) plus its
1164
+ * first substantive paragraph as a short abstract, then links back to the
1165
+ * archived note for anyone who needs the full detail. Existing full-body KB
1166
+ * files already on disk are untouched — this only changes how NEW entries
1167
+ * are written.
1168
+ */
1169
+ function buildCondensedKbBody(content, titleLine, archiveRelPath) {
1170
+ const body = String(content || '').trim();
1171
+ const withoutTitle = titleLine ? body.replace(/^#\s+.+(\r?\n)+/, '') : body;
1172
+ const firstBlock = (withoutTitle.split(/\r?\n\s*\r?\n/).find(b => b.trim().length > 0) || '').trim();
1173
+ const MAX_LEN = 500;
1174
+ let abstract = firstBlock;
1175
+ if (abstract.length > MAX_LEN) {
1176
+ abstract = abstract.slice(0, MAX_LEN).replace(/\s+\S*$/, '').trim() + '\u2026';
1177
+ }
1178
+ const parts = [];
1179
+ if (titleLine) parts.push(titleLine.trim());
1180
+ parts.push(abstract || '_(no summary available)_');
1181
+ parts.push(`_Full note: \`${archiveRelPath}\`._`);
1182
+ return parts.join('\n\n') + '\n';
1183
+ }
1184
+
1139
1185
  async function classifyToKnowledgeBase(items, config) {
1140
1186
 
1141
1187
  if (!fs.existsSync(KNOWLEDGE_DIR)) fs.mkdirSync(KNOWLEDGE_DIR, { recursive: true });
@@ -1174,9 +1220,18 @@ async function classifyToKnowledgeBase(items, config) {
1174
1220
  const kbFilename = `${dateStamp()}-${agent}-${titleSlug}.md`;
1175
1221
  const kbPath = shared.uniquePath(path.join(categoryDirs[category], kbFilename));
1176
1222
 
1177
- const frontmatter = `---\nsource: ${item.name}\nagent: ${agent}\ncategory: ${category}\ndate: ${dateStamp()}\n---\n\n`;
1223
+ // Reserve full-body KB copies for content that's actually general/reusable
1224
+ // (patterns, conventions, gotchas); narrowly-scoped one-off notes get a
1225
+ // condensed stub + link back to the archived note instead (issue #604).
1226
+ const reusable = category === 'conventions' || hasReusableSignal(content);
1227
+ const archiveRelPath = `notes/archive/${dateStamp()}-${item.name}`;
1228
+ const kbBody = reusable
1229
+ ? content
1230
+ : buildCondensedKbBody(content, titleMatch ? titleMatch[0] : null, archiveRelPath);
1231
+
1232
+ const frontmatter = `---\nsource: ${item.name}\nagent: ${agent}\ncategory: ${category}\ndate: ${dateStamp()}\nreusable: ${reusable}\n---\n\n`;
1178
1233
  try {
1179
- safeWrite(kbPath, frontmatter + content);
1234
+ safeWrite(kbPath, frontmatter + kbBody);
1180
1235
  classified++;
1181
1236
  } catch (err) {
1182
1237
  log('warn', `Failed to classify ${item.name} to knowledge base: ${err.message}`);
@@ -1340,4 +1395,6 @@ module.exports = {
1340
1395
  archiveInboxFiles,
1341
1396
  _capNotesPreservingOverflow,
1342
1397
  NOTES_MAX_BYTES,
1398
+ hasReusableSignal,
1399
+ buildCondensedKbBody,
1343
1400
  };
@@ -4981,6 +4981,77 @@ function pickReReviewAgentHints(config, opts = {}) {
4981
4981
  return out;
4982
4982
  }
4983
4983
 
4984
+ /**
4985
+ * P-mqyp0009y025z6a7 — Goal invalidation.
4986
+ *
4987
+ * When a completing WI's report carries a non-empty `invalidates: [wi-id, …]`
4988
+ * array, cancel each listed WI that is currently pending or queued.
4989
+ *
4990
+ * Rules:
4991
+ * - Only cancels WIs in status 'pending' or 'queued'.
4992
+ * - WIs in any other status (dispatched, done, failed, cancelled, …) are
4993
+ * skipped with a warning log — they are not cancelled.
4994
+ * - Missing WI IDs produce a warning but do not throw.
4995
+ * - Uses mutateWorkItems (SQL-canonical) so the dashboard reflects the change.
4996
+ */
4997
+ function applyGoalInvalidation(sourceWiId, invalidates, config) {
4998
+ if (!Array.isArray(invalidates) || invalidates.length === 0) return;
4999
+
5000
+ const wiPaths = getWorkItemPaths(config);
5001
+ const cancellableStatuses = new Set([WI_STATUS.PENDING, WI_STATUS.QUEUED]);
5002
+ const cancellationReason = `invalidated-by:${sourceWiId}`;
5003
+
5004
+ for (const targetId of invalidates) {
5005
+ if (!targetId || typeof targetId !== 'string') continue;
5006
+
5007
+ // Find which file contains the WI.
5008
+ let foundPath = null;
5009
+ let foundStatus = null;
5010
+ for (const wiPath of wiPaths) {
5011
+ try {
5012
+ const items = readOptionalJsonStrict(wiPath, 'work-items', Array.isArray) || [];
5013
+ const item = items.find(i => i && i.id === targetId);
5014
+ if (item) {
5015
+ foundPath = wiPath;
5016
+ foundStatus = item.status;
5017
+ break;
5018
+ }
5019
+ } catch { /* best-effort scan */ }
5020
+ }
5021
+
5022
+ if (!foundPath) {
5023
+ log('warn', `Goal invalidation: WI ${targetId} listed in invalidates[] by ${sourceWiId} not found — skipping`);
5024
+ continue;
5025
+ }
5026
+
5027
+ if (!cancellableStatuses.has(foundStatus)) {
5028
+ log('warn', `Goal invalidation: WI ${targetId} is in status '${foundStatus}' — only pending/queued WIs are cancelled; skipping`);
5029
+ continue;
5030
+ }
5031
+
5032
+ // Cancel the WI via mutateWorkItems so the SQL store + state event are updated.
5033
+ try {
5034
+ mutateWorkItems(foundPath, (items) => {
5035
+ if (!Array.isArray(items)) return items;
5036
+ const wi = items.find(i => i && i.id === targetId);
5037
+ if (!wi) return items;
5038
+ // Re-check status inside the lock to avoid TOCTOU races.
5039
+ if (!cancellableStatuses.has(wi.status)) {
5040
+ log('warn', `Goal invalidation: WI ${targetId} changed to '${wi.status}' before cancel lock acquired — skipping`);
5041
+ return items;
5042
+ }
5043
+ wi.status = WI_STATUS.CANCELLED;
5044
+ wi.cancellationReason = cancellationReason;
5045
+ wi.cancelledAt = ts();
5046
+ log('info', `Goal invalidation: cancelled WI ${targetId} (${cancellationReason})`);
5047
+ return items;
5048
+ });
5049
+ } catch (err) {
5050
+ log('warn', `Goal invalidation: failed to cancel WI ${targetId}: ${err.message}`);
5051
+ }
5052
+ }
5053
+ }
5054
+
4984
5055
  async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config, opts) {
4985
5056
 
4986
5057
  const detectPhantom = !!(opts && opts.detectPhantom);
@@ -5459,6 +5530,11 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5459
5530
  promoteCompletionArtifacts(meta, agentId, dispatchItem.id, structuredCompletion, { resultSummary });
5460
5531
  // M003 — auto-dispatch a live-validation WI when a coding WI completes.
5461
5532
  try { autoDispatchLiveValidationWi(meta, config); } catch (err) { log('warn', `autoDispatchLiveValidationWi: ${err.message}`); }
5533
+ // P-mqyp0009y025z6a7 — goal invalidation: cancel any pending/queued WIs
5534
+ // listed in the completion report's optional `invalidates[]` field.
5535
+ if (structuredCompletion && Array.isArray(structuredCompletion.invalidates) && structuredCompletion.invalidates.length > 0) {
5536
+ try { applyGoalInvalidation(meta.item.id, structuredCompletion.invalidates, config); } catch (err) { log('warn', `applyGoalInvalidation: ${err.message}`); }
5537
+ }
5462
5538
  }
5463
5539
  // Failure retry is handled by completeDispatch in dispatch.js — not duplicated here.
5464
5540
  // Only clear _decomposing flag on failure so decompose items don't get permanently stuck.
@@ -5927,12 +6003,83 @@ function syncPrdFromPrs(config) {
5927
6003
  if (sourcePlan) stampPrdItemPrUrl(itemId, sourcePlan, pr.url);
5928
6004
  }
5929
6005
  }
6006
+ // Advance PRD top-level status when all items are done and all linked PRs merged
6007
+ advancePrdStatusOnAllItemsDone(config);
5930
6008
  } catch (err) {
5931
6009
  // Non-fatal — log and continue
5932
6010
  try { log('warn', `syncPrdFromPrs error: ${err?.message || err}`); } catch { /* engine not available */ }
5933
6011
  }
5934
6012
  }
5935
6013
 
6014
+ // ─── PRD Top-Level Status Auto-Completion (P-d7e8f9a1) ───────────────────────
6015
+ // Runs at the end of syncPrdFromPrs (same PR-poll cadence). Advances PRD
6016
+ // status to 'completed' when every missing_features item satisfies both:
6017
+ // 1. A work item with that id exists in done status (DONE_STATUSES)
6018
+ // 2. The linked PR (WI._pr) is merged — or the item has no linked PR
6019
+ // (non-code items that never raised a PR)
6020
+ // Paused, rejected, awaiting-approval, completed, and archived PRDs are
6021
+ // skipped via _PRD_FROZEN_STATUSES. The locked write is idempotent.
6022
+ function advancePrdStatusOnAllItemsDone(config) {
6023
+ if (!fs.existsSync(PRD_DIR)) return;
6024
+ let prdFiles;
6025
+ try { prdFiles = fs.readdirSync(PRD_DIR).filter(f => f.endsWith('.json')); } catch { return; }
6026
+ if (!prdFiles.length) return;
6027
+
6028
+ const allWorkItems = queries.getWorkItems(config);
6029
+ if (!allWorkItems.length) return;
6030
+
6031
+ // Index done WIs by id for O(1) lookup
6032
+ const doneWiById = new Map();
6033
+ for (const wi of allWorkItems) {
6034
+ if (wi.id && DONE_STATUSES.has(wi.status)) doneWiById.set(wi.id, wi);
6035
+ }
6036
+ if (!doneWiById.size) return;
6037
+
6038
+ // Collect all PRs by id across all projects for merged-status lookup
6039
+ const prById = new Map();
6040
+ for (const project of shared.getProjects(config)) {
6041
+ for (const pr of safeJsonArr(shared.projectPrPath(project))) {
6042
+ if (pr?.id) prById.set(pr.id, pr);
6043
+ }
6044
+ }
6045
+
6046
+ for (const file of prdFiles) {
6047
+ try {
6048
+ const fpath = path.join(PRD_DIR, file);
6049
+ const plan = safeJsonNoRestore(fpath);
6050
+ if (!plan?.missing_features?.length) continue;
6051
+ // Skip frozen PRDs — paused/rejected/awaiting-approval/completed/archived
6052
+ if (_PRD_FROZEN_STATUSES.has(plan.status)) continue;
6053
+
6054
+ const allDone = plan.missing_features.every(feature => {
6055
+ const wi = doneWiById.get(feature.id);
6056
+ if (!wi) return false; // no done WI for this feature
6057
+ // If the WI has a linked PR, it must be merged
6058
+ if (wi._pr) {
6059
+ const pr = prById.get(wi._pr);
6060
+ if (!pr) return true; // PR no longer in tracker (swept after merge) — allow
6061
+ return pr.status === PR_STATUS.MERGED;
6062
+ }
6063
+ // No linked PR — non-code item or PR not tracked; done WI is sufficient
6064
+ return true;
6065
+ });
6066
+
6067
+ if (!allDone) continue;
6068
+
6069
+ mutateJsonFileLocked(fpath, (data) => {
6070
+ // Re-check under lock — another writer may have frozen this PRD
6071
+ if (_PRD_FROZEN_STATUSES.has(data.status)) return data;
6072
+ data.status = PLAN_STATUS.COMPLETED;
6073
+ if (!data.completedAt) data.completedAt = ts();
6074
+ return data;
6075
+ }, { skipWriteIfUnchanged: true });
6076
+ log('info', `PRD auto-advance: ${file} → completed (all items done, all linked PRs merged)`);
6077
+ } catch (err) {
6078
+ log('warn', `advancePrdStatusOnAllItemsDone error for ${file}: ${err?.message || err}`);
6079
+ }
6080
+ }
6081
+ }
6082
+
5936
6083
  // ─── Persist verify/E2E aggregate PRs onto the PRD JSON (W-mqps9jlb) ──────────
5937
6084
  // The PRD view's "E2E Aggregate PRs" section (dashboard/js/render-prd.js#
5938
6085
  // renderE2eSection) sources its PRs from the LIVE tracker (pull-requests.json).
@@ -6400,7 +6547,9 @@ module.exports = {
6400
6547
  mergeArtifactNotes,
6401
6548
  promoteCompletionArtifacts,
6402
6549
  runPostCompletionHooks,
6550
+ applyGoalInvalidation,
6403
6551
  syncPrdFromPrs,
6552
+ advancePrdStatusOnAllItemsDone,
6404
6553
  persistVerifyPrsToPrd,
6405
6554
  resolveWorkItemPath,
6406
6555
  isItemCompleted,