@yemi33/minions 0.1.2344 → 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. |
@@ -104,6 +104,33 @@ Runs the command with `child_process.exec` / `spawn`, asserts both exit code 0 A
104
104
 
105
105
  `tcp` and `log-match` healthcheck types are intentionally not implemented — see the plan's Rejected items for the reasoning.
106
106
 
107
+ ### Native app / emulator-backed targets (issue #712)
108
+
109
+ Not every managed-spawn target is a TCP-bound HTTP dev server. Native mobile/desktop apps (e.g. an Android Gradle/Kotlin app) have no HTTP endpoint to probe. Use a `command` healthcheck instead of `http`, and declare the emulator/simulator/device **boot** — not the app itself — as the long-lived spec:
110
+
111
+ 1. Spawn only the long-lived environment dependency (emulator/simulator/device boot, or an already-running device-farm session) as the managed-spawn spec.
112
+ 2. Defer the one-shot build + install + launch (e.g. a 30-45 minute Gradle assemble/installDebug/am start) to the DRAFT/EXECUTE phase rather than declaring it as the spec's `cmd` — managed-spawn owns persistent processes, not one-shot commands.
113
+ 3. Poll the boot/ready signal with a `command` healthcheck. For Android, `adb shell getprop sys.boot_completed` matched against `^1$` is the canonical ready signal.
114
+
115
+ ```jsonc
116
+ {
117
+ "name": "android-emulator",
118
+ "cmd": "emulator",
119
+ "args": ["-avd", "Pixel_6_API_34", "-no-snapshot", "-no-window"],
120
+ "healthcheck": {
121
+ "type": "command",
122
+ "cmd": "adb",
123
+ "args": ["shell", "getprop", "sys.boot_completed"],
124
+ "shell": false,
125
+ "expect_regex": "^1$",
126
+ "interval_s": 5,
127
+ "timeout_s": 180
128
+ }
129
+ }
130
+ ```
131
+
132
+ Known device-farm tooling: `adb` and `emulator` are both on the managed-spawn executable allowlist (device/emulator shell + install + logcat; `emulator -list-avds` discovers configured AVDs before picking one). `maestro` is a declarative mobile UI-flow runner already wired in as a built-in QA runner adapter (`engine/qa-runners.js`) for the DRAFT/EXECUTE phases — it is not itself on the managed-spawn `cmd`/healthcheck allowlist. The full worked example — including `ttl_minutes`, `attrs`, `written_by`/`wi_id` — is inlined in [`buildManagedSpawnHint`](../engine/managed-spawn.js).
133
+
107
134
  ### Non-PATH native launcher binaries (e.g. Android SDK's `emulator.exe` / `adb.exe`)
108
135
 
109
136
  `adb` and `emulator` are already on `executableAllowlist` (basename matching strips `.exe`/`.cmd`/`.bat`/`.ps1`/`.sh`, so `spec.cmd` can be a bare name resolved via PATH *or* a full absolute path — `path.basename()` matching doesn't care how deep the path is). This covers the common case directly; you do **not** need a `powershell -Command` wrapper just to launch a native SDK tool (issue #711).
@@ -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
  ]);