@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,420 @@
1
+ #!/usr/bin/env python3
2
+ """Canonical tasks.md row parser (plan v19 §0.2 / §1)."""
3
+
4
+ from __future__ import annotations
5
+ from runtime_io import safe_read_text
6
+
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import re
11
+ import sys
12
+ from dataclasses import asdict, dataclass, field
13
+ from pathlib import Path
14
+ from typing import Iterator, Optional
15
+
16
+ SCRIPT_DIR = Path(__file__).parent.resolve()
17
+ OPERATIONS_DIR = SCRIPT_DIR.parent
18
+
19
+ # Domains are DISCOVERED, not baked in. This was a hardcoded four-tuple of one
20
+ # user's business units, and because every gate below tests membership in it, the
21
+ # Python layer refused any other domain outright — including domains the server
22
+ # had just resolved from the same folders. `cos_api_bridge.py task-rows work`
23
+ # answered `invalid_domain` while `quilt` returned 156 rows.
24
+ #
25
+ # The signal is `tasks.md`, matching discoveredTaskDomains() on the server side,
26
+ # so the two layers cannot disagree about which domains exist.
27
+ def full_domains() -> tuple[str, ...]:
28
+ root = Path(os.environ.get("COS_TASK_ROOT") or OPERATIONS_DIR)
29
+ try:
30
+ found = sorted(
31
+ d.name for d in root.iterdir()
32
+ if d.is_dir() and not d.name.startswith(".") and (d / "tasks.md").is_file()
33
+ )
34
+ except OSError:
35
+ found = []
36
+ # A tree we cannot read must not silently become "no domains"; the legacy
37
+ # four keep an existing install working exactly as before.
38
+ return tuple(found) if found else LEGACY_DOMAINS
39
+
40
+
41
+ LEGACY_DOMAINS = ("quilt", "personal", "hermit_crabs", "sprocket_rocket")
42
+
43
+ # Convenience aliases only. `SHORT_TO_FULL.get(name, name)` passes anything
44
+ # unknown through untouched, so these never gate which domains exist.
45
+ SHORT_TO_FULL = {
46
+ "sr": "sprocket_rocket",
47
+ "hc": "hermit_crabs",
48
+ }
49
+ FULL_TO_SHORT = {
50
+ "quilt": "quilt",
51
+ "personal": "personal",
52
+ "sprocket_rocket": "sr",
53
+ "hermit_crabs": "hc",
54
+ }
55
+
56
+ # ONE marker vocabulary, two uses. MARKER_RE strips a trailing marker blob off
57
+ # the end of a line; MARKER_ANYWHERE_RE finds a marker wherever it sits. The
58
+ # writers use the second to REFUSE user text that would be re-read as a marker.
59
+ # They are built from the same string so a new marker type cannot be taught to
60
+ # the reader and forgotten by the writer.
61
+ _MARKER_BODY = (
62
+ r"\[(?:run \d{4}-\d{2}-\d{2} \d{2}:\d{2}|agent running|"
63
+ r"agent #\d+ (?:done|failed)|agent failed|"
64
+ r"stage (?:planning|active|review))\]"
65
+ )
66
+ MARKER_RE = re.compile(r"\s*(?:" + _MARKER_BODY + r"\s*)+$")
67
+ MARKER_ANYWHERE_RE = re.compile(_MARKER_BODY)
68
+ # Workflow stage. An UNMARKED row is planning, so the 80 open rows need no
69
+ # migration. Lives in the line, not a sidecar: the task id is a hash of the
70
+ # description, so any id-keyed store orphans itself on the first text edit.
71
+ # NOTE: the trailing \s* sits INSIDE the repeat group. The writer joins markers
72
+ # with spaces (_rebuild_line), but the old regex allowed none between groups, so
73
+ # "[run ...] [agent running]" stripped only the last one and silently dropped the
74
+ # run time on every read. Pre-existing; stage makes that pairing the common case.
75
+ # STAGE_RE only reads a value out of a blob MARKER_RE has already validated, so
76
+ # its own alternation was a second, unreachable copy: mutating it changed nothing
77
+ # under test BECAUSE MARKER_RE's `$`-anchored match rejects "[stage bogus]" first.
78
+ # That is true of the READ path only. The WRITE path is gated separately by
79
+ # STAGES below (task_write.set_stage) and by cos_api_bridge's own literal tuple,
80
+ # so there are three stage vocabularies in this system, not one. Widening the set
81
+ # means editing MARKER_RE, STAGES, and the bridge tuple together.
82
+ STAGE_RE = re.compile(r"\[stage (\w+)\]")
83
+ STAGES = ("planning", "active", "review")
84
+ RUN_AT_RE = re.compile(r"\[run (\d{4}-\d{2}-\d{2} \d{2}:\d{2})\]")
85
+ AGENT_NUM_RE = re.compile(r"\[agent #(\d+) (done|failed)\]")
86
+ INBOX_RE = re.compile(r"^#{1,3}\s*INBOX\s*$", re.IGNORECASE)
87
+ TODAY_RE = re.compile(r"^#{1,3}\s*TODAY\s+(\d{4}-\d{2}-\d{2})\s*$", re.IGNORECASE)
88
+ DELEGATED_HEADING_RE = re.compile(r"^#{1,2}\s*Delegated\b", re.IGNORECASE)
89
+ BOLD_OWNER_RE = re.compile(r"^\*\*(?P<body>(?!Owner:)[^*]+)\*\*:\s*")
90
+ BRACKET_OWNER_RE = re.compile(r"^\[(?P<body>[^\]]+)\]\s+")
91
+ CHECK_RE = re.compile(r"^- \[([ xX~])\]\s*(.*)$")
92
+ PRIORITY_SECTIONS = frozenset(
93
+ {"inbox", "today", "urgent", "this_week", "backlog", "completed"}
94
+ )
95
+
96
+
97
+ @dataclass
98
+ class TaskRow:
99
+ ref: str
100
+ id: str
101
+ domain: str
102
+ description: str
103
+ priority: str
104
+ is_checked: bool
105
+ archived: bool
106
+ line_number: int
107
+ source: Optional[str]
108
+ owner: Optional[str]
109
+ delegated: bool
110
+ needs_review: bool
111
+ thread: None
112
+ run_at: Optional[str]
113
+ agent_state: Optional[str]
114
+ agent_no: Optional[int]
115
+ section: str
116
+ section_day: Optional[str]
117
+ stage: Optional[str] = None
118
+ done_when: Optional[str] = None
119
+ work_stage: str = "planned"
120
+ work_identity: str = ""
121
+ work_revision: str = ""
122
+ meeting_refs: list[dict] = field(default_factory=list)
123
+ work_metadata_error: Optional[str] = None
124
+
125
+
126
+ def domain_path(domain: str) -> Path:
127
+ root = Path(os.environ.get("COS_TASK_ROOT") or OPERATIONS_DIR)
128
+ return root / domain / "tasks.md"
129
+
130
+
131
+ def split_source(line: str) -> tuple[str, Optional[str]]:
132
+ if "**Source:**" not in line:
133
+ return line.strip(), None
134
+ left, right = line.split("**Source:**", 1)
135
+ return left.strip().rstrip("—").strip(), right.strip()
136
+
137
+
138
+ DONE_WHEN_LABEL = "**Done when:**"
139
+
140
+
141
+ def split_done_when(text):
142
+ """Peel a **Done when:** segment off a description or source part.
143
+
144
+ Same convention as **Source:** so tasks.md stays readable by hand, and the
145
+ finish line travels with the row when the text is edited.
146
+ """
147
+ if not text or DONE_WHEN_LABEL not in text:
148
+ return text, None
149
+ left, right = text.split(DONE_WHEN_LABEL, 1)
150
+ return left.strip().rstrip("\u2014").strip(), (right.strip() or None)
151
+
152
+
153
+ def strip_markers(text: str) -> tuple[str, dict]:
154
+ """Remove end-of-line agent/run markers. Returns (text, fields)."""
155
+ fields: dict = {"run_at": None, "agent_state": None, "agent_no": None, "stage": None}
156
+ if not text:
157
+ return text, fields
158
+ match = MARKER_RE.search(text)
159
+ if not match:
160
+ return text, fields
161
+ blob = match.group(0)
162
+ run = RUN_AT_RE.search(blob)
163
+ if run:
164
+ fields["run_at"] = run.group(1)
165
+ stage = STAGE_RE.search(blob)
166
+ if stage:
167
+ fields["stage"] = stage.group(1)
168
+ numbered = AGENT_NUM_RE.search(blob)
169
+ if numbered:
170
+ fields["agent_no"] = int(numbered.group(1))
171
+ fields["agent_state"] = numbered.group(2)
172
+ elif "[agent running]" in blob:
173
+ fields["agent_state"] = "running"
174
+ elif "[agent failed]" in blob:
175
+ fields["agent_state"] = "failed"
176
+ return text[: match.start()].rstrip(), fields
177
+
178
+
179
+ def _merge_marker_fields(primary: dict, secondary: dict) -> dict:
180
+ out = dict(primary)
181
+ for key, value in secondary.items():
182
+ if value is not None:
183
+ out[key] = value
184
+ return out
185
+
186
+
187
+ def classify_heading(stripped: str) -> tuple[Optional[str], Optional[str]]:
188
+ """Return (section, section_day) for a heading, else (None, None)."""
189
+ if not stripped.startswith("#"):
190
+ return None, None
191
+ inbox = INBOX_RE.match(stripped)
192
+ if inbox:
193
+ return "inbox", None
194
+ today = TODAY_RE.match(stripped)
195
+ if today:
196
+ return "today", today.group(1)
197
+ upper = stripped.upper()
198
+ if "🔴 URGENT" in stripped or "URGENT" in upper:
199
+ return "urgent", None
200
+ if "🟡 THIS WEEK" in stripped or "THIS WEEK" in upper:
201
+ return "this_week", None
202
+ if "🟢 BACKLOG" in stripped or "BACKLOG" in upper:
203
+ return "backlog", None
204
+ if "✅ COMPLETED" in stripped or "COMPLETED" in upper:
205
+ return "completed", None
206
+ return "other", None
207
+
208
+
209
+ def _heading_depth(stripped: str) -> int:
210
+ i = 0
211
+ while i < len(stripped) and stripped[i] == "#":
212
+ i += 1
213
+ return i
214
+
215
+
216
+ def parse_owner(text: str) -> tuple[Optional[str], str]:
217
+ bold = BOLD_OWNER_RE.match(text)
218
+ if bold:
219
+ return bold.group("body").strip(), text[bold.end():]
220
+ bracket = BRACKET_OWNER_RE.match(text)
221
+ if bracket:
222
+ return bracket.group("body").strip(), text[bracket.end():]
223
+ return None, text
224
+
225
+
226
+ def owner_tokens(owner: str) -> list[str]:
227
+ return [part.strip() for part in re.split(r"\s*\+\s*|/", owner) if part.strip()]
228
+
229
+
230
+ def owner_includes_miles(owner: Optional[str]) -> bool:
231
+ if not owner:
232
+ return False
233
+ for token in owner_tokens(owner):
234
+ head = token.split("(")[0].strip().split()[0].casefold()
235
+ if head == os.environ.get("COS_TASK_OPERATOR", "").casefold():
236
+ return True
237
+ return False
238
+
239
+
240
+ def ends_delegated_zone(stripped: str) -> bool:
241
+ if stripped.startswith("---"):
242
+ return True
243
+ return stripped.startswith("#") and _heading_depth(stripped) <= 2
244
+
245
+
246
+ def parse_path(filepath: Path, domain: str) -> list[TaskRow]:
247
+ # No membership gate here on purpose. The caller has already chosen the FILE;
248
+ # re-deciding whether its domain "exists" by scanning a root that may not be
249
+ # the one this path came from turns a valid parse into silence. Discovery
250
+ # belongs where a NAME is resolved to a path, which is parse_domain and the
251
+ # cmd_* entry points below.
252
+ if not filepath.exists():
253
+ return []
254
+
255
+ rows: list[TaskRow] = []
256
+ current_priority = "backlog"
257
+ current_section = "other"
258
+ current_day: Optional[str] = None
259
+ in_comment = False
260
+ delegated_zone = False
261
+ counter = 0
262
+
263
+ contents = safe_read_text(filepath)
264
+ for line_number, line in enumerate(contents.splitlines(), 1):
265
+ stripped = line.strip()
266
+ if "<!--" in stripped:
267
+ in_comment = True
268
+ if "-->" in stripped:
269
+ in_comment = False
270
+ continue
271
+ if in_comment:
272
+ continue
273
+
274
+ if ends_delegated_zone(stripped):
275
+ delegated_zone = False
276
+ if DELEGATED_HEADING_RE.match(stripped):
277
+ delegated_zone = True
278
+
279
+ heading, heading_day = classify_heading(stripped)
280
+ if heading is not None:
281
+ current_section = heading
282
+ if heading != "other":
283
+ current_priority = heading
284
+ current_day = heading_day
285
+ else:
286
+ current_day = None
287
+ continue
288
+
289
+ checked = CHECK_RE.match(line)
290
+ if not checked:
291
+ continue
292
+
293
+ mark, raw = checked.group(1), checked.group(2)
294
+ archived = mark == "~"
295
+ is_checked = mark.lower() == "x"
296
+ description, source = split_source(raw)
297
+ description, desc_markers = strip_markers(description)
298
+ if source is not None:
299
+ source, src_markers = strip_markers(source)
300
+ markers = _merge_marker_fields(desc_markers, src_markers)
301
+ else:
302
+ markers = desc_markers
303
+
304
+ description, dw_desc = split_done_when(description)
305
+ if source is not None:
306
+ source, dw_src = split_done_when(source)
307
+ else:
308
+ dw_src = None
309
+ done_when = dw_desc or dw_src
310
+
311
+ needs_review = "[REVIEW]" in description
312
+ if needs_review:
313
+ description = description.replace("[REVIEW]", "").strip()
314
+
315
+ owner, description = parse_owner(description)
316
+ description = description.strip()
317
+ delegated = delegated_zone or (
318
+ owner is not None and not owner_includes_miles(owner)
319
+ )
320
+ priority = "completed" if is_checked else (
321
+ current_section if current_section in PRIORITY_SECTIONS
322
+ else current_priority
323
+ )
324
+ counter += 1
325
+ rows.append(TaskRow(
326
+ ref=f"{domain}-{counter}",
327
+ id="",
328
+ domain=domain,
329
+ description=description,
330
+ priority=priority,
331
+ is_checked=is_checked,
332
+ archived=archived,
333
+ line_number=line_number,
334
+ source=source,
335
+ owner=owner,
336
+ delegated=delegated,
337
+ needs_review=needs_review,
338
+ thread=None,
339
+ run_at=markers["run_at"],
340
+ agent_state=markers["agent_state"],
341
+ agent_no=markers["agent_no"],
342
+ section=current_section,
343
+ section_day=current_day if current_section == "today" else None,
344
+ stage=markers["stage"],
345
+ done_when=done_when,
346
+ ))
347
+
348
+ _assign_ids(filepath, domain, rows)
349
+ from task_work_metadata import decode_source, phase_for
350
+ identities: dict[str, list[TaskRow]] = {}
351
+ for row in rows:
352
+ row.source, metadata, row.work_metadata_error = decode_source(row.source)
353
+ row.work_stage = phase_for(row.stage, row.is_checked, metadata)
354
+ row.work_identity = metadata.get("workIdentity", row.id)
355
+ row.work_revision = hashlib.sha256((domain + "\0" + row.id + "\0" + contents).encode()).hexdigest()
356
+ row.meeting_refs = metadata.get("meetingRefs", [])
357
+ identities.setdefault(row.work_identity, []).append(row)
358
+ for matches in identities.values():
359
+ if len(matches) > 1:
360
+ for row in matches:
361
+ row.work_metadata_error = "Duplicate Work identity; repair the copied task metadata before linking or moving it."
362
+ row.work_identity = row.id
363
+ return rows
364
+
365
+
366
+ def _assign_ids(_filepath: Path, domain: str, rows: list[TaskRow]) -> None:
367
+ from task_dedup import TaskDeduplicator
368
+
369
+ seen: dict[str, int] = {}
370
+ for row in rows:
371
+ norm = TaskDeduplicator.normalize(row.description)
372
+ ordinal = seen.get(norm, 0)
373
+ seen[norm] = ordinal + 1
374
+ row.id = hashlib.sha256(
375
+ f"{domain}/tasks.md|{norm}|{ordinal}".encode()
376
+ ).hexdigest()[:12]
377
+
378
+
379
+ def parse_domain(domain: str) -> list[TaskRow]:
380
+ if domain not in full_domains():
381
+ return []
382
+ return parse_path(domain_path(domain), domain)
383
+
384
+
385
+ def iter_domain_rows() -> Iterator[tuple[str, list[TaskRow]]]:
386
+ for domain in full_domains():
387
+ yield domain, parse_domain(domain)
388
+
389
+
390
+ def cmd_task_rows(domain: str, day: str) -> list[dict]:
391
+ del day # server column math; required on the wire
392
+ return [asdict(row) for row in parse_domain(domain)]
393
+
394
+
395
+ def _cli(argv: list[str]) -> int:
396
+ if len(argv) < 2 or argv[0] in {"-h", "--help"}:
397
+ print("usage: task_rows.py <domain> --day YYYY-MM-DD", file=sys.stderr)
398
+ return 2
399
+ domain = argv[0]
400
+ day = None
401
+ if "--day" in argv:
402
+ idx = argv.index("--day")
403
+ if idx + 1 < len(argv):
404
+ day = argv[idx + 1]
405
+ if domain not in full_domains():
406
+ print(json.dumps({
407
+ "error": {"code": "invalid_domain", "message": f"full domain required, got {domain!r}"}
408
+ }))
409
+ return 0
410
+ if not day or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", day):
411
+ print(json.dumps({
412
+ "error": {"code": "day_required", "message": "--day YYYY-MM-DD is required"}
413
+ }))
414
+ return 0
415
+ print(json.dumps(cmd_task_rows(domain, day)))
416
+ return 0
417
+
418
+
419
+ if __name__ == "__main__":
420
+ raise SystemExit(_cli(sys.argv[1:]))
@@ -0,0 +1,107 @@
1
+ """Bounded Work metadata inside Source; legacy task marker grammar stays unchanged."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import json
6
+ import re
7
+
8
+ PHASES = ("mentioned", "planned", "draft", "built", "qa")
9
+ LEGACY_STAGE = {"mentioned": "planning", "planned": "planning", "draft": "active", "built": "active", "qa": "review"}
10
+ DEFAULT_PHASE = {"planning": "planned", "active": "draft", "review": "qa"}
11
+ PROTOCOL = "cos-work://"
12
+ MAX_BYTES = 16_384
13
+ MAX_REFS = 8
14
+ LINK = re.compile(r"\[Work details\]\(cos-work://v1/([A-Za-z0-9_-]+)\)")
15
+
16
+
17
+ class WorkMetadataError(ValueError):
18
+ def __init__(self, code: str, message: str):
19
+ super().__init__(message)
20
+ self.code = code
21
+
22
+
23
+ def reject_reserved(value: str) -> None:
24
+ if PROTOCOL in value.lower():
25
+ raise WorkMetadataError("reserved_work_metadata", "Work metadata links may only be written through Work controls.")
26
+
27
+
28
+ def validate_meeting(raw) -> dict:
29
+ if not isinstance(raw, dict) or set(raw) != {"recordId", "domain", "month", "filename", "title"}:
30
+ raise WorkMetadataError("invalid_meeting_ref", "A complete saved meeting reference is required.")
31
+ limits = {"recordId": 512, "domain": 64, "month": 7, "filename": 255, "title": 300}
32
+ if any(not isinstance(raw.get(k), str) or not raw[k].strip() or len(raw[k]) > n or any(ord(c) < 32 or ord(c) == 127 for c in raw[k]) for k, n in limits.items()):
33
+ raise WorkMetadataError("invalid_meeting_ref", "Meeting reference fields are invalid or too long.")
34
+ domain = raw["domain"]
35
+ if domain != domain.strip() or domain.startswith('.') or '/' in domain or '\\' in domain or not re.fullmatch(r"\d{4}-(0[1-9]|1[0-2])", raw["month"]):
36
+ raise WorkMetadataError("invalid_meeting_ref", "Meeting domain or month is invalid.")
37
+ if "/" in raw["filename"] or "\\" in raw["filename"] or not raw["filename"].endswith(".md") or raw["filename"] in (".md", "..md"):
38
+ raise WorkMetadataError("invalid_meeting_ref", "Meeting filename must be a saved Markdown basename.")
39
+ return dict(raw)
40
+
41
+
42
+ def validate_metadata(raw) -> dict:
43
+ if not isinstance(raw, dict) or set(raw) - {"workflowPhase", "workIdentity", "meetingRefs"}:
44
+ raise ValueError("Unknown metadata fields")
45
+ if "workflowPhase" in raw and raw["workflowPhase"] not in PHASES:
46
+ raise ValueError("Invalid workflow phase")
47
+ if "workIdentity" in raw and (not isinstance(raw["workIdentity"], str) or not re.fullmatch(r"[a-f0-9]{12}", raw["workIdentity"])):
48
+ raise ValueError("Invalid work identity")
49
+ refs = raw.get("meetingRefs", [])
50
+ if not isinstance(refs, list) or len(refs) > MAX_REFS:
51
+ raise ValueError("Too many meeting references")
52
+ cleaned = [validate_meeting(ref) for ref in refs]
53
+ if len({ref["recordId"] for ref in cleaned}) != len(cleaned):
54
+ raise ValueError("Duplicate meeting references")
55
+ return {**raw, **({"meetingRefs": cleaned} if "meetingRefs" in raw else {})}
56
+
57
+
58
+ def _unique_object(pairs):
59
+ out = {}
60
+ for key, value in pairs:
61
+ if key in out:
62
+ raise ValueError("Duplicate JSON key")
63
+ out[key] = value
64
+ return out
65
+
66
+
67
+ def decode_source(source: str | None) -> tuple[str | None, dict, str | None]:
68
+ """Invalid metadata stays visible/preserved and never contributes authority."""
69
+ if not source or PROTOCOL not in source.lower():
70
+ return source, {}, None
71
+ try:
72
+ matches = list(LINK.finditer(source))
73
+ if len(matches) != 1 or source.lower().count(PROTOCOL) != 1:
74
+ raise ValueError("Missing, duplicate, or unsupported metadata link")
75
+ token = matches[0].group(1)
76
+ if len(token) > (MAX_BYTES * 4 // 3 + 4):
77
+ raise ValueError("Metadata too large")
78
+ data = base64.b64decode(token + '=' * (-len(token) % 4), altchars=b'-_', validate=True)
79
+ if len(data) > MAX_BYTES or base64.urlsafe_b64encode(data).decode().rstrip('=') != token:
80
+ raise ValueError("Noncanonical encoding")
81
+ metadata = validate_metadata(json.loads(data, object_pairs_hook=_unique_object))
82
+ clean = (source[:matches[0].start()] + source[matches[0].end():]).strip()
83
+ return clean or None, metadata, None
84
+ except (ValueError, TypeError, UnicodeError):
85
+ return source, {}, "Invalid Work metadata; preserved without applying its phase or meeting links."
86
+
87
+
88
+ def write_source(source: str | None, metadata: dict) -> str:
89
+ clean, _, error = decode_source(source)
90
+ if error:
91
+ raise WorkMetadataError("work_metadata_invalid", error)
92
+ try:
93
+ data = json.dumps(validate_metadata(metadata), ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode()
94
+ if len(data) > MAX_BYTES:
95
+ raise ValueError("Metadata too large")
96
+ except (ValueError, TypeError) as exc:
97
+ raise WorkMetadataError("work_metadata_invalid", "Work metadata exceeds its schema or size limits.") from exc
98
+ token = base64.urlsafe_b64encode(data).decode().rstrip('=')
99
+ return ((clean + ' ') if clean else '') + '[Work details](cos-work://v1/' + token + ')'
100
+
101
+
102
+ def phase_for(stage: str | None, checked: bool, metadata: dict) -> str:
103
+ if checked:
104
+ return 'complete'
105
+ legacy = stage or 'planning'
106
+ phase = metadata.get('workflowPhase')
107
+ return phase if LEGACY_STAGE.get(phase) == legacy else DEFAULT_PHASE.get(legacy, 'planned')