@gotcos/glasses-server 6.76.1 → 6.77.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.
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env node
2
+ // Transcript cleanup 6.77.0 mutation gate: span selection, the deterministic fixes, the edit guards, the ledger, the stop
3
+ // conditions, the screen-context store and route, the dictionary suggestions and the save wiring. Mutations run only in a
4
+ // disposable copy of the checkout; the live checkout is never rewritten.
5
+ //
6
+ // What it proves, in order:
7
+ // 0. The target suites are GREEN unmutated. A gate over a red suite reads every mutant as killed and proves nothing.
8
+ // 1. Each target string appears EXACTLY ONCE in its file, so the mutation is unambiguous.
9
+ // 2. The mutated file differs from the original, so the edit landed.
10
+ // 3. A NAMED test fails ("FAIL server/x.test.ts > name"); a build error or a harness failure is not a kill.
11
+ // 4. The file is restored byte for byte (compared) before the next mutant.
12
+ // Usage: node server/scripts/transcript-cleanup-mutation-gate.mjs [--list | --check] [name…]
13
+ import { mkdtempSync, cpSync, readFileSync, writeFileSync, symlinkSync, rmSync } from 'node:fs'
14
+ import { tmpdir } from 'node:os'
15
+ import { resolve, join, dirname, basename } from 'node:path'
16
+ import { fileURLToPath } from 'node:url'
17
+ import { spawnSync } from 'node:child_process'
18
+
19
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
20
+ const LIB = ['server/lib/transcript-cleanup.test.ts', 'server/lib/transcript-cleanup-spans.test.ts']
21
+ const ROUTE = ['server/routes/transcript-cleanup.test.ts']
22
+ const MEETING = ['server/routes/meeting.test.ts']
23
+ const WIRE = ['server/routes/health.test.ts', 'server/routes/transcribe.test.ts', 'server/lib/transcript-cleanup-provider.test.ts']
24
+ const ALL = [...LIB, ...ROUTE, ...MEETING, ...WIRE]
25
+ const RUN = 'server/lib/transcript-cleanup.ts', SPANS = 'server/lib/transcript-cleanup-spans.ts', TEXT = 'server/lib/transcript-cleanup-text.ts'
26
+ const MODEL = 'server/lib/transcript-cleanup-model.ts', LEDGER = 'server/lib/transcript-cleanup-ledger.ts', STATE = 'server/lib/transcript-cleanup-state.ts'
27
+ const SCREEN = 'server/lib/transcript-cleanup-screen.ts', SUG = 'server/lib/transcript-cleanup-suggestions.ts', CTX = 'server/lib/transcript-cleanup-context.ts'
28
+ const R = 'server/routes/transcript-cleanup.ts', MEET = 'server/routes/meeting.ts'
29
+ const m = (name, file, edits, tests = LIB) => ({ name, file, edits: typeof edits[0] === 'string' ? [edits] : edits, tests })
30
+
31
+ const mutations = [
32
+ // ── Switch, model, stops ──
33
+ m('switch-checked-first', RUN, ['if (!resolveCleanupSwitch(deps.env, deps.settings).enabled) return', 'if (false) return']),
34
+ m('env-0-forces-off', STATE, [" if (env.COS_TRANSCRIPT_CLEANUP === '0') return { enabled: false, source: 'env' }\n", '']),
35
+ m('env-1-forces-on', STATE, [" if (env.COS_TRANSCRIPT_CLEANUP === '1') return { enabled: true, source: 'env' }\n", '']),
36
+ m('default-off', STATE, [" return { enabled: false, source: 'default' }", " return { enabled: true, source: 'default' }"], [...LIB, ...ROUTE]),
37
+ m('default-model-haiku', STATE, ["return { choice: 'haiku', id: CLEANUP_MODEL_IDS.haiku, source: 'default' }", "return { choice: 'sonnet', id: CLEANUP_MODEL_IDS.sonnet, source: 'default' }"], [...LIB, ...ROUTE]),
38
+ m('setting-model-read', STATE, [" if (stored) return { choice: stored, id: CLEANUP_MODEL_IDS[stored], source: 'setting' }\n", '']),
39
+ m('env-model-wins', STATE, [" if (raw === 'haiku' || raw === 'sonnet') return { choice: raw, id: CLEANUP_MODEL_IDS[raw], source: 'env' }\n", '']),
40
+ m('charge-before-call', RUN, ["else if (!deps.budget.charge(limits)) modelSkipped = 'daily_calls'", "else if (false) modelSkipped = 'daily_calls'"]),
41
+ m('gate-breaker', STATE, [" if (s.breakerOpenUntil !== null && this.now().getTime() < s.breakerOpenUntil) return 'breaker_open'\n", '']),
42
+ m('gate-daily-calls', STATE, [" if (s.calls >= limits.dailyCalls) return 'daily_calls'\n", '']),
43
+ m('gate-daily-tokens', STATE, [" if (s.tokens >= limits.dailyTokens) return 'daily_tokens'\n", '']),
44
+ m('usage-recorded', RUN, [' deps.budget.recordUsage(trace.inputTokens + trace.outputTokens)\n', '']),
45
+ m('provider-limit-opens-breaker', STATE, ['const open = providerLimit || failures >= limits.breakerFailures', 'const open = failures >= limits.breakerFailures']),
46
+ m('breaker-after-three', STATE, [' breakerFailures: 3,', ' breakerFailures: 4,']),
47
+ m('success-closes-breaker', STATE, [' this.write({ ...s, consecutiveFailures: 0, breakerOpenUntil: null })', ' return']),
48
+ m('unreadable-state-is-spent', STATE, [' } catch {\n return spent\n', ' } catch {\n return { day, calls: 0, tokens: 0, consecutiveFailures: 0, breakerOpenUntil: null }\n']),
49
+ m('future-day-is-spent', STATE, [' if (raw.day > day) return { ...spent, ...breaker }\n', '']),
50
+ m('negative-count-is-spent', STATE, [' if (!(calls >= 0) || !(tokens >= 0)) return { ...spent, ...breaker }\n', '']),
51
+ m('failure-recorded', RUN, [' deps.budget.recordFailure(limits, limit)\n', '']),
52
+ m('api-key-guard', RUN, ["else if (deps.env.ANTHROPIC_API_KEY && deps.env.COS_ALLOW_API_FALLBACK !== '1') modelSkipped = 'api_key_in_env'", "else if (false) modelSkipped = 'api_key_in_env'"]),
53
+ m('claude-cli-guard', RUN, ["else if (!deps.claudeAvailable()) modelSkipped = 'claude_not_found'", "else if (false) modelSkipped = 'claude_not_found'"]),
54
+ m('live-session-skipped', RUN, [" if (live) return finish({ status: 'skipped', reason: 'session_live', applied: 0 })", " if (false) return finish({ status: 'skipped', reason: 'session_live', applied: 0 })"]),
55
+ m('span-cap-per-meeting', RUN, ['{ maxSpans: limits.maxSpans, fixed:', '{ maxSpans: 80, fixed:']),
56
+ m('idempotent-rerun', RUN, [" if (alreadyCleaned(input.sessionId, inputSha)) return finish({ status: 'skipped', reason: 'already_done', applied: 0 })", " if (false) return finish({ status: 'skipped', reason: 'already_done', applied: 0 })"]),
57
+ m('ledger-intent-first', RUN, [" if (!appendLedger(input.sessionId, [{ kind: 'run', runId, phase: 'intent', at: at(), inputSha }])) {", " if (!appendLedger(input.sessionId, [{ kind: 'run', runId, phase: 'intent', at: at(), inputSha }]) && false) {"]),
58
+ m('prompt-size-cap', MODEL, [' if (prompt.length + block.length + 1 > MODEL_LIMITS.promptChars) break\n', '']),
59
+ m('screen-lines-in-prompt', MODEL, ['const screen = screenLinesFor(pack.screen, span.atMs, span.near, { max: MODEL_LIMITS.screenLinesPerSpan })', 'const screen: typeof pack.screen = []']),
60
+ m('owner-in-prompt', MODEL, [' if (pack.owner) {\n head.push', ' if (false) {\n head.push']),
61
+ m('parse-scans-past-first-object', MODEL, [" i = text.indexOf('{', i + 1)", ' i = -1']),
62
+ m('parse-string-aware', MODEL, [" else if (c === '\"') inString = false", '']),
63
+
64
+ m('notes-exclude-this-meeting', RUN, ['if (!hit.recordId || hit.sessionId === sessionId) continue', 'if (!hit.recordId) continue']),
65
+ m('notes-bridge-when-memory-empty', RUN, [' if (!memoryHits && !signal.aborted) {', ' if (!signal.aborted) {']),
66
+ m('notes-in-prompt', MODEL, [' for (const n of pack.notes) head.push', ' for (const n of [] as typeof pack.notes) head.push']),
67
+ m('notes-snippet-bounded', CTX, [
68
+ ["snippet: redactSecrets(n.snippet).replace(/\\s+/g, ' ').slice(0, CONTEXT_LIMITS.noteChars) }\n pack.notes.push(note)", "snippet: n.snippet }\n pack.notes.push(note)"]]),
69
+ m('notes-snippet-clipped-in-prompt', MODEL, ['${quote(n.title)}: ${clip(n.snippet, 280)}`)', '${quote(n.title)}: ${n.snippet}`)']),
70
+
71
+ // ── Span selection ──
72
+ m('parakeet-floor', SPANS, [" if (engine.startsWith('parakeet')) return LOW_CONFIDENCE.parakeet\n", '']),
73
+ m('fillers-never-doubtful', SPANS, [' && !FILLERS.has(t.key)) flag(t.i, ', ') flag(t.i, ']),
74
+ m('near-name-needs-unsure', SPANS, ['const unsure = input.probability === undefined || input.probability < NEAR_NAME_UNSURE', 'const unsure = true']),
75
+ m('near-name-inflection-skip', SPANS, [' if (inflectionOf(key, t.key)) continue\n', '']),
76
+ m('exact-join-is-a-hit', SPANS, [' if (d === 0 && !input.join) return null', ' if (d === 0) return null']),
77
+ m('join-no-function-words', SPANS, ['group.some(g => g.key.length > 5 || FUNCTION_WORDS.has(g.key))', 'group.some(g => g.key.length > 5)']),
78
+ m('join-pieces-short', SPANS, ['group.some(g => g.key.length > 5 || FUNCTION_WORDS.has(g.key))', 'group.some(g => FUNCTION_WORDS.has(g.key))']),
79
+ m('sounds-alike-onset', TEXT, [' if (vowelOnset(fa) !== vowelOnset(fb)) return false\n', '']),
80
+ m('sounds-alike-two-consonants', TEXT, [' if (sa.length < 2 || sa !== sb) return false', ' if (sa !== sb) return false']),
81
+ m('oov-not-at-sentence-start', SPANS, ['if (fixed.has(t.i) || t.sentenceStart || !/^\\p{Lu}/u.test(t.core)', 'if (fixed.has(t.i) || !/^\\p{Lu}/u.test(t.core)']),
82
+ m('repeat-needs-three', SPANS, ['if (reps >= (unit === 1 ? 4 : 3)) found', 'if (reps >= 2) found']),
83
+ m('merge-stops-at-clause', SPANS, [' if (tokens.slice(to - 1, next).some(t => /[,.;:!?]["\')\\]]*$/.test(t.raw))) break\n', '']),
84
+ m('merge-stays-in-line', SPANS, [' if (tokens[next].line !== tokens[from].line || next - to > 1', ' if (next - to > 1']),
85
+ m('span-word-cap', SPANS, [' maxSpanWords: 6,', ' maxSpanWords: 8,']),
86
+ m('names-ranked-first', SPANS, ["(d.reasons.has('near_name') ? 4 : 0)", "(d.reasons.has('near_name') ? 0 : 0)"]),
87
+ m('align-by-folded-word', TEXT, ['if (words[k].word === token.raw || fold(words[k].word) === token.key)', 'if (words[k].word === token.raw)']),
88
+ m('word-time-on-meeting-clock', TEXT, ['Math.round(startTime + startElapsed + s * 1000)', 'Math.round(startTime + s * 1000)']),
89
+
90
+ // ── Deterministic fixes ──
91
+ m('fix-capitalised-only', SPANS, ['if (!/^\\p{Lu}/u.test(base) || allLower.has(base.toLowerCase()) || known.has(fold(base))) continue', 'if (allLower.has(base.toLowerCase()) || known.has(fold(base))) continue']),
92
+ m('fix-never-a-known-spelling', SPANS, ['if (!/^\\p{Lu}/u.test(base) || allLower.has(base.toLowerCase()) || known.has(fold(base))) continue', 'if (!/^\\p{Lu}/u.test(base) || allLower.has(base.toLowerCase())) continue']),
93
+ m('fix-strong-only-when-unsure', SPANS, ['let replacement = unsure ? fuzzyReplacementFor(base, strong, allLower) : null', 'let replacement = fuzzyReplacementFor(base, strong, allLower)']),
94
+ m('fix-sentence-opener-only-unsure', SPANS, [' if (token.sentenceStart && !unsure) continue\n', '']),
95
+ m('fix-no-inflection', SPANS, ['if (replacement === null || replacement === base || inflectionOf(base, replacement)) continue', 'if (replacement === null || replacement === base) continue']),
96
+ m('fix-no-attendee-targets', RUN, ['const strongTargets = [...pack.vocabulary, ...pack.corrections.map(c => c.written), ...(pack.owner ? [pack.owner.name] : [])]', 'const strongTargets = [...pack.vocabulary, ...pack.corrections.map(c => c.written), ...pack.speakers, ...(pack.owner ? [pack.owner.name] : [])]']),
97
+
98
+ // ── Edit guards ──
99
+ m('only-sent-ids', MODEL, ['const byId = new Map(spans.filter(s => sentIds.includes(s.id)).map(s => [s.id, s]))', 'const byId = new Map(spans.map(s => [s.id, s]))']),
100
+ m('duplicate-rejected', MODEL, [" if (seen.has(id)) { reject('duplicate'); continue }\n", '']),
101
+ m('bracket-is-unsafe', MODEL, ["/[\\u0000-\\u001f\\u007f\\[\\]]/.test(edit!.text", "/[\\u0000-\\u001f\\u007f]/.test(edit!.text"]),
102
+ m('no-op-rejected', MODEL, [" if (text === span.text) { reject('no_op'); continue }\n", '']),
103
+ m('word-count-cap', MODEL, ['newWords.length > spanWords.length + 1 ||', 'newWords.length > spanWords.length + 9 ||']),
104
+ m('char-length-cap', MODEL, [' || text.length > span.text.length * 2 + 8) {', ') {']),
105
+ m('repeat-shrinks-to-own-run', MODEL, [" if (!isRun) { reject('longer_than_span'); continue }\n", '']),
106
+ m('outside-span-before', MODEL, ['(beforeLast && firstNew === beforeLast && !spanKeys.has(beforeLast)) || ', '']),
107
+ m('outside-span-after', MODEL, [' || (afterFirst && lastNew === afterFirst && !spanKeys.has(afterFirst))', '']),
108
+ m('numbers-unchanged', MODEL, [" if (numbers(text) !== numbers(span.text)) { reject('number_unit'); continue }\n", '']),
109
+ m('negations-unchanged', MODEL, [" if (negationCount(text) !== negationCount(span.text)) { reject('negation'); continue }\n", '']),
110
+ m('added-content-rejected', MODEL, [" if (unsupported.length) { reject('added_content'); continue }\n", '']),
111
+ m('context-terms-admit', MODEL, ['const knownTerm = (w: string) => pack.terms.has(fold(w))', 'const knownTerm = (_w: string) => false']),
112
+
113
+ // ── Ledger and writes ──
114
+ m('overlap-dropped', LEDGER, [' if (e.start < cursor || e.end > source.length || e.start >= e.end) continue', ' if (e.end > source.length || e.start >= e.end) continue']),
115
+ m('commit-refuses-changed-files', LEDGER, [" if (markdownNow !== options.markdown || sidecarNow !== options.sidecarText) throw new CleanupWriteError('changed_during_cleanup')\n", '']),
116
+ m('failed-markdown-restores-sidecar', LEDGER, [' try { write(options.sidecarPath, options.sidecarText) } catch { /* the two files now disagree; say so */ }\n', '']),
117
+ m('batch-transcript-kept-canonical', LEDGER, ["sidecar.batchTranscript.replace(/\\n+$/, '') === oldSection) sidecar.batchTranscript = options.transcript", "sidecar.batchTranscript.replace(/\\n+$/, '') === oldSection) sidecar.batchTranscript = sidecar.batchTranscript"]),
118
+ m('lifecycle-revision-bumped', LEDGER, [' sidecar.lifecycleRevision = Number(sidecar.lifecycleRevision ?? 0) + 1\n', '']),
119
+ m('undo-refuses-stale', LEDGER, [" if (at < 0) return { ok: false, status: 409, error: 'stale' }\n", '']),
120
+ m('undo-exact-offsets', LEDGER, [' if (untouched.get(line.index) && line.text.slice(edit.offset, edit.offset + edit.replacement.length) === edit.replacement) at = edit.offset', ' if (false) at = edit.offset']),
121
+ m('undo-records-revert', LEDGER, [" appendLedger(options.sessionId, targets.map(t => ({ kind: 'revert' as const, editId: t.id, at: now })))\n", '']),
122
+
123
+ // ── Screen context ──
124
+ m('screen-secrets-held', SCREEN, [' if (imageTextHasSecrets(o.text) || imageTextHasSecrets(text)) { result.held++; continue }\n', ''], [...LIB, ...ROUTE]),
125
+ m('screen-line-truncated', SCREEN, [' if (text.length > SCREEN_LIMITS.charsPerLine) { text = text.slice(0, SCREEN_LIMITS.charsPerLine); result.truncated++ }\n', '']),
126
+ m('screen-duplicates', SCREEN, [" if (recent.some(r => norm(r.text) === key && Math.abs(r.at - at) <= SCREEN_LIMITS.duplicateWindowMs)) { result.duplicates++; continue }\n", '']),
127
+ m('screen-clock-bounds', SCREEN, ['at === null || at < earliest || at > latest ||', 'at === null ||']),
128
+ m('screen-meeting-line-cap', SCREEN, ['if (result.stored >= SCREEN_LIMITS.linesPerMeeting || storedBytes', 'if (storedBytes']),
129
+ m('screen-request-line-cap', SCREEN, [" if (lines.length > SCREEN_LIMITS.linesPerRequest) throw new ScreenContextError('too_many_lines', 413)\n", ''], [...LIB, ...ROUTE]),
130
+ m('screen-request-byte-cap', SCREEN, [" if (requestBytes > SCREEN_LIMITS.bytesPerRequest) throw new ScreenContextError('request_too_large', 413)\n", ''], [...LIB, ...ROUTE]),
131
+ m('screen-path-confined', SCREEN, [
132
+ [' if (!SESSION_ID.test(sessionId)) return null\n', ''],
133
+ [" return resolve(path).startsWith(resolve(dir) + '/') ? path : null", ' return path']]),
134
+ m('route-unknown-session-404', R, [" if (!meeting) return { ok: false, status: 404, error: 'unknown_session' }\n", ''], ROUTE),
135
+ m('route-cleanup-done-409', R, [" return done ? { ok: false, status: 409, error: 'cleanup_done' } : { ok: true }", ' return { ok: true }'], ROUTE),
136
+ m('route-env-forced-409', R, [" if (hasEnabled && (env.COS_TRANSCRIPT_CLEANUP === '1' || env.COS_TRANSCRIPT_CLEANUP === '0') && body.enabled !== (env.COS_TRANSCRIPT_CLEANUP === '1')) {", ' if (false) {'], ROUTE),
137
+ m('sweep-keeps-known', SCREEN, [' try { gone = !opts.known(sessionId) } catch { gone = false }', ' gone = true'], ROUTE),
138
+ m('sweep-screen-retention', SCREEN, [" const expired = kind === 'screen' && age > retentionMs", ' const expired = false'], ROUTE),
139
+
140
+ // ── Dictionary suggestions ──
141
+ m('suggestion-needs-evidence', SUG, ['if (!written || written.length > 100 || !input.evidence.length) return null', 'if (!written || written.length > 100) return null'], [...LIB, ...ROUTE]),
142
+ m('suggestion-not-for-dictionary-words', SUG, [' if (input.inDictionary(written)) return null\n', ''], ROUTE),
143
+ m('suggestion-dismissed-stays-out', SUG, [" if (existing && existing.status !== 'pending') return null\n", ''], ROUTE),
144
+ m('suggestion-dedupe-case', SUG, ['const existing = list.find(s => same(s.written, written))', 'const existing = list.find(s => s.written === written)'], ROUTE),
145
+ m('orchestrator-skips-dictionary-terms', RUN, [
146
+ [' if (!term || inDictionary(term) || heardKeys.has(term.key)) continue', ' if (!term || heardKeys.has(term.key)) continue'],
147
+ [" if (deps.suggestions.record({ written: term.display, heard: edit.original, evidence, sessionId, at, inDictionary: w => dictionary.has(w.trim().toLowerCase()) })) recorded++", " if (deps.suggestions.record({ written: term.display, heard: edit.original, evidence, sessionId, at, inDictionary: () => false })) recorded++"]]),
148
+ m('orchestrator-longest-term', RUN, [' if (parts.every(p => covered.has(p))) continue\n', '']),
149
+ m('evidence-maps-screen', CTX, ["['screen', 'screen'], ", '']),
150
+ m('accept-uses-vocabulary-writer', R, [' : await addVocabulary(suggestion.written)', " : { status: 200, body: { unchanged: false, glossary: null } }"], ROUTE),
151
+ m('accept-correction-path', R, ["const outcome = body.correction === true && heard", 'const outcome = false && heard'], ROUTE),
152
+ m('accept-refuses-dismissed', R, [" if (suggestion.status === 'dismissed') return res.status(409).json({ error: 'suggestion_dismissed' })\n", ''], ROUTE),
153
+
154
+ // ── Save wiring and capability ──
155
+ m('cleanup-stage-in-finalization', MEET, [' await runCleanupStage(runtime.transcriptCleanup, { sessionId: current.sessionId, meetingPath: current.meetingPath, sidecarPath: current.sidecarPath }, runtime.sessions)\n', ''], MEETING),
156
+ m('cleanup-stage-never-fails-save', MEET, [' try {\n await run(input)\n } catch (error) {', ' {\n await run(input)\n } if (false) { const error = null as unknown;'], MEETING),
157
+ m('health-says-off', R, [' return { protocol: TRANSCRIPT_CLEANUP_PROTOCOL, enabled }', ' return { protocol: TRANSCRIPT_CLEANUP_PROTOCOL, enabled: true }'], [...ROUTE, ...WIRE]),
158
+ m('capabilities-advertised', 'server/routes/transcribe.ts', [' transcriptCleanup: { protocol: 1, suggestions: 1, screenContext: 1 },\n', ''], WIRE),
159
+ m('provider-source-tag', RUN, ["source: 'transcript-cleanup', maxOutputTokens: opts.maxOutputTokens", "source: 'lens_gist', maxOutputTokens: opts.maxOutputTokens"], WIRE),
160
+ ]
161
+
162
+ const args = process.argv.slice(2)
163
+ if (args.includes('--list')) { for (const { name } of mutations) console.log(name); process.exit(0) }
164
+ const selected = args.length && !args.includes('--check') ? mutations.filter(({ name }) => args.includes(name)) : mutations
165
+ if (args.length && !args.includes('--check') && selected.length !== args.length) throw new Error('Unknown mutation name in: ' + args.join(' '))
166
+ if (new Set(mutations.map(x => x.name)).size !== mutations.length) throw new Error('Duplicate mutation name')
167
+ // --check: every target matches exactly once and changes its file. Runs no tests.
168
+ if (args.includes('--check')) {
169
+ let bad = 0
170
+ for (const { name, file, edits } of mutations) {
171
+ let text = readFileSync(join(root, file), 'utf8')
172
+ const original = text
173
+ for (const [find, replacement] of edits) {
174
+ const n = text.split(find).length - 1
175
+ if (n !== 1) { console.log(`${name}: matches ${n} times in ${file}: ${find.slice(0, 90)}`); bad++ }
176
+ else text = text.replace(find, () => replacement)
177
+ }
178
+ if (text === original) { console.log(`${name}: no change`); bad++ }
179
+ }
180
+ console.log(`${mutations.length - bad} of ${mutations.length} targets match exactly once`)
181
+ process.exit(bad ? 1 : 0)
182
+ }
183
+
184
+ const scratch = mkdtempSync(join(tmpdir(), 'transcript-cleanup-mutations-'))
185
+ try {
186
+ const skip = new Set(['data', 'models', 'certs', 'node_modules'])
187
+ cpSync(join(root, 'server'), join(scratch, 'server'), { recursive: true, filter: src => !(skip.has(basename(src)) && dirname(src) === join(root, 'server')) })
188
+ for (const dir of ['shared', 'work-task-runtime', 'bin', 'docs']) cpSync(join(root, dir), join(scratch, dir), { recursive: true })
189
+ for (const file of ['vitest.config.ts', 'package.json', 'managed-runtime-contract.json']) cpSync(join(root, file), join(scratch, file))
190
+ symlinkSync(join(root, 'node_modules'), join(scratch, 'node_modules'), 'dir')
191
+ const run = tests => spawnSync(process.execPath, [join(root, 'node_modules/vitest/vitest.mjs'), 'run', '--maxWorkers=1', ...tests], {
192
+ cwd: scratch, env: { PATH: process.env.PATH, HOME: scratch, TMPDIR: process.env.TMPDIR, COS_DATA_DIR: join(scratch, 'data'), COS_PROFILE_PATH: join(scratch, 'data', 'profile.json') },
193
+ encoding: 'utf8', timeout: 300000,
194
+ })
195
+ const baseline = run(ALL)
196
+ if (baseline.status !== 0) throw new Error('Baseline failed; a gate over a red suite proves nothing:\n' + baseline.stdout + '\n' + baseline.stderr)
197
+ const totals = /Tests\s+(\d+) passed/.exec(baseline.stdout)
198
+ console.log(`baseline PASS (${totals ? totals[1] : '?'} tests in ${ALL.length} files)`)
199
+ let killed = 0
200
+ for (const { name, file, edits, tests } of selected) {
201
+ const target = join(scratch, file), original = readFileSync(target, 'utf8')
202
+ let mutated = original
203
+ for (const [find, replacement] of edits) {
204
+ if (mutated.split(find).length !== 2) throw new Error(`Mutation ${name} does not match exactly once in ${file}: ${find.slice(0, 80)}`)
205
+ mutated = mutated.replace(find, () => replacement)
206
+ }
207
+ if (mutated === original) throw new Error(`Mutation ${name} did not change ${file}`)
208
+ writeFileSync(target, mutated)
209
+ let result
210
+ try { result = run(tests) } finally { writeFileSync(target, original) }
211
+ if (readFileSync(target, 'utf8') !== original) throw new Error(`Restore failed for ${file} after ${name}`)
212
+ const output = result.stdout + '\n' + result.stderr
213
+ const killers = [...new Set([...output.matchAll(/FAIL\s+(server\/\S+\.test\.ts > [^\n]+)/g)].map(x => x[1].trim()))]
214
+ if (result.status === 0 || result.error || !killers.length) { console.log(`${name} SURVIVED or harness failed\n${output.slice(-3000)}`); process.exitCode = 1; continue }
215
+ killed += 1
216
+ console.log(`${name} KILLED by ${killers.slice(0, 2).join(' | ')}${killers.length > 2 ? ` (+${killers.length - 2} more)` : ''}`)
217
+ }
218
+ console.log(`${killed} of ${selected.length} transcript cleanup 6.77.0 mutations killed`)
219
+ } finally { rmSync(scratch, { recursive: true, force: true }) }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Live cues v2 wire contract (server 6.75 candidate, companion 6.10.641 candidate).
2
+ * Live cues v2 wire contract (server 6.75 candidate, companion 6.10.641 candidate; optional capability fields since 6.76.2).
3
3
  * Additive to v1: old clients keep receiving `coaching_nudge` and ignore the new named event.
4
4
  * The companion keeps a byte-identical copy at src/lib/live-cues-v2-contract.ts.
5
5
  */
@@ -33,13 +33,18 @@ export interface LiveCueV2 {
33
33
  /** v1 `coaching_nudge` as the v2 engine emits it for old clients (headline only). */
34
34
  export interface LiveCueV1Compat { nudge: string; type: CueKind; priority: 2; cueId: string; sessionId: string; protocol: 2 }
35
35
 
36
- /** `reason` when unavailable: master_flag_off, api_key_in_env, jev_not_configured, claude_not_found, budget_exhausted,
37
- * jev_cap_reached, jev_breaker_open. */
38
- export interface LiveCuesV2Capability { available: boolean; reason?: string; protocol: 2; model: string }
36
+ /** `reason` when unavailable: master_flag_off, api_key_in_env, decision_invalid_provider, decision_not_configured (no key
37
+ * for the selected decision provider; jev_not_configured when Jev was chosen and only a SubQ key exists), claude_not_found,
38
+ * budget_exhausted, jev_cap_reached, jev_breaker_open, decision_cap_reached, decision_breaker_open (the last two for SubQ).
39
+ * Server 6.76.2 adds two optional fields; older servers omit them. `memory`: where past notes come from (cue_memory.py,
40
+ * the owner's own COS notes and meetings, or nothing). `decisionProvider`: which classifier runs the gate and the judge. */
41
+ export type LiveCuesV2MemorySource = 'cue_memory' | 'notes' | 'none'
42
+ export interface LiveCuesV2Capability { available: boolean; reason?: string; protocol: 2; model: string; memory?: LiveCuesV2MemorySource; decisionProvider?: 'jev' | 'subq' }
39
43
 
40
44
  /** Why cues are not coming for this meeting. `active` means evaluations are running normally (silence is a choice). */
41
45
  export type LiveCuesV2SessionState = 'active' | 'paused' | 'ended' | 'stopped'
42
46
  export type LiveCuesV2StopReason = 'breaker_open' | 'meeting_cap' | 'budget_exhausted' | 'jev_cap_reached' | 'jev_breaker_open' | 'jev_not_configured'
47
+ | 'decision_cap_reached' | 'decision_breaker_open' | 'decision_not_configured' | 'decision_invalid_provider'
43
48
 
44
49
  export interface LiveCuesV2SessionStatus {
45
50
  sessionId: string