@lawaty/lacode 1.3.0-beta.9 → 2.0.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.0-beta.9",
3
+ "version": "2.0.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",
@@ -50,7 +50,8 @@
50
50
  "CHANGELOG.md",
51
51
  "README.md",
52
52
  "docs",
53
- "scripts/score-models.mjs"
53
+ "scripts/score-models.mjs",
54
+ "scripts/refresh-pool.mjs"
54
55
  ],
55
56
  "scripts": {
56
57
  "build": "node scripts/build.mjs",
@@ -58,7 +59,8 @@
58
59
  "postpublish": "node scripts/sync-hosts.mjs && ./bin/oc-sync",
59
60
  "test": "node --test tests/",
60
61
  "benchmark": "node tests/benchmark/run.mjs",
61
- "score": "node scripts/score-models.mjs"
62
+ "score": "node scripts/score-models.mjs",
63
+ "refresh-pool": "node scripts/refresh-pool.mjs"
62
64
  },
63
65
  "devDependencies": {
64
66
  "esbuild": "^0.28.2"
@@ -0,0 +1,335 @@
1
+ #!/usr/bin/env node
2
+ // Daily-refreshable pool builder.
3
+ //
4
+ // Reads the scorer's output (~/.config/lacode/model-scores.json) and proposes a
5
+ // rewritten ~/.config/lacode/pool.txt. It never partially writes and never
6
+ // writes on a fail-closed condition.
7
+ //
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
15
+ //
16
+ // Total = #opencode-go free + #preserved unscored + 1.
17
+ //
18
+ // This file ships as source and must run with plain `node` (ESM, no TS).
19
+
20
+ import { mkdirSync, renameSync, writeFileSync, readFileSync, existsSync } from "node:fs"
21
+ import { execFileSync } from "node:child_process"
22
+ import { homedir } from "node:os"
23
+ import { dirname, join, resolve } from "node:path"
24
+ import { fileURLToPath } from "node:url"
25
+
26
+ const HOME = homedir()
27
+ export const DEFAULT_SCORES_PATH = join(HOME, ".config", "lacode", "model-scores.json")
28
+ export const DEFAULT_POOL_PATH = join(HOME, ".config", "lacode", "pool.txt")
29
+ export const SYSTEMD_USER_DIR = (home = HOME) => join(home, ".config", "systemd", "user")
30
+ export const REFRESH_SERVICE_NAME = "lacode-refresh-pool.service"
31
+ export const REFRESH_TIMER_NAME = "lacode-refresh-pool.timer"
32
+
33
+ const isSlotLine = (line) => {
34
+ const stripped = String(line).split("#")[0].trim()
35
+ return stripped.length > 0 && stripped.includes("/")
36
+ }
37
+
38
+ const providerOf = (modelOrLine) => {
39
+ const stripped = String(modelOrLine).split("#")[0].trim()
40
+ const token = stripped.split(/\s+/)[0] || ""
41
+ const slash = token.indexOf("/")
42
+ return slash < 0 ? token : token.slice(0, slash)
43
+ }
44
+
45
+ const isFree = (modelId) => {
46
+ const id = String(modelId || "")
47
+ return id.endsWith(":free") || id.endsWith("-free")
48
+ }
49
+
50
+ const numericScore = (row) => typeof row?.score === "number" && Number.isFinite(row.score)
51
+
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
+ }
56
+
57
+ function splitHeaderAndSlots(text) {
58
+ const lines = String(text ?? "").split(/\r?\n/)
59
+ // Drop a single trailing empty element produced by a final newline so we can
60
+ // reason about the file's logical lines; we re-add a final newline on output.
61
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop()
62
+ let first = -1
63
+ for (let i = 0; i < lines.length; i++) {
64
+ if (isSlotLine(lines[i])) { first = i; break }
65
+ }
66
+ if (first === -1) return { header: lines, slots: [] }
67
+ return { header: lines.slice(0, first), slots: lines.slice(first) }
68
+ }
69
+
70
+ function refreshComment(now, scoresGeneratedAt) {
71
+ const when = now instanceof Date ? now.toISOString() : String(now)
72
+ const gen = scoresGeneratedAt ? ` (scores generated ${scoresGeneratedAt})` : ""
73
+ return `# Refreshed ${when} from model-scores.json${gen}: opencode-go free models first, best opencode last.`
74
+ }
75
+
76
+ function withRefreshComment(headerLines, comment) {
77
+ const kept = headerLines.filter((line) => !/^#\s*Refreshed\b/.test(String(line)))
78
+ return [...kept, comment]
79
+ }
80
+
81
+ function formatGeneratedLine(model, weight, bucket) {
82
+ const parts = [String(model), String(weight)]
83
+ if (bucket) parts.push(`b:${bucket}`)
84
+ return parts.join(" ")
85
+ }
86
+
87
+ /**
88
+ * Pure builder. Throws on every fail-closed condition; never touches the filesystem.
89
+ *
90
+ * @param {{ scores: any, currentPoolText: string, now?: Date|string }} input
91
+ * @returns {{ text: string, slots: string[], warnings: string[], chosen: { opencodeGo: string[], opencode: string, preserved: string[] } }}
92
+ */
93
+ export function buildPool(input) {
94
+ const warnings = []
95
+ const scores = input?.scores
96
+ if (!scores || typeof scores !== "object" || !Array.isArray(scores.providers)) {
97
+ throw new Error("score file is missing or invalid (expected { providers: [...] })")
98
+ }
99
+
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")
109
+ }
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")
115
+ }
116
+ const bestOp = opRows[0].model
117
+
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]
127
+
128
+ const comment = refreshComment(input?.now ?? new Date(), scores.generatedAt)
129
+ const outHeader = withRefreshComment(header, comment)
130
+
131
+ const text = [...outHeader, "", ...outSlots, ""].join("\n")
132
+ return {
133
+ text,
134
+ slots: outSlots,
135
+ warnings,
136
+ chosen: {
137
+ opencodeGo: goRows.map((r) => r.model),
138
+ opencode: bestOp,
139
+ preserved: preserved.map((l) => String(l).split("#")[0].trim().split(/\s+/)[0]),
140
+ },
141
+ }
142
+ }
143
+
144
+ function readJsonFile(path) {
145
+ if (!existsSync(path)) throw new Error(`file not found: ${path}`)
146
+ const raw = readFileSync(path, "utf8")
147
+ try {
148
+ return JSON.parse(raw)
149
+ } catch (error) {
150
+ throw new Error(`invalid JSON in ${path}: ${String(error?.message ?? error)}`)
151
+ }
152
+ }
153
+
154
+ function readTextFile(path) {
155
+ if (!existsSync(path)) return ""
156
+ return readFileSync(path, "utf8")
157
+ }
158
+
159
+ function atomicWriteSimple(path, text) {
160
+ const dir = dirname(path)
161
+ const tmp = join(dir, `.pool.txt.tmp-${process.pid}-${Date.now()}`)
162
+ writeFileSync(tmp, text, "utf8")
163
+ renameSync(tmp, path)
164
+ }
165
+
166
+ function runScorer(max) {
167
+ const here = dirname(fileURLToPath(import.meta.url))
168
+ const scorer = resolve(here, "score-models.mjs")
169
+ if (!existsSync(scorer)) throw new Error(`scorer not found: ${scorer}`)
170
+ const args = [scorer, "--max", String(max)]
171
+ execFileSync(process.execPath, args, { stdio: "inherit" })
172
+ }
173
+
174
+ export function renderTimerUnits(input = {}) {
175
+ const nodePath = input.nodePath || process.execPath
176
+ const scriptPath = input.scriptPath || fileURLToPath(import.meta.url)
177
+ const home = input.home || HOME
178
+ const servicePath = join(SYSTEMD_USER_DIR(home), REFRESH_SERVICE_NAME)
179
+ const timerPath = join(SYSTEMD_USER_DIR(home), REFRESH_TIMER_NAME)
180
+ const service = `[Unit]
181
+ Description=Refresh the LaCode model pool from free-model scores
182
+ After=network-online.target
183
+ Wants=network-online.target
184
+
185
+ [Service]
186
+ Type=oneshot
187
+ ExecStart=${nodePath} ${scriptPath}
188
+ Environment=PATH=${home}/.opencode/bin:/usr/local/bin:/usr/bin:/bin
189
+ Nice=10
190
+ `
191
+ const timer = `[Unit]
192
+ Description=Daily LaCode model pool refresh
193
+
194
+ [Timer]
195
+ OnCalendar=daily
196
+ RandomizedDelaySec=2h
197
+ Persistent=true
198
+ Unit=${REFRESH_SERVICE_NAME}
199
+
200
+ [Install]
201
+ WantedBy=timers.target
202
+ `
203
+ return { service, timer, servicePath, timerPath }
204
+ }
205
+
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")
215
+ try {
216
+ execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "inherit" })
217
+ execFileSync("systemctl", ["--user", "enable", "--now", REFRESH_TIMER_NAME], { stdio: "inherit" })
218
+ } catch (error) {
219
+ console.error(`failed to install systemd user timer: ${String(error?.message ?? error)}`)
220
+ console.error(`unit files written:`)
221
+ console.error(` ${units.servicePath}`)
222
+ console.error(` ${units.timerPath}`)
223
+ console.error(`manual commands:`)
224
+ console.error(` systemctl --user daemon-reload`)
225
+ console.error(` systemctl --user enable --now ${REFRESH_TIMER_NAME}`)
226
+ process.exitCode = 1
227
+ return false
228
+ }
229
+ console.log(`installed ${units.servicePath}`)
230
+ console.log(`installed ${units.timerPath}`)
231
+ console.log(`enabled ${REFRESH_TIMER_NAME}`)
232
+ return true
233
+ }
234
+
235
+ function usage() {
236
+ console.log(`Usage: node scripts/refresh-pool.mjs [options]
237
+
238
+ --from-scores skip scoring, use the existing ~/.config/lacode/model-scores.json
239
+ --install-timer install and enable a user systemd oneshot service + daily timer
240
+ --print print the proposed pool.txt and make no changes (alias: --dry-run)
241
+ --dry-run same as --print
242
+ --max N models per provider passed to the scorer (default: 20)
243
+ --scores PATH scores file (default: ${DEFAULT_SCORES_PATH})
244
+ --pool PATH pool file to read/compare/write (default: ${DEFAULT_POOL_PATH})
245
+ -h, --help this message`)
246
+ }
247
+
248
+ async function main() {
249
+ const argv = process.argv.slice(2)
250
+ if (argv.includes("--help") || argv.includes("-h")) { usage(); return }
251
+ if (argv.includes("--install-timer")) {
252
+ installTimerUnits()
253
+ return
254
+ }
255
+ const fromScores = argv.includes("--from-scores")
256
+ const printOnly = argv.includes("--print") || argv.includes("--dry-run")
257
+ const value = (flag, fallback) => {
258
+ const hit = argv.find((a) => a === flag || a.startsWith(`${flag}=`))
259
+ if (!hit) return fallback
260
+ return hit.includes("=") ? hit.slice(hit.indexOf("=") + 1) : argv[argv.indexOf(hit) + 1]
261
+ }
262
+ const max = Math.max(1, Number(value("--max", "20")) || 20)
263
+ const scoresPath = value("--scores", DEFAULT_SCORES_PATH)
264
+ const poolPath = value("--pool", DEFAULT_POOL_PATH)
265
+
266
+ if (!fromScores) {
267
+ try {
268
+ runScorer(max)
269
+ } catch (error) {
270
+ console.error(`scorer failed; pool.txt not written: ${String(error?.message ?? error)}`)
271
+ process.exitCode = 1
272
+ return
273
+ }
274
+ }
275
+
276
+ let scores
277
+ try {
278
+ scores = readJsonFile(scoresPath)
279
+ } catch (error) {
280
+ console.error(String(error?.message ?? error))
281
+ process.exitCode = 1
282
+ return
283
+ }
284
+
285
+ const currentPoolText = readTextFile(poolPath)
286
+ let built
287
+ try {
288
+ built = buildPool({ scores, currentPoolText, now: new Date() })
289
+ } catch (error) {
290
+ console.error(`refresh refused (fail closed): ${String(error?.message ?? error)}`)
291
+ process.exitCode = 1
292
+ return
293
+ }
294
+
295
+ const oldSlotCount = splitHeaderAndSlots(currentPoolText).slots.filter(isSlotLine).length
296
+ const newSlotCount = built.slots.filter(isSlotLine).length
297
+ const summary = [
298
+ `opencode-go [${built.chosen.opencodeGo.join(", ")}]`,
299
+ `preserved-unscored [${built.chosen.preserved.join(", ")}]`,
300
+ `opencode-last [${built.chosen.opencode}]`,
301
+ ].join(" | ")
302
+
303
+ if (printOnly) {
304
+ process.stdout.write(built.text)
305
+ console.error(`--print: proposed ${oldSlotCount} -> ${newSlotCount} slots; no changes written. ${summary}`)
306
+ return
307
+ }
308
+
309
+ if (existsSync(poolPath)) {
310
+ try {
311
+ const existing = readFileSync(poolPath, "utf8")
312
+ if (existing === built.text) {
313
+ console.log(`unchanged: ${poolPath} (${newSlotCount} slots). ${summary}`)
314
+ return
315
+ }
316
+ } catch {}
317
+ }
318
+
319
+ try {
320
+ atomicWriteSimple(poolPath, built.text)
321
+ } catch (error) {
322
+ console.error(`failed to write ${poolPath}: ${String(error?.message ?? error)}`)
323
+ process.exitCode = 1
324
+ return
325
+ }
326
+ console.log(`wrote ${poolPath}: ${oldSlotCount} -> ${newSlotCount} slots. ${summary}`)
327
+ }
328
+
329
+ const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
330
+ if (isMain) {
331
+ main().catch((error) => {
332
+ console.error(String(error?.stack ?? error?.message ?? error))
333
+ process.exitCode = 1
334
+ })
335
+ }