@gotcos/glasses-server 6.55.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 (40) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/control2-foundation.cjs +12 -0
  3. package/bin/task-runtime-check.cjs +65 -0
  4. package/docs/control2-foundation.md +79 -0
  5. package/docs/control2-sandbox.md +47 -0
  6. package/docs/control2-task-runtime.md +68 -0
  7. package/docs/work-reviews.md +25 -0
  8. package/docs/work-task-runtime.md +82 -0
  9. package/package.json +18 -4
  10. package/server/index.ts +8 -0
  11. package/server/lib/control2-draft.ts +118 -0
  12. package/server/lib/control2-foundation.ts +164 -0
  13. package/server/lib/control2-sandbox.ts +192 -0
  14. package/server/lib/task-bridge.ts +85 -0
  15. package/server/lib/task-store.ts +75 -10
  16. package/server/lib/work-activity.ts +106 -0
  17. package/server/lib/work-review-backend.ts +28 -0
  18. package/server/lib/work-review-runtime.ts +203 -0
  19. package/server/lib/work-review-store.ts +102 -0
  20. package/server/routes/control2-foundation.ts +56 -0
  21. package/server/routes/health.ts +4 -3
  22. package/server/routes/meetings.ts +24 -59
  23. package/server/routes/work-board.ts +79 -0
  24. package/server/routes/work-reviews.ts +24 -0
  25. package/server/scripts/control2-draft-canary.ts +18 -0
  26. package/server/scripts/control2-foundation-lab.ts +45 -0
  27. package/server/scripts/control2-sandbox-canary.ts +4 -0
  28. package/task-runtime/LICENSE +21 -0
  29. package/task-runtime/manifest.json +37 -0
  30. package/task-runtime/task_runtime.py +380 -0
  31. package/work-task-runtime/cos_atomic.py +90 -0
  32. package/work-task-runtime/manifest.json +33 -0
  33. package/work-task-runtime/runtime_io.py +27 -0
  34. package/work-task-runtime/task_bridge.py +87 -0
  35. package/work-task-runtime/task_checkout.py +380 -0
  36. package/work-task-runtime/task_commands.py +275 -0
  37. package/work-task-runtime/task_dedup.py +11 -0
  38. package/work-task-runtime/task_rows.py +420 -0
  39. package/work-task-runtime/task_work_metadata.py +107 -0
  40. package/work-task-runtime/task_write.py +645 -0
@@ -0,0 +1,380 @@
1
+ #!/usr/bin/env python3
2
+ """Atomic task / file lock manager. Store: COS/.task_locks.json (v19 §1b.2)."""
3
+
4
+ from __future__ import annotations
5
+ from runtime_io import open_lock
6
+
7
+ import argparse
8
+ import fcntl
9
+ import json
10
+ import os
11
+ import sys
12
+ from dataclasses import asdict, dataclass
13
+ from datetime import datetime, timedelta, timezone
14
+ from pathlib import Path
15
+ from typing import Callable, Dict, List, Optional
16
+
17
+ SCRIPT_DIR = Path(__file__).parent.resolve()
18
+ DEFAULT_LOCKS_FILE = (
19
+ Path.home() / "Library" / "Application Support" / "COS" / ".task_locks.json"
20
+ )
21
+ LOCKS_FILE = Path(os.environ.get("COS_TASK_LOCK_STORE") or DEFAULT_LOCKS_FILE)
22
+ DEFAULT_TIMEOUT_MINUTES = 30
23
+ CORRUPT_NAME = ".task_locks.json.corrupt"
24
+
25
+
26
+ class LockStoreCorrupt(Exception):
27
+ """Lock file has no recoverable JSON object prefix."""
28
+
29
+
30
+ @dataclass
31
+ class CheckoutResult:
32
+ success: bool
33
+ task_id: str
34
+ user: str
35
+ conflict: bool = False
36
+ locked_by: Optional[str] = None
37
+ locked_at: Optional[str] = None
38
+ expires_at: Optional[str] = None
39
+ message: str = ""
40
+
41
+
42
+ @dataclass
43
+ class LockEntry:
44
+ task_id: str
45
+ user: str
46
+ locked_at: str
47
+ expires_at: str
48
+
49
+ def is_expired(self) -> bool:
50
+ # Unknown expiry is unknown ownership, never permission to steal a lock.
51
+ try:
52
+ locked = datetime.fromisoformat(self.locked_at)
53
+ expires = datetime.fromisoformat(self.expires_at)
54
+ if locked.tzinfo is None or expires.tzinfo is None or expires < locked:
55
+ raise ValueError("Invalid lock timestamp ordering or timezone")
56
+ return datetime.now(timezone.utc) > expires
57
+ except (ValueError, TypeError) as exc:
58
+ raise LockStoreCorrupt("Lock timestamps are invalid; repair required") from exc
59
+
60
+ def to_dict(self) -> dict:
61
+ return asdict(self)
62
+
63
+ @classmethod
64
+ def from_dict(cls, d: dict) -> "LockEntry":
65
+ known = {"task_id", "user", "locked_at", "expires_at"}
66
+ if not isinstance(d, dict) or set(d) != known or any(not isinstance(d[k], str) or not d[k].strip() for k in known):
67
+ raise LockStoreCorrupt("Lock entry schema is invalid; repair required")
68
+ return cls(**d)
69
+
70
+
71
+ def _entries_from_payload(data: dict) -> Dict[str, LockEntry]:
72
+ if not isinstance(data, dict):
73
+ raise LockStoreCorrupt("Lock store must be an object")
74
+ locks: Dict[str, LockEntry] = {}
75
+ for key, value in data.items():
76
+ entry = LockEntry.from_dict(value)
77
+ if not isinstance(key, str) or not key or entry.task_id != key:
78
+ raise LockStoreCorrupt("Lock key and task identity disagree; repair required")
79
+ if not entry.is_expired():
80
+ locks[key] = entry
81
+ return locks
82
+
83
+
84
+ def _unique_lock_object(pairs):
85
+ result = {}
86
+ for key, value in pairs:
87
+ if key in result:
88
+ raise LockStoreCorrupt("Duplicate lock JSON key; repair required")
89
+ result[key] = value
90
+ return result
91
+
92
+
93
+ def _decode_store(raw: bytes) -> tuple[Optional[dict], bool]:
94
+ """Return (payload, corrupt). Empty bytes are a fresh store, not corrupt."""
95
+ if not raw:
96
+ return {}, False
97
+ text = raw.decode("utf-8", errors="surrogateescape")
98
+ stripped = text.lstrip()
99
+ if "{" not in stripped:
100
+ return None, True
101
+ try:
102
+ obj, _end = json.JSONDecoder(object_pairs_hook=_unique_lock_object).raw_decode(stripped)
103
+ except json.JSONDecodeError:
104
+ return None, True
105
+ if not isinstance(obj, dict):
106
+ return None, True
107
+ return obj, False
108
+
109
+
110
+ class TaskCheckoutManager:
111
+ """Manages atomic task checkouts with file-based locking."""
112
+
113
+ def __init__(
114
+ self,
115
+ locks_file: Path = LOCKS_FILE,
116
+ timeout_minutes: int = DEFAULT_TIMEOUT_MINUTES,
117
+ ):
118
+ self.locks_file = Path(locks_file)
119
+ self.timeout_minutes = timeout_minutes
120
+ self._quarantined: set[tuple[int, int]] = set()
121
+
122
+ def _ensure_parent(self) -> None:
123
+ pass # Parent was securely opened by the portable wrapper
124
+
125
+ def _quarantine(self, raw: bytes) -> None:
126
+ dest = self.locks_file.parent / CORRUPT_NAME
127
+ raise LockStoreCorrupt("Portable lock store is corrupt; repair required")
128
+
129
+ def _persist(self, f, locks: Dict[str, LockEntry]) -> None:
130
+ out = {k: v.to_dict() for k, v in locks.items()}
131
+ encoded = json.dumps(out, indent=2).encode("utf-8") + b"\n"
132
+ f.seek(0)
133
+ f.write(encoded)
134
+ f.flush()
135
+ os.fsync(f.fileno())
136
+ f.truncate(len(encoded))
137
+
138
+ def _with_file_lock(
139
+ self,
140
+ fn: Callable,
141
+ *,
142
+ fail_closed: bool = False,
143
+ readonly: bool = False,
144
+ ):
145
+ """Same-fd flock. See plan v19 §1b.2.
146
+
147
+ Write path creates the store. Readonly never creates. Empty size is
148
+ a fresh map. A torn UTF-8 tail with a dict prefix is kept; only a
149
+ missing dict prefix quarantines.
150
+ """
151
+ if not readonly:
152
+ self._ensure_parent()
153
+
154
+ if readonly:
155
+ try:
156
+ fd = open_lock(self.locks_file, os.O_RDONLY)
157
+ except FileNotFoundError:
158
+ return fn({})
159
+ else:
160
+ fd = open_lock(self.locks_file, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
161
+
162
+ f = os.fdopen(fd, "rb+")
163
+ try:
164
+ fcntl.flock(f.fileno(), fcntl.LOCK_EX)
165
+ raw = f.read(1_048_577)
166
+ if len(raw) > 1_048_576: raise LockStoreCorrupt("Lock store exceeds supported size")
167
+ payload, corrupt = _decode_store(raw)
168
+ if corrupt:
169
+ stamp = (0, 0)
170
+ try:
171
+ st = os.fstat(f.fileno())
172
+ stamp = (st.st_mtime_ns, st.st_size)
173
+ except OSError:
174
+ pass
175
+ if stamp not in self._quarantined:
176
+ self._quarantine(raw)
177
+ self._quarantined.add(stamp)
178
+ locks = {}
179
+ if fail_closed or not readonly:
180
+ # Never clear unknown ownership as a side effect of admission.
181
+ # Otherwise the next retry steals every lock in the corrupt store.
182
+ raise LockStoreCorrupt(str(self.locks_file))
183
+ else:
184
+ locks = _entries_from_payload(payload or {})
185
+
186
+ result = fn(locks)
187
+
188
+ if not readonly:
189
+ self._persist(f, locks)
190
+ return result
191
+ finally:
192
+ fcntl.flock(f.fileno(), fcntl.LOCK_UN)
193
+ f.close()
194
+
195
+ def _purge_expired(self, locks: Dict[str, LockEntry]) -> int:
196
+ expired = [k for k, v in locks.items() if v.is_expired()]
197
+ for key in expired:
198
+ del locks[key]
199
+ return len(expired)
200
+
201
+ def _ttl(self, timeout_minutes: Optional[int]) -> int:
202
+ return self.timeout_minutes if timeout_minutes is None else timeout_minutes
203
+
204
+ def checkout(
205
+ self,
206
+ task_id: str,
207
+ user: str,
208
+ *,
209
+ exclusive: bool = False,
210
+ timeout_minutes: Optional[int] = None,
211
+ ) -> CheckoutResult:
212
+ ttl = self._ttl(timeout_minutes)
213
+
214
+ def _do_checkout(locks: Dict[str, LockEntry]) -> CheckoutResult:
215
+ self._purge_expired(locks)
216
+
217
+ if task_id in locks:
218
+ existing = locks[task_id]
219
+ if existing.user == user and not exclusive:
220
+ now = datetime.now(timezone.utc)
221
+ existing.locked_at = now.isoformat()
222
+ existing.expires_at = (now + timedelta(minutes=ttl)).isoformat()
223
+ return CheckoutResult(
224
+ success=True, task_id=task_id, user=user,
225
+ message=f"Lock extended for {user}",
226
+ )
227
+ return CheckoutResult(
228
+ success=False, task_id=task_id, user=user,
229
+ conflict=True,
230
+ locked_by=existing.user,
231
+ locked_at=existing.locked_at,
232
+ expires_at=existing.expires_at,
233
+ message=f"Task {task_id} is checked out by {existing.user}",
234
+ )
235
+
236
+ now = datetime.now(timezone.utc)
237
+ locks[task_id] = LockEntry(
238
+ task_id=task_id,
239
+ user=user,
240
+ locked_at=now.isoformat(),
241
+ expires_at=(now + timedelta(minutes=ttl)).isoformat(),
242
+ )
243
+ return CheckoutResult(
244
+ success=True, task_id=task_id, user=user,
245
+ message=f"Checked out {task_id} for {user}",
246
+ )
247
+
248
+ return self._with_file_lock(
249
+ _do_checkout, fail_closed=task_id.startswith("file:")
250
+ )
251
+
252
+ def release(self, task_id: str, user: str) -> CheckoutResult:
253
+ def _do_release(locks: Dict[str, LockEntry]) -> CheckoutResult:
254
+ self._purge_expired(locks)
255
+ if task_id not in locks:
256
+ return CheckoutResult(
257
+ success=True, task_id=task_id, user=user,
258
+ message=f"Task {task_id} was not locked",
259
+ )
260
+ existing = locks[task_id]
261
+ if existing.user != user:
262
+ return CheckoutResult(
263
+ success=False, task_id=task_id, user=user,
264
+ conflict=True, locked_by=existing.user,
265
+ message=f"Cannot release — locked by {existing.user}, not {user}",
266
+ )
267
+ del locks[task_id]
268
+ return CheckoutResult(
269
+ success=True, task_id=task_id, user=user,
270
+ message=f"Released {task_id}",
271
+ )
272
+
273
+ return self._with_file_lock(
274
+ _do_release, fail_closed=task_id.startswith("file:")
275
+ )
276
+
277
+ def force_release(self, task_id: str) -> CheckoutResult:
278
+ def _do_force(locks: Dict[str, LockEntry]) -> CheckoutResult:
279
+ self._purge_expired(locks)
280
+ if task_id in locks:
281
+ old_user = locks[task_id].user
282
+ del locks[task_id]
283
+ return CheckoutResult(
284
+ success=True, task_id=task_id, user="admin",
285
+ message=f"Force-released {task_id} (was held by {old_user})",
286
+ )
287
+ return CheckoutResult(
288
+ success=True, task_id=task_id, user="admin",
289
+ message=f"Task {task_id} was not locked",
290
+ )
291
+
292
+ return self._with_file_lock(_do_force)
293
+
294
+ def status(self) -> List[LockEntry]:
295
+ def _do_status(locks: Dict[str, LockEntry]):
296
+ self._purge_expired(locks)
297
+ return list(locks.values())
298
+
299
+ return self._with_file_lock(_do_status, readonly=True)
300
+
301
+ def is_locked(self, task_id: str) -> Optional[LockEntry]:
302
+ def _do_check(locks: Dict[str, LockEntry]):
303
+ self._purge_expired(locks)
304
+ return locks.get(task_id)
305
+
306
+ return self._with_file_lock(_do_check, readonly=True)
307
+
308
+
309
+ def doctor(locks_file: Path, *, confirm: bool) -> int:
310
+ corrupt = locks_file.parent / CORRUPT_NAME
311
+ print(f"store: {locks_file}")
312
+ print(f"corrupt: {corrupt}")
313
+ if not confirm:
314
+ print("pass --confirm to recreate an empty store")
315
+ return 0
316
+ locks_file.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
317
+ locks_file.write_text("{}\n")
318
+ print("rewrote empty store")
319
+ return 0
320
+
321
+
322
+ def main():
323
+ parser = argparse.ArgumentParser(description="Task Checkout Manager")
324
+ subparsers = parser.add_subparsers(dest="command")
325
+
326
+ co = subparsers.add_parser("checkout", help="Check out a task")
327
+ co.add_argument("task_id")
328
+ co.add_argument("user")
329
+
330
+ rel = subparsers.add_parser("release", help="Release a checkout")
331
+ rel.add_argument("task_id")
332
+ rel.add_argument("user")
333
+
334
+ fr = subparsers.add_parser("force-release", help="Force release (admin)")
335
+ fr.add_argument("task_id")
336
+
337
+ subparsers.add_parser("status", help="Show all active locks")
338
+
339
+ doc = subparsers.add_parser("doctor", help="Inspect / recreate the lock store")
340
+ doc.add_argument("--confirm", action="store_true")
341
+
342
+ args = parser.parse_args()
343
+ mgr = TaskCheckoutManager()
344
+
345
+ if args.command == "checkout":
346
+ result = mgr.checkout(args.task_id, args.user)
347
+ if result.conflict:
348
+ print(f"CONFLICT: {result.message}")
349
+ print(f" Locked by: {result.locked_by}")
350
+ print(f" Since: {result.locked_at}")
351
+ print(f" Expires: {result.expires_at}")
352
+ sys.exit(1)
353
+ print(f"OK: {result.message}")
354
+ elif args.command == "release":
355
+ result = mgr.release(args.task_id, args.user)
356
+ if result.success:
357
+ print(f"OK: {result.message}")
358
+ else:
359
+ print(f"DENIED: {result.message}")
360
+ sys.exit(1)
361
+ elif args.command == "force-release":
362
+ print(f"OK: {mgr.force_release(args.task_id).message}")
363
+ elif args.command == "status":
364
+ locks = mgr.status()
365
+ if not locks:
366
+ print("No active locks.")
367
+ return
368
+ print(f"\n{'Task ID':<20} {'User':<15} {'Locked At':<25} {'Expires At'}")
369
+ print("-" * 80)
370
+ for lock in locks:
371
+ print(f"{lock.task_id:<20} {lock.user:<15} {lock.locked_at:<25} {lock.expires_at}")
372
+ print()
373
+ elif args.command == "doctor":
374
+ sys.exit(doctor(LOCKS_FILE, confirm=args.confirm))
375
+ else:
376
+ parser.print_help()
377
+
378
+
379
+ if __name__ == "__main__":
380
+ main()
@@ -0,0 +1,275 @@
1
+ """Extracted canonical task handlers only; see manifest provenance."""
2
+ import json, re, sys
3
+
4
+ def cmd_task_rows(args):
5
+ from task_rows import full_domains, cmd_task_rows as emit_rows
6
+
7
+ domain = args[0] if args and not args[0].startswith("-") else None
8
+ day = None
9
+ if "--day" in args:
10
+ idx = args.index("--day")
11
+ if idx + 1 < len(args):
12
+ day = args[idx + 1]
13
+ if domain not in full_domains():
14
+ print(json.dumps({
15
+ "error": {
16
+ "code": "invalid_domain",
17
+ "message": f"full domain required, got {domain!r}",
18
+ }
19
+ }))
20
+ return
21
+ if not day or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", day):
22
+ print(json.dumps({
23
+ "error": {
24
+ "code": "day_required",
25
+ "message": "--day YYYY-MM-DD is required",
26
+ }
27
+ }))
28
+ return
29
+ print(json.dumps(emit_rows(domain, day)))
30
+
31
+ def _bridge_error(code: str, message: str) -> None:
32
+ print(json.dumps({"error": {"code": code, "message": message}}))
33
+
34
+ def _bridge_flag(args, name: str):
35
+ if name in args:
36
+ idx = args.index(name)
37
+ if idx + 1 < len(args):
38
+ return args[idx + 1]
39
+ return None
40
+
41
+ def _bridge_domain(args):
42
+ from task_rows import full_domains
43
+
44
+ domain = args[0] if args and not args[0].startswith("-") else None
45
+ if domain not in full_domains():
46
+ _bridge_error("invalid_domain", f"full domain required, got {domain!r}")
47
+ return None
48
+ return domain
49
+
50
+ def _bridge_write(fn):
51
+ from task_write import LockLost, TaskFileLocked, TaskRunning
52
+ from task_work_metadata import WorkMetadataError
53
+
54
+ try:
55
+ return fn()
56
+ except TaskFileLocked as exc:
57
+ _bridge_error("task_file_locked", str(exc))
58
+ except TaskRunning as exc:
59
+ _bridge_error("task_running", str(exc))
60
+ except LockLost as exc:
61
+ _bridge_error("lock_lost", str(exc))
62
+ except WorkMetadataError as exc:
63
+ _bridge_error(exc.code, str(exc))
64
+ except ValueError as exc:
65
+ _bridge_error("invalid_section", str(exc))
66
+ return None
67
+
68
+ def cmd_task_capture(args):
69
+ from task_write import capture
70
+
71
+ domain = _bridge_domain(args)
72
+ if domain is None:
73
+ return
74
+ section = _bridge_flag(args, "--section")
75
+ if not section:
76
+ _bridge_error("section_required", "--section is required")
77
+ return
78
+ text = sys.stdin.read().strip()
79
+ if not text:
80
+ _bridge_error("text_required", "task text required on STDIN")
81
+ return
82
+ result = _bridge_write(lambda: capture(
83
+ domain,
84
+ text,
85
+ section=section,
86
+ run_at=_bridge_flag(args, "--run-at"),
87
+ purge_before=_bridge_flag(args, "--purge-before"),
88
+ ))
89
+ if result is not None:
90
+ print(json.dumps(result))
91
+
92
+ def cmd_task_set_run_at(args):
93
+ from task_write import set_run_at
94
+
95
+ domain = _bridge_domain(args)
96
+ if domain is None:
97
+ return
98
+ if len(args) < 3:
99
+ _bridge_error("argv_required", "usage: task-set-run-at <domain> <id> <YYYY-MM-DD HH:MM>|--clear")
100
+ return
101
+ task_id = args[1]
102
+ raw = args[2]
103
+ run_at = None if raw == "--clear" else raw
104
+ ok = _bridge_write(lambda: set_run_at(domain, task_id, run_at))
105
+ if ok is None:
106
+ return
107
+ if ok is False:
108
+ _bridge_error("task_not_found", f"no task {task_id} in {domain}")
109
+ return
110
+ print(json.dumps({"ok": True}))
111
+
112
+ def cmd_task_set_text(args):
113
+ """Rewrite a task's words. Text arrives on STDIN so it needs no quoting."""
114
+ from task_write import set_text
115
+
116
+ domain = _bridge_domain(args)
117
+ if domain is None:
118
+ return
119
+ if len(args) < 2:
120
+ _bridge_error("argv_required", "usage: task-set-text <domain> <id> (text on STDIN)")
121
+ return
122
+ task_id = args[1]
123
+ text = sys.stdin.read().strip()
124
+ if not text:
125
+ _bridge_error("text_required", "task text required on STDIN")
126
+ return
127
+ ok = _bridge_write(lambda: set_text(domain, task_id, text))
128
+ if ok is None:
129
+ return
130
+ if ok is False:
131
+ _bridge_error("task_not_found", f"no task {task_id} in {domain}")
132
+ return
133
+ print(json.dumps({"ok": True}))
134
+
135
+ def cmd_task_work_write(args, *, linking=False):
136
+ from task_write import set_work_stage, link_meeting
137
+ domain = _bridge_domain(args)
138
+ if domain is None:
139
+ return
140
+ if len(args) != (2 if linking else 3):
141
+ _bridge_error("argv_required", "Expected domain, task ID, and phase for stage writes.")
142
+ return
143
+ try:
144
+ raw = sys.stdin.read(32_769)
145
+ if len(raw.encode()) > 32_768:
146
+ raise ValueError()
147
+ body = json.loads(raw)
148
+ if not isinstance(body, dict) or not isinstance(body.get("expectedText"), str) or not isinstance(body.get("expectedRevision"), str):
149
+ raise ValueError()
150
+ allowed = {"expectedText", "expectedRevision", "meeting", "removeRecordId"} if linking else {"expectedText", "expectedRevision"}
151
+ if set(body) - allowed:
152
+ raise ValueError()
153
+ except (ValueError, TypeError):
154
+ _bridge_error("invalid_work_request", "Bounded JSON with expectedText and expectedRevision is required.")
155
+ return
156
+ if linking:
157
+ 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
+ else:
159
+ ok = _bridge_write(lambda: set_work_stage(domain, args[1], args[2], expected_text=body["expectedText"], expected_revision=body["expectedRevision"]))
160
+ if ok is False:
161
+ _bridge_error("task_not_found", "Task not found in this domain.")
162
+ elif ok is not None:
163
+ print(json.dumps({"ok": True}))
164
+
165
+ def cmd_task_set_stage(args):
166
+ """Move a task between board stages. Clearing returns it to planning, which
167
+ is the unmarked default, so the marker is removed rather than written."""
168
+ from task_write import set_stage
169
+
170
+ domain = _bridge_domain(args)
171
+ if domain is None:
172
+ return
173
+ if len(args) < 3:
174
+ _bridge_error("argv_required", "usage: task-set-stage <domain> <id> planning|active|review|--clear")
175
+ return
176
+ # Validated HERE, not via except: _bridge_write catches ValueError itself and
177
+ # reports it as invalid_section, so a try/except around it never fires.
178
+ stage = args[2]
179
+ if stage not in ("--clear", "planning", "active", "review"):
180
+ _bridge_error("invalid_stage", f"invalid stage: {stage}")
181
+ return
182
+ ok = _bridge_write(lambda: set_stage(domain, task_id=args[1], stage=stage))
183
+ if ok is None:
184
+ return
185
+ if ok is False:
186
+ _bridge_error("task_not_found", f"no task {args[1]} in {domain}")
187
+ return
188
+ print(json.dumps({"ok": True}))
189
+
190
+ def cmd_task_set_done_when(args):
191
+ """Set or clear the finish line. Text arrives on STDIN so it needs no
192
+ quoting; an empty body clears it. A dispatch is refused while it is empty."""
193
+ from task_write import set_done_when
194
+
195
+ domain = _bridge_domain(args)
196
+ if domain is None:
197
+ return
198
+ if len(args) < 2:
199
+ _bridge_error("argv_required", "usage: task-set-done-when <domain> <id> (text on STDIN, empty clears)")
200
+ return
201
+ text = sys.stdin.read().strip()
202
+ if "\n" in text or "**" in text:
203
+ _bridge_error("invalid_done_when", "done_when must be a single line without ** markup")
204
+ return
205
+ ok = _bridge_write(lambda: set_done_when(domain, task_id=args[1], text=text))
206
+ if ok is None:
207
+ return
208
+ if ok is False:
209
+ _bridge_error("task_not_found", f"no task {args[1]} in {domain}")
210
+ return
211
+ print(json.dumps({"ok": True}))
212
+
213
+ def cmd_task_set_marker(args):
214
+ from task_write import set_marker
215
+
216
+ domain = _bridge_domain(args)
217
+ if domain is None:
218
+ return
219
+ if len(args) < 3:
220
+ _bridge_error("argv_required", "usage: task-set-marker <domain> <id> running|done:<N>|failed:<N>|failed|--clear")
221
+ return
222
+ marker = None if args[2] == "--clear" else args[2]
223
+ ok = _bridge_write(lambda: set_marker(domain, task_id=args[1], marker=marker))
224
+ if ok is None:
225
+ return
226
+ if ok is False:
227
+ _bridge_error("task_not_found", f"no task {args[1]} in {domain}")
228
+ return
229
+ print(json.dumps({"ok": True}))
230
+
231
+ def cmd_task_move(args):
232
+ from task_write import move
233
+
234
+ domain = _bridge_domain(args)
235
+ if domain is None:
236
+ return
237
+ if len(args) < 2:
238
+ _bridge_error("argv_required", "usage: task-move <domain> <id> --section <section>")
239
+ return
240
+ section = _bridge_flag(args, "--section")
241
+ if not section:
242
+ _bridge_error("section_required", "--section is required")
243
+ return
244
+ ok = _bridge_write(lambda: move(
245
+ domain, args[1], section, purge_before=_bridge_flag(args, "--purge-before")
246
+ ))
247
+ if ok is None:
248
+ return
249
+ if ok is False:
250
+ _bridge_error("task_not_found", f"no task {args[1]} in {domain}")
251
+ return
252
+ print(json.dumps({"ok": True}))
253
+
254
+ def cmd_task_check(args):
255
+ from task_write import check
256
+
257
+ domain = _bridge_domain(args)
258
+ if domain is None:
259
+ return
260
+ text = _bridge_flag(args, "--text")
261
+ task_id = None
262
+ if len(args) >= 2 and not args[1].startswith("-"):
263
+ task_id = args[1]
264
+ if not task_id and not text:
265
+ _bridge_error("argv_required", "usage: task-check <domain> <id>|--text <…> [--uncheck]")
266
+ return
267
+ status = _bridge_write(lambda: check(
268
+ domain, task_id, text=text, uncheck="--uncheck" in args
269
+ ))
270
+ if status is None:
271
+ return
272
+ if status != "ok":
273
+ _bridge_error(status, status.replace("_", " "))
274
+ return
275
+ print(json.dumps({"ok": True}))
@@ -0,0 +1,11 @@
1
+ import string
2
+ class TaskDeduplicator:
3
+ @staticmethod
4
+ def normalize(text: str) -> str:
5
+ """Normalize text for comparison."""
6
+ from task_rows import strip_markers
7
+ text, _ = strip_markers(text)
8
+ text = text.lower()
9
+ text = text.translate(str.maketrans('', '', string.punctuation))
10
+ text = ' '.join(text.split())
11
+ return text