@gotcos/glasses-server 6.55.0 → 6.56.1
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.
- package/CHANGELOG.md +34 -0
- package/bin/control2-foundation.cjs +12 -0
- package/bin/task-runtime-check.cjs +65 -0
- package/docs/control2-foundation.md +79 -0
- package/docs/control2-sandbox.md +47 -0
- package/docs/control2-task-runtime.md +68 -0
- package/docs/work-reviews.md +25 -0
- package/docs/work-task-runtime.md +82 -0
- package/package.json +18 -4
- package/server/index.ts +8 -0
- package/server/lib/control2-draft.ts +118 -0
- package/server/lib/control2-foundation.ts +164 -0
- package/server/lib/control2-sandbox.ts +192 -0
- package/server/lib/cos-operations-meetings.ts +7 -0
- package/server/lib/provider-binary.ts +6 -2
- package/server/lib/task-bridge.ts +85 -0
- package/server/lib/task-store.ts +75 -10
- package/server/lib/work-activity.ts +106 -0
- package/server/lib/work-review-backend.ts +28 -0
- package/server/lib/work-review-runtime.ts +203 -0
- package/server/lib/work-review-store.ts +102 -0
- package/server/routes/control2-foundation.ts +56 -0
- package/server/routes/health.ts +4 -3
- package/server/routes/meetings.ts +24 -59
- package/server/routes/work-board.ts +79 -0
- package/server/routes/work-reviews.ts +24 -0
- package/server/scripts/control2-draft-canary.ts +18 -0
- package/server/scripts/control2-foundation-lab.ts +45 -0
- package/server/scripts/control2-sandbox-canary.ts +4 -0
- package/task-runtime/LICENSE +21 -0
- package/task-runtime/manifest.json +37 -0
- package/task-runtime/task_runtime.py +380 -0
- package/work-task-runtime/cos_atomic.py +90 -0
- package/work-task-runtime/manifest.json +33 -0
- package/work-task-runtime/runtime_io.py +27 -0
- package/work-task-runtime/task_bridge.py +87 -0
- package/work-task-runtime/task_checkout.py +380 -0
- package/work-task-runtime/task_commands.py +275 -0
- package/work-task-runtime/task_dedup.py +11 -0
- package/work-task-runtime/task_rows.py +420 -0
- package/work-task-runtime/task_work_metadata.py +107 -0
- 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')
|