@gotcos/glasses-server 6.56.1 → 6.57.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,30 @@
1
+ ## 6.57.0
2
+
3
+ ### Jev in the server: session suggestions and a key you can set from COS Control
4
+
5
+ - New `POST /api/work-board/session-recommendation` answers Continue, Fork or New for a Work task. The client sends only the sessions it can target (at most 80); the task and its meetings come from the board. Jev answers two questions in one request: which session shares this work's context (same deliverable, page, module, codebase or client thread), and which, if any, is already doing this exact task. Continue at 0.5 or above on the second, Fork when a session covers the context at 0.6 or above, otherwise New. Answers are cached for 30 minutes per task and session set. On Miles's live sessions a 1:1 task went New, a server bug continued its server session at 0.91, and a hardware-page task forked the hardware session; about 10K input tokens per task.
6
+ - Advice only: nothing is selected, sent or started. Without a key, over the cap, or while paused, the route answers `provider: none` and the client keeps its local word match.
7
+ - New `POST /api/jev-key/set`, `GET /api/jev-key/status` and `DELETE /api/jev-key`, like `/api/openai-key`: the key is validated live against TypeSafe's free model listing, saved to `data/jev-key.json` (0600), and never returned. A key saved this way wins over `TYPESAFE_API_KEY` in the environment or either `.env` file (both held one on Miles's Mac, which made a Settings key do nothing); the COS meeting producer resolves in the same order. Status says which source is active. A saved key works without a restart, and saving one clears a breaker left open by the old key.
8
+ - Cost controls: a daily input-token cap (`COS_JEV_DAILY_TOKENS`, default 1,000,000, about $0.04), a ledger in the data directory so a restart does not reset it, a 20-second timeout, and a breaker that pauses Jev for an hour after three consecutive failures. The model is pinned to `jev-1.13.0`.
9
+
10
+ ### Work intake: meeting work that is not on the board yet
11
+
12
+ - New authenticated routes `GET /api/work-intake`, `POST /api/work-intake/items` and `POST /api/work-intake/:id/resolve` hold what the COS meeting producer (`work_intake.py`) found but did not write to Work: meeting links it was less sure of (0.60 to 0.85, or higher for a task that already links several meetings), other people's asks, and Miles's own items that need a look (from an older meeting, not clear enough to be a task, or owner unclear). Nothing here calls a model or starts an agent.
13
+ - Producer batches are validated per item. A bad item comes back in `rejected` with its code and never blocks the rest of the batch; only a malformed batch as a whole is refused. Unknown fields, unknown review reasons and a malformed session pull are dropped, not stored, and the item itself still arrives.
14
+ - A decision is sticky while it is retained (60 days). Once Miles accepts or dismisses an item, or the producer records a card it already made, a later producer upsert never reopens it. Resolution is serialized per item, so a double click answers "already being saved" instead of writing twice.
15
+ - Accepting a suggested link checks that the board can write, re-reads the task by its Work identity (it survives a rename), confirms the meeting is still the same saved record, uses the library's title, and retries up to twice on a revision race.
16
+ - Accepting an ask creates one Mentioned card linked to its meeting through one locked write in `task_write.capture_work_item`. Someone else's item is written with "(from Name)". If the same words are already an open task, that task is linked instead of adding a second card; the same words on closed, archived or delegated work are refused.
17
+ - Card creation needs a COS bridge that reports `captureWork`. Older bridges and the bundled task runtime report it off, and accepting an ask is refused with the code `card_creation_unavailable`. A bridge that fails or times out is reported as `bridgeUnavailable` (accept answers `task_bridge_unavailable`), never as an older server. Earlier servers read only `version === 1`, so the new capability is additive.
18
+ - The journal lives in the server data directory under `work-intake/` with one writer. A bad record is quarantined on its own and logged, and an unreadable journal leaves the rest of the API running. Unexpected failures are logged with the route. See `docs/work-intake.md`.
19
+ - The local Work candidate (`server/scripts/work-review-candidate.ts`) mounts Intake with its own journal in the candidate home, accepts producer-sized batches, and stays up if that journal is unreadable. It also mounts the Jev routes against the same board.
20
+ - Tests: `node server/scripts/work-intake-mutation-gate.mjs` runs 29 mutations over intake, the Jev client and session recommendation in a private copy, after a green baseline.
21
+
22
+ ### Fixed
23
+
24
+ - Session advice only suggests a Fork on a Claude or Codex session, the sessions a Work handoff can fork. A Cursor session that shares the context no longer produces a Fork that Send then refuses. A Fork's confidence is now the named session's own probability, not the chance that some session shares the context (a 0.5 and 0.48 split used to read 98%). A Jev answer without its "none" option is treated as a bad answer instead of a certain Fork. The board lookup tries a task's Work identity before its row id.
25
+ - The local Work candidate reads the installed Jev key but can no longer set or remove it, counts its own Jev spend in the candidate home, and accepts session-advice requests up to 128 KB (66 real sessions measured 20 KB, over the old 16 KB limit).
26
+ - The Control 2 sandbox probe's loopback listener accepted connections with no error handler. The bash probes close right after writing, so the accepted socket could see a reset. Vitest reported it as an unhandled error in 2 of 3 runs on 6.56.1, and in a running server it would have been uncaught.
27
+
1
28
  ## 6.56.1
2
29
 
3
30
  ### Codex runs again after the ChatGPT app update
@@ -0,0 +1,32 @@
1
+ # Work intake
2
+
3
+ Work intake holds meeting-derived Work that is not on the board yet. A producer outside the server (COS `operations/scripts/work_intake.py`, which uses Jev to judge saved meetings) pushes items; the user resolves them in COS Control → Work → Intake. The server never calls a model or starts an agent for intake.
4
+
5
+ ## Items
6
+
7
+ | Kind | Status | Meaning | Accept does |
8
+ | --- | --- | --- | --- |
9
+ | `link` | `suggested` | An existing task was probably worked on in a meeting (0.60 to 0.85, or higher for a task that already links several meetings) | Links the task to the meeting |
10
+ | `ask` | `ask` | Someone else's action item. `pull` names one of the user's sessions that may already cover it | Creates a Mentioned card linked to the meeting, written with "(from Name)" |
11
+ | `ask` | `review` | The user's own item that needs a look. `reason`: `outside_window`, `unclear_task` or `owner_uncertain` | Creates a Mentioned card linked to the meeting |
12
+ | any | `accepted` / `dismissed` | Decided. A producer may record cards it made itself (`resolution.by: "producer"`) and may retract its own open items (a producer dismissal) | — |
13
+
14
+ ## API (authenticated like every `/api` route)
15
+
16
+ - `GET /api/work-intake` returns `{ schemaVersion: 1, capabilities: { linkWrites, cardCreation, bridgeUnavailable? }, counts, items, quarantined }` with open items only.
17
+ - `POST /api/work-intake/items` takes `{ items: [...] }`, at most 200 per batch. Each item is validated on its own; refusals come back in `rejected: [{ id, code }]`.
18
+ - `POST /api/work-intake/:id/resolve` takes `{ action: "accept" | "dismiss" }`. Refusals are 409 with a code (`intake_task_changed`, `meeting_links_full`, `meeting_identity_changed`, `meeting_unavailable`, `intake_text_collision`, `work_board_read_only`, `card_creation_unavailable`, `intake_busy`), 503 `task_bridge_unavailable` when the task bridge did not answer, 404 `intake_not_found`. A refused item stays open.
19
+
20
+ Card creation needs a COS task bridge that reports `captureWork: 1`. The bundled task runtime does not, so on installs without COS the Intake list stays empty and accepting an ask is refused.
21
+
22
+ ## Storage and recovery
23
+
24
+ The journal is `work-intake/intake.json` in the server data directory, owned by one server process (`writer.lock`). User decisions are kept for 60 days and are sticky against producer upserts; open items are capped at 5,000. An unreadable record is quarantined on its own and logged at start. An unreadable or unsafe journal disables intake (503) without stopping the rest of the server: preserve the file for diagnosis, then move it aside to start empty. The producer's own ledger (`.work_intake_state.json`) never re-sends a decided item.
25
+
26
+ ## Verification
27
+
28
+ `npx vitest run server/lib/work-intake-store.test.ts server/routes/work-intake.test.ts server/lib/task-store-intake.test.ts` covers the store, routes and bridge powers. `node server/scripts/work-intake-mutation-gate.mjs` runs its mutations in a private copy and refuses to start on a red baseline. The local Work candidate (`server/scripts/work-review-candidate.ts`) mounts intake with a journal in the candidate home.
29
+
30
+ ## Jev key and session suggestions
31
+
32
+ `POST /api/jev-key/set` `{ key }` validates the key against TypeSafe and saves it to `data/jev-key.json`; `GET /api/jev-key/status` returns `{ configured, source, usedToday, dailyCap, breakerOpenUntil, lastError }` and never the key; `DELETE /api/jev-key` removes the saved key. `POST /api/work-board/session-recommendation` `{ domain, id, sessions: [{ id, provider, title, summary? }] }` returns `{ provider: "jev", action: "continue" | "fork" | "new", sessionId, confidence, reason, alternatives }` or `{ provider: "none", reason }`. The COS meeting producer reads the same saved key when its own `.env` has none.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.56.1",
3
+ "version": "6.57.0",
4
4
  "description": "COS Glasses \u2014 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": {
@@ -43,6 +43,7 @@
43
43
  "!server/scripts/engine-canary.ts",
44
44
  "!server/scripts/work-review-candidate.ts",
45
45
  "!server/scripts/work-review-mutation-gate.mjs",
46
+ "!server/scripts/work-intake-mutation-gate.mjs",
46
47
  "!server/scripts/mutation-gate.mjs",
47
48
  "!server/lib/meeting-engine/__fixtures__/**",
48
49
  "!server/lib/__fixtures__/**",
@@ -62,6 +63,7 @@
62
63
  "docs/control2-sandbox.md",
63
64
  "docs/control2-task-runtime.md",
64
65
  "docs/work-reviews.md",
66
+ "docs/work-intake.md",
65
67
  "work-task-runtime",
66
68
  "docs/work-task-runtime.md",
67
69
  "!server/scripts/build-work-task-runtime.py"
package/server/index.ts CHANGED
@@ -124,6 +124,10 @@ import { getTranscriptionProfileStatus } from './lib/profile.js'
124
124
  import { createQueryJobsRouter } from './routes/query-jobs.js'
125
125
  import { createWorkReviewsRouter } from './routes/work-reviews.js'
126
126
  import { createWorkBoardRouter } from './routes/work-board.js'
127
+ import { createWorkIntakeRouter } from './routes/work-intake.js'
128
+ import { createJevRouter } from './routes/jev.js'
129
+ import { dataPath } from './lib/data-dir.js'
130
+ import { WorkIntakeStore, createOptionalWorkIntakeStore } from './lib/work-intake-store.js'
127
131
  import { createDefaultWorkReviewRuntime } from './lib/work-review-backend.js'
128
132
  import { createMorningBriefRouter } from './routes/morning-brief.js'
129
133
  import { getMorningBriefScheduler, startMorningBriefScheduler, stopMorningBriefScheduler } from './lib/morning-brief-runtime.js'
@@ -622,6 +626,11 @@ app.use('/api', createQueryJobsRouter(queryJobCoordinator, {
622
626
  const workReviewRuntime = createDefaultWorkReviewRuntime()
623
627
  app.use('/api', createWorkReviewsRouter(workReviewRuntime))
624
628
  app.use('/api', createWorkBoardRouter())
629
+ // Work intake (6.57.0): meeting-derived suggestions, asks and review items. Optional: a bad journal never blocks boot.
630
+ const workIntakeStore = createOptionalWorkIntakeStore(() => new WorkIntakeStore(dataPath('work-intake')))
631
+ app.use('/api', createWorkIntakeRouter({ store: workIntakeStore }))
632
+ // Jev key (Control Settings) and Continue/Fork/New session recommendations for Work tasks (6.57.0).
633
+ app.use('/api', createJevRouter())
625
634
  app.use('/api', queryRouter)
626
635
  // The scheduled start-of-day brief: settings, status, run-now. Same auth as
627
636
  // every other settings route; the brief itself is an ordinary durable job.
@@ -937,6 +946,7 @@ async function gracefulShutdown(): Promise<void> {
937
946
  forceExit.unref?.()
938
947
  stopMorningBriefScheduler()
939
948
  await workReviewRuntime?.close()
949
+ workIntakeStore?.close()
940
950
  stopMeetingImportScheduler()
941
951
  stopMeetingMergeScheduler()
942
952
  sessionHooksRuntime.stop()
@@ -139,7 +139,9 @@ export async function probeControl2Sandbox(): Promise<Control2SandboxCapability>
139
139
  const protectedRoot = mkdtempSync(join(tmpdir(), 'cos-control2-protected-'))
140
140
  const sentinel = join(realpathSync(protectedRoot), 'sentinel.txt')
141
141
  writeFileSync(sentinel, 'protected-canary-only', { mode: 0o600 })
142
- const listener = createServer(socket => socket.end('reachable'))
142
+ // 6.57.0: the bash probes close right after writing, so the accepted socket can see ECONNRESET.
143
+ // Without a listener that 'error' was unhandled (vitest reported it in 2 of 3 runs on 6.56.1).
144
+ const listener = createServer(socket => { socket.on('error', () => {}); socket.end('reachable') })
143
145
  let hits = 0
144
146
  listener.on('connection', () => { hits++ })
145
147
  try {
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Jev (TypeSafe System One): a classifier that answers typed questions with calibrated probabilities. It never
3
+ * writes text. Used for Work session matching (6.57.0); the COS meeting producer uses the same key.
4
+ *
5
+ * Key resolution: data/jev-key.json (saved from COS Control Settings, validated live, never echoed back) wins, then
6
+ * TYPESAFE_API_KEY in the environment (which includes ~/.cos-glasses/.env, loaded at boot), then the COS scripts
7
+ * .env. Unlike the OpenAI key the saved key comes FIRST: Miles's Mac has TYPESAFE_API_KEY in both .env files, so an
8
+ * environment-first order made the Settings key inert (QA 2026-09-28). The COS producer (work_intake.py) resolves
9
+ * in the same order: saved, its environment, then the other .env.
10
+ *
11
+ * Cost controls: a daily input-token cap (COS_JEV_DAILY_TOKENS, default 1,000,000, about $0.04 at $42 per billion),
12
+ * a breaker that opens for an hour after three consecutive failures, and a 20-second request timeout. Every call
13
+ * counts against a ledger in the data directory, so a restart does not reset the cap.
14
+ */
15
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
16
+ import { dirname, resolve } from 'node:path'
17
+ import { COS_SCRIPTS_DIR } from './python-bridge.js'
18
+ import { dataPath } from './data-dir.js'
19
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
20
+ import { secureExistingPrivateFile } from './secure-user-config.js'
21
+
22
+ export const JEV_ENDPOINT = 'https://api.typesafe.ai/v1'
23
+ export const JEV_MODEL = 'jev-1.13.0'
24
+ export const JEV_KEY_FILE = dataPath('jev-key.json')
25
+ export const JEV_USAGE_FILE = dataPath('jev-usage.json')
26
+ export const JEV_LIMITS = { defaultDailyTokens: 1_000_000, timeoutMs: 20_000, breakerFailures: 3, breakerMs: 3_600_000 } as const
27
+
28
+ export type JevKeySource = 'env' | 'config' | 'scripts-env' | 'none'
29
+ export class JevError extends Error {
30
+ constructor(readonly code: 'jev_not_configured' | 'jev_cap_reached' | 'jev_breaker_open' | 'jev_key_rejected'
31
+ | 'jev_request_rejected' | 'jev_unavailable' | 'jev_bad_answer', message: string = code) { super(message) }
32
+ }
33
+
34
+ interface KeyFile { key: string; savedAt: string; validatedAt?: string }
35
+ function readKeyFile(): KeyFile | null {
36
+ if (!existsSync(JEV_KEY_FILE)) return null
37
+ try {
38
+ secureExistingPrivateFile(JEV_KEY_FILE)
39
+ const parsed = JSON.parse(readFileSync(JEV_KEY_FILE, 'utf8')) as Partial<KeyFile>
40
+ return typeof parsed.key === 'string' && parsed.key.trim()
41
+ ? { key: parsed.key.trim(), savedAt: String(parsed.savedAt ?? ''), validatedAt: parsed.validatedAt } : null
42
+ } catch { return null }
43
+ }
44
+
45
+ /** Resolved fresh on every call (a small file read), so a key saved from Control works without a restart. */
46
+ export function resolveJevKey(): { key: string; source: Exclude<JevKeySource, 'none'>; savedAt?: string; validatedAt?: string } | null {
47
+ const file = readKeyFile()
48
+ if (file) return { key: file.key, source: 'config', savedAt: file.savedAt, validatedAt: file.validatedAt }
49
+ const env = process.env.TYPESAFE_API_KEY?.trim().replace(/^["']|["']$/g, '')
50
+ if (env) return { key: env, source: 'env' }
51
+ if (COS_SCRIPTS_DIR) {
52
+ for (const path of [resolve(COS_SCRIPTS_DIR, '.env'), resolve(COS_SCRIPTS_DIR, '../../.env')]) {
53
+ try {
54
+ const match = readFileSync(path, 'utf8').match(/^TYPESAFE_API_KEY=(.+)$/m)
55
+ if (match?.[1].trim()) return { key: match[1].trim().replace(/^["']|["']$/g, ''), source: 'scripts-env' }
56
+ } catch { /* next */ }
57
+ }
58
+ }
59
+ return null
60
+ }
61
+
62
+ export function saveJevKey(key: string, now = new Date()): void {
63
+ mkdirSync(dirname(JEV_KEY_FILE), { recursive: true, mode: 0o700 })
64
+ const at = now.toISOString()
65
+ durableAtomicWriteFileSync(JEV_KEY_FILE, JSON.stringify({ key, savedAt: at, validatedAt: at }, null, 2) + '\n', { mode: 0o600 })
66
+ }
67
+
68
+ type Fetch = typeof fetch
69
+ /** A 200 from the free model listing proves the key works without spending tokens. */
70
+ export async function validateJevKey(key: string, fetchImpl: Fetch = fetch): Promise<{ ok: true } | { ok: false; status: number; reason: string }> {
71
+ try {
72
+ const res = await fetchImpl(`${JEV_ENDPOINT}/models`, { headers: { Authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(8_000) })
73
+ if (res.ok) return { ok: true }
74
+ return { ok: false, status: res.status, reason: res.status === 401 || res.status === 403 ? 'TypeSafe did not accept this key.' : `TypeSafe returned ${res.status}.` }
75
+ } catch { return { ok: false, status: 0, reason: 'TypeSafe could not be reached. Check the network and try again.' } }
76
+ }
77
+
78
+ function today(now: Date): string { return now.toISOString().slice(0, 10) }
79
+ function dailyCap(): number {
80
+ const raw = Number(process.env.COS_JEV_DAILY_TOKENS)
81
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : JEV_LIMITS.defaultDailyTokens
82
+ }
83
+
84
+ export interface JevAnswers { answers: Record<string, { choice?: string; probabilities?: Record<string, number>; noul?: number }>; model?: string; inputTokens: number }
85
+
86
+ /** One Jev client per process: it owns the breaker and the usage ledger. */
87
+ export class JevClient {
88
+ private failures = 0
89
+ private breakerUntil = 0
90
+ private lastError: string | null = null
91
+ constructor(private readonly fetchImpl: Fetch = fetch, private readonly now: () => Date = () => new Date(),
92
+ private readonly usageFile = JEV_USAGE_FILE) {}
93
+
94
+ usedToday(): number {
95
+ try {
96
+ const ledger = JSON.parse(readFileSync(this.usageFile, 'utf8')) as Record<string, number>
97
+ return Number(ledger[today(this.now())]) || 0
98
+ } catch { return 0 }
99
+ }
100
+ private record(tokens: number): void {
101
+ let ledger: Record<string, number> = {}
102
+ try { ledger = JSON.parse(readFileSync(this.usageFile, 'utf8')) } catch { /* fresh */ }
103
+ const day = today(this.now())
104
+ const kept = Object.fromEntries(Object.entries(ledger).filter(([d]) => d >= today(new Date(this.now().getTime() - 31 * 86_400_000))))
105
+ kept[day] = (Number(kept[day]) || 0) + tokens
106
+ mkdirSync(dirname(this.usageFile), { recursive: true, mode: 0o700 })
107
+ durableAtomicWriteFileSync(this.usageFile, JSON.stringify(kept) + '\n', { mode: 0o600 })
108
+ }
109
+ private fail(code: JevError['code'], countsTowardBreaker: boolean): never {
110
+ this.lastError = code
111
+ if (countsTowardBreaker && ++this.failures >= JEV_LIMITS.breakerFailures) {
112
+ this.breakerUntil = this.now().getTime() + JEV_LIMITS.breakerMs
113
+ this.failures = 0
114
+ }
115
+ throw new JevError(code)
116
+ }
117
+
118
+ /** A newly validated key starts clean: failures under the old key must not pause the new one for an hour. */
119
+ resetForNewKey(): void { this.failures = 0; this.breakerUntil = 0; this.lastError = null }
120
+
121
+ status(): { configured: boolean; source: JevKeySource; savedAt?: string; validatedAt?: string; usedToday: number; dailyCap: number; breakerOpenUntil: string | null; lastError: string | null } {
122
+ const key = resolveJevKey()
123
+ return { configured: !!key, source: key?.source ?? 'none', savedAt: key?.savedAt, validatedAt: key?.validatedAt,
124
+ usedToday: this.usedToday(), dailyCap: dailyCap(),
125
+ breakerOpenUntil: this.breakerUntil > this.now().getTime() ? new Date(this.breakerUntil).toISOString() : null, lastError: this.lastError }
126
+ }
127
+
128
+ /** Ask one request. `estimate` is the caller's input-token estimate, checked against the cap BEFORE sending. */
129
+ async ask(state: unknown, questions: Record<string, unknown>, estimate: number): Promise<JevAnswers> {
130
+ const key = resolveJevKey()
131
+ if (!key) throw new JevError('jev_not_configured')
132
+ if (this.breakerUntil > this.now().getTime()) throw new JevError('jev_breaker_open')
133
+ if (this.usedToday() + estimate > dailyCap()) this.fail('jev_cap_reached', false)
134
+ let res: Response
135
+ try {
136
+ res = await this.fetchImpl(`${JEV_ENDPOINT}/systemone`, { method: 'POST', signal: AbortSignal.timeout(JEV_LIMITS.timeoutMs),
137
+ headers: { Authorization: `Bearer ${key.key}`, 'Content-Type': 'application/json' },
138
+ body: JSON.stringify({ state, model: JEV_MODEL, questions }) })
139
+ } catch { return this.fail('jev_unavailable', true) }
140
+ if (res.status === 401 || res.status === 403 || res.status === 402) return this.fail('jev_key_rejected', true)
141
+ if (res.status === 400 || res.status === 413 || res.status === 422) return this.fail('jev_request_rejected', false)
142
+ if (!res.ok) return this.fail('jev_unavailable', true)
143
+ let body: { answers?: JevAnswers['answers']; model?: string; usage?: { input_tokens?: number } }
144
+ try { body = await res.json() } catch { return this.fail('jev_bad_answer', true) }
145
+ const used = Number(body.usage?.input_tokens) || estimate // count something even if usage is missing
146
+ this.record(used)
147
+ if (!body.answers || typeof body.answers !== 'object') return this.fail('jev_bad_answer', true)
148
+ this.failures = 0; this.lastError = null
149
+ return { answers: body.answers, model: body.model, inputTokens: used }
150
+ }
151
+ }
152
+
153
+ let shared: JevClient | null = null
154
+ export function sharedJevClient(): JevClient { return shared ??= new JevClient() }
155
+ /** Jev bills by input size; about 2.6 characters per token, measured on COS meeting and task text. */
156
+ export function estimateJevTokens(value: unknown): number { return Math.ceil(JSON.stringify(value).length / 2.6) + 1 }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Which of the user's agent sessions should take a Work task: Continue one already doing it, Fork one that shares
3
+ * its context (same deliverable, page, module, codebase or client thread) but is doing different work, or start a
4
+ * New session. Jev answers two choice questions over the sessions in one request; the thresholds below were
5
+ * checked on Miles's live sessions on 2026-09-28 (a 1:1 task went New, a server bug Continued its server session at
6
+ * 0.91, a hardware-page task Forked the hardware session). A recommendation is advice: nothing is selected or sent.
7
+ */
8
+ import { createHash } from 'node:crypto'
9
+ import { estimateJevTokens, JevError, type JevClient } from './jev.js'
10
+
11
+ export const CONTINUE_AT = 0.5 // P(a session is already doing this exact task)
12
+ export const FORK_AT = 0.6 // P(some session shares the context) = 1 - P(none)
13
+ export const RECOMMENDATION_LIMITS = { sessions: 80, title: 120, summary: 240, cacheMs: 30 * 60_000, cacheEntries: 200 } as const
14
+
15
+ export interface CandidateSession { id: string; provider: string; title: string; summary?: string; updated?: string }
16
+ export interface RecommendationInput { task: { text: string; domain: string; meetings: string[] }; sessions: CandidateSession[] }
17
+ export type Recommendation =
18
+ | { provider: 'jev'; model: string; action: 'continue' | 'fork' | 'new'; sessionId: string | null; confidence: number; reason: string;
19
+ alternatives: Array<{ sessionId: string; p: number }>; cached: boolean }
20
+ | { provider: 'none'; reason: string }
21
+
22
+ const BEST = "Which one of the user's agent sessions is the natural place to do the work in `task`: the session already working on the "
23
+ + 'same deliverable, page, module, codebase or client thread, so continuing or forking it saves re-explaining context? '
24
+ + 'Choose none if no session works on the same deliverable, page, module, codebase or client thread.'
25
+ const EXACT = "Which one of the user's agent sessions is already doing this exact task, so the work should simply continue there? "
26
+ + 'A session on the same project doing different work does not count. Choose none if no session is doing this task.'
27
+
28
+ /** Providers the Work handoff can fork (Control's destinationSupported). */
29
+ export const FORKABLE = new Set(['claude', 'codex'])
30
+ const clip = (text: string | undefined, max: number) => (text ?? '').replace(/\s+/g, ' ').trim().slice(0, max)
31
+ const round = (n: number) => Math.round(n * 100) / 100
32
+
33
+ function ranked(probabilities: Record<string, number> | undefined, offered: Set<string>): Array<[string, number]> {
34
+ const rows = Object.entries(probabilities ?? {}).filter(([k]) => k !== 'none')
35
+ if (rows.some(([k]) => !offered.has(k))) throw new JevError('jev_bad_answer', 'Jev chose a session that was not offered')
36
+ return rows.map(([k, v]) => [k, Number(v) || 0] as [string, number]).sort((a, b) => b[1] - a[1])
37
+ }
38
+
39
+ /** Pure policy over Jev's two answers. Exported for tests. */
40
+ export function decide(best: Record<string, number>, exact: Record<string, number>, sessions: CandidateSession[]):
41
+ Omit<Extract<Recommendation, { provider: 'jev' }>, 'provider' | 'model' | 'cached'> {
42
+ const offered = new Set(sessions.map((_, i) => `s${i}`))
43
+ const bestRank = ranked(best, offered), exactRank = ranked(exact, offered)
44
+ const idOf = (key: string) => sessions[Number(key.slice(1))].id
45
+ const alternatives = bestRank.slice(0, 3).filter(([, p]) => p > 0).map(([k, p]) => ({ sessionId: idOf(k), p: round(p) }))
46
+ const [exactKey, exactP] = exactRank[0] ?? ['', 0]
47
+ if (exactKey && exactP >= CONTINUE_AT) {
48
+ return { action: 'continue', sessionId: idOf(exactKey), confidence: round(exactP), reason: 'Already doing this task.', alternatives }
49
+ }
50
+ // A choice answer always carries `none`; without it "covered" would read 1 and advise a Fork at 100%.
51
+ if (typeof best.none !== 'number' || !Number.isFinite(best.none)) throw new JevError('jev_bad_answer', 'Jev left out none')
52
+ const covered = 1 - best.none
53
+ // Fork only where the workspace can fork (Claude and Codex); a Cursor session sharing context cannot take a Fork.
54
+ const [forkKey, forkP] = bestRank.find(([k, p]) => p > 0 && FORKABLE.has(sessions[Number(k.slice(1))].provider)) ?? ['', 0]
55
+ if (forkKey && covered >= FORK_AT) {
56
+ // The confidence shown is the named session's own probability, not the chance that SOME session shares context.
57
+ return { action: 'fork', sessionId: idOf(forkKey), confidence: round(forkP),
58
+ reason: 'Shares this work’s context (same deliverable, page, module or thread) but is doing different work.', alternatives }
59
+ }
60
+ return { action: 'new', sessionId: null, confidence: round(1 - covered), reason: 'No existing session shares this work’s context.', alternatives }
61
+ }
62
+
63
+ /** Validate what the client sends. Sessions are candidates only: the task text always comes from the board. */
64
+ export function parseCandidates(raw: unknown): CandidateSession[] | null {
65
+ if (!Array.isArray(raw) || raw.length > RECOMMENDATION_LIMITS.sessions) return null
66
+ const seen = new Set<string>(), out: CandidateSession[] = []
67
+ for (const r of raw as Array<Record<string, unknown>>) {
68
+ if (!r || typeof r !== 'object' || typeof r.id !== 'string' || !r.id || r.id.length > 200 || seen.has(r.id)) return null
69
+ if (typeof r.title !== 'string' || !clip(r.title, RECOMMENDATION_LIMITS.title)) return null
70
+ seen.add(r.id)
71
+ out.push({ id: r.id, provider: clip(typeof r.provider === 'string' ? r.provider : 'agent', 20), title: clip(r.title, RECOMMENDATION_LIMITS.title),
72
+ summary: clip(typeof r.summary === 'string' ? r.summary : '', RECOMMENDATION_LIMITS.summary), updated: clip(typeof r.updated === 'string' ? r.updated : '', 10) })
73
+ }
74
+ return out
75
+ }
76
+
77
+ export class SessionRecommender {
78
+ private cache = new Map<string, { at: number; value: Extract<Recommendation, { provider: 'jev' }> }>()
79
+ constructor(private readonly jev: JevClient, private readonly now: () => number = () => Date.now()) {}
80
+
81
+ async recommend(input: RecommendationInput): Promise<Recommendation> {
82
+ const sessions = input.sessions
83
+ if (!sessions.length) return { provider: 'none', reason: 'no_sessions' }
84
+ const key = createHash('sha256').update(JSON.stringify(input)).digest('hex')
85
+ const hit = this.cache.get(key)
86
+ if (hit && this.now() - hit.at < RECOMMENDATION_LIMITS.cacheMs) return { ...hit.value, cached: true }
87
+ const state = { task: { text: clip(input.task.text, 1200), domain: input.task.domain, meetings: input.task.meetings.map(t => clip(t, 160)).slice(0, 8) },
88
+ sessions: sessions.map((s, i) => ({ i, provider: s.provider, title: s.title, summary: s.summary, updated: s.updated })) }
89
+ const options = Object.fromEntries(sessions.map((s, i) => [`s${i}`, `sessions[${i}]: ${clip(s.title, 100)}`]))
90
+ const questions = {
91
+ best: { type: 'choice', criteria: { ...options, none: 'No listed session shares this work’s context.' }, instructions: BEST },
92
+ exact: { type: 'choice', criteria: { ...options, none: 'No listed session is doing this task.' }, instructions: EXACT },
93
+ }
94
+ try {
95
+ const reply = await this.jev.ask(state, questions, estimateJevTokens({ state, questions }))
96
+ const best = reply.answers.best?.probabilities, exact = reply.answers.exact?.probabilities
97
+ if (!best || !exact) throw new JevError('jev_bad_answer')
98
+ const value = { provider: 'jev' as const, model: reply.model ?? 'jev', cached: false, ...decide(best, exact, sessions) }
99
+ if (this.cache.size >= RECOMMENDATION_LIMITS.cacheEntries) this.cache.delete(this.cache.keys().next().value!)
100
+ this.cache.set(key, { at: this.now(), value })
101
+ return value
102
+ } catch (e) {
103
+ if (e instanceof JevError) return { provider: 'none', reason: e.code }
104
+ throw e
105
+ }
106
+ }
107
+ }
@@ -560,6 +560,30 @@ export async function setTaskWorkStage(domain: string, id: string, workStage: Wo
560
560
  if (!WORK_STAGES.includes(workStage)) throw new TaskRunError(422, 'invalid_work_stage', 'Unknown Work stage')
561
561
  await withLockRetry(() => bridge(['task-set-work-stage', domain, id, workStage], JSON.stringify({ expectedText, expectedRevision })))
562
562
  }
563
+ /**
564
+ * Work intake needs two bridge powers: exact meeting links (version 1) and one-write card capture. A bridge that
565
+ * failed or timed out is reported as `bridgeUnavailable`, so a hiccup never reads as "this server is too old".
566
+ */
567
+ export async function workIntakeCapabilities(): Promise<{ linkWrites: boolean; cardCreation: boolean; bridgeUnavailable?: true }> {
568
+ if (!taskBridgeAvailable()) return { linkWrites: false, cardCreation: false }
569
+ try {
570
+ const value = await bridge(['task-work-capabilities']) as { version?: unknown; captureWork?: unknown }
571
+ // `captureWork` is additive on purpose: servers through 6.56.x read only `version === 1`.
572
+ return { linkWrites: value?.version === 1, cardCreation: value?.version === 1 && value?.captureWork === 1 }
573
+ } catch { return { linkWrites: false, cardCreation: false, bridgeUnavailable: true } }
574
+ }
575
+ export interface CapturedWorkItem { created: boolean; id: string; linked: boolean; checked: boolean; archived: boolean; delegated: boolean }
576
+ /** One locked write: a Mentioned card already linked to its meeting (COS `task_write.capture_work_item`). */
577
+ export async function captureWorkItem(domain: string, text: string, meeting: WorkMeetingRef): Promise<CapturedWorkItem> {
578
+ if (!isSafeDomainName(domain) || !taskDomains().includes(domain)) throw new TaskRunError(400, 'invalid_domain', 'Unknown task domain')
579
+ if (!text || text.length > 2000) throw new TaskRunError(422, 'invalid_task_text', 'Card text must be 1 to 2,000 characters')
580
+ rejectReservedWorkText(text)
581
+ const out = await withLockRetry(() => bridge(['task-capture-work', domain], JSON.stringify({ text, meeting }))) as Partial<CapturedWorkItem> & { ok?: unknown }
582
+ if (out?.ok !== true || typeof out.id !== 'string' || !/^[a-f0-9]{12}$/.test(out.id) || typeof out.created !== 'boolean' || typeof out.linked !== 'boolean') {
583
+ throw new TaskRunError(502, 'capture_unverified', 'The card could not be confirmed. Refresh Work before trying again.')
584
+ }
585
+ return { created: out.created, id: out.id, linked: out.linked, checked: out.checked === true, archived: out.archived === true, delegated: out.delegated === true }
586
+ }
563
587
  export async function linkTaskMeeting(domain: string, id: string, expectedText: string, expectedRevision: string, meeting: WorkMeetingRef): Promise<void> {
564
588
  validateWorkTarget(domain, id, expectedText, expectedRevision)
565
589
  await withLockRetry(() => bridge(['task-link-meeting', domain, id], JSON.stringify({ expectedText, expectedRevision, meeting })))
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Work intake: meeting-derived Work that is not yet on the board, and cards a producer already made.
3
+ *
4
+ * A producer (COS `work_intake.py`, Jev-scored) upserts items; the user resolves them in Control.
5
+ * Suggested meeting links (0.60 to 0.85, or higher for a task already linked to several meetings), other people's
6
+ * asks, and review items (his own, from an older meeting or not clear enough to card)
7
+ * live here, never in tasks.md: task metadata accepts only workflowPhase/workIdentity/meetingRefs, and an
8
+ * older validator would mark a whole task line invalid if a suggestion were stored on it.
9
+ *
10
+ * One process owns the journal (instance lock). A bad record is quarantined on its own instead of taking
11
+ * the store down. A user's decision is sticky while it is retained (60 days): a later producer upsert never reopens
12
+ * it, and the producer's own ledger never re-sends a decided item.
13
+ */
14
+ import { existsSync, lstatSync, mkdirSync, readFileSync } from 'node:fs'
15
+ import { join, resolve } from 'node:path'
16
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
17
+ import { isSafeDomainName } from './domains.js'
18
+ import { acquireServerInstanceLock, type ServerInstanceLock } from './server-instance-lock.js'
19
+
20
+ export class WorkIntakeError extends Error {
21
+ constructor(readonly code: string, readonly status = 409) { super(code) }
22
+ }
23
+ export type IntakeKind = 'link' | 'ask'
24
+ export type IntakeStatus = 'suggested' | 'review' | 'ask' | 'accepted' | 'dismissed'
25
+ export interface IntakeMeeting { domain: string; month: string; filename: string; recordId: string; title: string }
26
+ export interface IntakeTask { domain: string; id: string; workIdentity: string; text: string }
27
+ export interface IntakePull { kind: 'session'; id: string; title: string; p: number }
28
+ export interface IntakeResolution { action: 'accept' | 'dismiss'; by: 'producer' | 'user'; at: string; taskId?: string }
29
+ /** Why an item of Miles's landed in Review instead of becoming a card. */
30
+ export type IntakeReason = 'unclear_task' | 'owner_uncertain' | 'outside_window'
31
+ const REASONS: ReadonlySet<string> = new Set<IntakeReason>(['unclear_task', 'owner_uncertain', 'outside_window'])
32
+ export interface WorkIntakeItem {
33
+ id: string; kind: IntakeKind; status: IntakeStatus; meeting: IntakeMeeting; confidence: number; model: string
34
+ producedAt: string; updatedAt: string
35
+ task?: IntakeTask; relation?: 'originated' | 'advanced'
36
+ text?: string; owner?: string | null; ownerIsMiles?: boolean; pull?: IntakePull | null; reason?: IntakeReason
37
+ resolution?: IntakeResolution
38
+ }
39
+
40
+ export const INTAKE_LIMITS = { items: 5_000, batch: 200, text: 600, title: 300, owner: 80, model: 64, retainResolvedDays: 60 } as const
41
+ const OPEN: ReadonlySet<IntakeStatus> = new Set(['suggested', 'review', 'ask'])
42
+ const ID = /^wi_[a-f0-9]{32}$/
43
+ const TASK_ID = /^[a-f0-9]{12}$/
44
+ const MONTH = /^\d{4}-(0[1-9]|1[0-2])$/
45
+ const clean = (v: unknown, max: number): string | null =>
46
+ typeof v === 'string' && v.trim() && v.length <= max && !/[\x00-\x08\x0b-\x1f\x7f]/.test(v) ? v.replace(/\s+/g, ' ').trim() : null
47
+ const iso = (v: unknown): string | null => typeof v === 'string' && v.length <= 40 && !Number.isNaN(Date.parse(v)) ? v : null
48
+
49
+ function meetingOf(raw: unknown): IntakeMeeting {
50
+ const m = raw as Partial<IntakeMeeting> | null
51
+ const domain = clean(m?.domain, 64), month = clean(m?.month, 7), filename = clean(m?.filename, 255)
52
+ const recordId = clean(m?.recordId, 512), title = clean(m?.title, INTAKE_LIMITS.title)
53
+ if (!domain || !isSafeDomainName(domain) || !month || !MONTH.test(month) || !filename || !/^[^/\\]+\.md$/.test(filename) || !recordId || !title) {
54
+ throw new WorkIntakeError('invalid_intake_meeting', 400)
55
+ }
56
+ return { domain, month, filename, recordId, title }
57
+ }
58
+
59
+ /** Validate one producer or journal record. Unknown fields are dropped, never trusted. */
60
+ export function parseIntakeItem(raw: unknown, now = new Date().toISOString()): WorkIntakeItem {
61
+ const r = raw as Record<string, unknown> | null
62
+ if (!r || typeof r !== 'object' || Array.isArray(r)) throw new WorkIntakeError('invalid_intake_item', 400)
63
+ const id = typeof r.id === 'string' && ID.test(r.id) ? r.id : null
64
+ const kind = r.kind === 'link' || r.kind === 'ask' ? r.kind : null
65
+ const status = typeof r.status === 'string' && ['suggested', 'review', 'ask', 'accepted', 'dismissed'].includes(r.status) ? r.status as IntakeStatus : null
66
+ const confidence = typeof r.confidence === 'number' && r.confidence >= 0 && r.confidence <= 1 ? r.confidence : null
67
+ const model = clean(r.model, INTAKE_LIMITS.model)
68
+ if (!id || !kind || !status || confidence === null || !model) throw new WorkIntakeError('invalid_intake_item', 400)
69
+ const allowed: readonly IntakeStatus[] = kind === 'link' ? ['suggested', 'accepted', 'dismissed'] : ['review', 'ask', 'accepted', 'dismissed']
70
+ if (!allowed.includes(status)) throw new WorkIntakeError('invalid_intake_status', 400)
71
+ const item: WorkIntakeItem = { id, kind, status, meeting: meetingOf(r.meeting), confidence, model,
72
+ producedAt: iso(r.producedAt) ?? now, updatedAt: iso(r.updatedAt) ?? now }
73
+ if (kind === 'link') {
74
+ const t = r.task as Partial<IntakeTask> | null
75
+ const domain = clean(t?.domain, 64), tid = clean(t?.id, 12), identity = clean(t?.workIdentity, 12), text = clean(t?.text, INTAKE_LIMITS.text)
76
+ if (!domain || !isSafeDomainName(domain) || !tid || !TASK_ID.test(tid) || !identity || !TASK_ID.test(identity) || !text) {
77
+ throw new WorkIntakeError('invalid_intake_task', 400)
78
+ }
79
+ item.task = { domain, id: tid, workIdentity: identity, text }
80
+ item.relation = r.relation === 'originated' ? 'originated' : 'advanced'
81
+ } else {
82
+ const text = clean(r.text, INTAKE_LIMITS.text)
83
+ if (!text) throw new WorkIntakeError('invalid_intake_text', 400)
84
+ item.text = text
85
+ item.owner = r.owner === null || r.owner === undefined ? null : clean(r.owner, INTAKE_LIMITS.owner)
86
+ item.ownerIsMiles = r.ownerIsMiles === true
87
+ // Advisory, like `reason`: a malformed pull is dropped and the ask itself still arrives.
88
+ const p = r.pull as Partial<IntakePull> | null | undefined
89
+ const pid = clean(p?.id, 200), title = clean(p?.title, 120)
90
+ item.pull = p && p.kind === 'session' && pid && title && typeof p.p === 'number' && p.p >= 0 && p.p <= 1 ? { kind: 'session', id: pid, title, p: p.p } : null
91
+ // Optional and advisory: an unknown reason from a newer producer is dropped, never a reason to refuse the item.
92
+ if (typeof r.reason === 'string' && REASONS.has(r.reason)) item.reason = r.reason as IntakeReason
93
+ }
94
+ const res = r.resolution as Partial<IntakeResolution> | undefined
95
+ if (res !== undefined) {
96
+ if ((res.action !== 'accept' && res.action !== 'dismiss') || (res.by !== 'producer' && res.by !== 'user')) throw new WorkIntakeError('invalid_intake_resolution', 400)
97
+ const taskId = res.taskId === undefined ? undefined : clean(res.taskId, 12)
98
+ if (taskId !== undefined && (!taskId || !TASK_ID.test(taskId))) throw new WorkIntakeError('invalid_intake_resolution', 400)
99
+ item.resolution = { action: res.action, by: res.by, at: iso(res.at) ?? now, ...(taskId ? { taskId } : {}) }
100
+ }
101
+ if ((item.status === 'accepted' || item.status === 'dismissed') !== (item.resolution !== undefined)) throw new WorkIntakeError('invalid_intake_resolution', 400)
102
+ return item
103
+ }
104
+
105
+ export interface IntakeRejection { id: string | null; code: string }
106
+ export interface UpsertResult { inserted: number; updated: number; sticky: number; rejected: IntakeRejection[] }
107
+
108
+ export class WorkIntakeStore {
109
+ private rows: WorkIntakeItem[] = []
110
+ private quarantined: unknown[] = []
111
+ private readonly path: string
112
+ private readonly lock: ServerInstanceLock
113
+ private closed = false
114
+ private readonly busy = new Set<string>()
115
+ constructor(root: string, private readonly now: () => Date = () => new Date()) {
116
+ const dir = resolve(root)
117
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
118
+ const stat = lstatSync(dir)
119
+ if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || (stat.mode & 0o077)) throw new WorkIntakeError('intake_store_unsafe', 503)
120
+ this.path = join(dir, 'intake.json')
121
+ try { this.lock = acquireServerInstanceLock({ lockDir: join(dir, 'writer.lock') }) }
122
+ catch { throw new WorkIntakeError('intake_store_locked', 503) }
123
+ if (!existsSync(this.path)) return
124
+ try {
125
+ const file = lstatSync(this.path)
126
+ if (!file.isFile() || file.isSymbolicLink() || file.uid !== process.getuid?.() || (file.mode & 0o077) || file.size > 32_000_000) throw new Error('unsafe')
127
+ const journal = JSON.parse(readFileSync(this.path, 'utf8'))
128
+ if (journal?.version !== 1 || !Array.isArray(journal.items)) throw new Error('version')
129
+ const seen = new Set<string>()
130
+ for (const raw of journal.items) {
131
+ try {
132
+ const item = parseIntakeItem(raw)
133
+ if (seen.has(item.id)) throw new Error('duplicate')
134
+ seen.add(item.id); this.rows.push(item)
135
+ } catch { this.quarantined.push(raw) } // one bad record never takes the store down
136
+ }
137
+ if (Array.isArray(journal.quarantined)) this.quarantined.push(...journal.quarantined.slice(0, 500))
138
+ if (this.quarantined.length) console.warn(`[work-intake] ${this.quarantined.length} unreadable journal record(s) set aside in ${this.path}`)
139
+ } catch { this.close(); throw new WorkIntakeError('intake_store_recovery_required', 503) }
140
+ }
141
+ list(): WorkIntakeItem[] { return structuredClone(this.rows) }
142
+ open(): WorkIntakeItem[] { return this.list().filter(r => OPEN.has(r.status)) }
143
+ get(id: string): WorkIntakeItem | undefined { return this.list().find(r => r.id === id) }
144
+ quarantineCount(): number { return this.quarantined.length }
145
+
146
+ /**
147
+ * Validate per item: one bad item is answered in `rejected` and never blocks the rest of the batch, so a
148
+ * producer queue cannot wedge on a single record. Only a malformed batch as a whole is refused outright.
149
+ */
150
+ upsert(raws: unknown[]): UpsertResult {
151
+ if (!Array.isArray(raws) || raws.length > INTAKE_LIMITS.batch) throw new WorkIntakeError('invalid_intake_batch', 400)
152
+ const at = this.now().toISOString()
153
+ const items: WorkIntakeItem[] = [], result: UpsertResult = { inserted: 0, updated: 0, sticky: 0, rejected: [] }
154
+ for (const raw of raws) {
155
+ const rawId = (raw as { id?: unknown } | null)?.id
156
+ const id = typeof rawId === 'string' && ID.test(rawId) ? rawId : null
157
+ try {
158
+ const item = parseIntakeItem(raw, at)
159
+ if (item.resolution?.by === 'user') throw new WorkIntakeError('producer_cannot_record_user_decisions', 400)
160
+ items.push(item)
161
+ } catch (e) {
162
+ result.rejected.push({ id, code: e instanceof WorkIntakeError ? e.code : 'invalid_intake_item' })
163
+ }
164
+ }
165
+ if (!items.length) return result
166
+ const next = this.list()
167
+ for (const item of items) {
168
+ const index = next.findIndex(r => r.id === item.id)
169
+ if (index < 0) { next.unshift({ ...item, updatedAt: at }); result.inserted++; continue }
170
+ const prior = next[index]
171
+ if (prior.resolution?.by === 'user' || (prior.status === 'accepted' && item.status !== 'accepted')) { result.sticky++; continue }
172
+ next[index] = { ...item, producedAt: prior.producedAt, updatedAt: at }
173
+ result.updated++
174
+ }
175
+ this.write(next)
176
+ return result
177
+ }
178
+
179
+ /** Serialize resolution per item: a double click gets 409 instead of a second link or card. */
180
+ async resolve(id: string, fn: (item: WorkIntakeItem) => Promise<{ taskId?: string; action: 'accept' | 'dismiss' }>): Promise<WorkIntakeItem> {
181
+ if (!ID.test(id)) throw new WorkIntakeError('invalid_intake_id', 400)
182
+ if (this.busy.has(id)) throw new WorkIntakeError('intake_busy', 409)
183
+ const current = this.get(id)
184
+ if (!current) throw new WorkIntakeError('intake_not_found', 404)
185
+ if (!OPEN.has(current.status)) return current // repeat accept or dismiss: already decided, idempotent
186
+ this.busy.add(id)
187
+ try {
188
+ const outcome = await fn(current)
189
+ const at = this.now().toISOString()
190
+ const next = this.list(), index = next.findIndex(r => r.id === id)
191
+ if (index < 0) throw new WorkIntakeError('intake_not_found', 404)
192
+ next[index] = { ...next[index], status: outcome.action === 'accept' ? 'accepted' : 'dismissed', updatedAt: at,
193
+ resolution: { action: outcome.action, by: 'user', at, ...(outcome.taskId ? { taskId: outcome.taskId } : {}) } }
194
+ this.write(next)
195
+ return structuredClone(next[index])
196
+ } finally { this.busy.delete(id) }
197
+ }
198
+
199
+ private write(rows: WorkIntakeItem[]): void {
200
+ if (this.closed) throw new WorkIntakeError('intake_store_closed', 503)
201
+ const cutoff = this.now().getTime() - INTAKE_LIMITS.retainResolvedDays * 86_400_000
202
+ const kept = rows.filter(r => OPEN.has(r.status) || Date.parse(r.updatedAt) >= cutoff)
203
+ if (kept.length > INTAKE_LIMITS.items) throw new WorkIntakeError('intake_store_full', 503)
204
+ durableAtomicWriteFileSync(this.path, JSON.stringify({ version: 1, items: kept, quarantined: this.quarantined }) + '\n')
205
+ this.rows = kept
206
+ }
207
+ close(): void {
208
+ if (this.closed) return
209
+ this.closed = true
210
+ this.lock.release()
211
+ }
212
+ }
213
+
214
+ /** Optional: a locked or unreadable journal must never keep the base API from starting. */
215
+ export function createOptionalWorkIntakeStore(create: () => WorkIntakeStore): WorkIntakeStore | null {
216
+ try { return create() }
217
+ catch (error) {
218
+ console.error('[work-intake] store unavailable; base API remains available:', error instanceof WorkIntakeError ? error.code : error)
219
+ return null
220
+ }
221
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Jev key management (COS Control Settings) and Work session recommendations. Auth comes from the parent /api mount.
3
+ *
4
+ * The key is push-only, like /api/openai-key: validated live against TypeSafe's free model listing, saved to
5
+ * data/jev-key.json (0600), and never returned to any client. The saved key wins over TYPESAFE_API_KEY in the
6
+ * environment (see resolveJevKey); /status says which source is active so Control can show it honestly.
7
+ */
8
+ import { Router } from 'express'
9
+ import { existsSync, unlinkSync } from 'node:fs'
10
+ import { JEV_KEY_FILE, saveJevKey, sharedJevClient, validateJevKey, type JevClient } from '../lib/jev.js'
11
+ import { SessionRecommender, parseCandidates } from '../lib/session-recommendation.js'
12
+ import { listBoard } from '../lib/task-store.js'
13
+ import { isSafeDomainName } from '../lib/domains.js'
14
+
15
+ export interface JevRouteDependencies {
16
+ jev: JevClient
17
+ recommender: Pick<SessionRecommender, 'recommend'>
18
+ list: typeof listBoard
19
+ validate: typeof validateJevKey
20
+ save: typeof saveJevKey
21
+ }
22
+
23
+ export function createJevRouter(overrides: Partial<JevRouteDependencies> = {}): Router {
24
+ const jev = overrides.jev ?? sharedJevClient()
25
+ const deps: JevRouteDependencies = { jev, recommender: new SessionRecommender(jev), list: listBoard, validate: validateJevKey, save: saveJevKey, ...overrides }
26
+ const router = Router()
27
+ router.use(['/jev-key', '/work-board/session-recommendation'], (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
28
+
29
+ router.get('/jev-key/status', (_req, res) => res.json(deps.jev.status()))
30
+
31
+ router.post('/jev-key/set', async (req, res) => {
32
+ const raw = req.body?.key
33
+ if (!req.body || typeof req.body !== 'object' || Object.keys(req.body).some(k => k !== 'key') || typeof raw !== 'string') {
34
+ return res.status(400).json({ error: { code: 'invalid_jev_key', message: 'Paste a TypeSafe API key.' } })
35
+ }
36
+ const key = raw.trim()
37
+ if (key.length < 16 || key.length > 512 || /[\s\x00-\x1f\x7f]/.test(key)) {
38
+ return res.status(400).json({ error: { code: 'invalid_jev_key', message: 'That does not look like a TypeSafe API key.' } })
39
+ }
40
+ const check = await deps.validate(key)
41
+ if (!check.ok) return res.status(400).json({ error: { code: 'jev_key_not_accepted', message: check.reason, upstreamStatus: check.status } })
42
+ try { deps.save(key) } catch {
43
+ return res.status(500).json({ error: { code: 'jev_key_not_saved', message: 'The key was valid but could not be saved. Check the server data folder.' } })
44
+ }
45
+ deps.jev.resetForNewKey()
46
+ return res.json({ ok: true, ...deps.jev.status() })
47
+ })
48
+
49
+ router.delete('/jev-key', (_req, res) => {
50
+ try { if (existsSync(JEV_KEY_FILE)) unlinkSync(JEV_KEY_FILE) } catch {
51
+ return res.status(500).json({ error: { code: 'jev_key_not_removed', message: 'The saved key could not be removed.' } })
52
+ }
53
+ return res.json({ ok: true, ...deps.jev.status() })
54
+ })
55
+
56
+ /** Continue / Fork / New for one task. The task comes from the board; the client sends only candidate sessions. */
57
+ router.post('/work-board/session-recommendation', async (req, res) => {
58
+ const body = req.body
59
+ const sessions = parseCandidates(body?.sessions)
60
+ if (!body || typeof body !== 'object' || Object.keys(body).some(k => !['domain', 'id', 'sessions'].includes(k))
61
+ || typeof body.domain !== 'string' || !isSafeDomainName(body.domain) || typeof body.id !== 'string' || !/^[a-f0-9]{12}$/.test(body.id) || !sessions) {
62
+ return res.status(400).json({ error: { code: 'invalid_recommendation_request', message: 'Select an exact task and send at most 80 sessions.' } })
63
+ }
64
+ try {
65
+ // Work identity first: it survives a rename, while a new card's id could equal an old identity's text hash.
66
+ const rows = (await deps.list()).filter(r => r.domain === body.domain)
67
+ const row = rows.find(r => r.workIdentity === body.id) ?? rows.find(r => r.id === body.id)
68
+ if (!row) return res.status(404).json({ error: { code: 'task_not_found', message: 'That task changed or was removed. Refresh Work.' } })
69
+ const meetings = (row.meetingRefs ?? []).map(ref => ref.title ?? '').filter(Boolean)
70
+ return res.json(await deps.recommender.recommend({ task: { text: row.text || row.title || '', domain: row.domain, meetings }, sessions }))
71
+ } catch (e) {
72
+ console.error('[jev] session recommendation failed:', e instanceof Error ? e.message : e)
73
+ return res.json({ provider: 'none', reason: 'recommendation_unavailable' }) // advice only: never block the workspace
74
+ }
75
+ })
76
+ return router
77
+ }
@@ -0,0 +1,136 @@
1
+ import { Router } from 'express'
2
+ import { WorkIntakeError, WorkIntakeStore, type IntakeMeeting, type WorkIntakeItem } from '../lib/work-intake-store.js'
3
+ import { captureWorkItem, linkTaskMeeting, listBoard, workIntakeCapabilities, TaskBridgeError, TaskRunError,
4
+ type WorkMeetingRef } from '../lib/task-store.js'
5
+ import { resolveSavedMeetingDetail } from './meetings.js'
6
+ import type { MeetingDescriptor } from '../lib/work-review-store.js'
7
+ import type { MeetingDetail } from '../lib/meeting-store.js'
8
+
9
+ export interface WorkIntakeDependencies {
10
+ store: WorkIntakeStore | null
11
+ list: typeof listBoard
12
+ capabilities: typeof workIntakeCapabilities
13
+ link: typeof linkTaskMeeting
14
+ capture: typeof captureWorkItem
15
+ resolveMeeting: (descriptor: MeetingDescriptor) => MeetingDetail | Promise<MeetingDetail>
16
+ }
17
+ const MAX_MEETING_REFS = 8 // task_work_metadata.MAX_REFS
18
+ export const LINK_RACE_RETRIES = 2
19
+ const DUE_TAIL = /\s*[—–-]+\s*Due:[^()]*$/
20
+
21
+ /**
22
+ * The words an accepted item becomes on the board. An ask records whose item it was: the producer moves the
23
+ * owner's trailing tag out of the words, so it is written back here as "(from Name)", ahead of any due date.
24
+ */
25
+ export function intakeCardText(item: Pick<WorkIntakeItem, 'text' | 'owner' | 'ownerIsMiles'>): string {
26
+ const text = item.text ?? '', owner = item.owner?.trim()
27
+ if (item.ownerIsMiles || !owner) return text
28
+ const due = DUE_TAIL.exec(text)
29
+ return due ? `${text.slice(0, due.index)} (from ${owner})${due[0]}` : `${text} (from ${owner})`
30
+ }
31
+
32
+ /** Auth is supplied by the parent /api mount. Nothing here calls a model or starts an agent. */
33
+ export function createWorkIntakeRouter(overrides: Partial<WorkIntakeDependencies> & Pick<WorkIntakeDependencies, 'store'>): Router {
34
+ const deps: WorkIntakeDependencies = { list: listBoard, capabilities: workIntakeCapabilities, link: linkTaskMeeting,
35
+ capture: captureWorkItem, resolveMeeting: resolveSavedMeetingDetail, ...overrides }
36
+ const router = Router()
37
+ router.use('/work-intake', (_req, res, next) => {
38
+ res.set('Cache-Control', 'private, no-store')
39
+ if (!deps.store) return res.status(503).json({ error: { code: 'work_intake_unavailable', message: 'Work intake is unavailable on this server.' } })
40
+ next()
41
+ })
42
+ /** Refusals are answers; anything unexpected is also logged, since Control only shows the code. */
43
+ const fail = (res: import('express').Response, e: unknown, where: string) => {
44
+ if (e instanceof WorkIntakeError) {
45
+ if (e.status >= 500) console.error(`[work-intake] ${where}: ${e.code}`)
46
+ return res.status(e.status).json({ error: { code: e.code } })
47
+ }
48
+ if (e instanceof TaskRunError) {
49
+ if (e.status >= 500) console.error(`[work-intake] ${where}: ${e.code}: ${e.message}`)
50
+ return res.status(e.status).json({ error: { code: e.code, message: e.message } })
51
+ }
52
+ if (e instanceof TaskBridgeError) return res.status(e.code === 'task_not_found' ? 404 : 409).json({ error: { code: e.code, message: e.message } })
53
+ console.error(`[work-intake] ${where} failed:`, e instanceof Error ? e.message : e)
54
+ return res.status(503).json({ error: { code: 'work_intake_unavailable', message: 'Work intake is unavailable. Refresh before trying again.' } })
55
+ }
56
+
57
+ router.get('/work-intake', async (_req, res) => {
58
+ try {
59
+ const items = deps.store!.open()
60
+ const counts = { suggested: 0, ask: 0, review: 0 }
61
+ for (const item of items) counts[item.status as keyof typeof counts]++
62
+ res.json({ schemaVersion: 1, capabilities: await deps.capabilities(), counts, items, quarantined: deps.store!.quarantineCount() })
63
+ } catch (e) { fail(res, e, 'list') }
64
+ })
65
+
66
+ router.post('/work-intake/items', (req, res) => {
67
+ try {
68
+ const body = req.body
69
+ if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(k => k !== 'items') || !Array.isArray(body.items)) {
70
+ throw new WorkIntakeError('invalid_intake_batch', 400)
71
+ }
72
+ res.json({ ok: true, ...deps.store!.upsert(body.items) })
73
+ } catch (e) { fail(res, e, 'upsert') }
74
+ })
75
+
76
+ router.post('/work-intake/:id/resolve', async (req, res) => {
77
+ try {
78
+ const action = req.body?.action
79
+ if (!req.body || typeof req.body !== 'object' || Object.keys(req.body).some(k => k !== 'action') || (action !== 'accept' && action !== 'dismiss')) {
80
+ throw new WorkIntakeError('invalid_intake_action', 400)
81
+ }
82
+ const item = await deps.store!.resolve(String(req.params.id), async current =>
83
+ action === 'dismiss' ? { action: 'dismiss' } : { action: 'accept', taskId: await accept(current) })
84
+ res.json({ ok: true, item })
85
+ } catch (e) { fail(res, e, 'resolve') }
86
+ })
87
+
88
+ /** The meeting must still be the one that was judged; its canonical title comes from the library, not the producer. */
89
+ async function verifiedMeeting(m: IntakeMeeting): Promise<WorkMeetingRef> {
90
+ const descriptor: MeetingDescriptor = { domain: m.domain, month: m.month, filename: m.filename, recordId: m.recordId }
91
+ let resolved: MeetingDetail
92
+ try { resolved = await deps.resolveMeeting(descriptor) }
93
+ catch { throw new WorkIntakeError('meeting_unavailable', 409) }
94
+ if (resolved.recordId !== m.recordId) throw new WorkIntakeError('meeting_identity_changed', 409)
95
+ return { ...descriptor, recordId: m.recordId, title: (resolved.title || m.title).replace(/\s+/g, ' ').trim().slice(0, 300) }
96
+ }
97
+
98
+ async function accept(item: WorkIntakeItem): Promise<string> {
99
+ // The write power first: a read-only board must say so, not report its meeting as unreadable.
100
+ const capabilities = await deps.capabilities()
101
+ if (capabilities.bridgeUnavailable) throw new WorkIntakeError('task_bridge_unavailable', 503)
102
+ if (item.kind === 'link' && !capabilities.linkWrites) throw new WorkIntakeError('work_board_read_only', 409)
103
+ if (item.kind === 'ask' && !capabilities.cardCreation) throw new WorkIntakeError('card_creation_unavailable', 409)
104
+ const meeting = await verifiedMeeting(item.meeting)
105
+ if (item.kind === 'link') {
106
+ const task = item.task!
107
+ // Work identity survives a rename; the task id does not.
108
+ return linkRow(task.domain, r => r.workIdentity === task.workIdentity || r.id === task.id, meeting)
109
+ }
110
+ const out = await deps.capture(meeting.domain, intakeCardText(item), meeting)
111
+ if (out.created || out.linked) return out.id
112
+ // The same words are already an OPEN task: that task is this work, so link it (as the producer does).
113
+ // On closed, archived or delegated work, adding a second card with the same words is refused.
114
+ if (!out.checked && !out.archived && !out.delegated) return linkRow(meeting.domain, r => r.id === out.id, meeting)
115
+ throw new WorkIntakeError('intake_text_collision', 409)
116
+ }
117
+
118
+ /** Link one task to the meeting. Re-reads the row on every attempt; up to two retries on a revision race. */
119
+ async function linkRow(domain: string, match: (row: Awaited<ReturnType<typeof listBoard>>[number]) => boolean, meeting: WorkMeetingRef): Promise<string> {
120
+ for (let attempt = 0; ; attempt++) {
121
+ const row = (await deps.list()).find(r => r.domain === domain && match(r))
122
+ if (!row || !row.workRevision) throw new WorkIntakeError('intake_task_changed', 409)
123
+ const refs = row.meetingRefs ?? []
124
+ if (refs.some(ref => ref.recordId === meeting.recordId)) return row.id
125
+ if (refs.length >= MAX_MEETING_REFS) throw new WorkIntakeError('meeting_links_full', 409)
126
+ try {
127
+ await deps.link(row.domain, row.id, row.text, row.workRevision, meeting)
128
+ return row.id
129
+ } catch (e) {
130
+ if (attempt < LINK_RACE_RETRIES && e instanceof TaskBridgeError && e.code === 'task_revision_changed') continue
131
+ throw e
132
+ }
133
+ }
134
+ }
135
+ return router
136
+ }