@yemi33/minions 0.1.2345 → 0.1.2346

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dashboard.js CHANGED
@@ -10050,13 +10050,28 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10050
10050
  },
10051
10051
  onDone: () => {
10052
10052
  _emitTimingLog(_lifecycle, _tSessionReady, Date.now(), 'done');
10053
- resolveResult({ text: accumulated, sessionId: sessionHandle.sessionId, code: 0, usage: {}, raw: accumulated, stderr: '' });
10053
+ // W-mrax0he4000kc90d resolve with ONLY the trailing segment's
10054
+ // text, not the full accumulated turn. ccSegmentsFinalize's
10055
+ // contract (dashboard/js/render-utils.js) is that its finalText
10056
+ // argument is the trailing/last text segment only — the same
10057
+ // contract onChunk already honors via `accumulated.slice(segmentStart)`
10058
+ // (W-mqvakva3000i3325). Resolving with the full multi-segment
10059
+ // `accumulated` buffer here welds every earlier segment onto the
10060
+ // end of the message via _ccSegMergeText's "distinct — never weld"
10061
+ // branch, visibly duplicating the whole prior turn for any
10062
+ // response with >=1 mid-turn tool call. `raw` still carries the
10063
+ // full turn (logging/telemetry only — never rendered).
10064
+ resolveResult({ text: accumulated.slice(segmentStart), sessionId: sessionHandle.sessionId, code: 0, usage: {}, raw: accumulated, stderr: '' });
10054
10065
  },
10055
10066
  onError: (err) => {
10056
10067
  _emitTimingLog(_lifecycle, _tSessionReady, Date.now(), cancelled ? 'cancelled' : 'error');
10057
10068
  if (cancelled) {
10069
+ // Same trailing-segment contract as onDone above — a mid-stream
10070
+ // cancel (e.g. user hits "stop") can still reach the SSE 'done'
10071
+ // frame with text accumulated so far, so it must not leak the
10072
+ // full multi-segment buffer either.
10058
10073
  resolveResult({
10059
- text: accumulated,
10074
+ text: accumulated.slice(segmentStart),
10060
10075
  sessionId: sessionHandle.sessionId,
10061
10076
  code: 0,
10062
10077
  usage: {},
@@ -10599,7 +10614,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10599
10614
  finishMissingRuntime(result, liveState);
10600
10615
  return;
10601
10616
  }
10602
- if (!result.text || result.error) {
10617
+ // W-mrax0he4000kc90d — `result.text` from the pool path is now the
10618
+ // TRAILING segment only (see _invokeCcStreamViaPool's onDone), which
10619
+ // is legitimately empty when a turn ends right on a tool call with
10620
+ // no closing prose. That's a real success, not "no output" — so also
10621
+ // check `result.raw` (always the full accumulated turn) before
10622
+ // treating an empty `text` as a failure. A genuinely empty turn has
10623
+ // both text and raw empty.
10624
+ if ((!result.text && !result.raw) || result.error) {
10603
10625
  if (req.destroyed) {
10604
10626
  _ccStreamEnded = true;
10605
10627
  _logCcStreamEnd(_ccTelemetry, 'llm-empty-client-gone', { code: result.code });
package/docs/README.md CHANGED
@@ -40,6 +40,7 @@ Architecture, design proposals, and lifecycle references for people working on t
40
40
  - [pr-comment-followup.md](pr-comment-followup.md) — PR-comment follow-up dispatch contract: fix/review agents may spin off a new WI via `POST /api/work-items` with `meta.pr_followup` instead of broadening the current PR or rebutting the comment.
41
41
  - [pr-review-fix-loop.md](pr-review-fix-loop.md) — How the engine moves a PR from creation through review, fix dispatch, and re-review, including stale-status guards.
42
42
  - [project-skills.md](project-skills.md) — Project-local skill discovery (`.claude/skills/`, `.claude/commands/`, `CLAUDE.md` / `.github/copilot-instructions.md` slash-command mentions): how dispatched agents see and steer toward purpose-built tooling the project ships, plus the intent-vocabulary contract.
43
+ - [proposals/repo-pool-for-live-checkout.md](proposals/repo-pool-for-live-checkout.md) — Review-only artifact reproducing the approved plan + PRD for a repo pool covering live-checkout projects (multi-enlistment dispatch), so the design has a git-tracked surface for human comment before the dependent implementation work items land as code PRs.
43
44
  - [qa-runbook-lifecycle.md](qa-runbook-lifecycle.md) — End-to-end QA runbook lifecycle (W-mpeiwz6k0005bf34): runbook + run-record storage, `POST /api/qa/runbooks/run` dispatch into the `qa-validate` playbook, artifact contract, and how the `/qa` page mirrors managed-spawn observability.
44
45
  - [qa-runbooks.md](qa-runbooks.md) — Per-project QA runbook schema, storage layout (`projects/<name>/runbooks/<id>.json`), CRUD endpoints, run-record lifecycle, and the `qa-validate` agent sidecar contract.
45
46
  - [rfc-completion-json.md](rfc-completion-json.md) — RFC for replacing stdout regex-scraping with a structured `completion.json` control-plane protocol.
@@ -278,6 +278,7 @@ Defined in `engine/shared.js` as `FAILURE_CLASS`. Use the canonical hyphenated s
278
278
  |---|---|---|
279
279
  | `config-error` | CLI not found, exit code 78, malformed config | Never retry |
280
280
  | `permission-blocked` | Trust gate, permission denied, auth failure | Never retry |
281
+ | `auth` | Git/network credential failure (e.g. ADO bearer-token acquire or GCM prompt) — structural | Never retry |
281
282
  | `merge-conflict` | Git merge conflict in worktree or dependency | Retry same agent |
282
283
  | `build-failure` | Compilation, lint, or test failure introduced by the agent | Retry same agent |
283
284
  | `timeout` | Hard runtime timeout or stale-orphan timeout | Retry with fresh session |
@@ -288,8 +289,30 @@ Defined in `engine/shared.js` as `FAILURE_CLASS`. Use the canonical hyphenated s
288
289
  | `max-turns` | Claude CLI `error_max_turns` — work in progress | Retry same agent |
289
290
  | `completion-nonce-mismatch` | Completion JSON missing or mismatched `nonce` (forged completion). See [Trust boundary](#trust-boundary). | Never retry (untrusted) |
290
291
  | `worktree-preflight` | Pre-spawn worktree validation rejected the dispatch (nested-in-project, drive-root collapse, missing base). See [Pre-spawn preflight vs agent failure](#pre-spawn-preflight-vs-agent-failure). | Never retry |
292
+ | `worktree-dirty` | Reused worktree had uncommitted edits the engine could not auto-heal (#2996) | Never retry this dispatch (fresh worktree next cycle) |
293
+ | `worktree-divergent` | Reused worktree's local branch was ahead of origin — unsafe to reset (#2996) | Never retry this dispatch (worktree quarantined) |
294
+ | `worktree-quarantine-env-blocked` | Quarantine rename failed because the OS wouldn't release the worktree dir (Windows EBUSY/EPERM/EACCES) | Retry — re-queued without bumping the per-agent retry counter |
295
+ | `dependency-merge-setup` | Dependency pre-merge plumbing (stash/status/reset) failed before a real file conflict was verified | Retry — fresh worktree can recover |
296
+ | `invalid-keep-processes-workdir` | `keep-pids.json` declared a cwd that isn't a real git worktree | Never retry until the agent reruns in a real worktree |
297
+ | `invalid-keep-processes-schema` | `keep-pids.json` failed schema validation (pids-missing, ttl-too-long, etc.) | Never retry until the agent fixes the file |
298
+ | `invalid-managed-spawn` | `agents/<id>/managed-spawn.json` failed the validator (bad schema, workdir, allowlist, healthcheck shape) | Never retry as-is — agent must fix the file |
299
+ | `managed-spawn-healthcheck-failed` | A managed-spawn spec spawned but failed its healthcheck within `timeout_s`; failing PIDs killed, siblings kept alive | Flag for human review (log tail in inbox alert) |
300
+ | `injection-flagged` | Agent set `securityFlags.injectionAttempt:true` after spotting a prompt-injection attempt in an `<UNTRUSTED-INPUT>` fence | Never retry — human reviews the source |
291
301
  | `live-checkout-dirty` | Live-checkout project tree had uncommitted changes; engine refused to spawn in-place. See [Pre-spawn preflight vs agent failure](#pre-spawn-preflight-vs-agent-failure). | Never retry |
302
+ | `live-checkout-failed` | `prepareLiveCheckout` threw before spawn (helper guard, ref validation, transient git error) — distinct from confirmed-dirty | Retry with bounded backoff (up to `maxRetries`) |
303
+ | `live-checkout-mid-operation` | Operator tree is mid-operation (in-progress merge/rebase/cherry-pick/bisect) or detached HEAD | Never retry — operator must finish/abort first |
304
+ | `live-checkout-blob-fetch` | `git checkout` failed materializing the tree on a blobless/GVFS-partial clone — deterministic, not transient | Never retry — operator hydrates the branch with their own credentials |
305
+ | `live-checkout-worktree-conflict` | Target branch is already checked out in a second worktree elsewhere — structural, doesn't clear on retry | Never retry — operator removes/reassigns the other worktree |
306
+ | `live-checkout-wrong-base` | New-branch fork requested but operator HEAD is on an unrelated branch, not the project base | Never retry — operator checks out `mainRef` first (or enable `liveCheckoutAutoBaseRepair`) |
307
+ | `invalid-workdir` | Dispatch carried a `meta.workdir` override that failed validation (absolute path, `..` segment, escape, etc.) | Never retry until the WI's `meta.workdir` is fixed |
308
+ | `model-unavailable` | Requested model returned `overloaded_error` / 503 / service unavailable | Retry — engine swaps in the runtime-appropriate fallback model |
292
309
  | `workspace-manifest-repo-forbidden` | Dispatch routed an agent to a repo not in its `workspace_manifest.allowed_repos`. See [Pre-spawn preflight vs agent failure](#pre-spawn-preflight-vs-agent-failure). | Never retry |
310
+ | `workspace-manifest-tool-forbidden` | Out-of-scope tool call caught by manifest enforcement at the runtime gate | Never retry as-is |
311
+ | `workspace-manifest-url-forbidden` | Out-of-scope external URL fetch caught by manifest enforcement | Never retry as-is |
312
+ | `spawn-phase-stall` | Process spawned and ran startup-only events (MCP init/hooks) but never emitted real task progress before the grace window | Retry — fresh session clears a transient MCP wedge |
313
+ | `output-truncated` | Agent streamed more stdout than the engine's hard capture cap before the terminal `result` event arrived | Never retry — agent must reduce output volume or split the task |
314
+ | `verify-missing-pr` | A verify WI exited `done` but no PR was attached | Retry — agent may have phantom-crashed before pushing |
315
+ | `project_not_found` | A work item's `project` field names a project that isn't configured | Never retry until the WI's `project` field or config is fixed |
293
316
  | `unknown` | Unclassified failure | Default retry logic |
294
317
 
295
318
  Use `"N/A"` when `status` is `success` or `partial` without a failure.
package/docs/kb-sweep.md CHANGED
@@ -4,31 +4,61 @@ How the `knowledge/` directory stays curated, deduplicated, and small enough to
4
4
 
5
5
  ## What the Sweep Does
6
6
 
7
- The KB sweep is a 3-pass cycle implemented in [`engine/kb-sweep.js`](../engine/kb-sweep.js) that prunes duplicate, stale, and oversized entries from `knowledge/`. It runs asynchronously, archives (rather than deletes) what it removes, and writes a `_swept` frontmatter flag so the expensive rewrite pass is idempotent.
7
+ The KB sweep is a multi-pass cycle implemented in [`engine/kb-sweep.js`](../engine/kb-sweep.js) that prunes duplicate, boilerplate, stale, and oversized entries from `knowledge/`. It runs asynchronously, archives (rather than deletes) what it removes, and writes a `_swept` frontmatter flag so the expensive rewrite pass is idempotent.
8
8
 
9
9
  ```
10
10
  Entries in knowledge/<category>/*.md
11
- → Pass 1: Hash dedup (cheap, no LLM)
12
- → Pass 2: LLM batch sweep (Haiku, batches of 30)
13
- → Pass 2.5: Age-TTL expiry (per-category, no LLM)
14
- → Pass 3: Per-entry rewrite (Haiku, concurrency 5)
15
- Prune _swept/ archive (>30 days)
11
+ → Pass 1: Hash dedup (cheap, no LLM)
12
+ → Pass 1.5: Structural consolidation (cheap, no LLM — PRIMARY balloon fix)
13
+ → Pass 2: LLM batch sweep (Haiku, batches of 30)
14
+ → Pass 2.5: Age-TTL expiry (per-category, no LLM — secondary safety net)
15
+ Pass 3: Per-entry rewrite (Haiku, concurrency 5)
16
+ → Prune _swept/ archive (>30 days)
16
17
  → Invalidate KB cache, write engine/kb-swept.json
17
18
  ```
18
19
 
19
- ## The 3-Pass Cycle
20
+ > **Why consolidation is the primary fix.** Hash dedup only catches *near-exact* re-postings, and the LLM batch pass only compares entries *within* a 30-entry batch. The dominant balloon source — hundreds of one-off boilerplate notes (agent-failure dumps, no-op / merge-conflict fix reports) that differ only by work-item / date / dispatch id — is never collapsed by those passes: each note is unique by PR#/date and is spread across dozens of batches. Pass 1.5 groups those notes *structurally* and rolls each large group into a single recoverable digest, shrinking the KB even for **recent** entries. The age-TTL pass (2.5) is retained only as a secondary safety net for genuinely stale one-offs that never clustered into a digest.
21
+
22
+ ## The Pass Cycle
20
23
 
21
24
  ### Pass 1 — Hash Dedup
22
25
 
23
26
  Cheap, deterministic. No LLM call.
24
27
 
25
- For every entry, compute `sha256(normalize(content).slice(0,500) + ':' + content.length)` (source: [`engine/kb-sweep.js:29`](../engine/kb-sweep.js#L29)). Group by hash. When two or more entries collide, keep the most recent (by `date:` frontmatter, then mtime) and archive the rest into `knowledge/_swept/` with a `<!-- swept: ... | reason: hash-duplicate of ... -->` header (source: [`engine/kb-sweep.js:84-105`](../engine/kb-sweep.js#L84)).
28
+ For every entry, compute `sha256(normalize(content).slice(0,500) + ':' + content.length)` (source: [`engine/kb-sweep.js:33-35`](../engine/kb-sweep.js#L33)). Group by hash. When two or more entries collide, keep the most recent (by `date:` frontmatter, then mtime) and archive the rest into `knowledge/_swept/` with a `<!-- swept: ... | reason: hash-duplicate of ... -->` header via `_archiveKbFile()` (source: [`engine/kb-sweep.js:60-69`](../engine/kb-sweep.js#L60), called at [`:165`](../engine/kb-sweep.js#L165)).
26
29
 
27
30
  This catches near-identical re-postings across batches that the LLM pass might miss because they land in different batches.
28
31
 
32
+ ### Pass 1.5 — Structural Consolidation (primary balloon fix)
33
+
34
+ Cheap, deterministic. No LLM call. This is the pass that actually stops `knowledge/` from ballooning: it collapses **structural duplication**, not just exact duplication.
35
+
36
+ The highest-volume category (`project-notes`) is dominated by near-identical boilerplate — e.g. hundreds of `# Agent Failed: …` dumps that differ only by work-item id, date, and dispatch id (a single agent/failure-class pair like `dallas` / `live-checkout-failed` can account for hundreds of files). Hash dedup can't collapse them (the leading bytes differ) and the LLM batch pass can't either (they're scattered across dozens of 30-entry batches). Left alone they survive every other pass forever.
37
+
38
+ Pass 1.5 groups eligible entries by a **structural signature** and rolls any group of at least `minGroupSize` notes into a single digest, archiving the originals to `knowledge/_swept/` (source: [`engine/kb-sweep.js`](../engine/kb-sweep.js) `_structuralConsolidate`).
39
+
40
+ - **Signature** = `category :: agent :: kind :: time-bucket`. `kind` is derived from the note (source: [`engine/kb-sweep.js`](../engine/kb-sweep.js) `_consolidationKind`):
41
+ - `failure-<failureClass>` when the note carries a `failureClass:` (or `result: error`) — the agent-failure dumps.
42
+ - `noop` / `merge-conflict` when the title matches a no-op or merge-conflict fix report.
43
+ - Any note that doesn't match a boilerplate kind returns `null` and is **left untouched** — unique, valuable notes are never grouped.
44
+ - **Time-bucket** = the start date of the `periodDays` window the note's authored date falls in (default one digest per agent/kind per **week** — source: `_periodBucket`).
45
+ - **Digest** is written to `<category>/_digest-<agent>-<kind>-<period>.md` with `source: kb-consolidation` and a `_digest:` frontmatter flag. Its body is a compact roll-up table (one row per collapsed note: date, work item, dispatch, failureClass, one-line reason, and the original filename for recovery). Re-running the sweep **merges** new rows into the existing digest keyed on the original filename, so repeated sweeps accumulate rather than clobber.
46
+ - **Digests are never re-consolidated or rewritten** — `_isDigestEntry()` excludes them from Pass 1.5 grouping and from the Pass 3 rewrite (which would otherwise destroy the table).
47
+
48
+ Only categories in `ENGINE_DEFAULTS.kbConsolidation.categories` are eligible; durable categories (`architecture`, `conventions`) and per-agent memory (`knowledge/agents/`, never scanned) are never consolidated. Config is overridable per-fleet via `config.engine.kbConsolidation` (merged over the defaults):
49
+
50
+ | Key | Default | Meaning |
51
+ |-----|---------|---------|
52
+ | `enabled` | `true` | Set `false` (or `categories: []`) to disable the pass. |
53
+ | `categories` | `['project-notes', 'build-reports', 'reviews']` | Only these are consolidated. |
54
+ | `minGroupSize` | `5` | A group collapses only once it reaches this many near-duplicate notes. |
55
+ | `periodDays` | `7` | One digest per (agent, kind) per this many days. |
56
+
57
+ Because originals are archived to `_swept/` (same 30-day retention as every other pass), consolidation is fully recoverable for ~30 days.
58
+
29
59
  ### Pass 2 — LLM Batch Sweep
30
60
 
31
- The remaining survivors are sent to Claude Haiku in batches of `LLM_BATCH_SIZE = 30` (source: [`engine/kb-sweep.js:25`](../engine/kb-sweep.js#L25)). Each batch prompt asks for three lists in JSON:
61
+ The remaining survivors are sent to Claude Haiku in batches of `LLM_BATCH_SIZE = 30` (source: [`engine/kb-sweep.js:29`](../engine/kb-sweep.js#L29)). Each batch prompt asks for three lists in JSON:
32
62
 
33
63
  | Field | What the LLM looks for |
34
64
  |-------|------------------------|
@@ -36,15 +66,15 @@ The remaining survivors are sent to Claude Haiku in batches of `LLM_BATCH_SIZE =
36
66
  | `reclassify` | Entries in the wrong category. Returned as `{index, from, to, reason}`. |
37
67
  | `remove` | Stale or empty entries (boilerplate, "no changes needed", bail-out notes). Returned as `{index, reason}`. |
38
68
 
39
- Each action archives the file via the same `_archiveKbFile()` helper used by Pass 1; reclassification rewrites the `category:` frontmatter line and moves the file into the new category directory (source: [`engine/kb-sweep.js:243-279`](../engine/kb-sweep.js#L243)).
69
+ Each action archives the file via the same `_archiveKbFile()` helper used by Pass 1; reclassification rewrites the `category:` frontmatter line and moves the file into the new category directory (source: [`engine/kb-sweep.js:323-340`](../engine/kb-sweep.js#L323)).
40
70
 
41
71
  Reclassification targets are validated against `shared.KB_CATEGORIES` (`architecture`, `conventions`, `project-notes`, `build-reports`, `reviews` — source: [`engine/shared.js`](../engine/shared.js) `KB_CATEGORIES`); unknown categories are silently dropped.
42
72
 
43
- If a batch returns invalid JSON or the runtime is unavailable, that batch is skipped with a warning and the rest of the sweep continues (source: [`engine/kb-sweep.js:139-151`](../engine/kb-sweep.js#L139)).
73
+ If a batch returns invalid JSON or the runtime is unavailable, that batch is skipped with a warning and the rest of the sweep continues (source: [`engine/kb-sweep.js:203-215`](../engine/kb-sweep.js#L203)).
44
74
 
45
- ### Pass 2.5 — Age-Based TTL Expiry
75
+ ### Pass 2.5 — Age-Based TTL Expiry (secondary safety net)
46
76
 
47
- Cheap, deterministic. No LLM call. This pass is what keeps the KB from ballooning indefinitely: the dedup and rewrite passes can only reclaim *duplicate* or *empty* entries, but the highest-volume categories (`project-notes`, `build-reports`, `reviews`) are dominated by **one-off, PR/incident-scoped notes** that are each unique by PR#/date. Nothing in Passes 1–3 can ever remove them, so without an age cutoff they accumulate forever.
77
+ Cheap, deterministic. No LLM call. With Pass 1.5 now collapsing the bulk of the boilerplate structurally, this pass is a **secondary safety net**: it archives genuinely stale one-off notes that never clustered into a large enough group to be consolidated (e.g. a lone failure class that only occurred twice in a week). The dedup and rewrite passes can only reclaim *duplicate* or *empty* entries, so an age cutoff is still the backstop for long-tail one-offs.
48
78
 
49
79
  For every surviving entry, the pass computes the entry's age and archives it to `knowledge/_swept/` when it exceeds that category's TTL (source: [`engine/kb-sweep.js`](../engine/kb-sweep.js) `_expireByAge`). Two safety properties:
50
80
 
@@ -64,30 +94,30 @@ Because expiry archives to `_swept/` (subject to the same 30-day retention as ev
64
94
 
65
95
  ### Pass 3 — Per-Entry Rewrite
66
96
 
67
- Survivors of Passes 1 and 2 that are still on disk get rewritten one entry at a time, with up to `NORMALIZE_CONCURRENCY = 5` parallel workers (source: [`engine/kb-sweep.js:26`](../engine/kb-sweep.js#L26)). Each entry is sent to Haiku with a fixed template prompt that:
97
+ Survivors of Passes 1, 1.5, and 2 that are still on disk get rewritten one entry at a time, with up to `NORMALIZE_CONCURRENCY = 5` parallel workers (source: [`engine/kb-sweep.js:30`](../engine/kb-sweep.js#L30)). Consolidation digests are excluded (rewriting would destroy their roll-up table). Each remaining entry is sent to Haiku with a fixed template prompt that:
68
98
 
69
99
  - Caps output at ~800 words.
70
100
  - Forces the structure `## Summary` → `## Key Findings` → `## Action Items` (omit if none) → `## References` (omit if none).
71
101
  - Preserves all `file:line` references and code snippets.
72
102
  - Drops boilerplate (full dates, agent IDs in the body, narrative scaffolding).
73
103
 
74
- After a successful rewrite, the entry's frontmatter gains a `_swept: <ISO timestamp>` key (source: [`engine/kb-sweep.js:229`](../engine/kb-sweep.js#L229)).
104
+ After a successful rewrite, the entry's frontmatter gains a `_swept: <ISO timestamp>` key (source: [`engine/kb-sweep.js:293`](../engine/kb-sweep.js#L293)).
75
105
 
76
106
  ## The `_swept` Frontmatter Flag
77
107
 
78
108
  `_swept` makes Pass 3 idempotent across runs. Semantics:
79
109
 
80
- - **Set** to `new Date().toISOString()` whenever the rewrite pass successfully rewrites the body (source: [`engine/kb-sweep.js:229`](../engine/kb-sweep.js#L229)).
81
- - **Skipped** by Pass 3 on subsequent sweeps as long as the file's `mtimeMs` is `<= sweptAt + 1000` ms (1 s grace for FS timestamp jitter — source: [`engine/kb-sweep.js:196-202`](../engine/kb-sweep.js#L196)).
110
+ - **Set** to `new Date().toISOString()` whenever the rewrite pass successfully rewrites the body (source: [`engine/kb-sweep.js:293`](../engine/kb-sweep.js#L293)).
111
+ - **Skipped** by Pass 3 on subsequent sweeps as long as the file's `mtimeMs` is `<= sweptAt + 1000` ms (1 s grace for FS timestamp jitter — source: [`engine/kb-sweep.js:260-264`](../engine/kb-sweep.js#L260)).
82
112
  - **Re-processed** if the file is edited after the sweep flag was set (mtime exceeds the grace window).
83
113
 
84
114
  Hash dedup (Pass 1) and the LLM sweep (Pass 2) ignore `_swept` — those passes act on metadata and similarity, not on whether the entry was previously rewritten.
85
115
 
86
- The flag key is exported as `SWEPT_FLAG_KEY` for tests (source: [`engine/kb-sweep.js:27,429`](../engine/kb-sweep.js#L27)).
116
+ The flag key is exported as `SWEPT_FLAG_KEY` for tests (source: defined [`engine/kb-sweep.js:31`](../engine/kb-sweep.js#L31), exported [`:785`](../engine/kb-sweep.js#L785)).
87
117
 
88
118
  ## 30-Day Archive Retention
89
119
 
90
- Archived entries land in `knowledge/_swept/` with their original filename (deduped via `shared.uniquePath()` if it collides). They're not deleted immediately — `_pruneOldSwept()` runs at the end of every sweep and removes any file whose `mtimeMs` is older than `SWEPT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000` (30 days — source: [`engine/kb-sweep.js:23,69-81`](../engine/kb-sweep.js#L23)).
120
+ Archived entries land in `knowledge/_swept/` with their original filename (deduped via `shared.uniquePath()` if it collides). They're not deleted immediately — `_pruneOldSwept()` runs at the end of every sweep and removes any file whose `mtimeMs` is older than `SWEPT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000` (30 days — source: [`engine/kb-sweep.js:25`](../engine/kb-sweep.js#L25), pruning function [`:72-84`](../engine/kb-sweep.js#L72)).
91
121
 
92
122
  This gives humans a recovery window: if a sweep archives something useful, you have ~30 days to copy it back out of `_swept/` before it's permanently pruned.
93
123
 
@@ -100,7 +130,7 @@ The dashboard exposes two endpoints:
100
130
  | `POST` | `/api/knowledge/sweep` | Start a sweep in the background. Returns `202 { ok: true, started: true }` (source: [`dashboard.js:7351`](../dashboard.js#L7351), [`dashboard.js:4285`](../dashboard.js#L4285)). |
101
131
  | `GET` | `/api/knowledge/sweep/status` | Poll `{ inFlight, startedAt, lastResult, lastCompletedAt }` (source: [`dashboard.js:7352`](../dashboard.js#L7352), [`dashboard.js:4335`](../dashboard.js#L4335)). |
102
132
 
103
- POST body is optional. Pass `{ "pinnedKeys": ["knowledge/conventions/foo.md", ...] }` to add request-level pins on top of `pinned.md`. Pinned entries are excluded from every pass (source: [`engine/kb-sweep.js:345-356`](../engine/kb-sweep.js#L345)).
133
+ POST body is optional. Pass `{ "pinnedKeys": ["knowledge/conventions/foo.md", ...] }` to add request-level pins on top of `pinned.md`. Pinned entries are excluded from every pass (source: [`engine/kb-sweep.js:555-561`](../engine/kb-sweep.js#L555)).
104
134
 
105
135
  ```bash
106
136
  # Trigger a sweep
@@ -116,7 +146,7 @@ curl http://localhost:7331/api/knowledge/sweep/status
116
146
 
117
147
  Only one sweep runs at a time. The POST handler refuses to start a second sweep with `{ ok: true, alreadyRunning: true, startedAt }` while one is in flight (source: [`dashboard.js:4306-4311`](../dashboard.js#L4306)).
118
148
 
119
- The guard auto-releases when the sweep has been running longer than `staleGuardMs(entryCount)` — `max(30 min, 1 s × entryCount)` — which protects against a dashboard process that died mid-sweep leaving the flag stuck (source: [`engine/kb-sweep.js:413-417`](../engine/kb-sweep.js#L413), [`dashboard.js:4286-4305`](../dashboard.js#L4286)).
149
+ The guard auto-releases when the sweep has been running longer than `staleGuardMs(entryCount)` — `max(30 min, 1 s × entryCount)` — which protects against a dashboard process that died mid-sweep leaving the flag stuck (source: [`engine/kb-sweep.js:758-761`](../engine/kb-sweep.js#L758), [`dashboard.js:4286-4305`](../dashboard.js#L4286)).
120
150
 
121
151
  ### Persistent State
122
152
 
@@ -133,7 +163,7 @@ Sweep state is mirrored to `engine/kb-sweep-state.json` so the dashboard can rec
133
163
  { "status": "failed", "startedAt": ..., "completedAt": ..., "error": "..." }
134
164
  ```
135
165
 
136
- Memory still wins when present; the disk file is a fallback (source: [`engine/kb-sweep.js:298-320`](../engine/kb-sweep.js#L298), [`dashboard.js:4296-4354`](../dashboard.js#L4296)). A separate `engine/kb-swept.json` is written after each successful sweep with the human-readable summary line shown by the dashboard's "swept N days ago" badge (source: [`engine/kb-sweep.js:407`](../engine/kb-sweep.js#L407)).
166
+ Memory still wins when present; the disk file is a fallback (source: [`engine/kb-sweep.js:372-410`](../engine/kb-sweep.js#L372) `readSweepLiveness()`, [`dashboard.js:4296-4354`](../dashboard.js#L4296)). A separate `engine/kb-swept.json` is written after each successful sweep with the human-readable summary line shown by the dashboard's "swept N days ago" badge (source: [`engine/kb-sweep.js:626`](../engine/kb-sweep.js#L626)).
137
167
 
138
168
  ## Automatic Periodic Sweep (opt-in)
139
169
 
@@ -164,6 +194,9 @@ The KB page is refreshed by the dashboard's main refresh loop **every third tick
164
194
  entriesBefore, entriesAfter,
165
195
  bytesBefore, bytesAfter,
166
196
  hashDuplicatesArchived, // Pass 1
197
+ notesConsolidated, // Pass 1.5 (boilerplate notes rolled into digests)
198
+ consolidationGroups, // Pass 1.5 (groups collapsed)
199
+ digestsWritten, // Pass 1.5 (digest files written/updated)
167
200
  llmDuplicatesArchived, // Pass 2 (within-batch dupes)
168
201
  staleRemoved, // Pass 2 (LLM-flagged removals)
169
202
  ageExpired, // Pass 2.5 (per-category age-TTL archival)
@@ -172,11 +205,11 @@ The KB page is refreshed by the dashboard's main refresh loop **every third tick
172
205
  rewriteBytesBefore, rewriteBytesAfter,
173
206
  sweptArchivePruned, // Files purged from _swept/ (>30 days)
174
207
  durationMs,
175
- summary: '<n> hash-dup, <n> llm-dup, <n> stale, <n> age-expired, <n> reclassified, <n> rewritten (<bytes> saved)',
208
+ summary: '<n> hash-dup, <n> consolidated (<n> digests), <n> llm-dup, <n> stale, <n> age-expired, <n> reclassified, <n> rewritten (<bytes> saved)',
176
209
  }
177
210
  ```
178
211
 
179
- After a non-dry-run sweep, `queries.invalidateKnowledgeBaseCache()` is called so the next `/api/knowledge` read sees the new state (source: [`engine/kb-sweep.js:408`](../engine/kb-sweep.js#L408)).
212
+ After a non-dry-run sweep, `queries.invalidateKnowledgeBaseCache()` is called so the next `/api/knowledge` read sees the new state (source: [`engine/kb-sweep.js:627`](../engine/kb-sweep.js#L627)).
180
213
 
181
214
  ## What NOT to Do
182
215
 
@@ -18,9 +18,9 @@ The default `worktree` mode spawns each dispatch inside its own git worktree und
18
18
 
19
19
  For these repos, the operator usually already has one canonical checkout that builds correctly. Live-checkout mode lets the agent dispatch into that checkout instead of cloning a side-by-side copy.
20
20
 
21
- ## Contract (five guarantees)
21
+ ## Contract (six guarantees)
22
22
 
23
- Live mode is opinionated about what the engine will and will not do to the operator's tree. The contract has five guarantees; the engine enforces all five and fails dispatches that would violate them.
23
+ Live mode is opinionated about what the engine will and will not do to the operator's tree. The contract has six guarantees; the engine enforces all six and fails dispatches that would violate them.
24
24
 
25
25
  ### 1. Per-project mutating-concurrency cap of 1
26
26
 
@@ -125,7 +125,28 @@ Pool short-circuits live in `engine.js:1368` and `engine/cleanup.js`; both gate
125
125
 
126
126
  Either condition fails the dispatch non-retryably with `FAILURE_CLASS.LIVE_CHECKOUT_MID_OPERATION` (`'live-checkout-mid-operation'`; added to `engine/dispatch.js`'s neverRetry set alongside `LIVE_CHECKOUT_DIRTY`). `spawnAgent` writes a `live-checkout-blocked-<wi-id>` inbox alert and stamps the work item `_pendingReason: 'live_checkout_mid_operation'` (or `'live_checkout_detached_head'` for the detached case). The recovery guidance tells the operator to finish or abort the in-progress op with their own commands (`git <op> --continue` / `git <op> --abort`), or checkout a branch, then re-dispatch. The engine never runs `git reset`, `git clean`, `git stash`, `git rebase --abort`, or moves HEAD on the operator's behalf.
127
127
 
128
- ### 6. Refuse on stale non-main base when forking a new branch (W-mr3lunnq000o9f41)
128
+ ### 6. STALE-BASE GUARD refuse to fork off a diverged local base (W-mr98op8w000ma4ad)
129
+
130
+ A **clean** tree can still hide **committed** contamination. When `prepareLiveCheckout` is about to create a **new** branch (`git checkout -b <branch>`) off the operator's current HEAD, and HEAD is sitting on the project base ref (`mainRef`, the common case), the local `mainRef` branch may carry commits that were never pushed to `origin/<mainRef>` — e.g. leftover in-progress work from a prior dispatch on a live-mode project (capped to one mutating dispatch, but shared across many sequential dispatches), an interrupted process, or a bad rebase. Forking a new branch off that base silently inherits those commits into the branch **and its PR**. This is exactly the scope-contamination class first seen on ADO PR 5411214 and recurring on AB#12016662 / ADO PR 5419146 (~20 unrelated OCM files baked into an otherwise-clean 2-file a11y fix).
131
+
132
+ It survives the dirty check (Guarantee 2) because the contamination is **committed**, not uncommitted, and it cannot be caught by a "HEAD is on the wrong branch" check because here HEAD **is** on `mainRef`.
133
+
134
+ The guard detects it **without a fetch** (preserving issue #226's no-forced-fetch guarantee) by comparing the local `refs/heads/<mainRef>` tip against the **already-present** `refs/remotes/origin/<mainRef>` remote-tracking ref:
135
+
136
+ ```
137
+ git rev-list --count refs/remotes/origin/<mainRef>..<mainRef> # > 0 ⇒ local base is ahead / contaminated
138
+ ```
139
+
140
+ A count `> 0` returns `{ ok:false, reason:'stale-main', mainRef, ahead, localMainSha, originMainSha, branch, originalRef, originalRefType }`. Being merely *behind* `origin/<mainRef>` (fewer commits) is **not** flagged — that just yields a branch off an older tip, not contamination — so only the local-ahead side is counted. The guard **skips** (proceeds normally) when:
141
+
142
+ - HEAD is not on `mainRef` (the fork base is something the operator/engine deliberately parked there — out of scope);
143
+ - no local `origin/<mainRef>` remote-tracking ref exists (local-only / no-remote repo — comparison would require the forbidden fetch, and the contamination class only exists when an origin baseline is present to diverge from);
144
+ - the divergence count cannot be computed (a transient `rev-list` failure must not become a permanent refusal).
145
+
146
+ `spawnAgent` completes the dispatch non-retryably with the dedicated **`FAILURE_CLASS.LIVE_CHECKOUT_STALE_BASE`** (`'live-checkout-stale-base'`; in `dispatch.js`'s `neverRetry` set) plus a `live-checkout-stale-base-<wi-id>` inbox alert and a `_pendingReason: 'live_checkout_stale_base'` stamp — instead of `LIVE_CHECKOUT_FAILED` (retryable) retry-storming an identical structural failure to `maxRetries`. The engine **never** resets, cleans, or rebases the operator's local `mainRef` — surfacing it as a refusal keeps history rewrites in the operator's hands, consistent with the dirty and mid-operation refusals. The alert's recovery guidance offers two paths: salvage the extra commits onto their own branch then `git reset --hard origin/<mainRef>`, or discard them with the same reset — either leaves `mainRef` matching `origin/<mainRef>` so the next dispatch forks off a clean base.
147
+
148
+
149
+ ### 7. Refuse on stale non-main base when forking a new branch (W-mr3lunnq000o9f41)
129
150
 
130
151
  When a live-checkout dispatch needs a **new** branch, `git checkout -b <branch>` seeds it from the operator's **current HEAD** (issue #226 — live mode never fetches `origin/<mainRef>` to avoid auth-less GVFS blob fetches). That is safe only when HEAD is already on the project's base branch. If a human — or a prior dispatch — left the operator checkout sitting on some unrelated topic/feature branch, the new branch (and therefore its PR) would silently inherit **every commit already on that topic branch**. This is scope contamination baked into git history, not a stray uncommitted diff, so it survives the dirty-tree (Guarantee 2) and mid-operation (Guarantee 5) preflights, which only check for uncommitted changes / in-progress ops, never *"is HEAD on the expected base?"*. The motivating incident is ADO PR `!5411214`, which silently carried ~22 unrelated OCM files the PR author disavowed.
131
152
 
@@ -136,12 +157,12 @@ Immediately before the new-branch `git checkout -b`, `prepareLiveCheckout` compa
136
157
  - **HEAD off-base, local `refs/heads/<mainRef>` exists** → PLAIN `git checkout <mainRef>` (no fetch, no reset, no `--force` — the tree was verified clean at Guarantee 2), then forks off it. No auth-less GVFS blob fetch is forced in the common case.
137
158
  - **HEAD off-base, no usable local `mainRef`** (fresh-clone edge case) — or the local checkout itself fails — → **fail closed** with `{ ok:false, reason:'wrong-base' }` **unless auto-base-repair is enabled (see §6a).** The caller refuses **non-retryably** with `FAILURE_CLASS.LIVE_CHECKOUT_WRONG_BASE` (`'live-checkout-wrong-base'`; added to `engine/dispatch.js`'s neverRetry set), writes an inbox alert naming the off-base HEAD, the expected base, and the exact `git checkout <mainRef>` recovery command, and stamps the work item `_pendingReason`. This mirrors the dirty / mid-operation philosophy of refusing on ambiguous operator state rather than silently forking off the wrong base.
138
159
 
139
- #### 6a. Opt-in auto-base-repair (`liveCheckoutAutoBaseRepair`, W-mr3lunnq000o9f41)
160
+ #### 7a. Opt-in auto-base-repair (`liveCheckoutAutoBaseRepair`, W-mr3lunnq000o9f41)
140
161
 
141
162
  The fail-closed refusal above is safe but forces a manual `git checkout <mainRef>` before every re-dispatch. Enabling **`liveCheckoutAutoBaseRepair`** (per-project `project.liveCheckoutAutoBaseRepair` > fleet-wide `engine.liveCheckoutAutoBaseRepair` > **`false`**, resolved by `shared.resolveLiveCheckoutAutoBaseRepair`) lets the engine self-heal the *missing-local-base* case:
142
163
 
143
164
  - When HEAD is off-base **and** there is no local `refs/heads/<mainRef>` **but** a `refs/remotes/origin/<mainRef>` remote-tracking ref exists, the guard materializes a local base branch from it — `git branch --no-track <mainRef> refs/remotes/origin/<mainRef>` — then proceeds exactly like the local-base path (plain `git checkout <mainRef>`, then fork).
144
- - **Issue #226 is preserved:** `origin/<mainRef>` is consumed as a **local ref** — there is **no `git fetch`**. `origin/<mainRef>` is the *correct* project base, so there is **no scope-contamination risk** (the entire point of Guarantee 6 survives — it never forks off the stale topic HEAD).
165
+ - **Issue #226 is preserved:** `origin/<mainRef>` is consumed as a **local ref** — there is **no `git fetch`**. `origin/<mainRef>` is the *correct* project base, so there is **no scope-contamination risk** (the entire point of Guarantee 7 survives — it never forks off the stale topic HEAD).
145
166
  - **Bounded, degrades safely:** the subsequent `git checkout <mainRef>` may hydrate blobs on a partial clone (the one operation issue #226 flagged as hang-prone in headless sessions). It is bounded by the git-wrapper timeout; on failure/hang it falls through to the **same** fail-closed `wrong-base` refusal — strictly no worse than the non-repair path.
146
167
  - **Still fail-closed** when neither a local base nor an `origin/<mainRef>` tracking ref exists, or when the base checkout itself fails. Default **OFF**.
147
168
 
@@ -308,6 +329,7 @@ Live-checkout mode is deliberately small. These are NOT supported and will not b
308
329
  | `engine/shared.js` — `FAILURE_CLASS.LIVE_CHECKOUT_MID_OPERATION` | Non-retryable refusal class for a mid-operation / detached-HEAD operator tree (in-progress merge/rebase/cherry-pick/revert/bisect or detached HEAD), distinct from the dirty-tree class. Emitted by `spawnAgent`'s mid-op / detached-HEAD refusal block (P-a7f3c1d9; wired P-c5a1f3b8). |
309
330
  | `engine/shared.js` — `FAILURE_CLASS.LIVE_CHECKOUT_BLOB_FETCH` | Non-retryable refusal class for an existing-branch checkout that could not hydrate the tree on a blobless GVFS partial clone (auth-less cache fetch, headless) — deterministic, so excluded from mechanical retry (PL-live-checkout-reliability-hardening). |
310
331
  | `engine/shared.js` — `FAILURE_CLASS.LIVE_CHECKOUT_WORKTREE_CONFLICT` | Non-retryable refusal class for an existing-branch checkout that refused because the branch is already checked out in another worktree — structural, so excluded from mechanical retry (W-mr28h2j2000y0de1). |
332
+ | `engine/shared.js` — `FAILURE_CLASS.LIVE_CHECKOUT_STALE_BASE` | Non-retryable refusal class for a new-branch fork whose local `mainRef` base has diverged from `origin/<mainRef>` with unpushed commits (STALE-BASE GUARD) — detected fetch-free via `git rev-list --count origin/<mainRef>..<mainRef>`; deterministic, so excluded from mechanical retry (W-mr98op8w000ma4ad). |
311
333
  | `engine.js` — `_liveCheckoutDirtyAttempts` counter | Dedicated two-strike dirty memory (survives the discovery + retry `_pendingReason` scrubs that defeated #434); first dirty failure retries once, second fails non-retryably (PL-live-checkout-reliability-hardening) — **unless** `liveCheckoutAutoReset`/`liveCheckoutAutoStash` is enabled, in which case the two-strike cap is skipped entirely and the dirty failure stays retryable up to `maxRetries` (W-mqzmkoqt000hbca2, #582). |
312
334
  | `engine/dispatch.js` — `isRetryableFailureReason` neverRetry | Excludes `LIVE_CHECKOUT_DIRTY`, `LIVE_CHECKOUT_MID_OPERATION`, `LIVE_CHECKOUT_BLOB_FETCH`, and `LIVE_CHECKOUT_WORKTREE_CONFLICT` from mechanical retry. |
313
335
  | `engine/timeout.js` header comment | Confirms no special live-mode kill handling. |
@@ -749,6 +749,7 @@ function isRetryableFailureReason(reason = '', failureClass = '') {
749
749
  FAILURE_CLASS.LIVE_CHECKOUT_MID_OPERATION, // P-a7f3c1d9 — live-checkout refused to spawn because the operator tree is mid-operation (in-progress merge/rebase/cherry-pick/bisect or detached HEAD); mechanical retry won't fix it (operator must finish/abort the op or checkout a branch)
750
750
  FAILURE_CLASS.LIVE_CHECKOUT_BLOB_FETCH, // PL-live-checkout-reliability-hardening — live-checkout `git checkout <existing-branch>` failed hydrating the tree through the auth-less GVFS cache server (blobless partial clone, headless); deterministic, so mechanical retry just reproduces it (operator must hydrate the branch with their own creds, then re-dispatch)
751
751
  FAILURE_CLASS.LIVE_CHECKOUT_WORKTREE_CONFLICT, // W-mr28h2j2000y0de1 — live-checkout `git checkout <existing-branch>` failed because that branch is already checked out in another worktree; structural conflict, so mechanical retry just reproduces it (operator must `git worktree remove` the other tree or finish its WIP, then re-dispatch)
752
+ FAILURE_CLASS.LIVE_CHECKOUT_STALE_BASE, // W-mr98op8w000ma4ad — live-checkout STALE-BASE GUARD refused to fork a new branch off a local mainRef that has diverged from origin/<mainRef> with unpushed commits; contaminated local base does not change on retry (operator must reconcile the local base branch — the engine never resets it — then re-dispatch)
752
753
  FAILURE_CLASS.LIVE_CHECKOUT_WRONG_BASE, // W-mr3lunnq000o9f41 — live-checkout refused to fork a new branch because the operator HEAD is on an unrelated branch (not the project base) and no local base ref was usable; forking off it would bake that branch's commits into the new PR (scope contamination). Deterministic — mechanical retry reproduces it (operator must `git checkout <mainRef>`, then re-dispatch)
753
754
  FAILURE_CLASS.OUTPUT_TRUNCATED, // P-8e4c2a17 — agent stdout exceeded the hard capture cap before the terminal result event; mechanical retry just reproduces the overflow (agent must reduce output volume or the task must be split)
754
755
  ]);
@@ -121,6 +121,11 @@ function _entryAgeMs(entry, now) {
121
121
  * (absent from the TTL map) are never touched. Pinned entries are already
122
122
  * excluded upstream (they never enter the manifest), and knowledge/agents/ is
123
123
  * never scanned into the manifest at all — so per-agent memory is inherently safe.
124
+ * Consolidation digests are also exempt — like the rewrite pass (`_isDigestEntry`
125
+ * guard below), a digest's `date` frontmatter is inherited from the old
126
+ * period-bucket of the boilerplate notes it rolled up, not its own write time, so
127
+ * age-based TTL would silently archive a freshly-written digest on the very next
128
+ * sweep if it were not excluded here.
124
129
  *
125
130
  * @returns {{ expired:number, survivors:object[] }}
126
131
  */
@@ -132,6 +137,7 @@ function _expireByAge(manifest, opts = {}) {
132
137
  let expired = 0;
133
138
  const survivors = [];
134
139
  for (const e of manifest) {
140
+ if (_isDigestEntry(e)) { survivors.push(e); continue; }
135
141
  const ttlDays = ttlByCategory[e.category];
136
142
  if (!ttlDays) { survivors.push(e); continue; }
137
143
  if (_entryAgeMs(e, now) <= ttlDays * DAY_MS) { survivors.push(e); continue; }
@@ -168,6 +174,252 @@ function _hashDedup(manifest, opts = {}) {
168
174
  return { survivors, archived };
169
175
  }
170
176
 
177
+ // ── Structural consolidation (W-mr973knu) ───────────────────────────────────
178
+ // The primary fix for the ballooning KB. Hash-dedup only catches near-exact
179
+ // duplicates and the LLM batch pass only compares entries *within* a 30-entry
180
+ // batch, so the dominant cost — hundreds of one-off boilerplate notes (agent
181
+ // failure dumps, no-op / merge-conflict fix reports) that differ only by
182
+ // WI/date/dispatch id — is never collapsed: each is unique by PR#/date and
183
+ // spread across dozens of batches. This pass groups those boilerplate notes by
184
+ // a *structural* signature (category + agent + kind + time bucket) rather than
185
+ // by content, and rolls each large group into a single recoverable digest,
186
+ // archiving the originals to knowledge/_swept/. It runs on RECENT entries too,
187
+ // so it shrinks the KB continuously instead of merely aging files out.
188
+
189
+ const CONSOLIDATION_DIGEST_PREFIX = '_digest-';
190
+ const CONSOLIDATION_DIGEST_FLAG = '_digest'; // frontmatter marker on a digest (never re-consolidated / re-written)
191
+ const DAY_MS = 24 * 60 * 60 * 1000;
192
+
193
+ /**
194
+ * Resolve the effective structural-consolidation config, merging
195
+ * `opts.engineConfig.kbConsolidation` over `ENGINE_DEFAULTS.kbConsolidation`.
196
+ * `enabled` is false when no categories are configured or an explicit
197
+ * `enabled: false` override is present.
198
+ */
199
+ function _resolveConsolidationConfig(opts = {}) {
200
+ const defaults = (shared.ENGINE_DEFAULTS && shared.ENGINE_DEFAULTS.kbConsolidation) || {};
201
+ const override = (opts.engineConfig && opts.engineConfig.kbConsolidation) || {};
202
+ const merged = { ...defaults, ...override };
203
+ const categories = Array.isArray(merged.categories)
204
+ ? merged.categories.filter(c => typeof c === 'string' && c)
205
+ : [];
206
+ const minGroupSize = Number(merged.minGroupSize);
207
+ const periodDays = Number(merged.periodDays);
208
+ return {
209
+ enabled: merged.enabled !== false && categories.length > 0,
210
+ categories: new Set(categories),
211
+ minGroupSize: Number.isFinite(minGroupSize) && minGroupSize >= 2 ? Math.floor(minGroupSize) : 5,
212
+ periodDays: Number.isFinite(periodDays) && periodDays > 0 ? periodDays : 7,
213
+ };
214
+ }
215
+
216
+ /** First `^key: value$` match anywhere in the entry content (handles the
217
+ * double-frontmatter shape of agent-authored notes). '' when absent. */
218
+ function _extractHeadField(content, key) {
219
+ const re = new RegExp('^' + key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ':\\s*(.+)$', 'm');
220
+ const m = String(content || '').match(re);
221
+ return m ? m[1].trim() : '';
222
+ }
223
+
224
+ /** True when an entry is itself a consolidation digest — such entries are never
225
+ * re-grouped and never rewritten. */
226
+ function _isDigestEntry(entry) {
227
+ if (!entry) return false;
228
+ if (typeof entry.file === 'string' && entry.file.startsWith(CONSOLIDATION_DIGEST_PREFIX)) return true;
229
+ if (entry._digest) return true;
230
+ const content = entry.content || '';
231
+ if (_extractHeadField(content, CONSOLIDATION_DIGEST_FLAG)) return true;
232
+ if (_extractHeadField(content, 'source') === 'kb-consolidation') return true;
233
+ return false;
234
+ }
235
+
236
+ /**
237
+ * Classify an entry into a consolidatable "kind", or null when it is not
238
+ * boilerplate we roll up (unique/durable notes are left alone). Kinds target
239
+ * the two dominant balloon sources: agent-failure dumps (keyed by failureClass)
240
+ * and no-op / merge-conflict fix reports.
241
+ * @returns {{ key:string, label:string }|null}
242
+ */
243
+ function _consolidationKind(entry) {
244
+ const content = entry.content || '';
245
+ const failureClass = _extractHeadField(content, 'failureClass');
246
+ if (failureClass) return { key: `failure-${failureClass}`, label: `${failureClass} failures` };
247
+ const result = _extractHeadField(content, 'result');
248
+ const title = String(entry.title || '').toLowerCase();
249
+ if (/\berror\b/i.test(result) || /agent fail/.test(title)) {
250
+ return { key: 'failure-unknown', label: 'agent failures' };
251
+ }
252
+ if (/\bno-?op\b/.test(title)) return { key: 'noop', label: 'no-op dispatches' };
253
+ if (/merge.?conflict/.test(title)) return { key: 'merge-conflict', label: 'merge-conflict fixes' };
254
+ return null;
255
+ }
256
+
257
+ /** ISO date (YYYY-MM-DD) of the start of the `periodDays` bucket an entry falls
258
+ * in, from its authored date (mtime fallback). 'undated' when neither known. */
259
+ function _periodBucket(entry, periodDays) {
260
+ const parsed = typeof entry.date === 'string' && entry.date ? Date.parse(entry.date) : NaN;
261
+ const ms = Number.isFinite(parsed)
262
+ ? parsed
263
+ : (Number.isFinite(entry.mtimeMs) && entry.mtimeMs > 0 ? entry.mtimeMs : NaN);
264
+ if (!Number.isFinite(ms)) return 'undated';
265
+ const dayIdx = Math.floor(ms / DAY_MS);
266
+ const startMs = Math.floor(dayIdx / periodDays) * periodDays * DAY_MS;
267
+ return new Date(startMs).toISOString().slice(0, 10);
268
+ }
269
+
270
+ function _slugify(s) {
271
+ return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'x';
272
+ }
273
+
274
+ function _digestFileName(agent, kindKey, period) {
275
+ return `${CONSOLIDATION_DIGEST_PREFIX}${_slugify(agent)}-${_slugify(kindKey)}-${period}.md`;
276
+ }
277
+
278
+ /** Extract the compact per-note fields that become one digest table row. */
279
+ function _digestRow(entry) {
280
+ const content = entry.content || '';
281
+ const wi = _extractHeadField(content, 'sourceItem')
282
+ || (content.match(/\bW-[a-z0-9]{6,}/i) || [''])[0]
283
+ || '';
284
+ const dispatch = _extractHeadField(content, 'dispatchId');
285
+ const failureClass = _extractHeadField(content, 'failureClass');
286
+ let reason = _extractHeadField(content, 'Reason') || _extractHeadField(content, 'reason');
287
+ if (!reason) {
288
+ const rm = content.match(/(?:^|\n)\s*(?:\*\*)?Reason:(?:\*\*)?\s*(.+)/i);
289
+ if (rm) reason = rm[1];
290
+ }
291
+ if (!reason) {
292
+ // First prose line after the last frontmatter block / heading.
293
+ const body = content.replace(/^---\n[\s\S]*?\n---\n?/g, '').replace(/^#.*$/m, '');
294
+ const line = (body.split('\n').find(l => l.trim() && !l.trim().startsWith('#')) || '').trim();
295
+ reason = line;
296
+ }
297
+ reason = String(reason || '').replace(/\|/g, '\\|').replace(/\s+/g, ' ').trim().slice(0, 160);
298
+ return {
299
+ date: entry.date || '',
300
+ wi,
301
+ dispatch,
302
+ failureClass,
303
+ reason: reason || '—',
304
+ file: entry.file,
305
+ };
306
+ }
307
+
308
+ /** Parse the `source file` column out of an existing digest so re-runs merge
309
+ * rather than clobber. Returns a Map<sourceFile, rowLine>. */
310
+ function _readExistingDigestRows(digestPath) {
311
+ const rows = new Map();
312
+ const content = safeReadOrNull(digestPath);
313
+ if (!content) return rows;
314
+ for (const line of content.split('\n')) {
315
+ if (!line.startsWith('| ')) continue;
316
+ const cells = line.split('|').map(c => c.trim());
317
+ // columns: '' | date | work item | dispatch | failureClass | reason | source file | ''
318
+ const src = cells[cells.length - 2];
319
+ if (!src || src === 'source file' || /^-+$/.test(src)) continue;
320
+ rows.set(src, line);
321
+ }
322
+ return rows;
323
+ }
324
+
325
+ function _formatDigestRowLine(r) {
326
+ return `| ${r.date} | ${r.wi || '—'} | ${r.dispatch || '—'} | ${r.failureClass || '—'} | ${r.reason} | ${r.file} |`;
327
+ }
328
+
329
+ function _buildDigestContent(group, rowLines, count) {
330
+ const fm = [
331
+ `category: ${group.category}`,
332
+ `agent: ${group.agent}`,
333
+ `date: ${group.period}`,
334
+ `${CONSOLIDATION_DIGEST_FLAG}: ${new Date().toISOString()}`,
335
+ 'source: kb-consolidation',
336
+ `_consolidatedCount: ${count}`,
337
+ ].join('\n');
338
+ const header = `# Consolidated: ${group.agent} — ${group.kind.label} (from ${group.period})`;
339
+ const intro = `Rolled up ${count} near-identical ${group.kind.label} note(s) into this single digest. `
340
+ + 'Originals were archived to `knowledge/_swept/` (recoverable for 30 days). '
341
+ + 'Each row is one collapsed note; recover a specific one from `_swept/` by its `source file`.';
342
+ const table = [
343
+ '| date | work item | dispatch | failureClass | reason | source file |',
344
+ '|------|-----------|----------|--------------|--------|-------------|',
345
+ ...rowLines,
346
+ ].join('\n');
347
+ return `---\n${fm}\n---\n\n${header}\n\n${intro}\n\n${table}\n`;
348
+ }
349
+
350
+ /**
351
+ * Structural consolidation pass. Groups boilerplate transient-category notes by
352
+ * (category, agent, kind, time-bucket) and collapses any group of at least
353
+ * `minGroupSize` into a single recoverable digest, archiving the originals.
354
+ * Digests, durable categories, pinned entries (already filtered upstream), and
355
+ * unclassifiable/unique notes are left untouched.
356
+ *
357
+ * @returns {{ survivors:object[], consolidated:number, groupsCollapsed:number, digestsWritten:number }}
358
+ */
359
+ function _structuralConsolidate(manifest, opts = {}) {
360
+ const cfg = _resolveConsolidationConfig(opts);
361
+ if (!cfg.enabled) {
362
+ return { survivors: manifest.slice(), consolidated: 0, groupsCollapsed: 0, digestsWritten: 0 };
363
+ }
364
+ const groups = new Map(); // groupKey → { agent, category, kind, period, entries[] }
365
+ const survivors = [];
366
+ for (const e of manifest) {
367
+ if (!cfg.categories.has(e.category) || _isDigestEntry(e)) { survivors.push(e); continue; }
368
+ const kind = _consolidationKind(e);
369
+ if (!kind) { survivors.push(e); continue; }
370
+ const agent = e.agent || 'unknown';
371
+ const period = _periodBucket(e, cfg.periodDays);
372
+ const groupKey = `${e.category}::${agent}::${kind.key}::${period}`;
373
+ if (!groups.has(groupKey)) groups.set(groupKey, { agent, category: e.category, kind, period, entries: [] });
374
+ groups.get(groupKey).entries.push(e);
375
+ }
376
+
377
+ let consolidated = 0, groupsCollapsed = 0, digestsWritten = 0;
378
+ for (const g of groups.values()) {
379
+ if (g.entries.length < cfg.minGroupSize) { survivors.push(...g.entries); continue; }
380
+ g.entries.sort((a, b) => (b.date || '').localeCompare(a.date || '') || (b.mtimeMs - a.mtimeMs));
381
+
382
+ if (opts.dryRun) {
383
+ consolidated += g.entries.length;
384
+ groupsCollapsed++;
385
+ digestsWritten++;
386
+ // Nothing archived in dryRun — the originals remain on disk.
387
+ survivors.push(...g.entries);
388
+ continue;
389
+ }
390
+
391
+ const digestFile = _digestFileName(g.agent, g.kind.key, g.period);
392
+ const digestPath = path.join(KB_DIR, g.category, digestFile);
393
+ // Merge with any prior digest for this exact group so repeated sweeps
394
+ // accumulate rows instead of clobbering earlier ones.
395
+ const merged = _readExistingDigestRows(digestPath);
396
+ for (const e of g.entries) {
397
+ const r = _digestRow(e);
398
+ merged.set(r.file, _formatDigestRowLine(r));
399
+ }
400
+ const rowLines = [...merged.values()];
401
+ const digestContent = _buildDigestContent(g, rowLines, rowLines.length);
402
+ try {
403
+ const dir = path.dirname(digestPath);
404
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
405
+ safeWrite(digestPath, digestContent);
406
+ digestsWritten++;
407
+ } catch (err) {
408
+ log('warn', `[kb-sweep] consolidate write ${g.category}/${digestFile}: ${err.message}`);
409
+ survivors.push(...g.entries); // write failed — keep originals visible
410
+ continue;
411
+ }
412
+ let collapsed = 0;
413
+ for (const e of g.entries) {
414
+ const fp = path.join(KB_DIR, e.category, e.file);
415
+ if (!fs.existsSync(fp)) continue;
416
+ if (_archiveKbFile(fp, `structurally consolidated into ${g.category}/${digestFile}`)) { consolidated++; collapsed++; }
417
+ }
418
+ if (collapsed > 0) groupsCollapsed++;
419
+ }
420
+ return { survivors, consolidated, groupsCollapsed, digestsWritten };
421
+ }
422
+
171
423
  /** Batched LLM sweep — finds within-batch dupes, reclassifies, removes stale. */
172
424
  async function _llmBatchSweep(manifest, callLLM, trackEngineUsage, opts = {}) {
173
425
  const plan = { duplicates: [], reclassify: [], remove: [] };
@@ -252,6 +504,9 @@ ${body}`;
252
504
 
253
505
  const candidates = [];
254
506
  for (const e of survivors) {
507
+ // Never rewrite a consolidation digest — the rewrite template would destroy
508
+ // its roll-up table.
509
+ if (_isDigestEntry(e)) continue;
255
510
  const fp = path.join(KB_DIR, e.category, e.file);
256
511
  const content = safeRead(fp);
257
512
  if (content == null) continue;
@@ -540,6 +795,9 @@ async function _runKbSweepImpl(opts = {}) {
540
795
  llmDuplicatesArchived: 0,
541
796
  staleRemoved: 0,
542
797
  ageExpired: 0,
798
+ notesConsolidated: 0,
799
+ consolidationGroups: 0,
800
+ digestsWritten: 0,
543
801
  reclassified: 0,
544
802
  rewritten: 0,
545
803
  rewriteBytesBefore: 0,
@@ -579,27 +837,41 @@ async function _runKbSweepImpl(opts = {}) {
579
837
  const { survivors: afterHash, archived: hashArchived } = _hashDedup(manifest, opts);
580
838
  summary.hashDuplicatesArchived = hashArchived;
581
839
 
840
+ // 1.5. Structural consolidation (W-mr973knu) — PRIMARY balloon fix. Collapse
841
+ // large groups of near-identical boilerplate notes (agent-failure dumps, no-op
842
+ // / merge-conflict reports) into one recoverable digest per (agent, kind,
843
+ // week). Runs on recent entries too, so it shrinks the KB continuously rather
844
+ // than merely aging files out. Downstream passes operate on the survivors so
845
+ // the LLM/rewrite passes no longer waste calls on the collapsed notes.
846
+ const consolidateResult = _structuralConsolidate(afterHash, opts);
847
+ summary.notesConsolidated = consolidateResult.consolidated;
848
+ summary.consolidationGroups = consolidateResult.groupsCollapsed;
849
+ summary.digestsWritten = consolidateResult.digestsWritten;
850
+ const afterConsolidate = consolidateResult.survivors.filter(
851
+ e => e._digest || fs.existsSync(path.join(KB_DIR, e.category, e.file)),
852
+ );
853
+
582
854
  // 2. LLM batch sweep — within-batch dupes + reclassify + remove stale
583
855
  // Only runs against survivors, but we need indices that match the LIST sent to the LLM
584
- const llmManifest = afterHash;
856
+ const llmManifest = afterConsolidate;
585
857
  const plan = await _llmBatchSweep(llmManifest, callLLM, trackEngineUsage, opts);
586
858
  const llmActions = _applyLlmPlan(plan, llmManifest, opts);
587
859
  summary.llmDuplicatesArchived = llmActions.merged;
588
860
  summary.staleRemoved = llmActions.removed;
589
861
  summary.reclassified = llmActions.reclassified;
590
862
 
591
- // 2.5. Age-based TTL expiry (W-mr48yth5) — archive transient-category entries
592
- // older than their per-category TTL to knowledge/_swept/. This is the pass that
593
- // keeps PR/incident-scoped one-off notes (project-notes, build-reports, reviews)
594
- // from accumulating indefinitely dedup/rewrite can't reclaim them because each
595
- // is unique by PR#/date. Durable categories (no TTL) are untouched.
596
- const survivingAfterLlm = afterHash.filter(e => fs.existsSync(path.join(KB_DIR, e.category, e.file)));
863
+ // 2.5. Age-based TTL expiry (W-mr48yth5) — SECONDARY safety net. Archives
864
+ // transient-category entries older than their per-category TTL to
865
+ // knowledge/_swept/. Structural consolidation above is the primary reducer;
866
+ // this catches genuinely stale one-offs that never clustered into a digest.
867
+ // Durable categories (no TTL) are untouched.
868
+ const survivingAfterLlm = afterConsolidate.filter(e => fs.existsSync(path.join(KB_DIR, e.category, e.file)));
597
869
  const ageResult = _expireByAge(survivingAfterLlm, opts);
598
870
  summary.ageExpired = ageResult.expired;
599
871
 
600
872
  // 3. Per-entry rewrite (compress + normalize)
601
- // Filter to entries that survived hash + LLM + age passes (still on disk)
602
- const stillOnDisk = afterHash.filter(e => fs.existsSync(path.join(KB_DIR, e.category, e.file)));
873
+ // Filter to entries that survived hash + consolidation + LLM + age passes (still on disk)
874
+ const stillOnDisk = afterConsolidate.filter(e => fs.existsSync(path.join(KB_DIR, e.category, e.file)));
603
875
  const rewriteResult = await _rewritePass(stillOnDisk, callLLM, trackEngineUsage, opts);
604
876
  summary.rewritten = rewriteResult.processed;
605
877
  summary.rewriteBytesBefore = rewriteResult.bytesBefore;
@@ -620,7 +892,7 @@ async function _runKbSweepImpl(opts = {}) {
620
892
  }
621
893
 
622
894
  summary.durationMs = Date.now() - t0;
623
- summary.summary = `${summary.hashDuplicatesArchived} hash-dup, ${summary.llmDuplicatesArchived} llm-dup, ${summary.staleRemoved} stale, ${summary.ageExpired} age-expired, ${summary.reclassified} reclassified, ${summary.rewritten} rewritten (${(summary.bytesBefore - summary.bytesAfter).toLocaleString()} bytes saved)`;
895
+ summary.summary = `${summary.hashDuplicatesArchived} hash-dup, ${summary.notesConsolidated} consolidated (${summary.digestsWritten} digests), ${summary.llmDuplicatesArchived} llm-dup, ${summary.staleRemoved} stale, ${summary.ageExpired} age-expired, ${summary.reclassified} reclassified, ${summary.rewritten} rewritten (${(summary.bytesBefore - summary.bytesAfter).toLocaleString()} bytes saved)`;
624
896
 
625
897
  if (!opts.dryRun) {
626
898
  try { shared.mutateKbSwept(() => ({ timestamp: ts(), summary: summary.summary, detail: summary })); } catch { /* ignore */ }
@@ -781,6 +1053,17 @@ module.exports = {
781
1053
  _entryAgeMs,
782
1054
  _resolveTtlByCategory,
783
1055
  _archiveKbFile,
1056
+ _expireByAge,
1057
+ _entryAgeMs,
1058
+ _resolveTtlByCategory,
1059
+ _structuralConsolidate,
1060
+ _resolveConsolidationConfig,
1061
+ _consolidationKind,
1062
+ _periodBucket,
1063
+ _digestRow,
1064
+ _isDigestEntry,
1065
+ _extractHeadField,
1066
+ CONSOLIDATION_DIGEST_PREFIX,
784
1067
  COMPRESS_THRESHOLD_BYTES,
785
1068
  SWEPT_FLAG_KEY,
786
1069
  };
@@ -39,25 +39,30 @@
39
39
  * NO fast-forward, NO reset). The operator owns the checkout
40
40
  * state per the live-checkout contract; the engine never
41
41
  * overwrites local commits.
42
- * b. rev-parse fails → branch is new → fork it. STALE-BASE GUARD
43
- * (W-mr3lunnq000o9f41) runs first, UNLESS `allowNonMainBase` is set
44
- * (PR-targeted fixes / shared-branch continuations opt out — they
45
- * intend to keep building on a non-main branch): if HEAD's branch is
46
- * NOT the project base (`mainRef`), forking off it would silently bake
47
- * every commit already on that branch into the new branch and its PR
48
- * (the ADO PR 5411214 scope-contamination incident — survives the
49
- * dirty/mid-operation preflights because it is committed history, not
50
- * an uncommitted diff). Recovery is LOCAL-ONLY (preserves issue #226's
51
- * no-forced-fetch guarantee): if `refs/heads/<mainRef>` exists locally,
52
- * PLAIN-checkout it (no fetch/reset/--force) and fork off it; else
53
- * FAIL CLOSED with { ok:false, reason:'wrong-base' }. When HEAD is
54
- * already on `mainRef` (or the guard is bypassed) →
55
- * `git checkout -b <branchName>` from the CURRENT HEAD (NOT
56
- * origin/<mainRef>). In live mode the operator's checked-out HEAD IS
57
- * the baseline; branching off origin/<mainRef> forces on-demand blob
58
- * fetching on blobless partial clones (Scalar/GVFS-managed ADO repos),
59
- * which fails in headless mode because the GVFS cache server receives
60
- * no auth and credential.helper is disabled. See issue #226.
42
+ * b. rev-parse fails → branch is new → fork it. Two ordered guards run
43
+ * before the fork:
44
+ * i. WRONG-BASE GUARD (W-mr3lunnq000o9f41) UNLESS `allowNonMainBase`
45
+ * is set (PR-targeted fixes / shared-branch continuations opt out):
46
+ * if HEAD's branch is NOT the project base (`mainRef`), forking off
47
+ * it would silently bake every commit already on that branch into
48
+ * the new branch and its PR (the ADO PR 5411214 scope-contamination
49
+ * incident). Recovery is LOCAL-ONLY (issue #226's no-forced-fetch
50
+ * guarantee): if `refs/heads/<mainRef>` exists locally, PLAIN-checkout
51
+ * it (no fetch/reset/--force) and fork off it; else (optionally after
52
+ * opt-in auto-base-repair materializes a local `<mainRef>` from the
53
+ * existing `refs/remotes/origin/<mainRef>` ref, W-mr3lunnq000o9f41)
54
+ * FAIL CLOSED with { ok:false, reason:'wrong-base' }.
55
+ * ii. STALE-BASE GUARD (W-mr98op8w000ma4ad) runs once HEAD is confirmed
56
+ * on `mainRef` (either it started there, or guard i left it there):
57
+ * if the local `mainRef` tip has diverged from `origin/<mainRef>`
58
+ * with unpushed commits, bail with { ok:false, reason:'stale-main', ... }
59
+ * rather than fork a contaminated base (see RETURN SHAPES).
60
+ * When both guards pass, `git checkout -b <branchName>` is created from
61
+ * the CURRENT HEAD (NOT origin/<mainRef>). In live mode the operator's
62
+ * checked-out HEAD IS the baseline; branching off origin/<mainRef> forces
63
+ * on-demand blob fetching on blobless partial clones (Scalar/GVFS-managed
64
+ * ADO repos), which fails in headless mode because the GVFS cache server
65
+ * receives no auth and credential.helper is disabled. See issue #226.
61
66
  * 5. Returns { ok:true, branch, created:boolean, originalRef, originalRefType }.
62
67
  *
63
68
  * RETURN SHAPES:
@@ -80,6 +85,19 @@
80
85
  * (W-mr28h2j2000y0de1) — the target branch is already checked out in another
81
86
  * worktree; `git checkout <branch>` refuses deterministically. Non-retryable
82
87
  * at the caller — a human/engine must remove or reassign the other worktree.
88
+ * { ok:false, reason:'stale-main', mainRef, ahead, localMainSha, originMainSha, branch, originalRef, originalRefType }
89
+ * (W-mr98op8w000ma4ad — STALE-BASE GUARD) — a NEW branch was about to be
90
+ * forked off the current HEAD while HEAD sits on the project base ref
91
+ * (mainRef), but the LOCAL mainRef tip has diverged from origin/<mainRef>
92
+ * with `ahead` unpushed commit(s). Forking would silently inherit that
93
+ * committed contamination into the new branch and its PR. Detected without a
94
+ * fetch (issue #226) by comparing local `refs/heads/<mainRef>` against the
95
+ * EXISTING `refs/remotes/origin/<mainRef>` ref via
96
+ * `git rev-list --count origin/<mainRef>..<mainRef>`. The guard is SKIPPED
97
+ * (proceeds normally) when HEAD is not on mainRef, when the origin/<mainRef>
98
+ * remote-tracking ref is absent (local-only/no-remote repo), or when the
99
+ * rev-list count cannot be computed (transient). Non-retryable at the caller
100
+ * — a human must reconcile the local base branch; the engine never resets it.
83
101
  *
84
102
  * NOTE: `mainRef` is still accepted (and validated) for caller-contract
85
103
  * stability, but it is NOT used to seed the new branch in live mode — HEAD
@@ -565,13 +583,32 @@ async function prepareLiveCheckout(opts = {}) {
565
583
  return { ok: false, reason: 'dirty', dirtyFiles, branchInfo };
566
584
  }
567
585
 
568
- if (typeof log === 'function') {
569
- log(`live-checkout auto-reset: discarding ${dirtyFiles.length} dirty path(s) on '${branchName}' via fetch + reset --hard origin/${branchName}`);
570
- }
586
+ // W-mrav3u0c0008ce38 live-checkout WI types that never push a branch
587
+ // (setup/test/verify live-validation dispatches on checkoutMode:'live'
588
+ // or hybrid liveValidation projects) leave `origin/<branchName>`
589
+ // unresolvable, so a bare `reset --hard origin/<branchName>` fails every
590
+ // time with "ambiguous argument … unknown revision" and the tree stays
591
+ // dirty forever. Probe `origin/<branchName>` first (after the fetch, so
592
+ // it reflects the latest remote state); only fall back to
593
+ // `origin/<mainRef>` when that branch genuinely doesn't exist upstream.
594
+ // PR-continuation flows (fix/implement WIs resuming an existing pushed
595
+ // branch) keep resetting to `origin/<branchName>` exactly as before.
571
596
  let resetOk = true;
597
+ let resetTarget = `origin/${branchName}`;
572
598
  try {
573
599
  await git(['fetch', 'origin'], baseOpts);
574
- await git(['reset', '--hard', `origin/${branchName}`], baseOpts);
600
+ try {
601
+ await git(['rev-parse', '--verify', resetTarget], baseOpts);
602
+ } catch {
603
+ resetTarget = `origin/${mainRef}`;
604
+ if (typeof log === 'function') {
605
+ log(`live-checkout auto-reset: origin/${branchName} does not exist (never pushed) — falling back to '${resetTarget}'`);
606
+ }
607
+ }
608
+ if (typeof log === 'function') {
609
+ log(`live-checkout auto-reset: discarding ${dirtyFiles.length} dirty path(s) on '${branchName}' via fetch + reset --hard ${resetTarget}`);
610
+ }
611
+ await git(['reset', '--hard', resetTarget], baseOpts);
575
612
  } catch (e) {
576
613
  resetOk = false;
577
614
  if (typeof log === 'function') {
@@ -613,7 +650,7 @@ async function prepareLiveCheckout(opts = {}) {
613
650
  '',
614
651
  `⚠️ The live-checkout tree at \`${localPath}\` was dirty at dispatch time and`,
615
652
  '`liveCheckoutAutoReset` is enabled, so it was force-reset to',
616
- `\`origin/${branchName}\`. **The following uncommitted changes were DISCARDED**`,
653
+ `\`${resetTarget}\`. **The following uncommitted changes were DISCARDED**`,
617
654
  '(recover from `git reflog` / `git fsck --lost-found` if needed):',
618
655
  '',
619
656
  '```',
@@ -949,6 +986,102 @@ async function prepareLiveCheckout(opts = {}) {
949
986
  }
950
987
  }
951
988
 
989
+
990
+ // ── Step 4d: STALE-BASE GUARD (W-mr98op8w000ma4ad). ───────────────────
991
+ // We are about to fork a NEW branch off the CURRENT HEAD. In the common
992
+ // live-mode case HEAD sits on the project base ref (mainRef). Step 1 (dirty
993
+ // check) and Step 2 (mid-operation/detached preflight) both passed, but a
994
+ // CLEAN tree can still hide COMMITTED contamination: a prior/parallel
995
+ // dispatch (or a human) may have left extra commits on the local mainRef
996
+ // branch that were never pushed to origin/<mainRef>. `git checkout -b` would
997
+ // then silently inherit those commits into the new branch (and its PR) — the
998
+ // exact scope-contamination class this guard prevents (original incident ADO
999
+ // PR 5411214; recurrence on AB#12016662 / ADO PR 5419146, ~20 unrelated OCM
1000
+ // files baked into a 2-file a11y fix). This survives the dirty check (the
1001
+ // contamination is COMMITTED, not uncommitted) and — unlike a HEAD parked on
1002
+ // some OTHER branch — cannot be spotted by a headBranch !== mainRef check,
1003
+ // because here headBranch IS mainRef.
1004
+ //
1005
+ // Detect it by comparing the local mainRef tip against the EXISTING
1006
+ // origin/<mainRef> remote-tracking ref (NO fetch — issue #226's
1007
+ // no-forced-fetch guarantee): any commit reachable from local mainRef but not
1008
+ // from origin/<mainRef> (`git rev-list --count origin/<mainRef>..<mainRef>` >
1009
+ // 0) means the base is contaminated with unpushed commits. Local mainRef
1010
+ // being merely BEHIND origin (fewer commits) is fine — that just yields a
1011
+ // branch based on an older tip, not contamination — so we count only the
1012
+ // local-ahead side, not strict equality.
1013
+ //
1014
+ // Scope: only when HEAD is actually on mainRef (that IS the base we fork
1015
+ // off). If HEAD is on some other branch the operator/engine deliberately
1016
+ // parked it there (e.g. the orphan-branch self-heal above) — out of scope.
1017
+ //
1018
+ // AMBIGUITY / local-only repos: if the origin/<mainRef> remote-tracking ref
1019
+ // does not exist locally we CANNOT compare without fetching, which #226
1020
+ // forbids. Rather than fail closed on legitimate local-only / no-remote repos
1021
+ // (which have no origin ref by design and for which forking off HEAD is the
1022
+ // only and expected behavior), we SKIP the check and log. Failing closed
1023
+ // there would regress those setups; the contamination class this targets only
1024
+ // exists when an origin baseline is present to diverge from. A transient
1025
+ // rev-list failure (origin ref present but the count could not be computed)
1026
+ // likewise proceeds without the check rather than converting a transient git
1027
+ // hiccup into a permanent non-retryable refusal.
1028
+ if (curBranch && curBranch === mainRef) {
1029
+ let originMainSha = '';
1030
+ try {
1031
+ const originRaw = await git(['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${mainRef}`], baseOpts);
1032
+ originMainSha = (typeof originRaw === 'string' ? originRaw : '').trim();
1033
+ } catch {
1034
+ originMainSha = ''; // remote-tracking ref absent → ambiguous, skip below
1035
+ }
1036
+ if (originMainSha) {
1037
+ let aheadCount = -1;
1038
+ try {
1039
+ const cntRaw = await git(['rev-list', '--count', `refs/remotes/origin/${mainRef}..${mainRef}`], baseOpts);
1040
+ aheadCount = parseInt((typeof cntRaw === 'string' ? cntRaw : '').trim(), 10);
1041
+ if (Number.isNaN(aheadCount)) aheadCount = -1;
1042
+ } catch {
1043
+ aheadCount = -1; // rev-list failed → transient/ambiguous; proceed without the check
1044
+ }
1045
+ if (aheadCount > 0) {
1046
+ let localMainSha = '';
1047
+ try {
1048
+ const localRaw = await git(['rev-parse', '--verify', '--quiet', `refs/heads/${mainRef}`], baseOpts);
1049
+ localMainSha = (typeof localRaw === 'string' ? localRaw : '').trim();
1050
+ } catch { /* best-effort — informational only */ }
1051
+ logFn(
1052
+ `[live-checkout] STALE-BASE GUARD: refusing to fork '${branchName}' off local '${mainRef}' — ` +
1053
+ `local '${mainRef}' is ${aheadCount} commit(s) ahead of 'origin/${mainRef}' (unpushed/contaminated base). ` +
1054
+ `A human must reconcile the local base branch before dispatch; the engine never resets it for you.`,
1055
+ 'warn'
1056
+ );
1057
+ return {
1058
+ ok: false,
1059
+ reason: 'stale-main',
1060
+ mainRef,
1061
+ ahead: aheadCount,
1062
+ localMainSha,
1063
+ originMainSha,
1064
+ branch: branchName,
1065
+ originalRef,
1066
+ originalRefType,
1067
+ };
1068
+ }
1069
+ if (aheadCount < 0) {
1070
+ logFn(
1071
+ `[live-checkout] STALE-BASE GUARD: could not compute divergence of local '${mainRef}' vs 'origin/${mainRef}' ` +
1072
+ `(rev-list failed); proceeding without the base-divergence check.`,
1073
+ 'debug'
1074
+ );
1075
+ }
1076
+ } else {
1077
+ logFn(
1078
+ `[live-checkout] STALE-BASE GUARD: no local 'origin/${mainRef}' remote-tracking ref — ` +
1079
+ `skipping base-divergence check (local-only/no-remote repo; #226 no-fetch).`,
1080
+ 'debug'
1081
+ );
1082
+ }
1083
+ }
1084
+
952
1085
  // Create from HEAD — now guaranteed to be the project base branch unless
953
1086
  // allowNonMainBase bypassed the guard. NOT origin/<mainRef>: in live mode the
954
1087
  // operator's HEAD is the baseline, and seeding from a remote ref forces
Binary file
package/engine/shared.js CHANGED
@@ -3193,6 +3193,24 @@ const ENGINE_DEFAULTS = {
3193
3193
  'build-reports': 14,
3194
3194
  'reviews': 30,
3195
3195
  },
3196
+ // W-mr973knu — structural consolidation for the KB sweep (PRIMARY balloon
3197
+ // fix; TTL above is the secondary safety net). The sweep groups boilerplate
3198
+ // transient-category notes by (category, agent, kind, time-bucket) — where
3199
+ // kind is the agent-failure `failureClass`, or a no-op / merge-conflict fix
3200
+ // report — and rolls any group of at least `minGroupSize` notes into a single
3201
+ // recoverable digest (`_digest-<agent>-<kind>-<period>.md`), archiving the
3202
+ // originals to knowledge/_swept/. Unlike TTL expiry this collapses RECENT
3203
+ // duplication too, so the KB shrinks continuously instead of only aging out.
3204
+ // Only `categories` listed here are consolidated; durable categories
3205
+ // (architecture, conventions) and per-agent memory are never touched.
3206
+ // config.engine.kbConsolidation is merged over these defaults; set
3207
+ // `enabled: false` or `categories: []` to disable.
3208
+ kbConsolidation: {
3209
+ enabled: true,
3210
+ categories: ['project-notes', 'build-reports', 'reviews'],
3211
+ minGroupSize: 5, // collapse a group only once it reaches this many near-duplicate notes
3212
+ periodDays: 7, // one digest per (agent, kind) per this many days
3213
+ },
3196
3214
  // W-mqtvnnj1000357fa — fleet-wide fallback for live-checkout auto-stash. When
3197
3215
  // true, a dirty live-checkout tree is `git stash push --include-untracked`'d
3198
3216
  // before dispatch instead of failing with FAILURE_CLASS.LIVE_CHECKOUT_DIRTY,
@@ -4698,12 +4716,19 @@ function mutateWatches(mutator) {
4698
4716
  function mutateMetrics(mutator) {
4699
4717
  const metricsPath = path.join(MINIONS_DIR, 'engine', 'metrics.json');
4700
4718
  const store = require('./metrics-store');
4719
+ // W-mradg74d000rfd45 — the JSON mirror is now written INSIDE
4720
+ // applyMetricsMutation's SQL transaction (before commit), not here
4721
+ // after the fact. Mirroring post-commit left a cross-process window
4722
+ // where a sibling process (dashboard vs. engine — separate Node
4723
+ // processes, each with its own in-memory mirror-hash cache) could
4724
+ // observe the newly committed SQL rows before the JSON file caught up
4725
+ // and read stale/missing metrics off the sidecar. See
4726
+ // metrics-store.js#applyMetricsMutation for the full rationale.
4701
4727
  const { wrote, result } = store.applyMetricsMutation((m) => {
4702
4728
  if (!m || typeof m !== 'object') m = {};
4703
4729
  return mutator(m) || m;
4704
- });
4730
+ }, { mirrorPath: metricsPath });
4705
4731
  if (wrote) {
4706
- try { store._mirrorJsonFromSql(metricsPath); } catch { /* mirror best-effort */ }
4707
4732
  try { require('./db-events').emitStateEvent('metrics'); } catch { /* optional */ }
4708
4733
  }
4709
4734
  return result;
@@ -4877,6 +4902,7 @@ const FAILURE_CLASS = {
4877
4902
  LIVE_CHECKOUT_MID_OPERATION: 'live-checkout-mid-operation', // P-a7f3c1d9 (live-checkout dispatch mode): spawnAgent could not switch/create the target branch in project.localPath because the operator tree is mid-operation — an in-progress merge/rebase/cherry-pick/bisect or a detached HEAD. Distinct from LIVE_CHECKOUT_DIRTY (uncommitted changes): here the tree may be clean but the branch op cannot proceed. Engine refuses to spawn (it never runs `git reset`/`git clean`/`git rebase --abort` against the operator tree). Non-retryable — operator must finish or abort the in-progress operation, or checkout a branch, before re-dispatch.
4878
4903
  LIVE_CHECKOUT_BLOB_FETCH: 'live-checkout-blob-fetch', // PL-live-checkout-reliability-hardening (live-checkout dispatch mode): `git checkout <existing-branch>` in project.localPath failed because the tree could not be materialized — on a Scalar/GVFS-managed ADO partial (blobless) clone, switching onto a branch whose tree differs from HEAD hydrates the changed paths' blobs through the GVFS cache server (`*.gvfscache.dev.azure.com`), a different endpoint than the main git remote that receives NO auth in the headless engine shell, so the fetch fails DETERMINISTICALLY. Distinct from LIVE_CHECKOUT_FAILED (transient) because retrying reproduces it identically — surfaced once as an operator-actionable refusal instead of retry-storming to the cap. Non-retryable — the operator hydrates the branch once with their own credentials (`git checkout <branch>` interactively, or `scalar prefetch` / `git -C <repo> fetch`), then re-dispatches. The engine NEVER forces, resets, cleans, or stashes the operator tree, and best-effort switches HEAD back to the original ref so the tree is not stranded half-populated.
4879
4904
  LIVE_CHECKOUT_WORKTREE_CONFLICT: 'live-checkout-worktree-conflict', // W-mr28h2j2000y0de1 (live-checkout dispatch mode): `git checkout <existing-branch>` in project.localPath failed because that exact branch is ALSO checked out in a SECOND worktree elsewhere (a leftover from a prior isolated-worktree dispatch, a manually-created worktree, or a stale worktree left by a checkoutMode change). git refuses deterministically with `fatal: '<branch>' is already used by worktree at '<path>'`. Distinct from LIVE_CHECKOUT_FAILED (transient) because this is a STRUCTURAL conflict — it does NOT clear on retry, ever, until a human or the engine removes/reassigns the other worktree; retrying just reproduces it identically and burns maxRetries. Non-retryable — the operator either `git worktree remove <path>` (freeing the branch) if that worktree is stale, or finishes/commits/pushes from it directly if it holds real WIP. The engine NEVER forces, resets, cleans, or stashes the operator tree, and best-effort switches HEAD back to the original ref so the tree is not stranded.
4905
+ LIVE_CHECKOUT_STALE_BASE: 'live-checkout-stale-base', // W-mr98op8w000ma4ad (live-checkout dispatch mode, STALE-BASE GUARD): spawnAgent was about to fork a NEW branch off the operator's current HEAD while HEAD sits on the project base ref (mainRef), but the LOCAL mainRef tip has DIVERGED from origin/<mainRef> with unpushed commit(s) (`git rev-list --count origin/<mainRef>..<mainRef>` > 0). Forking would silently inherit that COMMITTED contamination into the new branch and its PR — the exact scope-contamination class first seen on ADO PR 5411214 and recurring on AB#12016662 / ADO PR 5419146. Survives the dirty check (contamination is committed, not uncommitted) and cannot be caught by a headBranch !== mainRef check (headBranch IS mainRef here). Detected WITHOUT a fetch (issue #226) via the existing origin/<mainRef> remote-tracking ref. Distinct from LIVE_CHECKOUT_FAILED (transient) because the contaminated local base does NOT change on retry — it reproduces identically until a human reconciles the local base branch. Non-retryable — the engine NEVER resets/cleans the operator's local mainRef; the operator must reconcile it (push/reset/rebase their own way), then re-dispatch. The guard SKIPS (proceeds normally) when HEAD is not on mainRef, when no local origin/<mainRef> ref exists (local-only/no-remote repo), or when the divergence count cannot be computed.
4880
4906
  LIVE_CHECKOUT_WRONG_BASE: 'live-checkout-wrong-base', // W-mr3lunnq000o9f41 (live-checkout dispatch mode): a NEW-branch fork was requested but the operator's checkout HEAD is on an unrelated topic/feature branch, NOT the project base (mainRef), and prepareLiveCheckout could not switch to a LOCAL base ref (no `refs/heads/<mainRef>`, or the local checkout itself failed). Forking off the stale HEAD would silently bake every commit already on that branch into the new branch AND its PR — committed-history scope contamination that survives the dirty/mid-operation preflights (the ADO PR 5411214 incident: ~22 unrelated OCM files, author-disavowed). The engine never fetches origin/<mainRef> in live mode (issue #226) so it cannot self-heal a missing local base. Non-retryable — the operator must `git checkout <mainRef>` in the operator checkout before re-dispatch. Does NOT fire for PR-targeted fixes or shared-branch continuations (those pass allowNonMainBase, intentionally continuing a non-main branch), nor when HEAD is already on mainRef, nor when a local mainRef ref exists (the guard switches to it and forks cleanly). OPT-IN AUTO-RECOVERY (`liveCheckoutAutoBaseRepair`, per-project > fleet-wide, default OFF): when enabled AND a `refs/remotes/origin/<mainRef>` remote-tracking ref exists, the guard materializes a local `<mainRef>` branch from it (a LOCAL ref op — still no `git fetch`, issue #226 preserved; origin/<mainRef> IS the base so no contamination) and forks cleanly instead of failing — eliminating the manual `git checkout <mainRef>` step. Still fails closed when neither a local base nor an origin-tracking base ref exists, or when the base checkout itself fails/hangs.
4881
4907
  INVALID_WORKDIR: 'invalid-workdir', // P-714ef144: dispatch carried a meta.workdir override that failed validation — non-string, absolute path, drive-letter prefix, null byte, ".." segment, or post-resolve containment escape against project.localPath / worktree root. Engine refuses to spawn (the subpath would either be unreachable on disk or point outside the operator's allowed surface). Non-retryable — operator must fix the WI's meta.workdir before re-dispatch. Inbox alert lists the offending value + the resolved-vs-base mismatch.
4882
4908
  MODEL_UNAVAILABLE: 'model-unavailable', // W-mpg6isvy000xca4d: requested model returned overloaded_error / 503 / service_unavailable. Retriable — engine swaps in the runtime-appropriate fallback model on next spawn (Claude leans on --fallback-model already plumbed; Copilot overrides --model with engine.copilotFallbackModel).
package/engine.js CHANGED
@@ -2934,6 +2934,83 @@ async function spawnAgent(dispatchItem, config) {
2934
2934
  cleanupTempAgent(agentId);
2935
2935
  return null;
2936
2936
  }
2937
+ // W-mr98op8w000ma4ad: STALE-BASE GUARD refusal. prepareLiveCheckout was about
2938
+ // to fork a NEW branch off the operator's current HEAD while HEAD sits on the
2939
+ // project base ref (mainRef), but the LOCAL mainRef tip has diverged from
2940
+ // origin/<mainRef> with unpushed commit(s). Forking would silently bake that
2941
+ // committed contamination into the new branch and its PR (the scope-leak class
2942
+ // first seen on ADO PR 5411214, recurring on AB#12016662 / ADO PR 5419146).
2943
+ // Deterministic — a contaminated local base reproduces identically on retry —
2944
+ // so it gets its own non-retryable FAILURE_CLASS instead of LIVE_CHECKOUT_FAILED's
2945
+ // bounded retry-storm. The engine NEVER resets/cleans the operator's local
2946
+ // mainRef; the operator must reconcile it themselves, then re-dispatch.
2947
+ if (_liveResult && _liveResult.ok === false && _liveResult.reason === 'stale-main') {
2948
+ const _staleMainRef = typeof _liveResult.mainRef === 'string' && _liveResult.mainRef ? _liveResult.mainRef : 'main';
2949
+ const _staleAhead = Number.isFinite(_liveResult.ahead) ? _liveResult.ahead : 0;
2950
+ const _localSha = typeof _liveResult.localMainSha === 'string' && _liveResult.localMainSha ? _liveResult.localMainSha : '(unknown)';
2951
+ const _originSha = typeof _liveResult.originMainSha === 'string' && _liveResult.originMainSha ? _liveResult.originMainSha : '(unknown)';
2952
+ const _alertBody = [
2953
+ '# Live-checkout blocked: local base branch has diverged (stale/contaminated base)',
2954
+ '',
2955
+ `**Project:** ${project.name || '(unknown)'}`,
2956
+ `**Local path:** ${cwd}`,
2957
+ `**New branch (refused):** ${branchName}`,
2958
+ `**Base ref:** ${_staleMainRef}`,
2959
+ `**Local ${_staleMainRef}:** ${_localSha}`,
2960
+ `**origin/${_staleMainRef}:** ${_originSha}`,
2961
+ `**Unpushed commits on local ${_staleMainRef}:** ${_staleAhead}`,
2962
+ `**Work item:** ${_wiIdForAlert}`,
2963
+ `**Dispatch:** ${id}`,
2964
+ '',
2965
+ `The engine refused to fork \`${branchName}\` off local \`${_staleMainRef}\` because your local \`${_staleMainRef}\` is **${_staleAhead} commit(s) ahead of \`origin/${_staleMainRef}\`**. Those commits were never pushed — forking a new branch (and its PR) off this base would silently inherit them, contaminating an otherwise-clean change with unrelated commits. The working tree was clean (no uncommitted changes), so the dirty check could not catch this: the contamination is COMMITTED. Your tree was left untouched — the engine never resets, cleans, or rebases your \`${_staleMainRef}\`.`,
2966
+ '',
2967
+ '## Recovery',
2968
+ '',
2969
+ `Reconcile your local \`${_staleMainRef}\` so it no longer carries unpushed commits, then re-dispatch. Pick ONE, depending on whether those commits are real work:`,
2970
+ '',
2971
+ `1. **If the extra commits are real work you want to keep** — move them onto their own branch, then reset \`${_staleMainRef}\` to the remote:`,
2972
+ '',
2973
+ '```',
2974
+ `git -C "${cwd}" branch salvage-${_staleMainRef} ${_staleMainRef}`,
2975
+ `git -C "${cwd}" checkout ${_staleMainRef}`,
2976
+ `git -C "${cwd}" reset --hard origin/${_staleMainRef}`,
2977
+ '```',
2978
+ '',
2979
+ `2. **If the extra commits are leftover junk** (e.g. a prior dispatch's abandoned WIP) — discard them by resetting \`${_staleMainRef}\` to the remote (this DISCARDS those commits):`,
2980
+ '',
2981
+ '```',
2982
+ `git -C "${cwd}" checkout ${_staleMainRef}`,
2983
+ `git -C "${cwd}" reset --hard origin/${_staleMainRef}`,
2984
+ '```',
2985
+ '',
2986
+ `The salvage branch in option 1 keeps the commits recoverable; both leave \`${_staleMainRef}\` matching \`origin/${_staleMainRef}\` so the next dispatch forks off a clean base.`,
2987
+ ].join('\n');
2988
+ try { writeInboxAlert(`live-checkout-stale-base-${_wiIdForAlert}`, _alertBody); }
2989
+ catch (e) { log('warn', `live-checkout: writeInboxAlert failed: ${e.message}`); }
2990
+ try {
2991
+ const _wiPath = resolveWorkItemPath(dispatchItem.meta);
2992
+ if (_wiPath && dispatchItem.meta?.item?.id) {
2993
+ mutateJsonFileLocked(_wiPath, (data) => {
2994
+ if (!Array.isArray(data)) return data;
2995
+ const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
2996
+ if (wi) wi._pendingReason = 'live_checkout_stale_base';
2997
+ return data;
2998
+ });
2999
+ }
3000
+ } catch (e) { log('warn', `live-checkout: failed to stamp _pendingReason: ${e.message}`); }
3001
+ const _shortMsg = `live-checkout blocked: local ${_staleMainRef} is ${_staleAhead} commit(s) ahead of origin/${_staleMainRef} (would contaminate ${branchName})`;
3002
+ log('error', `spawnAgent: ${_shortMsg}`);
3003
+ _cleanupPromptFiles();
3004
+ completeDispatch(
3005
+ id,
3006
+ DISPATCH_RESULT.ERROR,
3007
+ _shortMsg.slice(0, 800),
3008
+ `Live-checkout STALE-BASE GUARD: forking ${branchName} off local ${_staleMainRef} would inherit ${_staleAhead} unpushed commit(s) not on origin/${_staleMainRef}. Deterministic — operator must reconcile the local base branch (the engine never resets it) before re-dispatch. Retrying is provably useless.`,
3009
+ { failureClass: FAILURE_CLASS.LIVE_CHECKOUT_STALE_BASE, agentRetryable: false },
3010
+ );
3011
+ cleanupTempAgent(agentId);
3012
+ return null;
3013
+ }
2937
3014
  log('info', `live-checkout: ${_liveResult.created ? 'created' : 'switched to'} branch ${branchName} in ${cwd} (in-place; no worktree)`);
2938
3015
  // PL-live-checkout-reliability-hardening: a clean spawn clears the two-strike
2939
3016
  // dirty counter so a project that was transiently dirty once isn't treated as
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2345",
3
+ "version": "0.1.2346",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"