@gotcos/glasses-server 6.81.0 → 6.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## 6.82.0 - Routines: any COS skill on a schedule, each with its own cap, off switch and breaker
2
+
3
+ Miles, 2026-10-20 (plan Decision Q4): "Any COS skill on a schedule. Pick a skill, a time and days; each routine has its own cap and off switch." Until now the server had one routine, the morning brief.
4
+
5
+ - **Client matrix.** Server only in this release. Companion 6.10.650 lists, adds, edits, pauses and runs routines from the phone; older companions and COS Control keep using `/api/morning-brief`, which is unchanged. Nothing runs that a user did not create: a fresh server has the morning brief and no skill routines.
6
+ - **The morning brief is routine #1, unchanged.** Its config file, scheduler, prompt, scheduled submission, `/api/morning-brief` envelope, a rejected PUT and Run now while it runs are pinned by `morning-brief-golden.test.ts`, recorded on the unmodified 6.81.0 base (6b2e70b) before any routine code existed and passing byte for byte after. The only change it sees: its run is counted in the token audit as `routine:morning-brief` instead of `full_query`.
7
+ - **`/api/routines`.** `GET` lists the brief first, then skill routines, each with schedule, next run, gate (why it will not run), breaker state, today's runs against its cap, and its last run. `GET /routines/skills` lists this workspace's skills (`.agents/skills`, `.claude/skills`; labels only, never paths). `POST` creates; `PATCH` (or `PUT`) edits, and `resetBreaker: true` closes an open breaker; `POST /:id/pause` and `/:id/resume`; `DELETE` (the brief can only be turned off: 409 `cannot_delete_brief`); `POST /:id/run` is Run now (202; 409 running, off or skill missing; 423 breaker open, with `openUntil`; 429 capped); `GET /:id/runs` and `/routines/runs` are history. A brief edit through `/api/routines/morning-brief` takes only the brief's own fields and refuses any other (400 `not_a_brief_field`).
8
+ - **A skill routine.** A workspace skill, an optional extra instruction, a time and days in a zone (default the Mac's, weekdays at 08:00), runs a day (default 2, at most 10), a model (default `sonnet`, never left to the user default, never a key lane), and its breaker (default 3 failures, 6 hours). Stored in `data/routines/routines.json`, runs in `data/routines/runs.json` (0600, atomic; a damaged file keeps its good routines and drops the rest). It runs as an ordinary durable job with `origin: { kind: 'routine', id }`, so it survives the phone sleeping and lands in the inbox with a message number. Its prompt says nobody is watching: read and summarise freely, write only the skill's own files, never send, publish, deploy, push, delete or spend; if a source is missing, say so in one line.
9
+ - **How it stops.** Its own switch, and `COS_ROUTINES=0` turns every skill routine off (the brief keeps its own switch). Its daily cap counts scheduled and Run now runs together; a failed submit never ran and is not counted. Every skill routine together is capped at 40 runs a day. The breaker opens after N consecutive failed runs (a failed or interrupted job, or a failed submit) and nothing fires until the cooldown ends; then one trial run is allowed, a success closes it and a failure opens it again. A canceled run counts neither way, and each finished run is counted once however many ticks read it. One scheduled slot per selected day, three submit attempts two minutes apart, crash-resumed by a fixed client id. Inert while Background jobs are off or maintenance is closed, as the brief.
10
+ - **Token audit by caller.** A routine's run is logged as `routine:<id>` (Claude, Codex, Cursor and direct bridges; the runtime passes `auditCaller` only for routine origins, so every other caller is byte for byte what it was).
11
+ - **Checks.** Canary C7 (`server/scripts/routines-canary.ts`, not packaged): on a scratch data dir, a scratch HOME and workspace, an ephemeral port, and a stub `claude` on PATH, two routines fired at their slot on the production 30 s tick; the one whose skill fails exited 1, the breaker counted it, a second failure opened it, Run now answered 423 and the next tick skipped it, the other routine stayed ready, and the token audit row read `routine:<id>` on `sonnet`. Mutation gate `server/scripts/routines-mutation-gate.mjs` (not packaged): green baseline (55 tests in 6 files), 42 of 42 mutants killed, each by a named test. Full suite 8,503 passed, 0 failed, 3 skipped (562 files); `tsc --noEmit` clean. The key-lanes census classifies the three new model sites as Reject.
12
+
1
13
  ## 6.81.0 - The Mac saves a stopped meeting on its own once it holds every chunk
2
14
 
3
15
  Miles, 2026-10-20: "if the application isn't in the foreground, it can fail to save the meetings". Both G2 meetings that day saved only once the phone came back on screen: the phone's Stop needs IndexedDB, and iOS suspends IndexedDB while the companion is hidden (11:19 qbbgyq: Stop 12:43, `stop_blocked`, "Load failed", saved 12:47 by a new WebView; 14:07 mip9qb: Stop 15:13:50, saved 15:14:37 only after the phone was visible). The full map is `docs/meeting-autonomous-save.md`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.81.0",
3
+ "version": "6.82.0",
4
4
  "description": "COS Glasses: self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
@@ -55,6 +55,8 @@
55
55
  "!server/scripts/fact-check-mutation-gate.mjs",
56
56
  "!server/scripts/stale-session-mutation-gate.mjs",
57
57
  "!server/scripts/autonomous-save-mutation-gate.mjs",
58
+ "!server/scripts/routines-mutation-gate.mjs",
59
+ "!server/scripts/routines-canary.ts",
58
60
  "!server/scripts/stt-mutation-gate.mjs",
59
61
  "!server/scripts/dictation-stt-mutation-gate.mjs",
60
62
  "!server/scripts/pairing-mutation-gate.mjs",
package/server/index.ts CHANGED
@@ -170,6 +170,8 @@ import { listBoard } from './lib/task-store.js'
170
170
  import { createDefaultWorkReviewRuntime } from './lib/work-review-backend.js'
171
171
  import { createMorningBriefRouter } from './routes/morning-brief.js'
172
172
  import { getMorningBriefScheduler, startMorningBriefScheduler, stopMorningBriefScheduler } from './lib/morning-brief-runtime.js'
173
+ import { createRoutinesRouter } from './routes/routines.js'
174
+ import { getRoutineRegistry, routineSkillCatalog, startRoutineScheduler, stopRoutineScheduler } from './lib/routines-runtime.js'
173
175
  import {
174
176
  initQueryJobRuntime,
175
177
  preparePublicDurableQueryAdmission,
@@ -838,6 +840,8 @@ app.use('/api', queryRouter)
838
840
  // The scheduled start-of-day brief: settings, status, run-now. Same auth as
839
841
  // every other settings route; the brief itself is an ordinary durable job.
840
842
  app.use('/api', createMorningBriefRouter(getMorningBriefScheduler))
843
+ // 6.82.0: every routine (the brief first, then skill routines), each with its own cap, switch and breaker.
844
+ app.use('/api', createRoutinesRouter(getRoutineRegistry, routineSkillCatalog))
841
845
  app.use('/api', providerProofRouter)
842
846
  app.use('/api', transcribeRouter)
843
847
  // Ported from cos-glasses-app in 6.24.0. The companion's Sessions tab has been
@@ -1257,6 +1261,7 @@ async function gracefulShutdown(): Promise<void> {
1257
1261
  forceExit.unref?.()
1258
1262
  await meetingAskRuntime.shutdown()
1259
1263
  stopMorningBriefScheduler()
1264
+ stopRoutineScheduler()
1260
1265
  await workReviewRuntime?.close()
1261
1266
  workIntakeStore?.close()
1262
1267
  workHandoffRequestStore?.close()
@@ -1534,6 +1539,8 @@ listenRequiredServers(listeners).then(() => {
1534
1539
  // that store is ready. Its own tick checks the durable-jobs switch and the
1535
1540
  // maintenance gate, so starting it here is safe when either is off.
1536
1541
  startMorningBriefScheduler()
1542
+ // 6.82.0: skill routines ride the same coordinator and the same gates.
1543
+ startRoutineScheduler()
1537
1544
  void workReviewRuntime?.start().catch(error => console.error('[work-reviews] reconciliation unavailable:', error))
1538
1545
  }).catch(error => {
1539
1546
  // The store remains degraded and rejects admission. Legacy /api/query is
@@ -436,6 +436,11 @@ export interface CallOptions {
436
436
  dispatch?: { restricted: true; tools: readonly string[] }
437
437
  /** 6.58.2: `claude --name` for a session this COS session starts fresh (QueryJobRequest.sessionName). */
438
438
  sessionName?: string
439
+ /**
440
+ * 6.82.0: the token-audit `caller` for this run, so a server-started job is counted by who started
441
+ * it (`routine:morning-brief`, `routine:routine-<id>`). Absent, every bridge writes the caller it always did.
442
+ */
443
+ auditCaller?: string
439
444
  /**
440
445
  * 6.78.0 Messages steer: the job's steer port. Present only when the coordinator decided the
441
446
  * run is steerable (lib/claude-steer.ts). Absent, every argument and every stdin byte is the
@@ -927,7 +932,7 @@ export async function callClaudeStreaming(
927
932
  inputChars: inputEstimate,
928
933
  outputChars: text.length,
929
934
  durationMs: totalMs,
930
- caller: options?.lightweight ? 'voice_query' : 'full_query',
935
+ caller: options?.auditCaller ?? (options?.lightweight ? 'voice_query' : 'full_query'),
931
936
  ...keyAuditFields,
932
937
  ...keyUsdFields,
933
938
  })
@@ -817,7 +817,7 @@ export async function callCodexStreaming(
817
817
  inputChars: systemPrompt.length + fullQuery.length + contextPrompt.length,
818
818
  outputChars: text.length,
819
819
  durationMs: totalMs,
820
- caller: options?.lightweight ? 'voice_query' : 'full_query',
820
+ caller: options?.auditCaller ?? (options?.lightweight ? 'voice_query' : 'full_query'),
821
821
  ...keyAuditFields,
822
822
  ...keyUsdFields,
823
823
  })
@@ -528,7 +528,7 @@ export async function callCursorStreaming(
528
528
  inputChars: systemPrompt.length + fullQuery.length + contextPrompt.length,
529
529
  outputChars: text.length,
530
530
  durationMs: totalMs,
531
- caller: options?.lightweight ? 'voice_query' : 'full_query',
531
+ caller: options?.auditCaller ?? (options?.lightweight ? 'voice_query' : 'full_query'),
532
532
  turnId: options?.turnId,
533
533
  runId: run.runId,
534
534
  sessionId: sid,
@@ -564,7 +564,7 @@ export async function callDirectStreaming(
564
564
  inputChars: messages.reduce((n, m) => n + (typeof m.content === 'string' ? m.content.length : 0), 0),
565
565
  outputChars: text.length,
566
566
  durationMs: Date.now() - startTime,
567
- caller: 'direct_query',
567
+ caller: options?.auditCaller ?? 'direct_query',
568
568
  ...(options?.turnId ? { turnId: options.turnId } : {}),
569
569
  runId: run.runId,
570
570
  sessionId: sid,
@@ -113,9 +113,15 @@ function daysBetween(fromDay: string, toDay: string): number {
113
113
  return Math.round((Date.UTC(ty, tm - 1, td) - Date.UTC(fy, fm - 1, fd)) / 86_400_000)
114
114
  }
115
115
 
116
+ /** The schedule fields the arithmetic reads. The morning brief config and a
117
+ * 6.82.0 skill routine both carry them, so both share one decision table. */
118
+ export type ScheduleShape = Pick<MorningBriefConfig, 'enabled' | 'time' | 'timezone' | 'days' | 'catchUpMinutes'>
119
+ /** The ledger fields the fire decision reads (a brief run and a routine run both have them). */
120
+ export type ScheduledRunShape = Pick<MorningBriefRun, 'day' | 'trigger' | 'attempt' | 'firedAt' | 'jobId' | 'submitError'>
121
+
116
122
  /** ISO instant of the next scheduled fire at or after `nowMs`, or null when
117
123
  * the brief is disabled or no weekday is selected. */
118
- export function nextScheduledFire(config: MorningBriefConfig, nowMs: number): string | null {
124
+ export function nextScheduledFire(config: Pick<ScheduleShape, 'enabled' | 'time' | 'timezone' | 'days'>, nowMs: number): string | null {
119
125
  if (!config.enabled || config.days.length === 0) return null
120
126
  const target = parseTime(config.time)
121
127
  const clock = localClock(nowMs, config.timezone)
@@ -129,8 +135,8 @@ export function nextScheduledFire(config: MorningBriefConfig, nowMs: number): st
129
135
  return null
130
136
  }
131
137
 
132
- export type FireDecision =
133
- | { fire: true; day: string; attempt: number; resume?: MorningBriefRun }
138
+ export type FireDecision<R extends ScheduledRunShape = MorningBriefRun> =
139
+ | { fire: true; day: string; attempt: number; resume?: R }
134
140
  | { fire: false; reason: 'disabled' | 'not_a_brief_day' | 'before_slot' | 'window_closed' | 'already_fired' | 'in_flight' | 'attempts_exhausted' | 'backoff' }
135
141
 
136
142
  /**
@@ -140,11 +146,11 @@ export type FireDecision =
140
146
  * job id and no error is a crash between ledger write and admission — resumed
141
147
  * by client identity rather than re-run.
142
148
  */
143
- export function decideScheduledFire(
144
- config: MorningBriefConfig,
145
- runs: readonly MorningBriefRun[],
149
+ export function decideScheduledFire<R extends ScheduledRunShape = MorningBriefRun>(
150
+ config: ScheduleShape,
151
+ runs: readonly R[],
146
152
  nowMs: number,
147
- ): FireDecision {
153
+ ): FireDecision<R> {
148
154
  if (!config.enabled) return { fire: false, reason: 'disabled' }
149
155
  const clock = localClock(nowMs, config.timezone)
150
156
  if (!config.days.includes(clock.weekday)) return { fire: false, reason: 'not_a_brief_day' }
@@ -1,3 +1,4 @@
1
+ import { routineAuditCaller } from './routines-config.js'
1
2
  import { carriesBoundTo } from './agent-session-binding-store.js'
2
3
  import { randomUUID } from 'node:crypto'
3
4
  import { resolve } from 'node:path'
@@ -420,6 +421,8 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
420
421
  sessionLockHeld: true,
421
422
  ...(request.dispatch ? { dispatch: request.dispatch } : {}),
422
423
  ...(request.sessionName ? { sessionName: request.sessionName } : {}),
424
+ // 6.82.0: a routine's run is counted in the token audit under the routine that started it.
425
+ ...(routineAuditCaller(request) ? { auditCaller: routineAuditCaller(request) } : {}),
423
426
  ...(request.keyTurnOnly ? { keyTurnOnly: true } : {}),
424
427
  // 6.78.0: only on a run the coordinator decided is steerable.
425
428
  ...(steer ? { steer } : {}),
@@ -0,0 +1,421 @@
1
+ // 6.82.0 Routines: any COS skill on a schedule (Miles, 2026-10-20, Q4: "Pick a skill, a time
2
+ // and days; each routine has its own cap and off switch").
3
+ //
4
+ // The morning brief stays exactly what it was (its own config file, scheduler and golden) and is
5
+ // listed as routine #1 by the registry. Everything here is the SECOND kind: a skill routine.
6
+ //
7
+ // HOW A SKILL ROUTINE STOPS (every new provider caller answers this):
8
+ // - its own off switch (`enabled`), and the master switch COS_ROUTINES=0 for all of them;
9
+ // - its own daily cap: admitted runs per local day, scheduled and Run now together;
10
+ // - a server-wide ceiling across every skill routine per day (ROUTINE_LIMITS.globalRunsPerDay);
11
+ // - a breaker: N consecutive failed runs open it for a cooldown, and nothing fires while it is open;
12
+ // - one scheduled slot per selected day, at most `scheduledAttemptsPerDay` submit attempts;
13
+ // - inert while durable jobs are off or maintenance admissions are closed (as the brief).
14
+ // It runs on a cheap model (sonnet) unless the routine names another, and never on a key lane.
15
+
16
+ import { createHash, randomBytes } from 'node:crypto'
17
+ import { durableAtomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
18
+ import { dataPath } from './data-dir.js'
19
+ import { ensurePrivateDir, isValidTimezone, serverTimezone } from './morning-brief-config.js'
20
+ import { KEY_PROFILE_REJECT_CODE, KEY_PROFILE_SCHEDULED_REJECT, keyProfileRejectApplies } from './key-profile-rejects.js'
21
+ import {
22
+ normalizeEffortPreference,
23
+ normalizeModelPreference,
24
+ type EffortPreference,
25
+ type ModelPreference,
26
+ } from '../../shared/model-preference.js'
27
+
28
+ export const ROUTINES_PROTOCOL_VERSION = 1 as const
29
+
30
+ export const ROUTINE_LIMITS = Object.freeze({
31
+ /** Skill routines one server holds (the morning brief is not counted). */
32
+ maxRoutines: 12,
33
+ nameChars: 60,
34
+ instructionChars: 1_000,
35
+ skillNameChars: 64,
36
+ dailyCapDefault: 2,
37
+ dailyCapMin: 1,
38
+ dailyCapMax: 10,
39
+ /** Every skill routine together, per local day of the server zone. */
40
+ globalRunsPerDay: 40,
41
+ breakerFailuresDefault: 3,
42
+ breakerFailuresMin: 1,
43
+ breakerFailuresMax: 10,
44
+ cooldownMinutesDefault: 6 * 60,
45
+ cooldownMinutesMin: 15,
46
+ cooldownMinutesMax: 7 * 24 * 60,
47
+ catchUpMinutesDefault: 3 * 60,
48
+ catchUpMinutesMax: 12 * 60,
49
+ /** Submit attempts for one scheduled slot before the day is given up (as the brief). */
50
+ scheduledAttemptsPerDay: 3,
51
+ attemptSpacingMs: 2 * 60_000,
52
+ retainedRuns: 240,
53
+ })
54
+
55
+ /** Unless a routine names another model, it runs here: the server's fast default, never Opus. */
56
+ export const ROUTINE_DEFAULT_MODEL: ModelPreference = 'sonnet'
57
+
58
+ const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/
59
+ const CONTROL_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g
60
+ /** A skill's loader identity (`good-morning`, `customer-marketing:intel`). No slash, no path. */
61
+ export const ROUTINE_SKILL_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/
62
+ /** Routine ids double as the job origin id (QUERY_JOB_ORIGIN_ID_RE) and never equal `morning-brief`. */
63
+ export const ROUTINE_ID_RE = /^routine-[0-9a-f]{10}$/
64
+
65
+ export class RoutineConfigError extends Error {
66
+ constructor(readonly code: string, message = code, readonly status = 400) {
67
+ super(message)
68
+ this.name = 'RoutineConfigError'
69
+ }
70
+ }
71
+
72
+ export interface RoutineBreakerSettings {
73
+ /** Consecutive failed runs that open the breaker. */
74
+ failures: number
75
+ /** How long it stays open. */
76
+ cooldownMinutes: number
77
+ }
78
+
79
+ export interface SkillRoutine {
80
+ id: string
81
+ kind: 'skill'
82
+ name: string
83
+ /** Skill loader identity, without the slash. */
84
+ skill: string
85
+ /** Optional words added after the skill call. */
86
+ instruction: string
87
+ enabled: boolean
88
+ time: string
89
+ timezone: string
90
+ days: number[]
91
+ catchUpMinutes: number
92
+ /** Admitted runs per local day, scheduled and Run now together. */
93
+ dailyCap: number
94
+ model: ModelPreference
95
+ effort?: EffortPreference
96
+ breaker: RoutineBreakerSettings
97
+ createdAt: string
98
+ updatedAt: string
99
+ }
100
+
101
+ export interface RoutineBreakerState {
102
+ consecutiveFailures: number
103
+ /** Set while the breaker is open. Nothing fires before this instant. */
104
+ openUntil?: string
105
+ openedAt?: string
106
+ lastFailure?: { code: string; message: string; at: string }
107
+ }
108
+
109
+ export type RoutineTrigger = 'scheduled' | 'manual'
110
+
111
+ export interface RoutineRun {
112
+ id: string
113
+ routineId: string
114
+ day: string
115
+ trigger: RoutineTrigger
116
+ attempt: number
117
+ firedAt: string
118
+ clientJobId: string
119
+ generation: 1
120
+ sessionId: string
121
+ messageEra?: string
122
+ globalMsgNum?: number
123
+ jobId?: string
124
+ submitError?: { code: string; message: string }
125
+ lastKnownStatus?: string
126
+ /** Set once the run's result has been counted by the breaker (exactly once per run). */
127
+ outcome?: 'completed' | 'failed' | 'canceled'
128
+ }
129
+
130
+ export interface RoutinesFile {
131
+ v: 1
132
+ routines: SkillRoutine[]
133
+ breakers: Record<string, RoutineBreakerState>
134
+ }
135
+
136
+ export interface RoutinesLedger {
137
+ v: 1
138
+ runs: RoutineRun[]
139
+ }
140
+
141
+ export interface RoutineStorePaths {
142
+ routines: string
143
+ runs: string
144
+ }
145
+
146
+ export function routinePaths(root?: string): RoutineStorePaths {
147
+ const configured = root ?? process.env.COS_ROUTINES_DIR?.trim()
148
+ const base = configured ? configured : dataPath('routines')
149
+ return { routines: `${base}/routines.json`, runs: `${base}/runs.json` }
150
+ }
151
+
152
+ /** Master switch. Only an explicit `0`, `false`, `off` or `no` turns every skill routine off. */
153
+ export function routinesEnabledByEnv(env: NodeJS.ProcessEnv = process.env): boolean {
154
+ const raw = env.COS_ROUTINES?.trim().toLowerCase()
155
+ return !(raw === '0' || raw === 'false' || raw === 'off' || raw === 'no')
156
+ }
157
+
158
+ export function newRoutineId(): string {
159
+ return `routine-${randomBytes(5).toString('hex')}`
160
+ }
161
+
162
+ /** Deterministic client id for one routine's scheduled slot, so a retry after a crashed submit
163
+ * admits as the SAME job (the brief's rule, keyed by routine as well). */
164
+ export function routineScheduledClientJobId(routineId: string, day: string, timezone: string): string {
165
+ const digest = createHash('sha256').update(`routine|${routineId}|${timezone}|${day}`).digest()
166
+ const bytes = Buffer.from(digest.subarray(0, 16))
167
+ bytes[6] = (bytes[6] & 0x0f) | 0x40
168
+ bytes[8] = (bytes[8] & 0x3f) | 0x80
169
+ const hex = bytes.toString('hex')
170
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
171
+ }
172
+
173
+ function cleanText(value: unknown, maxChars: number): string {
174
+ if (typeof value !== 'string') return ''
175
+ return value.replace(CONTROL_RE, '').replace(/\r\n?/g, '\n').trim().slice(0, maxChars)
176
+ }
177
+
178
+ function intIn(raw: unknown, min: number, max: number, code: string, label: string): number {
179
+ const n = typeof raw === 'number' ? raw : Number(raw)
180
+ if (!Number.isSafeInteger(n) || n < min || n > max) throw new RoutineConfigError(code, `${label} must be ${min} to ${max}.`)
181
+ return n
182
+ }
183
+
184
+ function normalizeDays(raw: unknown): number[] {
185
+ if (!Array.isArray(raw)) throw new RoutineConfigError('invalid_days', 'days must be a list of weekday numbers, 0 (Sunday) to 6.')
186
+ return [...new Set(raw
187
+ .map(value => (typeof value === 'number' ? value : Number(value)))
188
+ .filter(value => Number.isInteger(value) && value >= 0 && value <= 6))]
189
+ .sort((a, b) => a - b)
190
+ }
191
+
192
+ export function normalizeSkillName(raw: unknown): string {
193
+ const name = typeof raw === 'string' ? raw.trim().replace(/^\//, '') : ''
194
+ if (!ROUTINE_SKILL_RE.test(name)) throw new RoutineConfigError('invalid_skill', 'Pick a skill from this COS workspace.')
195
+ return name
196
+ }
197
+
198
+ export interface RoutinePatchContext {
199
+ now: Date
200
+ /** True when the skill is in this workspace's catalog. A routine can only name one that is. */
201
+ skillExists: (name: string) => boolean
202
+ }
203
+
204
+ /**
205
+ * Create or edit a skill routine from an untrusted body. Every field is validated and an invalid
206
+ * one throws (a settings screen that shows Saved over a rejected time is the failure this guards).
207
+ * `current` absent means create: `skill` is required and everything else takes its default.
208
+ */
209
+ export function applyRoutinePatch(current: SkillRoutine | null, raw: unknown, ctx: RoutinePatchContext): SkillRoutine {
210
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new RoutineConfigError('invalid_patch', 'Expected a JSON object.')
211
+ const patch = raw as Record<string, unknown>
212
+ const at = ctx.now.toISOString()
213
+ const base: SkillRoutine = current
214
+ ? { ...current, days: [...current.days], breaker: { ...current.breaker } }
215
+ : {
216
+ id: newRoutineId(),
217
+ kind: 'skill',
218
+ name: '',
219
+ skill: '',
220
+ instruction: '',
221
+ enabled: true,
222
+ time: '08:00',
223
+ timezone: serverTimezone(),
224
+ days: [1, 2, 3, 4, 5],
225
+ catchUpMinutes: ROUTINE_LIMITS.catchUpMinutesDefault,
226
+ dailyCap: ROUTINE_LIMITS.dailyCapDefault,
227
+ model: ROUTINE_DEFAULT_MODEL,
228
+ breaker: { failures: ROUTINE_LIMITS.breakerFailuresDefault, cooldownMinutes: ROUTINE_LIMITS.cooldownMinutesDefault },
229
+ createdAt: at,
230
+ updatedAt: at,
231
+ }
232
+ if (!current && !('skill' in patch)) throw new RoutineConfigError('invalid_skill', 'Pick a skill from this COS workspace.')
233
+ if ('skill' in patch) {
234
+ const skill = normalizeSkillName(patch.skill)
235
+ if (!ctx.skillExists(skill)) throw new RoutineConfigError('unknown_skill', `/${skill} is not a skill in this COS workspace.`)
236
+ base.skill = skill
237
+ }
238
+ if ('name' in patch) {
239
+ if (patch.name != null && typeof patch.name !== 'string') throw new RoutineConfigError('invalid_name', 'name must be text.')
240
+ base.name = cleanText(patch.name, ROUTINE_LIMITS.nameChars)
241
+ }
242
+ if (!base.name) base.name = `/${base.skill}`
243
+ if ('instruction' in patch) {
244
+ if (patch.instruction != null && typeof patch.instruction !== 'string') throw new RoutineConfigError('invalid_instruction', 'instruction must be text.')
245
+ base.instruction = cleanText(patch.instruction, ROUTINE_LIMITS.instructionChars)
246
+ }
247
+ if ('enabled' in patch) {
248
+ if (typeof patch.enabled !== 'boolean') throw new RoutineConfigError('invalid_enabled', 'enabled must be true or false.')
249
+ base.enabled = patch.enabled
250
+ }
251
+ if ('time' in patch) {
252
+ const time = typeof patch.time === 'string' ? patch.time.trim() : ''
253
+ if (!TIME_RE.test(time)) throw new RoutineConfigError('invalid_time', 'time must be HH:MM in 24-hour form, e.g. 08:00.')
254
+ base.time = time
255
+ }
256
+ if ('timezone' in patch) {
257
+ if (!isValidTimezone(patch.timezone)) throw new RoutineConfigError('invalid_timezone', 'timezone must be an IANA zone such as America/Chicago.')
258
+ base.timezone = patch.timezone
259
+ }
260
+ if ('days' in patch) base.days = normalizeDays(patch.days)
261
+ if ('catchUpMinutes' in patch) {
262
+ base.catchUpMinutes = intIn(patch.catchUpMinutes, 0, ROUTINE_LIMITS.catchUpMinutesMax, 'invalid_catch_up', 'catchUpMinutes')
263
+ }
264
+ if ('dailyCap' in patch) {
265
+ base.dailyCap = intIn(patch.dailyCap, ROUTINE_LIMITS.dailyCapMin, ROUTINE_LIMITS.dailyCapMax, 'invalid_daily_cap', 'dailyCap')
266
+ }
267
+ if ('model' in patch) {
268
+ if (patch.model == null || patch.model === '') {
269
+ base.model = ROUTINE_DEFAULT_MODEL
270
+ } else {
271
+ const model = normalizeModelPreference(patch.model)
272
+ // A scheduled routine always runs on the subscription, never a key lane (Decision Q2).
273
+ if (!model && keyProfileRejectApplies(patch.model)) throw new RoutineConfigError(KEY_PROFILE_REJECT_CODE, KEY_PROFILE_SCHEDULED_REJECT)
274
+ if (!model) throw new RoutineConfigError('invalid_model', 'Unknown model preference.')
275
+ base.model = model
276
+ }
277
+ }
278
+ if ('effort' in patch) {
279
+ if (patch.effort == null || patch.effort === '') {
280
+ delete base.effort
281
+ } else {
282
+ const effort = normalizeEffortPreference(patch.effort)
283
+ if (!effort) throw new RoutineConfigError('invalid_effort', 'Unknown effort preference.')
284
+ base.effort = effort
285
+ }
286
+ }
287
+ if ('breaker' in patch) {
288
+ const b = patch.breaker
289
+ if (!b || typeof b !== 'object' || Array.isArray(b)) throw new RoutineConfigError('invalid_breaker', 'breaker must be an object.')
290
+ const rec = b as Record<string, unknown>
291
+ if ('failures' in rec) base.breaker.failures = intIn(rec.failures, ROUTINE_LIMITS.breakerFailuresMin, ROUTINE_LIMITS.breakerFailuresMax, 'invalid_breaker_failures', 'breaker.failures')
292
+ if ('cooldownMinutes' in rec) base.breaker.cooldownMinutes = intIn(rec.cooldownMinutes, ROUTINE_LIMITS.cooldownMinutesMin, ROUTINE_LIMITS.cooldownMinutesMax, 'invalid_breaker_cooldown', 'breaker.cooldownMinutes')
293
+ }
294
+ base.updatedAt = at
295
+ return base
296
+ }
297
+
298
+ /** Coerce a stored routine. A damaged field takes its default; a routine with no usable id or skill is dropped. */
299
+ export function coerceRoutine(raw: unknown): SkillRoutine | null {
300
+ if (!raw || typeof raw !== 'object') return null
301
+ const r = raw as Record<string, unknown>
302
+ if (typeof r.id !== 'string' || !ROUTINE_ID_RE.test(r.id)) return null
303
+ if (typeof r.skill !== 'string' || !ROUTINE_SKILL_RE.test(r.skill)) return null
304
+ const num = (value: unknown, min: number, max: number, fallback: number) => {
305
+ const n = Number(value)
306
+ return Number.isSafeInteger(n) && n >= min && n <= max ? n : fallback
307
+ }
308
+ const breaker = (r.breaker && typeof r.breaker === 'object' ? r.breaker : {}) as Record<string, unknown>
309
+ const model = normalizeModelPreference(r.model)
310
+ const effort = normalizeEffortPreference(r.effort)
311
+ const now = new Date().toISOString()
312
+ let days: number[]
313
+ try { days = normalizeDays(r.days) } catch { days = [1, 2, 3, 4, 5] }
314
+ return {
315
+ id: r.id,
316
+ kind: 'skill',
317
+ name: cleanText(r.name, ROUTINE_LIMITS.nameChars) || `/${r.skill}`,
318
+ skill: r.skill,
319
+ instruction: cleanText(r.instruction, ROUTINE_LIMITS.instructionChars),
320
+ enabled: typeof r.enabled === 'boolean' ? r.enabled : false,
321
+ time: typeof r.time === 'string' && TIME_RE.test(r.time) ? r.time : '08:00',
322
+ timezone: isValidTimezone(r.timezone) ? r.timezone : serverTimezone(),
323
+ days,
324
+ catchUpMinutes: num(r.catchUpMinutes, 0, ROUTINE_LIMITS.catchUpMinutesMax, ROUTINE_LIMITS.catchUpMinutesDefault),
325
+ dailyCap: num(r.dailyCap, ROUTINE_LIMITS.dailyCapMin, ROUTINE_LIMITS.dailyCapMax, ROUTINE_LIMITS.dailyCapDefault),
326
+ model: model ?? ROUTINE_DEFAULT_MODEL,
327
+ ...(effort ? { effort } : {}),
328
+ breaker: {
329
+ failures: num(breaker.failures, ROUTINE_LIMITS.breakerFailuresMin, ROUTINE_LIMITS.breakerFailuresMax, ROUTINE_LIMITS.breakerFailuresDefault),
330
+ cooldownMinutes: num(breaker.cooldownMinutes, ROUTINE_LIMITS.cooldownMinutesMin, ROUTINE_LIMITS.cooldownMinutesMax, ROUTINE_LIMITS.cooldownMinutesDefault),
331
+ },
332
+ createdAt: typeof r.createdAt === 'string' ? r.createdAt : now,
333
+ updatedAt: typeof r.updatedAt === 'string' ? r.updatedAt : now,
334
+ }
335
+ }
336
+
337
+ function coerceBreaker(raw: unknown): RoutineBreakerState {
338
+ const r = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
339
+ const failures = Number(r.consecutiveFailures)
340
+ const state: RoutineBreakerState = { consecutiveFailures: Number.isSafeInteger(failures) && failures > 0 ? failures : 0 }
341
+ if (typeof r.openUntil === 'string' && Number.isFinite(Date.parse(r.openUntil))) state.openUntil = r.openUntil
342
+ if (typeof r.openedAt === 'string') state.openedAt = r.openedAt
343
+ const last = r.lastFailure as Record<string, unknown> | undefined
344
+ if (last && typeof last.code === 'string' && typeof last.at === 'string') {
345
+ state.lastFailure = { code: last.code.slice(0, 64), message: String(last.message ?? '').slice(0, 300), at: last.at }
346
+ }
347
+ return state
348
+ }
349
+
350
+ export function loadRoutinesFile(paths: RoutineStorePaths): { file: RoutinesFile; quarantinedAs?: string } {
351
+ const loaded = loadJsonOrQuarantine<unknown>(paths.routines)
352
+ if (loaded.status !== 'ok' || !loaded.data || typeof loaded.data !== 'object') {
353
+ return { file: { v: 1, routines: [], breakers: {} }, ...(loaded.status === 'corrupt' ? { quarantinedAs: loaded.quarantinedAs } : {}) }
354
+ }
355
+ const data = loaded.data as { routines?: unknown; breakers?: unknown }
356
+ const seen = new Set<string>()
357
+ const routines: SkillRoutine[] = []
358
+ for (const entry of Array.isArray(data.routines) ? data.routines : []) {
359
+ const routine = coerceRoutine(entry)
360
+ if (!routine || seen.has(routine.id)) continue
361
+ seen.add(routine.id)
362
+ routines.push(routine)
363
+ }
364
+ const breakers: Record<string, RoutineBreakerState> = {}
365
+ const rawBreakers = data.breakers && typeof data.breakers === 'object' ? data.breakers as Record<string, unknown> : {}
366
+ for (const id of seen) if (rawBreakers[id]) breakers[id] = coerceBreaker(rawBreakers[id])
367
+ return { file: { v: 1, routines: routines.slice(0, ROUTINE_LIMITS.maxRoutines), breakers } }
368
+ }
369
+
370
+ export function saveRoutinesFile(paths: RoutineStorePaths, file: RoutinesFile): void {
371
+ ensurePrivateDir(paths.routines)
372
+ durableAtomicWriteFileSync(paths.routines, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 })
373
+ }
374
+
375
+ function isRoutineRun(value: unknown): value is RoutineRun {
376
+ if (!value || typeof value !== 'object') return false
377
+ const run = value as Record<string, unknown>
378
+ return typeof run.id === 'string'
379
+ && typeof run.routineId === 'string'
380
+ && typeof run.day === 'string'
381
+ && (run.trigger === 'scheduled' || run.trigger === 'manual')
382
+ && typeof run.firedAt === 'string'
383
+ && typeof run.clientJobId === 'string'
384
+ && typeof run.sessionId === 'string'
385
+ }
386
+
387
+ export function loadRoutinesLedger(paths: RoutineStorePaths): RoutinesLedger {
388
+ const loaded = loadJsonOrQuarantine<unknown>(paths.runs)
389
+ if (loaded.status !== 'ok' || !loaded.data || typeof loaded.data !== 'object') return { v: 1, runs: [] }
390
+ const raw = (loaded.data as { runs?: unknown }).runs
391
+ return { v: 1, runs: (Array.isArray(raw) ? raw : []).filter(isRoutineRun) }
392
+ }
393
+
394
+ export function saveRoutinesLedger(paths: RoutineStorePaths, ledger: RoutinesLedger): void {
395
+ ensurePrivateDir(paths.runs)
396
+ const runs = ledger.runs.slice(-ROUTINE_LIMITS.retainedRuns)
397
+ durableAtomicWriteFileSync(paths.runs, `${JSON.stringify({ v: 1, runs }, null, 2)}\n`, { mode: 0o600 })
398
+ }
399
+
400
+ /** The prompt a skill routine's durable job carries. Deterministic for (routine, day) so a crash
401
+ * resume re-admits the same job. Unattended: it never sends, publishes or deletes. */
402
+ export function composeSkillRoutinePrompt(routine: Pick<SkillRoutine, 'name' | 'skill' | 'instruction'>, day: string, ownerName: string, trigger: RoutineTrigger): string {
403
+ const who = ownerName.trim() || 'the owner'
404
+ const lines = [
405
+ `Scheduled COS routine "${routine.name}" for ${day} (${trigger === 'manual' ? 'Run now' : 'on schedule'}).`,
406
+ `Run the /${routine.skill} skill from this COS workspace and give ${who} its result.`,
407
+ ...(routine.instruction ? ['', `Also: ${routine.instruction}`] : []),
408
+ '',
409
+ 'Nobody is watching this run. Read and summarise freely, and write only the files this skill keeps on this Mac.',
410
+ 'Do not send email, Slack or any message, publish, deploy, push, delete data, or spend money. If the skill needs one of those, stop and say what it would do.',
411
+ 'If the skill is missing or a source it needs is unavailable, say so in one plain line instead of guessing.',
412
+ 'Answer in plain text with short lines, so it reads on the glasses.',
413
+ ]
414
+ return lines.join('\n')
415
+ }
416
+
417
+ /** 6.82.0: the token-audit caller for a job a routine started (`routine:<id>`), '' for every other
418
+ * job, whose callers stay exactly what they were. */
419
+ export function routineAuditCaller(request: { origin?: { kind: string; id: string } }): string {
420
+ return request.origin?.kind === 'routine' ? `routine:${request.origin.id}` : ''
421
+ }