@yemi33/minions 0.1.2338 → 0.1.2340

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.
@@ -291,10 +291,10 @@ function prRow(pr) {
291
291
  '<td><a class="pr-title" title="' + escapeHtml(titleText) + '" href="' + escapeHtml(safeUrl(url)) + '" target="_blank" rel="noopener">' + escapeHtml(titleText) + '</a>' + followupChip + pausedChips + (descPreview ? '<div class="pr-desc" title="' + escapeHtml(descPreview) + '">' + escapeHtml(descPreview.length > 120 ? descPreview.slice(0, 120) + '...' : descPreview) + '</div>' : '') + '</td>' +
292
292
  '<td><span class="pr-badge ' + statusClass + '" title="' + escapeHtml(statusLabel) + '">' + escapeHtml(statusLabel) + '</span></td>' +
293
293
  '<td><span class="pr-badge ' + reviewClass + '" title="' + escapeHtml(reviewTitle || reviewLabel) + '">' + escapeHtml(reviewLabel) + '</span></td>' +
294
+ '<td><span class="pr-badge ' + buildClass + '" title="' + escapeHtml(buildTitle || buildLabel) + '">' + escapeHtml(buildLabel) + '</span></td>' +
294
295
  '<td><span class="' + branchClass + '" title="' + escapeHtml(branchError || branchLabel) + '">' + escapeHtml(branchLabel) + '</span>' + pendingReasonHtml + '</td>' +
295
296
  '<td><span class="pr-agent" title="' + escapeHtml(agentText) + '">' + escapeHtml(agentText) + '</span></td>' +
296
297
  '<td>' + reviewerCell + '</td>' +
297
- '<td><span class="pr-badge ' + buildClass + '" title="' + escapeHtml(buildTitle || buildLabel) + '">' + escapeHtml(buildLabel) + '</span></td>' +
298
298
  '<td>' + (observeBtn || '<span style="color:var(--muted);font-size:var(--text-base)">—</span>') + '</td>' +
299
299
  '<td><span class="pr-date" title="' + escapeHtml(createdLabel) + '">' + escapeHtml(createdLabel) + '</span></td>' +
300
300
  '<td><button class="pr-info-pill" data-pr-id="' + escapeHtml(String(prId)) + '" onclick="event.stopPropagation();openPrDetail(this.dataset.prId)" title="Open PR detail (in-stack modal)">&#x2197;</button>' +
@@ -312,10 +312,10 @@ const PRS_COLGROUP =
312
312
  '<col style="width:320px">' + // Title
313
313
  '<col style="width:110px">' + // Status
314
314
  '<col style="width:130px">' + // Review
315
+ '<col style="width:130px">' + // Build
315
316
  '<col style="width:200px">' + // Branch
316
317
  '<col style="width:140px">' + // Agent
317
318
  '<col style="width:140px">' + // Signed Off By
318
- '<col style="width:130px">' + // Build
319
319
  '<col style="width:100px">' + // Observe
320
320
  '<col style="width:130px">' + // Created
321
321
  '<col style="width:80px">' + // Actions (info-pill + remove)
@@ -325,7 +325,7 @@ function prTableHtml(rows) {
325
325
  return '<div class="pr-table-wrap pr-table-wrap--prs"><table class="pr-table pr-table--prs">' +
326
326
  PRS_COLGROUP +
327
327
  '<thead><tr>' +
328
- '<th>PR</th><th>Title</th><th>Status</th><th>Review</th><th>Branch</th><th>Agent</th><th>Signed Off By</th><th>Build</th><th>Observe</th><th>Created</th><th></th>' +
328
+ '<th>PR</th><th>Title</th><th>Status</th><th>Review</th><th>Build</th><th>Branch</th><th>Agent</th><th>Signed Off By</th><th>Observe</th><th>Created</th><th></th>' +
329
329
  '</tr></thead><tbody>' + rows + '</tbody></table></div>';
330
330
  }
331
331
 
package/docs/kb-sweep.md CHANGED
@@ -8,10 +8,11 @@ The KB sweep is a 3-pass cycle implemented in [`engine/kb-sweep.js`](../engine/k
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)
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)
13
14
  → Pass 3: Per-entry rewrite (Haiku, concurrency 5)
14
- → Prune _swept/ archive (>30 days)
15
+ → Prune _swept/ archive (>30 days)
15
16
  → Invalidate KB cache, write engine/kb-swept.json
16
17
  ```
17
18
 
@@ -41,6 +42,26 @@ Reclassification targets are validated against `shared.KB_CATEGORIES` (`architec
41
42
 
42
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)).
43
44
 
45
+ ### Pass 2.5 — Age-Based TTL Expiry
46
+
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.
48
+
49
+ 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
+
51
+ - **Age is taken from the authored date** (`YYYY-MM-DD-` filename prefix / `date:` frontmatter), *not* mtime — so the Pass 3 rewrite touching a file does not reset its clock and let a stale entry live forever. When no date is parseable it falls back to mtime; when neither exists the entry is treated as fresh and never age-expired (source: [`engine/kb-sweep.js`](../engine/kb-sweep.js) `_entryAgeMs`).
52
+ - **Only categories with a positive TTL are eligible.** Durable categories (`architecture`, `conventions`) are absent from the default map and are never age-expired. Per-agent memory under `knowledge/agents/` is never even scanned into the manifest (it's not in `KB_CATEGORIES`), and pinned entries are excluded upstream — so none of them can be aged out.
53
+
54
+ The TTL map is `ENGINE_DEFAULTS.kbCategoryTtlDays`, overridable per-fleet via `config.engine.kbCategoryTtlDays` (merged over the defaults; set a category to `0` to disable expiry for it):
55
+
56
+ | Category | Default TTL (days) |
57
+ |----------|--------------------|
58
+ | `project-notes` | 21 |
59
+ | `build-reports` | 14 |
60
+ | `reviews` | 30 |
61
+ | `architecture`, `conventions` | — (never) |
62
+
63
+ Because expiry archives to `_swept/` (subject to the same 30-day retention as every other pass), an over-aggressive TTL is fully recoverable for ~30 days before the archive is pruned.
64
+
44
65
  ### Pass 3 — Per-Entry Rewrite
45
66
 
46
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:
@@ -145,12 +166,13 @@ The KB page is refreshed by the dashboard's main refresh loop **every third tick
145
166
  hashDuplicatesArchived, // Pass 1
146
167
  llmDuplicatesArchived, // Pass 2 (within-batch dupes)
147
168
  staleRemoved, // Pass 2 (LLM-flagged removals)
169
+ ageExpired, // Pass 2.5 (per-category age-TTL archival)
148
170
  reclassified, // Pass 2 (category moves)
149
171
  rewritten, // Pass 3
150
172
  rewriteBytesBefore, rewriteBytesAfter,
151
173
  sweptArchivePruned, // Files purged from _swept/ (>30 days)
152
174
  durationMs,
153
- summary: '<n> hash-dup, <n> llm-dup, <n> stale, <n> reclassified, <n> rewritten (<bytes> saved)',
175
+ summary: '<n> hash-dup, <n> llm-dup, <n> stale, <n> age-expired, <n> reclassified, <n> rewritten (<bytes> saved)',
154
176
  }
155
177
  ```
156
178
 
@@ -0,0 +1,239 @@
1
+ # Design review: Repo pool for live-checkout projects (multi-enlistment dispatch)
2
+
3
+ > **Review-only artifact.** This document exists so the approved implementation
4
+ > plan and materialized PRD for the *Repo pool for live-checkout projects*
5
+ > feature have a reviewable, git-tracked surface — `plans/` and `prd/` are
6
+ > gitignored and never committed, so there is otherwise nothing for a human to
7
+ > comment on before the dependent implementation work items land as code PRs.
8
+ > **No functional code changes accompany this file.**
9
+ >
10
+ > **Sources (do not edit — reproduced here verbatim):**
11
+ > - Plan: `plans/plan-w-mr3xban70004446a-2026-07-02.md` (author: Ripley, 2026-07-02)
12
+ > - PRD: `prd/src-2026-07-06.json` (generated by lambert, approved by calebt 2026-07-06)
13
+
14
+ ---
15
+
16
+ ## Goal
17
+
18
+ Let a single logical live-checkout project (e.g. `src` / Office monorepo) be
19
+ backed by a **pool of live enlistment paths** instead of one `localPath`. Today
20
+ live mode caps mutating dispatch to **one** in-flight operation per project and
21
+ refuses on a dirty tree — even when the operator has a second, equally-valid
22
+ enlistment of the same monorepo (e.g. `C:/office/src` **and** `C:/off2/src`)
23
+ sitting idle. The engine has no way to know about the second checkout, so all
24
+ live dispatches serialize through one path.
25
+
26
+ With a pool, when a mutating work item is dispatched the engine:
27
+
28
+ 1. Picks any **free** (not currently mid-dispatch) + **clean-tree** enlistment
29
+ from the pool.
30
+ 2. Validates that the specific subproject/subpath the WI needs actually exists
31
+ in that enlistment (some enlistments may be sparse/partial checkouts).
32
+ 3. Dispatches **in-place** in the chosen enlistment, applying today's per-path
33
+ live-checkout policies (dirty refusal, auto-stash, auto-reset, auto-base-repair)
34
+ against **that** enlistment only.
35
+ 4. If **all** pool members are busy or invalid for the required subpath, falls
36
+ back to today's behavior: the WI stays pending with a `skipReason` and is
37
+ retried next tick (no hard failure).
38
+
39
+ Backward compatibility is a hard requirement: a project with no `livePool` (just
40
+ the singular `localPath`) behaves **exactly** as today. `livePool` is strictly
41
+ additive.
42
+
43
+ ## Codebase Analysis
44
+
45
+ ### How live mode works today (single path)
46
+
47
+ - **Config shape.** A project opts into live mode with `checkoutMode: 'live'`
48
+ and a single `localPath`. Example `src` in `config.json`:
49
+ `{ name:'src', localPath:'C:/office/src', checkoutMode:'live', … }`
50
+ (source: `config.json` → projects[] `src`).
51
+ - **Mode resolution.** `shared.resolveCheckoutMode(project, workItemType)`
52
+ returns `'live'|'worktree'`, honoring the legacy `worktreeMode` field and the
53
+ `liveValidation` hybrid routing (source: `engine/shared.js:2911`).
54
+ `shared.isLiveCheckoutProject(project)` is the convenience predicate
55
+ (source: `engine/shared.js:2943`).
56
+ - **Path resolution.** `shared.resolveSpawnPaths(project, type, minionsDir, opts)`
57
+ short-circuits for live mode and returns
58
+ `{ cwd: path.resolve(project.localPath) (+workdir), worktreeRootDir:null, liveMode:true }`
59
+ (source: `engine/shared.js:6355-6382`). It throws `LIVE_CHECKOUT_NO_LOCALPATH`
60
+ if `localPath` is falsy. **This is the single point where the enlistment path
61
+ is chosen** — it hard-reads `project.localPath`.
62
+ - **Per-project mutating-concurrency cap of 1.** The allocation loop in the
63
+ engine builds an in-memory `liveProjectsInUse` **Set of project names**, seeded
64
+ from `dispatch.active` each tick, and skips any pending mutating item whose
65
+ project is already in the set with `_pendingReason = 'live_checkout_busy'`
66
+ (source: `engine.js:10604-10613` seed, `engine.js:10767-10797` gate,
67
+ `engine.js:10861-10897` skip-reason annotation). Read-only types
68
+ (`READ_ONLY_ROOT_TASK_TYPES`) are excluded. **This cap survives restart because
69
+ it is derived from `dispatch.active`, which is SQL-backed** (see persistence
70
+ below). The pool needs this to become a **per-path** claim, not per-project.
71
+ - **In-place checkout + recovery policies.** `spawnAgent` (engine.js) calls
72
+ `engine/live-checkout.js#prepareLiveCheckout({ localPath: cwd, branchName, … })`
73
+ which: runs `git status --porcelain` (dirty gate), mid-operation / detached-HEAD
74
+ preflight, stale-base guard, and branch checkout/creation — all against the one
75
+ `cwd` (source: `engine.js:2453-2497`, `engine/live-checkout.js:374`). The dirty
76
+ recovery policies resolve per-project-then-fleet-wide:
77
+ - `liveCheckoutAutoStash` — `resolveLiveCheckoutAutoStash` (source: `engine/live-checkout.js:1248`), applied via `applyLiveCheckoutAutoStash` (source: `engine.js:2509`).
78
+ - `liveCheckoutAutoReset` — `shared.resolveLiveCheckoutAutoReset` (self-resolves by matching project on `localPath`, source: `docs/live-checkout-mode.md` §2b, `engine/live-checkout.js:332`).
79
+ - `liveCheckoutAutoBaseRepair` — `_defaultResolveAutoBaseRepair` (source: `engine/live-checkout.js:352`).
80
+ **All three resolve/match by `localPath`**, so they need to key off the
81
+ selected enlistment path, not the project's canonical `localPath`.
82
+ - **Dispatch-end restore.** `restoreLiveCheckoutAtDispatchEnd` /
83
+ `maybeRestoreLiveCheckoutFromRecord` put the tree back on `originalRef`
84
+ (source: `engine/live-checkout.js:1009, 1423`). These operate on a recorded
85
+ `localPath` and must record/restore the **selected** enlistment.
86
+
87
+ ### Persistence (survives restart)
88
+
89
+ - Dispatch state lives in **SQLite** `engine/state.db` `dispatches` table via
90
+ `engine/dispatch-store.js` (`readDispatchSectioned` / `applyDispatchMutation`),
91
+ mirrored read-only to `dispatch.json` (source: `engine/dispatch-store.js:49,244,298`).
92
+ Each active dispatch is one JSON record. **Because `liveProjectsInUse` is
93
+ rebuilt from `dispatch.active` every tick, recording the claimed enlistment
94
+ path on the active dispatch record makes the per-path claim automatically
95
+ restart-durable** — no new table strictly required. The claim field (e.g.
96
+ `meta.liveEnlistmentPath`) rides on the existing record and is diffed/persisted
97
+ by `applyDispatchMutation` like any other field.
98
+
99
+ ### Subproject / subpath declaration (already partially exists)
100
+
101
+ - `meta.workdir` is already a validated relative-POSIX subpath under the spawn
102
+ base; when set, the engine lands the agent at `<base>/<workdir>` and clips
103
+ harness discovery to that subtree (source: `engine/shared.js:6404-6416`,
104
+ `validateWorkItemWorkdir`). This is the natural signal for "which subpath does
105
+ this WI need" — subproject validation can reuse/extend it. There is also
106
+ `detectDominantSubproject({ projectPath, changedPaths })` used post-completion
107
+ (source: `engine.js:4033`).
108
+
109
+ ### Dashboard / Settings surfaces (parity requirement)
110
+
111
+ - **Config-mirror API.** `dashboard.js` `mergeSettingsConfigUpdate` mirrors each
112
+ per-project field (`mainBranch`, `checkoutMode`, `liveCheckoutAutoStash`,
113
+ `liveValidation`) from the validated candidate to `current` with
114
+ present→copy / absent→delete semantics (source: `dashboard.js:415-467`).
115
+ **Any new `livePool` field must be added here or it is silently dropped.**
116
+ - **Settings UI.** `dashboard/js/settings.js` renders the per-project checkout-mode
117
+ dropdown, the `liveCheckoutAutoStash` tri-state, and `localPath` display
118
+ (source: `dashboard/js/settings.js:262-316`). The pool editor lives here.
119
+ - **Project cards.** `dashboard/js/render-other.js` renders per-project git
120
+ branch/dirty/state pills from `p.gitBranch/p.gitDirty/p.gitState`
121
+ (source: `dashboard/js/render-other.js:103-172`), fed by the per-`localPath`
122
+ git-status probe cache in `engine/queries.js:2512-2607` (15s TTL, keyed by
123
+ normalized `localPath`). Per-enlistment status reuses this probe per path.
124
+ - **Status payload.** `dashboard.js:2768-2785` emits `checkoutMode`,
125
+ `liveValidationType`, etc. per project — the place to add pool-member status.
126
+
127
+ ### Tests
128
+
129
+ - Rich existing suite under `test/unit/` — `resolve-spawn-paths-live-mode.test.js`,
130
+ `prepare-live-checkout.test.js`, `spawn-agent-live-mode-wiring.test.js`,
131
+ `live-checkout-same-tick-concurrency.test.js`, `live-checkout-mutex-issue518.test.js`,
132
+ `live-checkout-auto-stash.test.js`, `live-checkout-auto-reset.test.js`,
133
+ `live-validation-config.test.js`. These are the templates for the new tests and
134
+ the regression guard for back-compat. `prepareLiveCheckout` and the resolvers
135
+ already take injectable `_git`/`_exists`/`autoReset` seams for filesystem-free
136
+ unit tests — the pool selector should follow the same DI style.
137
+
138
+ ## Approach (scope & design)
139
+
140
+ **Core idea:** introduce a small, pure **pool-selection module** that, given a
141
+ project + the set of currently-claimed enlistment paths + the required subpath,
142
+ returns the chosen free/clean/valid enlistment (or `null`). Thread the chosen
143
+ path through the existing single-path machinery by treating it as the effective
144
+ `localPath` for that one dispatch. Everything downstream of "which path" stays
145
+ unchanged — it just receives the selected path instead of `project.localPath`.
146
+
147
+ Key design decisions and rationale:
148
+
149
+ 1. **`livePool` is additive; `localPath` stays canonical.** New optional field
150
+ `project.livePool: [{ path, label?, sparseScope?: string[] }]`. When absent or
151
+ empty → **current behavior, unchanged** (single `localPath`). When present, the
152
+ effective pool is `livePool` (and `localPath` MAY be included as an implicit
153
+ first member for zero-config migration — decided in item 1). Rationale: keeps
154
+ the entire non-pool codepath byte-identical, satisfying the hard back-compat
155
+ requirement, and lets `resolveCheckoutMode` / `isLiveCheckoutProject` stay
156
+ untouched (pool is orthogonal to mode).
157
+
158
+ 2. **Per-path claim replaces per-project Set.** Change the engine's
159
+ `liveProjectsInUse` (Set<projectName>) into a claim map keyed by
160
+ `projectName + '\u0000' + enlistmentPath`, seeded from `dispatch.active` by
161
+ reading each active dispatch's recorded `meta.liveEnlistmentPath` (falling back
162
+ to `project.localPath` for legacy single-path active dispatches). Rationale:
163
+ reuses the existing restart-durable derivation (no new SQL table), and a
164
+ single-path project degenerates to exactly one key = today's cap-of-1.
165
+
166
+ 3. **Selection happens at allocation, is recorded on the WI/dispatch meta, and
167
+ is consumed by `resolveSpawnPaths`.** The allocation loop selects the
168
+ enlistment and stamps `item.meta.liveEnlistmentPath`; `resolveSpawnPaths`
169
+ (and `spawnAgent`'s `prepareLiveCheckout` call) prefer
170
+ `meta.liveEnlistmentPath` over `project.localPath` when set. Rationale: keeps
171
+ selection in the one place that already reasons about cross-tick/within-tick
172
+ claims (the branch-mutex + live-busy gate), and makes the claim durable via the
173
+ dispatch record before the agent spawns, closing the double-claim window.
174
+
175
+ 4. **Clean-tree + subpath validation at selection are cheap.** Clean-tree reuses
176
+ the existing 15s-TTL per-path git-status probe cache (`engine/queries.js`), so
177
+ selection does not add a fresh `git status` per tick per path. Subpath
178
+ validation is a pure `fs.existsSync(<path>/<subpath>)` (or a `sparseScope`
179
+ prefix match when declared) — no build, no git. Rationale: selection runs every
180
+ tick; it must be O(cheap). The authoritative dirty gate still runs inside
181
+ `prepareLiveCheckout` at spawn against the **selected** path (defense in depth).
182
+
183
+ 5. **Per-enlistment recovery policies.** `resolveLiveCheckoutAutoReset` /
184
+ `resolveLiveCheckoutAutoStash` / `resolveLiveCheckoutAutoBaseRepair` currently
185
+ match the project by `localPath`; they must match/apply against the **selected
186
+ enlistment path**. Rationale: auto-stash/reset must touch only the chosen
187
+ enlistment, never a sibling.
188
+
189
+ 6. **Mid-dispatch subpath race fails non-retryably.** If a chosen enlistment
190
+ passes selection-time subpath validation but the subpath is gone by spawn
191
+ (manual repo edit / sparse re-checkout race), fail with a dedicated
192
+ `FAILURE_CLASS.LIVE_POOL_SUBPATH_MISSING` (added to `dispatch.js` `neverRetry`)
193
+ rather than silently re-picking mid-dispatch. Rationale: matches the existing
194
+ `LIVE_CHECKOUT_*` structural-failure pattern (blob-fetch, worktree-conflict) —
195
+ deterministic, operator-actionable, no retry storm.
196
+
197
+ 7. **Selection order is deterministic: first-free-clean-valid in `livePool`
198
+ declaration order** (with `localPath` implicit-first if adopted). Rationale:
199
+ simplest correct, predictable, and directly testable; round-robin adds state
200
+ with no real benefit at the expected pool size (2–3). Documented explicitly so
201
+ behavior is a contract.
202
+
203
+ ---
204
+
205
+ ## Work items (from PRD `prd/src-2026-07-06.json`)
206
+
207
+ The approved PRD breaks the feature into 10 items. `depends_on` uses the PRD item
208
+ ids; complexity is the PRD's `estimated_complexity`.
209
+
210
+ | # | id | Title | Type | Depends on | Complexity |
211
+ |---|----|-------|------|-----------|-----------|
212
+ | 1 | `P-a1c3f2b9` | livePool config schema + validation + normalization | implement | — | medium |
213
+ | 2 | `P-b7d4e0a1` | Pure pool-selection module (`engine/live-pool.js`) | implement | `P-a1c3f2b9` | medium |
214
+ | 3 | `P-c2e8f5d3` | Thread selected enlistment path through spawn-path resolution | implement | `P-a1c3f2b9` | medium |
215
+ | 4 | `P-e5a9b2c7` | Per-path claim gate in the allocation loop | implement | `P-b7d4e0a1`, `P-c2e8f5d3` | large |
216
+ | 5 | `P-f8b1a4d6` | Per-enlistment recovery policy resolution | implement | `P-c2e8f5d3` | medium |
217
+ | 6 | `P-a0d7c3e9` | Mid-dispatch subpath-missing failure class | implement | `P-c2e8f5d3`, `P-e5a9b2c7` | small |
218
+ | 7 | `P-d4f0b8a2` | Settings UI: pool editor + config-mirror wiring | implement | `P-a1c3f2b9` | large |
219
+ | 8 | `P-c9e2a6f4` | Dashboard project card: per-enlistment status | implement | `P-e5a9b2c7` | medium |
220
+ | 9 | `P-b3a7d1c8` | Docs + CLAUDE.md pool contract | docs | `P-a1c3f2b9`, `P-e5a9b2c7`, `P-f8b1a4d6`, `P-a0d7c3e9` | small |
221
+ | 10 | `P-e1c5b9a3` | Worked back-compat + example config regression test | test | `P-e5a9b2c7`, `P-f8b1a4d6` | small |
222
+
223
+ ### Open questions (from the PRD)
224
+
225
+ 1. **Subpath declaration source:** should the PRD add an explicit `meta.subpath`
226
+ field, or is the existing `meta.workdir` sufficient for the driving Loop-Android
227
+ use case? (Plan recommends reusing `meta.workdir`; add `meta.subpath` only if a
228
+ WI needs a validation subpath differing from where the agent lands.)
229
+ 2. **localPath auto-prepend:** should a project's existing `localPath` be an
230
+ implicit first pool member when `livePool` is set (safest for migration), or
231
+ must the operator list it explicitly? (Plan recommends implicit-first + dedup —
232
+ this PRD assumes yes.)
233
+ 3. **Clean-tree probe staleness:** the 15s-TTL probe could claim a path that just
234
+ went dirty; the authoritative gate in `prepareLiveCheckout` catches it at spawn
235
+ (refuse/auto-stash/auto-reset) at the cost of an occasional wasted selection.
236
+ Confirm this trade-off vs. a fresh probe at selection.
237
+ 4. **Second enlistment on a different base branch:** pool selection does not
238
+ normalize branches across members; the existing stale-base guard applies per
239
+ selected path. Confirm this is acceptable operator responsibility.
@@ -83,6 +83,67 @@ function _pruneOldSwept() {
83
83
  return pruned;
84
84
  }
85
85
 
86
+ /**
87
+ * Resolve the effective per-category age-TTL map (category → days), merging
88
+ * `opts.engineConfig.kbCategoryTtlDays` over `ENGINE_DEFAULTS.kbCategoryTtlDays`.
89
+ * Only positive finite day-counts survive — a category set to 0, negative, or a
90
+ * non-number is dropped (expiry disabled for it). Durable categories that appear
91
+ * in neither map are simply absent → never age-expired.
92
+ */
93
+ function _resolveTtlByCategory(opts = {}) {
94
+ const defaults = (shared.ENGINE_DEFAULTS && shared.ENGINE_DEFAULTS.kbCategoryTtlDays) || {};
95
+ const override = (opts.engineConfig && opts.engineConfig.kbCategoryTtlDays) || {};
96
+ const merged = { ...defaults, ...override };
97
+ const out = {};
98
+ for (const [cat, days] of Object.entries(merged)) {
99
+ const n = Number(days);
100
+ if (Number.isFinite(n) && n > 0) out[cat] = n;
101
+ }
102
+ return out;
103
+ }
104
+
105
+ /**
106
+ * Age of a manifest entry in ms. Prefers the authored date (`entry.date`, from
107
+ * the filename `YYYY-MM-DD-` prefix / frontmatter) so age is immune to the
108
+ * rewrite pass touching mtime; falls back to mtime. Unknown age → 0 (fresh,
109
+ * never expired) so a malformed/date-less entry is never archived by age alone.
110
+ */
111
+ function _entryAgeMs(entry, now) {
112
+ const parsed = typeof entry.date === 'string' && entry.date ? Date.parse(entry.date) : NaN;
113
+ if (Number.isFinite(parsed)) return Math.max(0, now - parsed);
114
+ if (Number.isFinite(entry.mtimeMs) && entry.mtimeMs > 0) return Math.max(0, now - entry.mtimeMs);
115
+ return 0;
116
+ }
117
+
118
+ /**
119
+ * Age-based TTL expiry pass. Archives (to knowledge/_swept/) manifest entries in
120
+ * a TTL-listed category whose age exceeds that category's TTL. Durable categories
121
+ * (absent from the TTL map) are never touched. Pinned entries are already
122
+ * excluded upstream (they never enter the manifest), and knowledge/agents/ is
123
+ * never scanned into the manifest at all — so per-agent memory is inherently safe.
124
+ *
125
+ * @returns {{ expired:number, survivors:object[] }}
126
+ */
127
+ function _expireByAge(manifest, opts = {}) {
128
+ const ttlByCategory = _resolveTtlByCategory(opts);
129
+ if (Object.keys(ttlByCategory).length === 0) return { expired: 0, survivors: manifest.slice() };
130
+ const now = Number(opts.now) || Date.now();
131
+ const DAY_MS = 24 * 60 * 60 * 1000;
132
+ let expired = 0;
133
+ const survivors = [];
134
+ for (const e of manifest) {
135
+ const ttlDays = ttlByCategory[e.category];
136
+ if (!ttlDays) { survivors.push(e); continue; }
137
+ if (_entryAgeMs(e, now) <= ttlDays * DAY_MS) { survivors.push(e); continue; }
138
+ const fp = path.join(KB_DIR, e.category, e.file);
139
+ if (!fs.existsSync(fp)) continue; // already removed by an earlier pass
140
+ if (opts.dryRun) { expired++; continue; }
141
+ if (_archiveKbFile(fp, `age-expired: ${e.category} entry older than ${ttlDays}d`)) expired++;
142
+ else survivors.push(e); // archive failed — keep it visible
143
+ }
144
+ return { expired, survivors };
145
+ }
146
+
86
147
  /** Group entries by content hash, keep most-recent per group. Cheap, no LLM. */
87
148
  function _hashDedup(manifest, opts = {}) {
88
149
  const groups = new Map(); // hash → entries[]
@@ -478,6 +539,7 @@ async function _runKbSweepImpl(opts = {}) {
478
539
  hashDuplicatesArchived: 0,
479
540
  llmDuplicatesArchived: 0,
480
541
  staleRemoved: 0,
542
+ ageExpired: 0,
481
543
  reclassified: 0,
482
544
  rewritten: 0,
483
545
  rewriteBytesBefore: 0,
@@ -526,8 +588,17 @@ async function _runKbSweepImpl(opts = {}) {
526
588
  summary.staleRemoved = llmActions.removed;
527
589
  summary.reclassified = llmActions.reclassified;
528
590
 
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)));
597
+ const ageResult = _expireByAge(survivingAfterLlm, opts);
598
+ summary.ageExpired = ageResult.expired;
599
+
529
600
  // 3. Per-entry rewrite (compress + normalize)
530
- // Filter to entries that survived hash + LLM passes (still on disk)
601
+ // Filter to entries that survived hash + LLM + age passes (still on disk)
531
602
  const stillOnDisk = afterHash.filter(e => fs.existsSync(path.join(KB_DIR, e.category, e.file)));
532
603
  const rewriteResult = await _rewritePass(stillOnDisk, callLLM, trackEngineUsage, opts);
533
604
  summary.rewritten = rewriteResult.processed;
@@ -549,7 +620,7 @@ async function _runKbSweepImpl(opts = {}) {
549
620
  }
550
621
 
551
622
  summary.durationMs = Date.now() - t0;
552
- summary.summary = `${summary.hashDuplicatesArchived} hash-dup, ${summary.llmDuplicatesArchived} llm-dup, ${summary.staleRemoved} stale, ${summary.reclassified} reclassified, ${summary.rewritten} rewritten (${(summary.bytesBefore - summary.bytesAfter).toLocaleString()} bytes saved)`;
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)`;
553
624
 
554
625
  if (!opts.dryRun) {
555
626
  try { shared.mutateKbSwept(() => ({ timestamp: ts(), summary: summary.summary, detail: summary })); } catch { /* ignore */ }
@@ -706,6 +777,9 @@ module.exports = {
706
777
  _parseFrontmatter,
707
778
  _serializeFrontmatter,
708
779
  _hashDedup,
780
+ _expireByAge,
781
+ _entryAgeMs,
782
+ _resolveTtlByCategory,
709
783
  _archiveKbFile,
710
784
  COMPRESS_THRESHOLD_BYTES,
711
785
  SWEPT_FLAG_KEY,
package/engine/shared.js CHANGED
@@ -3155,6 +3155,22 @@ const ENGINE_DEFAULTS = {
3155
3155
  // & Review Loop, or set `engine.autoFixPaused: true` in config.json.
3156
3156
  autoFixPaused: false, // hard-stop kill-switch / master override (see comment above)
3157
3157
  autoConsolidateMemory: false, // opt-in: periodically spawn engine/kb-sweep-runner.js from the tick loop (4h cadence). Inbox→notes consolidation already runs every tick via consolidateInbox; this flag only controls the KB sweep.
3158
+ // W-mr48yth5 — per-category age-based TTL (in days) for the KB sweep's expiry
3159
+ // pass. During runKbSweep, an entry in a listed category whose authored date
3160
+ // (filename `YYYY-MM-DD-` prefix, falling back to file mtime) is older than
3161
+ // its TTL is archived to knowledge/_swept/ (recoverable — pruned after 30d by
3162
+ // the existing _pruneOldSwept pass). Transient, PR/incident-scoped categories
3163
+ // accumulate one-off notes that hash-dedup / LLM-dedup / rewrite can never
3164
+ // reclaim (each is unique by PR#/date), so they get finite TTLs; durable
3165
+ // categories (architecture, conventions) and per-agent memory (knowledge/agents/,
3166
+ // never scanned by the sweep) are intentionally omitted → never age-expired.
3167
+ // config.engine.kbCategoryTtlDays is merged over these defaults; set a category
3168
+ // to 0 (or omit it) to disable expiry for that category.
3169
+ kbCategoryTtlDays: {
3170
+ 'project-notes': 21,
3171
+ 'build-reports': 14,
3172
+ 'reviews': 30,
3173
+ },
3158
3174
  // W-mqtvnnj1000357fa — fleet-wide fallback for live-checkout auto-stash. When
3159
3175
  // true, a dirty live-checkout tree is `git stash push --include-untracked`'d
3160
3176
  // before dispatch instead of failing with FAILURE_CLASS.LIVE_CHECKOUT_DIRTY,
package/engine.js CHANGED
@@ -10645,55 +10645,84 @@ async function tickInner() {
10645
10645
 
10646
10646
  // Dispatch items — spawnAgent moves each from pending→active on disk.
10647
10647
  // We use the already-loaded item objects; spawnAgent handles the state transition.
10648
+ //
10649
+ // W-mr9o1oe0001c4601 — Spawn all claimed items for this tick CONCURRENTLY
10650
+ // rather than awaiting each spawnAgent serially. `spawnAgent` does real
10651
+ // wall-clock work per item (prompt build + `git worktree add`, which on a
10652
+ // ~250k-file monorepo can take multiple seconds), so the old sequential loop
10653
+ // made eager routing (engine/routing.js already fans work across every idle
10654
+ // agent in a single allocation pass) visibly "trickle in" one agent at a time,
10655
+ // contradicting the stated eager-dispatch design. `toDispatch` is already
10656
+ // bounded by `slotsAvailable` (≤ maxConcurrent — the seed loop above decrements
10657
+ // `generalSlots` and stops pushing at 0), so an unbounded Promise.allSettled
10658
+ // here is naturally capped; no dedicated spawn-concurrency knob is needed.
10659
+ // Concurrent `git worktree add` against the same repo is safe on this codebase:
10660
+ // each worktree gets its own `.git/worktrees/<name>` metadata dir, and
10661
+ // runWorktreeAdd already retries the exact lock-contention errors
10662
+ // (`index.lock`, `could not lock`, `resource busy`, `already exists`) that
10663
+ // concurrent adds can transiently produce. The per-project live-checkout gate
10664
+ // (liveProjectsInUse) already caps live-mode projects to one mutating dispatch,
10665
+ // so concurrent in-place writes to one operator checkout cannot happen here.
10648
10666
  const dispatched = new Set();
10649
- for (const item of toDispatch) {
10650
- if (!dispatched.has(item.id)) {
10651
- let proc;
10652
- try { proc = await spawnAgent(item, config); } catch (spawnErr) {
10653
- log('error', `spawnAgent exception for ${item.id}: ${spawnErr.message}`);
10654
- proc = null;
10655
- }
10656
- if (_isTickStale(myGeneration)) return;
10657
- if (proc === null) {
10658
- // spawnAgent failed (e.g., worktree creation error). It already called
10659
- // completeDispatch internally which handles retry logic, but log at the
10660
- // dispatch-loop level for visibility and handle any edge cases where
10661
- // completeDispatch wasn't called.
10662
- log('error', `spawnAgent returned null for ${item.id} (${item.type} → ${item.agent}) — spawn failed`);
10663
- // Defensive: ensure the work item is re-queued if completeDispatch didn't fire
10664
- if (item.meta?.item?.id) {
10665
- try {
10666
- const wiPath = item.meta.source === 'central-work-item' || item.meta.source === 'central-work-item-fanout'
10667
- ? path.join(ENGINE_DIR, '..', 'work-items.json')
10668
- : item.meta.project?.name ? projectWorkItemsPath({ name: item.meta.project.name, localPath: item.meta.project.localPath }) : null;
10669
- if (wiPath) {
10670
- if (_isTickStale(myGeneration)) return;
10671
- mutateWorkItems(wiPath, items => {
10672
- const wi = items.find(i => i.id === item.meta.item.id);
10673
- if (wi && wi.status === WI_STATUS.DISPATCHED) {
10674
- // completeDispatch didn't update the work item — re-queue manually
10675
- wi.status = WI_STATUS.PENDING;
10676
- wi._retryCount = (wi._retryCount || 0) + 1;
10677
- wi._lastRetryReason = 'spawnAgent returned null';
10678
- wi._lastRetryAt = ts();
10679
- delete wi.dispatched_at;
10680
- delete wi.dispatched_to;
10681
- log('info', `Re-queued ${item.meta.item.id} as pending (retry ${wi._retryCount})`);
10682
- }
10683
- });
10684
- }
10685
- } catch (e) { log('warn', `Failed to re-queue work item after spawn failure: ${e.message}`); }
10686
- }
10687
- } else {
10688
- dispatched.add(item.id);
10689
- // Sync PRD item status after successful spawn (#480)
10690
- if (item.meta?.item?.sourcePlan) {
10691
- try { syncPrdItemStatus(item.meta.item.id, WI_STATUS.DISPATCHED, item.meta.item.sourcePlan); }
10692
- catch (e) { log('warn', `prd sync after spawn: ${e.message}`); }
10693
- }
10667
+ const spawnOneItem = async (item) => {
10668
+ // Dedup by id (toDispatch is already deduped by seenPendingIds — defensive).
10669
+ if (dispatched.has(item.id)) return;
10670
+ // Staleness: if a newer tick force-released this one while we were building
10671
+ // the batch, don't spawn a stale tick's agents.
10672
+ if (_isTickStale(myGeneration)) return;
10673
+ let proc;
10674
+ try { proc = await spawnAgent(item, config); } catch (spawnErr) {
10675
+ log('error', `spawnAgent exception for ${item.id}: ${spawnErr.message}`);
10676
+ proc = null;
10677
+ }
10678
+ // A newer tick may have superseded us mid-spawn don't mutate shared state.
10679
+ if (_isTickStale(myGeneration)) return;
10680
+ if (proc === null) {
10681
+ // spawnAgent failed (e.g., worktree creation error). It already called
10682
+ // completeDispatch internally which handles retry logic, but log at the
10683
+ // dispatch-loop level for visibility and handle any edge cases where
10684
+ // completeDispatch wasn't called.
10685
+ log('error', `spawnAgent returned null for ${item.id} (${item.type} → ${item.agent}) — spawn failed`);
10686
+ // Defensive: ensure the work item is re-queued if completeDispatch didn't fire
10687
+ if (item.meta?.item?.id) {
10688
+ try {
10689
+ const wiPath = item.meta.source === 'central-work-item' || item.meta.source === 'central-work-item-fanout'
10690
+ ? path.join(ENGINE_DIR, '..', 'work-items.json')
10691
+ : item.meta.project?.name ? projectWorkItemsPath({ name: item.meta.project.name, localPath: item.meta.project.localPath }) : null;
10692
+ if (wiPath) {
10693
+ if (_isTickStale(myGeneration)) return;
10694
+ mutateWorkItems(wiPath, items => {
10695
+ const wi = items.find(i => i.id === item.meta.item.id);
10696
+ if (wi && wi.status === WI_STATUS.DISPATCHED) {
10697
+ // completeDispatch didn't update the work item — re-queue manually
10698
+ wi.status = WI_STATUS.PENDING;
10699
+ wi._retryCount = (wi._retryCount || 0) + 1;
10700
+ wi._lastRetryReason = 'spawnAgent returned null';
10701
+ wi._lastRetryAt = ts();
10702
+ delete wi.dispatched_at;
10703
+ delete wi.dispatched_to;
10704
+ log('info', `Re-queued ${item.meta.item.id} as pending (retry ${wi._retryCount})`);
10705
+ }
10706
+ });
10707
+ }
10708
+ } catch (e) { log('warn', `Failed to re-queue work item after spawn failure: ${e.message}`); }
10709
+ }
10710
+ } else {
10711
+ dispatched.add(item.id);
10712
+ // Sync PRD item status after successful spawn (#480)
10713
+ if (item.meta?.item?.sourcePlan) {
10714
+ try { syncPrdItemStatus(item.meta.item.id, WI_STATUS.DISPATCHED, item.meta.item.sourcePlan); }
10715
+ catch (e) { log('warn', `prd sync after spawn: ${e.message}`); }
10694
10716
  }
10695
10717
  }
10696
- }
10718
+ };
10719
+ // Promise.allSettled: one item's spawn failure never affects another's, and
10720
+ // each spawnOneItem already swallows its own errors (per-item try/catch), so a
10721
+ // rejected task here is not expected — allSettled is belt-and-suspenders.
10722
+ await Promise.allSettled(toDispatch.map((item) => spawnOneItem(item)));
10723
+ // A force-release may have superseded this tick while spawns were in flight —
10724
+ // abort before the downstream skip-reason annotation reads/mutates shared state.
10725
+ if (_isTickStale(myGeneration)) return;
10697
10726
 
10698
10727
  // Annotate remaining pending items with skipReason so dashboard can show why they're waiting.
10699
10728
  // Re-read dispatch after spawns (spawnAgent moves items from pending→active).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2338",
3
+ "version": "0.1.2340",
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"