@gotcos/glasses-server 6.94.1 → 6.94.2

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.
@@ -13,6 +13,7 @@
13
13
  import { lstat, readFile, readdir, realpath } from 'node:fs/promises'
14
14
  import { basename, dirname, join } from 'node:path'
15
15
  import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
16
+ import { readMacSpeakerMachineEvidence } from './mac-speaker-machine-evidence.js'
16
17
  import { dataPath } from './data-dir.js'
17
18
  import { confirmedLabels, confirmedChunks } from './meeting-corrections.js'
18
19
  import { resolveCosOperationsDir, resolveMeetingLibrary } from './cos-operations-meetings.js'
@@ -294,11 +295,14 @@ export async function buildVoiceDirectorySnapshot(): Promise<VoiceDirectorySnaps
294
295
  storedTitle,
295
296
  )
296
297
 
298
+ const machineEvidence = readMacSpeakerMachineEvidence(record)
297
299
  const review = reviewMeetingSpeakers(chunks as ReviewChunk[], {
298
300
  owner,
299
301
  phrasesPerVoice: 1,
300
302
  confirmed: confirmedLabels(sessionId),
301
- confirmedChunks: confirmedChunks(sessionId),
303
+ confirmedChunks: confirmedChunks(sessionId),
304
+ machineQualifiedPositions: machineEvidence.positions,
305
+ machineEvidenceStatus: machineEvidence.status,
302
306
  durationMs: typeof record.durationMs === 'number' ? record.durationMs : undefined,
303
307
  batchSegments: Array.isArray(record.batchSegments)
304
308
  ? record.batchSegments as SpeakerWordSegment[]
@@ -1,3 +1,5 @@
1
+ import { runQualifiedMacProfilePass } from '../lib/mac-speaker-profile-finalizer.js'
2
+ import { readMacSpeakerMachineEvidence } from '../lib/mac-speaker-machine-evidence.js'
1
3
  import { dataPath } from '../lib/data-dir.js'
2
4
  import { PHONE_CAPTURE_ID, PHONE_SOURCE_TYPE, assertPhoneProvenance, readPhoneRecording, requirePhoneRecording, closePhoneRecording } from '../lib/phone-recording.js'
3
5
  import { MAC_CAPTURE_ID, readMacRecording, requireMacRecording, macChunkReceipt, closeMacRecording } from '../lib/mac-recording.js'
@@ -309,6 +311,7 @@ export interface MeetingRouteDependencies {
309
311
  emit?: typeof emitDisplay
310
312
  finalizationJobs?: MeetingFinalizationJobStore
311
313
  finalDiarize?: FinalDiarize
314
+ qualifiedProfilePass?: typeof runQualifiedMacProfilePass
312
315
  /** Control's stop requests for live G2 meetings (lib/meeting-stop-requests.ts). */
313
316
  stopRequests?: StopRequestStore
314
317
  /** 6.77.0: the post-save transcript cleanup (lib/transcript-cleanup.ts). */
@@ -334,6 +337,7 @@ interface FinalizationRuntime {
334
337
  scheduleBackground: (task: Promise<void>) => void
335
338
  sessions?: MeetingSessionSource
336
339
  finalDiarize?: FinalDiarize
340
+ qualifiedProfilePass?: typeof runQualifiedMacProfilePass
337
341
  transcriptCleanup?: TranscriptCleanupRunner
338
342
  }
339
343
 
@@ -367,7 +371,7 @@ function validReplayEntries(raw: unknown[]): IndexedTranscriptChunk[] | null {
367
371
  return entries.length > 0 ? entries : null
368
372
  }
369
373
 
370
- function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: FinalizationRuntime): void {
374
+ export function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: FinalizationRuntime): void {
371
375
  const key = `${runtime.finalizationJobs.root}:${job.sessionId}`
372
376
  if (activeFinalizationJobs.has(key)) return
373
377
  activeFinalizationJobs.add(key)
@@ -452,8 +456,12 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
452
456
  sessionId: current.sessionId,
453
457
  runBatch: runtime.runBatch,
454
458
  finalDiarize: runtime.finalDiarize ?? runNemotronFinalPass,
459
+ qualifiedProfilePass: runtime.qualifiedProfilePass,
455
460
  })
456
461
  } else {
462
+ // A torn profile transaction must block filing even if pending audio
463
+ // vanished. Missing audio never waives journal/proof recovery gates.
464
+ if (MAC_CAPTURE_ID.test(current.sessionId)) await (runtime.qualifiedProfilePass ?? runQualifiedMacProfilePass)({ meetingPath: current.meetingPath, sidecarPath: current.sidecarPath, audioDir: current.audioDir ?? '', resumeOnly: true })
457
465
  console.warn(`[meeting/save] Pending HQ audio missing for ${current.sessionId}; preserving streaming canonical`)
458
466
  }
459
467
  current = runtime.finalizationJobs.save({
@@ -720,6 +728,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
720
728
  const emit = deps.emit ?? emitDisplay
721
729
  const finalizationJobs = deps.finalizationJobs ?? new MeetingFinalizationJobStore()
722
730
  const finalDiarize = deps.finalDiarize ?? runNemotronFinalPass
731
+ const qualifiedProfilePass = deps.qualifiedProfilePass ?? runQualifiedMacProfilePass
723
732
  const transcriptCleanup = deps.transcriptCleanup
724
733
  const router = Router()
725
734
  const captureActive = deps.captureActive ?? (() => getTranscriptionSessionLiveness().live > 0)
@@ -1206,6 +1215,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1206
1215
  scheduleBackground,
1207
1216
  sessions,
1208
1217
  finalDiarize,
1218
+ qualifiedProfilePass,
1209
1219
  })
1210
1220
  if (origin === 'phone') notePhoneAfterAutonomousSave(sessionId, 'save_replay')
1211
1221
  respond(200, publicSaveResponse(alreadySaved, true), true)
@@ -1403,6 +1413,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1403
1413
  scheduleBackground,
1404
1414
  sessions,
1405
1415
  finalDiarize,
1416
+ qualifiedProfilePass,
1406
1417
  transcriptCleanup,
1407
1418
  })
1408
1419
  }
@@ -1659,9 +1670,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1659
1670
  const filename = operations?.filename ?? direct?.filename ?? saved!.filename
1660
1671
 
1661
1672
  let chunks: unknown
1673
+ let sidecar: Record<string, unknown>
1662
1674
  try {
1663
- const raw = JSON.parse(readFileSync(sidecarPath, 'utf-8')) as Record<string, unknown>
1664
- chunks = Array.isArray(raw) ? raw : raw.chunks
1675
+ sidecar = JSON.parse(readFileSync(sidecarPath, 'utf-8')) as Record<string, unknown>
1676
+ chunks = Array.isArray(sidecar) ? sidecar : sidecar.chunks
1665
1677
  } catch {
1666
1678
  // Defensive: both lookups above already read this sidecar to match the
1667
1679
  // session, so a corrupt file 404s and never reaches here. This covers the
@@ -1678,8 +1690,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1678
1690
 
1679
1691
  // Raw capture indices, so a phrase can address its own audio. Position in
1680
1692
  // `chunks` is NOT the WAV number — see attachRawChunkIndices.
1681
- const sidecar = (JSON.parse(readFileSync(sidecarPath, 'utf-8')) ?? {}) as Record<string, unknown>
1682
1693
  const withIndices = attachRawChunkIndices(chunks as ReviewChunk[], sidecar.chunkEntries)
1694
+ const machineEvidence = readMacSpeakerMachineEvidence(sidecar)
1683
1695
  const review = reviewMeetingSpeakers(withIndices, {
1684
1696
  owner: getOwnerSpeakerLabel(),
1685
1697
  // Labels a human already vouched for in this meeting. Without this the
@@ -1687,6 +1699,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1687
1699
  // confirms the same voice forever.
1688
1700
  confirmed: confirmedLabels(sessionId),
1689
1701
  confirmedChunks: confirmedChunks(sessionId),
1702
+ machineQualifiedPositions: machineEvidence.positions,
1703
+ machineEvidenceStatus: machineEvidence.status,
1690
1704
  phrasesPerVoice: Math.max(1, Math.min(6, Number(req.query.phrases) || 3)),
1691
1705
  // The sidecar's own durationMs is the meeting's true end. Deriving it from
1692
1706
  // max(elapsed) uses the START of the last chunk, which made the final
@@ -1791,10 +1805,13 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1791
1805
  }
1792
1806
  const chunks = rawChunks as ReviewChunk[]
1793
1807
 
1808
+ const machineEvidence = readMacSpeakerMachineEvidence(sidecar)
1794
1809
  const review = reviewMeetingSpeakers(attachRawChunkIndices(chunks, sidecar.chunkEntries), {
1795
1810
  owner: getOwnerSpeakerLabel(),
1796
1811
  confirmed: confirmedLabels(sessionId),
1797
1812
  confirmedChunks: confirmedChunks(sessionId),
1813
+ machineQualifiedPositions: machineEvidence.positions,
1814
+ machineEvidenceStatus: machineEvidence.status,
1798
1815
  // This route renders no phrases; the default of 3 per voice computed and
1799
1816
  // discarded 48 transcript excerpts on a 16-voice meeting.
1800
1817
  phrasesPerVoice: 1,
@@ -1818,6 +1835,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1818
1835
  // "voice matching" for the wearer exemption and for names a human typed.
1819
1836
  isOwner: v.isOwner,
1820
1837
  confirmedByHuman: v.confirmedByHuman,
1838
+ machineQualified: v.machineQualified,
1839
+ machineQualifiedSegments: v.machineQualifiedSegments,
1821
1840
  }))
1822
1841
 
1823
1842
  // A missing .md is not fatal — the review and the sidecar still describe the
@@ -3152,7 +3171,7 @@ function synthesizeEntriesFromChunkWavs(audioDir: string): RecoveredCapture {
3152
3171
  return { entries, startTime, durationMs }
3153
3172
  }
3154
3173
 
3155
- async function finalizeBatch(options: {
3174
+ export async function finalizeBatch(options: {
3156
3175
  audioDir: string
3157
3176
  entries: IndexedTranscriptChunk[]
3158
3177
  streamingWordCount: number
@@ -3161,7 +3180,11 @@ async function finalizeBatch(options: {
3161
3180
  sessionId?: string
3162
3181
  runBatch: NonNullable<MeetingRouteDependencies['runBatch']>
3163
3182
  finalDiarize?: FinalDiarize
3183
+ qualifiedProfilePass?: typeof runQualifiedMacProfilePass
3164
3184
  }): Promise<void> {
3185
+ const qualifiedProfilePass = options.qualifiedProfilePass ?? runQualifiedMacProfilePass
3186
+ const mac = MAC_CAPTURE_ID.test(options.sessionId ?? '')
3187
+ if (mac && (await qualifiedProfilePass({ ...options, resumeOnly: true })).status === 'applied') return
3165
3188
  const result = await options.runBatch(
3166
3189
  options.audioDir,
3167
3190
  options.entries,
@@ -3226,6 +3249,10 @@ async function finalizeBatch(options: {
3226
3249
  }
3227
3250
  }
3228
3251
 
3252
+ // Exact qualified profile evidence runs before raw deletion and filing.
3253
+ // Prepared-transaction failures propagate: mixed generations cannot be filed.
3254
+ if (mac && metadataPersisted) await qualifiedProfilePass(options)
3255
+
3229
3256
  if (canDeletePendingBatchAudio(transcriptApplied, metadataPersisted)) {
3230
3257
  rmSync(options.audioDir, { recursive: true, force: true })
3231
3258
  } else {
@@ -0,0 +1,324 @@
1
+ #!/usr/bin/env tsx
2
+ import { boundWords, renderMarkdown, renderBoundSpeakerLabels } from '../lib/mac-speaker-repair-render.js'
3
+ export { renderBoundSpeakerLabels, renderSparseBoundSpeakerLabels, renderTruncatedBoundSpeakerLabels } from '../lib/mac-speaker-repair-render.js'
4
+ /** Offline-only, labels-only repair transaction. No server/data-home discovery.
5
+ * Default preview writes nothing. --prepare saves private durable before/after
6
+ * images; --apply/--undo can modify only explicit marker-certified offline copies.
7
+ * Live installation requires the deploying session's own sync/capture coordination.
8
+ */
9
+ import { createHash } from 'node:crypto'
10
+ import { closeSync, constants, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs'
11
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
12
+ import { pathToFileURL } from 'node:url'
13
+ import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
14
+ import { hashMacSpeakerMachineBinding, projectMacSpeakerRepair, type MacSpeakerMachineEvidence, type MacSpeakerRepairPlan, type MacSpeakerRepairReceipt } from '../lib/mac-speaker-repair-projection.js'
15
+
16
+ type Doc = Record<string, any>
17
+ export interface RepairFileSnapshot { path: string; sha256: string | null }
18
+ export interface MacRepairManifest {
19
+ version: 1
20
+ id: string
21
+ at: string
22
+ evidenceHash: string
23
+ offlineRoot: string
24
+ /** Read-only immutable evidence, including frozen profile/model versions. */
25
+ preconditions: RepairFileSnapshot[]
26
+ audioFiles: Array<{ rawChunkIndex: number; path: string; sha256: string }>
27
+ copies: Array<{
28
+ copy: 'local' | 'operations'
29
+ sidecar: RepairFileSnapshot
30
+ markdown: RepairFileSnapshot
31
+ ledger: RepairFileSnapshot
32
+ plan: MacSpeakerRepairPlan
33
+ }>
34
+ }
35
+ interface PreparedFile {
36
+ path: string
37
+ beforeHash: string
38
+ afterHash: string
39
+ beforeImage: string
40
+ afterImage: string
41
+ role: 'sidecar' | 'markdown'
42
+ copy: 'local' | 'operations'
43
+ }
44
+ interface TransactionPayload {
45
+ version: 1
46
+ id: string
47
+ at: string
48
+ evidenceHash: string
49
+ offlineRoot: string
50
+ preconditions: RepairFileSnapshot[]
51
+ files: PreparedFile[]
52
+ receipts: MacSpeakerRepairReceipt[]
53
+ }
54
+ export interface MacRepairTransaction extends TransactionPayload {
55
+ payloadHash: string
56
+ state: 'prepared' | 'applying' | 'applied' | 'undoing' | 'reverted' | 'partial'
57
+ completed: string[]
58
+ error?: string
59
+ }
60
+ export interface MacRepairPreview {
61
+ transaction: MacRepairTransaction
62
+ images: Array<{ name: string; bytes: string }>
63
+ summary: { copies: number; chunks: number; labelPatches: number; offlineOnly: true }
64
+ }
65
+ const sha = (data: string | Buffer) => createHash('sha256').update(data).digest('hex')
66
+ const digest = (value: unknown) => sha(JSON.stringify(value))
67
+ const HASH = /^[a-f0-9]{64}$/
68
+ const MARKER = '.mac-speaker-repair-offline.json'
69
+ function refuse(message: string): never { throw new Error(`Mac offline repair: ${message}`) }
70
+ function absolute(path: string) { if (typeof path !== 'string' || !isAbsolute(path)) refuse('all paths must be explicit and absolute'); return resolve(path) }
71
+ function safeRead(path: string): Buffer {
72
+ absolute(path)
73
+ const stat = lstatSync(path)
74
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || realpathSync(path) !== resolve(path)) refuse('symlink, hardlink or nonregular source refused')
75
+ return readFileSync(path)
76
+ }
77
+ function snapshot(path: string): string | null { return existsSync(path) ? sha(safeRead(path)) : null }
78
+ function checkSnapshot(file: RepairFileSnapshot) {
79
+ absolute(file.path)
80
+ if (file.sha256 !== null && !HASH.test(file.sha256)) refuse('invalid source hash')
81
+ if (snapshot(file.path) !== file.sha256) refuse('source/profile/ledger drift')
82
+ }
83
+ function inside(root: string, path: string) {
84
+ const rel = relative(root, absolute(path))
85
+ if (!rel || rel.startsWith('..') || isAbsolute(rel)) refuse('write target must be strictly inside offline root')
86
+ }
87
+ function offlineRoot(path: string): string {
88
+ const root = absolute(path)
89
+ if (realpathSync(root) !== root || lstatSync(root).isSymbolicLink()) refuse('offline root must not contain symlinks')
90
+ // An explicit marker is an offline-copy assertion, never a server maintenance
91
+ // authorization. Also reject recognizable production data locations.
92
+ if (root.split('/').includes('.cos-glasses') || /\/MU-Chief-Staff\/operations(?:\/|$)/.test(root)) refuse('production data path is not an offline root')
93
+ const marker = JSON.parse(safeRead(join(root, MARKER)).toString('utf8'))
94
+ if (marker.kind !== 'cos-mac-speaker-repair-offline' || marker.version !== 1) refuse('offline-copy marker required')
95
+ return root
96
+ }
97
+ /** Only WAV metadata + hash verification; never playback/ASR/native extraction. */
98
+ function pcmSamples(bytes: Buffer): { count: number; pcm: Buffer } {
99
+ if (bytes.length < 44 || bytes.toString('ascii', 0, 4) !== 'RIFF' || bytes.toString('ascii', 8, 12) !== 'WAVE' || bytes.readUInt32LE(4) + 8 !== bytes.length) refuse('invalid WAV container')
100
+ let format = false, samples: number | null = null, pcm: Buffer | null = null
101
+ for (let offset = 12; offset + 8 <= bytes.length;) {
102
+ const tag = bytes.toString('ascii', offset, offset + 4), size = bytes.readUInt32LE(offset + 4), start = offset + 8
103
+ if (start + size > bytes.length) refuse('truncated WAV')
104
+ if (tag === 'fmt ') {
105
+ if (size < 16 || bytes.readUInt16LE(start) !== 1 || bytes.readUInt16LE(start + 2) !== 1 || bytes.readUInt32LE(start + 4) !== 16000 || bytes.readUInt16LE(start + 12) !== 2 || bytes.readUInt16LE(start + 14) !== 16) refuse('expected mono 16kHz PCM16 WAV')
106
+ format = true
107
+ }
108
+ if (tag === 'data') { if (samples !== null || size % 2) refuse('ambiguous PCM data'); samples = size / 2; pcm = bytes.subarray(start, start + size) }
109
+ offset = start + size + (size % 2)
110
+ }
111
+ if (!format || !samples) refuse('missing WAV PCM')
112
+ return { count: samples, pcm: pcm! }
113
+ }
114
+
115
+ function protectedRaw(doc: Doc, ledger: RepairFileSnapshot): number[] {
116
+ checkSnapshot(ledger)
117
+ if (ledger.sha256 === null) return []
118
+ const rows = safeRead(ledger.path).toString('utf8').split('\n').filter(line => line.trim()).map(line => JSON.parse(line))
119
+ const byId = new Map<string, Doc[]>()
120
+ for (const row of rows) {
121
+ if (!row.id || !['intent', 'applied', 'failed', 'confirmed', 'confirmed-chunks', 'reverted'].includes(row.phase)) refuse('unreadable correction ledger')
122
+ byId.set(row.id, [...(byId.get(row.id) ?? []), row])
123
+ }
124
+ const raw = doc.chunkEntries.filter((e: Doc) => e.chunk?.text).map((e: Doc) => e.chunkIndex)
125
+ const protectedIndexes = new Set<number>()
126
+ for (const history of byId.values()) {
127
+ const last = history.at(-1)!
128
+ if (last.phase === 'intent') refuse('incomplete prior correction intent')
129
+ if (last.phase === 'reverted' || last.phase === 'failed') continue
130
+ for (const row of history.filter(r => r.phase !== 'intent')) {
131
+ if (!Array.isArray(row.chunks) || row.chunks.some((position: number) => !Number.isInteger(position) || raw[position] === undefined)) refuse('ambiguous correction ledger positions')
132
+ if (!row.chunks.length) for (const index of raw) protectedIndexes.add(index)
133
+ else for (const position of row.chunks) protectedIndexes.add(raw[position])
134
+ }
135
+ }
136
+ return [...protectedIndexes]
137
+ }
138
+ function revision(value: any): number { if (value === undefined) return 0; if (!Number.isSafeInteger(value) || value < 0) refuse('invalid lifecycle/correction revision'); return value }
139
+ export function previewMacSpeakerRepair(manifest: MacRepairManifest): MacRepairPreview {
140
+ if (manifest.version !== 1 || !/^[A-Za-z0-9_-]{3,100}$/.test(manifest.id) || !HASH.test(manifest.evidenceHash) || !Number.isFinite(Date.parse(manifest.at))) refuse('invalid manifest identity')
141
+ const root = offlineRoot(manifest.offlineRoot)
142
+ if (!Array.isArray(manifest.copies) || manifest.copies.length !== 2 || new Set(manifest.copies.map(copy => copy.copy)).size !== 2) refuse('explicit local and operations copies required')
143
+ if (!Array.isArray(manifest.preconditions) || !manifest.preconditions.length || !Array.isArray(manifest.audioFiles) || !manifest.audioFiles.length) refuse('frozen evidence and raw audio required')
144
+ if (!manifest.preconditions.some(file=>file.sha256===manifest.evidenceHash)) refuse('qualified evidence bytes must be a frozen precondition')
145
+ manifest.preconditions.forEach(checkSnapshot)
146
+ const pcm = new Map<number, Buffer>()
147
+ const samples: Record<string, number> = {}, audioPreconditions: RepairFileSnapshot[] = []
148
+ for (const audio of manifest.audioFiles) {
149
+ if (!Number.isInteger(audio.rawChunkIndex) || audio.rawChunkIndex < 0 || samples[String(audio.rawChunkIndex)] !== undefined || !HASH.test(audio.sha256)) refuse('invalid audio evidence binding')
150
+ const bytes = safeRead(audio.path)
151
+ if (sha(bytes) !== audio.sha256) refuse('raw audio drift')
152
+ const metadata = pcmSamples(bytes)
153
+ samples[String(audio.rawChunkIndex)] = metadata.count
154
+ pcm.set(audio.rawChunkIndex, metadata.pcm)
155
+ audioPreconditions.push({ path: audio.path, sha256: audio.sha256 })
156
+ }
157
+ const files: PreparedFile[] = [], images: MacRepairPreview['images'] = [], receipts: MacSpeakerRepairReceipt[] = []
158
+ const seen = new Set<string>(); let session: string | undefined, agreement: string | undefined
159
+ for (const [ci, copy] of manifest.copies.entries()) {
160
+ for (const file of [copy.sidecar, copy.markdown]) {
161
+ inside(root, file.path); if (seen.has(resolve(file.path))) refuse('duplicate target file'); seen.add(resolve(file.path)); checkSnapshot(file)
162
+ }
163
+ if (copy.sidecar.sha256 === null || copy.markdown.sha256 === null) refuse('source copies must exist')
164
+ const beforeSidecar = safeRead(copy.sidecar.path).toString('utf8'), beforeMarkdown = safeRead(copy.markdown.path).toString('utf8'), before = JSON.parse(beforeSidecar)
165
+ if (session && session !== before.sessionId) refuse('copy capture IDs disagree'); session = before.sessionId
166
+ const copyAgreement = digest({ assignments: copy.plan.assignments, samples: copy.plan.rawAudioSampleCounts, chunks: before.chunks.map((chunk: Doc) => [chunk.text, chunk.speaker, chunk.elapsed]), raw: before.chunkEntries.map((entry: Doc) => entry.chunkIndex) })
167
+ if (agreement && agreement !== copyAgreement) refuse('local and operations projection mappings disagree'); agreement = copyAgreement
168
+ if (copy.plan.id !== manifest.id || copy.plan.evidenceHash !== manifest.evidenceHash) refuse('copy plan identity disagrees')
169
+ for (const [raw, count] of Object.entries(copy.plan.rawAudioSampleCounts)) {
170
+ if (samples[raw] !== count) refuse('raw sample count not supported by WAV evidence')
171
+ const sourceChunk = before.chunkEntries.find((entry: Doc) => entry.chunkIndex === Number(raw))?.chunk
172
+ const verified = manifest.audioFiles.find(file => file.rawChunkIndex === Number(raw))
173
+ if (sourceChunk?.audioSha256 && sourceChunk.audioSha256 !== verified?.sha256) refuse('sidecar raw audio hash disagrees with verified WAV')
174
+ }
175
+ for (const assignment of copy.plan.assignments) {
176
+ const proof = assignment.targetEvidence
177
+ if (!proof) continue
178
+ const profileFile = manifest.preconditions.find(file => file.sha256 === proof.profileSha256)
179
+ if (!profileFile || !manifest.preconditions.some(file => file.sha256 === proof.modelSha256)) refuse('profile/model proof files are not frozen preconditions')
180
+ const profile = JSON.parse(safeRead(profileFile.path).toString('utf8'))
181
+ if (!Array.isArray(profile.profiles) || profile.profiles.filter((row: Doc) => row.name === proof.profileName).length !== 1) refuse('profile evidence name is missing or ambiguous')
182
+ for (const window of proof.windows) {
183
+ if (window.rawChunks.some(raw => !pcm.has(raw.rawChunkIndex)) || sha(Buffer.concat(window.rawChunks.map(raw => pcm.get(raw.rawChunkIndex)!))) !== window.pcmSha256) refuse('concatenated profile PCM proof disagrees')
184
+ for (const raw of window.rawChunks) {
185
+ if (!manifest.audioFiles.some(file => file.rawChunkIndex === raw.rawChunkIndex && file.sha256 === raw.audioSha256) || samples[String(raw.rawChunkIndex)] !== raw.sampleCount) refuse('profile evidence raw audio is not verified')
186
+ }
187
+ }
188
+ }
189
+ const plan = { ...copy.plan, protectedRawChunkIndices: [...new Set([...copy.plan.protectedRawChunkIndices, ...protectedRaw(before, copy.ledger)])] }
190
+ const projected = projectMacSpeakerRepair(before, plan), after = projected.doc, sequence = boundWords(before, after)
191
+ after.batchTranscript = renderBoundSpeakerLabels(before.batchTranscript, sequence)
192
+ const afterMarkdown = renderMarkdown(beforeMarkdown, sequence)
193
+ after.correctionRevision = revision(before.correctionRevision) + 1
194
+ after.lifecycleRevision = revision(before.lifecycleRevision) + 1
195
+ after.lifecycleUpdatedAt = manifest.at
196
+ after.labelsNewerThanGraph = true
197
+ after.proseStale = true
198
+ const rawChunkMap = Object.fromEntries(plan.assignments.map(assignment => [assignment.rawChunkIndex, assignment.to]))
199
+ after.speakerRepair = { version: 1, id: manifest.id, evidenceHash: manifest.evidenceHash, at: manifest.at, requiresSyncRefresh: true, rawChunkMap }
200
+ after.diarization = { engine: 'mac-speaker-repair-v1', status: 'applied', mode: 'labels-only', labels: plan.assignments.every(assignment => /^Voice (?:[1-9]|[1-9]\d)$/.test(assignment.to)) ? 'unnamed' : 'profile-matched', appliedAt: manifest.at, outputSha256: sha(after.batchTranscript), rawChunkMap }
201
+ const machineEvidence: MacSpeakerMachineEvidence = {
202
+ version: 1, correctionRevision: after.correctionRevision,
203
+ documentBinding: hashMacSpeakerMachineBinding(after),
204
+ receipt: projected.receipt, rawAudioSampleCounts: structuredClone(plan.rawAudioSampleCounts),
205
+ }
206
+ after.speakerRepair.machineEvidence = machineEvidence
207
+ // The receipt records aggregate model/profile confidence. It neither changes
208
+ // capture similarities nor fabricates ledger confirmations or enrollment.
209
+ // Original diarization/output maps remain byte-exact in the before-image.
210
+ const afterSidecar = JSON.stringify(after, null, 2) + '\n'
211
+ receipts.push(projected.receipt)
212
+ for (const [role, path, beforeBytes, afterBytes] of [['sidecar', copy.sidecar.path, beforeSidecar, afterSidecar], ['markdown', copy.markdown.path, beforeMarkdown, afterMarkdown]] as const) {
213
+ const stem = `${ci}-${role}`, beforeImage = `${stem}.before`, afterImage = `${stem}.after`
214
+ images.push({ name: beforeImage, bytes: beforeBytes }, { name: afterImage, bytes: afterBytes })
215
+ files.push({ path, beforeHash: sha(beforeBytes), afterHash: sha(afterBytes), beforeImage, afterImage, role, copy: copy.copy })
216
+ }
217
+ }
218
+ const payload: TransactionPayload = { version: 1, id: manifest.id, at: manifest.at, evidenceHash: manifest.evidenceHash, offlineRoot: root, preconditions: [...manifest.preconditions, ...audioPreconditions, ...manifest.copies.map(copy => copy.ledger)], files, receipts }
219
+ return { transaction: { ...payload, payloadHash: digest(payload), state: 'prepared', completed: [] }, images, summary: { copies: 2, chunks: receipts[0].rawChunkIndices.length, labelPatches: receipts.reduce((n, receipt) => n + receipt.patches.length, 0), offlineOnly: true } }
220
+ }
221
+ function writePrivate(path: string, bytes: string) { durableAtomicWriteFileSync(path, bytes, { mode: 0o600 }) }
222
+ function saveTransaction(path: string, tx: MacRepairTransaction) { writePrivate(path, JSON.stringify(tx, null, 2) + '\n') }
223
+ function lockRoot(root: string, resume = false): () => void {
224
+ const path = join(root, '.mac-speaker-repair.lock')
225
+ if (resume && existsSync(path)) {
226
+ const prior = JSON.parse(safeRead(path).toString('utf8'))
227
+ if (!Number.isSafeInteger(prior.pid) || prior.pid <= 0) refuse('invalid lock owner; manual review required')
228
+ try { process.kill(prior.pid, 0); refuse('offline repair lock owner is still alive') }
229
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error }
230
+ unlinkSync(path)
231
+ }
232
+ const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
233
+ try { writeFileSync(fd, JSON.stringify({ pid: process.pid })); fsyncSync(fd) } finally { closeSync(fd) }
234
+ return () => { unlinkSync(path) }
235
+ }
236
+ export function prepareMacSpeakerRepair(manifest: MacRepairManifest, transactionDir: string): MacRepairPreview {
237
+ const root = offlineRoot(manifest.offlineRoot); inside(root, transactionDir)
238
+ if (existsSync(transactionDir)) refuse('transaction directory already exists')
239
+ const parent = dirname(absolute(transactionDir)); if (realpathSync(parent) !== parent) refuse('transaction parent symlink refused')
240
+ const release = lockRoot(root)
241
+ try {
242
+ const preview = previewMacSpeakerRepair(manifest)
243
+ mkdirSync(transactionDir, { mode: 0o700 })
244
+ const parentFd = openSync(root, constants.O_RDONLY); try { fsyncSync(parentFd) } finally { closeSync(parentFd) }
245
+ for (const image of preview.images) writePrivate(join(transactionDir, image.name), image.bytes)
246
+ saveTransaction(join(transactionDir, 'transaction.json'), preview.transaction)
247
+ return preview
248
+ } finally { release() }
249
+ }
250
+ function payloadOf(tx: MacRepairTransaction): TransactionPayload {
251
+ const { payloadHash: _, state: __, completed: ___, error: ____, ...payload } = tx
252
+ return payload
253
+ }
254
+ export function executeMacSpeakerRepair(transactionDir: string, direction: 'apply' | 'undo', options: { resume?: boolean; beforeWrite?: (index: number) => void } = {}): MacRepairTransaction {
255
+ const transactionPath = join(absolute(transactionDir), 'transaction.json')
256
+ const tx = JSON.parse(safeRead(transactionPath).toString('utf8')) as MacRepairTransaction
257
+ if (tx.version !== 1 || digest(payloadOf(tx)) !== tx.payloadHash) refuse('transaction integrity mismatch')
258
+ const root = offlineRoot(tx.offlineRoot); inside(root, transactionDir)
259
+ if (realpathSync(transactionDir) !== resolve(transactionDir)) refuse('transaction directory symlink refused')
260
+ const release = lockRoot(root, options.resume)
261
+ try {
262
+ tx.preconditions.forEach(checkSnapshot)
263
+ const forward = direction === 'apply', wanted = forward ? 'applied' : 'reverted'
264
+ if ((tx.state === 'partial' || tx.state === 'applying' || tx.state === 'undoing') && !options.resume && forward) refuse('incomplete prior intent; explicit --resume or --undo required')
265
+ const writes: Array<{ file: PreparedFile; bytes: string; changed: boolean }> = []
266
+ for (const file of tx.files) {
267
+ inside(root, file.path)
268
+ for (const image of [file.beforeImage, file.afterImage]) if (image !== image.split('/').at(-1) || image.includes('..')) refuse('unsafe image path')
269
+ const before = safeRead(join(transactionDir, file.beforeImage)), after = safeRead(join(transactionDir, file.afterImage))
270
+ if (sha(before) !== file.beforeHash || sha(after) !== file.afterHash) refuse('before/after-image drift')
271
+ const current = snapshot(file.path), desired = forward ? file.afterHash : file.beforeHash, prior = forward ? file.beforeHash : file.afterHash
272
+ if (current !== desired && current !== prior) refuse('copy drift; restore would overwrite a later edit')
273
+ if (!options.resume && tx.state === 'prepared' && forward && current !== prior) refuse('copy changed before first apply')
274
+ const currentDoc = file.role === 'sidecar' ? JSON.parse(safeRead(file.path).toString('utf8')) : null
275
+ if (currentDoc && (currentDoc.finalizationState !== 'complete' || currentDoc.claimPending === true || currentDoc.active === true || currentDoc.isActive === true)) refuse('capture is active or no longer finalized')
276
+ writes.push({ file, bytes: (forward ? after : before).toString('utf8'), changed: current !== desired })
277
+ }
278
+ if (tx.state === wanted && writes.every(write => !write.changed)) return tx
279
+ tx.state = forward ? 'applying' : 'undoing'; tx.completed = []; delete tx.error
280
+ saveTransaction(transactionPath, tx) // durable intent before any copy mutation
281
+ try {
282
+ for (const [index, write] of writes.entries()) {
283
+ options.beforeWrite?.(index)
284
+ tx.preconditions.forEach(checkSnapshot)
285
+ // Recheck all remaining copies immediately before each publish. The
286
+ // offline lock excludes this tool; hash CAS also detects external edits.
287
+ for (const completed of writes.slice(0, index)) if (snapshot(completed.file.path) !== (forward ? completed.file.afterHash : completed.file.beforeHash)) refuse('completed copy drift after publish')
288
+ for (const pending of writes.slice(index)) {
289
+ const actual = snapshot(pending.file.path)
290
+ const expected = pending.changed ? (forward ? pending.file.beforeHash : pending.file.afterHash) : (forward ? pending.file.afterHash : pending.file.beforeHash)
291
+ if (actual !== expected) refuse('copy changed immediately before write')
292
+ }
293
+ if (write.changed) writePrivate(write.file.path, write.bytes)
294
+ if (snapshot(write.file.path) !== (forward ? write.file.afterHash : write.file.beforeHash)) refuse('post-write verification failed')
295
+ tx.completed.push(write.file.path); saveTransaction(transactionPath, tx)
296
+ }
297
+ for (const write of writes) if (snapshot(write.file.path) !== (forward ? write.file.afterHash : write.file.beforeHash)) refuse('final copy postimage drift')
298
+ tx.state = wanted; saveTransaction(transactionPath, tx)
299
+ } catch (error) {
300
+ tx.state = 'partial'; tx.error = error instanceof Error ? error.message : String(error); saveTransaction(transactionPath, tx); throw error
301
+ }
302
+ return tx
303
+ } finally { release() }
304
+ }
305
+ export function macSpeakerRepairMain(args = process.argv.slice(2)): void {
306
+ const known = new Set(['--manifest', '--transaction', '--prepare', '--apply', '--undo', '--resume'])
307
+ for (let i = 0; i < args.length; i++) { if (!known.has(args[i])) refuse('unknown command option'); if (args[i] === '--manifest' || args[i] === '--transaction') { if (!args[++i]) refuse('missing option value') } }
308
+ const value = (flag: string) => { const index = args.indexOf(flag); return index < 0 ? undefined : args[index + 1] }
309
+ const transactionDir = value('--transaction'), apply = args.includes('--apply'), undo = args.includes('--undo'), prepare = args.includes('--prepare')
310
+ if (Number(apply) + Number(undo) + Number(prepare) > 1) refuse('choose one of --prepare, --apply or --undo')
311
+ if (apply || undo) {
312
+ if (!transactionDir) refuse('--transaction required')
313
+ const tx = executeMacSpeakerRepair(transactionDir, undo ? 'undo' : 'apply', { resume: args.includes('--resume') })
314
+ console.log(JSON.stringify({ id: tx.id, state: tx.state, offlineOnly: true })); return
315
+ }
316
+ const manifestPath = value('--manifest'); if (!manifestPath) refuse('--manifest required for preview or prepare')
317
+ const manifest = JSON.parse(safeRead(manifestPath).toString('utf8')) as MacRepairManifest
318
+ if (prepare && !transactionDir) refuse('--transaction required for prepare')
319
+ const preview = prepare ? prepareMacSpeakerRepair(manifest, transactionDir!) : previewMacSpeakerRepair(manifest)
320
+ console.log(JSON.stringify({ id: manifest.id, mode: prepare ? 'prepared' : 'dry-run', ...preview.summary }))
321
+ }
322
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
323
+ try { macSpeakerRepairMain() } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1 }
324
+ }