@gotcos/glasses-server 6.57.0 → 6.58.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,45 @@
1
+ ## 6.58.0
2
+
3
+ ### Work: did the session finish the task?
4
+
5
+ - Miles, 2026-09-29: "If a session is responsible for completing multiple tasks and we have evidence of the session receiving the context necessary to complete that task (as well as a confirmation that it has), then we want to be moving those tasks through the Kanban as well automatically."
6
+ - COS Control 0.5.247 follows each Work handoff until the session reports on it.
7
+ - Every handoff asks the agent to make its first reply line `COS-WORK <id>: done: <evidence>`. Control reads it from the session's recent messages (`GET /api/agent-sessions/:provider/:id?turns=N`, served since 6.50.0) and moves the card itself, as far as QA and never to Complete.
8
+ - **New `POST /api/work-board/completion-check`**, the fallback when the session's newest reply since the handoff has no status line and the session is idle.
9
+ - The body is exactly `{domain, id, provider, sessionId}`, plus an optional `after` (an ISO time).
10
+ - The task comes from the board. The replies come from this server's own transcripts: with `after`, every reply written since then, joined and kept to the newest 4,000 characters (`LATEST_REPLY_MAX`); without it, the newest reply.
11
+ - The client never sends work or reply text.
12
+ - Jev answers one choice question (done, not_done or unclear) with a probability.
13
+ - The task is judged against its Done when, or, when it has none, against its own text. No open task has one today. The answer says which, as `basis: "done_when" | "task"`.
14
+ - The answer is advice. Control moves a card only on done at 0.8 or more (`done_when`) or 0.85 (`task`). Confidence is not rounded, so 0.795 never reads as 0.8.
15
+ - **Answers:**
16
+ - `{provider: "jev", verdict, confidence, basis, model, cached}`.
17
+ - Otherwise `{provider: "none", reason}`:
18
+ - `no_reply` when there is no reply;
19
+ - `no_task_text` when the task has no text;
20
+ - any Jev condition (`jev_not_configured`, `jev_cap_reached`, `jev_breaker_open` and the rest);
21
+ - `completion_unavailable` for an unexpected failure, which is logged and answered 200.
22
+ - An unknown task is 404 `task_not_found`, an unknown session 404 `session_not_found`, and any other body 400 `invalid_completion_request`.
23
+ - Answers are cached for 30 minutes per task text, Done when and reply.
24
+ - **Cost:** Control asks at most twice per handoff, once per new reply, and only while the session is idle. The task text is clipped to 1,200 characters, the Done when to 500 and the replies to 4,000, so one check is at most about 2,500 input tokens, inside the shared daily Jev cap (`COS_JEV_DAILY_TOKENS`). The reply text goes to TypeSafe unredacted, as with every Jev question about Work.
25
+ - **Other changes:**
26
+ - `server/routes/jev.ts` finds a task through one `findTask` (Work identity first, then row id) for both suggestions and completion checks.
27
+ - The local Work candidate answers completion checks with no session, so it never reads this Mac's transcripts.
28
+ - **Tests:**
29
+ - The checker: policy, unrounded confidence, basis, the clipping of task, Done when and reply, cache and reasons.
30
+ - The replies reader, over a fixture transcript: `after` window, newest kept, unknown session.
31
+ - The route: board and transcripts as the only sources, `after` passed through and validated, exact bodies, 404s, a failure answered as advice.
32
+ - The mutation gate adds 16 mutants and runs `work-completion.test.ts`.
33
+
34
+ ## 6.57.1
35
+
36
+ ### Session suggestions for meeting reviews
37
+
38
+ - Miles, 2026-09-28: the meeting review "Retail Liquor Summit Campaign Launch" got no session suggestion, although a session named "Retail Liquor Summit campaign launch" existed. Suggestions only took a board task. `POST /api/work-board/session-recommendation` now also takes exactly `{reviewId, sessions}`. The server reads the review's meeting title and reviewed follow-up from its own review store; the client never supplies the work text, the same rule as for tasks. An unknown review answers 404 `review_not_found`, a server with reviews off answers 404 `reviews_unavailable`, a review request with a bad id or session list answers 400 `invalid_review_request` (its own code, so Control can tell it from a 6.57.0 server), and a mixed or unknown body answers 400. Task requests are unchanged. Needs COS Control 0.5.243 to ask for reviews.
39
+ - The review is read with a new `peek`, straight from the review store. The existing `get` reconciled every review row on each call, and suggestions ask on each Work visit. A lookup that throws answers 503 `review_store_unavailable` rather than "not found" (defensive: the stock lookup reads memory). `WorkReviewStore.get` now clones only the matched row.
40
+ - The local Work candidate passes its own review runtime the same way.
41
+ - Tests: the route test covers the review body, the missing review, reviews off, the exact-shape refusals and the failure fallback; the runtime test covers `peek` (no reconcile, no meeting resolution). The mutation gate adds seven mutants for the new branches (including `peek` and the review 400 code) and now runs the review runtime tests. docs/work-intake.md documents the review request.
42
+
1
43
  ## 6.57.0
2
44
 
3
45
  ### Jev in the server: session suggestions and a key you can set from COS Control
@@ -30,3 +30,7 @@ The journal is `work-intake/intake.json` in the server data directory, owned by
30
30
  ## Jev key and session suggestions
31
31
 
32
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.
33
+
34
+ Since 6.57.1 `POST /api/work-board/session-recommendation` also takes exactly `{ reviewId, sessions }` for a meeting review (`reviewId` = `wr_` + 32 hex). The server reads the review's meeting title and reviewed follow-up from its own store; the client never sends work text. Errors: 400 `invalid_review_request` (bad id or session list), 404 `review_not_found`, 404 `reviews_unavailable` (reviews off), 503 `review_store_unavailable` (defensive; the stock lookup reads memory).
35
+
36
+ Since 6.58.0 `POST /api/work-board/completion-check` takes exactly `{ domain, id, provider, sessionId }` plus an optional `after` (ISO time). It asks Jev whether the session's replies since `after` (or its newest reply) show the task finished, judged against its Done when or, without one, its own text. The server reads the task from the board and the replies from its own transcripts; the client sends names and a time only. It answers `{ provider: "jev", verdict: "done" | "not_done" | "unclear", confidence, basis: "done_when" | "task", model, cached }` or `{ provider: "none", reason }` (`no_reply`, `no_task_text`, a Jev condition, `completion_unavailable`). Errors: 400 `invalid_completion_request`, 404 `task_not_found`, 404 `session_not_found`. COS Control 0.5.247 asks only while the session is idle and its newest reply since the handoff has no `COS-WORK` status line, at most twice per handoff, and moves a card to QA only on done at 0.8 (0.85 without a Done when).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.57.0",
3
+ "version": "6.58.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": {
package/server/index.ts CHANGED
@@ -629,8 +629,9 @@ app.use('/api', createWorkBoardRouter())
629
629
  // Work intake (6.57.0): meeting-derived suggestions, asks and review items. Optional: a bad journal never blocks boot.
630
630
  const workIntakeStore = createOptionalWorkIntakeStore(() => new WorkIntakeStore(dataPath('work-intake')))
631
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())
632
+ // Jev key (Control Settings), Continue/Fork/New session recommendations for Work tasks (6.57.0) and meeting reviews
633
+ // (6.57.1), and the Work completion check (6.58.0).
634
+ app.use('/api', createJevRouter(workReviewRuntime ? { review: async id => workReviewRuntime.peek(id) } : {}))
634
635
  app.use('/api', queryRouter)
635
636
  // The scheduled start-of-day brief: settings, status, run-now. Same auth as
636
637
  // every other settings route; the brief itself is an ordinary durable job.
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { createHash } from 'node:crypto'
9
9
  import { estimateJevTokens, JevError, type JevClient } from './jev.js'
10
+ import { FORKABLE_PROVIDERS } from './agent-session-binding-store.js'
10
11
 
11
12
  export const CONTINUE_AT = 0.5 // P(a session is already doing this exact task)
12
13
  export const FORK_AT = 0.6 // P(some session shares the context) = 1 - P(none)
@@ -25,8 +26,9 @@ const BEST = "Which one of the user's agent sessions is the natural place to do
25
26
  const EXACT = "Which one of the user's agent sessions is already doing this exact task, so the work should simply continue there? "
26
27
  + 'A session on the same project doing different work does not count. Choose none if no session is doing this task.'
27
28
 
28
- /** Providers the Work handoff can fork (Control's destinationSupported). */
29
- export const FORKABLE = new Set(['claude', 'codex'])
29
+ /** Providers a Work handoff can fork natively (Control 0.5.243 applies a Fork suggestion as a same-platform fork).
30
+ * One list with the fork route (agent-session-binding-store FORKABLE_PROVIDERS). */
31
+ export const FORKABLE = new Set<string>(FORKABLE_PROVIDERS)
30
32
  const clip = (text: string | undefined, max: number) => (text ?? '').replace(/\s+/g, ' ').trim().slice(0, max)
31
33
  const round = (n: number) => Math.round(n * 100) / 100
32
34
 
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Did an agent session finish a Work task? (6.58.0)
3
+ *
4
+ * Control 0.5.247 follows a Work handoff until the session reports on it. Every handoff asks the agent to start its
5
+ * reply with a status line (`COS-WORK <id>: done: <evidence>`). When the session's newest reply since the handoff
6
+ * carries none and the session has gone idle, Control asks this (at most twice per handoff, once per reply). Jev reads
7
+ * the session's replies since the handoff arrived (`after`) against the task's Done when, or, when the task has none
8
+ * (most do), against the task itself. It answers done, not_done or unclear with a probability. Control moves the card
9
+ * to QA only on done at 0.8 or more (0.85 without a Done when), and never to Complete: that stays the user's.
10
+ *
11
+ * The task comes from the board and the replies from this server's own session transcripts. The client sends names
12
+ * and a time only.
13
+ */
14
+ import { createHash } from 'node:crypto'
15
+ import { estimateJevTokens, JevError, type JevClient } from './jev.js'
16
+ import { agentSessionRoots, findAgentSessionFile, LATEST_REPLY_MAX, parseAgentSession, type AgentProvider } from './agent-session-store.js'
17
+ import { readRecentSessionTurns, SESSION_TURNS_MAX, type SessionTurn } from './agent-session-turns.js'
18
+
19
+ export const COMPLETION_LIMITS = { task: 1200, doneWhen: 500, reply: LATEST_REPLY_MAX, cacheMs: 30 * 60_000, cacheEntries: 200 } as const
20
+ export const COMPLETION_CHOICES = ['done', 'not_done', 'unclear'] as const
21
+ export type CompletionChoice = typeof COMPLETION_CHOICES[number]
22
+
23
+ export interface CompletionInput { task: { text: string; doneWhen: string }; reply: string }
24
+ /** `basis`: what the reply was read against, the task's Done when or (without one) the task itself. */
25
+ export type CompletionVerdict =
26
+ | { provider: 'jev'; model: string; verdict: CompletionChoice; confidence: number; basis: 'done_when' | 'task'; cached: boolean }
27
+ | { provider: 'none'; reason: string }
28
+
29
+ const AGAINST_DONE_WHEN = "Does the agent's reply in `reply` show that the task in `task` is finished, as `task.doneWhen` defines finished? "
30
+ + 'Choose done only when the reply reports the work complete and checked against that finish line.'
31
+ const AGAINST_TASK = "Does the agent's reply in `reply` show that the task in `task.text` is finished? "
32
+ + 'Choose done only when the reply reports that exact work complete and checked, not merely started or planned.'
33
+ const REST = ' A plan, a partial result, a question for the user, a blocker, or a promise to do it next is not_done. '
34
+ + 'Choose unclear when the reply does not say enough to tell.'
35
+
36
+ const clip = (text: string, max: number) => text.trim().slice(0, max)
37
+
38
+ /** Pure policy over Jev's answer: the most probable choice and its probability (unrounded: a threshold decides on it). */
39
+ export function decideCompletion(probabilities: Record<string, number> | undefined): { verdict: CompletionChoice; confidence: number } {
40
+ const rows = Object.entries(probabilities ?? {})
41
+ if (!rows.length || rows.some(([k, v]) => !(COMPLETION_CHOICES as readonly string[]).includes(k) || typeof v !== 'number' || !Number.isFinite(v) || v < 0 || v > 1)) {
42
+ throw new JevError('jev_bad_answer', 'Jev answered outside done, not_done and unclear')
43
+ }
44
+ const [verdict, confidence] = rows.sort((a, b) => b[1] - a[1])[0] as [CompletionChoice, number]
45
+ return { verdict, confidence }
46
+ }
47
+
48
+ /** The replies among `turns` written at or after `afterMs`, joined oldest first, keeping the newest `max` characters. */
49
+ export function joinRepliesSince(turns: SessionTurn[], afterMs: number, max: number = COMPLETION_LIMITS.reply): string {
50
+ const joined = turns.filter(t => t.role === 'assistant' && t.at !== undefined && Date.parse(t.at) >= afterMs).map(t => t.text).join('\n\n')
51
+ return joined.length <= max ? joined : `\u2026${joined.slice(joined.length - (max - 1))}`
52
+ }
53
+
54
+ export type SessionFileFinder = (provider: AgentProvider, sessionId: string) => Promise<string | null>
55
+ const findInHome: SessionFileFinder = (provider, sessionId) => findAgentSessionFile(provider, sessionId, agentSessionRoots())
56
+
57
+ /** What a completion check judges: the session's replies since `afterMs`, or its newest reply when no time is given.
58
+ * Null when the session is not on this Mac. */
59
+ export async function readRepliesSince(provider: AgentProvider, sessionId: string, afterMs: number | null,
60
+ find: SessionFileFinder = findInHome): Promise<string | null> {
61
+ const path = await find(provider, sessionId)
62
+ if (!path) return null
63
+ if (afterMs === null) return (await parseAgentSession(provider, path)).latest_reply || ''
64
+ return joinRepliesSince((await readRecentSessionTurns(provider, path, SESSION_TURNS_MAX)).turns, afterMs)
65
+ }
66
+
67
+ export class CompletionChecker {
68
+ private cache = new Map<string, { at: number; value: Extract<CompletionVerdict, { provider: 'jev' }> }>()
69
+ constructor(private readonly jev: JevClient, private readonly now: () => number = () => Date.now()) {}
70
+
71
+ async check(input: CompletionInput): Promise<CompletionVerdict> {
72
+ const state = { task: { text: clip(input.task.text, COMPLETION_LIMITS.task), doneWhen: clip(input.task.doneWhen, COMPLETION_LIMITS.doneWhen) },
73
+ reply: clip(input.reply, COMPLETION_LIMITS.reply) }
74
+ const basis = state.task.doneWhen ? 'done_when' as const : 'task' as const
75
+ if (!state.task.doneWhen && !state.task.text) return { provider: 'none', reason: 'no_task_text' }
76
+ if (!state.reply) return { provider: 'none', reason: 'no_reply' }
77
+ const key = createHash('sha256').update(JSON.stringify(state)).digest('hex')
78
+ const hit = this.cache.get(key)
79
+ if (hit && this.now() - hit.at < COMPLETION_LIMITS.cacheMs) return { ...hit.value, cached: true }
80
+ const questions = {
81
+ finished: { type: 'choice', instructions: (basis === 'done_when' ? AGAINST_DONE_WHEN : AGAINST_TASK) + REST, criteria: {
82
+ done: basis === 'done_when' ? 'The reply reports the task finished and checked against its Done when.' : 'The reply reports this task finished and checked.',
83
+ not_done: 'The reply shows work remaining, a plan, a question, a blocker, or a partial result.',
84
+ unclear: 'The reply does not say enough to tell.',
85
+ } },
86
+ }
87
+ try {
88
+ const reply = await this.jev.ask(state, questions, estimateJevTokens({ state, questions }))
89
+ const value = { provider: 'jev' as const, model: reply.model ?? 'jev', cached: false, basis, ...decideCompletion(reply.answers.finished?.probabilities) }
90
+ if (this.cache.size >= COMPLETION_LIMITS.cacheEntries) this.cache.delete(this.cache.keys().next().value!)
91
+ this.cache.set(key, { at: this.now(), value })
92
+ return value
93
+ } catch (e) {
94
+ if (e instanceof JevError) return { provider: 'none', reason: e.code }
95
+ throw e
96
+ }
97
+ }
98
+ }
@@ -174,6 +174,12 @@ export class WorkReviewRuntime {
174
174
  await this.serial(async () => { if (this.stopping) return; for (const row of this.deps.store.list()) await this.reconcileRow(row, allowAdmission) })
175
175
  }
176
176
  async list(): Promise<WorkReview[]> { await this.reconcile(false); return this.deps.store.list().map(publicReview) }
177
+ /** 6.57.1: one review as stored, without reconciling every row. Session advice reads it on each Work visit.
178
+ * Exact id lookup only (the route validates the `wr_` pattern first). */
179
+ peek(id: string): WorkReview | null {
180
+ const row = this.deps.store.get(id)
181
+ return row ? publicReview(row) : null
182
+ }
177
183
  async get(id: string): Promise<WorkReview> {
178
184
  if (!/^wr_[a-f0-9]{32}$/.test(id)) throw new WorkReviewError('invalid_review_id',400)
179
185
  await this.reconcile(false); const row = this.deps.store.get(id)
@@ -84,7 +84,8 @@ export class WorkReviewStore {
84
84
  } catch { this.close(); throw new WorkReviewError('review_store_recovery_required', 503) }
85
85
  }
86
86
  list(): StoredWorkReview[] { return structuredClone(this.rows) }
87
- get(id: string): StoredWorkReview | undefined { return this.list().find(r => r.id === id) }
87
+ // 6.57.1: clone the one row, not the whole journal (session advice reads a review on each Work visit).
88
+ get(id: string): StoredWorkReview | undefined { const row = this.rows.find(r => r.id === id); return row ? structuredClone(row) : undefined }
88
89
  put(row: StoredWorkReview): void {
89
90
  if (this.closed) throw new WorkReviewError('review_store_closed', 503)
90
91
  const next = this.list(); const index = next.findIndex(r => r.id === row.id)
@@ -9,22 +9,40 @@ import { Router } from 'express'
9
9
  import { existsSync, unlinkSync } from 'node:fs'
10
10
  import { JEV_KEY_FILE, saveJevKey, sharedJevClient, validateJevKey, type JevClient } from '../lib/jev.js'
11
11
  import { SessionRecommender, parseCandidates } from '../lib/session-recommendation.js'
12
+ import { CompletionChecker, readRepliesSince } from '../lib/work-completion.js'
13
+ import { isSafeSessionId, type AgentProvider } from '../lib/agent-session-store.js'
12
14
  import { listBoard } from '../lib/task-store.js'
13
15
  import { isSafeDomainName } from '../lib/domains.js'
14
16
 
17
+ /** What the route reads from a meeting review (6.57.1): the server's own record, never client text. */
18
+ export interface ReviewForAdvice { source: { title: string; domain: string }; markdown?: string }
19
+
15
20
  export interface JevRouteDependencies {
16
21
  jev: JevClient
17
22
  recommender: Pick<SessionRecommender, 'recommend'>
18
23
  list: typeof listBoard
19
24
  validate: typeof validateJevKey
20
25
  save: typeof saveJevKey
26
+ /** Looks a meeting review up by its `wr_` id; throws or returns null when it does not exist. Absent when reviews are off. */
27
+ review?: (id: string) => Promise<ReviewForAdvice | null>
28
+ /** 6.58.0: Jev's reading of a reply against a task's Done when. */
29
+ completion: Pick<CompletionChecker, 'check'>
30
+ /** 6.58.0: a session's replies since a time (or its newest reply) from this server's transcripts; null when the
31
+ * session is not on this Mac. */
32
+ replies: (provider: AgentProvider, sessionId: string, afterMs: number | null) => Promise<string | null>
33
+ }
34
+
35
+ /** Work identity first: it survives a rename, while a new card's id could equal an old identity's text hash. */
36
+ function findTask<T extends { id: string; workIdentity?: string }>(rows: T[], id: string): T | undefined {
37
+ return rows.find(r => r.workIdentity === id) ?? rows.find(r => r.id === id)
21
38
  }
22
39
 
23
40
  export function createJevRouter(overrides: Partial<JevRouteDependencies> = {}): Router {
24
41
  const jev = overrides.jev ?? sharedJevClient()
25
- const deps: JevRouteDependencies = { jev, recommender: new SessionRecommender(jev), list: listBoard, validate: validateJevKey, save: saveJevKey, ...overrides }
42
+ const deps: JevRouteDependencies = { jev, recommender: new SessionRecommender(jev), list: listBoard, validate: validateJevKey, save: saveJevKey,
43
+ completion: new CompletionChecker(jev), replies: (provider, sessionId, afterMs) => readRepliesSince(provider, sessionId, afterMs), ...overrides }
26
44
  const router = Router()
27
- router.use(['/jev-key', '/work-board/session-recommendation'], (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
45
+ router.use(['/jev-key', '/work-board/session-recommendation', '/work-board/completion-check'], (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
28
46
 
29
47
  router.get('/jev-key/status', (_req, res) => res.json(deps.jev.status()))
30
48
 
@@ -53,18 +71,41 @@ export function createJevRouter(overrides: Partial<JevRouteDependencies> = {}):
53
71
  return res.json({ ok: true, ...deps.jev.status() })
54
72
  })
55
73
 
56
- /** Continue / Fork / New for one task. The task comes from the board; the client sends only candidate sessions. */
74
+ /** Continue / Fork / New for one task or meeting review. The work comes from the board or the review store; the
75
+ * client sends only candidate sessions. */
57
76
  router.post('/work-board/session-recommendation', async (req, res) => {
58
77
  const body = req.body
59
78
  const sessions = parseCandidates(body?.sessions)
79
+ const keys = body && typeof body === 'object' ? Object.keys(body).sort().join(',') : ''
80
+ // 6.57.1: a meeting review is named by its id alone (`{reviewId, sessions}`), exactly.
81
+ if (keys === 'reviewId,sessions') {
82
+ if (typeof body.reviewId !== 'string' || !/^wr_[a-f0-9]{32}$/.test(body.reviewId) || !sessions) {
83
+ // Its own code: 6.57.0 answers a review body with invalid_recommendation_request, and Control reads THAT as an old server.
84
+ return res.status(400).json({ error: { code: 'invalid_review_request', message: 'Select an exact meeting review and send at most 80 sessions.' } })
85
+ }
86
+ if (!deps.review) return res.status(404).json({ error: { code: 'reviews_unavailable', message: 'Meeting reviews are not available on this server.' } })
87
+ let review: ReviewForAdvice | null
88
+ try { review = await deps.review(body.reviewId) } catch (e) { // defensive: the stock lookup reads memory and does not throw
89
+ console.error('[jev] review lookup failed:', e instanceof Error ? e.message : e)
90
+ return res.status(503).json({ error: { code: 'review_store_unavailable', message: 'Meeting reviews could not be read. Try again shortly.' } })
91
+ }
92
+ if (!review) return res.status(404).json({ error: { code: 'review_not_found', message: 'That meeting review changed or was removed. Refresh Work.' } })
93
+ try {
94
+ const title = review.source.title || 'Meeting follow-up'
95
+ return res.json(await deps.recommender.recommend({
96
+ task: { text: [title, review.markdown ?? ''].filter(Boolean).join('\n\n'), domain: review.source.domain, meetings: [title] }, sessions }))
97
+ } catch (e) {
98
+ console.error('[jev] session recommendation failed:', e instanceof Error ? e.message : e)
99
+ return res.json({ provider: 'none', reason: 'recommendation_unavailable' })
100
+ }
101
+ }
60
102
  if (!body || typeof body !== 'object' || Object.keys(body).some(k => !['domain', 'id', 'sessions'].includes(k))
61
103
  || typeof body.domain !== 'string' || !isSafeDomainName(body.domain) || typeof body.id !== 'string' || !/^[a-f0-9]{12}$/.test(body.id) || !sessions) {
62
104
  return res.status(400).json({ error: { code: 'invalid_recommendation_request', message: 'Select an exact task and send at most 80 sessions.' } })
63
105
  }
64
106
  try {
65
- // Work identity first: it survives a rename, while a new card's id could equal an old identity's text hash.
66
107
  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)
108
+ const row = findTask(rows, body.id)
68
109
  if (!row) return res.status(404).json({ error: { code: 'task_not_found', message: 'That task changed or was removed. Refresh Work.' } })
69
110
  const meetings = (row.meetingRefs ?? []).map(ref => ref.title ?? '').filter(Boolean)
70
111
  return res.json(await deps.recommender.recommend({ task: { text: row.text || row.title || '', domain: row.domain, meetings }, sessions }))
@@ -73,5 +114,31 @@ export function createJevRouter(overrides: Partial<JevRouteDependencies> = {}):
73
114
  return res.json({ provider: 'none', reason: 'recommendation_unavailable' }) // advice only: never block the workspace
74
115
  }
75
116
  })
117
+ /** 6.58.0 (Control 0.5.247): did a session finish a board task? Asked when the session's newest reply since the
118
+ * handoff has no status line and the session is idle. The task comes from the board and the replies from this
119
+ * server's transcripts; the client sends names and, optionally, `after` (judge replies from then on). An answer is
120
+ * advice: Control moves a card to QA only on a confident done, never to Complete. */
121
+ router.post('/work-board/completion-check', async (req, res) => {
122
+ const body = req.body
123
+ const keys = body && typeof body === 'object' && !Array.isArray(body) ? Object.keys(body).filter(k => k !== 'after').sort().join(',') : ''
124
+ const afterMs = body?.after === undefined ? null : typeof body.after === 'string' && body.after.length <= 40 ? Date.parse(body.after) : Number.NaN
125
+ if (keys !== 'domain,id,provider,sessionId' || Number.isNaN(afterMs)
126
+ || typeof body.domain !== 'string' || !isSafeDomainName(body.domain) || typeof body.id !== 'string' || !/^[a-f0-9]{12}$/.test(body.id)
127
+ || !['claude', 'codex', 'cursor'].includes(body.provider) || typeof body.sessionId !== 'string' || !isSafeSessionId(body.sessionId)) {
128
+ return res.status(400).json({ error: { code: 'invalid_completion_request', message: 'Select an exact task and session.' } })
129
+ }
130
+ try {
131
+ const rows = (await deps.list()).filter(r => r.domain === body.domain)
132
+ const row = findTask(rows, body.id)
133
+ if (!row) return res.status(404).json({ error: { code: 'task_not_found', message: 'That task changed or was removed. Refresh Work.' } })
134
+ const reply = await deps.replies(body.provider as AgentProvider, body.sessionId, afterMs)
135
+ if (reply === null) return res.status(404).json({ error: { code: 'session_not_found', message: 'That session is not on this Mac.' } })
136
+ // A task with no Done when (most tasks) is judged against its own text; the answer says which (`basis`).
137
+ return res.json(await deps.completion.check({ task: { text: row.text || row.title || '', doneWhen: (row.doneWhen ?? '').trim() }, reply }))
138
+ } catch (e) {
139
+ console.error('[jev] completion check failed:', e instanceof Error ? e.message : e)
140
+ return res.json({ provider: 'none', reason: 'completion_unavailable' }) // advice only: tracking goes on without it
141
+ }
142
+ })
76
143
  return router
77
144
  }