@gotcos/glasses-server 6.61.2 → 6.61.5

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.
@@ -5,6 +5,7 @@
5
5
  import { existsSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync } from 'node:fs'
6
6
  import { isWorthRecovering } from '../lib/quarantine-auto-recover.js'
7
7
  import { resolve } from 'node:path'
8
+ import { createHash } from 'node:crypto'
8
9
  import { Router } from 'express'
9
10
  import { emitDisplay } from '../lib/display-bus.js'
10
11
  import { cleanTranscriptLines } from '../lib/hallucination-filter.js'
@@ -847,6 +848,31 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
847
848
  // rebuilding are the /api/voice/* routes, each with its own confirmation.
848
849
  //
849
850
  // Keyed on sessionId so it can reuse the store's traversal-hardened readers
851
+ function reviewRevision(meetingPath: string, sidecarPath: string): string {
852
+ const hash = createHash('sha256')
853
+ for (const path of [meetingPath, sidecarPath]) {
854
+ let text: string | null = null
855
+ try { text = readFileSync(path, 'utf-8') } catch { /* missing content is part of the revision */ }
856
+ hash.update(JSON.stringify(text))
857
+ }
858
+ return hash.digest('hex')
859
+ }
860
+
861
+ function selectedReadMatches(requested: unknown, sessionId: string, actual: string): boolean {
862
+ return requestedRecordMatches(requested, actual) || blendedRecordFor(requested, sessionId) !== undefined
863
+ }
864
+
865
+ function sourceRevisionRefusal(sessionId: string, body: any): { status: number; body: Record<string, unknown> } | null {
866
+ const ops = cosOperationsMeetingsConfigured() ? findCosOperationsMeetingBySessionId(sessionId) : null
867
+ if (ops?.mergedScribeConflict?.length) return { status: 409, body: { reason: 'record_source_mismatch', error: markdownUnreadableReason(ops) } }
868
+ if (body?.expectedRevision == null) return null // older clients keep their existing record guard
869
+ const saved = ops ? null : store.findBySessionId(sessionId)
870
+ if (!ops && !saved) return { status: 409, body: { reason: 'record_source_mismatch', error: 'Meeting source changed; reopen the meeting' } }
871
+ const actual = reviewRevision(ops?.meetingPath ?? saved!.filepath, ops?.sidecarPath ?? saved!.sidecarPath)
872
+ if (typeof body.expectedRevision !== 'string' || body.expectedRevision !== actual) return { status: 409, body: { reason: 'record_source_mismatch', error: 'Meeting changed since review; reopen before correcting it' } }
873
+ return null
874
+ }
875
+
850
876
  // (safeDirectoryRealpath / safeReadFile) instead of reassembling a path from
851
877
  // client-supplied domain and filename components.
852
878
  router.get('/meeting/:sessionId/speakers', (req, res) => {
@@ -878,6 +904,11 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
878
904
  res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
879
905
  return
880
906
  }
907
+ const identity = speakerRecordIdentity(sessionId, operations, direct, blendedRecordFor(req.query.recordId, sessionId))
908
+ if (!selectedReadMatches(req.query.recordId, sessionId, identity.recordId)) {
909
+ res.status(409).json({ error: 'Meeting source changed; reopen the selected meeting', reason: 'record_source_mismatch' })
910
+ return
911
+ }
881
912
  const sidecarPath = operations?.sidecarPath ?? direct?.sidecarPath ?? saved!.sidecarPath
882
913
  const title = operations?.title ?? direct?.title ?? saved!.title
883
914
  const domain = operations?.domain ?? direct?.domain ?? saved!.domain
@@ -932,7 +963,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
932
963
  title,
933
964
  domain,
934
965
  filename,
935
- ...speakerRecordIdentity(sessionId, operations, direct, blendedRecordFor(req.query.recordId, sessionId)),
966
+ ...identity,
967
+ reviewIdentityVersion: 1,
968
+ sourceRevision: reviewRevision(operations?.meetingPath ?? direct?.meetingPath ?? saved!.filepath, sidecarPath),
969
+ ...(operations?.mergedScribeConflict?.length ? { mutable: false, identityConflict: operations.mergedScribeConflict } : {}),
936
970
  ...(saved ? { durationMin: saved.durationMin } : {}),
937
971
  ...review,
938
972
  })
@@ -985,6 +1019,11 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
985
1019
  res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
986
1020
  return
987
1021
  }
1022
+ const identity = speakerRecordIdentity(sessionId, operations, direct, blendedRecordFor(req.query.recordId, sessionId))
1023
+ if (!selectedReadMatches(req.query.recordId, sessionId, identity.recordId)) {
1024
+ res.status(409).json({ error: 'Meeting source changed; reopen the selected meeting', reason: 'record_source_mismatch' })
1025
+ return
1026
+ }
988
1027
  const sidecarPath = operations?.sidecarPath ?? direct?.sidecarPath ?? saved!.sidecarPath
989
1028
  const title = operations?.title ?? direct?.title ?? saved!.title
990
1029
  const mdPath = operations?.meetingPath ?? direct?.meetingPath ?? sidecarPath.replace(/\.g2-chunks\.json$/, '.md')
@@ -1112,7 +1151,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1112
1151
  // no write-up yet.
1113
1152
  capturedChars: clip.capturedChars,
1114
1153
  domain: clip.domain,
1115
- ...speakerRecordIdentity(sessionId, operations, direct, blendedRecordFor(req.query.recordId, sessionId)),
1154
+ ...identity,
1155
+ reviewIdentityVersion: 1,
1156
+ sourceRevision: reviewRevision(operations?.meetingPath ?? direct?.meetingPath ?? saved!.filepath, sidecarPath),
1157
+ ...(operations?.mergedScribeConflict?.length ? { mutable: false, identityConflict: operations.mergedScribeConflict } : {}),
1116
1158
  // So the panel can warn above the write-up, not just the clipboard.
1117
1159
  removedNames: clip.removed,
1118
1160
  coverage,
@@ -1169,7 +1211,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1169
1211
  // Enrolment writes to the voice store, so the same two gates as the other
1170
1212
  // corrections: the session must be a capture, and the record aimed at must
1171
1213
  // not be a derived one.
1172
- const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId)
1214
+ const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? sourceRevisionRefusal(sessionId, req.body)
1173
1215
  if (evidenceRefusal) {
1174
1216
  res.status(evidenceRefusal.status).json(evidenceRefusal.body)
1175
1217
  return
@@ -1235,7 +1277,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1235
1277
  // A merged G2 session is NOT refused here: it is a real capture with real
1236
1278
  // audio, and relabelling it is how its profile gets better. Only an imported
1237
1279
  // or derived IDENTITY is refused.
1238
- const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? finalizingRefusal(sessionId)
1280
+ const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? sourceRevisionRefusal(sessionId, req.body) ?? finalizingRefusal(sessionId)
1239
1281
  if (evidenceRefusal) {
1240
1282
  res.status(evidenceRefusal.status).json(evidenceRefusal.body)
1241
1283
  return
@@ -1478,7 +1520,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1478
1520
  res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
1479
1521
  return
1480
1522
  }
1481
- const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId)
1523
+ const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? sourceRevisionRefusal(sessionId, req.body)
1482
1524
  if (evidenceRefusal) {
1483
1525
  res.status(evidenceRefusal.status).json(evidenceRefusal.body)
1484
1526
  return
@@ -1569,7 +1611,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1569
1611
  res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
1570
1612
  return
1571
1613
  }
1572
- const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? finalizingRefusal(sessionId)
1614
+ const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? sourceRevisionRefusal(sessionId, req.body) ?? finalizingRefusal(sessionId)
1573
1615
  if (evidenceRefusal) {
1574
1616
  res.status(evidenceRefusal.status).json(evidenceRefusal.body)
1575
1617
  return
@@ -1,3 +1,4 @@
1
+ import { isDictationCleanModel, type DictationCleanModel } from '../lib/dictation-clean-models.js'
1
2
  import { Router } from 'express'
2
3
  import { createHash } from 'node:crypto'
3
4
  import type { Response } from 'express'
@@ -75,7 +76,11 @@ function speculativeHqWarmEnabled(): boolean {
75
76
  return !['0', 'false', 'off'].includes((process.env.COS_HQ_SPECULATIVE_WARM ?? '1').toLowerCase())
76
77
  }
77
78
 
78
- const autoCleanBreaker = createBreaker({ label: 'dictation-autoclean' })
79
+ const autoCleanBreakers = {
80
+ sonnet: createBreaker({ label: 'dictation-autoclean-sonnet' }),
81
+ 'luna-5.6-fast': createBreaker({ label: 'dictation-autoclean-luna' }),
82
+ haiku: createBreaker({ label: 'dictation-autoclean-haiku' }),
83
+ }
79
84
  const autoCleanCountFile = () => process.env.COS_DICTATION_AUTOCLEAN_COUNT_FILE || dataPath('.dictation_autoclean_count.json')
80
85
  const autoCleanDefaultEnabled = () => ['1', 'true', 'on'].includes((process.env.COS_DICTATION_AUTOCLEAN ?? '').toLowerCase())
81
86
  const autoCleanDailyCap = () => {
@@ -97,12 +102,14 @@ function recordAutoCleanCall(): void {
97
102
  } catch {}
98
103
  }
99
104
 
100
- interface AutoCleanRequest { enabled?: boolean; model?: 'haiku' | 'sonnet' }
105
+ interface AutoCleanRequest { enabled?: boolean; model?: DictationCleanModel }
101
106
  function routeAutoClean(req: { body?: any; query?: any }): AutoCleanRequest {
102
107
  const rawEnabled = req.body?.autoclean ?? req.query?.autoclean
103
108
  const enabled = rawEnabled === undefined ? undefined : ['1', 'true', 'on'].includes(String(rawEnabled).toLowerCase())
104
109
  const rawModel = String(req.body?.autocleanModel ?? req.query?.autocleanModel ?? '').toLowerCase()
105
- return { enabled, model: rawModel === 'sonnet' ? 'sonnet' : rawModel === 'haiku' ? 'haiku' : undefined }
110
+ // An unknown model must never silently select another provider.
111
+ if (rawModel && !isDictationCleanModel(rawModel)) return { enabled: false }
112
+ return { enabled, model: isDictationCleanModel(rawModel) ? rawModel : undefined }
106
113
  }
107
114
 
108
115
  async function cleanOutboundDictation(text: string, opts: AutoCleanRequest & { signal?: AbortSignal }): Promise<string> {
@@ -129,9 +136,10 @@ async function cleanOutboundDictation(text: string, opts: AutoCleanRequest & { s
129
136
  console.warn(`[prompt-draft] Fuzzy correction failed (non-fatal): ${fuzzyErr?.message ?? fuzzyErr}`)
130
137
  }
131
138
  if (!(opts.enabled ?? autoCleanDefaultEnabled())) return cleaned
139
+ const model = opts.model ?? 'sonnet'
140
+ const autoCleanBreaker = autoCleanBreakers[model]
132
141
  if (cleaned.length > AUTOCLEAN_MAX_CHARS || autoCleanBreaker.isOpen() || autoCleanCountToday() >= autoCleanDailyCap()) return cleaned
133
142
  const startedAt = Date.now()
134
- const model = opts.model === 'sonnet' ? 'sonnet' : 'haiku'
135
143
  recordAutoCleanCall()
136
144
  try {
137
145
  const polished = (await autoCleanDictation(cleaned, getVocabulary(), { model, signal: opts.signal })).trim()
@@ -162,7 +170,7 @@ promptDraftsRouter.post('/dictation/finalize', async (req, res) => {
162
170
  if (surface !== 'message' && surface !== 'meeting') {
163
171
  return res.status(400).json({ error: 'invalid_surface' })
164
172
  }
165
- if (model !== undefined && model !== 'haiku' && model !== 'sonnet') {
173
+ if (model !== undefined && !isDictationCleanModel(model)) {
166
174
  return res.status(400).json({ error: 'invalid_autoclean_model' })
167
175
  }
168
176
 
@@ -49,6 +49,8 @@ function wireError(error: unknown): { status: number; body: WireError } {
49
49
  || error instanceof QueryJobIdentityConflictError
50
50
  || error instanceof QueryJobActiveGenerationError
51
51
  || error instanceof QueryJobGenerationOrderError
52
+ // Since 6.61.3 admission never throws this (the fence holds a job at its start);
53
+ // kept so the store error map stays complete for any caller of markStarting.
52
54
  || error instanceof QueryJobProviderOrphanFenceError
53
55
  || error instanceof QueryJobAnswerCommittingError
54
56
  || error instanceof QueryJobNotTerminalError) status = 409
@@ -1,7 +1,7 @@
1
1
  import { Router } from 'express'
2
2
  import { readWorkJournal, projectWorkActivity, projectOpenWork, savedDestinationFor, controlSnapshotRevision,
3
3
  type WorkActivityCapabilities, type WorkActivityResult } from '../lib/work-activity.js'
4
- import { listBoard, workBoardCapabilities, setTaskWorkStage, linkTaskMeeting, TaskRunError, TaskBridgeError,
4
+ import { listBoard, workBoardCapabilities, setTaskWorkStage, linkTaskMeeting, editWorkTask, TaskRunError, TaskBridgeError,
5
5
  WORK_STAGES, type WorkStage, type WorkMeetingRef, type TaskBoardRow } from '../lib/task-store.js'
6
6
  import { createWorkBoardReader, WORK_BOARD_READ_LIMITS, WorkBoardTimeoutError, type WorkBoardReader } from '../lib/work-board-reader.js'
7
7
  import type { MeetingDescriptor } from '../lib/work-review-store.js'
@@ -16,6 +16,7 @@ export interface WorkBoardDependencies {
16
16
  capabilities: typeof workBoardCapabilities
17
17
  stage: typeof setTaskWorkStage
18
18
  link: typeof linkTaskMeeting
19
+ edit: typeof editWorkTask
19
20
  resolveMeeting: (descriptor: MeetingDescriptor) => MeetingDetail | Promise<MeetingDetail>
20
21
  /** 6.59.0: the handoff request inbox as the glasses should see it (lib/work-handoff-requests.ts handoffRequestCapabilities). */
21
22
  requests: () => Pick<WorkActivityCapabilities, 'requests' | 'requestsInbox' | 'requestsConsumerSeenAt'>
@@ -24,7 +25,7 @@ export interface WorkBoardDependencies {
24
25
  now: () => number
25
26
  }
26
27
  const defaults: WorkBoardDependencies = { journal: () => readWorkJournal(), list: listBoard, capabilities: workBoardCapabilities,
27
- stage: setTaskWorkStage, link: linkTaskMeeting, resolveMeeting: resolveSavedMeetingDetail,
28
+ stage: setTaskWorkStage, link: linkTaskMeeting, edit: editWorkTask, resolveMeeting: resolveSavedMeetingDetail,
28
29
  requests: () => ({ requests: 0, requestsInbox: 0, requestsConsumerSeenAt: null }), now: () => Date.now() }
29
30
  /** How old a board read the activity and open-work glances accept (the reader shares one read between them). */
30
31
  export const WORK_GLANCE_BOARD_AGE_MS = 10_000
@@ -87,10 +88,10 @@ export function createWorkBoardRouter(overrides: Partial<WorkBoardDependencies>
87
88
  }
88
89
  catch (e) { fail(res, e) }
89
90
  })
90
- for (const action of ['stage', 'meeting'] as const) router.post('/work-board/' + action, async (req, res) => {
91
+ for (const action of ['stage', 'meeting', 'edit'] as const) router.post('/work-board/' + action, async (req, res) => {
91
92
  try {
92
93
  const body = req.body
93
- const keys = ['domain', 'id', 'expectedText', 'expectedRevision', action === 'stage' ? 'workStage' : 'meeting']
94
+ const keys = ['domain', 'id', 'expectedText', 'expectedRevision', ...(action === 'edit' ? ['text', 'doneWhen'] : [action === 'stage' ? 'workStage' : 'meeting'])]
94
95
  if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(k => !keys.includes(k))
95
96
  || Buffer.byteLength(JSON.stringify(body)) > 16_384 || !['domain','id','expectedText','expectedRevision'].every(k => typeof body[k] === 'string')
96
97
  || !isSafeDomainName(body.domain) || !/^[a-f0-9]{12}$/.test(body.id)
@@ -99,7 +100,14 @@ export function createWorkBoardRouter(overrides: Partial<WorkBoardDependencies>
99
100
  }
100
101
  const capability = await deps.capabilities()
101
102
  if (capability.version !== 1 || !capability.writable) throw new TaskRunError(409, 'work_board_read_only', 'The canonical task bridge needs a compatible update')
102
- if (action === 'stage') {
103
+ if (action === 'edit') {
104
+ if (capability.editTasks !== 1) throw new TaskRunError(409, 'task_edit_unavailable', 'Update the canonical task bridge before editing task names')
105
+ if (typeof body.text !== 'string' || typeof body.doneWhen !== 'string') throw new TaskRunError(400, 'invalid_task_edit', 'Task name and finish line are required')
106
+ try {
107
+ const saved = await deps.edit(body.domain, body.id, body.text, body.doneWhen, body.expectedText, body.expectedRevision)
108
+ return res.json(saved)
109
+ } finally { board.invalidate() }
110
+ } else if (action === 'stage') {
103
111
  if (!WORK_STAGES.includes(body.workStage as WorkStage)) throw new TaskRunError(422, 'invalid_work_stage', 'Choose a valid Work stage')
104
112
  // The glasses' shared board read must not serve the board as it was before this write.
105
113
  try { await deps.stage(body.domain, body.id, body.workStage, body.expectedText, body.expectedRevision) } finally { board.invalidate() }
@@ -2,14 +2,14 @@
2
2
  "version": 1,
3
3
  "protocol": "cos-control-task-write/1",
4
4
  "pythonMinimum": "3.10",
5
- "sourceCommit": "177d0bd875f446f5e3d57de52983d5ab4e679915",
5
+ "sourceCommit": "2fd2be86f072781adc9ca856194325ea263858ae",
6
6
  "sourceHashes": {
7
7
  "task_rows.py": "58b568fd4081072eddc3110b4c3397f5e5048ede9e052391e1d09f919e8c2e7d",
8
- "task_write.py": "4d1d4c51cf5ec6fa75f941c2ce1aee05952fde9a6bac89be4fca51b0ffd37981",
8
+ "task_write.py": "fb618ba4a75cdd40859587cf0d74b5d5c8d005c0cc0eaab3573412ac028171a1",
9
9
  "task_work_metadata.py": "534c45910e2109ac1e18eeb236c6b286d8a899f96c7a153d0187aee5839854a5",
10
10
  "task_checkout.py": "56c94c5b28282398abc1f8daea9d910e64228afb8ac8cfae89b54117a7e30b86",
11
11
  "cos_atomic.py": "055f7d6bc3093aeec7b051f87cb68c543637603a538f383a614c1903bd1b503e",
12
- "cos_api_bridge.py": "1e17960332e8b778c8e5923de355f4395c9244692cd4d988ccd944c6a30575ef",
12
+ "cos_api_bridge.py": "539cd27d3929d46205cfa77482b0d5f3e8d9385d0fc839cce7bdcd5437c7b2fd",
13
13
  "task_dedup.py": "5331fdea01c293ecd2773726acea110e9339ff26e72e5bcdce66777ab518bfe8"
14
14
  },
15
15
  "adaptations": [
@@ -22,12 +22,12 @@
22
22
  "files": {
23
23
  "cos_atomic.py": "0da53eecaf07331b008c10b1e06c39701ecc3291c511fd0779e35795b3b22534",
24
24
  "runtime_io.py": "e5b191b6af3a4966f517ecd05d3dcae9f4136a9e99cf36d7102303ca55ae928d",
25
- "task_bridge.py": "8ec1f6533710c3de2c51e518586d2f6b120f90f7c3773480a08d53544ac07fd4",
25
+ "task_bridge.py": "45d5afcfa3b951b6ed3143a636ef6f36c49de1394728280b1ff7bfe7cd138006",
26
26
  "task_checkout.py": "669af045b2f64b9879df03aacae7a4550b46291974ed2df2ba2ed0775c1be78f",
27
- "task_commands.py": "7e843b9164112d610190385df8e41d6044d6546d4ece92d09a60b6e4ad33cc75",
27
+ "task_commands.py": "b020b8249cbbf91661f63dd660b10994fe01b91bad1dfa9f92196cc94782cc6d",
28
28
  "task_dedup.py": "745971722898a3de520f8bb5246d714c70f3189dbe53bf8f42feafaaf241a51b",
29
29
  "task_rows.py": "9e48375d9d6ee5d29bf3cc1f92bdf1182a6eea40ee305ff1fe1632b9f3a9fcf7",
30
30
  "task_work_metadata.py": "534c45910e2109ac1e18eeb236c6b286d8a899f96c7a153d0187aee5839854a5",
31
- "task_write.py": "238b869fac49c8e01bca72a1f28ffab74c7f94938e2323246936aec0241bbd16"
31
+ "task_write.py": "6aea995bd213515eaae00e6e9dae16d77c7de579151fe6a717e6ad9e83b7db63"
32
32
  }
33
33
  }
@@ -31,7 +31,7 @@ def main():
31
31
  raw=sys.stdin.buffer.read(32769)
32
32
  if len(raw)>32768: raise ValueError('Task input exceeds supported limit')
33
33
  sys.stdin=io.StringIO(raw.decode('utf-8'))
34
- commands={'task-rows':'cmd_task_rows','task-capture':'cmd_task_capture','task-set-text':'cmd_task_set_text','task-set-run-at':'cmd_task_set_run_at','task-set-marker':'cmd_task_set_marker','task-set-stage':'cmd_task_set_stage','task-set-done-when':'cmd_task_set_done_when','task-move':'cmd_task_move','task-check':'cmd_task_check','task-set-work-stage':'cmd_task_work_write','task-link-meeting':'cmd_task_work_write'}
34
+ commands={'task-rows':'cmd_task_rows','task-capture':'cmd_task_capture','task-set-text':'cmd_task_set_text','task-set-run-at':'cmd_task_set_run_at','task-set-marker':'cmd_task_set_marker','task-set-stage':'cmd_task_set_stage','task-set-done-when':'cmd_task_set_done_when','task-move':'cmd_task_move','task-check':'cmd_task_check','task-set-work-stage':'cmd_task_work_write','task-link-meeting':'cmd_task_work_write','task-edit-work':'cmd_task_work_write'}
35
35
  if opts.command not in commands and opts.command!='task-work-capabilities': raise ValueError('Unsupported task command')
36
36
  rootfd=directory(opts.root,create=True); domainfds={}; lockfd=None
37
37
  try:
@@ -68,13 +68,14 @@ def main():
68
68
  original_write(domain,identity,path,text)
69
69
  task_write._guarded_write=guarded
70
70
  import task_commands
71
- if opts.command=='task-work-capabilities': print(json.dumps({'version':1,'protocol':'cos-control-task-write/1'}));return
71
+ if opts.command=='task-work-capabilities': print(json.dumps({'version':1,'protocol':'cos-control-task-write/1','editTasks':1}));return
72
72
  if not opts.args or opts.args[0] not in domains: raise ValueError('Unknown task domain')
73
73
  path=domain_path(opts.args[0])
74
74
  try: safe_read_text(path)
75
75
  except FileNotFoundError: pass
76
76
  fn=getattr(task_commands,commands[opts.command])
77
77
  if opts.command=='task-link-meeting':fn(opts.args,linking=True)
78
+ elif opts.command=='task-edit-work':fn(opts.args,editing=True)
78
79
  else:fn(opts.args)
79
80
  finally:
80
81
  for fd in domainfds.values():os.close(fd)
@@ -132,12 +132,12 @@ def cmd_task_set_text(args):
132
132
  return
133
133
  print(json.dumps({"ok": True}))
134
134
 
135
- def cmd_task_work_write(args, *, linking=False):
136
- from task_write import set_work_stage, link_meeting
135
+ def cmd_task_work_write(args, *, linking=False, editing=False):
136
+ from task_write import set_work_stage, link_meeting, edit_work_task
137
137
  domain = _bridge_domain(args)
138
138
  if domain is None:
139
139
  return
140
- if len(args) != (2 if linking else 3):
140
+ if len(args) != (2 if linking or editing else 3):
141
141
  _bridge_error("argv_required", "Expected domain, task ID, and phase for stage writes.")
142
142
  return
143
143
  try:
@@ -147,20 +147,24 @@ def cmd_task_work_write(args, *, linking=False):
147
147
  body = json.loads(raw)
148
148
  if not isinstance(body, dict) or not isinstance(body.get("expectedText"), str) or not isinstance(body.get("expectedRevision"), str):
149
149
  raise ValueError()
150
- allowed = {"expectedText", "expectedRevision", "meeting", "removeRecordId"} if linking else {"expectedText", "expectedRevision"}
150
+ allowed = {"expectedText", "expectedRevision", "text", "doneWhen"} if editing else {"expectedText", "expectedRevision", "meeting", "removeRecordId"} if linking else {"expectedText", "expectedRevision"}
151
151
  if set(body) - allowed:
152
152
  raise ValueError()
153
+ if editing and (not isinstance(body.get("text"), str) or not isinstance(body.get("doneWhen"), str)):
154
+ raise ValueError()
153
155
  except (ValueError, TypeError):
154
156
  _bridge_error("invalid_work_request", "Bounded JSON with expectedText and expectedRevision is required.")
155
157
  return
156
- if linking:
158
+ if editing:
159
+ ok = _bridge_write(lambda: edit_work_task(domain, args[1], body["text"], body["doneWhen"], expected_text=body["expectedText"], expected_revision=body["expectedRevision"]))
160
+ elif linking:
157
161
  ok = _bridge_write(lambda: link_meeting(domain, args[1], expected_text=body["expectedText"], expected_revision=body["expectedRevision"], meeting=body.get("meeting"), remove_record_id=body.get("removeRecordId")))
158
162
  else:
159
163
  ok = _bridge_write(lambda: set_work_stage(domain, args[1], args[2], expected_text=body["expectedText"], expected_revision=body["expectedRevision"]))
160
164
  if ok is False:
161
165
  _bridge_error("task_not_found", "Task not found in this domain.")
162
166
  elif ok is not None:
163
- print(json.dumps({"ok": True}))
167
+ print(json.dumps(ok if editing else {"ok": True}))
164
168
 
165
169
  def cmd_task_set_stage(args):
166
170
  """Move a task between board stages. Clearing returns it to planning, which
@@ -24,6 +24,7 @@ from task_rows import (
24
24
  classify_heading,
25
25
  domain_path,
26
26
  parse_path,
27
+ parse_owner,
27
28
  split_source,
28
29
  split_done_when,
29
30
  STAGES,
@@ -576,7 +577,106 @@ def check(domain: str, task_id: Optional[str] = None, *, text: Optional[str] = N
576
577
  LockStoreCorrupt = LockStoreCorrupt
577
578
 
578
579
 
579
- def _work_mutation(domain: str, task_id: str, expected_text: str, expected_revision: str, change) -> bool:
580
+ def _probe_rows(full: str, line: str) -> list:
581
+ """Parse one rebuilt line exactly as parse_path will, in a scratch file, before it touches tasks.md."""
582
+ import tempfile
583
+ with tempfile.TemporaryDirectory() as scratch:
584
+ probe = Path(scratch) / "tasks.md"
585
+ probe.write_text("## INBOX\n" + line + "\n")
586
+ return parse_path(probe, full)
587
+
588
+
589
+ def capture_work_item(domain: str, text: str, *, meeting, phase: str = "mentioned") -> dict:
590
+ """Create one Work card from a meeting action item, linked to that meeting, in ONE locked write.
591
+
592
+ The row lands at the top of INBOX as `<text> — **Source:** <meeting title> [<date>]` carrying
593
+ `workflowPhase` and the exact meeting reference, so no second write is needed to link it.
594
+
595
+ Never a second copy. Task ids are sha256(domain|normalized text|ordinal), so a copy inserted
596
+ above an existing row would silently shift that row's id; and a renamed card keeps its first id
597
+ as its Work identity, so recapturing the old wording would duplicate that identity and lock both
598
+ rows. Either kind of match returns the existing row instead: `created` False, `linked` says
599
+ whether it already carries this meeting, and checked/archived/delegated describe its state.
600
+ The rebuilt line is parsed in isolation first and refused unless it reads back as exactly this
601
+ text, meeting and phase (the parser drops leading owners, [REVIEW], a trailing dash, comments).
602
+ """
603
+ from task_dedup import TaskDeduplicator
604
+
605
+ text = " ".join(text.split()) if isinstance(text, str) else ""
606
+ if not text or len(text) > 2000:
607
+ raise WorkMetadataError("invalid_task_text", "Card text must be 1 to 2,000 characters.")
608
+ try:
609
+ reject_marker_text(text, "task text")
610
+ except WorkMetadataError:
611
+ raise
612
+ except ValueError as exc:
613
+ raise WorkMetadataError("invalid_task_text", str(exc)) from exc
614
+ meeting = validate_meeting(meeting)
615
+ if phase not in PHASES:
616
+ raise WorkMetadataError("invalid_work_stage", "Unknown Work phase.")
617
+ full = _full_domain(domain)
618
+ path = domain_path(full)
619
+ date = meeting["filename"][:10] if re.match(r"\d{4}-\d{2}-\d{2}_", meeting["filename"]) else datetime.now().strftime("%Y-%m-%d")
620
+ title = re.sub(r"[\[\]*]", "", meeting["title"]).strip() or "Meeting"
621
+ line = _rebuild_line(text, write_source(f"{title} [{date}]", {"workflowPhase": phase, "meetingRefs": [meeting]}),
622
+ None, None, None, False, False)
623
+ probe = _probe_rows(full, line)
624
+ if (len(probe) != 1 or probe[0].description != text or probe[0].work_metadata_error
625
+ or probe[0].meeting_refs != [meeting] or probe[0].work_stage != phase):
626
+ raise WorkMetadataError("invalid_task_text", "This text would not read back as the same card.")
627
+ norm, candidate = TaskDeduplicator.normalize(text), probe[0].id
628
+ if not norm:
629
+ raise WorkMetadataError("invalid_task_text", "Card text has no words.")
630
+ with file_lock(full) as identity:
631
+ same = [r for r in parse_path(path, full)
632
+ if TaskDeduplicator.normalize(r.description) == norm or candidate in (r.id, r.work_identity)]
633
+ if same:
634
+ row = next((r for r in same if any(ref["recordId"] == meeting["recordId"] for ref in r.meeting_refs)), same[0])
635
+ return {"ok": True, "created": False, "id": row.id,
636
+ "linked": any(ref["recordId"] == meeting["recordId"] for ref in row.meeting_refs),
637
+ "checked": row.is_checked, "archived": row.archived, "delegated": row.delegated}
638
+ lines = safe_read_text(path).splitlines() if path.exists() else []
639
+ lines, heading_idx, _ = _locked_ensure_section(lines, "inbox")
640
+ _top_insert(lines, heading_idx, line)
641
+ _guarded_write(full, identity, path, "\n".join(lines) + "\n")
642
+ created = [r for r in parse_path(path, full) if r.id == candidate]
643
+ if len(created) != 1 or created[0].meeting_refs != [meeting]:
644
+ raise WorkMetadataError("capture_unverified", "The new card could not be read back exactly once.")
645
+ return {"ok": True, "created": True, "id": candidate, "linked": True,
646
+ "checked": False, "archived": False, "delegated": created[0].delegated}
647
+
648
+
649
+ def archive_work_item(domain: str, task_id: str, *, record_id: str, expected_text: Optional[str] = None) -> str:
650
+ """Undo for capture_work_item: archive ([~]) the card only while it is still untouched.
651
+
652
+ Untouched means open and still Mentioned (so not completed or moved to another stage), no run or agent
653
+ marker, no finish line, linked to exactly the one meeting it was created from, and, when `expected_text`
654
+ is given, still worded as written. Anything else is left alone. A move between sections is not checked.
655
+ Returns archived | already_archived | changed | absent.
656
+ """
657
+ if not isinstance(task_id, str) or not re.fullmatch(r"[a-f0-9]{12}", task_id):
658
+ raise WorkMetadataError("invalid_task_id", "An exact canonical task ID is required.")
659
+ full = _full_domain(domain)
660
+ path = domain_path(full)
661
+ with file_lock(full) as identity:
662
+ matches = [r for r in parse_path(path, full) if r.id == task_id]
663
+ if len(matches) != 1:
664
+ return "absent"
665
+ row = matches[0]
666
+ if row.archived:
667
+ return "already_archived"
668
+ if (row.work_stage != "mentioned" or row.run_at or row.agent_state or row.done_when
669
+ or [ref["recordId"] for ref in row.meeting_refs] != [record_id]
670
+ or (expected_text is not None and row.description != expected_text)):
671
+ return "changed"
672
+ result = _rewrite_row(path, full, task_id, lambda state: state.update(archived=True))
673
+ if result is False:
674
+ return "absent"
675
+ _guarded_write(full, identity, path, result[1])
676
+ return "archived"
677
+
678
+
679
+ def _work_mutation(domain: str, task_id: str, expected_text: str, expected_revision: str, change, *, return_row=False):
580
680
  if not isinstance(expected_text, str) or not expected_text or len(expected_text) > 20_000:
581
681
  raise WorkMetadataError("expected_text_required", "Full current task text is required.")
582
682
  if not isinstance(task_id, str) or not re.fullmatch(r"[a-f0-9]{12}", task_id):
@@ -612,9 +712,39 @@ def _work_mutation(domain: str, task_id: str, expected_text: str, expected_revis
612
712
  return False
613
713
  _, contents = result
614
714
  _guarded_write(full, identity, path, contents)
715
+ if return_row:
716
+ # Still under the file lock: the response describes this exact commit.
717
+ row = next(r for r in parse_path(path, full) if r.line_number == target.line_number)
718
+ return {"ok": True, "id": row.id, "workIdentity": row.work_identity,
719
+ "workRevision": row.work_revision, "text": row.description,
720
+ "doneWhen": row.done_when or ""}
615
721
  return True
616
722
 
617
723
 
724
+ def edit_work_task(domain: str, task_id: str, text: str, done_when: str, *, expected_text: str, expected_revision: str):
725
+ """One guarded replacement for task name and finish line; preserve Work identity."""
726
+ if not isinstance(text, str) or not isinstance(done_when, str):
727
+ raise ValueError("Task name and finish line must be text")
728
+ clean = " ".join(text.split())
729
+ finish = " ".join(done_when.split())
730
+ if not clean or len(clean.encode("utf-16-le")) // 2 > 2000:
731
+ raise ValueError("Task name must contain 1 to 2,000 characters")
732
+ if len(finish.encode("utf-16-le")) // 2 > 500 or "**" in finish:
733
+ raise ValueError("Finish line must be at most 500 characters without ** markup")
734
+ reject_marker_text(clean, "task text")
735
+ reject_marker_text(finish, "done_when")
736
+ if parse_owner(clean)[0] is not None or "[REVIEW]" in clean:
737
+ raise WorkMetadataError("reserved_task_name", "Task names cannot contain an owner prefix or the reserved [REVIEW] flag.")
738
+
739
+ def change(state, metadata):
740
+ if state["agent_state"] == "running":
741
+ raise TaskRunning("This task is already running; its name cannot change during the run")
742
+ state["description"] = clean
743
+ state["done_when"] = finish or None
744
+
745
+ return _work_mutation(domain, task_id, expected_text, expected_revision, change, return_row=True)
746
+
747
+
618
748
  def set_work_stage(domain: str, task_id: str, phase: str, *, expected_text: str, expected_revision: str) -> bool:
619
749
  if phase not in (*PHASES, 'complete'):
620
750
  raise WorkMetadataError("invalid_work_stage", "Unknown Work phase.")