@gotcos/glasses-server 6.54.0 → 6.56.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +16 -0
  3. package/bin/control2-foundation.cjs +12 -0
  4. package/bin/task-runtime-check.cjs +65 -0
  5. package/docs/control2-foundation.md +79 -0
  6. package/docs/control2-sandbox.md +47 -0
  7. package/docs/control2-task-runtime.md +68 -0
  8. package/docs/work-reviews.md +25 -0
  9. package/docs/work-task-runtime.md +82 -0
  10. package/package.json +18 -4
  11. package/server/index.ts +8 -0
  12. package/server/lib/control2-draft.ts +118 -0
  13. package/server/lib/control2-foundation.ts +164 -0
  14. package/server/lib/control2-sandbox.ts +192 -0
  15. package/server/lib/lens-gist.ts +110 -16
  16. package/server/lib/task-bridge.ts +85 -0
  17. package/server/lib/task-store.ts +75 -10
  18. package/server/lib/work-activity.ts +106 -0
  19. package/server/lib/work-review-backend.ts +28 -0
  20. package/server/lib/work-review-runtime.ts +203 -0
  21. package/server/lib/work-review-store.ts +102 -0
  22. package/server/routes/control2-foundation.ts +56 -0
  23. package/server/routes/health.ts +4 -3
  24. package/server/routes/meetings.ts +24 -59
  25. package/server/routes/work-board.ts +79 -0
  26. package/server/routes/work-reviews.ts +24 -0
  27. package/server/scripts/control2-draft-canary.ts +18 -0
  28. package/server/scripts/control2-foundation-lab.ts +45 -0
  29. package/server/scripts/control2-sandbox-canary.ts +4 -0
  30. package/task-runtime/LICENSE +21 -0
  31. package/task-runtime/manifest.json +37 -0
  32. package/task-runtime/task_runtime.py +380 -0
  33. package/work-task-runtime/cos_atomic.py +90 -0
  34. package/work-task-runtime/manifest.json +33 -0
  35. package/work-task-runtime/runtime_io.py +27 -0
  36. package/work-task-runtime/task_bridge.py +87 -0
  37. package/work-task-runtime/task_checkout.py +380 -0
  38. package/work-task-runtime/task_commands.py +275 -0
  39. package/work-task-runtime/task_dedup.py +11 -0
  40. package/work-task-runtime/task_rows.py +420 -0
  41. package/work-task-runtime/task_work_metadata.py +107 -0
  42. package/work-task-runtime/task_write.py +645 -0
@@ -0,0 +1,645 @@
1
+ #!/usr/bin/env python3
2
+ """Canonical tasks.md writers. One file_lock, one _guarded_write (v19 §1b)."""
3
+
4
+ from __future__ import annotations
5
+ from runtime_io import safe_read_text
6
+
7
+ import os
8
+ import re
9
+ import signal
10
+ import time
11
+ from contextlib import contextmanager
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Iterator, Optional
15
+ from uuid import uuid4
16
+
17
+ from task_work_metadata import WorkMetadataError, decode_source, write_source, reject_reserved, validate_meeting, phase_for, PHASES, LEGACY_STAGE, DEFAULT_PHASE
18
+ from cos_atomic import atomic_write_text
19
+ from task_checkout import LockStoreCorrupt, TaskCheckoutManager
20
+ from task_rows import (
21
+ full_domains,
22
+ MARKER_ANYWHERE_RE,
23
+ SHORT_TO_FULL,
24
+ classify_heading,
25
+ domain_path,
26
+ parse_path,
27
+ split_source,
28
+ split_done_when,
29
+ STAGES,
30
+ strip_markers,
31
+ )
32
+
33
+ TASK_LOCK_TTL_MINUTES = 1
34
+ TASK_LOCK_WRITE_BUDGET_S = 5
35
+ _STALE_TMP_S = 5 * 60
36
+ _CHECKOUT = TaskCheckoutManager()
37
+ _first_lock = False
38
+ _DAY_RE = re.compile(r"^today:(\d{4}-\d{2}-\d{2})$")
39
+ _TODAY_HEADING = re.compile(r"^#{1,3}\s*TODAY\s+(\d{4}-\d{2}-\d{2})\s*$")
40
+ SECTIONS = ("inbox", "today", "urgent", "this_week", "backlog")
41
+
42
+
43
+ class LockLost(Exception):
44
+ """TTL remaining is below the write budget, or identity mismatch."""
45
+
46
+
47
+ class TaskFileLocked(Exception):
48
+ """file:<domain> is held; fail fast, no wait."""
49
+
50
+
51
+ class TaskRunning(Exception):
52
+ """Row shows [agent running]; refuse re-arm."""
53
+
54
+
55
+ def _arm_sigterm() -> None:
56
+ signal.signal(signal.SIGTERM, lambda *_: os._exit(1))
57
+
58
+
59
+ def _sweep_stale_tmps(directory: Path) -> None:
60
+ now = time.time()
61
+ for tmp in directory.glob("*.tmp.*"):
62
+ try:
63
+ if now - tmp.stat().st_mtime > _STALE_TMP_S:
64
+ tmp.unlink()
65
+ except OSError:
66
+ pass
67
+
68
+
69
+ def _is_safe_domain(name: str) -> bool:
70
+ """Safe as a path component. A SAFETY check, not a naming policy."""
71
+ if not name or len(name) > 64 or name != name.strip():
72
+ return False
73
+ if name in (".", "..") or name.startswith("."):
74
+ return False
75
+ return not any(c in name for c in ("/", "\\", "\0")) and not any(ord(c) < 32 or ord(c) == 127 for c in name)
76
+
77
+
78
+ def _full_domain(domain: str) -> str:
79
+ full = SHORT_TO_FULL.get(domain, domain)
80
+ # Writes must not require the domain to already exist: creating the first
81
+ # task in a new domain is exactly how its tasks.md comes into being, so
82
+ # gating on full_domains() here made a new domain uncreatable. Safety is
83
+ # still enforced, because this name becomes a path component.
84
+ if not _is_safe_domain(full):
85
+ raise ValueError(f"invalid domain: {domain}")
86
+ return full
87
+
88
+
89
+ def domain_from_path(path: Path) -> str:
90
+ return _full_domain(path.parent.name)
91
+
92
+
93
+ @contextmanager
94
+ def file_lock(domain: str) -> Iterator[str]:
95
+ """Exclusive file:<domain> lock. Fail fast → TaskFileLocked. No nesting."""
96
+ global _first_lock
97
+ full = _full_domain(domain)
98
+ if not _first_lock:
99
+ _arm_sigterm()
100
+ _sweep_stale_tmps(domain_path(full).parent)
101
+ _first_lock = True
102
+ identity = f"python:{os.getpid()}:{uuid4()}"
103
+ result = _CHECKOUT.checkout(
104
+ f"file:{full}",
105
+ identity,
106
+ exclusive=True,
107
+ timeout_minutes=TASK_LOCK_TTL_MINUTES,
108
+ )
109
+ if result.conflict:
110
+ raise TaskFileLocked(result.message)
111
+ try:
112
+ yield result.user
113
+ finally:
114
+ _CHECKOUT.release(f"file:{full}", identity)
115
+
116
+
117
+ def _guarded_write(domain: str, identity: str, path: Path, text: str) -> None:
118
+ entry = _CHECKOUT.is_locked(f"file:{_full_domain(domain)}")
119
+ if entry is None or entry.user != identity:
120
+ raise LockLost("lock identity mismatch")
121
+ expires = datetime.fromisoformat(entry.expires_at)
122
+ remaining = (expires - datetime.now(timezone.utc)).total_seconds()
123
+ if remaining < TASK_LOCK_WRITE_BUDGET_S:
124
+ raise LockLost(f"write budget exhausted ({remaining:.1f}s left)")
125
+ atomic_write_text(path, text, encoding="utf-8", durable=True)
126
+
127
+
128
+ def locked_replace(path: Path, text: str) -> None:
129
+ """Run-boundary helper for legacy writers."""
130
+ domain = domain_from_path(path)
131
+ with file_lock(domain) as identity:
132
+ _guarded_write(domain, identity, path, text)
133
+
134
+
135
+ def _parse_section(token: str) -> tuple[str, Optional[str]]:
136
+ today = _DAY_RE.match(token)
137
+ if today:
138
+ return "today", today.group(1)
139
+ if token in SECTIONS and token != "today":
140
+ return token, None
141
+ raise ValueError(f"invalid section: {token}")
142
+
143
+
144
+ def _heading_depth(line: str) -> Optional[int]:
145
+ stripped = line.lstrip()
146
+ if not stripped.startswith("#"):
147
+ return None
148
+ return len(stripped) - len(stripped.lstrip("#"))
149
+
150
+
151
+ def _section_end(lines: list[str], start: int, depth: int) -> int:
152
+ idx = start + 1
153
+ while idx < len(lines):
154
+ stripped = lines[idx].strip()
155
+ if stripped.startswith("---"):
156
+ return idx
157
+ other = _heading_depth(lines[idx])
158
+ if other is not None and other <= depth:
159
+ return idx
160
+ idx += 1
161
+ return len(lines)
162
+
163
+
164
+ def _find_heading(lines: list[str], want: str, day: Optional[str] = None) -> Optional[int]:
165
+ for idx, line in enumerate(lines):
166
+ kind, heading_day = classify_heading(line.strip())
167
+ if kind != want:
168
+ continue
169
+ if want == "today" and day and heading_day != day:
170
+ continue
171
+ return idx
172
+ return None
173
+
174
+
175
+ def _insert_heading(lines: list[str], idx: int, heading: str) -> int:
176
+ block = ["", heading, ""]
177
+ lines[idx:idx] = block
178
+ return idx + 1
179
+
180
+
181
+ def _locked_ensure_section(
182
+ lines: list[str], token: str, *, purge_before: Optional[str] = None
183
+ ) -> tuple[list[str], int, bool]:
184
+ """Return (lines, heading_index, fell_to_inbox)."""
185
+ kind, day = _parse_section(token)
186
+ if purge_before:
187
+ lines = _purge_empty_today(lines, purge_before)
188
+
189
+ if kind == "inbox":
190
+ found = _find_heading(lines, "inbox")
191
+ if found is not None:
192
+ return lines, found, False
193
+ first_h2 = next(
194
+ (i for i, line in enumerate(lines) if line.startswith("## ")),
195
+ len(lines),
196
+ )
197
+ return lines, _insert_heading(lines, first_h2, "## INBOX"), False
198
+
199
+ if kind == "today":
200
+ assert day
201
+ found = _find_heading(lines, "today", day)
202
+ if found is not None:
203
+ return lines, found, False
204
+ lines, inbox_idx, _ = _locked_ensure_section(lines, "inbox")
205
+ search_from = inbox_idx + 1
206
+ insert_at = len(lines)
207
+ for idx in range(search_from, len(lines)):
208
+ stripped = lines[idx].strip()
209
+ if stripped.startswith("## ") or stripped.startswith("---"):
210
+ insert_at = idx
211
+ break
212
+ return lines, _insert_heading(lines, insert_at, f"## TODAY {day}"), False
213
+
214
+ found = _find_heading(lines, kind)
215
+ if found is not None:
216
+ return lines, found, False
217
+ lines, inbox_idx, _ = _locked_ensure_section(lines, "inbox")
218
+ return lines, inbox_idx, True
219
+
220
+
221
+ def _purge_empty_today(lines: list[str], purge_before: str) -> list[str]:
222
+ keep: list[str] = []
223
+ idx = 0
224
+ while idx < len(lines):
225
+ match = _TODAY_HEADING.match(lines[idx].strip())
226
+ if match and match.group(1) < purge_before:
227
+ depth = _heading_depth(lines[idx]) or 2
228
+ end = _section_end(lines, idx, depth)
229
+ body = [line for line in lines[idx + 1:end] if line.strip() and not line.strip().startswith("---")]
230
+ has_task = any(line.startswith("- [") for line in lines[idx + 1:end])
231
+ if not has_task and not any(classify_heading(b.strip())[0] for b in body):
232
+ idx = end
233
+ continue
234
+ keep.append(lines[idx])
235
+ idx += 1
236
+ return keep
237
+
238
+
239
+ def _top_insert(lines: list[str], heading_idx: int, task_line: str) -> None:
240
+ depth = _heading_depth(lines[heading_idx]) or 2
241
+ insert_at = heading_idx + 1
242
+ while insert_at < len(lines) and not lines[insert_at].strip():
243
+ insert_at += 1
244
+ end = _section_end(lines, heading_idx, depth)
245
+ insert_at = min(insert_at, end)
246
+ lines.insert(insert_at, task_line)
247
+
248
+
249
+ def _rebuild_line(description: str, source: Optional[str], run_at, agent_state, agent_no, checked: bool, archived: bool, stage=None, done_when=None) -> str:
250
+ mark = "~" if archived else ("x" if checked else " ")
251
+ body = description
252
+ if source:
253
+ body = f"{body} — **Source:** {source}"
254
+ if done_when:
255
+ body = f"{body} — **Done when:** {done_when}"
256
+ parts = []
257
+ if run_at:
258
+ parts.append(f"[run {run_at}]")
259
+ if agent_state == "running":
260
+ parts.append("[agent running]")
261
+ elif agent_state == "done" and agent_no is not None:
262
+ parts.append(f"[agent #{agent_no} done]")
263
+ elif agent_state == "failed":
264
+ if agent_no is not None:
265
+ parts.append(f"[agent #{agent_no} failed]")
266
+ else:
267
+ parts.append("[agent failed]")
268
+ if stage:
269
+ parts.append(f"[stage {stage}]")
270
+ if parts:
271
+ body = f"{body} {' '.join(parts)}"
272
+ return f"- [{mark}] {body}"
273
+
274
+
275
+ def _match_row(row, task_id: str, domain: str) -> bool:
276
+ if row.id == task_id or row.ref == task_id:
277
+ return True
278
+ short = {"sprocket_rocket": "sr", "hermit_crabs": "hc"}.get(domain, domain)
279
+ ordinal = row.ref.rsplit("-", 1)[-1]
280
+ return task_id == f"{short}-{ordinal}"
281
+
282
+
283
+ def _rewrite_row(path: Path, domain: str, task_id: str, mutator):
284
+ rows = parse_path(path, domain)
285
+ target = next((row for row in rows if _match_row(row, task_id, domain)), None)
286
+ if target is None:
287
+ return False
288
+ lines = safe_read_text(path).splitlines()
289
+ idx = target.line_number - 1
290
+ raw = lines[idx]
291
+ prefix = raw[: raw.index("- [")] if "- [" in raw else ""
292
+ desc, source = split_source(raw.split("]", 1)[-1].lstrip())
293
+ desc, markers = strip_markers(desc)
294
+ if source:
295
+ source, src_m = strip_markers(source)
296
+ if src_m.get("run_at"):
297
+ markers["run_at"] = src_m["run_at"]
298
+ if src_m.get("agent_state"):
299
+ markers["agent_state"] = src_m["agent_state"]
300
+ markers["agent_no"] = src_m.get("agent_no")
301
+ desc, dw_desc = split_done_when(desc)
302
+ if source:
303
+ source, dw_src = split_done_when(source)
304
+ else:
305
+ dw_src = None
306
+ state = {
307
+ "description": desc,
308
+ "source": source,
309
+ "stage": markers.get("stage") or target.stage,
310
+ "done_when": dw_desc or dw_src or target.done_when,
311
+ "run_at": markers.get("run_at") or target.run_at,
312
+ "agent_state": markers.get("agent_state") or target.agent_state,
313
+ "agent_no": markers.get("agent_no") if markers.get("agent_no") is not None else target.agent_no,
314
+ "checked": target.is_checked,
315
+ "archived": target.archived,
316
+ }
317
+ mutator(state)
318
+ lines[idx] = prefix + _rebuild_line(**state)
319
+ return True, "\n".join(lines) + "\n"
320
+
321
+
322
+ def capture(domain: str, text: str, *, section: str, run_at=None, purge_before=None) -> dict:
323
+ reject_marker_text(text, "task text")
324
+ full = _full_domain(domain)
325
+ path = domain_path(full)
326
+ with file_lock(full) as identity:
327
+ return _locked_capture(
328
+ full, path, identity, text, section=section,
329
+ run_at=run_at, purge_before=purge_before,
330
+ )
331
+
332
+
333
+ def _locked_capture(domain, path, identity, text, *, section, run_at, purge_before) -> dict:
334
+ lines = safe_read_text(path).splitlines() if path.exists() else []
335
+ lines, heading_idx, fell = _locked_ensure_section(
336
+ lines, section, purge_before=purge_before
337
+ )
338
+ stamp = datetime.now().strftime("%Y-%m-%d")
339
+ line = _rebuild_line(text, f"Manual entry {stamp}", run_at, None, None, False, False)
340
+ _top_insert(lines, heading_idx, line)
341
+ _guarded_write(domain, identity, path, "\n".join(lines) + "\n")
342
+ landed = "inbox" if fell else section
343
+ return {"ok": True, "fell_to_inbox": fell, "section": landed}
344
+
345
+
346
+ def ensure_section(domain: str, token: str, *, purge_before=None) -> None:
347
+ full = _full_domain(domain)
348
+ path = domain_path(full)
349
+ with file_lock(full) as identity:
350
+ lines = safe_read_text(path).splitlines() if path.exists() else []
351
+ lines, _, _ = _locked_ensure_section(lines, token, purge_before=purge_before)
352
+ _guarded_write(full, identity, path, "\n".join(lines) + "\n")
353
+
354
+
355
+ def reject_marker_text(value: str, field: str) -> None:
356
+ """User text may never contain a marker. This is the only gate.
357
+
358
+ _rebuild_line appends the marker blob AFTER the description and the
359
+ **Done when:** segment, so any marker inside user text becomes part of the
360
+ line's trailing syntax and is claimed by MARKER_RE on the next read. The
361
+ typed words vanish and the row changes state silently.
362
+
363
+ Reproduced before this guard existed: set_done_when(id, "[run 2026-12-25
364
+ 09:00]") cleared a live finish line AND armed a scheduled run, on the field
365
+ labelled "What does finished look like?". set_text(id, "... [stage
366
+ planning]") moved a row out of ACTIVE and dropped the words.
367
+
368
+ Both writers pass through here because it is the one layer every client
369
+ (phone, lens, COS Control, CLI) reaches.
370
+ """
371
+ reject_reserved(value)
372
+ if "**source:**" in value.lower():
373
+ raise WorkMetadataError("reserved_source_delimiter", "Source fields may only be written through source-link controls.")
374
+ if "**done when:**" in value.lower():
375
+ raise WorkMetadataError("reserved_done_when_delimiter", "Done when fields may only be written through finish-line controls.")
376
+ hit = MARKER_ANYWHERE_RE.search(value)
377
+ if hit:
378
+ raise ValueError(
379
+ f"{field} may not contain {hit.group(0)}: square-bracket run, agent "
380
+ "and stage markers are reserved for the task line itself"
381
+ )
382
+
383
+
384
+ def set_run_at(domain: str, task_id: str, run_at: Optional[str]) -> bool:
385
+ full = _full_domain(domain)
386
+ path = domain_path(full)
387
+ with file_lock(full) as identity:
388
+ rows = parse_path(path, full) if path.exists() else []
389
+ target = next((row for row in rows if _match_row(row, task_id, full)), None)
390
+ if target is None:
391
+ return False
392
+ if target.agent_state == "running":
393
+ raise TaskRunning(f"task {task_id} is already running")
394
+ result = _rewrite_row(path, full, task_id, lambda s: s.update(run_at=run_at))
395
+ if result is False:
396
+ return False
397
+ _, text = result
398
+ _guarded_write(full, identity, path, text)
399
+ return True
400
+
401
+
402
+ def set_text(domain: str, task_id: str, text: str) -> bool:
403
+ """Rewrite a task's description, preserving its source block and markers.
404
+
405
+ Only the description changes. The `**Source:**` block, the `[run ...]`
406
+ schedule and any agent marker are rebuilt from the parsed state, so editing
407
+ the words of a task never silently drops its provenance or its schedule.
408
+ """
409
+ clean = " ".join(text.split()).strip()
410
+ if not clean:
411
+ raise ValueError("task text required")
412
+ if "\n" in text or "\r" in text:
413
+ raise ValueError("task text must be a single line")
414
+ reject_marker_text(clean, "task text")
415
+ full = _full_domain(domain)
416
+ path = domain_path(full)
417
+ with file_lock(full) as identity:
418
+ rows = parse_path(path, full) if path.exists() else []
419
+ target = next((row for row in rows if _match_row(row, task_id, full)), None)
420
+ if target is None:
421
+ return False
422
+ # A running agent owns the line; rewriting it underneath would change the
423
+ # task the agent was dispatched against.
424
+ if target.agent_state == "running":
425
+ raise TaskRunning(f"task {task_id} is already running")
426
+ if target.work_metadata_error:
427
+ raise WorkMetadataError("work_metadata_invalid", target.work_metadata_error)
428
+ def rename(state):
429
+ _, metadata, error = decode_source(state["source"])
430
+ if error:
431
+ raise WorkMetadataError("work_metadata_invalid", error)
432
+ metadata.setdefault("workIdentity", target.work_identity or target.id)
433
+ state.update(description=clean, source=write_source(state["source"], metadata))
434
+ result = _rewrite_row(path, full, task_id, rename)
435
+ if result is False:
436
+ return False
437
+ _, rebuilt = result
438
+ _guarded_write(full, identity, path, rebuilt)
439
+ return True
440
+
441
+
442
+ def set_marker(domain: str, task_id: str, marker: Optional[str]) -> bool:
443
+ def mutate(state):
444
+ if marker is None or marker == "--clear":
445
+ state["agent_state"] = None
446
+ state["agent_no"] = None
447
+ return
448
+ if marker == "running":
449
+ state["agent_state"] = "running"
450
+ state["agent_no"] = None
451
+ return
452
+ if marker == "failed":
453
+ state["agent_state"] = "failed"
454
+ state["agent_no"] = None
455
+ return
456
+ kind, _, number = marker.partition(":")
457
+ state["agent_state"] = kind
458
+ state["agent_no"] = int(number) if number else None
459
+
460
+ full = _full_domain(domain)
461
+ path = domain_path(full)
462
+ with file_lock(full) as identity:
463
+ result = _rewrite_row(path, full, task_id, mutate)
464
+ if result is False:
465
+ return False
466
+ _, text = result
467
+ _guarded_write(full, identity, path, text)
468
+ return True
469
+
470
+
471
+ def set_stage(domain: str, task_id: str, stage: Optional[str]) -> bool:
472
+ """Write the workflow stage. None or --clear returns the row to planning,
473
+ which is the unmarked default, so clearing removes the marker entirely."""
474
+ if stage in (None, "", "--clear", "planning"):
475
+ wanted = None
476
+ elif stage in STAGES:
477
+ wanted = stage
478
+ else:
479
+ raise ValueError(f"invalid stage: {stage}")
480
+
481
+ def mutate(state):
482
+ _, metadata, error = decode_source(state["source"])
483
+ if error:
484
+ raise WorkMetadataError("work_metadata_invalid", error)
485
+ if metadata:
486
+ metadata["workflowPhase"] = DEFAULT_PHASE[wanted or 'planning']
487
+ state["source"] = write_source(state["source"], metadata)
488
+ state["stage"] = wanted
489
+
490
+ full = _full_domain(domain)
491
+ path = domain_path(full)
492
+ with file_lock(full) as identity:
493
+ result = _rewrite_row(path, full, task_id, mutate)
494
+ if result is False:
495
+ return False
496
+ _, text = result
497
+ _guarded_write(full, identity, path, text)
498
+ return True
499
+
500
+
501
+ def set_done_when(domain: str, task_id: str, text: Optional[str]) -> bool:
502
+ """Set or clear the finish line. A run is refused until this is non-empty."""
503
+ wanted = (text or "").strip() or None
504
+ if wanted and ("\n" in wanted or "**" in wanted):
505
+ raise ValueError("done_when must be a single line without ** markup")
506
+ if wanted:
507
+ reject_marker_text(wanted, "done_when")
508
+
509
+ def mutate(state):
510
+ state["done_when"] = wanted
511
+
512
+ full = _full_domain(domain)
513
+ path = domain_path(full)
514
+ with file_lock(full) as identity:
515
+ result = _rewrite_row(path, full, task_id, mutate)
516
+ if result is False:
517
+ return False
518
+ _, text2 = result
519
+ _guarded_write(full, identity, path, text2)
520
+ return True
521
+
522
+
523
+ def move(domain: str, task_id: str, section: str, *, purge_before=None) -> bool:
524
+ full = _full_domain(domain)
525
+ path = domain_path(full)
526
+ with file_lock(full) as identity:
527
+ rows = parse_path(path, full)
528
+ target = next((row for row in rows if _match_row(row, task_id, full)), None)
529
+ if target is None:
530
+ return False
531
+ lines = safe_read_text(path).splitlines()
532
+ idx = target.line_number - 1
533
+ task_line = lines.pop(idx)
534
+ lines, heading_idx, _ = _locked_ensure_section(
535
+ lines, section, purge_before=purge_before
536
+ )
537
+ _top_insert(lines, heading_idx, task_line)
538
+ _guarded_write(full, identity, path, "\n".join(lines) + "\n")
539
+ return True
540
+
541
+
542
+ def check(domain: str, task_id: Optional[str] = None, *, text: Optional[str] = None, uncheck: bool = False) -> str:
543
+ """Returns 'ok' | 'task_not_found' | 'ambiguous_text'."""
544
+ full = _full_domain(domain)
545
+ path = domain_path(full)
546
+ rows = parse_path(path, full)
547
+ if task_id:
548
+ matches = [row for row in rows if _match_row(row, task_id, full)]
549
+ else:
550
+ needle = (text or "").lower()
551
+ matches = [
552
+ row for row in rows
553
+ if needle and (
554
+ needle[:50] in row.description.lower()
555
+ or row.description.lower()[:50] in needle
556
+ )
557
+ ]
558
+ if not matches:
559
+ return "task_not_found"
560
+ if len(matches) > 1 and text and not task_id:
561
+ return "ambiguous_text"
562
+ target = matches[0]
563
+ with file_lock(full) as identity:
564
+ result = _rewrite_row(
565
+ path, full, target.id,
566
+ lambda s: s.update(checked=not uncheck),
567
+ )
568
+ if result is False:
569
+ return "task_not_found"
570
+ _, body = result
571
+ _guarded_write(full, identity, path, body)
572
+ return "ok"
573
+
574
+
575
+ # Re-export for tests / writers
576
+ LockStoreCorrupt = LockStoreCorrupt
577
+
578
+
579
+ def _work_mutation(domain: str, task_id: str, expected_text: str, expected_revision: str, change) -> bool:
580
+ if not isinstance(expected_text, str) or not expected_text or len(expected_text) > 20_000:
581
+ raise WorkMetadataError("expected_text_required", "Full current task text is required.")
582
+ if not isinstance(task_id, str) or not re.fullmatch(r"[a-f0-9]{12}", task_id):
583
+ raise WorkMetadataError("invalid_task_id", "An exact canonical task ID is required.")
584
+ if not isinstance(expected_revision, str) or not re.fullmatch(r"[a-f0-9]{64}", expected_revision):
585
+ raise WorkMetadataError("expected_revision_required", "The current Work revision is required.")
586
+ full = _full_domain(domain)
587
+ path = domain_path(full)
588
+ with file_lock(full) as identity:
589
+ matches = [r for r in parse_path(path, full) if r.id == task_id]
590
+ if not matches:
591
+ return False
592
+ if len(matches) != 1:
593
+ raise WorkMetadataError("ambiguous_task_id", "Task ID is not unique in this domain.")
594
+ target = matches[0]
595
+ if target.archived:
596
+ raise WorkMetadataError("task_archived", "Restore the archived task before changing its Work details.")
597
+ if target.work_revision != expected_revision:
598
+ raise WorkMetadataError("task_revision_changed", "Task file changed. Refresh before editing Work details.")
599
+ if target.description != expected_text:
600
+ raise WorkMetadataError("task_text_changed", "Task text changed. Refresh before editing its Work details.")
601
+ if target.work_metadata_error:
602
+ raise WorkMetadataError("work_metadata_invalid", target.work_metadata_error)
603
+ def mutate(state):
604
+ _, metadata, error = decode_source(state["source"])
605
+ if error:
606
+ raise WorkMetadataError("work_metadata_invalid", error)
607
+ metadata.setdefault("workIdentity", target.work_identity or target.id)
608
+ change(state, metadata)
609
+ state["source"] = write_source(state["source"], metadata)
610
+ result = _rewrite_row(path, full, task_id, mutate)
611
+ if result is False:
612
+ return False
613
+ _, contents = result
614
+ _guarded_write(full, identity, path, contents)
615
+ return True
616
+
617
+
618
+ def set_work_stage(domain: str, task_id: str, phase: str, *, expected_text: str, expected_revision: str) -> bool:
619
+ if phase not in (*PHASES, 'complete'):
620
+ raise WorkMetadataError("invalid_work_stage", "Unknown Work phase.")
621
+ def change(state, metadata):
622
+ if phase == 'complete':
623
+ metadata['workflowPhase'] = phase_for(state['stage'], False, metadata)
624
+ state['checked'] = True
625
+ else:
626
+ metadata['workflowPhase'] = phase
627
+ state['stage'] = None if LEGACY_STAGE[phase] == 'planning' else LEGACY_STAGE[phase]
628
+ state['checked'] = False
629
+ return _work_mutation(domain, task_id, expected_text, expected_revision, change)
630
+
631
+
632
+ def link_meeting(domain: str, task_id: str, *, expected_text: str, expected_revision: str, meeting=None, remove_record_id=None) -> bool:
633
+ if (meeting is None) == (remove_record_id is None):
634
+ raise WorkMetadataError("invalid_meeting_ref", "Provide one meeting reference or one record ID to unlink.")
635
+ if meeting is not None:
636
+ meeting = validate_meeting(meeting)
637
+ elif not isinstance(remove_record_id, str) or not remove_record_id or len(remove_record_id) > 512:
638
+ raise WorkMetadataError("invalid_meeting_ref", "Invalid record ID to unlink.")
639
+ def change(state, metadata):
640
+ key = meeting['recordId'] if meeting is not None else remove_record_id
641
+ refs = [ref for ref in metadata.get('meetingRefs', []) if ref['recordId'] != key]
642
+ if meeting is not None:
643
+ refs.append(meeting)
644
+ metadata['meetingRefs'] = refs
645
+ return _work_mutation(domain, task_id, expected_text, expected_revision, change)