SLOPSHOPPER

spare10

Keeps the last part of your 5-hour and weekly quota for you. At the reserve, spare10 holds all work and asks you whether to continue. After a Resume, it asks…

newspinnerguardcommandprompttimer
v0.4.0MITupdated 2026-10-08chrisns/spare10-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · spare10
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /spare10 ⎿ spare10: version 0.4.0 ⎿ spare10: ⎿ spare10: ● armed spare10 steps in at 90% used, or at 90% used of the weekly window. ⎿ spare10: · reserve 10% of the 5-hour window (from /config) ⎿ spare10: · weekly reserve 10% of the weekly window (from /config) ⎿ spare10: · reserve opens in the last 20 min of the 5-hour window (from /config) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⟨Claude Code's own drawing⟩ ● spare10
README

spare10-mod

A quota circuit breaker for Claude Code and the OpenAI Codex CLI. It keeps the last part of your 5-hour and weekly quota windows for you.

The idea comes from spare10 by Alessandro Diano. spare10 wraps the claude command and stops Claude Code before the quota runs out. spare10-mod keeps the idea and moves it into the agent, as a plugin. Many texts and rules come from spare10. Thank you, Alessandro.

spare10-mod is early access software. In Claude Code, it uses function hooks, an early access feature of Claude Code 2.1.281 and later. In Codex, it uses plugin hooks and a small MCP server. It is tested on Codex CLI 0.157.0. The plugin name is spare10 in both.

What spare10 does

spare10 watches two quota windows: the 5-hour window and the weekly window. By default, it keeps the last 10% of each window for you. At the reserve of either window, spare10 holds all work at the next step. It then asks you one question: Stop here or Resume.

  • Resume continues all held work from the point where it stopped. It lasts until the floor, by default until 5% is left. There spare10 asks a second time. A second Resume lets the work use the rest of the window.
  • Stop here ends the work at its next step. The session stays open.

At 100% used, spare10 holds all work until the reset, and asks once: Continue at the reset or Stop here.

Near the reset, a reserve that you do not use is lost. So spare10 opens the reserve in the last 20 minutes of the 5-hour window and the last 8 hours of the weekly window. Then, or after the reset, spare10 continues held and stopped work by itself. You can switch each of these off.

Quick start

Claude Code

  1. Switch on function hooks. Put this env entry in ~/.claude/settings.json:
   { "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }

If the file has an env block already, add only the CLAUDE_CODE_ENABLE_FUNCTION_HOOKS line to it. This flag switches on function hooks for every installed plugin that has a hooks module. Before you start tells you more.

Run this command in bash, zsh or sh. It needs jq. macOS has jq in /usr/bin.

   sh -c 'f="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json"; s="$f"; [ -e "$f" ] || s=/dev/null; t=$(mktemp) || exit 1; jq -s "if length > 1 then error(\"more than one JSON value\") else (.[0] // {}) | .env.CLAUDE_CODE_ENABLE_FUNCTION_HOOKS = \"1\" end" "$s" > "$t" || { rm -f "$t"; exit 1; }; mkdir -p "$(dirname "$f")" && cat "$t" > "$f" || { echo "Could not write $f. The new settings are in $t" >&2; exit 1; }; rm -f "$t"; echo "Function hooks are on in $f"'

The command keeps your other settings. jq rewrites the file with two-space indentation. If jq cannot read or parse the file, the command shows an error and changes nothing. If you export CLAUDE_CONFIG_DIR, the command uses that folder, as Claude Code does. Run it once for each config folder that you use.

  1. Install the plugin:
   claude plugin marketplace add chrisns/spare10-mod
   claude plugin install spare10@spare10

The install can say that 12 userConfig options are not set yet. You do not have to set them. The defaults apply until you change them in /config.

  1. Start a new Claude Code session in a terminal. spare10 arms itself. You do not need a command. The footer shows ⧗ spare10 until spare10 reads your quota. Then it shows ● spare10. Type /spare10 to see the full status.
  • Type / to see the command list. If /spare10 is not in the list, spare10 did not load.
  • Make sure that you use Claude Code 2.1.281 or later.
  • Check step 1 and step 2, then start a new session.
  • Claude Code ignores a settings file that has an error in it. Run claude doctor. If it shows Invalid settings, correct that entry.
  • A "0" in the env block of a project's .claude/settings.json switches function hooks off in that project.
  • The desktop app and the IDE hosts are unattended. There, spare10 only watches by default. See Unattended runs.

Codex CLI

You need Node.js 20 or later. spare10 looks for it in Homebrew, in /usr/local/bin, in /usr/bin and in Volta. Then it tries the versions of nvm, mise, asdf and fnm, and last your PATH. spare10 runs it outside the Codex sandbox. Differences in Codex item 26 says where to install it.

  1. Install the plugin:
   codex plugin marketplace add chrisns/spare10-mod
   codex plugin add spare10@spare10
  1. Start codex. Codex shows Hooks need review. Choose Trust all and continue. Codex runs plugin hooks only after you trust them. codex exec never asks, so trust the hooks once in the TUI first.
  1. Type spare10 as the whole prompt, and press Enter. spare10 shows its status in the transcript, and sends nothing to the model.

Use the Codex shell prefix !, such as !spare10 resume. Codex does not use your shell aliases there. So add the spare10 folder to your PATH in ~/.zshrc or ~/.bashrc:

export PATH="$HOME/.codex/plugins/data/spare10-spare10/bin:$PATH"

If you set CODEX_HOME, use that folder in place of $HOME/.codex. Start a new Codex session after you change the file. See Install in Codex.

spare10 starts one small Node.js process for each Codex thread. If spare10 finds no Node.js 20 or later, Codex cannot start a session. This includes the Codex desktop app and the IDE extension. Install Node.js 20 or later, or remove the plugin:

codex plugin remove spare10@spare10

You can also set enabled = false under [plugins."spare10@spare10"] in ~/.codex/config.toml.

Try it

A test reading trips spare10 at any time. It spends no quota when you choose Stop here.

  1. Type /spare10 simulate 92 in Claude Code, or spare10 simulate 92 in Codex.
  2. Send a prompt. spare10 asks you.
  3. Choose Stop here. spare10 drops the prompt, and no request goes to the model.
  4. Type /spare10 simulate off, or spare10 simulate off in Codex, to clear the test reading.

If the 5-hour window resets in less than 20 minutes, the reserve is open, and spare10 does not ask. Then use simulate 92 in 1h. On a Codex plan with only a weekly window, the test reading is for the weekly window. If the weekly window resets in less than 8 hours, use spare10 simulate 92 in 9h. See Test reading.

Screenshots

Claude Code

The badge at the right of the prompt footer, and the /spare10 report:

The /spare10 report

At the reserve, spare10 holds the work and asks you:

The question at the reserve

After Stop here, the work stops until the reserve opens, 20 min before the reset:

The session after Stop here

When the reserve opens, spare10 continues the stopped work by itself:

The work continues when the reserve opens

These screenshots use a test reading from /spare10 simulate, so they show (test). They come from spare10-mod 0.2, which had no floor.

Codex

At the reserve, spare10 asks you in a Codex form:

The question in Codex

Stop here drops the prompt. Codex sends no model request:

The prompt after Stop here in Codex

The spare10 report in Codex

The Codex screenshots show a real account whose plan has only a weekly window.

Commands

What it doesClaude CodeCodex
Show the status/spare10spare10
Continue on the reserve/spare10 resumespare10 resume
Stop at the reserve now/spare10 stopspare10 stop
Set a test reading/spare10 simulate 92spare10 simulate 92
Change an option/configspare10 set reserve 15

In Codex, type the command as the whole prompt. During a turn, put ! in front of it.

Options

OptionDefaultWhat it does
reserve10The percent of the 5-hour window that you keep.
weeklyReserve10The percent of the weekly window that you keep. 0 switches the weekly guard off.
lastMinutes20spare10 opens the reserve in these last minutes of the 5-hour window. 0 switches this off.
weeklyLastHours8spare10 opens the weekly reserve in these last hours of the weekly window. 0 switches this off.
resumeFloor5After a Resume, spare10 asks again when this percent of the 5-hour window is left.
weeklyResumeFloor5The same for the weekly window.
pausePromptemptyText here tells the agents to wind down, and spare10 stops nothing. At 100% used, limitPause still holds all work and asks you.
autoResumeonspare10 continues held and stopped work when the reserve opens, or after the reset.
limitPauseonAt 100% used, spare10 holds all work and asks you once, also with a pause prompt. With no answer, it continues the work after the reset, unless autoResume is off. Switch it off when you pay for extra usage.
headlessoffWhat spare10 does in unattended runs: off, prompt, stop or wait.
scopeallall guards every interactive session. opt-in guards only runs started with SPARE10=on.
badgeonShows the badge in the Claude Code footer. Codex has no badge.

In Claude Code, set the options in /config, in the row for spare10. In Codex, use spare10 set <option> <value>. A SPARE10_* variable changes an option for one run. See Configure.

Learn more

  • spare10 in Claude Code: the question, the resume floor, the open reserve, the reset, the badge and the /spare10 command.
  • spare10 in Codex: the install, the question, the commands and the differences from Claude Code.
  • Configure: scope, options, variables and unattended runs.
  • How spare10 works: the design, a comparison with spare10, and the known limitations.
  • Develop: the checks, the layout and the rules for contributors.
  • Changelog

License

MIT. See LICENSE. spare10-mod ports texts and design from spare10 by Alessandro Diano, also under the MIT license.

Source 9 files
hooks/register.tsx 2123 lines
1import type { EngineInterface, Register, SessionRateLimit, Timer } from 'claude-code'
2import {
3  DEFAULTS,
4  LIMIT_UNREAD,
5  NO_SPANS,
6  childHeadless,
7  flagOnlyInShell,
8  fromOptions,
9  questionTimeout,
10  reserveOf,
11  spanOf,
12  unreadEnv,
13  watchedKinds,
14  withEnv,
15} from './core/config.ts'
16import type { Effective, EnvReads, Settings, Spans } from './core/config.ts'
17import {
18  BUDGET_FLOOR_MS,
19  CHECK_MS,
20  TICK_MS,
21  afterFailure,
22  askVerdict,
23  buried,
24  bury,
25  consentCounts,
26  consentCovers,
27  formatConsent,
28  formatStopped,
29  fullCovers,
30  heldPast,
31  isOverdue,
32  joinableAt,
33  limitVerdict,
34  noteSlot,
35  parseConsent,
36  parseStopped,
37  shouldAbortTurn,
38  slotList,
39  stopAction,
40  stopDue,
41  unbury,
42  withoutFloor,
43} from './core/decide.ts'
44import type { Answered, Consent, ConsentSlots, Holder, Mode, Outcome, Site, StoppedRecord, Tomb } from './core/decide.ts'
45import {
46  KINDS,
47  anchoredOf,
48  asAnchored,
49  atLimit,
50  basis,
51  initialMemory,
52  isTripped,
53  limitOf,
54  newer,
55  parseSimulate,
56  sawLive,
57  sawMeasure,
58  viewOf,
59  voidedByReset,
60  withoutVoided,
61} from './core/reading.ts'
62import type { Anchored, Basis, Kind, Memory } from './core/reading.ts'
63import {
64  ARGUMENT_HINT,
65  COMMAND_DESCRIPTION,
66  HEADER,
67  HEADLESS_GENERIC,
68  LIMIT_OPTIONS,
69  NOT_STARTED_GENERIC,
70  QUESTION_OPTIONS,
71  RESUME_LABEL,
72  STOP_GENERIC,
73  W_FLAG,
74  bgEnvWarning,
75  commandFailed,
76  debugLine,
77  limitQuestionText,
78  notPerson,
79  notice,
80  questionText,
81  resetContext,
82  resumeContext,
83  resumePrompt,
84  resumeReply,
85  simulateReply,
86  statusReport,
87  stopReply,
88  timeoutWarning,
89  unknownVerb,
90  withdrawnText,
91} from './core/text.ts'
92import type { Ended } from './core/text.ts'
93import { badgeView } from './core/badge.ts'
94import type { View } from './core/badge.ts'
95import {
96  againNotice,
97  answeredOf,
98  answersKind,
99  checksStop,
100  claimTold,
101  commandHolders,
102  consentBeyond,
103  consentOfEnd,
104  continuesLine,
105  dueRelease,
106  dueStep,
107  dueWait,
108  emptySplit,
109  endedFor,
110  extendNotice,
111  extended,
112  factsFrom,
113  floorEndsOf,
114  gatesAfter,
115  handoverTakes,
116  holdersFrom,
117  modeOf,
118  namedKinds,
119  namedOf,
120  namedStop,
121  needsRealList,
122  noFloor,
123  notStartedFor,
124  promptWaitsLine,
125  questionEdges,
126  questionOf,
127  quietOf,
128  raisesInPlace,
129  refusalText,
130  resetTooRecent,
131  resumeAskingReply,
132  resumeAtLimit,
133  resumeCase,
134  resumeNotice,
135  resumeReadReply,
136  seenOf,
137  seenSplit,
138  sensedNote,
139  sensesOf,
140  simulateText,
141  splitKind,
142  statusInput,
143  stopAutoOf,
144  stopAskingIdle,
145  stopAskingReply,
146  stopCase,
147  stopInForce,
148  stopKept,
149  stopKeptReply,
150  stopNewer,
151  stopNotice,
152  stopOpenNotice,
153  stopOverdueReply,
154  stopPlan,
155  stopRecordOf,
156  stopTrippedReply,
157  stopWriteOf,
158  stopsAtLimit,
159  supersedes,
160  takenOf,
161  takeoverSense,
162  tellText,
163  testReading,
164  tickPlan,
165  tierAtResume,
166  toldMainOf,
167  toldNotice,
168  unansweredGating,
169  unansweredHolders,
170  unattendedLines,
171  untilOf,
172  verdictOf,
173  viewedOf,
174  withRealEntries,
175} from './core/flow.ts'
176import type {
177  Acted,
178  Bases,
179  KindSense,
180  Late,
181  QuestionCore,
182  Seen,
183  Sensed,
184  Sourced,
185  Split,
186  StopSense,
187  StopWrite,
188  Taken,
189  Told,
190  Via,
191} from './core/flow.ts'
192
193// The only file that uses $ (design 10.9.6). Sense fails open, act fails closed (4.2). Every held
194// dispatch parks on the re-armed $.spare10.park carrier (4.4). The first waiter raises the one question
195// in its own dispatch, and a lost raiser hands it on (4.5). Decisions cross module copies in the env.
196// 0.2: one basis, consent and told set per kind. A ticker that session.start arms continues held and
197// stopped work at the reset (4 of the 0.2 design). The gates decide in rounds (5.3).
198// Skip near the reset: a tripped kind in the last span before its reset is open. It never gates, and no
199// stop holds it (B41, B44). While its skip start is ahead, that start is its hold end, with no margin (B42).
200// The resume floor (floor B48 to B55): a Resume at the reserve consents only until the floor point. A
201// consent to the floor applies while the reading is below its end point, and a gate path ends it for
202// good when its own basis reaches that point (B52). Then the kind gates again: the second question.
203// The quota limit (limit design): at 100% used with a known reset, a kind gates in every state, also open
204// or consented. The limit question asks Continue at the reset or Stop here. Continue at the reset writes
205// nothing: the held loops wait in place, and its due time releases them after the reset.
206
207type Ctx = { site: Site; agentId?: string; person?: boolean; resumed?: readonly Answered[] } // resumed: what the Resume that ended the last round answered (B50)
208type Settled = Outcome | 'again' // again: ended without an answer (4.5)
209type Raiser = { signal: AbortSignal }
210type Question = QuestionCore & {
211  budgetLogAt: number // the last B40 debug line
212  checking: boolean // one waiter runs the check at a time
213  waiting: number
214}
215type Release = { raw: string; record: StoppedRecord; cancelled: boolean }
216type Wake = { p: Promise<'woke'>; fire: () => void }
217
218const NAME = 'spare10'
219const ENV = crypto.randomUUID() // this copy of the module
220const HANDOFF_LIMIT = 5
221const FAST_MS = 1000
222const FAST_LIMIT = 3
223const PULSE_MS = 1000
224const PULSE_REUSE_MS = 5 * PULSE_MS // a pulse render reuses the last tripped inputs this long: only the glyph changes
225const BUDGET_LOG_MS = 600_000 // one budget debug line per 10 minutes per question (B40)
226const EDGE_LIMIT = 64
227const WATCH_MS = 300_000 // the period of the watch timer, the ticker's slow second clock (4.2)
228const BOX_DEFER_LIMIT = 10 // ticks the resume prompt waits for the prompt box (4.6.2)
229const STALE_TICK_MS = 90_000 // /spare10 warns when the last tick is older (2.7)
230
231function noKinds<T>(make: () => T): Record<Kind, T> {
232  return { five_hour: make(), seven_day: make() }
233}
234
235let base: Settings = DEFAULTS // register()
236let effective: Promise<Effective> | undefined // register() resets it
237let autoNow: boolean = DEFAULTS.autoResume // this copy's effective autoResume, answered by $.spare10.auto()
238let spansNow: Spans = NO_SPANS // B47: this copy's spans of its last successful settings read, answered by $.spare10.spans()
239let limitNow: boolean = LIMIT_UNREAD // this copy's effective limitPause, answered by $.spare10.limit(): on until a settings read succeeds
240let attended: boolean | undefined // session.start, else lazily
241let sid: string | undefined // session.start, refreshed by stoppedNow, writeStopped, writeConsent, and act on a tell or headless verdict
242let endedSid: string | undefined // the id the last /clear or /resume ended: its stop no longer counts (D3)
243const pastIds = new Set<string>() // ids that /clear or /resume ended in this process: their consent is this process's
244let bgKind: boolean | undefined // CLAUDE_CODE_SESSION_KIND=bg, read once
245const mem: Record<Kind, Memory> = noKinds(initialMemory)
246let seedLoaded = false
247let test: Partial<Record<Kind, Anchored>> = {}
248let testFromEnvDone = false
249let consentCache: Partial<Record<Kind, ConsentSlots>> = {}
250let testConsent: Partial<Record<Kind, ConsentSlots>> = {} // a Resume on a test reading: never in the env, cleared with the test reading
251const tombs: Partial<Record<Kind, Tomb[]>> = {} // B52: the real consents to the floor that a gate of this copy ended
252let consentEpoch = 0
253const fallbackEnd: Partial<Record<Kind, number>> = {} // R11: one fallback window end per kind and episode
254let startWarnings: string[] = []
255let seq = 0
256let openKey: string | undefined
257const questions = new Map<string, Question>()
258const outcomes = new Map<string, Settled>()
259const outcomeWaits = new Map<string, Array<(o: Settled | 'continue') => void>>() // 'continue': Continue at the reset withdraws the dialog
260const needsRaise = new Set<string>()
261const raising: Array<{ text: string; key: string }> = [] // one entry per $.ui.ask in flight, until that ask ends
262const parked = new Map<string, (why: string) => void>()
263let wake = newWake()
264const stepped = new Set<string>()
265const refusedTurns: string[] = []
266const HOLDING = new WeakSet<object>()
267const told: Told = noKinds(() => ({ windowEnd: 0, keys: new Set<string>() }))
268const toldNoticeFor: Record<Kind, string> = noKinds(() => '') // `${windowEnd}:${stage}` of the last B12 notice (B51)
269const unattendedNoteFor: Record<Kind, number> = noKinds(() => 0) // window end of the last B15 debug line
270const openNoteFor: Record<Kind, number> = noKinds(() => 0) // window end of the last open B15 debug line (skip 2.5)
271let tickerWanted = false // session.start: enabled, and attended or wait
272let ticker: Timer | undefined
273let tickGen = 0
274let lastTick = 0 // clock time of the last tick (the watchdog reads it)
275let ticking = false // a tick is running: the next one skips its work
276const edges: number[] = [] // times at which the badge can change
277let release: Release | undefined // the ticker's stop release in flight in this copy (4.6)
278let taking = false // a person path takes an overdue stop over (4.6.3)
279let handedOver: { record: StoppedRecord; at: number } | undefined // cleared by the ticker while a person prompt was in flight
280let personHeld = 0 // person prompt.submit dispatches in flight in this copy
281let stopEpoch = 0 // writeStopped and clearStopped in this copy
282let workMarked = false // markWork ran for the current stop
283let lastRestored: string | undefined // the draft restoreDraft put back
284let resumeDefers = 0 // ticks the resume prompt waited for the prompt box
285let deferFor: string | undefined // the SPARE10_STOPPED value that resumeDefers counts for
286let watch: Timer | undefined // the second clock: it re-arms a dead ticker (4.2)
287let lastWatch = 0 // clock time of the last watch period (the ticker reads it)
288let pulse: Timer | undefined
289let blink = true
290let drawGen = 0 // redraw() calls: a render after one senses afresh
291let pulseDue = false // a pulse asked for renders, and no redraw came since: each render of each surface may reuse
292let trippedInputs: { gen: number; at: number; reserve: number; test: boolean; mode: Mode } | undefined // the last full tripped render
293let viewKey = ''
294
295// ---- Settings (8.2) ----
296
297function settings($: EngineInterface): Promise<Effective> {
298  effective ??= readEnv($).then(
299    (env) => {
300      const eff = withEnv(base, env)
301      autoNow = eff.autoResume
302      spansNow = { lastMinutes: eff.lastMinutes, weeklyLastHours: eff.weeklyLastHours }
303      limitNow = eff.limitPause
304      return eff
305    },
306    () => {
307      effective = undefined // a failed read is tried again at the next event
308      return unreadEnv(base) // B47: spans of 0 until a read succeeds
309    },
310  )
311  return effective
312}
313
314async function readEnv($: EngineInterface): Promise<EnvReads> {
315  const reserve = await $.env.get('SPARE10_RESERVE')
316  const weeklyReserve = await $.env.get('SPARE10_WEEKLY_RESERVE')
317  const lastMinutes = await $.env.get('SPARE10_LAST_MINUTES')
318  const weeklyLastHours = await $.env.get('SPARE10_WEEKLY_LAST_HOURS')
319  const resumeFloor = await $.env.get('SPARE10_RESUME_FLOOR')
320  const weeklyResumeFloor = await $.env.get('SPARE10_WEEKLY_RESUME_FLOOR')
321  const pausePrompt = await $.env.get('SPARE10_PAUSE_PROMPT')
322  const autoResume = await $.env.get('SPARE10_AUTO_RESUME')
323  const limitPause = await $.env.get('SPARE10_LIMIT_PAUSE')
324  const headless = await $.env.get('SPARE10_HEADLESS')
325  const onOff = await $.env.get('SPARE10')
326  const simulate = await $.env.get('SPARE10_SIMULATE')
327  return {
328    ...(reserve !== undefined && { reserve }),
329    ...(weeklyReserve !== undefined && { weeklyReserve }),
330    ...(lastMinutes !== undefined && { lastMinutes }),
331    ...(weeklyLastHours !== undefined && { weeklyLastHours }),
332    ...(resumeFloor !== undefined && { resumeFloor }),
333    ...(weeklyResumeFloor !== undefined && { weeklyResumeFloor }),
334    ...(pausePrompt !== undefined && { pausePrompt }),
335    ...(autoResume !== undefined && { autoResume }),
336    ...(limitPause !== undefined && { limitPause }),
337    ...(headless !== undefined && { headless }),
338    ...(onOff !== undefined && { onOff }),
339    ...(simulate !== undefined && { simulate }),
340  }
341}
342
343// ---- Attendance (9.1) ----
344
345async function isAttended($: EngineInterface): Promise<boolean> {
346  if (attended !== undefined) return attended
347  attended = (await $.session.surfaces()).includes('terminal')
348  return attended
349}
350
351// ---- Reading (6, 3.1 of 0.2) ----
352
353/** A test reading of a kind (flow.ts testReading). Its end is a time at which the badge can change. */
354function testReadingAt(pct: number, kind: Kind, live: SessionRateLimit | undefined, now: number, inMs?: number): Anchored {
355  const reading = testReading(pct, kind, live, now, inMs)
356  addEdge(reading.resetsAtMs)
357  return reading
358}
359
360/**
361 * The spans in force (B47): the newest copy's answer. A copy that cannot reach the noun uses 0. The
362 * try also covers a newest copy whose noun has no spans method (an older build): that call throws
363 * before it returns a promise, and a throw here would fail the whole sense open.
364 */
365async function spansInForce($: EngineInterface): Promise<Spans> {
366  try {
367    return await $.spare10.spans()
368  } catch {
369    return NO_SPANS
370  }
371}
372
373/** One usage read, the seeds once, the test reading per kind. */
374async function currentBases($: EngineInterface, now: number): Promise<Bases> {
375  if (!seedLoaded) {
376    seedLoaded = true
377    const five = asAnchored(await $.store.get('seed').catch(() => undefined))
378    if (five !== undefined) mem.five_hour = { ...mem.five_hour, seed: newer(mem.five_hour.seed, five) }
379    const week = asAnchored(await $.store.get('seed-weekly').catch(() => undefined))
380    if (week !== undefined) mem.seven_day = { ...mem.seven_day, seed: newer(mem.seven_day.seed, week) }
381  }
382  const limits = (await $.session.usage()).rateLimits
383  for (const kind of KINDS) {
384    const live = limitOf(limits, kind)
385    if (live !== undefined) mem[kind] = sawLive(mem[kind], live, now)
386  }
387  if (!testFromEnvDone) {
388    testFromEnvDone = true
389    const cfg = await settings($)
390    if (cfg.testPct !== undefined) {
391      const kind = cfg.testKind ?? 'five_hour'
392      test[kind] = testReadingAt(cfg.testPct, kind, limitOf(limits, kind), now, cfg.testInMs)
393    }
394  }
395  const both = (kind: Kind): { basis: Basis; real: Basis } => ({
396    basis: basis(limitOf(limits, kind), mem[kind], now, test[kind], kind),
397    real: basis(limitOf(limits, kind), mem[kind], now, undefined, kind),
398  })
399  return { five_hour: both('five_hour'), seven_day: both('seven_day') }
400}
401
402/** The spans for one sense: asked only when a watched kind is tripped, so an event below the reserve costs no noun call. */
403async function spansFor($: EngineInterface, cfg: Effective, bases: Bases): Promise<Spans> {
404  const anyTripped = watchedKinds(cfg).some((kind) => isTripped(bases[kind].basis, reserveOf(cfg, kind)))
405  return anyTripped ? await spansInForce($) : NO_SPANS
406}
407
408/**
409 * The settings with the pause at the quota limit in force: the newest copy's answer, asked only when a
410 * watched kind is at 100% or more with a reset (so an event below it costs no noun call). A reload changes
411 * the option, and a held loop of an older copy then reads the new value. A copy that cannot reach the noun,
412 * or a newest copy with no limit method (an older build: the call throws before it returns a promise),
413 * keeps this copy's value.
414 */
415async function limitFor($: EngineInterface, cfg: Effective, bases: Bases): Promise<Effective> {
416  if (!watchedKinds(cfg).some((kind) => atLimit(bases[kind].basis))) return cfg
417  let on: boolean
418  try {
419    on = await $.spare10.limit()
420  } catch {
421    on = cfg.limitPause
422  }
423  return on === cfg.limitPause ? cfg : { ...cfg, limitPause: on }
424}
425
426/** Every watched kind at now (flow.ts sensesOf). The skip starts of tripped kinds are badge edges (skip 4.1). */
427function sensesNow(cfg: Effective, bases: Bases, spans: Spans, now: number): KindSense[] {
428  const r = sensesOf(cfg, bases, spans, now, fallbackEnd, mem)
429  for (const t of r.edges) addEdge(t) // the ticker redraws at the skip start
430  return r.kinds
431}
432
433function noteBasis($: EngineInterface, kinds: ReadonlyArray<{ kind: Kind; basis: Basis; tripped: boolean; open: boolean }>): void {
434  // The reset is in the key too: a new window at the same figure starts a new told set (5.1).
435  const key = kinds
436    .map((k) => `${k.kind}:${k.basis.kind}:${k.basis.kind === 'none' ? k.basis.why : `${k.basis.pct}:${k.basis.resetsAtMs}`}:${k.tripped}:${k.open}`)
437    .join('|')
438  if (key === viewKey) return
439  viewKey = key
440  redraw($)
441}
442
443// ---- Consent and stopped (3.5, 3.2) ----
444
445async function isBg($: EngineInterface): Promise<boolean> {
446  bgKind ??= (await $.env.get('CLAUDE_CODE_SESSION_KIND')) === 'bg'
447  return bgKind
448}
449
450/**
451 * The consents of a kind (floor 4.2): this copy's slots, the test slots on a test basis, and an env value
452 * that belongs to this process (3.5, 9.3) with its raw text. A clear during the read gives the slots only.
453 * An env value that a tomb of this copy buries is no consent, and it goes by compare-and-set (B52).
454 * `realEnd` (A22): the reset of the kind's real reading, null when unknown. A stamped env value and a real
455 * slot that an early reset voided are no consent either: the value goes by compare-and-set, the slot at once.
456 * A bare value that you set yourself stays until its time.
457 */
458async function consentsOf(
459  $: EngineInterface,
460  kind: Kind,
461  attendedNow: boolean,
462  testBasis: boolean,
463  realEnd: number | null = null,
464): Promise<Sourced[]> {
465  const epoch = consentEpoch
466  // Two branches, so each $.env.get keeps a literal name.
467  const raw = kind === 'seven_day' ? await $.env.get('SPARE10_WEEKLY_CONSENT') : await $.env.get('SPARE10_CONSENT')
468  const read = parseConsent(raw)
469  const voided = read?.sessionId !== undefined && realEnd !== null && voidedByReset(read, realEnd)
470  const dead = read !== undefined && (buried(tombs[kind], read) || voided)
471  if (dead) void unsetIfSame($, kind, raw).catch(() => undefined)
472  const c = dead ? undefined : read
473  const counts =
474    c !== undefined &&
475    consentCounts(c.sessionId, {
476      attended: attendedNow,
477      bg: attendedNow && c.sessionId === undefined ? await isBg($) : false,
478      ids: attendedNow && c.sessionId !== undefined ? [...pastIds, await $.session.id()] : [],
479    })
480  if (epoch !== consentEpoch) return slotsOf(kind, false, realEnd) // a clear ran meanwhile: this read is stale
481  const env: Sourced[] = counts ? [{ c: { until: c.until, ...(c.to === undefined ? {} : { to: c.to }) }, from: 'env', ...(raw === undefined ? {} : { raw }) }] : []
482  return [...slotsOf(kind, testBasis, realEnd), ...env]
483}
484
485/**
486 * This copy's consents of a kind as they are now: the slots, and the test slots on a test basis. A22: a
487 * real slot that an early reset voided (`realEnd`) goes at once. A test slot never does: its window is the test's.
488 */
489function slotsOf(kind: Kind, testBasis: boolean, realEnd: number | null = null): Sourced[] {
490  if (realEnd !== null) consentCache[kind] = withoutVoided(consentCache[kind], realEnd)
491  return [
492    ...slotList(consentCache[kind]).map((x): Sourced => ({ c: x, from: 'slot' })),
493    ...(testBasis ? slotList(testConsent[kind]).map((x): Sourced => ({ c: x, from: 'test' })) : []),
494  ]
495}
496
497/** A Resume of this copy. A new real consent to the floor lifts the tombs that bury it (B52). */
498function noteConsent(kind: Kind, c: Consent, isTest: boolean): void {
499  if (isTest) {
500    testConsent[kind] = noteSlot(testConsent[kind], c)
501    return
502  }
503  consentCache[kind] = noteSlot(consentCache[kind], c)
504  if (c.to !== undefined) tombs[kind] = unbury(tombs[kind], c)
505}
506
507/**
508 * A Resume's consent of one kind. A consent to the floor never replaces a full value of this process
509 * for the same window in the env (floor 3.3): the stronger tier stays. `noted`: the caller noted the
510 * consent in this copy's slots when it decided (settle), so a split that ended it since stays ended.
511 */
512async function writeConsent($: EngineInterface, kind: Kind, c: Consent, now: number, isTest: boolean, noted = false): Promise<void> {
513  if (c.until <= now) return // never for a window that has ended (R10)
514  if (!noted) noteConsent(kind, c, isTest)
515  addEdge(c.until)
516  if (isTest) return // a Resume on a test reading never carries into real use (3.5)
517  sid = await $.session.id() // stamped: only this process honours it in an attended session (9.3)
518  if (buried(tombs[kind], c)) return // B52: a split ended it since the Resume
519  const value = formatConsent(sid, c.until, c.to)
520  if (kind === 'seven_day') {
521    if (c.to !== undefined && fullCovers(parseConsent(await $.env.get('SPARE10_WEEKLY_CONSENT')), [...pastIds, sid], c.until, now)) return
522    await $.env.set('SPARE10_WEEKLY_CONSENT', value)
523  } else {
524    if (c.to !== undefined && fullCovers(parseConsent(await $.env.get('SPARE10_CONSENT')), [...pastIds, sid], c.until, now)) return
525    await $.env.set('SPARE10_CONSENT', value)
526  }
527  // B52: a split that ended this consent to the floor while it was written (the reading passed its point
528  // meanwhile) removed its slot and buried it. The late write must not bring it back: unset it by
529  // compare-and-set. A split that ends it after this check finds the value in its sweep.
530  if (c.to !== undefined && (buried(tombs[kind], c) || !holdsConsent(consentCache[kind], c, now))) await unsetIfSame($, kind, value)
531}
532
533/** This copy's slots still hold a consent at least as strong as `c` for its window: `c` did not end. */
534const holdsConsent = (slots: ConsentSlots | undefined, c: Consent, now: number): boolean =>
535  slotList(slots).some((x) => consentCovers(x.until, now, c.until) && (x.to === undefined || x.to >= (c.to ?? 0)))
536
537/**
538 * B52: the consents to the floor of a tripped, not open kind whose own basis has reached their end point
539 * end for good (flow.ts floorEndsOf). This copy's slots go at once (synchronously). A real consent also
540 * gets a tomb at once, and a sweep unsets the env value that the tomb buries, fire and forget. `failed`:
541 * the env read failed, so `list` has only the slots. Never throws.
542 */
543function endFloors($: EngineInterface, k: KindSense, list: readonly Sourced[], now: number, failed = false): void {
544  const ends = floorEndsOf(k, list, now, failed)
545  for (const e of ends.unset) {
546    if (e.from === 'test') testConsent[k.kind] = withoutFloor(testConsent[k.kind]) // never in the env, so no tomb
547    else if (e.from === 'slot') consentCache[k.kind] = withoutFloor(consentCache[k.kind])
548  }
549  for (const t of ends.tombs) tombs[k.kind] = bury(tombs[k.kind], t, now)
550  if (ends.tombs.length > 0) void sweep($, k.kind).catch(() => undefined)
551}
552
553/** B52: unsets the env value of a kind that a tomb of this copy buries. It reads the value first. */
554async function sweep($: EngineInterface, kind: Kind): Promise<void> {
555  const raw = kind === 'seven_day' ? await $.env.get('SPARE10_WEEKLY_CONSENT') : await $.env.get('SPARE10_CONSENT')
556  const c = parseConsent(raw)
557  if (c !== undefined && buried(tombs[kind], c)) await unsetIfSame($, kind, raw)
558}
559
560/** B52: unsets a consent value only while it still holds the raw text that the split read. */
561async function unsetIfSame($: EngineInterface, kind: Kind, raw: string | undefined): Promise<void> {
562  if (raw === undefined) return
563  // Two branches, so each $.env call keeps a literal name.
564  if (kind === 'seven_day') {
565    if ((await $.env.get('SPARE10_WEEKLY_CONSENT')) === raw) await $.env.set('SPARE10_WEEKLY_CONSENT', undefined)
566  } else if ((await $.env.get('SPARE10_CONSENT')) === raw) await $.env.set('SPARE10_CONSENT', undefined)
567}
568
569async function clearConsent($: EngineInterface): Promise<void> {
570  consentEpoch += 1
571  consentCache = {}
572  testConsent = {}
573  await Promise.all([$.env.set('SPARE10_CONSENT', undefined), $.env.set('SPARE10_WEEKLY_CONSENT', undefined)])
574}
575
576/**
577 * After /clear or /resume: a consent stamped with an ended id of this process takes the new id, with its
578 * end point. Each write goes only over the raw value read first (floor 3.3): a consent that a gate ended
579 * or a second Resume replaced meanwhile stays as it is now. A consent that a tomb buries is never
580 * written again, and a gate that buries it during the write unsets the new value (B52).
581 */
582async function restampConsent($: EngineInterface): Promise<void> {
583  const epoch = consentEpoch
584  const fiveRaw = await $.env.get('SPARE10_CONSENT')
585  const weekRaw = await $.env.get('SPARE10_WEEKLY_CONSENT')
586  const five = parseConsent(fiveRaw)
587  const week = parseConsent(weekRaw)
588  const ended = (c: typeof five): c is { until: number; sessionId: string; to?: number } =>
589    c?.sessionId !== undefined && pastIds.has(c.sessionId)
590  if (!ended(five) && !ended(week)) return
591  const id = await $.session.id()
592  if (epoch !== consentEpoch) return // cleared meanwhile
593  if (ended(five) && id !== five.sessionId && (await $.env.get('SPARE10_CONSENT')) === fiveRaw && !buried(tombs.five_hour, five)) {
594    const value = formatConsent(id, five.until, five.to)
595    await $.env.set('SPARE10_CONSENT', value)
596    if (buried(tombs.five_hour, five)) await unsetIfSame($, 'five_hour', value)
597  }
598  if (ended(week) && id !== week.sessionId && (await $.env.get('SPARE10_WEEKLY_CONSENT')) === weekRaw && !buried(tombs.seven_day, week)) {
599    const value = formatConsent(id, week.until, week.to)
600    await $.env.set('SPARE10_WEEKLY_CONSENT', value)
601    if (buried(tombs.seven_day, week)) await unsetIfSame($, 'seven_day', value)
602  }
603}
604
605/**
606 * The stop of this conversation that applies now, if any. `gating`: the kinds that gate now. `holders`:
607 * the kinds whose real reading gates now. A stop past its until that one of them keeps applies too (TS1:
608 * `holdsPast`), with the end it will have.
609 */
610async function stoppedNow(
611  $: EngineInterface,
612  now: number,
613  gating: readonly KindSense[],
614  holders: readonly Holder[],
615): Promise<StoppedRecord | undefined> {
616  sid = await $.session.id()
617  const st = parseStopped(await $.env.get('SPARE10_STOPPED'))
618  // A stop of the conversation that /clear or /resume ended no longer counts, also before the engine
619  // answers the new id (D3).
620  return stopInForce(st, sid, endedSid, now, gating, holders)
621}
622
623/**
624 * 5.7: the 0.2 record, merged with an earlier stop of this session (3.2). Returns what it wrote, for the
625 * texts. `real` (TS1): the kinds whose real reading gates at the stop, each with the reset of its real
626 * reading now. Only those of `kinds` are kept.
627 */
628async function writeStopped($: EngineInterface, n: StopWrite, now: number): Promise<StoppedRecord> {
629  sid = await $.session.id() // R3: a fresh id, a /clear may have run since the last read
630  const prev = parseStopped(await $.env.get('SPARE10_STOPPED').catch(() => undefined))
631  const r = stopRecordOf(prev, n, sid, now)
632  stopEpoch += 1
633  workMarked = r.work === true
634  await $.env.set('SPARE10_STOPPED', formatStopped(r))
635  addEdge(r.windowEnd)
636  addEdge(stopDue(r))
637  return r
638}
639
640async function clearStopped($: EngineInterface): Promise<void> {
641  stopEpoch += 1
642  workMarked = false
643  await $.env.set('SPARE10_STOPPED', undefined)
644}
645
646/** 5.7: the first refused loop of a stop adds `work`, so the reset continues it. Never delays the refusal. */
647function markWork($: EngineInterface): void {
648  if (workMarked) return
649  workMarked = true
650  const epoch = stopEpoch
651  void (async () => {
652    const raw = await $.env.get('SPARE10_STOPPED')
653    const r = parseStopped(raw)
654    if (r?.kinds === undefined || r.work === true || r.sessionId !== sid) return
655    // A Resume, a release or a new Stop may have come meanwhile: write only over the value just read.
656    if ((await $.env.get('SPARE10_STOPPED')) !== raw || epoch !== stopEpoch) return
657    await $.env.set('SPARE10_STOPPED', formatStopped({ ...r, work: true }))
658  })().catch(() => {
659    workMarked = false
660  })
661}
662
663async function listed($: EngineInterface, agentId: string): Promise<boolean> {
664  return (await $.agent.list()).some((a) => a.id === agentId)
665}
666
667// ---- The decision (5.2) ----
668
669async function sense($: EngineInterface): Promise<Sensed> {
670  const own = await settings($)
671  const now = await $.clock.now()
672  const bases = await currentBases($, now)
673  const cfg = await limitFor($, own, bases) // the pause at the limit in force
674  const kinds = sensesNow(cfg, bases, await spansFor($, cfg, bases), now)
675  noteBasis($, kinds)
676  const tripped = kinds.some((k) => k.tripped)
677  const att = tripped ? await isAttended($) : attended === true
678  return { cfg, now, kinds: att ? kinds : kinds.map(noFloor), tripped, attended: att } // B55
679}
680
681/**
682 * Skip 3.3: the tripped kinds, split into those with a consent that applies (B49), those that gate and
683 * those that are open. An open kind whose only covering consent is a consent to the floor is open (floor
684 * 1.3 item 7). B52: a consent to the floor of a kind that is not open ends for good when its own basis
685 * reaches its end point, also when the env read fails. Never throws: an unreadable consent is not consent.
686 */
687async function splitOf($: EngineInterface, s: { kinds: readonly KindSense[]; now: number; attended: boolean }): Promise<Split> {
688  const out = emptySplit()
689  for (const k of s.kinds) {
690    if (!k.tripped) continue
691    const read = await consentsOf($, k.kind, s.attended, k.test, k.realReset).then(
692      (list) => ({ list, failed: false }),
693      () => ({ list: slotsOf(k.kind, k.test, k.realReset), failed: true }),
694    )
695    if (!k.open) endFloors($, k, read.list, s.now, read.failed) // B52: sync slots and tombs, void env
696    splitKind(out, k, read.list, read.failed, s.now) // unreadable: not consented
697  }
698  return out
699}
700
701/** The watched kinds that gate: tripped, not consented and not open. Never throws. */
702async function gatingOf($: EngineInterface, s: Sensed): Promise<KindSense[]> {
703  return (await splitOf($, s)).gating
704}
705
706/**
707 * TS1: the kinds whose real reading gates now. A kind whose view is its real reading gates as the view
708 * says, so it is in `gating`. Beneath a test reading, the real reading gates when it is tripped, not open
709 * and no real consent applies on the real reading (B49): one read of the real consent, never a Resume on
710 * the test reading (3.5). A real reading without a reset time takes the one-hour bound. It only reads:
711 * the next split ends a real consent to the floor (B52). Never throws: an unreadable consent is not consent.
712 */
713async function holdersOf(
714  $: EngineInterface,
715  s: { kinds: readonly KindSense[]; now: number; attended: boolean },
716  gating: readonly KindSense[],
717): Promise<Holder[]> {
718  const lists: Partial<Record<Kind, Sourced[]>> = {}
719  for (const k of s.kinds) {
720    if (needsRealList(k)) lists[k.kind] = await consentsOf($, k.kind, s.attended, false, k.realReset).catch((): Sourced[] => [])
721  }
722  return holdersFrom(s.kinds, gating, (k) => lists[k.kind] ?? [], s.now)
723}
724
725async function act($: EngineInterface, s: Sensed, ctx: Ctx): Promise<Acted> {
726  // The round after a Resume leaves out the kinds it answered: only a kind the dialog did not name asks
727  // again (B38). B50: only on the Resume's basis and below its end point, so no step passes the floor.
728  const resumed = ctx.resumed ?? []
729  const gating = s.cfg.enabled ? unansweredGating(resumed, await gatingOf($, s)) : []
730  // TS1: the kinds whose real reading gates. They keep a stop past its end, and a Stop here names them.
731  const holders = s.cfg.enabled ? unansweredHolders(s, resumed, await holdersOf($, s, gating)) : []
732  const stopped = checksStop(s, gating) ? (await stoppedNow($, s.now, gating, holders).catch(() => undefined)) !== undefined : false
733  const toldMain = toldMainOf(told, gating, sid ?? '')
734  let { verdict } = verdictOf({ s, site: ctx.site, person: ctx.person === true, gating, holders, stopped, toldMain })
735  if (
736    (verdict.kind === 'hold' || verdict.kind === 'refuse') &&
737    ctx.site === 'tool' &&
738    ctx.agentId !== undefined &&
739    !stepped.has(ctx.agentId) &&
740    !(await listed($, ctx.agentId).catch(() => true))
741  ) {
742    verdict = { kind: 'pass', trip: true } // an engine fork: never held (G8)
743  }
744  // The told key and HEADLESS carry the current id: a /clear brings no session.start (3.6), and an
745  // unattended run never reaches stoppedNow.
746  if (verdict.kind === 'tell' || (!s.attended && (verdict.kind === 'refuse' || verdict.kind === 'hold'))) {
747    sid = await $.session.id().catch(() => sid)
748  }
749  if (s.cfg.enabled && !s.attended) noteUnattended($, s) // B15 debug line, once per kind and window (R2: enabled runs only)
750  return { verdict, stopped, gating, holders }
751}
752
753// ---- The hold (4.4, 5.4) ----
754
755function newWake(): Wake {
756  let fire: () => void = () => undefined
757  const p = new Promise<'woke'>((resolve) => {
758    fire = () => resolve('woke')
759  })
760  return { p, fire }
761}
762
763function wakeAll(): void {
764  for (const resolve of parked.values()) resolve('wake')
765  parked.clear()
766  wake.fire()
767  wake = newWake()
768}
769
770function outcomeOf(key: string): Promise<Settled | 'continue'> {
771  const o = outcomes.get(key)
772  if (o !== undefined) return Promise.resolve(o)
773  return new Promise((resolve) => outcomeWaits.set(key, [...(outcomeWaits.get(key) ?? []), resolve]))
774}
775
776async function hold($: EngineInterface, signal: AbortSignal, key: string, left: () => number): Promise<Settled | 'aborted'> {
777  const q0 = questions.get(key)
778  if (q0 !== undefined) q0.waiting += 1
779  try {
780    const waiter = crypto.randomUUID()
781    let fast = 0
782    for (;;) {
783      const o = outcomes.get(key)
784      if (o !== undefined) return o
785      if (signal.aborted) return 'aborted'
786      if (!questions.has(key)) return 'stop' // closed under a live waiter: fail closed
787      if (left() < BUDGET_FLOOR_MS) {
788        void settle($, key, 'stop', 'time limit') // B40: before the budget runs out
789        return 'stop'
790      }
791      if (needsRaise.has(key)) {
792        needsRaise.delete(key)
793        raise($, key, { signal }) // this waiter raises the dialog (4.5)
794      }
795      const t0 = performance.now()
796      const carrier = $.spare10.park({ waiter }).then(
797        () => 'woke' as const,
798        () => 'rejected' as const,
799      )
800      // The park call is in flight from here: the reads below cost the budget nothing (G2).
801      const d = await decidedElsewhere($, key)
802      if (d !== undefined) {
803        void settle($, key, d, 'elsewhere')
804        return d
805      }
806      if (await dueCheck($, key)) continue // the top returns 'again'
807      await noteBudget($, key, left())
808      // A hand-off or a close that came while this waiter read the env: act on it now, not after a carrier cycle.
809      if (outcomes.has(key) || signal.aborted || needsRaise.has(key) || !questions.has(key)) continue
810      // One abort promise per round, its listener gone when the round ends: a promise that never
811      // settles would keep one race reaction per cycle for the whole hold. No await since the check above.
812      let end = (): void => undefined
813      const round = new Promise<'aborted'>((resolve) => {
814        end = () => resolve('aborted')
815      })
816      signal.addEventListener('abort', end, { once: true })
817      let r: 'woke' | 'rejected' | 'aborted'
818      try {
819        r = await Promise.race([carrier, wake.p, round])
820      } finally {
821        signal.removeEventListener('abort', end)
822      }
823      if (r === 'aborted') return 'aborted'
824      if (r === 'rejected' && performance.now() - t0 < FAST_MS) {
825        fast += 1
826        if (fast >= FAST_LIMIT) return 'stop' // the noun is gone: never spin, fail closed
827      } else fast = 0
828    }
829  } catch {
830    return 'stop' // a hold never throws
831  } finally {
832    const q = questions.get(key)
833    if (q !== undefined) {
834      q.waiting -= 1
835      // Nobody left to raise it, or a quiet hold with no waiter (5.5): silent, or chosen at the limit. The next step asks again.
836      if (q.waiting === 0 && !outcomes.has(key) && (needsRaise.has(key) || quietOf(q))) forget($, key)
837    }
838  }
839}
840
841async function decidedElsewhere($: EngineInterface, key: string): Promise<Outcome | undefined> {
842  const q = questions.get(key)
843  if (q === undefined) return undefined
844  const now = await $.clock.now().catch(() => q.since)
845  let covered = q.kinds.length > 0 && q.limit !== true // a consent never answers the limit question
846  for (const kind of covered ? q.kinds : []) {
847    const end = q.ends[kind]
848    // B50 item 3: a consent answers a kind at a matching tier. A consent to the floor never answers a kind asked at the floor.
849    // A22: a consent of an earlier window is void, also when the gate could not unset it. As Codex question.ts.
850    const realEnd = end === undefined || end.test ? null : end.end
851    const list = end === undefined ? [] : await consentsOf($, kind, !q.silent, end.test, realEnd).catch((): Sourced[] => [])
852    if (!answersKind(end, list, now)) {
853      covered = false
854      break
855    }
856  }
857  if (covered) return 'resume'
858  // R6: a stop from another copy settles a question in hold and tell mode alike.
859  const st = parseStopped(await $.env.get('SPARE10_STOPPED').catch(() => undefined))
860  if (!stopNewer(st, q, now)) return undefined
861  const id = await $.session.id().catch(() => sid)
862  return st.sessionId === id ? 'stop' : undefined
863}
864
865/** 4.5: the waiter's check. True when the question ended as again. */
866async function dueCheck($: EngineInterface, key: string): Promise<boolean> {
867  const q = questions.get(key)
868  if (q === undefined || q.checking || outcomes.has(key)) return false
869  q.checking = true // before the first await: one waiter at a time
870  try {
871    const now = await $.clock.now()
872    if (dueWait(q, now)) return false
873    q.nextCheck = now + CHECK_MS
874    // The setting in force: a quiet question never reads it.
875    const step = dueStep(q, now, quietOf(q) || (await $.spare10.auto().catch(() => q.auto)))
876    if (step === 'note') {
877      q.noted = true
878      $.ui.log(notice.resetWaitingFor(namedOf(q))) // D0.2, byte for byte
879      return false
880    }
881    if (step === 'noteSensed') {
882      // B43: the note names what opened or reset, so it senses. A throw: the catch logs it, the next check tries again.
883      const s = await sense($)
884      const n = sensedNote(q, s, await gatingOf($, s), now)
885      if ('noteAt' in n) {
886        q.noteAt = n.noteAt // B45: nothing of the question opened yet. Wait for its hold end.
887        addEdge(q.noteAt)
888        return false
889      }
890      q.noted = true
891      $.ui.log(n.text)
892      return false
893    }
894    if (step === 'wait') return false
895    const s = await sense($) // throws: nothing is released
896    const gatingNow = await gatingOf($, s)
897    const via = dueRelease(q, now, gatingNow, resetTooRecent(s, mem))
898    if (via === undefined) return false
899    if (outcomes.has(key)) return false // answered meanwhile
900    settleAgain($, key, via, gatingNow, s)
901    return true
902  } catch (err) {
903    $.ui.log(debugLine.checkFailed(String(err)), { to: 'debug' })
904    return false
905  } finally {
906    q.checking = false
907  }
908}
909
910/** 4.5: the question ends without an answer. Synchronous, writes nothing: every held loop decides afresh. */
911function settleAgain($: EngineInterface, key: string, via: 'reset' | 'quota' | 'limit', gatingNow: readonly KindSense[], s: Sensed): void {
912  if (outcomes.has(key)) return
913  const q = questions.get(key)
914  outcomes.set(key, 'again')
915  needsRaise.delete(key)
916  for (const resolve of outcomeWaits.get(key) ?? []) resolve('again') // withdraws this copy's dialog
917  outcomeWaits.delete(key)
918  if (openKey === key) openKey = undefined
919  wakeAll()
920  if (q !== undefined) $.ui.log(againNotice(q, via, gatingNow, s))
921  redraw($)
922}
923
924/** B40: the waiter logs its budget once per 10 minutes per question, so LC25 can measure a cycle. */
925async function noteBudget($: EngineInterface, key: string, leftMs: number): Promise<void> {
926  const q = questions.get(key)
927  if (q === undefined) return
928  const now = await $.clock.now()
929  if (now - q.budgetLogAt < BUDGET_LOG_MS) return
930  q.budgetLogAt = now
931  $.ui.log(debugLine.budget(Math.round((now - q.since) / 60_000), Math.round(leftMs)), { to: 'debug' })
932}
933
934// ---- The question (4.3, 4.5, 4.6, 5.6) ----
935
936function ensureQuestion($: EngineInterface, opener: 'loop' | 'prompt', s: Sensed, a: Acted): string {
937  const g = namedKinds(s, a)
938  if (openKey !== undefined) {
939    const key = openKey
940    const open = questions.get(key)
941    if (open !== undefined && !outcomes.has(key) && supersedes(open, g)) {
942      // A kind is at the quota limit now: the question at the reserve gives way. Its waiters decide again
943      // and join the limit question that opens below (synchronous: no race).
944      settleAgain($, key, 'limit', g, s)
945    } else if (open !== undefined && joinableAt(outcomes.get(key), answeredOf(open), g.map(viewedOf))) {
946      if (opener === 'loop') open.loops += 1
947      else {
948        // A person prompt after Continue at the reset joins with no dialog: say that it waits.
949        const line = promptWaitsLine(open, s.now)
950        if (line !== undefined) $.ui.log(line)
951      }
952      return key // join (synchronous check: no race)
953    }
954    // A settled again, or a settled Resume that does not answer a kind that gates now (B50): a new question.
955  }
956  seq += 1
957  const key = `${ENV}:${seq}`
958  openKey = key
959  const q: Question = { ...questionOf(opener, s, a, s.now), budgetLogAt: s.now, checking: false, waiting: 0 }
960  questions.set(key, q)
961  if (!q.silent) needsRaise.add(key) // the first waiter to loop raises it
962  for (const t of questionEdges(q)) addEdge(t)
963  redraw($)
964  return key
965}
966
967function raise($: EngineInterface, key: string, r: Raiser): void {
968  const q = questions.get(key)
969  if (q === undefined || outcomes.has(key) || q.chosen === true) return // settled, or chosen at the limit, before this waiter got to it
970  if (q.limit === true) return raiseLimit($, key, q, r)
971  const text = questionText(q.facts, q.opener, q.mode, q.auto)
972  // The entry lives until this ask ends, never less: a question settled before its dialog reaches hook 5
973  // must still find it there, so that hook 5 withdraws the dialog (4.3).
974  const entry = { text, key }
975  raising.push(entry)
976  const ended = (): void => {
977    const i = raising.indexOf(entry)
978    if (i >= 0) raising.splice(i, 1)
979  }
980  try {
981    void $.ui.ask(text, { options: [...QUESTION_OPTIONS], header: HEADER }).then(
982      (answer) => {
983        ended()
984        void settle($, key, askVerdict(answer, RESUME_LABEL), 'dialog')
985      },
986      () => {
987        ended()
988        lost($, key, r)
989      },
990    )
991  } catch {
992    ended()
993    void settle($, key, 'stop', 'could not ask')
994  }
995}
996
997/**
998 * The limit question: Continue at the reset first, with the focus. Only the exact Stop here label stops.
999 * Every other answer, and a dialog that cannot show, continues at the reset (`chooseContinue`). A dialog
1000 * that ends with no answer goes to `lost`: Esc and "Chat about this" continue there too.
1001 */
1002function raiseLimit($: EngineInterface, key: string, q: Question, r: Raiser): void {
1003  const text = limitQuestionText(q.facts, q.opener, q.auto)
1004  const entry = { text, key } // as in raise: the entry lives until this ask ends, so hook 5 can withdraw it
1005  raising.push(entry)
1006  const ended = (): void => {
1007    const i = raising.indexOf(entry)
1008    if (i >= 0) raising.splice(i, 1)
1009  }
1010  try {
1011    void $.ui.ask(text, { options: [...LIMIT_OPTIONS], header: HEADER }).then(
1012      (answer) => {
1013        ended()
1014        if (limitVerdict(answer, LIMIT_OPTIONS[1]) === 'stop') void settle($, key, 'stop', 'dialog')
1015        else chooseContinue($, key, 'dialog')
1016      },
1017      () => {
1018        ended()
1019        lost($, key, r)
1020      },
1021    )
1022  } catch {
1023    ended()
1024    chooseContinue($, key, 'could not ask')
1025  }
1026}
1027
1028/**
1029 * Continue at the reset on an open limit question. It writes no outcome, so the held loops keep waiting in
1030 * place, and new loops join the question with no dialog. The question becomes quiet (`quietOf`): no raise
1031 * and no hand-off again, a dialog still up is withdrawn, and its check runs at once. Its due time releases
1032 * the work after the reset in both autoResume modes.
1033 */
1034function chooseContinue($: EngineInterface, key: string, via: Via): void {
1035  const q = questions.get(key)
1036  if (q === undefined || outcomes.has(key) || q.limit !== true || q.chosen === true) return
1037  q.chosen = true
1038  q.nextCheck = 0
1039  needsRaise.delete(key)
1040  for (const resolve of outcomeWaits.get(key) ?? []) resolve('continue') // withdraws this copy's dialog
1041  outcomeWaits.delete(key)
1042  if (via !== 'command') void noteContinues($, q)
1043  wakeAll()
1044  redraw($)
1045}
1046
1047/** The line of Continue at the reset, only while the reset is ahead (flow.ts continuesLine). A failed clock read logs it. */
1048async function noteContinues($: EngineInterface, q: Question): Promise<void> {
1049  const now = await $.clock.now().catch(() => undefined)
1050  const line = now === undefined ? notice.limitContinues(q.facts) : continuesLine(q, now)
1051  if (line !== undefined) $.ui.log(line)
1052}
1053
1054function lost($: EngineInterface, key: string, r: Raiser): void {
1055  const q = questions.get(key)
1056  if (q === undefined || outcomes.has(key) || q.chosen === true) return // withdrawn by spare10 itself, or chosen at the limit
1057  if (r.signal.aborted) {
1058    // The raiser's dispatch went away and nobody answered.
1059    if (q.waiting === 0) return forget($, key) // nothing is held any more
1060    if (q.handoffs < HANDOFF_LIMIT) {
1061      q.handoffs += 1
1062      needsRaise.add(key)
1063      $.ui.log(debugLine.handedOn(q.handoffs), { to: 'debug' })
1064      wakeAll() // a live waiter picks it up
1065      return
1066    }
1067  }
1068  // Esc, dismissed, time limit, no tool: Stop here, and at the limit Continue at the reset.
1069  if (q.limit === true) chooseContinue($, key, 'dialog ended without an answer')
1070  else void settle($, key, 'stop', 'dialog ended without an answer')
1071}
1072
1073function forget($: EngineInterface, key: string): void {
1074  questions.delete(key)
1075  needsRaise.delete(key)
1076  outcomeWaits.delete(key)
1077  if (openKey === key) openKey = undefined
1078  redraw($)
1079}
1080
1081function takeRaising(text: string): string | undefined {
1082  const i = raising.findIndex((r) => r.text === text)
1083  return i < 0 ? undefined : raising.splice(i, 1)[0]?.key
1084}
1085
1086const askedText = (e: unknown): string =>
1087  (e as { questions?: Array<{ question?: string }> }).questions?.[0]?.question ?? ''
1088
1089/**
1090 * Settles a question. For a Stop that this copy writes, it returns the record as written (merged, 3.2)
1091 * and its time for the reply. For a Stop after the skip start (B46), it also returns what opened.
1092 */
1093async function settle($: EngineInterface, key: string, outcome: Outcome, via: Via): Promise<Late> {
1094  if (outcomes.has(key)) return {}
1095  // Every synchronous cache first, so an event that arrives during the writes sees the decision.
1096  outcomes.set(key, outcome)
1097  needsRaise.delete(key)
1098  const q = questions.get(key)
1099  for (const resolve of outcomeWaits.get(key) ?? []) resolve(outcome) // withdraws this copy's dialog
1100  outcomeWaits.delete(key)
1101  if (outcome === 'resume' && q !== undefined) {
1102    for (const kind of q.kinds) {
1103      const end = q.ends[kind]
1104      if (end !== undefined) noteConsent(kind, consentOfEnd(end), end.test) // B49: each kind at its tier
1105    }
1106  }
1107  wakeAll()
1108  let late: Late = {}
1109  try {
1110    if (via === 'elsewhere' || q === undefined) return {}
1111    const now = await $.clock.now()
1112    if (outcome === 'resume') {
1113      for (const kind of q.kinds) {
1114        const end = q.ends[kind]
1115        if (end !== undefined) await writeConsent($, kind, consentOfEnd(end), now, end.test, true) // each kind's own test flag, noted above
1116      }
1117      await clearStopped($)
1118      if (via !== 'command') $.ui.log(resumeNotice(q, now))
1119    } else if (!q.silent && (q.mode === 'hold' || via === 'command')) {
1120      late = await settleStop($, q, via, now)
1121    }
1122    if (openKey === key) openKey = undefined // the decision is readable in env now
1123    await $.spare10.poke({ from: ENV }) // wake the newest copy's waiters (3.6)
1124  } catch (err) {
1125    // Best effort: this copy's caches and outcomes already hold the decision. The debug log says what failed.
1126    $.ui.log(debugLine.settleFailed(String(err)), { to: 'debug' })
1127  } finally {
1128    // Closed only now: a crossing during the writes joins the settled question and gets its outcome.
1129    if (openKey === key) openKey = undefined
1130    redraw($)
1131  }
1132  return late
1133}
1134
1135/**
1136 * A Stop here (skip 3.5, B46). One sense gives the kinds whose real reading gates now (TS1: the `real`
1137 * tag). When the question's time has passed (its hold end with autoResume on, its skip start with it
1138 * off), it also gives the kinds that gate now (`late`) and the question's kinds that are open now
1139 * (`opened`). The kinds that gate are stopped as usual. When nothing gates, a kind of the question is
1140 * open, and no work waits for a resume prompt, nothing is written: such a stop would never apply. A
1141 * failed sense gives empty lists: the D0.2 write, with the real kinds of the question when it opened.
1142 */
1143async function settleStop($: EngineInterface, q: Question, via: Via, now: number): Promise<Late> {
1144  let sNow: StopSense | undefined
1145  try {
1146    const sensed = await sense($)
1147    const split = await splitOf($, sensed)
1148    sNow = { s: sensed, split, holders: await holdersOf($, sensed, split.gating) }
1149  } catch {
1150    sNow = undefined // fail closed: the question's real kinds (flow.ts stopPlan)
1151  }
1152  // The setting in force. A Stop here at the limit never continues by itself, also on a question at the
1153  // reserve while a kind at the limit gates now, but the hold time limit does (flow.ts stopsAtLimit).
1154  const auto = stopAutoOf(q, via, await $.spare10.auto().catch(() => autoNow), sNow)
1155  const plan = stopPlan(q, now, auto, sNow, stopsAtLimit(q, via, sNow))
1156  if (plan.kind === 'open') {
1157    // B46 open: nothing is stopped. Held work is refused (the outcome is stop), new work passes.
1158    const text = stopOpenNotice(q, plan.ended, via)
1159    if (text !== undefined) $.ui.log(text)
1160    return { ended: plan.ended }
1161  }
1162  const written = await writeStopped($, plan.record, now)
1163  const n = stopNotice(q, plan, written, via, now, auto)
1164  if (n.text !== undefined) $.ui.log(n.text)
1165  return n.late
1166}
1167
1168/** This copy's open question, if it is not settled yet. */
1169const openQuestion = (): string | undefined => (openKey !== undefined && !outcomes.has(openKey) ? openKey : undefined)
1170
1171// ---- Tell mode (5) ----
1172
1173function noteTold($: EngineInterface, s: Sensed, a: Acted, key: string): void {
1174  $.ui.log(debugLine.told(key), { to: 'debug' })
1175  const text = toldNotice(s, a, toldNoticeFor)
1176  if (text === undefined) return
1177  $.ui.log(text)
1178  redraw($)
1179}
1180
1181function noteUnattended($: EngineInterface, s: Sensed): void {
1182  for (const line of unattendedLines(s, { reserve: unattendedNoteFor, open: openNoteFor })) $.ui.log(line, { to: 'debug' })
1183}
1184
1185// ---- Turn end and draft (4.7) ----
1186
1187function endTurn($: EngineInterface, turnId: string, attendedNow: boolean): void {
1188  const before = refusedTurns.includes(turnId)
1189  if (!before) {
1190    refusedTurns.push(turnId)
1191    if (refusedTurns.length > 32) refusedTurns.shift()
1192  }
1193  if (!shouldAbortTurn(attendedNow, before)) return
1194  void $.turn.abort({ turnId }).catch((err: unknown) => {
1195    $.ui.log(debugLine.abortFailed(String(err)), { to: 'debug' })
1196  })
1197}
1198
1199function restoreDraft($: EngineInterface, text: string): void {
1200  lastRestored = text
hooks/core/config.ts 369 lines
1import type { PluginOptions, Settings as HostSettings } from 'claude-code'
2import { KINDS, parseSimulate, simulateWords } from './reading.ts'
3import type { Kind } from './reading.ts'
4import { badWarning, floorWarning, fmtPct, simulateWarning } from './text.ts'
5
6// Options, per-run env overrides, scope and the start-up checks (design section 8). No $ here.
7// Precedence, highest first: SPARE10_* in the process env, pluginConfigs (managed, then --settings,
8// then user), the manifest default. One normaliser per field, for the option and the env alike.
9
10export type Headless = 'off' | 'prompt' | 'stop' | 'wait'
11export type Scope = 'all' | 'opt-in'
12export type Settings = {
13  reserve: number
14  weeklyReserve: number // 0: the weekly window is not watched
15  lastMinutes: number // the 5-hour span: the reserve opens this many minutes before the reset. 0 is off
16  weeklyLastHours: number // the weekly span, in hours. 0 is off
17  resumeFloor: number // B48: the 5-hour floor in % left. A Resume at the reserve lasts until 100 - this. 0 is off
18  weeklyResumeFloor: number // B48: the weekly floor. 0 is off
19  pausePrompt: string | null
20  autoResume: boolean
21  limitPause: boolean // at 100% used, spare10 holds all work until the reset (the quota limit). Off: work runs into the limit
22  headless: Headless
23  scope: Scope
24  badge: boolean
25}
26export type Source = 'option' | 'env'
27/** Where a span comes from. 'unread': the env read failed, so the span is 0 (B47). */
28export type SpanSource = Source | 'unread'
29/** The spans in force (B47): the newest copy answers them through $.spare10.spans(). */
30export type Spans = { lastMinutes: number; weeklyLastHours: number }
31/** Both spans off: the guard holds until the reset. Every unknown gives this. */
32export const NO_SPANS: Spans = Object.freeze({ lastMinutes: 0, weeklyLastHours: 0 })
33/**
34 * The pause at the limit that a copy answers through $.spare10.limit() until its first successful settings
35 * read, as NO_SPANS (B47): on, so an older copy's held loop never lets work past 100% on a guess.
36 */
37export const LIMIT_UNREAD: boolean = true
38export type Effective = Settings & {
39  enabled: boolean
40  from: {
41    reserve: Source
42    weeklyReserve: Source
43    lastMinutes: SpanSource
44    weeklyLastHours: SpanSource
45    resumeFloor: Source
46    weeklyResumeFloor: Source
47    pausePrompt: Source
48    autoResume: Source
49    limitPause: Source
50    headless: Source
51    enabled: 'scope' | 'SPARE10'
52  }
53  testPct?: number // from SPARE10_SIMULATE
54  testKind?: Kind // only when SPARE10_SIMULATE names the weekly window
55  testInMs?: number // only when SPARE10_SIMULATE has `in`
56  warnings: string[] // B27 wording
57}
58export type EnvReads = {
59  reserve?: string
60  weeklyReserve?: string
61  lastMinutes?: string
62  weeklyLastHours?: string
63  resumeFloor?: string
64  weeklyResumeFloor?: string
65  pausePrompt?: string
66  autoResume?: string
67  limitPause?: string
68  headless?: string
69  onOff?: string
70  simulate?: string
71}
72
73export const DEFAULTS: Settings = {
74  reserve: 10,
75  weeklyReserve: 10,
76  lastMinutes: 20,
77  weeklyLastHours: 8,
78  resumeFloor: 5,
79  weeklyResumeFloor: 5,
80  pausePrompt: null,
81  autoResume: true,
82  limitPause: true,
83  headless: 'off',
84  scope: 'all',
85  badge: true,
86}
87
88const HEADLESS: readonly Headless[] = ['off', 'prompt', 'stop', 'wait']
89const SCOPES: readonly Scope[] = ['all', 'opt-in']
90
91const word = (raw: unknown): string | undefined => (typeof raw === 'string' ? raw.trim().toLowerCase() : undefined)
92
93const numberOf = (raw: unknown): number =>
94  typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() !== '' ? Number(raw.trim()) : Number.NaN
95
96/** 1 to 99, rounded to one decimal. A number or a numeric string. */
97export function parseReserve(raw: unknown): number | undefined {
98  const n = numberOf(raw)
99  if (!Number.isFinite(n)) return undefined
100  const r = Math.round(n * 10) / 10
101  return r >= 1 && r <= 99 ? r : undefined
102}
103
104/** 0 (the weekly guard is off), or 1 to 99 rounded to one decimal. A number or a numeric string. */
105export function parseWeeklyReserve(raw: unknown): number | undefined {
106  const n = numberOf(raw)
107  if (!Number.isFinite(n)) return undefined
108  const r = Math.round(n * 10) / 10
109  if (r === 0) return 0
110  return r >= 1 && r <= 99 ? r : undefined
111}
112
113// One rule for both spans: 0 to max, rounded to one decimal. A number or a numeric string.
114function parseSpan(raw: unknown, max: number): number | undefined {
115  const n = numberOf(raw)
116  if (!Number.isFinite(n)) return undefined
117  const r = Math.round(n * 10) / 10 + 0 // + 0: never -0
118  return r >= 0 && r <= max ? r : undefined
119}
120
121/** The 5-hour span in minutes: 0 (off) to 299, rounded to one decimal. */
122export const parseLastMinutes = (raw: unknown): number | undefined => parseSpan(raw, 299)
123
124/** The weekly span in hours: 0 (off) to 167, rounded to one decimal. */
125export const parseWeeklyLastHours = (raw: unknown): number | undefined => parseSpan(raw, 167)
126
127/** B48: a resume floor in % left: 0 (off) to 99, rounded to one decimal. A number or a numeric string. */
128export const parseResumeFloor = (raw: unknown): number | undefined => parseSpan(raw, 99)
129
130/**
131 * B48, B54: the floor of a kind when it is above 0 and below that kind's reserve, else 0. No attendance
132 * here: an unattended run has no floor in force either (B55), and the module applies that.
133 */
134export const floorOf = (s: Pick<Settings, 'reserve' | 'weeklyReserve' | 'resumeFloor' | 'weeklyResumeFloor'>, kind: Kind): number => {
135  const floor = kind === 'seven_day' ? s.weeklyResumeFloor : s.resumeFloor
136  const reserve = kind === 'seven_day' ? s.weeklyReserve : s.reserve
137  return floor > 0 && floor < reserve ? floor : 0
138}
139
140/** A kind's span in ms (B41). 0 is off. */
141export const spanOf = (s: Spans, kind: Kind): number =>
142  Math.round(kind === 'seven_day' ? s.weeklyLastHours * 3_600_000 : s.lastMinutes * 60_000)
143
144/** Blank or white space means stop and ask. Anything else is the instruction, verbatim. */
145export const parsePausePrompt = (raw: unknown): string | null => (typeof raw === 'string' && raw.trim() !== '' ? raw : null)
146
147export function parseHeadless(raw: unknown): Headless | undefined {
148  const w = word(raw)
149  return HEADLESS.find((h) => h === w)
150}
151
152export function parseScope(raw: unknown): Scope | undefined {
153  const w = word(raw)
154  return SCOPES.find((s) => s === w)
155}
156
157export function parseSwitch(raw: unknown): 'on' | 'off' | undefined {
158  const w = word(raw)
159  return w === 'on' || w === 'off' ? w : undefined
160}
161
162/** A boolean with no default arrives as "", so only an explicit false turns the badge off. */
163export const parseBadge = (raw: unknown): boolean => raw !== false
164
165/** As parseBadge: only an explicit false turns autoResume off. */
166export const parseAutoResume = (raw: unknown): boolean => raw !== false
167
168/** As parseBadge: only an explicit false turns the pause at the limit off. */
169export const parseLimitPause = (raw: unknown): boolean => raw !== false
170
171/** The twelve declared fields, defaults filled. Extra stored keys are ignored. */
172export function fromOptions(options: PluginOptions): Settings {
173  return {
174    reserve: parseReserve(options['reserve']) ?? DEFAULTS.reserve,
175    weeklyReserve: parseWeeklyReserve(options['weeklyReserve']) ?? DEFAULTS.weeklyReserve,
176    lastMinutes: parseLastMinutes(options['lastMinutes']) ?? DEFAULTS.lastMinutes,
177    weeklyLastHours: parseWeeklyLastHours(options['weeklyLastHours']) ?? DEFAULTS.weeklyLastHours,
178    resumeFloor: parseResumeFloor(options['resumeFloor']) ?? DEFAULTS.resumeFloor,
179    weeklyResumeFloor: parseResumeFloor(options['weeklyResumeFloor']) ?? DEFAULTS.weeklyResumeFloor,
180    pausePrompt: parsePausePrompt(options['pausePrompt']),
181    autoResume: parseAutoResume(options['autoResume']),
182    limitPause: parseLimitPause(options['limitPause']),
183    headless: parseHeadless(options['headless']) ?? DEFAULTS.headless,
184    scope: parseScope(options['scope']) ?? DEFAULTS.scope,
185    badge: parseBadge(options['badge']),
186  }
187}
188
189/**
190 * The kinds spare10 acts on, five_hour first: the weekly window only while its reserve is above 0.
191 * `present` (Codex design 4.15): the kinds the host reports a window for. Claude passes none: both.
192 */
193export const watchedKinds = (s: Pick<Settings, 'weeklyReserve'>, present: readonly Kind[] = KINDS): Kind[] =>
194  KINDS.filter((k) => present.includes(k) && (k === 'five_hour' || s.weeklyReserve > 0))
195
196export const reserveOf = (s: Pick<Settings, 'reserve' | 'weeklyReserve'>, kind: Kind): number =>
197  kind === 'seven_day' ? s.weeklyReserve : s.reserve
198
199/** B37: the SPARE10_HEADLESS a guarded session sets for its children, when the variable is not set. */
200export const childHeadless = (headless: Headless, envSet: boolean): 'stop' | undefined =>
201  !envSet && (headless === 'off' || headless === 'wait') ? 'stop' : undefined
202
203/**
204 * The per-run env over the options. A bad value is ignored with a B27 warning, never fatal.
205 * `o.simulateKind` (Codex design 4.15): the kind of a SPARE10_SIMULATE with no kind word, the weekly
206 * window on a host that reports no 5-hour window. Claude passes none: the 5-hour window.
207 */
208export function withEnv(base: Settings, env: EnvReads, o: { simulateKind?: Kind } = {}): Effective {
209  const warnings: string[] = []
210  const out: Effective = {
211    ...base,
212    enabled: base.scope === 'all',
213    from: {
214      reserve: 'option',
215      weeklyReserve: 'option',
216      lastMinutes: 'option',
217      weeklyLastHours: 'option',
218      resumeFloor: 'option',
219      weeklyResumeFloor: 'option',
220      pausePrompt: 'option',
221      autoResume: 'option',
222      limitPause: 'option',
223      headless: 'option',
224      enabled: 'scope',
225    },
226    warnings,
227  }
228  if (env.reserve !== undefined) {
229    const r = parseReserve(env.reserve)
230    if (r === undefined) warnings.push(badWarning('SPARE10_RESERVE', env.reserve, fmtPct(base.reserve)))
231    else {
232      out.reserve = r
233      out.from.reserve = 'env'
234    }
235  }
236  if (env.weeklyReserve !== undefined) {
237    const r = parseWeeklyReserve(env.weeklyReserve)
238    if (r === undefined) warnings.push(badWarning('SPARE10_WEEKLY_RESERVE', env.weeklyReserve, fmtPct(base.weeklyReserve)))
239    else {
240      out.weeklyReserve = r
241      out.from.weeklyReserve = 'env'
242    }
243  }
244  if (env.lastMinutes !== undefined) {
245    const m = parseLastMinutes(env.lastMinutes)
246    if (m === undefined) warnings.push(badWarning('SPARE10_LAST_MINUTES', env.lastMinutes, fmtPct(base.lastMinutes)))
247    else {
248      out.lastMinutes = m
249      out.from.lastMinutes = 'env'
250    }
251  }
252  if (env.weeklyLastHours !== undefined) {
253    const h = parseWeeklyLastHours(env.weeklyLastHours)
254    if (h === undefined) warnings.push(badWarning('SPARE10_WEEKLY_LAST_HOURS', env.weeklyLastHours, fmtPct(base.weeklyLastHours)))
255    else {
256      out.weeklyLastHours = h
257      out.from.weeklyLastHours = 'env'
258    }
259  }
260  if (env.resumeFloor !== undefined) {
261    const f = parseResumeFloor(env.resumeFloor)
262    if (f === undefined) warnings.push(badWarning('SPARE10_RESUME_FLOOR', env.resumeFloor, fmtPct(base.resumeFloor)))
263    else {
264      out.resumeFloor = f
265      out.from.resumeFloor = 'env'
266    }
267  }
268  if (env.weeklyResumeFloor !== undefined) {
269    const f = parseResumeFloor(env.weeklyResumeFloor)
270    if (f === undefined) warnings.push(badWarning('SPARE10_WEEKLY_RESUME_FLOOR', env.weeklyResumeFloor, fmtPct(base.weeklyResumeFloor)))
271    else {
272      out.weeklyResumeFloor = f
273      out.from.weeklyResumeFloor = 'env'
274    }
275  }
276  if (env.pausePrompt !== undefined) {
277    // Set but empty is meaningful: `SPARE10_PAUSE_PROMPT= claude` forces stop-and-ask for this run.
278    out.pausePrompt = parsePausePrompt(env.pausePrompt)
279    out.from.pausePrompt = 'env'
280  }
281  if (env.autoResume !== undefined) {
282    const a = parseSwitch(env.autoResume)
283    if (a === undefined) warnings.push(badWarning('SPARE10_AUTO_RESUME', env.autoResume, base.autoResume ? 'on' : 'off'))
284    else {
285      out.autoResume = a === 'on'
286      out.from.autoResume = 'env'
287    }
288  }
289  if (env.limitPause !== undefined) {
290    const l = parseSwitch(env.limitPause)
291    if (l === undefined) warnings.push(badWarning('SPARE10_LIMIT_PAUSE', env.limitPause, base.limitPause ? 'on' : 'off'))
292    else {
293      out.limitPause = l === 'on'
294      out.from.limitPause = 'env'
295    }
296  }
297  if (env.headless !== undefined) {
298    const h = parseHeadless(env.headless)
299    if (h === undefined) warnings.push(badWarning('SPARE10_HEADLESS', env.headless, base.headless))
300    else {
301      out.headless = h
302      out.from.headless = 'env'
303    }
304  }
305  if (env.onOff !== undefined) {
306    const s = parseSwitch(env.onOff)
307    if (s === undefined) warnings.push(badWarning('SPARE10', env.onOff, base.scope))
308    else {
309      out.enabled = s === 'on'
310      out.from.enabled = 'SPARE10'
311    }
312  }
313  // SPARE10_SIMULATE: a blank value or `off` is no test reading and no warning. Junk is B27. A weekly test
314  // reading while the weekly reserve is 0 changes nothing yet, so it is B27 too. It stays set, as before: a
315  // weekly reserve that the person sets later puts it in force, and the warning says so.
316  const words = simulateWords(env.simulate)
317  const parsed = parseSimulate(words, o.simulateKind)
318  if (parsed === undefined && words.length > 0) warnings.push(simulateWarning(env.simulate ?? ''))
319  const spec = parsed === 'off' ? undefined : parsed
320  if (spec !== undefined) {
321    if (spec.kind === 'seven_day' && out.weeklyReserve <= 0) warnings.push(simulateWarning(env.simulate ?? '', true))
322    out.testPct = spec.pct
323    if (spec.kind !== 'five_hour') out.testKind = spec.kind
324    if (spec.inMs !== undefined) out.testInMs = spec.inMs
325  }
326  // B54: a floor above 0 at or above its reserve does nothing. The weekly one only while it is watched.
327  if (out.resumeFloor > 0 && out.resumeFloor >= out.reserve) warnings.push(floorWarning('five_hour', out.resumeFloor, out.reserve))
328  if (out.weeklyReserve > 0 && out.weeklyResumeFloor > 0 && out.weeklyResumeFloor >= out.weeklyReserve) {
329    warnings.push(floorWarning('seven_day', out.weeklyResumeFloor, out.weeklyReserve))
330  }
331  return out
332}
333
334/**
335 * B47: the settings after a failed env read. The D0.2 fallback (the options only), with both spans 0
336 * from 'unread': an unknown span keeps the guard on until the reset. The floors keep their options: for a
337 * floor, the guarded side is a floor in force (floor 1.3 item 9).
338 */
339export function unreadEnv(base: Settings): Effective {
340  const out = withEnv(base, {})
341  out.lastMinutes = 0
342  out.weeklyLastHours = 0
343  out.from.lastMinutes = 'unread'
344  out.from.weeklyLastHours = 'unread'
345  return out
346}
347
348function hasFlag(s: HostSettings | null | undefined): boolean {
349  if (typeof s !== 'object' || s === null) return false
350  const env = s['env']
351  if (typeof env !== 'object' || env === null) return false
352  return (env as Record<string, unknown>)['CLAUDE_CODE_ENABLE_FUNCTION_HOOKS'] !== undefined
353}
354
355/** B28: the process has the flag and no settings source (user, project, local, flag, policy) does. `flag` is `--settings`. */
356export const flagOnlyInShell = (inProcess: boolean, sources: readonly HostSettings[]): boolean =>
357  inProcess && !sources.some((s) => hasFlag(s))
358
359/** B29: the first of a positive askUserQuestionTimeout in the merged settings, a set CLAUDE_AFK_TIMEOUT_MS. */
360export function questionTimeout(
361  merged: HostSettings,
362  afk: string | undefined,
363): 'askUserQuestionTimeout' | 'CLAUDE_AFK_TIMEOUT_MS' | undefined {
364  const t = typeof merged === 'object' && merged !== null ? merged['askUserQuestionTimeout'] : undefined
365  if (typeof t === 'number' && Number.isFinite(t) && t > 0) return 'askUserQuestionTimeout'
366  if (afk !== undefined) return 'CLAUDE_AFK_TIMEOUT_MS'
367  return undefined
368}
369
hooks/core/decide.ts 601 lines
1import type { Headless } from './config.ts'
2import { KINDS, marginOf, windowMs } from './reading.ts'
3import type { Basis, Kind } from './reading.ts'
4
5// The gate's decision table (design 4.2) and the phase precedence (3.1), written once. No $ here.
6
7/** One step of the reset clock (4.2). */
8export const TICK_MS = 30_000
9
10/** A waiter checks the quota at most this often before the due time (4.5). */
11export const CHECK_MS = 60_000
12
13/** Below this much hook budget a hold ends as Stop here (B40). */
14export const BUDGET_FLOOR_MS = 2_000
15
16export type Site = 'tool' | 'step' | 'prompt'
17export type Mode = 'hold' | 'tell'
18export type Outcome = 'resume' | 'stop'
19
20export type Snapshot = {
21  site: Site
22  tripped: boolean
23  enabled: boolean
24  consented: boolean
25  attended: boolean
26  headless: Headless
27  mode: Mode
28  person: boolean
29  stopped: boolean
30  mainTold: boolean
31  seedOnly?: boolean // every gating kind rests on a seed (row 6a)
32  limit?: boolean // a gating kind is at the quota limit (row 7b)
33}
34// consented (row 3): tripped, and no kind gates. A kind gates when it is tripped, not consented and
35// not open (B41), so a kind in its skip window passes here too.
36
37export type Verdict =
38  | { kind: 'pass'; trip: boolean } // trip: inside the reserve, let through
39  | { kind: 'tell' }
40  | { kind: 'refuse'; text: 'stop' | 'paused' | 'headless' }
41  | { kind: 'hold' } // open or join the question, then wait
42
43const PASS: Verdict = { kind: 'pass', trip: false }
44const THROUGH: Verdict = { kind: 'pass', trip: true }
45
46/** The decision table of 4.2, top to bottom, first match wins. */
47export function decide(s: Snapshot): Verdict {
48  if (!s.tripped) return PASS // row 1
49  if (!s.enabled) return THROUGH // row 2
50  if (s.consented) return THROUGH // row 3
51  if (!s.attended) {
52    if (s.headless === 'stop') return s.site === 'prompt' ? THROUGH : { kind: 'refuse', text: 'headless' } // row 5
53    if (s.headless === 'prompt') return s.site === 'tool' ? { kind: 'tell' } : THROUGH // row 6
54    if (s.headless === 'wait') return s.site === 'prompt' || s.seedOnly === true ? THROUGH : { kind: 'hold' } // row 6a
55    return THROUGH // row 4
56  }
57  if (s.stopped) {
58    // row 7, in either mode
59    if (s.site === 'tool') return { kind: 'refuse', text: 'stop' }
60    if (s.site === 'step') return { kind: 'refuse', text: 'paused' }
61    return s.person ? { kind: 'hold' } : THROUGH
62  }
63  if (s.limit === true) {
64    // row 7b: at the quota limit every loop holds, in either mode. A stop still wins (row 7).
65    if (s.site === 'prompt') return s.person ? { kind: 'hold' } : THROUGH
66    return { kind: 'hold' }
67  }
68  if (s.mode === 'tell') {
69    // row 8
70    if (s.site === 'tool') return { kind: 'tell' }
71    if (s.site === 'step') return THROUGH
72    return s.person && !s.mainTold ? { kind: 'hold' } : THROUGH
73  }
74  if (s.site === 'prompt') return s.person ? { kind: 'hold' } : THROUGH // row 9
75  return { kind: 'hold' }
76}
77
78/** Only the exact yes label resumes. Everything else is Stop here. */
79export const askVerdict = (answer: string, yes: string): Outcome => (answer === yes ? 'resume' : 'stop')
80
81/** The limit question: only the exact Stop here label stops. Every other answer continues at the reset. */
82export const limitVerdict = (answer: string, stop: string): 'stop' | 'continue' => (answer === stop ? 'stop' : 'continue')
83
84/** What a .catch does: let a failure after next stand, refuse while holding, pass while deciding. */
85export function afterFailure(called: boolean, holding: boolean): 'replay' | 'refuse' | 'pass' {
86  if (called) return 'replay'
87  return holding ? 'refuse' : 'pass'
88}
89
90/** Consent counts only for the current window (3.5): a later time is ignored. */
91export const consentCovers = (until: number, now: number, windowEnd: number): boolean =>
92  now < until && until <= windowEnd + 60_000
93
94// ---- The resume floor: two tiers of consent (floor B48 to B52) ----
95
96/** B49: a consent of one kind. `to`: the % used where a consent to the floor ends. None: a full consent. */
97export type Consent = { until: number; to?: number }
98
99/** B49: the end point now: `to`, or the floor point in force when that is lower. */
100export const endOf = (to: number, point: number | null): number => (point === null ? to : Math.min(to, point))
101
102/** B49: consentCovers, and for a consent to the floor a reading below its end point. */
103export const consentApplies = (c: Consent, now: number, windowEnd: number, pct: number, point: number | null): boolean =>
104  consentCovers(c.until, now, windowEnd) && (c.to === undefined || pct < endOf(c.to, point))
105
106/** B52: a consent to the floor in its window whose end point `pct` has reached. Never a full consent. */
107export const floorEnded = (c: Consent, now: number, windowEnd: number, pct: number, point: number | null): boolean =>
108  c.to !== undefined && consentCovers(c.until, now, windowEnd) && pct >= endOf(c.to, point)
109
110/** B49: the consent that applies: a full one first (the latest until), else the consent to the floor with the highest to. */
111export function coveringConsent(
112  list: readonly Consent[],
113  now: number,
114  windowEnd: number,
115  pct: number,
116  point: number | null,
117): Consent | undefined {
118  const applying = list.filter((c) => consentApplies(c, now, windowEnd, pct, point))
119  const full = applying.filter((c) => c.to === undefined).sort((a, b) => b.until - a.until)[0]
120  if (full !== undefined) return full
121  return highestTo(applying)
122}
123
124/** The consent to the floor with the highest end point, then the latest until. */
125const highestTo = (list: readonly Consent[]): Consent | undefined =>
126  list
127    .filter((c) => c.to !== undefined)
128    .sort((a, b) => (b.to ?? 0) - (a.to ?? 0) || b.until - a.until)[0]
129
130/** The report: a consent to the floor that covers the window and has reached its end point on `pct`. The highest to. */
131export function endedFloor(
132  list: readonly Consent[],
133  now: number,
134  windowEnd: number,
135  pct: number,
136  point: number | null,
137): Consent | undefined {
138  return highestTo(list.filter((c) => floorEnded(c, now, windowEnd, pct, point)))
139}
140
141/** B49: this copy's consents of one kind: the latest full until, and the latest consent to the floor. */
142export type ConsentSlots = { full?: number; floor?: { until: number; to: number } }
143
144/** full: the later until (0.2 max). floor: replaced by a later until, or the same until with a higher to. */
145export function noteSlot(s: ConsentSlots | undefined, c: Consent): ConsentSlots {
146  const out: ConsentSlots = { ...(s ?? {}) }
147  if (c.to === undefined) {
148    out.full = Math.max(out.full ?? 0, c.until)
149    return out
150  }
151  const f = out.floor
152  if (f === undefined || c.until > f.until || (c.until === f.until && c.to > f.to)) out.floor = { until: c.until, to: c.to }
153  return out
154}
155
156/** The slots as a list of consents: the full one first. */
157export function slotList(s: ConsentSlots | undefined): Consent[] {
158  if (s === undefined) return []
159  return [...(s.full === undefined ? [] : [{ until: s.full }]), ...(s.floor === undefined ? [] : [{ until: s.floor.until, to: s.floor.to }])]
160}
161
162/** B52: the slots without the consent to the floor (undefined when nothing is left). */
163export function withoutFloor(s: ConsentSlots | undefined): ConsentSlots | undefined {
164  if (s?.full === undefined) return undefined
165  return { full: s.full }
166}
167
168/**
169 * B52: a tomb is a real consent to the floor that a gate of this copy ended: the time in its value and
170 * its end point. A tomb buries each consent to the floor with that time and an end point at or below
171 * its own, in any stamp. So a late write, a restamp or a value that a failed read missed never applies
172 * again. A tomb never buries a full consent, and it ends when its window resets.
173 */
174export type Tomb = { until: number; to: number }
175
176/** B52: a tomb buries `c`: a consent to the floor with the tomb's until and an end point at or below the tomb's. */
177export function buried(tombs: readonly Tomb[] | undefined, c: Consent): boolean {
178  const to = c.to
179  return to !== undefined && (tombs ?? []).some((t) => t.until === c.until && to <= t.to)
180}
181
182/** B52: the tombs with `t` added. A tomb whose window has reset goes, and so does a tomb that `t` covers. */
183export function bury(tombs: readonly Tomb[] | undefined, t: Tomb, now: number): Tomb[] {
184  const live = (tombs ?? []).filter((x) => now < x.until)
185  if (now >= t.until || buried(live, t)) return live
186  return [...live.filter((x) => !(x.until === t.until && x.to <= t.to)), t]
187}
188
189/**
190 * B52: a new Resume of this copy lifts each tomb that buries its consent. After a fall in the window,
191 * the first question consents to the floor again (floor 1.4 item 7).
192 */
193export const unbury = (tombs: readonly Tomb[] | undefined, c: Consent): Tomb[] => (tombs ?? []).filter((t) => !buried([t], c))
194
195/**
196 * B50: a kind as the gate sees it now. `end`: its consent bound, the identity of its window. None: no reset
197 * time is known. `limit`: the kind is at the quota limit, so no Resume answers it.
198 */
199export type Viewed = { kind: Kind; pct: number; test: boolean; end?: number; limit?: boolean }
200
201/** B50: what a settled Resume answered for one kind: its basis, its end point, and the consent bound of the question. */
202export type Answered = { kind: Kind; test: boolean; to?: number; end?: number }
203
204/**
205 * B50: the window of a Resume's answer is the window of the view: `sameWindow` of the two ends. `jitter`
206 * (Codex A22): a real window that ends more than the jitter after the answer's end is a new window, as
207 * `voidedByReset` voids a consent after a reset credit. Claude passes none.
208 */
209const answerWindow = (a: Answered, k: Viewed, jitter: number): boolean =>
210  sameWindow(k.kind, a.end ?? null, k.end ?? null) && (jitter <= 0 || k.test || a.end === undefined || k.end === undefined || k.end - a.end <= jitter)
211
212/**
213 * B50: a Resume answers a kind on the same basis and in the same window (`answerWindow`) while its reading
214 * is below the Resume's end point for it. A window that reset after the question asks again, also past
215 * its floor. An unknown end (no reset time, or an older answer) matches, as in TS1. A Resume never answers
216 * a kind at the quota limit: the limit question asks it.
217 */
218export const answers = (named: readonly Answered[], k: Viewed, jitter = 0): boolean =>
219  k.limit !== true && named.some((a) => a.kind === k.kind && a.test === k.test && answerWindow(a, k, jitter) && (a.to === undefined || k.pct < a.to))
220
221/**
222 * B50: joinable with the floor and the basis. A loop joins an open question, or one settled as Stop
223 * here, or one settled as Resume when that Resume answers every kind that gates now. Never after again.
224 * `jitter`: as in `answers`.
225 */
226export function joinableAt(
227  outcome: 'resume' | 'stop' | 'again' | undefined,
228  named: readonly Answered[],
229  gating: readonly Viewed[],
230  jitter = 0,
231): boolean {
232  if (outcome === undefined || outcome === 'stop') return true
233  return outcome === 'resume' && gating.every((k) => answers(named, k, jitter))
234}
235
236/**
237 * B50: another copy's consent answers a question's kind: it covers the kind's consent bound, and a
238 * consent to the floor answers only a kind asked at the reserve, with a point at least as high.
239 */
240export const answersQuestion = (c: Consent, end: { end: number; to?: number }, now: number): boolean =>
241  consentCovers(c.until, now, end.end) && (c.to === undefined || (end.to !== undefined && c.to >= end.to))
242
243/**
244 * SPARE10_CONSENT (3.3 of the floor design): `${sessionId} ${iso}` (full), `${sessionId} ${iso} to:${pct}`
245 * (a consent to the floor), or a bare time that a person set before launch (full). A 0.2 value has no
246 * `to`, so it is full.
247 */
248export type ConsentRecord = { until: number; sessionId?: string; to?: number }
249
250// Date.parse is lenient ('abc-123' is a number), so a time must also start like an ISO date.
251const isoMs = (text: string): number => (/^\d{4}-\d{2}-\d{2}T/.test(text) ? Date.parse(text) : Number.NaN)
252
253// An end point: 0.1 to 99.9, one decimal at most.
254const TO_TOKEN = /^to:(\d{1,2}(?:\.\d)?)$/
255
256export function parseConsent(raw: string | undefined): ConsentRecord | undefined {
257  const text = (raw ?? '').trim()
258  const m = /^(\S+) (\S+)(?: (\S+))?$/.exec(text)
259  if (m !== null && m[3] !== undefined) {
260    const until = isoMs(m[2] ?? '')
261    const t = TO_TOKEN.exec(m[3])
262    const to = t === null ? Number.NaN : Number(t[1])
263    if (!Number.isFinite(until) || !(to > 0)) return undefined // fail closed: a junk end point is no consent
264    return { until, sessionId: m[1] ?? '', to }
265  }
266  const stamped = m === null ? Number.NaN : isoMs(m[2] ?? '')
267  if (m !== null && Number.isFinite(stamped)) return { until: stamped, sessionId: m[1] ?? '' }
268  const bare = /\s/.test(text) ? Number.NaN : isoMs(text) // a bare time is one token: `<iso> to:95` is no consent
269  return Number.isFinite(bare) ? { until: bare } : undefined
270}
271
272export const formatConsent = (sessionId: string, until: number, to?: number): string =>
273  `${sessionId} ${new Date(until).toISOString()}${to === undefined ? '' : ` to:${String(Math.round(to * 10) / 10 + 0)}`}`
274
275/**
276 * 3.3: a full value stamped with one of these ids that covers the window of `until`. A write of a
277 * consent to the floor keeps it: the stronger tier stays.
278 */
279export const fullCovers = (prev: ConsentRecord | undefined, ids: readonly string[], until: number, now: number): boolean =>
280  prev !== undefined && prev.to === undefined && prev.sessionId !== undefined && ids.includes(prev.sessionId) && consentCovers(prev.until, now, until)
281
282/** B51: the told key of a stage. */
283export const stageKey = (key: string, atFloor: boolean): string => (atFloor ? `${key}:floor` : key)
284
285/** B51: a told key's loop part and stage. */
286export function keyStage(key: string): { base: string; atFloor: boolean } {
287  return key.endsWith(':floor') ? { base: key.slice(0, -':floor'.length), atFloor: true } : { base: key, atFloor: false }
288}
289
290/**
291 * Whose consent counts (3.5, 9.3). The process env reaches every descendant, also a long-lived one: the
292 * transient `claude daemon` and every --bg session it starts. So an attended session takes only a
293 * consent stamped with its own id (now or before a /clear or /resume in this process). An unattended
294 * run takes any consent: a nested `claude -p` follows the session that started it. A bare time is the
295 * person's own answer before launch, and counts outside a --bg session.
296 */
297export function consentCounts(
298  stamp: string | undefined,
299  who: { attended: boolean; bg: boolean; ids: readonly string[] },
300): boolean {
301  if (!who.attended) return true
302  if (stamp === undefined) return !who.bg
303  return who.ids.includes(stamp)
304}
305
306/**
307 * TS1: a kind whose real reading gates now: tripped, not open and not consented, read on the real basis
308 * beneath any test reading. `resetsAtMs`: the reset of that real reading, null when unknown. An entry of
309 * a stop's `real` tag has the same form: the kind, and the reset of its real window when it was written.
310 */
311export type Holder = { kind: Kind; resetsAtMs: number | null }
312
313/**
314 * A stop. No `kinds`: a 0.1 value, which stops the 5-hour window until `windowEnd` and is never
315 * continued. `windowEnd` is the `until` of 3.2. `work`: the stop held or refused a loop. `auto`:
316 * autoResume was on when the stop was made. `test`: every kind of the stop was a test reading.
317 * `skip`: the until is a skip start and the stop is a skip owner, so it has no margin (skip 3.5).
318 * `real` (TS1): the kinds of the stop whose real reading, beneath any test reading, was in the reserve
319 * when the entry was written: tripped, not open and not consented. Each entry keeps the reset of that
320 * real reading, the identity of its window (null: unknown). The value carries it only with `skip` or
321 * `test`, the stops that can hold past their end (`holdsPast`). A value without it (0.1, 0.2 before
322 * TS1, or a stop that ends at a reset) names none. One entry per kind, in KINDS order.
323 */
324export type StoppedRecord = {
325  sessionId: string
326  windowEnd: number
327  at: number
328  kinds?: Kind[]
329  work?: boolean
330  auto?: boolean
331  test?: boolean
332  skip?: boolean
333  real?: Holder[]
334}
335
336/**
337 * TS1: the tag of a real entry, `real_<kind>:<resetMs>`. A bare `real_<kind>` has an unknown reset: this
338 * build writes it for a real reading without a reset time, and a 0.2 value from before the reset in the
339 * tag reads the same way (fail closed). `:0` also reads as unknown.
340 */
341const realTag = (h: Holder): string => (h.resetsAtMs === null ? `real_${h.kind}` : `real_${h.kind}:${h.resetsAtMs}`)
342
343const REAL_TAG = /^real_(five_hour|seven_day)(?::(\d{1,16}))?$/
344
345const TAGS = new Set(['five_hour', 'seven_day', 'work', 'auto', 'test', 'skip'])
346
347/** TS1: a real tag as an entry, or undefined when the tag is not a real tag. */
348function realOf(tag: string): Holder | undefined {
349  const m = REAL_TAG.exec(tag)
350  if (m === null) return undefined
351  const kind: Kind = m[1] === 'seven_day' ? 'seven_day' : 'five_hour'
352  const ms = m[2] === undefined ? 0 : Number(m[2])
353  return { kind, resetsAtMs: ms > 0 ? ms : null }
354}
355
356/**
357 * TS1: of two entries of one kind, the one of the later window: a known reset over an unknown one, else
358 * the later reset (a tie: the first). The known reset is the more exact, and a stop of the current
359 * window still holds with it: the reading of that window has that reset.
360 */
361const laterEntry = (a: Holder, b: Holder): Holder => {
362  if (a.resetsAtMs === null) return b
363  if (b.resetsAtMs === null) return a
364  return b.resetsAtMs > a.resetsAtMs ? b : a
365}
366
367/** TS1: an entry whose recorded reset has passed: the window of that entry is over. */
368const windowOver = (h: Holder, now: number): boolean => h.resetsAtMs !== null && h.resetsAtMs <= now
369
370/** One entry per kind of `kinds`, in KINDS order: the later entry of each kind (`laterEntry`). */
371function perKind(entries: readonly Holder[], kinds: readonly Kind[] = KINDS): Holder[] {
372  const out: Holder[] = []
373  for (const k of KINDS) {
374    if (!kinds.includes(k)) continue
375    const mine = entries.filter((h) => h.kind === k)
376    const first = mine[0]
377    if (first !== undefined) out.push(mine.slice(1).reduce(laterEntry, first))
378  }
379  return out
380}
381
382/** The latest time that a Date can hold. A later until or at is junk: every clock text of it throws. */
383const DATE_MAX_MS = 8.64e15
384
385/**
386 * SPARE10_STOPPED is `${sessionId} ${untilMs} ${atMs} ${tags}`, or the 0.1 `${sessionId} ${windowEndMs} ${atMs}`.
387 * Anything else is not stopped, also a time that no Date can hold.
388 */
389export function parseStopped(raw: string | undefined): StoppedRecord | undefined {
390  const m = /^(\S+) (\d+) (\d+)(?: (\S+))?$/.exec(raw ?? '')
391  if (m === null) return undefined
392  const rec = { sessionId: m[1] ?? '', windowEnd: Number(m[2]), at: Number(m[3]) }
393  if (rec.windowEnd > DATE_MAX_MS || rec.at > DATE_MAX_MS) return undefined // also Infinity: a digit string is never NaN or below 0
394  if (m[4] === undefined) return rec
395  const tags = m[4].split(',')
396  if (tags.some((t) => !TAGS.has(t) && realOf(t) === undefined)) return undefined
397  const kinds = KINDS.filter((k) => tags.includes(k))
398  if (kinds.length === 0) return undefined
399  const out: StoppedRecord = { ...rec, kinds, work: tags.includes('work'), auto: tags.includes('auto'), test: tags.includes('test') }
400  if (tags.includes('skip')) out.skip = true
401  const real = perKind(tags.map(realOf).filter((h): h is Holder => h !== undefined), kinds)
402  if (real.length > 0) out.real = real
403  return out
404}
405
406export function formatStopped(s: StoppedRecord): string {
407  const head = `${s.sessionId} ${s.windowEnd} ${s.at}`
408  const kinds = KINDS.filter((k) => s.kinds?.includes(k) === true)
409  if (kinds.length === 0) return head
410  const tags = [
411    ...kinds,
412    ...(s.work === true ? ['work'] : []),
413    ...(s.auto === true ? ['auto'] : []),
414    ...(s.test === true ? ['test'] : []),
415    ...(s.skip === true ? ['skip'] : []),
416    ...(s.skip === true || s.test === true ? perKind(s.real ?? [], kinds).map(realTag) : []),
417  ]
418  return `${head} ${tags.join(',')}`
419}
420
421/**
422 * TS1: the real entries of two stops of one session, one per kind (`laterEntry`). An entry whose window
423 * is over is dropped: a trip in a later window asks again.
424 */
425export const joinReal = (a: readonly Holder[] | undefined, b: readonly Holder[] | undefined, now: number): Holder[] =>
426  perKind([...(a ?? []), ...(b ?? [])].filter((h) => !windowOver(h, now)))
427
428/**
429 * TS1 (B34): the real entries of an extension to `kinds`. A kind whose real reading gates now gets its
430 * current reset, which replaces its earlier entry. Any other kind keeps its earlier entry while that
431 * window lasts.
432 */
433export function extendedReal(prev: readonly Holder[] | undefined, holders: readonly Holder[], kinds: readonly Kind[], now: number): Holder[] {
434  const out: Holder[] = []
435  for (const k of KINDS) {
436    if (!kinds.includes(k)) continue
437    const h = holders.find((x) => x.kind === k)
438    const old = prev?.find((x) => x.kind === k)
439    if (h !== undefined) out.push({ kind: k, resetsAtMs: h.resetsAtMs })
440    else if (old !== undefined && !windowOver(old, now)) out.push(old)
441  }
442  return out
443}
444
445/**
446 * 3.2: a new stop keeps what an earlier 0.2 stop of the same session knew, while that stop still
447 * applies, or while it is an auto stop past its end that nobody released yet (it is still in the env,
448 * so its work still waits for the reset). Kinds join, the later end wins (a tie: the new record), work
449 * if either had it, test only if both had it, auto and at are new. `skip` is the later record's, and
450 * with `auto` only while the earlier record's due time is not after the later end (skip 3.5): a kind
451 * whose release rests on a reset never loses its margin to a skip start. `real` joins by kind (TS1,
452 * `joinReal`): the entry of the later window, and none whose window is over.
453 */
454export function mergeStopped(prev: StoppedRecord | undefined, next: StoppedRecord, now: number): StoppedRecord {
455  if (prev?.kinds === undefined || next.kinds === undefined) return next
456  if (prev.sessionId !== next.sessionId) return next
457  if (now >= prev.windowEnd && prev.auto !== true) return next // it ended by time, and nothing continues it
458  const joined = [...prev.kinds, ...next.kinds]
459  const later = prev.windowEnd > next.windowEnd ? prev : next
460  const earlier = later === prev ? next : prev
461  const { skip: _skip, real: _real, ...rest } = next
462  const out: StoppedRecord = {
463    ...rest,
464    kinds: KINDS.filter((k) => joined.includes(k)),
465    windowEnd: later.windowEnd,
466    work: prev.work === true || next.work === true,
467    test: prev.test === true && next.test === true,
468  }
469  if (later.skip === true && (out.auto !== true || stopDue(earlier) <= later.windowEnd)) out.skip = true
470  const real = joinReal(prev.real, next.real, now)
471  if (real.length > 0) out.real = real
472  return out
473}
474
475/** When spare10 may end a stop by itself: its end plus the margin (4.8). A skip stop has no margin. */
476export const stopDue = (r: StoppedRecord): number => r.windowEnd + marginOf(r.skip === true, r.test === true)
477
478/**
479 * The skip tag of a new stop (skip 3.5): its until is a skip start of its kinds, and with autoResume on
480 * no kind of it is due later (its hold end plus margin). With autoResume off spare10 never releases the
481 * stop, so only the first part counts.
482 */
483export const skipTag = (until: number, skipStarts: readonly number[], dues: readonly number[], auto: boolean): boolean =>
484  skipStarts.includes(until) && (!auto || dues.every((d) => d <= until))
485
486/**
487 * TS1: two resets of one kind name the same window: they lie less than half a window apart. An unknown
488 * reset matches any (fail closed). A reset that moves by a few seconds stays in its window.
489 */
490export const sameWindow = (kind: Kind, a: number | null, b: number | null): boolean =>
491  a === null || b === null || Math.abs(a - b) < windowMs(kind) / 2
492
493/**
494 * TS1: a kind whose real reading gates now keeps a 0.2 stop past its end, when that end was not a reset
495 * of the kind. A skip stop ends at a skip start: a kind that gates there has not opened (a test skip
496 * start over a real trip, B45, or a span lowered after the stop was written, B47). A test stop ends when
497 * its test window ends, and the real reading beneath can still gate. The stop must name the kind with
498 * its `real` tag: its real reading was in the reserve when the entry was written. The reading must be in
499 * the window of that entry (`sameWindow` of the two resets), or either reset is unknown (fail closed: it
500 * keeps the stop while it gates). The time of the stop plays no part: an extension adopts a kind whose
501 * window started after it. A later window or a later trip asks again, as in D0.2. Any other stop ended
502 * at a reset. A 0.1 value ends by time.
503 */
504export const holdsPast = (r: StoppedRecord, h: Holder): boolean =>
505  (r.skip === true || r.test === true) &&
506  r.kinds?.includes(h.kind) === true &&
507  r.real?.some((t) => t.kind === h.kind && sameWindow(h.kind, t.resetsAtMs, h.resetsAtMs)) === true
508
509/** TS1: a 0.2 stop past its end still holds while a kind that gates now keeps it (`holdsPast`). The ticker extends such an auto stop at its due time (B34). */
510export const heldPast = (r: StoppedRecord, holders: readonly Holder[]): boolean => holders.some((h) => holdsPast(r, h))
511
512/**
513 * B35: an auto 0.2 stop of this conversation whose end has passed, that nobody released yet, and that
514 * no kind of it still holds (TS1: `holders` are the kinds whose real reading gates now).
515 */
516export function isOverdue(
517  r: StoppedRecord | undefined,
518  sessionId: string,
519  endedSid: string | undefined,
520  now: number,
521  holders: readonly Holder[],
522): r is StoppedRecord {
523  return (
524    r?.kinds !== undefined &&
525    r.auto === true &&
526    r.sessionId === sessionId &&
527    r.sessionId !== endedSid &&
528    now >= r.windowEnd &&
529    !heldPast(r, holders)
530  )
531}
532
533export type StopAction = 'none' | 'drop' | 'check'
534
535/** What the ticker does with a stop (4.6): nothing, drop a stop of another conversation, or check the quota. */
536export function stopAction(i: {
537  record: StoppedRecord
538  now: number
539  sessionId: string
540  endedSid?: string
541  autoResume: boolean
542  enabled: boolean
543  attended: boolean
544}): StopAction {
545  const r = i.record
546  if (r.kinds === undefined || r.auto !== true || i.now < stopDue(r)) return 'none'
547  if (!(i.autoResume && i.enabled && i.attended)) return 'none'
548  if (r.sessionId !== i.sessionId || r.sessionId === i.endedSid) return 'drop'
549  return 'check'
550}
551
552/**
553 * 5.6: a loop joins the open question while it is open, or settled as Stop here, or settled as Resume
554 * for every kind that gates now. Never after again, and never a Resume for a kind the dialog did not name.
555 */
556export function joinable(outcome: 'resume' | 'stop' | 'again' | undefined, named: readonly Kind[], gating: readonly Kind[]): boolean {
557  if (outcome === undefined || outcome === 'stop') return true
558  return outcome === 'resume' && gating.every((k) => named.includes(k))
559}
560
561/** Attended: every refused main step ends its turn. Unattended: only a repeat in the same turn. */
562export const shouldAbortTurn = (attended: boolean, refusedBefore: boolean): boolean => attended || refusedBefore
563
564export type Phase = 'off' | 'blind' | 'waiting' | 'armed' | 'limit' | 'consented' | 'open' | 'stopped' | 'asking' | 'told' | 'reserve' | 'tripped'
565
566export type PhaseInput = {
567  enabled: boolean
568  basis: Basis
569  tripped: boolean
570  consented: boolean // tripped, and every tripped kind is consented
571  open?: boolean // tripped, no kind gates, and a kind is in its skip window (B41)
572  stopped: boolean
573  asking: boolean
574  told: boolean
575  attended: boolean
576  bases?: readonly Basis[] // Codex design 2.1: the bases of the kinds the host reports. Absent: [basis]
577  limit?: boolean // a kind that gates is at the quota limit
578}
579
580/**
581 * The breaker phase, first match: off, asking, blind, waiting, armed, limit, consented, open, stopped,
582 * told, reserve, tripped. The limit phase: attended, a kind at the quota limit gates, and no stop applies. `basis` is the five_hour basis, `tripped` is any watched kind (3.5). A stop never
583 * holds an open kind (B44), so open outranks stopped. With `bases` (a host that can lack a window, such
584 * as a weekly-only plan), the reading rule reads every basis: blind when all are blind, waiting when
585 * all are none, else armed. Claude passes no `bases`, so its rule does not change.
586 */
587export function phaseOf(i: PhaseInput): Phase {
588  if (!i.enabled) return 'off'
589  if (i.asking) return 'asking' // an open question outranks any reading (B6)
590  const bs = i.bases ?? [i.basis]
591  if (!i.tripped && bs.every((b) => b.kind === 'none')) return bs.length > 0 && bs.every((b) => b.kind === 'none' && b.why === 'blind') ? 'blind' : 'waiting'
592  if (!i.tripped) return 'armed'
593  if (i.limit === true && i.attended && !i.stopped) return 'limit'
594  if (i.consented) return 'consented'
595  if (i.open === true) return 'open'
596  if (i.stopped && i.attended) return 'stopped'
597  if (i.told) return 'told'
598  if (!i.attended) return 'reserve'
599  return 'tripped'
600}
601
hooks/core/reading.ts 311 lines
1import type { SessionRateLimit } from 'claude-code'
2import type { ConsentSlots } from './decide.ts'
3
4// The reading rule of the gate (design section 6), as pure functions. No $ here: register.tsx feeds
5// in what $.session.usage(), session.measure, $.store and the clock said. 0.2: one rule per kind.
6
7/** A window as SessionRateLimit.kind names it. Texts say 5-hour and weekly. */
8export type Kind = 'five_hour' | 'seven_day'
9
10/** The watched kinds, five_hour first. */
11export const KINDS: readonly Kind[] = ['five_hour', 'seven_day']
12
13/** A reading with the window it belongs to. */
14export type Anchored = { pct: number; resetsAtMs: number }
15
16/** What this copy of the module remembers of one kind: the newest reading with a reset, the blind count, and the first sight of a reading without a reset. */
17export type Memory = { seed?: Anchored; misses: number; noReset?: { since: number; pct: number } }
18
19export type Basis =
20  | { kind: 'live' | 'seed' | 'test'; pct: number; resetsAtMs: number | null }
21  | { kind: 'none'; why: 'no-reading' | 'window-reset' | 'blind' }
22
23/** One five-hour window plus a minute of clock slack: no reset is further away than this. */
24export const WINDOW_MS = 5 * 3_600_000 + 60_000
25
26/** The weekly window. */
27export const WEEK_MS = 7 * 24 * 3_600_000
28
29/** A release waits this long after a real reset (4.8). */
30export const RESET_MARGIN_MS = 300_000
31
32/** A release waits this long after the end of a test window (4.8). */
33export const TEST_MARGIN_MS = 60_000
34
35/** Two response-backed measures with no window: one can be a turn that ended just past a reset. */
36export const BLIND_AFTER = 2
37
38/** spare10's one-hour fallback when a reading has no reset time. It bounds consent and stopped only. */
39export const FALLBACK_MS = 3_600_000
40
41/** The window of a test reading that has no live reset to borrow. */
42export const TEST_WINDOW_MS = 5 * 3_600_000
43
44/** The length of a window: 5 h or 7 d. */
45export const windowMs = (kind: Kind): number => (kind === 'seven_day' ? WEEK_MS : 5 * 3_600_000)
46
47/** Two resets this close are one window (A22): a reset time moves by seconds, an early reset by much more. */
48export const RESET_JITTER_MS = 600_000
49
50/**
51 * A22: a real consent is void when the kind's real window ends more than RESET_JITTER_MS after the
52 * consent's end: the window reset early, and the consent belongs to the old one.
53 */
54export const voidedByReset = (c: { until: number }, windowEnd: number): boolean => windowEnd - c.until > RESET_JITTER_MS
55
56/**
57 * A22: this copy's real slots of a kind without the consents that an early reset voided. `realEnd`: the
58 * reset of the real reading, null when it is unknown (then nothing is void). Undefined when nothing is left.
59 */
60export function withoutVoided(s: ConsentSlots | undefined, realEnd: number | null): ConsentSlots | undefined {
61  if (s === undefined || realEnd === null) return s
62  const full = s.full !== undefined && !voidedByReset({ until: s.full }, realEnd) ? s.full : undefined
63  const floor = s.floor !== undefined && !voidedByReset(s.floor, realEnd) ? s.floor : undefined
64  if (full === undefined && floor === undefined) return undefined
65  return { ...(full === undefined ? {} : { full }), ...(floor === undefined ? {} : { floor }) }
66}
67
68const kindOfLimit = (live: SessionRateLimit): Kind => (live.kind === 'seven_day' ? 'seven_day' : 'five_hour')
69
70export const initialMemory = (): Memory => ({ misses: 0 })
71
72export const limitOf = (list: readonly SessionRateLimit[], kind: Kind): SessionRateLimit | undefined =>
73  list.find((r) => r.kind === kind)
74
75export const fiveHour = (list: readonly SessionRateLimit[]): SessionRateLimit | undefined => limitOf(list, 'five_hour')
76
77export function parseReset(iso: string | undefined): number | null {
78  if (iso === undefined) return null
79  const ms = Date.parse(iso)
80  return Number.isFinite(ms) ? ms : null
81}
82
83/** A live reading as something to remember. A reading without a reset time is never remembered. */
84export function anchoredOf(live: SessionRateLimit): Anchored | undefined {
85  const resetsAtMs = parseReset(live.resetsAt)
86  return resetsAtMs === null ? undefined : { pct: live.percentUsed, resetsAtMs }
87}
88
89/** A remembered reading counts while its window is open, and never for longer than one window of its kind. */
90export const inWindow = (r: Anchored, now: number, kind: Kind = 'five_hour'): boolean =>
91  now < r.resetsAtMs && r.resetsAtMs - now <= windowMs(kind) + 60_000
92
93/** The later window wins, then the higher percentage. */
94export function newer(a: Anchored | undefined, b: Anchored | undefined): Anchored | undefined {
95  if (a === undefined) return b
96  if (b === undefined) return a
97  if (a.resetsAtMs !== b.resetsAtMs) return a.resetsAtMs > b.resetsAtMs ? a : b
98  return b.pct > a.pct ? b : a
99}
100
101/** What the gate decides on at `now`: live, then blind, then the remembered reading, then none. */
102export function basis(live: SessionRateLimit | undefined, mem: Memory, now: number, test?: Anchored, kind: Kind = 'five_hour'): Basis {
103  const b = realBasis(live, mem, now, kind)
104  if (test === undefined || !inWindow(test, now, kind)) return b
105  if (b.kind === 'none' || test.pct > b.pct) return { kind: 'test', pct: test.pct, resetsAtMs: test.resetsAtMs }
106  return b
107}
108
109function realBasis(live: SessionRateLimit | undefined, mem: Memory, now: number, kind: Kind): Basis {
110  if (live !== undefined) {
111    // Live always wins over anything remembered, never max(live, remembered).
112    const resetsAtMs = parseReset(live.resetsAt)
113    if (resetsAtMs !== null && now >= resetsAtMs) return { kind: 'none', why: 'window-reset' }
114    // Without a reset time, a figure unchanged for one window since its first sight is stale (3.1).
115    const n = mem.noReset
116    if (resetsAtMs === null && n !== undefined && now >= n.since + windowMs(kind) && live.percentUsed === n.pct) {
117      return { kind: 'none', why: 'window-reset' }
118    }
119    return { kind: 'live', pct: live.percentUsed, resetsAtMs }
120  }
121  if (mem.misses >= BLIND_AFTER) return { kind: 'none', why: 'blind' }
122  if (mem.seed !== undefined && inWindow(mem.seed, now, kind)) return { kind: 'seed', ...mem.seed }
123  const expired = mem.seed !== undefined && now >= mem.seed.resetsAtMs
124  return { kind: 'none', why: expired ? 'window-reset' : 'no-reading' }
125}
126
127/** A live reading arrived (a gate read or a measure). With `now`, it also tracks the first sight of a reading without a reset. */
128export function sawLive(mem: Memory, live: SessionRateLimit, now?: number): Memory {
129  const seed = newer(mem.seed, anchoredOf(live))
130  const noReset = now === undefined ? mem.noReset : firstSight(mem.noReset, live, now)
131  return { ...(seed === undefined ? {} : { seed }), misses: 0, ...(noReset === undefined ? {} : { noReset }) }
132}
133
134// A fall means a new window, and so does a new figure one window after the first sight. A rise keeps it.
135function firstSight(n: Memory['noReset'], live: SessionRateLimit, now: number): Memory['noReset'] {
136  if (parseReset(live.resetsAt) !== null) return undefined
137  const pct = live.percentUsed
138  if (n === undefined || pct < n.pct) return { since: now, pct }
139  if (pct === n.pct) return n
140  if (now >= n.since + windowMs(kindOfLimit(live))) return { since: now, pct }
141  return { since: n.since, pct }
142}
143
144/**
145 * A session.measure arrived. `cost` in `changed` means a billed response completed. If the engine
146 * still lists no window of this kind, that response carried none: one miss.
147 */
148export function sawMeasure(
149  mem: Memory,
150  e: { rateLimits: readonly SessionRateLimit[]; changed: readonly string[] },
151  kind: Kind = 'five_hour',
152  now?: number,
153): Memory {
154  const live = limitOf(e.rateLimits, kind)
155  if (live !== undefined) return sawLive(mem, live, now)
156  if (!e.changed.includes('cost')) return mem
157  return { ...mem, misses: mem.misses + 1 }
158}
159
160/** A value read back from $.store, validated. Anything else is no seed. */
161export function asAnchored(v: unknown): Anchored | undefined {
162  if (typeof v !== 'object' || v === null) return undefined
163  const { pct, resetsAtMs } = v as Record<string, unknown>
164  return typeof pct === 'number' && Number.isFinite(pct) && typeof resetsAtMs === 'number' && Number.isFinite(resetsAtMs)
165    ? { pct, resetsAtMs }
166    : undefined
167}
168
169/** B48: the floor point, 100 - floor on the one-decimal grid: 95 for 5, 97.5 for 2.5, 89.4 for 10.6. */
170export const pointOf = (floor: number): number => Math.round((100 - floor) * 10) / 10
171
172// The trip point on the one-decimal grid, so 100 - 10.6 is 89.4 and not 89.40000000000001.
173const tripAt = (reserve: number): number => pointOf(reserve)
174
175/** The first point of the reserve trips: reserve 10 trips at 90.0 and passes at 89.9. */
176export const isTripped = (b: Basis, reserve: number): boolean => b.kind !== 'none' && b.pct >= tripAt(reserve)
177
178/** The quota limit: 100% used. Past it the host refuses each model request until the reset. */
179export const LIMIT_PCT = 100
180
181/** A basis at or past the quota limit, with a known reset. A reading without a reset time never counts. */
182export const atLimit = (b: Basis): boolean => b.kind !== 'none' && b.resetsAtMs !== null && b.pct >= LIMIT_PCT
183
184/** B48: a basis at or past a floor point. None with no point, and never for a none basis. */
185export const atPoint = (b: Basis, point: number | null): boolean => point !== null && b.kind !== 'none' && b.pct >= point
186
187/**
188 * 4.8: the last real reading of a kind was in the reserve, and its window reset less than
189 * RESET_MARGIN_MS ago. The local clock may run ahead of the server, so spare10 releases nothing by
190 * itself yet, also when a test window ends first.
191 */
192export const inResetMargin = (seed: Anchored | undefined, reserve: number, now: number): boolean =>
193  seed !== undefined && seed.resetsAtMs <= now && now - seed.resetsAtMs < RESET_MARGIN_MS && seed.pct >= tripAt(reserve)
194
195export const pctOf = (b: Basis): number | undefined => (b.kind === 'none' ? undefined : b.pct)
196
197/** The window end that bounds consent and stopped. Never a release time for held work. */
198export const windowEndOf = (b: Basis, now: number): number =>
199  b.kind !== 'none' && b.resetsAtMs !== null ? b.resetsAtMs : now + FALLBACK_MS
200
201/** The hold end (3.1): the reset time, else the first sight plus one window. Never the one-hour fallback. */
202export function holdEndOf(b: Basis, mem: Memory, now: number, kind: Kind = 'five_hour'): number {
203  if (b.kind === 'none') return now + windowMs(kind)
204  if (b.resetsAtMs !== null) return b.resetsAtMs
205  return (mem.noReset?.since ?? now) + windowMs(kind)
206}
207
208// ---- Skip near the reset (B41, B42, B45) ----
209
210/** The skip start of a basis: its reset minus the span. Null when the span is 0 or the reset is unknown. */
211export function skipStartOf(b: Basis, spanMs: number): number | null {
212  if (!(spanMs > 0) || b.kind === 'none' || b.resetsAtMs === null) return null
213  return b.resetsAtMs - spanMs
214}
215
216/** One kind at now: the basis it rests on, whether it is tripped, its skip start while ahead, and whether it is open. */
217export type KindView = { basis: Basis; tripped: boolean; skipAt: number | null; open: boolean }
218
219/**
220 * One kind at now (B41, B45). A kind is open when it is tripped and its skip start has come. A kind
221 * without a reset time is never open (fail safe). A test reading in its skip window yields to a real
222 * trip that is not open, so a test reading never releases a real hold.
223 */
224export function viewOf(real: Basis, withTest: Basis, reserve: number, spanMs: number, now: number): KindView {
225  const one = (b: Basis): KindView => {
226    const tripped = isTripped(b, reserve)
227    const start = skipStartOf(b, spanMs)
228    return { basis: b, tripped, skipAt: start !== null && now < start ? start : null, open: tripped && start !== null && now >= start }
229  }
230  const v = one(withTest)
231  if (withTest.kind !== 'test' || !v.open) return v
232  const r = one(real)
233  return r.tripped && !r.open ? r : v
234}
235
236/** The release margin (4.7): 0 at a skip start, 60 s after a test window, 5 min after a real reset. */
237export const marginOf = (skip: boolean, test: boolean): number => (skip ? 0 : test ? TEST_MARGIN_MS : RESET_MARGIN_MS)
238
239/** SPARE10_SIMULATE and /spare10 simulate: 0 to 100, rounded to one decimal. Junk is none. */
240export function parseTestPct(raw: string | undefined): number | undefined {
241  if (raw === undefined || raw.trim() === '') return undefined
242  const n = Number(raw.trim())
243  if (!Number.isFinite(n)) return undefined
244  const r = Math.round(n * 10) / 10
245  return r >= 0 && r <= 100 ? r : undefined
246}
247
248/** A test reading: its percentage, its kind, and when set, the time to its reset. */
249export type TestSpec = { pct: number; kind: Kind; inMs?: number }
250
251const UNIT_MS = new Map<string, number>([
252  ['s', 1_000],
253  ['m', 60_000],
254  ['h', 3_600_000],
255  ['d', 86_400_000],
256])
257
258/** `90s`, `2m`, `3h` or `1d`, any case. Under 10 s is none. */
259export function parseDuration(word: string): number | undefined {
260  const m = /^(\d+)([smhd])$/i.exec(word.trim())
261  if (m === null) return undefined
262  const ms = Number(m[1]) * (UNIT_MS.get((m[2] ?? '').toLowerCase()) ?? Number.NaN)
263  return Number.isFinite(ms) && ms >= 10_000 ? ms : undefined
264}
265
266const KIND_WORDS = new Map<string, Kind>([
267  ['5h', 'five_hour'],
268  ['5-hour', 'five_hour'],
269  ['five_hour', 'five_hour'],
270  ['weekly', 'seven_day'],
271  ['7d', 'seven_day'],
272  ['seven_day', 'seven_day'],
273])
274
275/**
276 * The 2.9 grammar: `off`, or a percentage, then a kind word and `in {n}{s|m|h|d}` in either order.
277 * `defaultKind`: the kind with no kind word (Codex design 4.15: the weekly window on a weekly-only plan).
278 */
279export function parseSimulate(words: readonly string[], defaultKind: Kind = 'five_hour'): TestSpec | 'off' | undefined {
280  const [first, ...rest] = words
281  if (first === undefined) return undefined
282  if (first.toLowerCase() === 'off') return rest.length === 0 ? 'off' : undefined
283  const pct = parseTestPct(first)
284  if (pct === undefined) return undefined
285  let kind: Kind | undefined
286  let inMs: number | undefined
287  for (let i = 0; i < rest.length; i += 1) {
288    const w = (rest[i] ?? '').toLowerCase()
289    const k = KIND_WORDS.get(w)
290    if (k !== undefined && kind === undefined) {
291      kind = k
292      continue
293    }
294    const d = w === 'in' && inMs === undefined ? parseDuration(rest[i + 1] ?? '') : undefined
295    if (d === undefined) return undefined
296    inMs = d
297    i += 1
298  }
299  const k = kind ?? defaultKind
300  return inMs === undefined ? { pct, kind: k } : { pct, kind: k, inMs: Math.min(inMs, windowMs(k)) }
301}
302
303/** SPARE10_SIMULATE as words: split on white space. A blank value has none. */
304export const simulateWords = (raw: string | undefined): string[] => (raw ?? '').trim().split(/\s+/).filter((w) => w !== '')
305
306/** SPARE10_SIMULATE: the same words, split on white space. `off` and junk are no test reading. */
307export function parseSimulateEnv(raw: string | undefined, defaultKind: Kind = 'five_hour'): TestSpec | undefined {
308  const spec = parseSimulate(simulateWords(raw), defaultKind)
309  return spec === 'off' ? undefined : spec
310}
311
hooks/core/text.ts 1187 lines
1import type { Headless, Scope, Source, SpanSource } from './config.ts'
2import type { Mode, Phase } from './decide.ts'
3import type { Basis, Kind } from './reading.ts'
4import { HOST } from './host.ts'
5
6// Every user-facing and model-facing string (design section 2), verbatim. No $ here.
7// Every text function takes the time zone through Facts.timeZone (the kit ignores TZ).
8//
9// The engine puts `spare10: ` in front of each transcript line ($.ui.log without `to`) and of each
10// command.run reply (live check LC1, defect D1). So a notice, a warning or a reply never starts with
11// `spare10: ` here. Texts the engine does not prefix keep it: model texts (STOP, PAUSED, HEADLESS, the
12// pause instruction, the resume note), the drop reasons of prompt.submit, and debug lines.
13//
14// A word that differs between hosts (a command, the host name) comes from HOST (host.ts). The Codex
15// bundle swaps host.ts, so the same texts carry the Codex words there (Codex design 2.1).
16
17export const VERSION: string = '0.4.0' // keep equal to .claude-plugin/plugin.json and .codex-plugin/plugin.json
18export const HEADER: string = 'spare10'
19export const QUESTION_OPTIONS: readonly [string, string] = ['Stop here', 'Resume']
20/** The limit question: Continue at the reset first, with the focus. Only the exact Stop here label stops. */
21export const LIMIT_OPTIONS: readonly [string, string] = ['Continue at the reset', 'Stop here']
22/** The way out of a pause at the limit: a reading that is out of date, or paid extra usage. */
23export const LIMIT_OFF: string = `To let work run past the limit, ${HOST.limitOff}.`
24export const RESUME_LABEL: string = 'Resume'
25export const COMMAND_DESCRIPTION: string = 'Show the spare10 quota breaker, or resume or stop at the reserve.'
26export const ARGUMENT_HINT: string = '[resume|stop]'
27export const STOP_GENERIC: string = 'spare10: stopped at the quota reserve. Stop now and wait for the user. Do not call any further tools.'
28export const NOT_STARTED_GENERIC: string = `spare10: not started. spare10 could not ask you. Send the prompt again, or run ${HOST.command} resume.`
29export const HEADLESS_GENERIC: string = 'spare10 stopped this unattended run at the quota reserve. No further model requests were sent.'
30export const W_FLAG: string =
31  'function hooks are on only in this shell. Background sessions and pane teammates start without spare10. Put CLAUDE_CODE_ENABLE_FUNCTION_HOOKS in the env block of ~/.claude/settings.json.'
32
33/**
34 * The figures of one kind. No `kind`: five_hour. `now` feeds the date rule of a weekly clock (2.1).
35 * `holdEnd`: the hold end when it is not the reset (3.1): a reading without a reset time, or a skip
36 * start that is still ahead (skip 2.1), for {at}. `span`: the kind's span in ms, set only when a text
37 * may name {lead} or {span} (a skip owner). `test`: a test reading, for {soon} and {lead}.
38 * The floor (floor 6.4): `to` is the end point of the consent that the text describes (the tier of a
39 * question or a Resume at the reserve, or the end point now of a covering consent to the floor).
40 * `floor` is set only for a kind at the floor: {R} becomes {F}. With neither, every text is the 0.2 text.
41 * `limit` is set only for a kind at the quota limit (100% used): {R} becomes the quota limit.
42 */
43export type Facts = {
44  used: number
45  left: number
46  resetsAtMs: number | null
47  reserve: number
48  timeZone?: string
49  kind?: Kind
50  now?: number
51  holdEnd?: number
52  span?: number
53  test?: boolean
54  to?: number
55  floor?: number
56  limit?: boolean
57}
58
59/** A window that reset or ended, for {reset}. */
60export type Named = { kind: Kind; test: boolean }
61
62/** Which kinds of a question or a stop reset, and which are open now (skip 4.4). */
63export type Ended = { reset: readonly Named[]; open: readonly Facts[] }
64
65const round1 = (n: number): number => Math.round(n * 10) / 10
66
67/** The figures of a basis that applies. A 'none' basis gives zero used and no reset. */
68export function factsOf(b: Basis, reserve: number, timeZone?: string, kind?: Kind, now?: number): Facts {
69  const used = b.kind === 'none' ? 0 : round1(b.pct)
70  const weekly = kind === 'seven_day'
71  return {
72    used,
73    left: round1(Math.max(0, 100 - used)),
74    resetsAtMs: b.kind === 'none' ? null : b.resetsAtMs,
75    reserve,
76    ...(timeZone === undefined ? {} : { timeZone }),
77    ...(weekly ? { kind } : {}),
78    ...(weekly && now !== undefined ? { now } : {}),
79  }
80}
81
82/** 91 is '91', 91.5 is '91.5', 91.54 is '91.5'. */
83export const fmtPct = (n: number): string => String(round1(n) + 0)
84
85const part = (ms: number, opts: Intl.DateTimeFormatOptions, timeZone?: string): string =>
86  new Intl.DateTimeFormat('en-GB', { ...opts, ...(timeZone === undefined ? {} : { timeZone }) }).format(new Date(ms))
87
88/** HH:MM, 24-hour, in the given zone (local time when none). */
89export function formatClock(ms: number, timeZone?: string): string {
90  return part(ms, { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }, timeZone)
91}
92
93const DAY_MS = 86_400_000
94// A fixed table: en-GB says 'Sept' in some runtimes and 'Sep' in others.
95const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
96
97/**
98 * {clock} (2.1): 'HH:MM' for the 5-hour window. 'ddd HH:MM' for the weekly window, and 'ddd D MMM HH:MM'
99 * more than 6 days after now. Separate formatters, so no locale punctuation gets in.
100 */
101export function clockText(ms: number, kind: Kind, timeZone?: string, now?: number): string {
102  const clock = formatClock(ms, timeZone)
103  if (kind !== 'seven_day') return clock
104  const weekday = part(ms, { weekday: 'short' }, timeZone)
105  if (now === undefined || ms - now <= 6 * DAY_MS) return `${weekday} ${clock}`
106  const month = MONTHS[Number(part(ms, { month: 'numeric' }, timeZone)) - 1] ?? ''
107  return `${weekday} ${part(ms, { day: 'numeric' }, timeZone)} ${month} ${clock}`
108}
109
110/** '3 d 21 h', '2 h 14 min', '14 min' or 'under 1 min'. */
111export function fmtDuration(ms: number): string {
112  const min = Math.floor(Math.max(0, ms) / 60_000)
113  if (min < 1) return 'under 1 min'
114  if (min < 60) return `${min} min`
115  const hours = Math.floor(min / 60)
116  if (hours < 24) return `${hours} h ${min % 60} min`
117  return `${Math.floor(hours / 24)} d ${hours % 24} h`
118}
119
120/** {at}: the clock of a hold end, with the weekday form when the kinds name the weekly window. */
121export const atText = (ms: number, kinds: readonly Kind[], timeZone?: string, now?: number): string =>
122  clockText(ms, kinds.includes('seven_day') ? 'seven_day' : 'five_hour', timeZone, now)
123
124const oneReset = (n: Named): string => {
125  if (n.kind === 'seven_day') return n.test ? 'the weekly test window ended' : 'the weekly window reset'
126  return n.test ? 'the test window ended' : 'the 5-hour window reset'
127}
128
129const cap = (text: string): string => text.charAt(0).toUpperCase() + text.slice(1)
130
131/** {reset}, five_hour first. No window named: the 5-hour window (a 0.1 stop). */
132export function resetText(named: readonly Named[], capital = false): string {
133  const five = named.find((n) => n.kind === 'five_hour')
134  const week = named.find((n) => n.kind === 'seven_day')
135  let text: string
136  if (five === undefined || week === undefined) text = oneReset(five ?? week ?? { kind: 'five_hour', test: false })
137  else if (five.test && week.test) text = 'the test windows ended'
138  else if (!five.test && !week.test) text = 'the 5-hour and weekly windows reset'
139  else text = `${oneReset(five)} and ${oneReset(week)}`
140  return capital ? cap(text) : text
141}
142
143/**
144 * The armed detail: 'spare10 steps in at 90% used.', with the weekly trip point when it is watched.
145 * `fiveAbsent` (Codex design 2.1): the host reports no 5-hour window, so only the weekly trip point
146 * counts, or no window when the weekly window is not watched either. Claude never passes it.
147 */
148export const stepsIn = (reserve: number, weeklyReserve?: number, fiveAbsent = false): string => {
149  const weekly = weeklyReserve !== undefined && weeklyReserve > 0
150  if (fiveAbsent) return weekly ? `spare10 steps in at ${fmtPct(100 - weeklyReserve)}% used of the weekly window.` : 'spare10 watches no window.'
151  return weekly
152    ? `spare10 steps in at ${fmtPct(100 - reserve)}% used, or at ${fmtPct(100 - weeklyReserve)}% used of the weekly window.`
153    : `spare10 steps in at ${fmtPct(100 - reserve)}% used.`
154}
155
156// ---- Placeholders of 2.1. A list of Facts reads five_hour first. ----
157
158const kindOf = (f: Facts): Kind => f.kind ?? 'five_hour'
159const isWeekly = (f: Facts): boolean => kindOf(f) === 'seven_day'
160const isList = (f: Facts | readonly Facts[]): f is readonly Facts[] => Array.isArray(f)
161const listOf = (f: Facts | readonly Facts[]): Facts[] =>
162  (isList(f) ? [...f] : [f]).sort((a, b) => Number(isWeekly(a)) - Number(isWeekly(b)))
163const onlyOf = (fs: readonly Facts[]): Facts | undefined => (fs.length === 1 ? fs[0] : undefined)
164
165const clockOf = (f: Facts): string =>
166  f.resetsAtMs === null ? 'at an unknown time' : clockText(f.resetsAtMs, kindOf(f), f.timeZone, f.now)
167const reserveOf = (f: Facts): string => `${fmtPct(f.reserve)}%`
168const UNTIL_RESET = 'until the window resets' // only when a caller has no figures
169
170/** {R}, or {F} for a kind at the floor (floor 2.1), or the quota limit for a kind at 100% used. */
171const reserveName = (f: Facts): string =>
172  f.limit === true
173    ? isWeekly(f)
174      ? 'weekly quota limit'
175      : 'quota limit'
176    : f.floor !== undefined
177    ? `${fmtPct(f.floor)}% ${isWeekly(f) ? 'weekly floor' : 'floor'}`
178    : `${reserveOf(f)} ${isWeekly(f) ? 'weekly reserve' : 'reserve'}`
179/** {Rs} */
180export const yourReserves = (fs: readonly Facts[]): string => fs.map((f) => `your ${reserveName(f)}`).join(' and ')
181/** {is} */
182const isAre = (fs: readonly Facts[]): string => (fs.length > 1 ? 'are' : 'is')
183/** {Rq}. One kind at the floor: the quota floor (floor 2.1). */
184const quotaReserve = (fs: readonly Facts[]): string => {
185  const f = onlyOf(fs)
186  if (f === undefined) return 'quota reserves'
187  if (f.floor !== undefined) return `${fmtPct(f.floor)}% ${isWeekly(f) ? 'weekly quota floor' : 'quota floor'}`
188  return `${reserveOf(f)} ${isWeekly(f) ? 'weekly quota reserve' : 'quota reserve'}`
189}
190/** {names} */
191const windowNames = (kinds: readonly Kind[]): string => {
192  const five = kinds.includes('five_hour')
193  const week = kinds.includes('seven_day')
194  if (five && week) return '5-hour and weekly windows'
195  return week ? 'weekly window' : '5-hour window'
196}
197
198const onePerson = (f: Facts): string => `${fmtPct(f.used)}% used · ${fmtPct(f.left)}% left · resets ${clockOf(f)}`
199
200/** {pf} */
201export function personFacts(f: Facts | readonly Facts[]): string {
202  const fs = listOf(f)
203  const one = onlyOf(fs)
204  if (one !== undefined) return onePerson(one)
205  return fs.map((x) => `${isWeekly(x) ? 'weekly' : '5-hour'} window ${onePerson(x)}`).join(', ')
206}
207
208const oneModel = (f: Facts): string =>
209  f.limit === true
210    ? `${fmtPct(f.used)}% of ${isWeekly(f) ? 'weekly quota' : 'quota'} used · resets ${clockOf(f)}`
211    : `into your ${reserveName(f)} · ${fmtPct(f.left)}% of ${isWeekly(f) ? 'weekly quota' : 'quota'} left · resets ${clockOf(f)}`
212
213/** {mf} */
214export const modelFacts = (f: Facts | readonly Facts[]): string => listOf(f).map(oneModel).join(', and ')
215
216/** {quiet}: 'until 14:00', 'for one hour' with no reset time, or 'until they reset (15:00 and Mon 09:00)'. */
217export function untilText(f: Facts | readonly Facts[]): string {
218  const fs = listOf(f)
219  const one = onlyOf(fs)
220  if (one !== undefined) return one.resetsAtMs === null ? 'for one hour' : `until ${clockOf(one)}`
221  const clocks = fs.map((x) => (x.resetsAtMs === null ? 'an unknown time' : clockOf(x)))
222  return `until they reset (${clocks.join(' and ')})`
223}
224
225/** {p}: an end point, '95% used', and for the weekly window '95% used of the weekly window'. */
226const pointText = (to: number, kind: Kind): string => `${fmtPct(to)}% used${kind === 'seven_day' ? ' of the weekly window' : ''}`
227
228/** A fact that names the floor: an end point (at the reserve) or the floor stage. */
229const hasFloor = (f: Facts): boolean => f.to !== undefined || f.floor !== undefined
230
231/** {use} of one kind (floor 2.1): at the floor the last part left, at the reserve its end point, else 0.2. */
232function useOne(f: Facts): string {
233  const weekly = isWeekly(f)
234  if (f.floor !== undefined) return `the last ${fmtPct(f.left)}%${weekly ? ' of the weekly window' : ''} ${untilText(f)}`
235  const name = weekly ? 'the weekly reserve' : 'the reserve'
236  if (f.to !== undefined) return f.resetsAtMs === null ? `${name} for one hour, or until ${fmtPct(f.to)}% used` : `${name} until ${fmtPct(f.to)}% used`
237  return `${name} ${untilText(f)}`
238}
239
240/** {use} */
241const useText = (fs: readonly Facts[]): string => {
242  const one = onlyOf(fs)
243  if (one !== undefined) return useOne(one)
244  if (!fs.some(hasFloor)) return `both reserves ${untilText(fs)}`
245  const [a, b] = fs
246  if (
247    a !== undefined &&
248    b !== undefined &&
249    a.floor === undefined &&
250    b.floor === undefined &&
251    a.to !== undefined &&
252    a.to === b.to &&
253    a.resetsAtMs !== null &&
254    b.resetsAtMs !== null
255  ) {
256    return `both reserves until ${fmtPct(a.to)}% used`
257  }
258  return fs.map(useOne).join(' and ')
259}
260
261/** {verb} (floor 2.1): what spare10 does at the floor in each mode. */
262const verbOf = (mode: Mode): string => (mode === 'tell' ? 'spare10 tells the agents to wind down' : 'spare10 asks you again')
263
264/** A skip start that is still ahead: a hold end that is not the reset, on a reading with a reset time. */
265const skipAhead = (f: Facts): boolean => f.resetsAtMs !== null && f.holdEnd !== undefined
266
267/**
268 * {asks} (floor 2.1): when spare10 asks again, for the kinds with an end point (`to`). '' when none.
269 * While a skip start is ahead, it says until when: from the skip start nothing gates.
270 */
271export function asksText(f: Facts | readonly Facts[], mode: Mode = 'hold'): string {
272  const fs = listOf(f).filter((x) => x.to !== undefined)
273  const verb = verbOf(mode)
274  const one = onlyOf(fs)
275  if (one !== undefined) {
276    const p = pointText(one.to ?? 0, kindOf(one))
277    if (!skipAhead(one)) return `At ${p}, ${verb}.`
278    return `Until ${clockText(one.holdEnd ?? 0, kindOf(one), one.timeZone, one.now)}, ${verb} at ${p}.`
279  }
280  const [a, b] = fs
281  if (a === undefined || b === undefined) return ''
282  const points = a.to === b.to ? `${fmtPct(a.to ?? 0)}% used of either window` : `${pointText(a.to ?? 0, 'five_hour')}, or at ${pointText(b.to ?? 0, 'seven_day')}`
283  if (!skipAhead(a) && !skipAhead(b)) return `At ${points}, ${verb}.`
284  return `Until its reserve opens, ${verb} at ${points}.`
285}
286
287/** ' {asks}', or '' when there is none. */
288const asksPart = (fs: readonly Facts[], mode: Mode): string => {
289  const a = asksText(fs, mode)
290  return a === '' ? '' : ` ${a}`
291}
292
293// ---- Skip near the reset: the placeholders of skip 2.1 ----
294
295/** The hold end of one kind: a given hold end, else its skip start when it has a span, else its reset. */
296const timeOf = (f: Facts): number | null =>
297  f.holdEnd ?? (f.span !== undefined && f.resetsAtMs !== null ? f.resetsAtMs - f.span : f.resetsAtMs)
298
299/** {span}: '20 min' for the 5-hour window, '8 h' for the weekly window. */
300export function spanText(f: Facts): string {
301  const ms = f.span ?? 0
302  return isWeekly(f) ? `${fmtPct(ms / 3_600_000)} h` : `${fmtPct(ms / 60_000)} min`
303}
304
305/** {lead}: where a skip start lies, such as '20 min before the reset'. Undefined without a span. */
306export function leadText(f: Facts): string | undefined {
307  if (f.span === undefined) return undefined
308  const end = isWeekly(f)
309    ? f.test === true
310      ? 'the weekly test window ends'
311      : 'the weekly reset'
312    : f.test === true
313      ? 'the test window ends'
314      : 'the reset'
315  return `${spanText(f)} before ${end}`
316}
317
318const oneSoon = (f: Facts): string => {
319  const clock = clockOf(f)
320  if (isWeekly(f)) return f.test === true ? `the weekly test window ends at ${clock}` : `the weekly window resets at ${clock}`
321  return f.test === true ? `the test window ends at ${clock}` : `the 5-hour window resets at ${clock}`
322}
323
324/** {soon}: when the open windows reset, five_hour first, joined with ', and '. Always the reset clock. */
325export const soonText = (open: Facts | readonly Facts[]): string => listOf(open).map(oneSoon).join(', and ')
326
327/** {event}: the windows that reset, then the open ones and their reserves. '' when both lists are empty. */
328export function eventText(named: readonly Named[], open: Facts | readonly Facts[]): string {
329  const fs = listOf(open)
330  const parts: string[] = []
331  if (named.length > 0) parts.push(resetText(named))
332  if (fs.length > 0) parts.push(soonText(fs), `${yourReserves(fs)} ${isAre(fs)} open until then`)
333  return parts.map((p, i) => (i === 0 ? p : cap(p))).join('. ')
334}
335
336// The D0.2 {reset} while no kind is open (4.4), else {event}.
337const eventOr = (named: readonly Named[], open: readonly Facts[]): string =>
338  open.length === 0 ? resetText(named) : eventText(named, open)
339
340/** {at} of a list of Facts: the latest hold end, and the owner's {lead} when the owner has a span. */
341export function whenOf(f: Facts | readonly Facts[]): { at: string; lead?: string } {
342  const fs = listOf(f)
343  const times = fs.map(timeOf).filter((t): t is number => t !== null)
344  if (times.length === 0) return { at: 'the reset' }
345  const latest = Math.max(...times)
346  const at = atText(latest, fs.map(kindOf), fs[0]?.timeZone, fs.find((x) => x.now !== undefined)?.now)
347  const owner = fs.find((x) => x.span !== undefined && timeOf(x) === latest)
348  const lead = owner === undefined ? undefined : leadText(owner)
349  return lead === undefined ? { at } : { at, lead }
350}
351
352/**
353 * {at} of a stop's until, and the {lead} of the kind whose skip start it is. The lead only when `skip`
354 * (a skip owner) and a kind with a span has its skip start at `ms`.
355 */
356export function untilFor(
357  f: Facts | readonly Facts[],
358  ms: number,
359  kinds: readonly Kind[],
360  skip: boolean,
361  now?: number,
362): { at: string; lead?: string } {
363  const fs = listOf(f)
364  const at = atText(ms, kinds, fs[0]?.timeZone, now ?? fs.find((x) => x.now !== undefined)?.now)
365  if (!skip) return { at }
366  const owner = fs.find((x) => x.span !== undefined && x.resetsAtMs !== null && x.resetsAtMs - x.span === ms)
367  const lead = owner === undefined ? undefined : leadText(owner)
368  return lead === undefined ? { at } : { at, lead }
369}
370
371/** '16:20', or '16:20, 20 min before the reset' with a lead. */
372export const untilPhrase = (u: { at: string; lead?: string }): string => (u.lead === undefined ? u.at : `${u.at}, ${u.lead}`)
373
374/**
375 * B2, and the tell-mode prompt wording. With `auto`, what Stop here and no answer mean (2.2). A skip
376 * owner's question names where its time lies (skip 2.2), and says `at`, not `after`: there is no margin.
377 */
378export function questionText(f: Facts | readonly Facts[], opener: 'loop' | 'prompt', mode: Mode, auto = false): string {
379  const fs = listOf(f)
380  const head = `${cap(yourReserves(fs))} ${isAre(fs)} reached: ${personFacts(fs)}.`
381  const ask = `Continue on ${useText(fs)}?${asksPart(fs, mode)}`
382  const hold = opener === 'loop' ? 'All work is on hold.' : mode === 'tell' ? 'spare10 holds your prompt.' : 'spare10 holds your prompt and any other work.'
383  if (!auto) return `${head} ${hold} ${ask}`
384  const { at, lead } = whenOf(fs)
385  if (lead !== undefined) {
386    const when = `${at}, ${lead}`
387    const after =
388      opener === 'loop'
389        ? `If you choose Stop here or do not answer, the work waits until ${when}. Then spare10 continues it, unless a reserve is still reached.`
390        : mode === 'tell'
391          ? `If you do not answer, your prompt goes in at ${when}, unless a reserve is still reached. Stop here ${HOST.backIt}.`
392          : `If you do not answer, all of it continues at ${when}, unless a reserve is still reached. Stop here ${HOST.backPrompt} and pauses other work until ${at}.`
393    return `${head} ${hold} ${ask} ${after}`
394  }
395  const after =
396    opener === 'loop'
397      ? `If you choose Stop here or do not answer, the work waits until ${at}. Then spare10 continues it, unless a reserve is still reached.`
398      : mode === 'tell'
399        ? `If you do not answer, your prompt goes in after ${at}, unless a reserve is still reached. Stop here ${HOST.backIt}.`
400        : `If you do not answer, all of it continues after ${at}, unless a reserve is still reached. Stop here ${HOST.backPrompt} and pauses other work until ${at}.`
401  return `${head} ${hold} ${ask} ${after}`
402}
403
404/** The facts of a model text: only the kinds at the quota limit when some kind is, else all. */
405const modelList = (f: Facts | readonly Facts[]): Facts[] => {
406  const fs = listOf(f)
407  const at = fs.filter((x) => x.limit === true)
408  return at.length > 0 ? at : fs
409}
410
411/** Where work stopped, as a model text says it: the quota limit, or the quota reserve. */
412const placeOf = (fs: readonly Facts[]): string => (fs.some((x) => x.limit === true) ? 'quota limit' : 'quota reserve')
413
414/** The head of a text at the quota limit: which limit is reached. Both windows: the quota limits of both. */
415export function limitHead(f: Facts | readonly Facts[]): string {
416  const fs = listOf(f)
417  if (fs.length > 1) return 'The quota limits of both windows are reached'
418  return fs.some(isWeekly) ? 'The weekly quota limit is reached' : 'The quota limit is reached'
419}
420
421/**
422 * The limit question (100% used): what it holds, and what no answer and Stop here mean. {at} is the latest
423 * reset. `auto`: autoResume when it opened. No answer: with it on, the work continues after the reset, and
424 * with it off, the work waits for the answer. Stop here stops until the reset and continues nothing. The
425 * last sentence names the way out, for a reading that is out of date or for paid extra usage (LIMIT_OFF).
426 */
427export function limitQuestionText(f: Facts | readonly Facts[], opener: 'loop' | 'prompt', auto = false): string {
428  const fs = listOf(f)
429  const { at } = whenOf(fs)
430  const head = `${limitHead(fs)}: ${personFacts(fs)}.`
431  const back = `After the reset, type a prompt to continue. ${LIMIT_OFF}`
432  if (opener === 'loop') {
433    const none = auto ? `If you do not answer, the work waits until ${at}. Then spare10 continues it, unless a reserve is still reached.` : 'Until you answer, the work waits.'
434    return `${head} All work is on hold. Continue the work at the reset? ${none} Stop here stops the work. ${back}`
435  }
436  const none = auto ? `If you do not answer, all of it continues after ${at}, unless a reserve is still reached.` : 'Until you answer, all of it waits.'
437  return `${head} spare10 holds your prompt and any other work. Continue the work at the reset? ${none} Stop here ${HOST.backPrompt} and stops other work. ${back}`
438}
439
440/** B7 STOP: the deny text of a tool call. */
441export const stopText = (f: Facts | readonly Facts[]): string => {
442  const fs = modelList(f)
443  return `spare10: the user stopped work at the ${placeOf(fs)} (${modelFacts(fs)}). Stop now and wait for the user. Do not call any further tools.`
444}
445
446/** B7 PAUSED: the answer of a refused model request. */
447export const pausedText = (f: Facts | readonly Facts[]): string => {
448  const fs = modelList(f)
449  return `spare10: work stopped at the ${placeOf(fs)} (${modelFacts(fs)}). No model request was sent, so this task is not finished. Wait for the user.`
450}
451
452/** B15 HEADLESS. */
453export const headlessText = (f: Facts | readonly Facts[], sessionId: string): string => {
454  const fs = modelList(f)
455  return `spare10 stopped this unattended run at the ${placeOf(fs)} (${modelFacts(fs)}). No further model requests were sent. To pick it up later: ${HOST.resume} ${sessionId}`
456}
457
458/** B12: spare10's template. Without user text there is no User instructions paragraph. A kind at the floor: the floor sentence. */
459export function pauseInstruction(f: Facts | readonly Facts[], pausePrompt: string | null): string {
460  const fs = modelList(f)
461  const reached = fs.some((x) => x.limit === true)
462    ? 'You have reached the quota limit for this session'
463    : fs.some((x) => x.floor !== undefined)
464      ? 'You have reached the floor of the quota reserve for this session'
465      : 'You have reached the safe usage limit for this session'
466  const head =
467    `spare10 budget guard. ${reached} (${modelFacts(fs)}). ` +
468    'Immediately wrap up your work and stop. Immediately stop any subagent, unless the user instructs otherwise.'
469  return pausePrompt === null || pausePrompt.trim() === '' ? head : `${head}\n\nUser instructions: ${pausePrompt}`
470}
471
472/** B9: the hidden context of a resumed prompt in a stopped session. */
473export function resumeContext(f: Facts | readonly Facts[]): string {
474  const fs = listOf(f)
475  return `spare10: earlier work stopped at the ${quotaReserve(fs)}. The user now chose to continue on ${useText(fs)}. Follow their message.`
476}
477
478/** B10: the reason of a dropped prompt. At the quota limit it names only the kinds at the limit. */
479export function notStarted(f: Facts | readonly Facts[]): string {
480  const at = listOf(f).filter((x) => x.limit === true)
481  if (at.length > 0) return `spare10: not started. ${limitHead(at)} until ${whenOf(at).at}. Send the prompt again after the reset.`
482  const fs = listOf(f)
483  return `spare10: not started. This session is inside ${yourReserves(fs)} ${untilText(fs)}. Send the prompt again to be asked again, or run ${HOST.command} resume.`
484}
485
486/** B35: the hidden context of a person prompt that ends a stop with work after the reset, or after the reserve opened. */
487export function resetContext(named: readonly Named[], open: readonly Facts[] = []): string {
488  const tail = "The stopped task is not finished. After the user's message, continue it unless the user says otherwise."
489  if (open.length === 0) return `spare10: earlier work stopped at the quota reserve. ${resetText(named, true)} since then, so the stop is over. ${tail}`
490  return `spare10: earlier work stopped at the quota reserve. ${cap(eventText(named, open))}, so the stop is over. ${tail}`
491}
492
493const RESUME_TAIL = `Continue the task from the point where it stopped. ${HOST.stoppedAgent} Run it again if you still need its result.`
494
495/** B34: the plugin prompt at the reset, or when the reserve opens. The engine frames it, so it has no prefix. */
496export function resumePrompt(named: readonly Named[], open: readonly Facts[] = []): string {
497  if (open.length === 0) {
498    return `${resetText(named, true)}, so the stop at the quota reserve is over. spare10 is set to continue the work at the reset, so do not wait for the user. ${RESUME_TAIL}`
499  }
500  return `${cap(eventText(named, open))}, so the stop at the quota reserve is over. spare10 is set to continue the work when the reserve opens, so do not wait for the user. ${RESUME_TAIL}`
501}
502
503/** The deny that withdraws this copy's dialog. */
504export const withdrawnText = (outcome: string | undefined): string => `spare10: withdrawn (${outcome ?? 'closed'})`
505
506/** B27 */
507export function badWarning(
508  name:
509    | 'SPARE10_RESERVE'
510    | 'SPARE10_WEEKLY_RESERVE'
511    | 'SPARE10_LAST_MINUTES'
512    | 'SPARE10_WEEKLY_LAST_HOURS'
513    | 'SPARE10_RESUME_FLOOR'
514    | 'SPARE10_WEEKLY_RESUME_FLOOR'
515    | 'SPARE10_AUTO_RESUME'
516    | 'SPARE10_LIMIT_PAUSE'
517    | 'SPARE10_HEADLESS'
518    | 'SPARE10',
519  raw: string,
520  used: string,
521): string {
522  if (name === 'SPARE10_RESERVE') return `SPARE10_RESERVE="${raw}" is not 1 to 99. spare10 uses ${used}.`
523  if (name === 'SPARE10_WEEKLY_RESERVE') return `SPARE10_WEEKLY_RESERVE="${raw}" is not 0 or 1 to 99. spare10 uses ${used}.`
524  if (name === 'SPARE10_LAST_MINUTES') return `SPARE10_LAST_MINUTES="${raw}" is not 0 to 299. spare10 uses ${used}.`
525  if (name === 'SPARE10_WEEKLY_LAST_HOURS') return `SPARE10_WEEKLY_LAST_HOURS="${raw}" is not 0 to 167. spare10 uses ${used}.`
526  if (name === 'SPARE10_RESUME_FLOOR') return `SPARE10_RESUME_FLOOR="${raw}" is not 0 to 99. spare10 uses ${used}.`
527  if (name === 'SPARE10_WEEKLY_RESUME_FLOOR') return `SPARE10_WEEKLY_RESUME_FLOOR="${raw}" is not 0 to 99. spare10 uses ${used}.`
528  if (name === 'SPARE10_AUTO_RESUME') return `SPARE10_AUTO_RESUME="${raw}" is not on or off. spare10 uses ${used}.`
529  if (name === 'SPARE10_LIMIT_PAUSE') return `SPARE10_LIMIT_PAUSE="${raw}" is not on or off. spare10 uses ${used}.`
530  if (name === 'SPARE10_HEADLESS') return `SPARE10_HEADLESS="${raw}" is not off, prompt, stop or wait. spare10 uses ${used}.`
531  return `SPARE10="${raw}" is not on or off. spare10 uses the scope option (${used}).`
532}
533
534/**
535 * B27 for SPARE10_SIMULATE: a value that is not a test reading, or a weekly test reading while the weekly
536 * reserve is 0. The second stays set: a weekly reserve that the person sets later puts it in force.
537 * Never "names the weekly window": on a Codex weekly-only plan a value with no kind word is weekly too.
538 */
539export const simulateWarning = (raw: string, weeklyOff = false): string =>
540  weeklyOff
541    ? `SPARE10_SIMULATE="${raw}" is a weekly test reading, and the weekly reserve is 0. spare10 uses it only when the weekly reserve is more than 0.`
542    : `SPARE10_SIMULATE="${raw}" is not a test reading. spare10 uses none.`
543
544/** B54: a floor at or above its reserve does nothing. */
545export const floorWarning = (kind: Kind, floor: number, reserve: number): string =>
546  kind === 'seven_day'
547    ? `the weekly resume floor (${fmtPct(floor)}%) is not below the weekly reserve (${fmtPct(reserve)}%), so it does nothing. Set it below the weekly reserve, or to 0.`
548    : `the resume floor (${fmtPct(floor)}%) is not below the reserve (${fmtPct(reserve)}%), so it does nothing. Set it below the reserve, or to 0.`
549
550/** B29 */
551export const timeoutWarning = (name: string, auto = false): string =>
552  auto
553    ? `questions here continue by themselves after a time limit (${name}). An unanswered spare10 question then counts as Stop here, and spare10 continues the work at the time that the question names.`
554    : `questions here continue by themselves after a time limit (${name}). An unanswered spare10 question then counts as Stop here.`
555
556/** B30 */
557export const consentWarning = (raw: string, kind: Kind = 'five_hour'): string =>
558  kind === 'seven_day'
559    ? `SPARE10_WEEKLY_CONSENT="${raw}" names a time after this weekly window. spare10 ignores it.`
560    : `SPARE10_CONSENT="${raw}" names a time after this 5-hour window. spare10 ignores it.`
561
562/** B31: SPARE10 switches in a --bg session come from the daemon or a settings file (9.3). */
563export const bgEnvWarning = (set: ReadonlyArray<readonly [string, string]>): string =>
564  `this background session has ${set.map(([name, raw]) => `${name}=${JSON.stringify(raw)}`).join(', ')}. ` +
565  'A background session gets such values from the claude daemon or a settings file, not from your terminal.'
566
567/** The way back after a stop: a new prompt asks again, or the resume command. */
568const AGAIN = `Type a prompt to be asked again, or run ${HOST.command} resume.`
569
570/** Work of the session is held in place under a stop (Codex design 4.19, 4.20): it waits, and the resume command continues it now. */
571export const HELD_WAITS = `Held work waits. Run ${HOST.anytime} resume to continue it now.`
572
573/** A stop's time: {at}, or {at} and the owner's {lead}, and whether spare10 continues the work then. */
574type AutoAt = { at: string; work: boolean; lead?: string }
575
576/**
577 * Transcript notices (B3, B4, B6, B12, 2.4). The engine prefixes them with `spare10: `. Each reset
578 * notice takes an optional list of open kinds (skip 2.4): with none, it is the D0.2 text.
579 */
580export const notice = {
581  /** Floor 2.4: with an end point on some kind, one part per kind and {asks}. Else the 0.2 text. */
582  continuing: (f: Facts | readonly Facts[], mode: Mode = 'hold'): string => {
583    const fs = listOf(f)
584    if (!fs.some((x) => x.to !== undefined)) return `continuing on ${yourReserves(fs)}. spare10 stays quiet ${untilText(fs)}.`
585    const parts = fs.map((x) => `your ${reserveName(x)} ${x.to !== undefined ? `until ${fmtPct(x.to)}% used` : untilText(x)}`)
586    return `continuing on ${parts.join(' and ')}.${asksPart(fs, mode)}`
587  },
588  newWindow: 'held work continues on the new 5-hour window.',
589  newWindowFor: (kinds: readonly Kind[]): string => `held work continues on the new ${windowNames(kinds)}.`,
590  stopped: (f: Facts | readonly Facts[], auto?: AutoAt): string => {
591    const rs = yourReserves(listOf(f))
592    if (auto === undefined) return `stopped at ${rs}. ${AGAIN}`
593    if (!auto.work) return `stopped at ${rs} until ${untilPhrase(auto)}. ${AGAIN}`
594    return `stopped at ${rs} until ${untilPhrase(auto)}. Then spare10 continues the work, unless a reserve is still reached. ${AGAIN}`
595  },
596  holdLimit: (f: Facts | readonly Facts[], auto?: AutoAt): string => {
597    const rs = yourReserves(listOf(f))
598    if (auto === undefined) return `the hold reached its time limit. The work is stopped at ${rs}. ${AGAIN}`
599    if (!auto.work) return `the hold reached its time limit. The work is stopped at ${rs} until ${untilPhrase(auto)}. ${AGAIN}`
600    return `the hold reached its time limit. The work is stopped at ${rs} until ${untilPhrase(auto)}. Then spare10 continues it, unless a reserve is still reached.`
601  },
602  /** B46: Stop here after the skip start. soon: the next tick continues the work. Else nothing is stopped. */
603  stoppedLate: (f: Facts | readonly Facts[], e: Ended, soon: boolean): string => {
604    const ev = cap(eventText(e.reset, e.open))
605    if (soon) return `stopped at ${yourReserves(listOf(f))}. ${ev}, so spare10 continues the work soon, unless a reserve is still reached.`
606    return `stopped. Held work is refused. ${ev}, so new work goes on with no question.`
607  },
608  /** B46 through the B40 time limit. */
609  holdLimitLate: (f: Facts | readonly Facts[], e: Ended, soon: boolean): string => {
610    const ev = cap(eventText(e.reset, e.open))
611    if (soon) {
612      return `the hold reached its time limit. The work is stopped at ${yourReserves(listOf(f))}. ${ev}, so spare10 continues it soon, unless a reserve is still reached.`
613    }
614    return `the hold reached its time limit. Held work is refused. ${ev}, so new work goes on with no question.`
615  },
616  resetWaiting: 'the 5-hour window reset. Held work still waits for your answer.',
617  /** `newWork`: no kind gates now, so new work goes on. False while another window still holds new work. */
618  resetWaitingFor: (named: readonly Named[], open: readonly Facts[] = [], newWork = open.length > 0): string =>
619    open.length === 0
620      ? `${resetText(named)}. Held work still waits for your answer.`
621      : `${eventText(named, open)}, but held work still waits for your answer.${newWork ? ' New work goes on with no question.' : ''}`,
622  told: (f: Facts | readonly Facts[]): string => {
623    const fs = listOf(f)
624    return `${yourReserves(fs)} ${isAre(fs)} reached. spare10 told the agents to wind down.`
625  },
626  resetContinues: (named: readonly Named[], open: readonly Facts[] = []): string => `${eventOr(named, open)}. Held work continues.`,
627  resetStillHeld: (named: readonly Named[], still: readonly Facts[], open: readonly Facts[] = []): string => {
628    const fs = listOf(still)
629    const tail = `${yourReserves(fs)} ${isAre(fs)} reached. Held work still waits.`
630    return named.length === 0 && open.length === 0 ? tail : `${eventOr(named, open)}, but ${tail}`
631  },
632  outOfReserve: 'the quota is no longer in the reserve. Held work continues.',
633  resetResumes: (named: readonly Named[], open: readonly Facts[] = []): string => `${eventOr(named, open)}. spare10 continues the stopped work.`,
634  resetStopOver: (named: readonly Named[], open: readonly Facts[] = []): string =>
635    `${eventOr(named, open)}, and the stop is over. Type a prompt to continue.`,
636  stopTakenOver: (named: readonly Named[], open: readonly Facts[] = []): string =>
637    named.length === 0 && open.length === 0 ? 'the stop is over.' : `${eventOr(named, open)}, and the stop is over.`,
638  stopExtended: (named: readonly Named[], still: readonly Facts[], at: string, open: readonly Facts[] = []): string => {
639    const fs = listOf(still)
640    const tail = `${yourReserves(fs)} ${isAre(fs)} reached. The stop lasts until ${at}.`
641    return named.length === 0 && open.length === 0 ? tail : `${eventOr(named, open)}, but ${tail}`
642  },
643  resumeFailed: (reason: string): string => `could not continue the stopped work: ${reason}. Type a prompt to continue.`,
644  /** Continue at the reset on the limit question: held work waits for the reset. It names the way out (LIMIT_OFF). */
645  limitContinues: (f: Facts | readonly Facts[]): string =>
646    `held work waits until ${whenOf(f).at}. Then spare10 continues it, unless a reserve is still reached. ${LIMIT_OFF}`,
647  /** A person prompt after Continue at the reset: it waits with the held work, and asks nothing. */
648  limitPromptWaits: (f: Facts | readonly Facts[]): string =>
649    `your prompt waits with the held work until ${whenOf(f).at}. Then spare10 continues all of it, unless a reserve is still reached.`,
650  /**
651   * A stop at the quota limit: Stop here, or a question at the reserve that ends with no answer at the limit.
652   * `cont`: the stop continues the work at the reset. No `at`: the reset has passed, so the line names no time.
653   */
654  limitStopped: (at?: string, cont = false): string =>
655    at === undefined
656      ? 'stopped at the quota limit. Type a prompt to continue.'
657      : cont
658        ? `stopped at the quota limit until ${at}. Then spare10 continues the work, unless a reserve is still reached.`
659        : `stopped at the quota limit until ${at}. After the reset, type a prompt to continue.`,
660  /** The hold time limit at the quota limit. `cont`: the stop continues the work at the reset. No `at` (only without `cont`): the reset has passed. */
661  limitHoldLimit: (at: string | undefined, cont: boolean): string =>
662    at === undefined
663      ? 'the hold reached its time limit. The work is stopped at the quota limit. Type a prompt to continue.'
664      : cont
665        ? `the hold reached its time limit. The work is stopped at the quota limit until ${at}. Then spare10 continues it, unless a reserve is still reached.`
666        : `the hold reached its time limit. The work is stopped at the quota limit until ${at}. After the reset, type a prompt to continue.`,
667  /** A question at the reserve gives way to the limit question. */
668  limitReached: 'the quota limit is reached. spare10 asks you again.',
669  /** A limit question ends before its reset: no kind that gates is at the quota limit now. */
670  limitOver: 'the pause at the limit is over. Held work continues, unless a reserve is still reached.',
671}
672
673/** Debug lines (B12, B15, 2.5, 4.5, 4.7, 10.9.6). The engine does not prefix them, so they keep `spare10: `. */
674export const debugLine = {
675  unattended: (f: Facts | readonly Facts[], policy: Headless): string =>
676    `spare10: unattended run inside the reserve (${personFacts(f)}), policy ${policy}.`,
677  unattendedOpen: (f: Facts | readonly Facts[]): string =>
678    `spare10: unattended run inside the reserve (${personFacts(f)}), but the reset is near. spare10 lets it through.`,
679  told: (key: string): string => `spare10: told ${key}`,
680  handedOn: (n: number): string => `spare10: the loop that asked went away. spare10 asks again (${n}).`,
681  abortFailed: (err: string): string => `spare10: turn.abort failed: ${err}`,
682  droppedStop: 'spare10: a stop of another conversation ended at its reset. spare10 dropped it.',
683  resumeSkipped: 'spare10: the conversation changed before the resume prompt. spare10 sent nothing.',
684  boxDefer: (n: number): string => `spare10: the prompt box has text. The resume prompt waits (${n} of 10).`,
685  budget: (min: number, ms: number): string => `spare10: held ${min} min. Budget left ${ms} ms.`,
686  checkFailed: (err: string): string => `spare10: the reset check did not run: ${err}`,
687  settleFailed: (err: string): string => `spare10: could not write the answer: ${err}`,
688  startFailed: (err: string): string => `spare10: a step of the session start failed: ${err}`,
689}
690
691export type StatusInput = {
692  phase: Phase
693  mode: Mode
694  reserve: number
695  reserveFrom: Source
696  pausePrompt: string | null
697  attended: boolean
698  headless: Headless
699  headlessFrom: Source
700  childPolicy: string // live SPARE10_HEADLESS, else the option
701  enabled: boolean
702  enabledFrom: 'scope' | 'SPARE10'
703  scope: Scope
704  basis: Basis
705  facts?: Facts // facts when basis is not 'none'
706  now: number
707  consentUntil?: number // only when it covers this window
708  toldCount: number
709  warnings: string[]
710  timeZone?: string
711  // 0.2. Every field below is optional: absent gives the 0.1 report.
712  weekly?:
713    | { reserve: number; from: Source; basis: Basis; facts?: Facts; consentUntil?: number; consentTo?: number; consentEnded?: boolean }
714    | 'off' // reserve 0 is off too
715  autoResume?: { on: boolean; from: Source }
716  limitPause?: { on: boolean; from: Source } // the pause at the quota limit. Its row shows only while it is off
717  limit?: { ms: number; kinds: readonly Kind[]; held: boolean } // the limit phase: when held work continues. held: a limit question after Continue at the reset. The stopped phase: the reset of the kinds at the limit
718  at?: { ms: number; kinds: readonly Kind[]; skip?: boolean } // when an open question or a stop continues. skip: a skip start ('at', not 'after')
719  work?: boolean // the stop has work
720  autoStop?: boolean // the stop has auto, and autoResume is on
721  tickerStale?: boolean // the reset clock is wanted and its last step is more than 90 s old
722  // Skip near the reset. Absent: no rows and the D0.2 lines.
723  spans?: { lastMinutes: number; lastMinutesFrom: SpanSource; weeklyLastHours: number; weeklyLastHoursFrom: SpanSource }
724  open?: readonly Facts[] // the open kinds: the open phase line, and the asking line
725  skipStop?: boolean // the stop that applies has the skip tag
726  // The resume floor (floor 2.7). Absent: no floor rows and the 0.2 help line.
727  floors?: { resumeFloor: number; resumeFloorFrom: Source; weeklyResumeFloor: number; weeklyResumeFloorFrom: Source }
728  consentTo?: number // the end point now of the 5-hour consent to the floor, in force or ended
729  consentEnded?: boolean // that consent reached its end point (consentUntil is then absent)
730  consented?: readonly Facts[] // the consented kinds, `to` set from their covering consents: the phase line {asks}
731  // Host inputs (Codex design 2.1). Claude passes none of them, so its report does not change.
732  absent?: readonly Kind[] // the kinds the host reports no window for (CX17)
733  heldInPlace?: boolean // work of this session is held in place under a stop: the stopped line says so
734  extraRows?: ReadonlyArray<readonly [string, string]> // rows after the claude -p (or unattended) row, as [label, value]
735  extraHelp?: readonly string[] // lines after the two help lines
736}
737
738const GLYPH: Record<Phase, string> = {
739  off: '○',
740  blind: '⚠',
741  waiting: '⧗',
742  armed: '●',
743  limit: '‖',
744  consented: '⨯',
745  open: '↻',
746  stopped: '■',
747  asking: '?',
748  told: '⏸',
749  reserve: '⚠',
750  tripped: '⚠',
751}
752
753/** D4: the phase detail and every field value start in one column: two spaces, a mark, a space, this width. */
754const LABEL_WIDTH = 15
755
756type Weekly = Exclude<StatusInput['weekly'], 'off' | undefined>
757
758/** The weekly window when it is watched. */
759const watchedWeekly = (s: StatusInput): Weekly | undefined =>
760  s.weekly !== undefined && s.weekly !== 'off' && s.weekly.reserve > 0 ? s.weekly : undefined
761
762/** The consented part of the consented phase line: {quiet} of the consented kinds. */
763function quietOf(s: StatusInput): string {
764  const ends: Array<[number, Kind]> = []
765  if (s.consentUntil !== undefined) ends.push([s.consentUntil, 'five_hour'])
766  const w = watchedWeekly(s)
767  if (w?.consentUntil !== undefined) ends.push([w.consentUntil, 'seven_day'])
768  const clocks = ends.map(([ms, kind]) => clockText(ms, kind, s.timeZone, s.now))
769  if (clocks.length === 1) return `until ${clocks[0] ?? ''}`
770  if (clocks.length > 1) return `until they reset (${clocks.join(' and ')})`
771  return s.facts ? untilText(s.facts) : UNTIL_RESET
772}
773
774function phaseLine(s: StatusInput): string {
775  const at = s.at === undefined ? undefined : atText(s.at.ms, s.at.kinds, s.timeZone, s.now)
776  // A stop at the quota limit: the resume command changes nothing until the reset, so the line names the reset and the way out.
777  const limitClock = s.phase === 'stopped' && s.limit !== undefined ? atText(s.limit.ms, s.limit.kinds, s.timeZone, s.now) : undefined
778  const again = limitClock !== undefined ? `Type a prompt to be asked again. ${LIMIT_OFF}` : s.heldInPlace === true ? HELD_WAITS : AGAIN
779  const open = s.open === undefined ? [] : listOf(s.open)
780  const openRs = open.length === 0 ? '' : `${cap(yourReserves(open))} ${isAre(open)} open ${untilText(open)}`
781  const stopped =
782    at === undefined
783      ? limitClock === undefined
784        ? `you chose Stop here. ${again}`
785        : `you chose Stop here, until ${limitClock}. ${again}`
786      : s.skipStop === true
787        ? s.work === true && s.autoStop === true
788          ? `you chose Stop here. spare10 continues the work at ${at}. ${again}`
789          : `you chose Stop here, until ${at}. ${again}`
790        : s.autoStop !== true
791          ? `you chose Stop here. ${again}`
792          : s.work === true
793            ? `you chose Stop here. spare10 continues the work after ${at}. ${again}`
794            : `you chose Stop here, until ${at}. ${again}`
795  const openNote = openRs === '' ? '' : ` ${openRs}, so new work goes on.`
796  const asking =
797    at === undefined || s.autoResume?.on !== true
798      ? `a question is open. Held work waits until you answer.${openNote} If no ${HOST.dialog} shows, run ${HOST.anytime} resume or ${HOST.anytime} stop.`
799      : `a question is open. Held work waits until you answer, or until ${at}.${openNote} If no ${HOST.dialog} shows, run ${HOST.anytime} resume or ${HOST.anytime} stop.`
800  const detail: Record<Phase, string> = {
801    off: 'spare10 only watches in this run.',
802    blind: `${HOST.blind} spare10 lets all work through.`,
803    waiting: 'no reading yet. spare10 lets all work through.',
804    armed: stepsIn(s.reserve, absentKind(s, 'seven_day') ? undefined : watchedWeekly(s)?.reserve, absentKind(s, 'five_hour')),
805    limit: limitLine(s),
806    consented:
807      s.consented !== undefined && s.consented.some((f) => f.to !== undefined)
808        ? `you chose to continue. ${asksText(s.consented, s.mode)}`
809        : `you chose to continue. spare10 is quiet ${quietOf(s)}.`,
810    open: openRs === '' ? 'the reset is near, so spare10 lets all work through.' : `the reset is near. ${openRs}, so spare10 lets all work through.`,
811    stopped,
812    asking,
813    told: `the wind-down went to ${s.toldCount} agent(s).`,
814    reserve:
815      s.headless === 'wait' && at !== undefined
816        ? `unattended run, policy wait. Held work continues ${s.at?.skip === true ? 'at' : 'after'} ${at}.`
817        : `unattended run, policy ${s.headless}.`,
818    tripped:
819      s.mode === 'tell'
820        ? 'spare10 tells each agent to wind down at its next step.'
821        : 'spare10 holds the next step and asks you.',
822  }
823  const name = s.phase === 'reserve' ? 'tripped' : s.phase
824  return `  ${GLYPH[s.phase]} ${name.padEnd(LABEL_WIDTH)}${detail[s.phase]}`
825}
826
827/**
828 * The limit phase line: held work waits for the reset, or the next step holds and asks. Both name the way
829 * out, for a reading that is out of date or for paid extra usage (how-it-works items 58 and 59).
830 */
831function limitLine(s: StatusInput): string {
832  const l = s.limit
833  const at = l === undefined ? 'the reset' : atText(l.ms, l.kinds, s.timeZone, s.now)
834  if (l?.held === true) return `the quota limit is reached. Held work waits until ${at}. Then spare10 continues it, unless a reserve is still reached. ${LIMIT_OFF}`
835  return `the quota limit is reached until ${at}. spare10 holds the next step and asks you. ${LIMIT_OFF}`
836}
837
838/** The host reports no window of this kind (CX17). */
839const absentKind = (s: Pick<StatusInput, 'absent'>, kind: Kind): boolean => s.absent?.includes(kind) === true
840
841/** The reading row. `absent`: the host reports no window of this kind for this plan (CX17). */
842function readingValue(b: Basis, f: Facts, now: number, absent = false, kind: Kind = 'five_hour'): string {
843  if (absent) return `none: ${HOST.name} reports no ${kind === 'seven_day' ? 'weekly' : '5-hour'} window for this plan`
844  if (b.kind === 'none') {
845    if (b.why === 'blind') return `none: ${HOST.name} reports no quota (blind)`
846    return b.why === 'window-reset' ? 'none: the window reset' : 'none: no reading yet'
847  }
848  const src = b.kind === 'live' ? 'live' : b.kind === 'seed' ? 'seed from another session' : 'test reading'
849  // D2: {pf} already says `resets HH:MM`, so the time left is in brackets, not a second `resets`.
850  const left = b.resetsAtMs === null ? '' : ` (in ${fmtDuration(b.resetsAtMs - now)})`
851  return `${src} · ${personFacts(f)}${left}`
852}
853
854function guardedValue(s: StatusInput): string {
855  if (!s.enabled) {
856    return s.enabledFrom === 'SPARE10' ? 'no: SPARE10=off. spare10 only watches.' : `no: scope opt-in. ${HOST.optIn}`
857  }
858  if (!s.attended) return 'no: this session is unattended.'
859  return s.enabledFrom === 'SPARE10' ? 'yes (SPARE10=on)' : 'yes (scope all)'
860}
861
862function actionValue(s: StatusInput): string {
863  if (!s.attended) return `unattended policy ${s.headless}`
864  return s.mode === 'tell' ? `tell every agent: ${JSON.stringify(s.pausePrompt ?? '')}` : 'stop and ask you'
865}
866
867const field = (label: string, value: string): string => `  · ${label.padEnd(LABEL_WIDTH)}${value}`
868const fromText = (from: Source, env: string): string => (from === 'env' ? `from ${env}` : `from ${HOST.config}`)
869const spanFrom = (from: SpanSource, env: string): string => (from === 'unread' ? `spare10 could not read ${HOST.unreadSource}` : fromText(from, env))
870
871/** Skip 2.7: the `reserve opens` row, and the `weekly opens` row while the weekly window is watched. */
872function spanRows(s: StatusInput): string[] {
873  const sp = s.spans
874  if (sp === undefined) return []
875  const five =
876    sp.lastMinutes > 0
877      ? `in the last ${fmtPct(sp.lastMinutes)} min of the 5-hour window (${spanFrom(sp.lastMinutesFrom, 'SPARE10_LAST_MINUTES')})`
878      : `only at the reset (${spanFrom(sp.lastMinutesFrom, 'SPARE10_LAST_MINUTES')})`
879  const rows = [field('reserve opens', five)]
880  if (watchedWeekly(s) !== undefined) {
881    const week =
882      sp.weeklyLastHours > 0
883        ? `in the last ${fmtPct(sp.weeklyLastHours)} h of the weekly window (${spanFrom(sp.weeklyLastHoursFrom, 'SPARE10_WEEKLY_LAST_HOURS')})`
884        : `only at the reset (${spanFrom(sp.weeklyLastHoursFrom, 'SPARE10_WEEKLY_LAST_HOURS')})`
885    rows.push(field('weekly opens', week))
886  }
887  return rows
888}
889
890/** 2.7: the weekly rows, or the one off row, or nothing for a 0.1 input. */
891function weeklyRows(s: StatusInput): { reserve: string[]; reading: string[]; consent: string[] } {
892  const none = { reserve: [], reading: [], consent: [] }
893  if (s.weekly === undefined) return none
894  const w = watchedWeekly(s)
895  if (w === undefined) {
896    const from = s.weekly === 'off' ? 'option' : s.weekly.from
897    return { ...none, reserve: [field('weekly reserve', `off. spare10 does not watch the weekly window (${fromText(from, 'SPARE10_WEEKLY_RESERVE')})`)] }
898  }
899  const f = w.facts ?? factsOf(w.basis, w.reserve, s.timeZone, 'seven_day', s.now)
900  const consent = consentValue(
901    w.consentUntil === undefined ? undefined : clockText(w.consentUntil, 'seven_day', s.timeZone, s.now),
902    w.consentTo,
903    w.consentEnded,
904  )
905  return {
906    reserve: [field('weekly reserve', `${fmtPct(w.reserve)}% of the weekly window (${fromText(w.from, 'SPARE10_WEEKLY_RESERVE')})`)],
907    reading: [field('weekly reading', readingValue(w.basis, f, s.now, absentKind(s, 'seven_day'), 'seven_day'))],
908    consent: [field('weekly consent', consent)],
909  }
910}
911
912/** Floor 2.7: a consent row. Full: until the reset. To the floor: its end point or the reset. Ended: at its end point. */
913function consentValue(until: string | undefined, to: number | undefined, ended: boolean | undefined): string {
914  if (ended === true && to !== undefined) return `ended at ${fmtPct(to)}% used (you chose to continue until then)`
915  if (until === undefined) return 'none'
916  return to === undefined ? `until ${until} (you chose to continue)` : `until ${fmtPct(to)}% used or ${until} (you chose to continue)`
917}
918
919/** Floor 2.7: the floor is in force for a kind: an attended run, and 0 < floor < reserve. */
920const floorInForce = (floor: number, reserve: number, attended: boolean): boolean => attended && floor > 0 && floor < reserve
921
922/** Floor 2.7: the `resume floor` row, and the `weekly floor` row while the weekly window is watched. */
923function floorRows(s: StatusInput): string[] {
924  const fl = s.floors
925  if (fl === undefined) return []
926  const verb = s.mode === 'tell' ? 'spare10 tells the agents to wind down' : 'spare10 asks again'
927  const row = (floor: number, reserve: number, from: string, weekly: boolean): string => {
928    if (floor <= 0) return `off. A Resume lasts until the ${weekly ? 'weekly reset' : 'reset'} (${from})`
929    if (floor >= reserve) return `${fmtPct(floor)}% does nothing, because it is not below the ${weekly ? 'weekly reserve' : 'reserve'} (${from})`
930    if (!s.attended) return `${fmtPct(floor)}%: this run is unattended and never asks, so the floor does nothing (${from})`
931    return `${fmtPct(floor)}%: after a Resume, ${verb} at ${pointText(Math.round((100 - floor) * 10) / 10, weekly ? 'seven_day' : 'five_hour')} (${from})`
932  }
933  const rows = [field('resume floor', row(fl.resumeFloor, s.reserve, fromText(fl.resumeFloorFrom, 'SPARE10_RESUME_FLOOR'), false))]
934  const w = watchedWeekly(s)
935  if (w !== undefined) {
936    rows.push(field('weekly floor', row(fl.weeklyResumeFloor, w.reserve, fromText(fl.weeklyResumeFloorFrom, 'SPARE10_WEEKLY_RESUME_FLOOR'), true)))
937  }
938  return rows
939}
940
941/** Floor 2.7: the help line names the floor while a floor is in force for a watched kind. */
942function floorHelp(s: StatusInput): boolean {
943  const fl = s.floors
944  if (fl === undefined) return false
945  const w = watchedWeekly(s)
946  return floorInForce(fl.resumeFloor, s.reserve, s.attended) || (w !== undefined && floorInForce(fl.weeklyResumeFloor, w.reserve, s.attended))
947}
948
949// The warning of 2.7 when the reset clock does not run.
950const TICKER_WARNING = 'spare10 cannot check the reset in this session. Type a prompt to continue after the reset.'
951
952/**
953 * B22: what /spare10 prints. The engine puts `spare10: ` in front of the reply, so the first line
954 * reads `spare10: version {VERSION}` (a deviation from B22's `spare10 {VERSION}`, defect D1).
955 */
956export function statusReport(s: StatusInput): string {
957  const consent = consentValue(s.consentUntil === undefined ? undefined : formatClock(s.consentUntil, s.timeZone), s.consentTo, s.consentEnded)
958  const weekly = weeklyRows(s)
959  const atReset =
960    s.autoResume === undefined || !s.attended
961      ? []
962      : [field('at the reset', `${s.autoResume.on ? 'continue by itself' : 'wait for your answer'} (${fromText(s.autoResume.from, 'SPARE10_AUTO_RESUME')})`)]
963  // The pause at the quota limit shows only while it is off, so a default report stays as it was.
964  const atLimit = s.limitPause === undefined || s.limitPause.on ? [] : [field('at the limit', `off. spare10 does not pause at the limit (${fromText(s.limitPause.from, 'SPARE10_LIMIT_PAUSE')})`)]
965  const lines = [
966    `version ${VERSION}`,
967    '',
968    phaseLine(s),
969    field('reserve', `${fmtPct(s.reserve)}% of the 5-hour window (${fromText(s.reserveFrom, 'SPARE10_RESERVE')})`),
970    ...weekly.reserve,
971    ...spanRows(s),
972    ...floorRows(s),
973    field('at the reserve', actionValue(s)),
974    ...atReset,
975    ...atLimit,
976    field('reading', readingValue(s.basis, s.facts ?? factsOf(s.basis, s.reserve, s.timeZone), s.now, absentKind(s, 'five_hour'))),
977    ...weekly.reading,
978    field('consent', consent),
979    ...weekly.consent,
980    field('guarded', guardedValue(s)),
981    s.attended
982      ? field(HOST.child, `runs started here: ${s.childPolicy}`)
983      : field('unattended', `${s.headless} (${fromText(s.headlessFrom, 'SPARE10_HEADLESS')})`),
984    ...(s.extraRows ?? []).map(([label, value]) => field(label, value)),
985    ...(s.tickerStale === true ? [`  ⚠ ${TICKER_WARNING}`] : []),
986    ...s.warnings.map((w) => `  ⚠ ${w}`),
987    '',
988    floorHelp(s)
989      ? `${`${HOST.command} resume`.padEnd(18)}continue on the reserve until the floor, or past the floor until the reset`
990      : `${`${HOST.command} resume`.padEnd(18)}continue on the reserve until the window resets`,
991    `${`${HOST.command} stop`.padEnd(18)}stop at the reserve now`,
992    ...(s.extraHelp ?? []),
993  ]
994  return lines.join('\n')
995}
996
997export type ReplyCase = 'asking' | 'stopped' | 'tripped' | 'consented' | 'below' | 'none' | 'off'
998
999// The replies of /spare10 (B23 to B26, 12.1, 2.8). The engine prefixes each reply with `spare10: `.
1000
1001/** {Rs} {is} open {quiet} of the open kinds, for the open replies (skip 2.8). */
1002const openText = (fs: readonly Facts[]): string =>
1003  fs.length === 0 ? 'the reserve is open' : `${yourReserves(fs)} ${isAre(fs)} open ${untilText(fs)}`
1004
1005/** Floor 2.8: one part of the consented reply: its end point, or its reset. */
1006const resumedPart = (f: Facts): string => {
1007  if (f.to !== undefined) return `until ${pointText(f.to, kindOf(f))}`
1008  return isWeekly(f) ? `${untilText(f)} on the weekly window` : untilText(f)
1009}
1010
1011/**
1012 * B23 ('tripped' is "tripped, not stopped"). 'overdue': a stop past its end that nobody released yet,
1013 * with the windows that reset and the open ones. 'open': no kind gates and some kind is open (`f`: the
1014 * open kinds). `absent` (Codex design 2.1): the kinds the host reports no window for. With five_hour in
1015 * it, a reply with no figures names no 5-hour reading. 'limit-late': a resume settles a limit question
1016 * whose reset has passed (it waited for the answer), so the reply names no time.
1017 */
1018export function resumeReply(
1019  c: ReplyCase | 'overdue' | 'open' | 'limit-asking' | 'limit' | 'limit-late',
1020  f?: Facts | readonly Facts[],
1021  named?: readonly Named[],
1022  open?: readonly Facts[],
1023  mode: Mode = 'hold',
1024  absent?: readonly Kind[],
1025): string {
1026  const noReading = absent?.includes('five_hour') === true ? 'nothing to resume. There is no reading yet.' : 'nothing to resume. There is no 5-hour reading yet.'
1027  const fs = f === undefined ? [] : listOf(f)
1028  const use = fs.length === 0 ? `the reserve ${UNTIL_RESET}` : useText(fs)
1029  const asks = asksPart(fs, mode)
1030  switch (c) {
1031    case 'asking':
1032      return `resumed. Held work continues on ${use}.${asks}`
1033    case 'stopped':
1034      return `resumed. You can use ${use}.${asks} Type a prompt to continue.`
1035    case 'overdue': {
1036      const ev = eventText(named ?? [], open ?? [])
1037      return ev === '' ? 'the stop is over. Type a prompt to continue.' : `${ev}, and the stop is over. Type a prompt to continue.`
1038    }
1039    case 'open':
1040      return `nothing to resume. The reset is near, so ${openText(fs)}.`
1041    case 'tripped':
1042      return `you can use ${use}.${asks}`
1043    case 'consented':
1044      if (fs.some((x) => x.to !== undefined)) return `already resumed ${fs.map(resumedPart).join(', and ')}.${asks}`
1045      return `already resumed ${fs.length === 0 ? UNTIL_RESET : untilText(fs)}.`
1046    case 'below':
1047      if (fs.length > 0) return `nothing to resume. ${personFacts(fs)}.`
1048      return noReading
1049    case 'none':
1050      return noReading
1051    case 'off':
1052      return 'this run is not guarded. Nothing changed.'
1053    case 'limit-asking':
1054      return notice.limitContinues(fs)
1055    case 'limit':
1056      return `nothing to resume now. ${limitHead(fs)} until ${whenOf(fs).at}. spare10 holds all work until then. ${LIMIT_OFF}`
1057    case 'limit-late':
1058      return 'resumed. Held work continues.'
1059  }
1060}
1061
1062/**
1063 * B24 ('tripped' covers consented). `auto`: the time the stop ends, {at} and for a skip owner {lead}.
1064 * `continues` (default true): autoResume is on, so spare10 continues the stopped work then.
1065 * `weeklyTrip`: the weekly window is watched. `ended`: the windows of a Stop here after the skip start
1066 * (B46). 'open' and 'overdue-open' take the open kinds as `f`. `absent` (Codex design 2.1): the kinds the
1067 * host reports no window for. With five_hour in it, 'below' and 'none' name no 5-hour trip point.
1068 */
1069export function stopReply(
1070  c:
1071    | Exclude<ReplyCase, 'consented'>
1072    | 'overdue'
1073    | 'overdue-open'
1074    | 'overdue-skip'
1075    | 'open'
1076    | 'asking-soon'
1077    | 'asking-open'
1078    | 'limit',
1079  f?: Facts | readonly Facts[],
1080  trip?: number,
1081  auto?: { at: string; lead?: string; continues?: boolean },
1082  weeklyTrip?: number,
1083  ended?: Ended,
1084  absent?: readonly Kind[],
1085): string {
1086  const fs = f === undefined ? [] : listOf(f)
1087  const ev = cap(eventText(ended?.reset ?? [], ended?.open ?? []))
1088  switch (c) {
1089    case 'asking':
1090      if (auto === undefined) return 'stopped. Held work is refused.'
1091      return auto.lead === undefined
1092        ? `stopped. Held work is refused. spare10 continues it after ${auto.at}.`
1093        : `stopped. Held work is refused. spare10 continues it at ${untilPhrase(auto)}.`
1094    case 'asking-soon':
1095      return `stopped. Held work is refused. ${ev}, so spare10 continues it soon, unless a reserve is still reached.`
1096    case 'asking-open':
1097      return `stopped. Held work is refused. ${ev}, so new work goes on with no question.`
1098    case 'tripped':
1099      if (auto === undefined) return `stopped at the reserve. ${AGAIN}`
1100      return auto.continues === false
1101        ? `stopped at the reserve until ${untilPhrase(auto)}. ${AGAIN}`
1102        : `stopped at the reserve until ${untilPhrase(auto)}. Then spare10 continues any stopped work. ${AGAIN}`
1103    case 'stopped':
1104      return auto === undefined ? 'already stopped.' : `already stopped until ${auto.at}.`
1105    case 'overdue':
1106      return 'the stop ended at the reset. spare10 will not continue the stopped work.'
1107    case 'overdue-open':
1108      return `the stop is over, because the reset is near. ${cap(openText(fs))}. spare10 will not continue the stopped work.`
1109    case 'overdue-skip':
1110      return 'the stop is over. spare10 will not continue the stopped work.'
1111    case 'open':
1112      return `nothing to stop. The reset is near, so ${openText(fs)}. To keep a reserve until the reset, ${HOST.keepOpen}.`
1113    case 'below':
1114    case 'none': {
1115      if (absent?.includes('five_hour') === true) return `nothing to stop. ${stepsIn(0, weeklyTrip === undefined ? undefined : 100 - weeklyTrip, true)}`
1116      const at = fmtPct(trip ?? 100 - ((f === undefined ? undefined : listOf(f)[0])?.reserve ?? 10))
1117      if (weeklyTrip === undefined) return `nothing to stop. spare10 steps in at ${at}% used.`
1118      return `nothing to stop. spare10 steps in at ${at}% used, or at ${fmtPct(weeklyTrip)}% used of the weekly window.`
1119    }
1120    case 'off':
1121      return 'this run is not guarded. Nothing changed.'
1122    case 'limit':
1123      return notice.limitStopped(auto?.at ?? 'the reset')
1124  }
1125}
1126
1127/** B25 */
1128export const notPerson = (verb: string): string => `only you can run ${HOST.command} ${verb}. Nothing changed.`
1129
1130/** B25 */
1131export const unknownVerb = (verb: string): string =>
1132  `unknown command "${verb}". Use ${HOST.command}, ${HOST.command} resume or ${HOST.command} stop.`
1133
1134/**
1135 * Section 12.1, 2.9. `opens` (skip 2.9), for a test reading at or above the trip point with a span:
1136 * when its reserve opens ({at} and {lead}), 'now' when it is open at once (`f.span` gives {span}), or
1137 * 'real' when the real reading beneath is in the reserve too and keeps the hold (B45). Floor 2.9:
1138 * 'raised' is a raise in place (B53). 'replaced' replaces a test reading of the kind: a new test (3.5),
1139 * so the consents of both windows and the stop are cleared. `pastFloor`: the floor of the kind when the
1140 * test reading is past it. `realIn`: the real reading beneath is in the reserve, so a Resume on the test
1141 * reading covers it.
1142 */
1143export function simulateReply(
1144  kind: 'set' | 'raised' | 'replaced' | 'off' | 'bad' | 'weekly-off',
1145  f?: Facts,
1146  opens?: { at: string; lead: string } | 'now' | 'real',
1147  pastFloor?: number,
1148  realIn?: boolean,
1149  limit?: boolean,
1150): string {
1151  if (kind === 'off') return 'test readings cleared. Your consents for both windows and any stop are cleared too.'
1152  if (kind === 'weekly-off') return 'the weekly reserve is 0, so spare10 does not watch the weekly window. Nothing changed.'
1153  if (kind === 'bad' || f === undefined) {
1154    return `${HOST.leadSimulate} takes a percentage from 0 to 100, or off. Add weekly for the weekly window, and in 22m for a test window that resets in 22 minutes.`
1155  }
1156  const weekly = isWeekly(f)
1157  const of = weekly ? ' of the weekly window' : ''
1158  let opensText = ''
1159  if (opens === 'real') {
1160    opensText = weekly
1161      ? ' The real weekly reading is also in the weekly reserve, so the weekly test window does not open it.'
1162      : ' The real reading is also in the reserve, so the test window does not open it.'
1163  } else if (opens === 'now') {
1164    opensText = weekly
1165      ? ` The weekly test window ends within ${spanText(f)}, so the weekly reserve is open at once.`
1166      : ` The test window ends within ${spanText(f)}, so the reserve is open at once.`
1167  } else if (opens !== undefined) {
1168    opensText = ` The ${weekly ? 'weekly reserve' : 'reserve'} opens at ${opens.at}, ${opens.lead}.`
1169  }
1170  // Floor 2.9: the test reading is past the floor, and a Resume on it covers the real reading beneath.
1171  const floorText = pastFloor === undefined ? '' : ` This is past your ${fmtPct(pastFloor)}% ${weekly ? 'weekly floor' : 'floor'}.`
1172  const realText = realIn === true ? ` A Resume on the test reading also lets real work use the ${weekly ? 'weekly reserve' : 'reserve'}.` : ''
1173  // The quota limit: all work holds until the test window ends. It takes the place of the other sentences.
1174  const limitText = limit === true ? ` This is the quota limit, so spare10 holds all work until the ${weekly ? 'weekly test window' : 'test window'} ends.` : ''
1175  const verb = kind === 'raised' ? 'raised' : 'set'
1176  const stays =
1177    kind === 'raised'
1178      ? ' Your earlier answers stay.'
1179      : kind === 'replaced'
1180        ? ' This starts a new test. Your consents for both windows and any stop are cleared.'
1181        : ''
1182  return `test reading ${verb} to ${fmtPct(f.used)}% used${of}, resets ${clockOf(f)}.${stays} It can only raise the real reading.${limitText === '' ? `${floorText}${opensText}${realText}` : limitText} Run ${HOST.command} simulate off to clear it.`
1183}
1184
1185/** A /spare10 that threw. */
1186export const commandFailed = (message: string): string => `${HOST.leadFailed} failed: ${message}`
1187
hooks/core/badge.ts 57 lines
1import type { Mode, Phase } from './decide.ts'
2import { fmtPct } from './text.ts'
3
4// The footer badge (design B21), one row per phase. No $ here.
5
6export type View = { text: string; color?: 'inactive' | 'success' | 'warning'; pulse: boolean }
7
8/** 'spare10', or 'spare10 (40%)' when the reserve is not 10, plus ' (test)' under a test reading. */
9export function badgeLabel(reserve: number, test: boolean): string {
10  const label = reserve === 10 ? 'spare10' : `spare10 (${fmtPct(reserve)}%)`
11  return test ? `${label} (test)` : label
12}
13
14/**
15 * The table in B21. Only the tripped row pulses, by swapping its glyph for one space. `until`: the
16 * clock at which a stop or an open question continues by itself, or, in the open row, the reset that
17 * ends the open reserve first (2.6), or, in the limit row, the time at which held work continues after
18 * the quota limit. `to` (floor 2.6): the end point now of the consent to the floor nearest its end, such
19 * as '95'. The consented row names it.
20 */
21export function badgeView(
22  phase: Phase,
23  i: { reserve: number; test: boolean; mode: Mode; blink: boolean; until?: string; to?: string },
24): View {
25  const label = badgeLabel(i.reserve, i.test)
26  const until = i.until === undefined ? '' : ` until ${i.until}`
27  switch (phase) {
28    case 'off':
29      return { text: `○ ${label} off`, color: 'inactive', pulse: false }
30    case 'blind':
31      return { text: '⚠ spare10 quota unavailable', pulse: false }
32    case 'waiting':
33      return { text: `⧗ ${label}`, color: 'inactive', pulse: false }
34    case 'armed':
35      return { text: `● ${label}`, color: 'success', pulse: false }
36    case 'limit':
37      return { text: `‖ ${label}: at the limit${until}`, color: 'warning', pulse: false }
38    case 'consented':
39      return { text: i.to === undefined ? `⨯ ${label}` : `⨯ ${label}: resumed until ${i.to}% used`, color: 'warning', pulse: false }
40    case 'open':
41      return { text: `↻ ${label}: reserve open${until}`, color: 'warning', pulse: false }
42    case 'stopped':
43      return { text: `■ ${label}: stopped${until}`, color: 'warning', pulse: false }
44    case 'asking':
45      return { text: `? ${label}: waiting for you${until}`, color: 'warning', pulse: false }
46    case 'told':
47      return { text: `⏸ ${label}`, color: 'warning', pulse: false }
48    case 'reserve':
49      return { text: `⚠ ${label}: in the reserve`, color: 'warning', pulse: false }
50    case 'tripped': {
51      const glyph = i.blink ? '⚠' : ' '
52      const what = i.mode === 'tell' ? 'Winding down at next step' : 'Pausing at next step'
53      return { text: `${glyph} ${what}`, color: 'warning', pulse: true }
54    }
55  }
56}
57
hooks/core/flow.ts 1654 lines
1import type { SessionRateLimit } from 'claude-code'
2import { floorOf, reserveOf, spanOf, watchedKinds } from './config.ts'
3import type { Effective, Spans } from './config.ts'
4import {
5  CHECK_MS,
6  answers,
7  answersQuestion,
8  coveringConsent,
9  decide,
10  endOf,
11  endedFloor,
12  extendedReal,
13  floorEnded,
14  heldPast,
15  holdsPast,
16  joinReal,
17  keyStage,
18  mergeStopped,
19  parseConsent,
20  phaseOf,
21  skipTag,
22  stageKey,
23} from './decide.ts'
24import type { Answered, Consent, Holder, Mode, Phase, Site, StoppedRecord, Tomb, Verdict, Viewed } from './decide.ts'
25import {
26  FALLBACK_MS,
27  KINDS,
28  LIMIT_PCT,
29  atLimit,
30  atPoint,
31  basis,
32  holdEndOf,
33  inResetMargin,
34  inWindow,
35  isTripped,
36  marginOf,
37  parseReset,
38  pctOf,
39  pointOf,
40  skipStartOf,
41  viewOf,
42  windowMs,
43} from './reading.ts'
44import type { Anchored, Basis, Kind, Memory, TestSpec } from './reading.ts'
45import {
46  HELD_WAITS,
47  atText,
48  clockText,
49  consentWarning,
50  debugLine,
51  factsOf,
52  fmtPct,
53  headlessText,
54  leadText,
55  notStarted,
56  notice,
57  pauseInstruction,
58  pausedText,
59  resumeReply,
60  simulateReply,
61  stopReply,
62  stopText,
63  untilFor,
64  untilPhrase,
65  whenOf,
66} from './text.ts'
67import type { Ended, Facts, Named, StatusInput } from './text.ts'
68
69// The host-free steps of the gate (Codex design A14, 7.1), lifted from register.tsx without a change of
70// logic. register.tsx and the Codex adapter both call them. No host here, and no module state: every
71// function takes its state as arguments, and some update the objects they are given.
72
73// ---- The sense of each kind (register.tsx 5.2) ----
74
75/** One watched kind at now, as the gate sees it. */
76export type KindSense = {
77  kind: Kind
78  reserve: number
79  basis: Basis // the view basis: the real one when a test reading yields to a real trip (B45)
80  tripped: boolean
81  windowEnd: number // the consent bound
82  holdEnd: number // the skip start while it is ahead, else the D0.2 hold end (B42)
83  stopEnd: number // the end of a stop with autoResume off: the skip start while it is ahead, else windowEnd
84  span: number // ms, 0 is off
85  skipAt: number | null // the skip start while it is ahead
86  open: boolean // tripped and in its skip window (B41)
87  test: boolean
88  seed: boolean
89  realIn: boolean // TS1: the real reading, beneath any test reading, is tripped and not open
90  realReset: number | null // TS1: the reset of the real reading, null when unknown
91  floor: number // B48: the floor in force in %, 0 when none (also for an unattended run, B55)
92  point: number | null // B48: pointOf(floor), null when floor is 0
93  atFloor: boolean // B48: tripped, not open, and the view reading at or past point
94  realPct: number | undefined // TS1, B52: the pct of the real basis
95  limit: boolean // at the quota limit with limitPause on (and on Codex no credits that pay): it gates in every state
96}
97
98/** One sense: the settings, the time, every watched kind, and the attendance. */
99export type Sensed = { cfg: Effective; now: number; kinds: KindSense[]; tripped: boolean; attended: boolean }
100
101/** A decision: the verdict, and what it rests on. `holders`: TS1. */
102export type Acted = { verdict: Verdict; stopped: boolean; gating: KindSense[]; holders: Holder[] }
103
104/** The basis of each kind with the test reading, and without it (B45). */
105export type Bases = Record<Kind, { basis: Basis; real: Basis }>
106
107/** R11: one fallback window end per kind and episode. `windowEndFor` keeps it. */
108export type FallbackEnds = Partial<Record<Kind, number>>
109
110/** What the host remembers of each kind (reading.ts Memory). */
111export type Mems = Readonly<Record<Kind, Memory>>
112
113/** The window end that bounds consent and stopped. Without resetsAt, one fallback per kind and episode (R11). */
114export function windowEndFor(kind: Kind, b: Basis, now: number, fallback: FallbackEnds): number {
115  if (b.kind !== 'none' && b.resetsAtMs !== null) {
116    delete fallback[kind]
117    return b.resetsAtMs
118  }
119  const f = fallback[kind]
120  if (f !== undefined && now < f) return f
121  fallback[kind] = now + FALLBACK_MS
122  return now + FALLBACK_MS
123}
124
125/**
126 * A basis is at the quota limit for the gate: 100% used or more with a known reset (`atLimit`), limitPause
127 * on, and on Codex no credits that pay past 100% (`paid`, A23).
128 */
129export const limitOf = (cfg: Pick<Effective, 'limitPause'>, b: Basis, paid = false): boolean => cfg.limitPause && !paid && atLimit(b)
130
131/**
132 * Every watched kind at now. `edges`: the skip starts of the tripped kinds, and the reset of a kind at the
133 * quota limit, the times at which the badge can change (skip 4.1). `watched` defaults to the kinds of the
134 * settings. `o.paid` (Codex A23): credits pay past 100%, so no kind is at the quota limit. A kind at the
135 * quota limit (`limitOf`) is tripped on its view basis with the test reading, and never open: its hold
136 * end, stop end and consent bound are its reset. So a test reading at 100% never yields to a real one (B45).
137 */
138export function sensesOf(
139  cfg: Effective,
140  bases: Bases,
141  spans: Spans,
142  now: number,
143  fallback: FallbackEnds,
144  mems: Mems,
145  watched: readonly Kind[] = watchedKinds(cfg),
146  o: { paid?: boolean } = {},
147): { kinds: KindSense[]; edges: number[] } {
148  const edges: number[] = []
149  const paid = o.paid === true
150  const kinds = watched.map((kind): KindSense => {
151    const reserve = reserveOf(cfg, kind)
152    const span = spanOf(spans, kind)
153    const real = bases[kind].real
154    const withTest = bases[kind].basis
155    const limit = limitOf(cfg, withTest, paid)
156    const v = limit ? { basis: withTest, tripped: true, skipAt: null, open: false } : viewOf(real, withTest, reserve, span, now) // B41, B45
157    const rv = viewOf(real, real, reserve, span, now) // TS1: the real reading alone
158    const b = v.basis
159    const windowEnd = windowEndFor(kind, b, now, fallback)
160    if (v.tripped && v.skipAt !== null) edges.push(v.skipAt)
161    if (limit && b.kind !== 'none' && b.resetsAtMs !== null) edges.push(b.resetsAtMs)
162    const floor = floorOf(cfg, kind) // B48
163    const point = floor > 0 ? pointOf(floor) : null
164    return {
165      kind,
166      reserve,
167      basis: b,
168      tripped: v.tripped,
169      windowEnd,
170      holdEnd: v.skipAt ?? holdEndOf(b, mems[kind], now, kind),
171      stopEnd: v.skipAt ?? windowEnd,
172      span,
173      skipAt: v.skipAt,
174      open: v.open,
175      test: b.kind === 'test',
176      seed: b.kind === 'seed',
177      realIn: rv.tripped && (!rv.open || limitOf(cfg, real, paid)), // a real reading at the limit is never open
178      realReset: real.kind === 'none' ? null : real.resetsAtMs,
179      floor,
180      point,
181      atFloor: !limit && v.tripped && !v.open && atPoint(b, point), // the limit question replaces the second question
182      realPct: real.kind === 'none' ? undefined : real.pct,
183      limit,
184    }
185  })
186  return { kinds, edges }
187}
188
189/** B55: an unattended run never asks, so it has no floor in force: no stage and no floor names. */
190export const noFloor = (k: KindSense): KindSense => ({ ...k, floor: 0, point: null, atFloor: false })
191
192/** B48: the end point of a Resume on a kind: its floor point when it is at the reserve with a floor in force. A kind at the limit has no tier. */
193export const resumeTo = (k: KindSense): number | undefined =>
194  k.tripped && !k.open && !k.atFloor && !k.limit && k.point !== null ? k.point : undefined
195
196/** A question's end of one kind: the consent bound, the test flag, the skip start and the tier (B50). */
197export type QuestionEnd = { end: number; test: boolean; skipAt?: number; to?: number } // to: the tier of a kind asked at the reserve (B50)
198
199/** A consent of a question's kind: its bound, and its end point when it was asked at the reserve (B49). */
200export const consentOfEnd = (end: QuestionEnd): Consent => ({ until: end.end, ...(end.to === undefined ? {} : { to: end.to }) })
201
202/** TS1: a kind whose real reading gates now, as heldPast reads it. */
203export const realHolder = (k: KindSense): Holder => ({ kind: k.kind, resetsAtMs: k.realReset })
204
205/**
206 * TS1: a command's view of the kinds whose real reading gates now. A command never waits on a consent
207 * read, so a consented kind counts too: the stop is then kept, the safe side.
208 */
209export const commandHolders = (kinds: readonly KindSense[]): Holder[] => kinds.filter((k) => k.realIn).map(realHolder)
210
211/**
212 * B50: a kind as the gate sees it: its view percentage, basis and window. A reading with no reset time
213 * has no known window: its fallback end is no window of its own (R11).
214 */
215export const viewedOf = (k: KindSense): Viewed => ({
216  kind: k.kind,
217  pct: pctOf(k.basis) ?? 0,
218  test: k.test,
219  ...(k.basis.kind === 'none' || k.basis.resetsAtMs === null ? {} : { end: k.windowEnd }),
220  ...(k.limit ? { limit: true } : {}),
221})
222
223/** The consent bound of a kind's real reading: the view window, or beneath a test reading the real reset (TS1). */
224export const realBound = (k: KindSense, now: number): number => (k.test ? (k.realReset ?? now + FALLBACK_MS) : k.windowEnd)
225
226/** Hold mode, or tell mode with a pause prompt. */
227export const modeOf = (cfg: Pick<Effective, 'pausePrompt'>): Mode => (cfg.pausePrompt === null ? 'hold' : 'tell')
228
229/**
230 * A test reading of a kind: `in` from now, else the live reset while it is ahead and within one window,
231 * else one window from now. A live reading keeps its reset after the window resets, until the next
232 * response: such a reset makes a test reading that basis() drops at once.
233 */
234export function testReading(pct: number, kind: Kind, live: SessionRateLimit | undefined, now: number, inMs?: number): Anchored {
235  const liveReset = live === undefined ? null : parseReset(live.resetsAt)
236  const borrow = liveReset !== null && inWindow({ pct, resetsAtMs: liveReset }, now, kind) ? liveReset : null
237  const resetsAtMs = inMs !== undefined ? now + inMs : (borrow ?? now + windowMs(kind))
238  return { pct, resetsAtMs }
239}
240
241/**
242 * 4.8: a watched kind whose last real reading was in the reserve reset less than the 5-minute margin
243 * ago. A release waits for it: the 60 s margin of a test window never applies at a real reset.
244 */
245export const resetTooRecent = (s: Pick<Sensed, 'kinds' | 'now'>, mems: Mems): boolean =>
246  s.kinds.some((k) => inResetMargin(mems[k.kind].seed, k.reserve, s.now))
247
248
249// ---- Facts and texts (register.tsx 5.2, 5.4) ----
250
251/**
252 * The figures of some kinds, five_hour first. A hold end that is not the reset (a reading without a
253 * reset time, or a skip start ahead) rides along for {at}. `owner`: a skip owner, whose kinds with a
254 * skip start ahead carry their span for {lead} (skip 2.1). A kind at the floor carries its floor, so
255 * the texts name it (B48). `toOf`: the end point of the consent that the text describes (floor 6.6).
256 */
257export function factsFrom(ks: readonly KindSense[], now: number, owner = false, toOf?: (k: KindSense) => number | undefined): Facts[] {
258  return ks.map((k) => {
259    const f = factsOf(k.basis, k.reserve, undefined, k.kind, now)
260    const reset = k.basis.kind === 'none' ? undefined : k.basis.resetsAtMs
261    const to = toOf?.(k)
262    return {
263      ...f,
264      ...(reset !== undefined && k.holdEnd !== reset ? { holdEnd: k.holdEnd } : {}),
265      ...(k.test ? { test: true } : {}),
266      ...(owner && k.skipAt !== null ? { span: k.span } : {}),
267      ...(k.atFloor ? { floor: k.floor } : {}),
268      ...(to === undefined ? {} : { to }),
269      ...(k.limit ? { limit: true } : {}),
270    }
271  })
272}
273
274/** Floor 6.6: the end point now of a covering consent to the floor, for the texts of a consented kind. */
275export const consentEnd =
276  (consents: ReadonlyArray<{ k: KindSense; c: Consent }>) =>
277  (k: KindSense): number | undefined => {
278    const c = consents.find((x) => x.k.kind === k.kind)?.c
279    return c?.to === undefined ? undefined : endOf(c.to, k.point)
280  }
281
282/** The kinds a text names: the gating kinds, else the tripped ones. */
283export const namedKinds = (s: Pick<Sensed, 'kinds'>, a: Pick<Acted, 'gating'>): KindSense[] =>
284  a.gating.length > 0 ? a.gating : s.kinds.filter((k) => k.tripped)
285
286/** The model text of a refused tool or step. `sessionId`: the id that the headless text names. */
287export function refusalText(kind: 'stop' | 'paused' | 'headless', s: Pick<Sensed, 'kinds' | 'now'>, a: Pick<Acted, 'gating'>, sessionId: string): string {
288  const f = factsFrom(namedKinds(s, a), s.now)
289  if (kind === 'stop') return stopText(f)
290  if (kind === 'paused') return pausedText(f)
291  return headlessText(f, sessionId)
292}
293
294/** The pause instruction of tell mode (5). */
295export const tellText = (s: Pick<Sensed, 'kinds' | 'now' | 'cfg'>, a: Pick<Acted, 'gating'>): string =>
296  pauseInstruction(factsFrom(namedKinds(s, a), s.now), s.cfg.pausePrompt)
297
298/** The drop reason of a person prompt that spare10 did not start. */
299export const notStartedFor = (s: Pick<Sensed, 'kinds' | 'now'>, a: Pick<Acted, 'gating'>): string => notStarted(factsFrom(namedKinds(s, a), s.now))
300
301/**
302 * The open question of a session, as the core builds and reads it (4.3, 4.5, 4.6, 5.6). A host adds its
303 * own fields: the waiter count and the check flag.
304 */
305export type QuestionCore = {
306  kinds: Kind[] // the gating kinds when it opened, five_hour first
307  ends: Partial<Record<Kind, QuestionEnd>> // consent bound, test flag, skip start and end point per kind
308  latestEnd: number // the latest consent bound (the B6 note of a question that is not a skip owner)
309  real: Holder[] // TS1: its kinds whose real reading gated when it opened, with their resets, for a Stop here whose sense fails
310  stopEnd: number // the latest stop end of its kinds: the until of a Stop here with autoResume off
311  holdEnd: number // the latest hold end of its kinds
312  due: number // the latest hold end plus margin of its kinds
313  skip: boolean // a skip owner: its hold end is a skip start, and no kind of it is due later (B42)
314  noteAt: number // autoResume off: the time of the one note. B43 can move it later
315  nextCheck: number // the next check before the due time
316  silent: boolean // an unattended wait hold: no dialog, never raised
317  auto: boolean // autoResume when it opened: the text only
318  loops: number // tool and step waiters that joined (a Stop with loops has work)
319  since: number
320  mode: Mode
321  opener: 'loop' | 'prompt'
322  facts: Facts[]
323  handoffs: number
324  noted: boolean
325  limit?: boolean // a limit question: its kinds are at the quota limit. Optional, so a 0.3 question file still reads
326  chosen?: boolean // a limit question after Continue at the reset: no dialog again, released after its reset
327}
328
329/** The kinds of a question, with their basis, for {reset}. */
330export const namedOf = (q: Pick<QuestionCore, 'kinds' | 'ends'>): Named[] => q.kinds.map((kind) => ({ kind, test: q.ends[kind]?.test === true }))
331
332/**
333 * B50: what a Resume of this question answers per kind: its basis, its end point when asked at the
334 * reserve, and its consent bound, so a later window is not answered.
335 */
336export const answeredOf = (q: Pick<QuestionCore, 'kinds' | 'ends'> | undefined): Answered[] =>
337  q === undefined
338    ? []
339    : q.kinds.map((kind) => {
340        const end = q.ends[kind]
341        return { kind, test: end?.test === true, ...(end?.to === undefined ? {} : { to: end.to }), ...(end === undefined ? {} : { end: end.end }) }
342      })
343
344/** The kinds of a stop, with its basis, for {reset}. A 0.1 value names the 5-hour window. */
345export const namedStop = (r: StoppedRecord): Named[] => (r.kinds ?? ['five_hour']).map((kind) => ({ kind, test: r.test === true }))
346
347/** Facts in KINDS order. */
348export const byKind = (fs: readonly Facts[]): Facts[] =>
349  [...fs].sort((a, b) => KINDS.indexOf(a.kind ?? 'five_hour') - KINDS.indexOf(b.kind ?? 'five_hour'))
350
351// ---- The consent split and the holders (register.tsx 5.2, floor 4.2, TS1) ----
352
353/** A consent of a kind and where it comes from. `raw`: the stored text, for a compare-and-set (B52). */
354export type Sourced = { c: Consent; from: 'slot' | 'test' | 'env'; raw?: string }
355
356/** Skip 3.3, floor 4.2: the tripped kinds with a consent that applies, those that gate and those that are open. */
357export type Split = { gating: KindSense[]; open: KindSense[]; consented: Array<{ k: KindSense; c: Consent }> }
358
359/** A split with no kind in it. */
360export const emptySplit = (): Split => ({ gating: [], open: [], consented: [] })
361
362/**
363 * B52: the consents to the floor of a kind whose own basis has reached their end point end for good. A
364 * real consent ends by the real reading, a test consent by the test reading. `unset`: the entries that
365 * ended, which the host removes (a test or slot entry at once, a stored value by compare-and-set).
366 * `tombs`: what to bury, in order: each ended real consent, and with `failed` (the stored read failed,
367 * so `list` has only the slots) the window whose end point the real reading reached. Call it only for
368 * a tripped kind that is not open.
369 */
370export function floorEndsOf(k: KindSense, list: readonly Sourced[], now: number, failed = false): { unset: Sourced[]; tombs: Tomb[] } {
371  const unset: Sourced[] = []
372  const tombs: Tomb[] = []
373  for (const e of list) {
374    if (e.c.to === undefined) continue
375    const test = e.from === 'test'
376    const pct = test ? pctOf(k.basis) : k.realPct
377    const bound = test ? k.windowEnd : realBound(k, now)
378    if (pct === undefined || !floorEnded(e.c, now, bound, pct, k.point)) continue
379    unset.push(e)
380    if (!test) tombs.push({ until: e.c.until, to: e.c.to }) // a test consent is never stored, so no tomb
381  }
382  if (failed && k.realPct !== undefined) tombs.push({ until: realBound(k, now), to: k.realPct })
383  return { unset, tombs }
384}
385
386/**
387 * One tripped kind into the split. An open kind whose only covering consent is a consent to the floor
388 * is open (floor 1.3 item 7). `failed`: the consent read failed, and an unreadable consent is not consent.
389 * A kind at the quota limit always gates: its consent is kept, but it does not apply.
390 */
391export function splitKind(out: Split, k: KindSense, list: readonly Sourced[], failed: boolean, now: number): void {
392  if (k.limit) {
393    out.gating.push(k) // at the quota limit no consent applies and the kind is never open
394    return
395  }
396  const use = failed ? [] : list
397  const c = coveringConsent(
398    use.map((e) => e.c),
399    now,
400    k.windowEnd,
401    pctOf(k.basis) ?? 0,
402    k.point,
403  )
404  if (c !== undefined && !(k.open && c.to !== undefined)) {
405    out.consented.push({ k, c })
406    return
407  }
408  if (k.open) out.open.push(k)
409  else out.gating.push(k)
410}
411
412/**
413 * Skip 3.3: the tripped kinds, split. `lists` gives the consents of each tripped kind, in KINDS order,
414 * once. A host ends the floor consents of a kind that is not open inside it (`floorEndsOf`), before the
415 * kind is classified, as register.tsx does.
416 */
417export function splitFrom(kinds: readonly KindSense[], lists: (k: KindSense) => { list: readonly Sourced[]; failed: boolean }, now: number): Split {
418  const out = emptySplit()
419  for (const k of kinds) {
420    if (!k.tripped) continue
421    const r = lists(k)
422    splitKind(out, k, r.list, r.failed, now)
423  }
424  return out
425}
426
427/** TS1: a kind whose real consent `holdersFrom` reads: its real reading gates beneath a test reading. */
428export const needsRealList = (k: KindSense): boolean => k.realIn && k.test
429
430/**
431 * TS1: the kinds whose real reading gates now. A kind whose view is its real reading gates as the view
432 * says, so it is in `gating`. Beneath a test reading, the real reading gates when it is tripped, not open
433 * and no real consent applies on the real reading (B49). `realLists` gives the real consents of each
434 * kind that `needsRealList` names, never a Resume on the test reading (3.5). A real reading without a
435 * reset time takes the one-hour bound.
436 */
437export function holdersFrom(
438  kinds: readonly KindSense[],
439  gating: readonly KindSense[],
440  realLists: (k: KindSense) => readonly Sourced[],
441  now: number,
442): Holder[] {
443  const out: Holder[] = []
444  for (const k of kinds) {
445    if (!k.realIn) continue
446    if (!k.test) {
447      if (gating.some((g) => g.kind === k.kind)) out.push(realHolder(k))
448      continue
449    }
450    const list = realLists(k)
451    const bound = k.realReset ?? now + FALLBACK_MS
452    const c = coveringConsent(
453      list.map((e) => e.c),
454      now,
455      bound,
456      k.realPct ?? 0,
457      k.point,
458    )
459    if (c === undefined) out.push(realHolder(k))
460  }
461  return out
462}
463
464// ---- The verdict and the told keys (register.tsx 5.2, 5, B38, B50, B51) ----
465
466/**
467 * B38, B50: the round after a Resume leaves out the kinds it answered, on its basis, in its window and
468 * below its end point. `jitter`: Codex passes RESET_JITTER_MS (A22, `answers`). Claude passes none.
469 */
470export const unansweredGating = (resumed: readonly Answered[], gating: readonly KindSense[], jitter = 0): KindSense[] =>
471  gating.filter((k) => !answers(resumed, viewedOf(k), jitter))
472
473/** B50: the holders that a Resume did not answer. A kind the sense does not list reads as 0% on the real basis. `jitter`: as above. */
474export function unansweredHolders(s: Pick<Sensed, 'kinds'>, resumed: readonly Answered[], holders: readonly Holder[], jitter = 0): Holder[] {
475  const viewOfKind = (kind: Kind): Viewed => {
476    const k = s.kinds.find((x) => x.kind === kind)
477    return k === undefined ? { kind, pct: 0, test: false } : viewedOf(k)
478  }
479  return holders.filter((h) => !answers(resumed, viewOfKind(h.kind), jitter))
480}
481
482/** Decide row 3: tripped, and no kind gates. */
483export const consentedOf = (s: Pick<Sensed, 'cfg' | 'tripped'>, gating: readonly KindSense[]): boolean =>
484  s.cfg.enabled && s.tripped && gating.length === 0
485
486/** Whether the stop matters to the verdict: guarded, attended and not consented. Else it is not read. */
487export const checksStop = (s: Pick<Sensed, 'cfg' | 'tripped' | 'attended'>, gating: readonly KindSense[]): boolean =>
488  s.cfg.enabled && s.attended && !consentedOf(s, gating)
489
490/** The told keys of each kind in its window (5, B51). */
491export type Told = Record<Kind, { windowEnd: number; keys: Set<string> }>
492
493/** Told sets with no key. */
494export const newTold = (): Told => ({ five_hour: { windowEnd: 0, keys: new Set<string>() }, seven_day: { windowEnd: 0, keys: new Set<string>() } })
495
496/**
497 * B51: a told window end is the window of `k`. `jitter`: Codex passes RESET_JITTER_MS, because its
498 * `resets_at` moves a little from one reading to the next (3.6). A test reading keeps its end exactly.
499 * Claude passes none, so the ends must be equal.
500 */
501export const toldWindow = (end: number, k: Pick<KindSense, 'windowEnd' | 'test'>, jitter = 0): boolean =>
502  end === k.windowEnd || (jitter > 0 && !k.test && Math.abs(end - k.windowEnd) <= jitter)
503
504/** B51: a loop's told key carries the stage of the kind: a second tell at the floor. */
505export function toldHas(told: Told, k: KindSense, key: string, jitter = 0): boolean {
506  const t = told[k.kind]
507  return toldWindow(t.windowEnd, k, jitter) && t.keys.has(stageKey(key, k.atFloor))
508}
509
510/** Decide `mainTold`: every gating kind told the main loop of this session at its stage. */
511export const toldMainOf = (told: Told, gating: readonly KindSense[], sessionId: string, jitter = 0): boolean =>
512  gating.length > 0 && gating.every((k) => toldHas(told, k, `${sessionId}:main`, jitter))
513
514/**
515 * The verdict (5.2) from what the host read: the gating kinds and the holders, both already without the
516 * kinds a Resume answered (`unansweredGating`, `unansweredHolders`), whether a stop applies (read only
517 * when `checksStop`), and `toldMain`. The host may still turn a hold or a refusal into a pass (the
518 * engine fork rule G8 of the Claude host).
519 */
520export function verdictOf(i: {
521  s: Sensed
522  site: Site
523  person: boolean
524  gating: KindSense[]
525  holders: Holder[]
526  stopped: boolean
527  toldMain: boolean
528}): Acted {
529  const { s, gating } = i
530  const verdict = decide({
531    site: i.site,
532    tripped: s.tripped,
533    enabled: s.cfg.enabled,
534    consented: consentedOf(s, gating),
535    attended: s.attended,
536    headless: s.cfg.headless,
537    mode: modeOf(s.cfg),
538    person: i.person,
539    stopped: i.stopped,
540    mainTold: i.toldMain,
541    seedOnly: gating.length > 0 && gating.every((k) => k.seed),
542    limit: gating.some((k) => k.limit),
543  })
544  return { verdict, stopped: i.stopped, gating, holders: i.holders }
545}
546
547/**
548 * A loop is told when some gating kind lacks its key of the kind's stage (B51). The claim adds the key
549 * of its stage to every gating kind. So each loop is told once at the reserve and once at the floor. A
550 * kind in its told window (`toldWindow`) keeps the stored end, so a reset that moves never drifts.
551 */
552export function claimTold(told: Told, gating: readonly KindSense[], key: string, jitter = 0): boolean {
553  let fresh = false
554  for (const k of gating) {
555    if (!toldWindow(told[k.kind].windowEnd, k, jitter)) told[k.kind] = { windowEnd: k.windowEnd, keys: new Set() }
556    const staged = stageKey(key, k.atFloor)
557    if (told[k.kind].keys.has(staged)) continue
558    told[k.kind].keys.add(staged)
559    fresh = true
560  }
561  return fresh
562}
563
564/** B51: the mark of the B12 notice per kind: its window and stage. */
565export const toldMark = (k: KindSense): string => `${k.windowEnd}:${k.atFloor ? 'floor' : 'reserve'}`
566
567/**
568 * B12, B51: the told notice, once per kind, window (`toldWindow`) and stage. It updates `marks`, and
569 * keeps the mark of a kind already shown. Undefined: shown already. A mark that is not text is not shown.
570 */
571export function toldNotice(s: Pick<Sensed, 'kinds' | 'now'>, a: Pick<Acted, 'gating'>, marks: Record<Kind, string>, jitter = 0): string | undefined {
572  const ks = namedKinds(s, a)
573  const shown = (k: KindSense): boolean => {
574    const m: unknown = marks[k.kind]
575    if (typeof m !== 'string') return false
576    const mine = toldMark(k)
577    if (m === mine) return true
578    const cut = m.lastIndexOf(':')
579    const end = Number(m.slice(0, cut))
580    return cut > 0 && m.slice(cut) === mine.slice(mine.lastIndexOf(':')) && Number.isFinite(end) && toldWindow(end, k, jitter)
581  }
582  const fresh = ks.filter((k) => !shown(k))
583  if (fresh.length === 0) return undefined
584  for (const k of fresh) marks[k.kind] = toldMark(k)
585  return notice.told(factsFrom(ks, s.now))
586}
587
588/** The window end of the last B15 debug line per kind, in the reserve and open (skip 2.5). */
589export type UnattendedMarks = { reserve: Record<Kind, number>; open: Record<Kind, number> }
590
591/** B15, skip 2.5: the debug lines of an unattended run, once per kind and window (`toldWindow`). It updates `marks`. */
592export function unattendedLines(s: Pick<Sensed, 'kinds' | 'now' | 'cfg'>, marks: UnattendedMarks, jitter = 0): string[] {
593  const out: string[] = []
594  const fresh = s.kinds.filter((k) => k.tripped && !k.open && !toldWindow(marks.reserve[k.kind], k, jitter))
595  if (fresh.length > 0) {
596    for (const k of fresh) marks.reserve[k.kind] = k.windowEnd
597    out.push(debugLine.unattended(factsFrom(fresh, s.now), s.cfg.headless)) // R9: every policy
598  }
599  const opened = s.kinds.filter((k) => k.open && !toldWindow(marks.open[k.kind], k, jitter))
600  if (opened.length > 0) {
601    for (const k of opened) marks.open[k.kind] = k.windowEnd
602    out.push(debugLine.unattendedOpen(factsFrom(opened, s.now))) // skip 2.5: every policy lets it through
603  }
604  return out
605}
606
607// ---- The question (register.tsx 4.3, 4.5, 4.6, 5.6) ----
608
609/** A kind's hold end plus this is when it may be released: 0 at a skip start (B42), else the reset margin (4.8). */
610export const dueMargin = (k: KindSense): number => marginOf(k.skipAt !== null, k.test)
611
612/**
613 * A new question on the kinds that `namedKinds` gives: per kind its consent bound, test flag, skip start
614 * and tier (B48, B50), and the ends, the due time, the skip owner and the note time of them all. When a
615 * kind of them is at the quota limit, it is a limit question on those kinds only, in hold mode also with a
616 * pause prompt: it holds until their latest reset plus the margin, with no skip start and no tier.
617 */
618export function questionOf(opener: 'loop' | 'prompt', s: Sensed, a: Pick<Acted, 'gating' | 'holders'>, now: number): QuestionCore {
619  const g0 = namedKinds(s, a)
620  const limit = g0.some((k) => k.limit)
621  const g = limit ? g0.filter((k) => k.limit) : g0
622  const ends: QuestionCore['ends'] = {}
623  for (const k of g) {
624    const to = resumeTo(k) // B48, B50: the tier of the question per kind
625    ends[k.kind] = { end: k.windowEnd, test: k.test, ...(k.skipAt === null ? {} : { skipAt: k.skipAt }), ...(to === undefined ? {} : { to }) }
626  }
627  const latestEnd = Math.max(...g.map((k) => k.windowEnd))
628  const real = a.holders.filter((h) => g.some((k) => k.kind === h.kind))
629  const holdEnd = Math.max(...g.map((k) => k.holdEnd))
630  const due = Math.max(...g.map((k) => k.holdEnd + dueMargin(k)))
631  // B42: a skip owner only when a skip start is the hold end, and no kind of it is due later.
632  const skip = g.some((k) => k.skipAt === holdEnd) && due === holdEnd
633  return {
634    kinds: g.map((k) => k.kind),
635    ends,
636    latestEnd,
637    real,
638    stopEnd: Math.max(...g.map((k) => k.stopEnd)),
639    holdEnd,
640    due,
641    skip,
642    noteAt: skip ? holdEnd : latestEnd,
643    nextCheck: now + CHECK_MS,
644    silent: !s.attended,
645    auto: s.cfg.autoResume,
646    loops: opener === 'loop' ? 1 : 0,
647    since: now,
648    mode: limit ? 'hold' : modeOf(s.cfg),
649    opener,
650    facts: factsFrom(g, now, skip, resumeTo),
651    handoffs: 0,
652    noted: false,
653    ...(limit ? { limit: true } : {}),
654  }
655}
656
657/** A question that raises no dialog: an unattended wait hold, or a limit question after Continue at the reset. */
658export const quietOf = (q: Pick<QuestionCore, 'silent' | 'chosen'>): boolean => q.silent || q.chosen === true
659
660/**
661 * An open question that is not a limit question gives way when a kind at the quota limit gates now. A
662 * silent question (an unattended wait) never does: it asks nobody, and holds until its due time anyway.
663 */
664export const supersedes = (q: Pick<QuestionCore, 'limit' | 'silent'>, gating: readonly KindSense[]): boolean =>
665  q.limit !== true && !q.silent && gating.some((k) => k.limit)
666
667/**
668 * A Stop here at the quota limit: any Stop here on the limit question but the hold time limit, or a
669 * person's own Stop here (the dialog or the stop command) on another question while a kind at the quota
670 * limit gates now (`sNow`). It lasts until the reset and never continues by itself. A question at the
671 * reserve that ends with no answer keeps the setting in force, as the limit question does.
672 */
673export const stopsAtLimit = (q: Pick<QuestionCore, 'limit'>, via: Via, sNow?: Pick<StopSense, 'split'>): boolean =>
674  via !== 'time limit' && (q.limit === true || ((via === 'dialog' || via === 'command') && sNow?.split.gating.some((k) => k.limit) === true))
675
676/** Stop here at the limit never continues by itself (`stopsAtLimit`). Else the auto stop of the setting in force. */
677export const stopAutoOf = (q: Pick<QuestionCore, 'limit'>, via: Via, auto: boolean, sNow?: Pick<StopSense, 'split'>): boolean =>
678  stopsAtLimit(q, via, sNow) ? false : auto
679
680/**
681 * The line of Continue at the reset, while the reset is still ahead. After the reset the release line
682 * follows at once, so a line that names a passed time would only confuse.
683 */
684export const continuesLine = (q: Pick<QuestionCore, 'facts' | 'holdEnd'>, now: number): string | undefined =>
685  now < q.holdEnd ? notice.limitContinues(q.facts) : undefined
686
687/** A person prompt joins a limit question after Continue at the reset: it waits with the held work, and says so. */
688export const promptWaitsLine = (q: Pick<QuestionCore, 'facts' | 'holdEnd' | 'chosen'>, now: number): string | undefined =>
689  q.chosen === true && now < q.holdEnd ? notice.limitPromptWaits(q.facts) : undefined
690
691/** The times at which a new question can change the badge: each consent bound, its hold end, due, stop end and note time. */
692export const questionEdges = (q: QuestionCore): number[] => [
693  ...q.kinds.map((kind) => q.ends[kind]?.end ?? 0),
694  q.holdEnd,
695  q.due,
696  q.stopEnd,
697  q.noteAt,
698]
699
700/** The B9 notice of a Resume: what continues, or a new window when every consent bound has passed. */
701export function resumeNotice(q: Pick<QuestionCore, 'kinds' | 'ends' | 'facts' | 'mode'>, now: number): string {
702  const open = q.kinds.filter((kind) => (q.ends[kind]?.end ?? 0) > now)
703  return open.length > 0
704    ? notice.continuing(
705        q.facts.filter((f) => open.includes(f.kind ?? 'five_hour')),
706        q.mode,
707      )
708    : notice.newWindowFor(q.kinds)
709}
710
711// ---- Stops (register.tsx 5.7, 3.2, skip 3.5, B34, B46, TS1) ----
712
713/** The skip starts that are still ahead of some kinds. */
714export const skipStarts = (ks: readonly KindSense[]): number[] => ks.map((k) => k.skipAt).filter((t): t is number => t !== null)
715
716/**
717 * B34: an auto stop extended to the kinds that gate now, until their latest hold end. The test tag keeps
718 * the short margin only while every kind that gates now is a test reading. TS1: each kind whose real
719 * reading gates now (`holders`) gets a real entry with its current reset, the window it gates in. Any
720 * other kind that still gates keeps its entry while that window lasts (`extendedReal`).
721 */
722export function extended(r: StoppedRecord, gatingNow: readonly KindSense[], holders: readonly Holder[], now: number): StoppedRecord {
723  const until = Math.max(...gatingNow.map((k) => k.holdEnd))
724  const skip = skipTag(until, skipStarts(gatingNow), gatingNow.map((k) => k.holdEnd + dueMargin(k)), true)
725  const { skip: _old, real: _real, ...kept } = r
726  const kinds = gatingNow.map((k) => k.kind)
727  const real = extendedReal(r.real, holders, kinds, now)
728  return {
729    ...kept,
730    kinds,
731    windowEnd: until,
732    test: r.test === true && gatingNow.every((k) => k.test),
733    ...(skip ? { skip: true } : {}),
734    ...(real.length > 0 ? { real } : {}),
735  }
736}
737
738/**
739 * TS1: a stop held past its until, as the badge, /spare10 and the stop reply show it. An auto stop gets
740 * the record that the ticker writes at its next tick (B34). A stop made with autoResume off lasts until
741 * the stop end of its kinds that keep it: their skip start, or else the consent bound. With no such kind
742 * among the kinds that gate (a Resume on a test reading over the real one), the record as it is.
743 */
744export function stillHeld(st: StoppedRecord, gating: readonly KindSense[], holders: readonly Holder[], now: number): StoppedRecord {
745  const mine = gating.filter((k) => holders.some((h) => h.kind === k.kind && holdsPast(st, h)))
746  if (mine.length === 0) return st
747  if (st.auto === true) return extended(st, gating, holders, now)
748  const until = Math.max(...mine.map((k) => k.stopEnd))
749  const skip = skipTag(until, skipStarts(mine), [], false)
750  const { skip: _old, ...kept } = st
751  return { ...kept, windowEnd: until, ...(skip ? { skip: true } : {}) }
752}
753
754/**
755 * The stop of this conversation that applies now, if any. `gating`: the kinds that gate now. `holders`:
756 * the kinds whose real reading gates now. A stop past its until that one of them keeps applies too (TS1:
757 * `holdsPast`), with the end it will have. A stop of an ended conversation (`endedSid`) no longer counts.
758 */
759export function stopInForce(
760  st: StoppedRecord | undefined,
761  sessionId: string,
762  endedSid: string | undefined,
763  now: number,
764  gating: readonly KindSense[],
765  holders: readonly Holder[],
766): StoppedRecord | undefined {
767  if (st === undefined || st.sessionId !== sessionId || st.sessionId === endedSid) return undefined
768  if (now < st.windowEnd) return st
769  return heldPast(st, holders) ? stillHeld(st, gating, holders, now) : undefined
770}
771
772/** A new stop to write. `real` (TS1): the kinds whose real reading gates at the stop, with their resets now. */
773export type StopWrite = { kinds: Kind[]; windowEnd: number; work: boolean; auto: boolean; test: boolean; skip: boolean; real: readonly Holder[] }
774
775/** 5.7: the 0.2 record, merged with an earlier stop of this session (3.2). Only the real entries of `kinds` are kept. */
776export function stopRecordOf(prev: StoppedRecord | undefined, n: StopWrite, sessionId: string, now: number): StoppedRecord {
777  const { skip, real: realNow, ...rest } = n
778  const real = realNow.filter((h) => n.kinds.includes(h.kind))
779  return mergeStopped(prev, { ...rest, ...(skip ? { skip: true } : {}), ...(real.length > 0 ? { real } : {}), sessionId, at: now }, now)
780}
781
782/**
783 * Skip 4.4: which kinds of a question or a stop reset, and which are open now. A kind is named as reset
784 * when it is neither open nor gating now. With no skip owner and no open kind, the D0.2 lists (every
785 * named kind reset), so each notice keeps its D0.2 wording. `owner` with no sense: nothing is known.
786 */
787export function endedFor(
788  named: readonly Named[],
789  s: { kinds: readonly KindSense[]; now: number } | undefined,
790  gatingNow: readonly KindSense[],
791  owner: boolean,
792): Ended {
793  if (s === undefined) return owner ? { reset: [], open: [] } : { reset: [...named], open: [] }
794  const open = factsFrom(
795    s.kinds.filter((k) => k.open && named.some((n) => n.kind === k.kind)),
796    s.now,
797  )
798  if (!owner && open.length === 0) return { reset: [...named], open: [] }
799  const reset = named.filter((n) => !open.some((f) => (f.kind ?? 'five_hour') === n.kind) && !gatingNow.some((k) => k.kind === n.kind))
800  return { reset, open }
801}
802
803/** How a question settles. */
804export type Via = 'dialog' | 'command' | 'elsewhere' | 'could not ask' | 'dialog ended without an answer' | 'reset' | 'quota' | 'limit' | 'time limit'
805
806/** A settle's result for a Stop (B46): the record as written, what opened, and the until for the reply. */
807export type Late = { record?: StoppedRecord; ended?: Ended; until?: { at: string; lead?: string } }
808
809/** A sense at a Stop here: the kinds, the split and the holders of now. */
810export type StopSense = { s: Sensed; split: Split; holders: readonly Holder[] }
811
812/**
813 * What a Stop here writes. 'open' (B46 open): nothing, because nothing gates and a kind of the question is
814 * open. `limit`: a Stop here at the quota limit (`stopsAtLimit`), with the limit texts.
815 */
816export type StopPlan =
817  | { kind: 'open'; until: number; ended: Ended }
818  | { kind: 'write'; until: number; ended?: Ended; late: KindSense[]; record: StopWrite; limit?: true }
819
820/**
821 * A Stop here (skip 3.5, B46). The sense gives the kinds whose real reading gates now (TS1: the `real`
822 * tag). When the question's time has passed (its hold end with autoResume on, its skip start with it
823 * off), it also gives the kinds that gate now (`late`) and the question's kinds that are open now. The
824 * kinds that gate are stopped as usual. When nothing gates, a kind of the question is open, and no work
825 * waits for a resume prompt, nothing is written: such a stop would never apply. No sense (it failed):
826 * the D0.2 write, with the real kinds of the question when it opened. `auto`: the setting in force.
827 * The kinds at the quota limit that gate now are always late, so the stop lasts until their reset, also
828 * when a question at the reserve had an earlier end: a Stop here, no answer, or the hold time limit. Such
829 * a stop has the limit texts. `atLimit` (`stopsAtLimit`): the limit texts also when the sense failed.
830 */
831export function stopPlan(q: QuestionCore, now: number, auto: boolean, sNow?: StopSense, atLimit = false): StopPlan {
832  const work = q.loops > 0
833  const passed = auto ? q.holdEnd <= now : q.skip && q.stopEnd <= now
834  const real = sNow === undefined ? q.real : sNow.holders // fail closed: the question's real kinds when the sense fails
835  const limitNow = sNow === undefined ? [] : sNow.split.gating.filter((k) => k.limit)
836  const late = sNow === undefined ? [] : passed ? sNow.split.gating : limitNow
837  const opened = sNow !== undefined && passed ? sNow.split.open.filter((k) => q.kinds.includes(k.kind)) : []
838  const until = auto ? Math.max(q.holdEnd, ...late.map((k) => k.holdEnd)) : Math.max(q.stopEnd, ...late.map((k) => k.stopEnd))
839  const ended = sNow === undefined || opened.length === 0 ? undefined : endedFor(namedOf(q), sNow.s, late, true)
840  if (until <= now && ended !== undefined && !(auto && work)) return { kind: 'open', until, ended }
841  const kinds = KINDS.filter((k) => q.kinds.includes(k) || late.some((l) => l.kind === k))
842  const allTest = q.kinds.every((kind) => q.ends[kind]?.test === true) && late.every((k) => k.test)
843  const starts = [
844    ...q.kinds.map((kind) => q.ends[kind]?.skipAt).filter((t): t is number => t !== undefined),
845    ...late.map((k) => k.skipAt).filter((t): t is number => t !== null),
846  ]
847  const skip = skipTag(until, starts, [q.due, ...late.map((k) => k.holdEnd + dueMargin(k))], auto)
848  return {
849    kind: 'write',
850    until,
851    ...(ended === undefined ? {} : { ended }),
852    late,
853    record: { kinds, windowEnd: until, work, auto, test: allTest, skip, real },
854    ...(atLimit || limitNow.length > 0 ? { limit: true as const } : {}),
855  }
856}
857
858/** B46 open: the transcript line of a Stop here that writes nothing. None for a command. */
859export function stopOpenNotice(q: Pick<QuestionCore, 'facts'>, ended: Ended, via: Via): string | undefined {
860  if (via === 'time limit') return notice.holdLimitLate(q.facts, ended, false)
861  if (via !== 'command') return notice.stoppedLate(q.facts, ended, false)
862  return undefined
863}
864
865/**
866 * The transcript line of a Stop here that wrote `written`, and the settle's result. The texts follow the
867 * record as written: the merge can add work, kinds and a later end (3.2). For a skip owner, a late kind's
868 * fresh facts replace the question's (under B45 it is now sensed on the real basis). Else only the late
869 * kinds that the question does not name are added (D0.2). No text for a command.
870 */
871export function stopNotice(
872  q: QuestionCore,
873  plan: Extract<StopPlan, { kind: 'write' }>,
874  written: StoppedRecord,
875  via: Via,
876  now: number,
877  auto: boolean,
878): { text?: string; late: Late } {
879  const work = q.loops > 0
880  const late = plan.late
881  const fresh = factsFrom(late, now, written.skip === true)
882  const facts = byKind(
883    q.skip
884      ? [...q.facts.filter((f) => !late.some((k) => k.kind === (f.kind ?? 'five_hour'))), ...fresh]
885      : [...q.facts, ...fresh.filter((f) => !q.kinds.includes(f.kind ?? 'five_hour'))],
886  )
887  const u = untilFor(facts, written.windowEnd, written.kinds ?? plan.record.kinds, written.skip === true, now)
888  const text = (limit: string, stopped: string): { text?: string } => (via === 'time limit' ? { text: limit } : via !== 'command' ? { text: stopped } : {})
889  if (q.limit === true || plan.limit === true) {
890    // Stop here at the limit stops until the reset and continues nothing. The hold time limit keeps the setting
891    // in force, and so does a question at the reserve that ends with no answer. A stop that continues nothing
892    // and whose end has passed (the question waited past its reset for the answer) names no time, as below.
893    const cont = auto && written.auto === true && written.work === true
894    const at = cont || written.windowEnd > now ? u.at : undefined
895    return { ...text(notice.limitHoldLimit(at, cont), notice.limitStopped(at, cont)), late: { record: written, until: u } }
896  }
897  const ended = plan.ended
898  if (ended !== undefined && late.length === 0 && auto && work && plan.until <= now) {
899    // B46 soon: the stop is written with its passed until, and the next tick continues the work.
900    return { ...text(notice.holdLimitLate(facts, ended, true), notice.stoppedLate(facts, ended, true)), late: { record: written, ended, until: u } }
901  }
902  // A skip stop shows its end in both modes: with autoResume off it ends by time then (skip 1.3 item 6).
903  // With autoResume off, an end that has already passed (the window reset while the question waited,
904  // or the sense failed) is no help to the person: the D0.2 text with no time.
905  const shows = written.skip === true && (auto || written.windowEnd > now)
906  const a = shows ? { ...u, work: auto && written.work === true } : auto ? { ...u, work: written.work === true } : undefined
907  return { ...text(notice.holdLimit(facts, a), notice.stopped(facts, a)), late: { record: written, until: u } }
908}
909
910/** B34: the transcript line of an auto stop that the ticker extended to `longer`. */
911export function extendNotice(r: StoppedRecord, longer: StoppedRecord, gatingNow: readonly KindSense[], s: Pick<Sensed, 'kinds' | 'now'>): string {
912  const skip = longer.skip === true
913  const ended = endedFor(namedStop(r), s, gatingNow, r.skip === true)
914  const facts = factsFrom(gatingNow, s.now, skip)
915  return notice.stopExtended(ended.reset, facts, untilPhrase(untilFor(facts, longer.windowEnd, longer.kinds ?? [], skip, s.now)), ended.open)
916}
917
918/** takeOverdueStop's result (skip 4.6): the stop that was taken over, and what reset and what opened. */
919export type Taken = { record: StoppedRecord } & Ended
920
921/** A stop taken over. `s`: a sense of now, so the notice can name what opened. None: nothing is known. */
922export const takenOf = (record: StoppedRecord, s: { kinds: readonly KindSense[]; now: number } | undefined): Taken => ({
923  record,
924  ...endedFor(namedStop(record), s, [], record.skip === true),
925})
926
927/** 4.6.3: a stop that the ticker cleared while a person prompt was in flight, and when. */
928export type HandedOver = { record: StoppedRecord; at: number }
929
930/**
931 * 4.6.3: the prompt takes a handed-over stop over when the hand-over came less than a check period ago
932 * and no kind of the stop still holds it (TS1).
933 */
934export const handoverTakes = (h: HandedOver | undefined, now: number, holders: readonly Holder[]): h is HandedOver =>
935  h !== undefined && now - h.at < CHECK_MS && !heldPast(h.record, holders)
936
937// ---- The waits (register.tsx 4.5, 4.6, 4.8, B43) ----
938
939/**
940 * 4.5: a waiter's check has nothing to do yet: the question is not due, the next check has not come, and
941 * the note is done or not yet due. Else the host moves `nextCheck` on by CHECK_MS and asks `dueStep`.
942 */
943export const dueWait = (q: Pick<QuestionCore, 'due' | 'nextCheck' | 'noted' | 'noteAt'>, now: number): boolean =>
944  !(now >= q.due) && now < q.nextCheck && (q.noted || now < q.noteAt)
945
946/**
947 * 4.5, B6, B43: the waiter's check after `dueWait`. `auto`: the autoResume setting in force (a quiet
948 * question never reads it: pass true). With it off, a question waits for the answer: 'note' writes the
949 * D0.2 note, 'noteSensed' (a skip owner) senses first and asks `sensedNote`. A quiet question (`quietOf`:
950 * an unattended wait, or a limit question after Continue at the reset) releases by time in both modes.
951 * Past the reset, inside the margin: 'wait'. Else 'check': sense, and ask `dueRelease`.
952 */
953export function dueStep(
954  q: Pick<QuestionCore, 'due' | 'silent' | 'chosen' | 'noted' | 'noteAt' | 'skip' | 'holdEnd'>,
955  now: number,
956  auto: boolean,
957): 'wait' | 'note' | 'noteSensed' | 'check' {
958  const due = now >= q.due
959  if (!quietOf(q) && !auto) {
960    // The setting in force now says wait for the answer (1.3 item 6, B6).
961    if (!q.noted && now >= q.noteAt) return q.skip ? 'noteSensed' : 'note'
962    return 'wait'
963  }
964  if (!due && now >= q.holdEnd) return 'wait' // past the reset, inside the margin: wait (4.8)
965  return 'check'
966}
967
968/**
969 * B43: the note of a skip owner names what opened or reset. `text`: the note, and the question is noted.
970 * `noteAt`: nothing of the question opened yet (a real trip beneath a test reading, B45), so the note
971 * waits for the hold end of its kinds that gate.
972 */
973export function sensedNote(
974  q: Pick<QuestionCore, 'kinds' | 'ends'>,
975  s: Pick<Sensed, 'kinds' | 'now'>,
976  gatingNow: readonly KindSense[],
977  now: number,
978): { text: string } | { noteAt: number } {
979  const ended = endedFor(namedOf(q), s, gatingNow, true)
980  if (ended.reset.length === 0 && ended.open.length === 0) {
981    const mine = gatingNow.filter((k) => q.kinds.includes(k.kind))
982    return { noteAt: Math.max(now + CHECK_MS, ...mine.map((k) => k.holdEnd)) }
983  }
984  // New work goes on only while no kind gates: a window that still gates holds new work too.
985  return { text: notice.resetWaitingFor(ended.reset, ended.open, gatingNow.length === 0) }
986}
987
988/**
989 * 4.5: whether a check releases the held work, and why. Before the due time only when no kind gates. A
990 * stop never holds an open kind (B44), so no kind gating means the gate lets the held loops through (skip
991 * 4.3). `tooRecent` (`resetTooRecent`): a test window that ends near a real reset waits (4.8). 'limit': a
992 * question that is not a limit question gives way to the limit question (`supersedes`). A limit question
993 * ends before its due time only when no kind that gates is at the quota limit any more ('quota').
994 */
995export function dueRelease(
996  q: Pick<QuestionCore, 'due' | 'limit' | 'silent'>,
997  now: number,
998  gatingNow: readonly KindSense[],
999  tooRecent: boolean,
1000): 'reset' | 'quota' | 'limit' | undefined {
1001  const due = now >= q.due
1002  if (supersedes(q, gatingNow)) return 'limit'
1003  if (!due && (q.limit === true ? gatingNow.some((k) => k.limit) : gatingNow.length > 0)) return undefined
1004  if (gatingNow.length === 0 && tooRecent) return undefined
1005  return due ? 'reset' : 'quota'
1006}
1007
1008/**
1009 * 4.5: the transcript line of a question that ends without an answer. The D0.2 texts byte for byte. A
1010 * question that gives way to the limit question, and a limit question that ends before its reset, have
1011 * their own lines.
1012 */
1013export function againNotice(
1014  q: Pick<QuestionCore, 'kinds' | 'ends' | 'skip' | 'limit'>,
1015  via: 'reset' | 'quota' | 'limit',
1016  gatingNow: readonly KindSense[],
1017  s: Pick<Sensed, 'kinds' | 'now'>,
1018): string {
1019  if (via === 'limit') return notice.limitReached
1020  if (via === 'quota' && q.limit === true) return notice.limitOver
1021  const ended = endedFor(namedOf(q), s, gatingNow, q.skip)
1022  if (!q.skip && ended.open.length === 0) {
1023    if (via === 'quota') return notice.outOfReserve
1024    if (gatingNow.length === 0) return notice.resetContinues(namedOf(q))
1025    return notice.resetStillHeld(namedOf(q), factsFrom(gatingNow, s.now))
1026  }
1027  if (gatingNow.length === 0) return ended.reset.length + ended.open.length > 0 ? notice.resetContinues(ended.reset, ended.open) : notice.outOfReserve
1028  return notice.resetStillHeld(ended.reset, factsFrom(gatingNow, s.now), ended.open)
1029}
1030
1031/**
1032 * 4.6: what the ticker does with an auto stop past its due time. 'skip': nothing gates, but a real reset
1033 * came less than the margin ago (4.8). 'extend': a kind still gates (B34). 'end': the stop ends.
1034 */
1035export const tickPlan = (gatingNow: readonly KindSense[], tooRecent: boolean): 'skip' | 'extend' | 'end' =>
1036  gatingNow.length === 0 && tooRecent ? 'skip' : gatingNow.length > 0 ? 'extend' : 'end'
1037
1038/** B50 item 3: a consent answers a question's kind at a matching tier. A consent to the floor never answers a kind asked at the floor. */
1039export const answersKind = (end: QuestionEnd | undefined, list: readonly Sourced[], now: number): boolean =>
1040  end !== undefined && list.some((e) => answersQuestion(e.c, end, now))
1041
1042/** R6: a stop newer than the question and still in force settles it as Stop here, when it is of this session. */
1043export const stopNewer = (st: StoppedRecord | undefined, q: Pick<QuestionCore, 'since'>, now: number): st is StoppedRecord =>
1044  st !== undefined && st.at > q.since && now < st.windowEnd
1045
1046// ---- Commands (register.tsx 2.7, 2.8, 2.9, 4.6, B23 to B26, B44, B46, B50, B53) ----
1047
1048/** The trip point of a reserve, on the one-decimal grid. */
1049export const tripOf = (reserve: number): number => Math.round((100 - reserve) * 10) / 10
1050
1051/**
1052 * What a command's takeover knows of now (skip 4.6, TS1): the kinds, and the kinds whose real reading
1053 * gates (`commandHolders`). With no sense nothing is known, and the takeover follows 4.6.
1054 */
1055export function takeoverSense(sNow: Pick<Sensed, 'kinds'> | undefined): { kinds?: readonly KindSense[]; holders: readonly Holder[] } {
1056  if (sNow === undefined) return { holders: [] }
1057  return { kinds: sNow.kinds, holders: commandHolders(sNow.kinds) }
1058}
1059
1060/**
1061 * B50 item 4: resume on an open question raises a kind to a full Resume when the fresh sense of that
1062 * kind is at the floor, on the question's basis and in the question's window. Its facts then follow the
1063 * fresh sense, for the reply and the B9 note. Else the question's tier stands. It updates `q`.
1064 */
1065export function raiseAtFloor(q: Pick<QuestionCore, 'ends' | 'facts' | 'skip'>, sNow: Pick<Sensed, 'kinds' | 'now'>): void {
1066  for (const k of sNow.kinds) {
1067    const end = q.ends[k.kind]
1068    if (end?.to === undefined || !k.atFloor || k.test !== end.test || Math.abs(k.windowEnd - end.end) > 60_000) continue
1069    const { to: _to, ...full } = end
1070    q.ends[k.kind] = full
1071    q.facts = byKind([...q.facts.filter((f) => (f.kind ?? 'five_hour') !== k.kind), ...factsFrom([k], sNow.now, q.skip)])
1072  }
1073}
1074
1075/**
1076 * Resume on an open limit question whose limit is gone (limitPause off, credits that pay, or a new test
1077 * reading): the limit question asked no tier, so each kind of it takes its tier now. A kind before its
1078 * floor, also one below its reserve, gets a consent to the floor. A kind at the floor or open keeps a full
1079 * Resume. Only on the question's basis and in its window. Its facts and mode then follow the fresh sense,
1080 * for the reply. So no work runs past the floor without a second Resume. It updates `q`.
1081 */
1082export function lowerAfterLimit(
1083  q: Pick<QuestionCore, 'ends' | 'facts' | 'skip' | 'limit' | 'mode'>,
1084  sNow: Pick<Sensed, 'kinds' | 'now' | 'cfg'>,
1085): void {
1086  if (q.limit !== true) return
1087  let lowered = false
1088  for (const k of sNow.kinds) {
1089    const end = q.ends[k.kind]
1090    if (end === undefined || end.to !== undefined || k.limit || k.test !== end.test || Math.abs(k.windowEnd - end.end) > 60_000) continue
1091    const to = k.open || k.atFloor || k.point === null ? undefined : k.point
1092    if (to === undefined) continue
1093    q.ends[k.kind] = { ...end, to }
1094    q.facts = byKind([...q.facts.filter((f) => (f.kind ?? 'five_hour') !== k.kind), ...factsFrom([k], sNow.now, q.skip, () => to)])
1095    lowered = true
1096  }
1097  if (lowered) q.mode = modeOf(sNow.cfg)
1098}
1099
1100/** A resume command on an open question: each kind at its tier now (`raiseAtFloor`, `lowerAfterLimit`). It updates `q`. */
1101export function tierAtResume(
1102  q: Pick<QuestionCore, 'ends' | 'facts' | 'skip' | 'limit' | 'mode'>,
1103  sNow: Pick<Sensed, 'kinds' | 'now' | 'cfg'>,
1104): void {
1105  raiseAtFloor(q, sNow)
1106  lowerAfterLimit(q, sNow)
1107}
1108
1109/**
1110 * B23: the reply of resume from the reading alone: no reading, or below every reserve. Undefined: split
1111 * next. `absent` (Codex design 2.1, CX17): the kinds the host reports no window for. Claude passes none.
1112 */
1113export function resumeReadReply(s: Pick<Sensed, 'kinds' | 'now' | 'tripped'>, absent?: readonly Kind[]): string | undefined {
1114  const read = s.kinds.filter((k) => k.basis.kind !== 'none')
1115  if (read.length === 0) return resumeReply('none', undefined, undefined, undefined, 'hold', absent)
1116  if (!s.tripped) return resumeReply('below', factsFrom(read, s.now), undefined, undefined, 'hold', absent)
1117  return undefined
1118}
1119
1120/**
1121 * B23, B44, floor 2.8: resume after the split. A reply when nothing gates: an open kind has nothing to
1122 * resume, and a consented kind is already resumed. Else the consents to write, each gating kind at its
1123 * tier now (floor 1.3 item 2: before the floor to the floor, past it until the reset), and the facts of
1124 * the reply ('stopped' when a stop applied, else 'tripped').
1125 */
1126export function resumeCase(
1127  s: Pick<Sensed, 'now'>,
1128  split: Split,
1129  mode: Mode,
1130): { reply: string } | { gating: KindSense[]; write: Array<{ kind: Kind; c: Consent; test: boolean }>; facts: Facts[] } {
1131  const gating = split.gating
1132  if (gating.length === 0 && split.open.length > 0) return { reply: resumeReply('open', factsFrom(split.open, s.now)) } // B44: nothing to resume
1133  if (gating.length === 0) {
1134    // Floor 2.8: the facts come from the consents that cover each kind, never from the stage. The floor
1135    // form names the tripped kinds that are not open. With no consent to the floor, the 0.2 form names them all.
1136    const shut = split.consented.filter((x) => !x.k.open)
1137    const named = shut.some((x) => x.c.to !== undefined) ? shut : split.consented
1138    return {
1139      reply: resumeReply(
1140        'consented',
1141        factsFrom(
1142          named.map((x) => x.k),
1143          s.now,
1144          false,
1145          consentEnd(named),
1146        ),
1147        undefined,
1148        undefined,
1149        mode,
1150      ),
1151    }
1152  }
1153  const write = gating.map((k) => {
1154    const to = resumeTo(k)
1155    return { kind: k.kind, c: { until: k.windowEnd, ...(to === undefined ? {} : { to }) }, test: k.test }
1156  })
1157  return { gating, write, facts: factsFrom(gating, s.now, false, resumeTo) }
1158}
1159
1160/**
1161 * Resume at the quota limit. An open limit question that is not chosen yet, while a kind is at the quota
1162 * limit (or the sense failed): choose Continue at the reset (`choose`). Else, while a kind is at the quota
1163 * limit (or a limit question is open and the sense failed): a reply that nothing resumes now. Nothing is
1164 * written or cleared: no consent, no stop takeover, no stop clear. Undefined: the resume of today, also
1165 * for a limit question whose limit is gone (limitPause off, credits that pay, or a reset): it settles as Resume,
1166 * each kind at its tier now (`tierAtResume`). A host whose first sense failed asks again on its second one.
1167 */
1168export function resumeAtLimit(
1169  sNow: Pick<Sensed, 'kinds' | 'now'> | undefined,
1170  q: Pick<QuestionCore, 'limit' | 'chosen' | 'facts'> | undefined,
1171): { choose: boolean; reply: string } | undefined {
1172  const at = sNow?.kinds.filter((k) => k.limit) ?? []
1173  if (q?.limit === true && q.chosen !== true && (sNow === undefined || at.length > 0)) return { choose: true, reply: resumeReply('limit-asking', q.facts) }
1174  if (sNow !== undefined && at.length > 0) return { choose: false, reply: resumeReply('limit', factsFrom(at, sNow.now)) }
1175  if (sNow === undefined && q?.limit === true) return { choose: false, reply: resumeReply('limit', q.facts) }
1176  return undefined
1177}
1178
1179/**
1180 * The reply of a resume that settles an open question. A limit question whose reset has passed (it waited
1181 * for the answer with autoResume off) names no time: a passed reset is no help to the person.
1182 */
1183export const resumeAskingReply = (q: Pick<QuestionCore, 'facts' | 'mode' | 'limit' | 'holdEnd'> | undefined, now: number): string =>
1184  q?.limit === true && q.holdEnd <= now ? resumeReply('limit-late') : resumeReply('asking', q?.facts, undefined, undefined, q?.mode)
1185
1186/** The kinds that gate after a stop: tripped and not open. Unknown (false) when the sense failed. */
1187export const gatesAfter = (sNow: Pick<Sensed, 'kinds'> | undefined): boolean => sNow?.kinds.some((k) => k.tripped && !k.open) === true
1188
1189/**
1190 * B24: the reply of a stop that took an overdue stop over while no kind gates (D0.2). An open kind needs
1191 * no stop (B44). The reply names a reset or an open reserve only when one came.
1192 */
1193export function stopOverdueReply(t: Ended): string {
1194  if (t.open.length > 0) return stopReply('overdue-open', t.open)
1195  if (t.reset.length > 0) return stopReply('overdue')
1196  return stopReply('overdue-skip')
1197}
1198
1199/**
1200 * B24, B46: the reply of a stop on an open question, from its settle. It follows the record as written
hooks/core/host.ts 44 lines
1// The words that differ between hosts (Codex design 2.1). Pure: it imports nothing.
2// A core text names a host word only through HOST, so each host renders its own words.
3
4/** The words that differ between hosts. The Codex bundle swaps this file for codex/src/host.ts. */
5export type Host = {
6  name: string // the host, as texts say it
7  command: string // the command a person types at an idle prompt
8  anytime: string // the command a person runs at any time, also during a turn
9  config: string // where options come from, in the report: 'from {config}'
10  child: string // the report label of nested unattended runs, 15 characters at most
11  resume: string // the command that picks up an unattended run, before its id
12  keepOpen: string // the end of 'To keep a reserve until the reset, {keepOpen}.'
13  limitOff: string // the end of 'To let work run past the limit, {limitOff}.'
14  backIt: string // what Stop here does with a held prompt, tell mode
15  backPrompt: string // what Stop here does with a held prompt, hold mode
16  blind: string // the blind phase detail, first sentence
17  leadSimulate: string // the first words of the bad simulate reply
18  leadFailed: string // the first words of the failed command reply
19  stoppedAgent: string // the RESUME_TAIL sentence about a subagent that did not finish
20  dialog: string // the name of the question in the asking phase line
21  optIn: string // the guarded row hint under scope opt-in
22  unreadSource: string // what spare10 could not read when a span source is 'unread'
23}
24
25export const HOST: Host = {
26  name: 'Claude Code',
27  command: '/spare10',
28  anytime: '/spare10',
29  config: '/config',
30  child: 'claude -p',
31  resume: 'claude --resume',
32  keepOpen: 'set its Open reserve option to 0 in /config',
33  limitOff: 'turn off Pause at the limit in /config',
34  backIt: 'gives it back to you',
35  backPrompt: 'gives your prompt back',
36  blind: 'Claude Code reports no 5-hour quota.',
37  leadSimulate: '/spare10 simulate',
38  leadFailed: '/spare10',
39  stoppedAgent: 'A subagent whose result says "spare10: work stopped" or "spare10: the user stopped work" did not finish.',
40  dialog: 'dialog',
41  optIn: 'Start with SPARE10=on to guard a run.',
42  unreadSource: 'the env',
43}
44
types.d.ts 22 lines
1// The spare10 type contract (plugin.json "types"). Types only: no import, export-from or reference.
2
3/** The budget-free carrier a held dispatch waits on, and its wake-up (section 4.4 of the design). */
4export type Spare10Hold = {
5  /** Parks the caller until spare10 wakes its waiters, or until the same waiter parks again. */
6  park: (args: { waiter: string }) => Promise<string>
7  /** Wakes every waiter parked in the newest copy of spare10. */
8  poke: (args: { from: string }) => Promise<string>
9  /** The autoResume setting in force: noun calls route to the newest copy of spare10. */
10  auto: () => Promise<boolean>
11  /** The spans in force: noun calls route to the newest copy of spare10. */
12  spans: () => Promise<{ lastMinutes: number; weeklyLastHours: number }>
13  /** The pause at the quota limit in force (limitPause): noun calls route to the newest copy of spare10. */
14  limit: () => Promise<boolean>
15}
16
17declare module 'claude-code' {
18  interface EngineInterface {
19    spare10: Spare10Hold
20  }
21}
22