@lawaty/lacode 1.3.1 → 2.1.0-beta.0

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/docs/CONTEXT.md CHANGED
@@ -30,8 +30,7 @@ the exploration it replaces is worse than no map:
30
30
  `src/orders/`". The cartographer is forbidden by name from writing directory
31
31
  listings, copying source, or restating what the README already says.
32
32
  - **One file per question.** Five navigation files, each with a defined scope, so
33
- an agent reads one of them rather than all of them. A sixth file,
34
- `active.md`, holds the work in flight rather than a map of the repository.
33
+ an agent reads one of them rather than all of them.
35
34
  - **Canonical entry points.** The highest-value thing in the map is a short
36
35
  "Start here" list per subsystem: the canonical files an agent should open, each
37
36
  with a one-phrase reason.
@@ -48,8 +47,7 @@ harness that produces a number for your own workload.
48
47
 
49
48
  ## The files in the map
50
49
 
51
- `.opencode/context/` holds the five navigation files below, plus one file that is
52
- a work list rather than a map:
50
+ `.opencode/context/` holds the five navigation files below:
53
51
 
54
52
  | File | What belongs in it |
55
53
  | --- | --- |
@@ -58,7 +56,6 @@ a work list rather than a map:
58
56
  | `conventions.md` | Repository-specific rules: naming, module layout, error handling, testing, persistence. Read this before writing code, not after. |
59
57
  | `workflows.md` | The few runtime flows worth tracing end to end — how a request becomes a mutation, how a plugin hook reaches the thing it hooks. Read this when you need to know what actually happens at runtime. |
60
58
  | `decisions.md` | Architectural constraints that bind future work and that the repository's own decision log does not already record. Each entry states the constraint and points at the page or path that owns it. Capped at 120 non-blank lines; decision records and deliberation are not copied here. |
61
- | `active.md` | The active backlog: work in flight, grouped by the ISO week each task was opened in. A task that is not marked `[x]` is open and is never removed; a task that is finished and dated is kept for a week after it was finished, then dropped. The plugin enforces that window — see [How it stays current](#how-it-stays-current). Not a navigation file: there is no question it is the answer to. |
62
59
 
63
60
  The project's root `AGENTS.md` is the standing-instructions home: the client's
64
61
  own statement of intent, what they want worked on and how they want it
@@ -98,20 +95,19 @@ Every navigation entry has one shape, in every file:
98
95
  the drift check.
99
96
 
100
97
  Every file has a hard cap, counted in **non-blank lines**: architecture 150,
101
- contexts 200, conventions 150, workflows 150, decisions 120, active 60. Lines
102
- are the unit because they are what a reader pays for, they bound an entry that
103
- wraps to three lines, and the validator can count them exactly; an entry count
104
- does not bound cost, and a token count needs a tokenizer and is model-specific.
105
- The cap is enforced by a post-generation validator, not only by the prompt.
106
-
107
- The lifecycle generalises the window `active.md` already had. A finished backlog
108
- task is dropped after 7 days. A `[provisional]` entry in any navigation file is
109
- dropped 30 days after its `verified` date unless it is re-verified. `[binding]`
110
- and `[disputed]` never expire by age. An entry whose cited source no longer
111
- exists is **never** auto-dropped: it may be the only copy, so it is reported as
112
- unarchived and kept. Only provisional entries whose source still resolves are
113
- eligible for the sweep. The arithmetic lives in `src/lib/map-schema.ts`; the
114
- cartographer writes the shape, code decides what has aged out.
98
+ contexts 200, conventions 150, workflows 150, decisions 120. Lines are the unit
99
+ because they are what a reader pays for, they bound an entry that wraps to three
100
+ lines, and the validator can count them exactly; an entry count does not bound
101
+ cost, and a token count needs a tokenizer and is model-specific. The cap is
102
+ enforced by a post-generation validator, not only by the prompt.
103
+
104
+ A `[provisional]` entry in any navigation file is dropped 30 days after its
105
+ `verified` date unless it is re-verified. `[binding]` and `[disputed]` never
106
+ expire by age. An entry whose cited source no longer exists is **never**
107
+ auto-dropped: it may be the only copy, so it is reported as unarchived and kept.
108
+ Only provisional entries whose source still resolves are eligible for the sweep.
109
+ The arithmetic lives in `src/lib/map-schema.ts`; the cartographer writes the
110
+ shape, code decides what has aged out.
115
111
 
116
112
  The map is **local and untracked**. It describes *your* checkout on *this*
117
113
  machine, so committing it would churn on every session that edits a file, and
@@ -193,25 +189,13 @@ your source.
193
189
  If you have defined a command of the same name, yours wins and is left exactly
194
190
  as you wrote it — the same merge-without-clobber rule the agents follow.
195
191
 
196
- `active.md` is the file the plugin maintains as a backlog rather than as a
197
- navigation map. The cartographer records what happened — work in flight, and a
198
- task marked finished with the date it finished — and the plugin decides what is
199
- still worth keeping: the file is regrouped by week and a task that was finished
200
- more than a week ago is dropped. The arithmetic lives in
201
- `src/lib/active-backlog.ts`, not in the cartographer's prompt, because a rule a
202
- model applies is a rule the model applies when it feels like it, and the failure
203
- is silent — the backlog just grows. The plugin normalizes the file on every edit
204
- of it and once when the session starts, so a hand edit is grouped and pruned
205
- whether the cartographer ran or not, and the file is rewritten only when its
206
- canonical form differs from what is on disk, so the write cannot loop.
207
-
208
192
  The same code owns the navigation files' lifecycle, but it may only **drop** an
209
193
  entry that is `[provisional]`, past its 30-day window, and whose cited source
210
194
  still exists. It never drops a binding constraint or disputed entry, and it
211
195
  never drops an entry with no source — that is the one case where the map may be
212
196
  the only copy. The plugin is not a second writer of the map in the permission
213
197
  sense: `context` is still the only **agent** that writes it. The lifecycle is
214
- housekeeping by code, the same category as the backlog window.
198
+ housekeeping by code, applied after a run and then audited.
215
199
 
216
200
  ## One writer
217
201
 
@@ -301,7 +285,7 @@ to check is that permission rule, not the map's content.
301
285
  - The prompt the model actually runs, in one place: `src/lib/cartographer-prompt.ts`; the tracked copy is `.opencode/prompts/context.txt`, and a test pins equality.
302
286
  - The idle/edit trigger and the post-run audit wiring: `src/plugins/context-autoupdate.ts`.
303
287
  - The schema, budget, lifecycle and deterministic drift check: `src/lib/map-schema.ts`.
304
- - The active backlog's window: `src/lib/active-backlog.ts`.
288
+
305
289
  - The one-writer rule check: `src/lib/writer-rule.ts`.
306
290
  - Fixtures and their report: `tests/fixtures/context-map/`, `scripts/context-map-fixture.mjs`.
307
291
  - The behavioural contracts, with acceptance criteria a test asserts:
@@ -471,6 +471,29 @@ Enforcement is split deliberately, and the split matters: **what the permission
471
471
  - [ ] Given the user's config already defines one of the three commands, When the config hook runs, Then that command is left byte-identical while the other two are still added.
472
472
  - [ ] Given the commands are injected, When each one's target agent is resolved against the same config object, Then all three resolve to an agent that actually exists there — a command must never point at a missing agent.
473
473
 
474
+ ### US-35: A timeline survives a restart and resumes unfinished work
475
+ **As a** user whose opencode process restarts in the middle of a session, **I want** the timeline to remember what it has already summarized and to finish the slice it had not written yet, **so that** a crash or restart never silently loses part of the session's work record.
476
+
477
+ The cursor and retry state used to live only in memory; a process that stopped between messages and the next idle flush forgot that a slice was pending, and a successful summary that produced no bullet text advanced the cursor only in memory, so a restart could summarize the same slice again. The plugin now writes a small per-session state file next to the JSONL timeline (cursor, pending flag, failure count, next retry time); startup resumes pending work, and a deleted session removes both files.
478
+
479
+ **Story ID(s) for tests:** US-35
480
+ **Acceptance criteria:**
481
+ - [ ] Given a session has a pending timeline slice recorded on disk, When the process restarts, Then the timeline resumes and completes that slice without waiting for a new message event.
482
+ - [ ] Given a summarization succeeds but produces no bullet text, When the process restarts, Then the advanced cursor is still known and the same transcript slice is not summarized again.
483
+ - [ ] Given a session is deleted, When its timeline is removed, Then its durable state file is removed with it.
484
+
485
+ ### US-36: A failed timeline summary retries with backoff
486
+ **As a** user whose summarizer call fails transiently, **I want** the timeline to retry on its own with an increasing delay, **so that** a temporary model or network failure does not leave the timeline permanently behind the session.
487
+
488
+ A failed summarization used to clear the pending flag and never reschedule, so only new session activity could try again. A failure now marks the slice pending, records the next attempt time, and schedules a retry; repeated failures back off exponentially up to a cap, and an idle event during the wait re-arms the timer instead of bypassing it. A successful retry clears the failure count.
489
+
490
+ **Story ID(s) for tests:** US-36
491
+ **Acceptance criteria:**
492
+ - [ ] Given a summarization fails transiently, When the retry interval elapses, Then the timeline retries without waiting for new session activity.
493
+ - [ ] Given repeated failures, When retries are scheduled, Then the wait grows with each failure up to a configured cap.
494
+ - [ ] Given a retry is waiting, When the session goes idle before the backoff window elapses, Then the idle event does not bypass the wait.
495
+ - [ ] Given a retry succeeds, When entries are appended, Then the failure count resets to zero and the timeline is no longer pending.
496
+
474
497
  ---
475
498
 
476
499
  ## Epic H — Retention (sweeps)
@@ -554,6 +577,8 @@ table was filled in.
554
577
  | US-20 | G | `tests/stories/g-context-management.test.mjs` |
555
578
  | US-21 | G | `tests/stories/g-context-management.test.mjs`, `tests/permission-test.mjs` |
556
579
  | US-22 | G | `tests/stories/g-context-management.test.mjs` |
580
+ | US-35 | G | `tests/stories/j-timeline-reliability.test.mjs` |
581
+ | US-36 | G | `tests/stories/j-timeline-reliability.test.mjs` |
557
582
  | US-32 | H | `tests/retire-old-sessions-test.mjs` |
558
583
  | US-33 | H | `tests/prune-old-events-test.mjs` |
559
584
 
@@ -561,8 +586,8 @@ The nine suites under `tests/` are unit-style; the `tests/stories/` suites are
561
586
  the behavioural layer, written one epic per file with every test name carrying
562
587
  its story id. Run everything with `npm test`.
563
588
 
564
- **No story is uncovered.** The guard reports 33 stories, 325 test cases, and
565
- 33/33 stories with at least one behavioural test; US-1, US-2, US-3, US-6, and
589
+ **No story is uncovered.** The guard reports 36 stories, 348 test cases, and
590
+ 36/36 stories with at least one behavioural test; US-1, US-2, US-3, US-6, and
566
591
  all of epic F have tests, and the coverage guard fails the build if any of them
567
592
  loses one. The scan covers the `tests/stories/` suites and the root-level
568
593
  `tests/*-test.mjs` suites.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lawaty/lacode",
3
- "version": "1.3.1",
3
+ "version": "2.1.0-beta.0",
4
4
  "description": "Ultimate opencode setup for Go users — cost-controlled pooled subagents, hang recovery, and notifications for opencode.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -6,20 +6,19 @@
6
6
  // writes on a fail-closed condition.
7
7
  //
8
8
  // Layout (exact, per spec):
9
- // 1. every free opencode-go model with ok:true and a numeric score, one slot
10
- // each, sorted by score descending, weight 4
11
- // 2. every slot from the current pool whose provider is NOT present in the
12
- // score file, preserved exactly (raw line text, weight/bucket/notify, order)
13
- // 3. exactly one free opencode model — the best ok:true numeric score —
14
- // weight 2, with b:opencode
9
+ // 1. every opencode-go model with ok:true, a numeric score at or above the
10
+ // score floor and a context at or above the context floor, sorted by score
11
+ // descending, weight 4
12
+ // 2. the best (10 - n) models from every other provider, sorted by score
13
+ // descending, weight 2; opencode models get b:opencode
15
14
  //
16
- // Total = #opencode-go free + #preserved unscored + 1.
15
+ // Total = n + min(10 - n, #others), where n = #1.
17
16
  //
18
17
  // This file ships as source and must run with plain `node` (ESM, no TS).
19
18
 
20
- import { mkdirSync, renameSync, writeFileSync, readFileSync, existsSync } from "node:fs"
19
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
21
20
  import { execFileSync } from "node:child_process"
22
- import { homedir } from "node:os"
21
+ import { homedir, userInfo } from "node:os"
23
22
  import { dirname, join, resolve } from "node:path"
24
23
  import { fileURLToPath } from "node:url"
25
24
 
@@ -27,6 +26,8 @@ const HOME = homedir()
27
26
  export const DEFAULT_SCORES_PATH = join(HOME, ".config", "lacode", "model-scores.json")
28
27
  export const DEFAULT_POOL_PATH = join(HOME, ".config", "lacode", "pool.txt")
29
28
  export const SYSTEMD_USER_DIR = (home = HOME) => join(home, ".config", "systemd", "user")
29
+ export const STABLE_LIB_DIR = (home = HOME) => join(home, ".local", "lib", "lacode")
30
+ export const STABLE_SCRIPT = (home = HOME) => join(STABLE_LIB_DIR(home), "refresh-pool.mjs")
30
31
  export const REFRESH_SERVICE_NAME = "lacode-refresh-pool.service"
31
32
  export const REFRESH_TIMER_NAME = "lacode-refresh-pool.timer"
32
33
 
@@ -42,17 +43,12 @@ const providerOf = (modelOrLine) => {
42
43
  return slash < 0 ? token : token.slice(0, slash)
43
44
  }
44
45
 
45
- const isFree = (modelId) => {
46
- const id = String(modelId || "")
47
- return id.endsWith(":free") || id.endsWith("-free")
48
- }
49
-
50
46
  const numericScore = (row) => typeof row?.score === "number" && Number.isFinite(row.score)
51
47
 
52
- const okFreeRows = (providerEntry) => {
53
- const rows = Array.isArray(providerEntry?.rows) ? providerEntry.rows : []
54
- return rows.filter((row) => row?.ok === true && numericScore(row) && isFree(row.model))
55
- }
48
+ // Fallbacks for score files written before the floors were recorded there.
49
+ const MIN_CONTEXT = 200_000
50
+ const MIN_SCORE = 60
51
+ const POOL_SIZE = 10
56
52
 
57
53
  function splitHeaderAndSlots(text) {
58
54
  const lines = String(text ?? "").split(/\r?\n/)
@@ -70,7 +66,7 @@ function splitHeaderAndSlots(text) {
70
66
  function refreshComment(now, scoresGeneratedAt) {
71
67
  const when = now instanceof Date ? now.toISOString() : String(now)
72
68
  const gen = scoresGeneratedAt ? ` (scores generated ${scoresGeneratedAt})` : ""
73
- return `# Refreshed ${when} from model-scores.json${gen}: opencode-go free models first, best opencode last.`
69
+ return `# Refreshed ${when} from model-scores.json${gen}: opencode-go first (weight 4), then the best others (weight 2).`
74
70
  }
75
71
 
76
72
  function withRefreshComment(headerLines, comment) {
@@ -88,7 +84,7 @@ function formatGeneratedLine(model, weight, bucket) {
88
84
  * Pure builder. Throws on every fail-closed condition; never touches the filesystem.
89
85
  *
90
86
  * @param {{ scores: any, currentPoolText: string, now?: Date|string }} input
91
- * @returns {{ text: string, slots: string[], warnings: string[], chosen: { opencodeGo: string[], opencode: string, preserved: string[] } }}
87
+ * @returns {{ text: string, slots: string[], warnings: string[], chosen: { opencodeGo: string[], others: string[] } }}
92
88
  */
93
89
  export function buildPool(input) {
94
90
  const warnings = []
@@ -97,33 +93,34 @@ export function buildPool(input) {
97
93
  throw new Error("score file is missing or invalid (expected { providers: [...] })")
98
94
  }
99
95
 
100
- const providerEntries = scores.providers
101
- const scoredProviders = new Set(
102
- providerEntries.map((p) => String(p?.provider ?? "").trim()).filter(Boolean),
103
- )
104
-
105
- const goEntry = providerEntries.find((p) => p?.provider === "opencode-go")
106
- const goRows = okFreeRows(goEntry).sort((a, b) => b.score - a.score)
107
- if (goRows.length === 0) {
108
- throw new Error("no free opencode-go model with ok:true and a numeric score is available; refusing to write pool.txt")
96
+ const minScore = Number(scores.minScore) || MIN_SCORE
97
+ const minContext = Number(scores.minContext) || MIN_CONTEXT
98
+ const rankable = (row) =>
99
+ row?.ok === true &&
100
+ numericScore(row) &&
101
+ row.score >= minScore &&
102
+ (row.context == null || row.context >= minContext)
103
+ const byScore = (a, b) => b.score - a.score || String(a.model).localeCompare(String(b.model))
104
+
105
+ const rows = scores.providers.flatMap((entry) => (Array.isArray(entry?.rows) ? entry.rows : []))
106
+ const goRows = rows.filter((row) => providerOf(row.model) === "opencode-go" && rankable(row)).sort(byScore)
107
+ const others = rows.filter((row) => providerOf(row.model) !== "opencode-go" && rankable(row)).sort(byScore)
108
+ const otherRows = others.slice(0, Math.max(0, POOL_SIZE - goRows.length))
109
+
110
+ if (goRows.length + otherRows.length === 0) {
111
+ throw new Error("no scorable model at or above the minimum score is available; refusing to write pool.txt")
109
112
  }
110
-
111
- const opEntry = providerEntries.find((p) => p?.provider === "opencode")
112
- const opRows = okFreeRows(opEntry).sort((a, b) => b.score - a.score)
113
- if (opRows.length === 0) {
114
- throw new Error("no free opencode model with ok:true and a numeric score is available; refusing to write pool.txt")
113
+ if (goRows.length + otherRows.length < POOL_SIZE) {
114
+ warnings.push(`only ${goRows.length + otherRows.length} scorable model(s) available, wanted ${POOL_SIZE}`)
115
115
  }
116
- const bestOp = opRows[0].model
117
116
 
118
- const { header, slots: currentSlots } = splitHeaderAndSlots(input?.currentPoolText ?? "")
119
- const preserved = currentSlots.filter((line) => {
120
- if (!isSlotLine(line)) return false
121
- return !scoredProviders.has(providerOf(line))
122
- })
123
-
124
- const block1 = goRows.map((row) => formatGeneratedLine(row.model, 4))
125
- const block3 = [formatGeneratedLine(bestOp, 2, "opencode")]
126
- const outSlots = [...block1, ...preserved, ...block3]
117
+ const { header } = splitHeaderAndSlots(input?.currentPoolText ?? "")
118
+ const outSlots = [
119
+ ...goRows.map((row) => formatGeneratedLine(row.model, 4)),
120
+ ...otherRows.map((row) =>
121
+ formatGeneratedLine(row.model, 2, providerOf(row.model) === "opencode" ? "opencode" : undefined),
122
+ ),
123
+ ]
127
124
 
128
125
  const comment = refreshComment(input?.now ?? new Date(), scores.generatedAt)
129
126
  const outHeader = withRefreshComment(header, comment)
@@ -135,8 +132,7 @@ export function buildPool(input) {
135
132
  warnings,
136
133
  chosen: {
137
134
  opencodeGo: goRows.map((r) => r.model),
138
- opencode: bestOp,
139
- preserved: preserved.map((l) => String(l).split("#")[0].trim().split(/\s+/)[0]),
135
+ others: otherRows.map((r) => r.model),
140
136
  },
141
137
  }
142
138
  }
@@ -163,6 +159,12 @@ function atomicWriteSimple(path, text) {
163
159
  renameSync(tmp, path)
164
160
  }
165
161
 
162
+ function writeFileAtomic(path, text) {
163
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`
164
+ writeFileSync(tmp, text, "utf8")
165
+ renameSync(tmp, path)
166
+ }
167
+
166
168
  function runScorer(max) {
167
169
  const here = dirname(fileURLToPath(import.meta.url))
168
170
  const scorer = resolve(here, "score-models.mjs")
@@ -192,8 +194,7 @@ Nice=10
192
194
  Description=Daily LaCode model pool refresh
193
195
 
194
196
  [Timer]
195
- OnCalendar=daily
196
- RandomizedDelaySec=2h
197
+ OnCalendar=*-*-* 01:00:00
197
198
  Persistent=true
198
199
  Unit=${REFRESH_SERVICE_NAME}
199
200
 
@@ -203,18 +204,23 @@ WantedBy=timers.target
203
204
  return { service, timer, servicePath, timerPath }
204
205
  }
205
206
 
206
- function installTimerUnits() {
207
- const units = renderTimerUnits({
208
- nodePath: process.execPath,
209
- scriptPath: fileURLToPath(import.meta.url),
210
- home: HOME,
211
- })
212
- mkdirSync(dirname(units.servicePath), { recursive: true })
213
- writeFileSync(units.servicePath, units.service, "utf8")
214
- writeFileSync(units.timerPath, units.timer, "utf8")
207
+ function installTimerUnits(stable = false) {
208
+ const live = fileURLToPath(import.meta.url)
209
+ const scriptPath = stable ? STABLE_SCRIPT(HOME) : live
210
+ const units = renderTimerUnits({ nodePath: process.execPath, scriptPath, home: HOME })
215
211
  try {
212
+ if (stable) {
213
+ mkdirSync(STABLE_LIB_DIR(HOME), { recursive: true })
214
+ copyFileSync(live, scriptPath)
215
+ }
216
+ mkdirSync(dirname(units.servicePath), { recursive: true })
217
+ writeFileAtomic(units.servicePath, units.service)
218
+ writeFileAtomic(units.timerPath, units.timer)
216
219
  execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "inherit" })
217
220
  execFileSync("systemctl", ["--user", "enable", "--now", REFRESH_TIMER_NAME], { stdio: "inherit" })
221
+ try {
222
+ execFileSync("loginctl", ["enable-linger", userInfo().username], { stdio: "ignore" })
223
+ } catch {}
218
224
  } catch (error) {
219
225
  console.error(`failed to install systemd user timer: ${String(error?.message ?? error)}`)
220
226
  console.error(`unit files written:`)
@@ -226,6 +232,7 @@ function installTimerUnits() {
226
232
  process.exitCode = 1
227
233
  return false
228
234
  }
235
+ if (stable) console.log(`copied ${live} to ${scriptPath}`)
229
236
  console.log(`installed ${units.servicePath}`)
230
237
  console.log(`installed ${units.timerPath}`)
231
238
  console.log(`enabled ${REFRESH_TIMER_NAME}`)
@@ -237,6 +244,8 @@ function usage() {
237
244
 
238
245
  --from-scores skip scoring, use the existing ~/.config/lacode/model-scores.json
239
246
  --install-timer install and enable a user systemd oneshot service + daily timer
247
+ (add --stable to copy the script to ~/.local/lib/lacode and
248
+ point ExecStart there, so plugin-cache updates do not break it)
240
249
  --print print the proposed pool.txt and make no changes (alias: --dry-run)
241
250
  --dry-run same as --print
242
251
  --max N models per provider passed to the scorer (default: 20)
@@ -248,10 +257,10 @@ function usage() {
248
257
  async function main() {
249
258
  const argv = process.argv.slice(2)
250
259
  if (argv.includes("--help") || argv.includes("-h")) { usage(); return }
251
- if (argv.includes("--install-timer")) {
252
- installTimerUnits()
253
- return
254
- }
260
+ if (argv.includes("--install-timer")) {
261
+ installTimerUnits(argv.includes("--stable"))
262
+ return
263
+ }
255
264
  const fromScores = argv.includes("--from-scores")
256
265
  const printOnly = argv.includes("--print") || argv.includes("--dry-run")
257
266
  const value = (flag, fallback) => {
@@ -294,10 +303,12 @@ async function main() {
294
303
 
295
304
  const oldSlotCount = splitHeaderAndSlots(currentPoolText).slots.filter(isSlotLine).length
296
305
  const newSlotCount = built.slots.filter(isSlotLine).length
306
+ const dropped = scores.providers.flatMap((entry) => (Array.isArray(entry?.excluded) ? entry.excluded : []))
297
307
  const summary = [
298
308
  `opencode-go [${built.chosen.opencodeGo.join(", ")}]`,
299
- `preserved-unscored [${built.chosen.preserved.join(", ")}]`,
300
- `opencode-last [${built.chosen.opencode}]`,
309
+ `others [${built.chosen.others.join(", ")}]`,
310
+ ...(dropped.length ? [`dropped [${dropped.map((x) => x.model).join(", ")}]`] : []),
311
+ ...(built.warnings.length ? [`warnings [${built.warnings.join("; ")}]`] : []),
301
312
  ].join(" | ")
302
313
 
303
314
  if (printOnly) {