@nidvue/teamwork 0.0.0-stage → 1.1.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 (70) hide show
  1. package/CHANGELOG.md +329 -0
  2. package/LICENSE +661 -0
  3. package/NOTICE +16 -0
  4. package/README.md +462 -2
  5. package/SECURITY.md +64 -0
  6. package/package.json +71 -4
  7. package/prompts/teamwork-pulse.md +12 -0
  8. package/prompts/teamwork-review.md +16 -0
  9. package/skills/teamwork/AGENTS.md +462 -0
  10. package/skills/teamwork/SKILL.md +795 -0
  11. package/skills/teamwork/references/Adapters.md +112 -0
  12. package/skills/teamwork/references/Audit.md +120 -0
  13. package/skills/teamwork/references/Concurrency.md +160 -0
  14. package/skills/teamwork/references/Dissent.md +85 -0
  15. package/skills/teamwork/references/Docs.md +74 -0
  16. package/skills/teamwork/references/Engineering.md +410 -0
  17. package/skills/teamwork/references/Evidence.md +140 -0
  18. package/skills/teamwork/references/Remotes.md +144 -0
  19. package/skills/teamwork/references/Roles.md +201 -0
  20. package/skills/teamwork/references/State.md +64 -0
  21. package/skills/teamwork/references/Testing.md +144 -0
  22. package/skills/teamwork/roles/architect.md +153 -0
  23. package/skills/teamwork/roles/ceo.md +218 -0
  24. package/skills/teamwork/roles/cfo.md +150 -0
  25. package/skills/teamwork/roles/coo.md +147 -0
  26. package/skills/teamwork/roles/cto.md +162 -0
  27. package/skills/teamwork/roles/data-engineer.md +120 -0
  28. package/skills/teamwork/roles/designer.md +349 -0
  29. package/skills/teamwork/roles/developer.md +166 -0
  30. package/skills/teamwork/roles/product-manager.md +151 -0
  31. package/skills/teamwork/roles/project-manager.md +159 -0
  32. package/skills/teamwork/roles/release-engineer.md +245 -0
  33. package/skills/teamwork/roles/reviewer.md +254 -0
  34. package/skills/teamwork/roles/security-auditor.md +157 -0
  35. package/skills/teamwork/roles/technical-writer.md +121 -0
  36. package/skills/teamwork/roles/tester.md +180 -0
  37. package/skills/teamwork/scripts/build_pi_package.py +200 -0
  38. package/skills/teamwork/scripts/privacy_scan.py +1185 -0
  39. package/skills/teamwork/scripts/sync_skill.py +108 -0
  40. package/skills/teamwork/scripts/teamwork.py +28 -0
  41. package/skills/teamwork/scripts/verify_release.py +502 -0
  42. package/skills/teamwork/src/teamwork/__init__.py +18 -0
  43. package/skills/teamwork/src/teamwork/__main__.py +10 -0
  44. package/skills/teamwork/src/teamwork/bundle.py +157 -0
  45. package/skills/teamwork/src/teamwork/cli.py +16 -0
  46. package/skills/teamwork/src/teamwork/config.py +396 -0
  47. package/skills/teamwork/src/teamwork/engine.py +10119 -0
  48. package/skills/teamwork/src/teamwork/gitutil.py +172 -0
  49. package/skills/teamwork/src/teamwork/imaging.py +124 -0
  50. package/skills/teamwork/src/teamwork/io.py +144 -0
  51. package/skills/teamwork/src/teamwork/layout.py +253 -0
  52. package/skills/teamwork/src/teamwork/names.py +191 -0
  53. package/skills/teamwork/src/teamwork/portable.py +79 -0
  54. package/skills/teamwork/src/teamwork/records.py +1220 -0
  55. package/skills/teamwork/src/teamwork/resources.py +120 -0
  56. package/skills/teamwork/src/teamwork/version.py +13 -0
  57. package/skills/teamwork/templates/AGENTS.md +460 -0
  58. package/skills/teamwork/templates/Docs/Architecture.md +40 -0
  59. package/skills/teamwork/templates/Docs/Archive.md +8 -0
  60. package/skills/teamwork/templates/Docs/Board.md +28 -0
  61. package/skills/teamwork/templates/Docs/Costs.md +30 -0
  62. package/skills/teamwork/templates/Docs/Decisions.md +26 -0
  63. package/skills/teamwork/templates/Docs/Design.md +106 -0
  64. package/skills/teamwork/templates/Docs/Plan.md +24 -0
  65. package/skills/teamwork/templates/Docs/Project.md +40 -0
  66. package/skills/teamwork/templates/Docs/Release.md +43 -0
  67. package/skills/teamwork/templates/Docs/Research.md +36 -0
  68. package/skills/teamwork/templates/Docs/Risks.md +18 -0
  69. package/skills/teamwork/templates/TeamMap.md +100 -0
  70. package/skills/teamwork-pi/SKILL.md +77 -0
@@ -0,0 +1,1220 @@
1
+ """The plain-markdown record layer: parse, render and find records.
2
+
3
+ The files are the trust boundary for every evidence gate, so parsing stops at
4
+ the body boundary and every scalar is written on one line.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from datetime import date
11
+ from pathlib import Path
12
+
13
+ from .config import (
14
+ ALL_LAYERS_SENTINEL,
15
+ DEFAULT_BASE,
16
+ DEFAULT_REQUIRED_LAYERS,
17
+ F_GLOBAL,
18
+ F_GLOBAL_ARCHIVE,
19
+ F_LONG,
20
+ F_MEM_ARCHIVE,
21
+ F_SHORT,
22
+ F_TASKS,
23
+ LONG_CAP,
24
+ MAX_ROUNDS,
25
+ PRIORITY_ORDER,
26
+ STALE_DAYS,
27
+ STATUSES,
28
+ TEST_LAYERS,
29
+ )
30
+ from .io import atomic_write, die
31
+ from .layout import (
32
+ _rel,
33
+ criteria_path,
34
+ link_fields,
35
+ reviews_path,
36
+ role_dir,
37
+ team_root_from,
38
+ testplan_path,
39
+ tests_path,
40
+ )
41
+ from .names import body_text, one_line
42
+
43
+ REVIEWS_HEADER = (
44
+ "# Reviews - PRs awaiting the Reviewer\n\n"
45
+ "> One entry per pull request. A branch may only be merged into `main` by the\n"
46
+ "> Reviewer, after its review passed. Manage with `teamwork.py pr ...`.\n\n"
47
+ "Related: [team map](TeamMap.md) | [disputes](Disputes.md)\n"
48
+ )
49
+
50
+ SKILLS_HEADER = (
51
+ "# Team skills - extracted, shared flows\n\n"
52
+ "> A skill is created when a flow is repetitive, widely reused and must stay\n"
53
+ "> consistent across instances of a role. The file lives in the project\n"
54
+ "> (`.agents/skills/<name>/SKILL.md`) so every agent and worktree sees it.\n"
55
+ "> Manage with `teamwork.py skill ...`.\n\n"
56
+ "Related: [team map](TeamMap.md) | [tests](Tests.md) | [criteria](Criteria.md) | "
57
+ "[reviews](Reviews.md)\n"
58
+ )
59
+
60
+ SKILL_NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
61
+
62
+ DISPUTE_HEADER = (
63
+ "# DISPUTES - design/decision challenges\n\n"
64
+ "> Every non-trivial decision needs an evidence chain. When you find a design\n"
65
+ "> or decision unreasonable, open a dispute here with counter-evidence.\n"
66
+ "> Max {rounds} rounds between the two agents; after that the CEO + CTO + CFO\n"
67
+ "> tribunal rules and the ruling is binding (record it in docs/Team/Decisions.md).\n"
68
+ )
69
+
70
+ DISPUTE_RE = re.compile(r"^##\s+(D-\d+)\s*(?:·|:)?\s*(.*)$")
71
+
72
+ ROUND_RE = re.compile(r"^-\s+r(\d+)\s+(\S+)\s+(\S+):\s*(.*)$")
73
+
74
+ ROLE_LINKS = (
75
+ "Related: [task queue]({tasks}) | [short-term]({short}) | "
76
+ "[long-term]({long}) | [memory archive]({mem_archive}) | "
77
+ "[global memory]({global_mem}) | [team map]({map}) | "
78
+ "[project]({project}) | [plan]({plan}) | "
79
+ "[architecture]({architecture}) | [archive ledger]({archive})"
80
+ )
81
+
82
+ AGENT_LINKS = (
83
+ "Related: [role charter]({role}) | [shared queue]({tasks}) | "
84
+ "[shared short-term]({short}) | [shared long-term]({long}) | "
85
+ "[shared memory archive]({mem_archive}) | "
86
+ "[worklog]({worklog}) | [inbox]({inbox}) | "
87
+ "[global memory]({global_mem}) | [team map]({map}) | "
88
+ "[project]({project}) | [plan]({plan})"
89
+ )
90
+
91
+ TASK_RE = re.compile(r"^##\s+(T-\d+)\s*(?:·|:)?\s*(.*)$")
92
+
93
+ ENTRY_RE = re.compile(r"^##\s+(M-\d+)\s*(?:·|:)?\s*(.*)$")
94
+
95
+ META_RE = re.compile(r"^-\s+([a-z_]+):\s*(.*)$")
96
+
97
+ TASK_HEADER = (
98
+ "# Task queue - {owner} (shared by every {owner} instance)\n\n"
99
+ "> Priority ordered (P0 highest). `task next <instance>` atomically claims the\n"
100
+ "> highest-priority unblocked task and records who is on it, so two instances\n"
101
+ "> never pick the same work. Lifecycle: `doing -> selfcheck -> review -> done`;\n"
102
+ "> a passed review is archived to docs/Team/Archive.md.\n\n" + ROLE_LINKS + "\n"
103
+ )
104
+
105
+ HEADERS = {
106
+ F_SHORT: (
107
+ "# Short-term memory - {owner} (shared by every {owner} instance)\n\n"
108
+ "> Working memory. Loaded at task start, updated continuously. Entries not\n"
109
+ "> used for {stale} days consolidate into long-term memory automatically.\n\n"
110
+ + ROLE_LINKS
111
+ + "\n"
112
+ ),
113
+ F_LONG: (
114
+ "# Long-term memory - {owner} (shared by every {owner} instance)\n\n"
115
+ "> Never loaded automatically. Query with "
116
+ '`teamwork.py recall "<query>" --agent {owner}`.\n'
117
+ "> Capped at {cap} entries; the least important overflow is archived.\n\n"
118
+ + ROLE_LINKS
119
+ + "\n"
120
+ ),
121
+ F_MEM_ARCHIVE: (
122
+ "# Archived memory - {owner} (shared)\n\n"
123
+ "> Overflow evicted from long-term memory. Searchable, never loaded.\n\n"
124
+ + ROLE_LINKS
125
+ + "\n"
126
+ ),
127
+ F_GLOBAL: (
128
+ "# Global memory (CEO-owned)\n\n"
129
+ "> Shared facts, interfaces and conventions for every agent. Read this\n"
130
+ "> before asking a question another agent may have already answered.\n"
131
+ "> Only the CEO writes here; propose additions through the CEO.\n\n"
132
+ "Related: [disputes]({disputes}) | [team map]({map}) | "
133
+ "[project]({project}) | [decisions]({decisions}) | "
134
+ "[archive ledger]({archive})\n"
135
+ ),
136
+ F_GLOBAL_ARCHIVE: (
137
+ "# Global memory archive\n\n"
138
+ "> Overflow evicted from global memory. Searchable, never loaded.\n\n"
139
+ "Related: [global memory]({global_mem}) | [team map]({map}) | "
140
+ "[project]({project}) | [archive ledger]({archive})\n"
141
+ ),
142
+ F_TASKS: TASK_HEADER,
143
+ }
144
+
145
+
146
+ def new_pr(title: str, **kw) -> dict:
147
+ pr = {
148
+ "id": "",
149
+ "title": one_line(title),
150
+ "status": "open",
151
+ "task": "",
152
+ "role": "",
153
+ "author": "",
154
+ "branch": "",
155
+ "base": DEFAULT_BASE,
156
+ "opened": today_iso(),
157
+ "updated": today_iso(),
158
+ "reviewer": "",
159
+ "merge_commit": "",
160
+ "summary": "",
161
+ "notes": [],
162
+ }
163
+ pr.update({k: one_line(v) if k != "notes" else v for k, v in kw.items() if v})
164
+ return pr
165
+
166
+
167
+ def parse_reviews(path: Path) -> list:
168
+ if not path.exists():
169
+ return []
170
+ out, cur, section = [], None, "meta"
171
+ for raw in path.read_text(encoding="utf-8").splitlines():
172
+ m = re.match(r"^##\s+(P-\d+)\s*(?:·|:)?\s*(.*)$", raw)
173
+ if m:
174
+ if cur:
175
+ out.append(cur)
176
+ cur = new_pr(m.group(2))
177
+ cur["id"] = m.group(1)
178
+ section = "meta"
179
+ continue
180
+ if cur is None:
181
+ continue
182
+ if raw.startswith("### "):
183
+ section = raw[4:].strip().lower()
184
+ continue
185
+ if section == "notes":
186
+ if raw.startswith("- "):
187
+ cur["notes"].append(raw[2:].strip())
188
+ continue
189
+ mm = META_RE.match(raw)
190
+ if mm:
191
+ field, value = mm.group(1), mm.group(2).strip()
192
+ if field in cur and field != "notes":
193
+ cur[field] = value
194
+ if cur:
195
+ out.append(cur)
196
+ return out
197
+
198
+
199
+ def render_reviews(prs: list) -> str:
200
+ assign_ids(prs, "P-")
201
+ out = [REVIEWS_HEADER, ""]
202
+ for pr in sorted(prs, key=lambda x: x["id"]):
203
+ out.append(f"## {pr['id']} · {one_line(pr['title']) or 'PR'}")
204
+ for field in (
205
+ "status",
206
+ "task",
207
+ "role",
208
+ "author",
209
+ "branch",
210
+ "base",
211
+ "opened",
212
+ "updated",
213
+ "reviewer",
214
+ "merge_commit",
215
+ ):
216
+ out.append(f"- {field}: {one_line(pr.get(field, ''))}")
217
+ out.append("")
218
+ if pr["summary"]:
219
+ out.append(f"- summary: {one_line(pr['summary'])}")
220
+ out.append("")
221
+ out.append("### notes")
222
+ for note in pr["notes"]:
223
+ out.append(f"- {one_line(note)}")
224
+ out.append("")
225
+ return "\n".join(out).rstrip() + "\n"
226
+
227
+
228
+ def write_reviews(path: Path, prs: list) -> None:
229
+ atomic_write(path, render_reviews(prs))
230
+
231
+
232
+ def _branch_matches(pr: dict, branch: str | None) -> bool:
233
+ return not branch or (pr.get("branch") or "") == branch
234
+
235
+
236
+ def find_pr(prs: list, handle: str, branch: str | None = None):
237
+ """Index of the PR named by ``handle`` (id, task or title).
238
+
239
+ ``branch`` disambiguates a task that has more than one PR record - a task
240
+ reworked after a merge keeps the old PR and gains a new branch, so matching
241
+ on the task id alone could return the record of the branch that is already
242
+ in the base. When it is given, only a record on that branch matches.
243
+ """
244
+ want = (handle or "").strip().upper()
245
+ for i, pr in enumerate(prs):
246
+ if pr["id"].upper() == want and _branch_matches(pr, branch):
247
+ return i
248
+ for i, pr in enumerate(prs):
249
+ if pr["task"].upper() == want and _branch_matches(pr, branch):
250
+ return i
251
+ for i, pr in enumerate(prs):
252
+ if want and want.lower() in pr["title"].lower() and _branch_matches(pr, branch):
253
+ return i
254
+ return None
255
+
256
+
257
+ def find_pr_exact(prs: list, handle: str, branch: str | None = None):
258
+ """Exact id/task match only: a merge must never hit a fuzzy title match.
259
+
260
+ ``branch`` narrows the match the same way ``find_pr`` does, so a merge can
261
+ never select a PR record belonging to a different branch of the same task.
262
+ """
263
+ want = (handle or "").strip().upper()
264
+ for i, pr in enumerate(prs):
265
+ if pr["id"].upper() == want and _branch_matches(pr, branch):
266
+ return i
267
+ for i, pr in enumerate(prs):
268
+ if pr["task"].upper() == want and _branch_matches(pr, branch):
269
+ return i
270
+ return None
271
+
272
+
273
+ def criteria_header(path: Path) -> str:
274
+ f = link_fields(path, "ceo")
275
+ return (
276
+ "# Criteria - acceptance criteria with verification evidence\n\n"
277
+ "> Machine-checkable definition of done. `loop` refuses to STOP while a\n"
278
+ "> criterion is unverified. The CEO writes them from the user's prompt; the\n"
279
+ "> verifier must not come from the owning role.\n\n"
280
+ f"Related: [project]({f['project']}) | [plan]({f['plan']}) | "
281
+ f"[archive ledger]({f['archive']}) | [team map]({f['map']}) | "
282
+ f"[reviews]({_rel(path, reviews_path(team_root_from(path)))}) | "
283
+ f"[disputes]({f['disputes']})\n"
284
+ )
285
+
286
+
287
+ def new_criterion(cid: str, text: str, owner: str = "", evidence: str = "") -> dict:
288
+ return {
289
+ "id": cid.strip().upper(),
290
+ "text": one_line(text),
291
+ "owner": one_line(owner),
292
+ "status": "open",
293
+ "evidence": one_line(evidence),
294
+ "verified_by": "",
295
+ "verified": "",
296
+ "rev": "",
297
+ }
298
+
299
+
300
+ def parse_criteria(path: Path) -> list:
301
+ if not path.exists():
302
+ return []
303
+ out, cur = [], None
304
+ for raw in path.read_text(encoding="utf-8").splitlines():
305
+ m = re.match(r"^##\s+(AC-[A-Za-z0-9._-]+)\s*(?:·|:)?\s*(.*)$", raw)
306
+ if m:
307
+ if cur:
308
+ out.append(cur)
309
+ cur = new_criterion(m.group(1), m.group(2))
310
+ continue
311
+ if cur is None:
312
+ continue
313
+ mm = META_RE.match(raw)
314
+ if mm:
315
+ field, value = mm.group(1), mm.group(2).strip()
316
+ if field in cur:
317
+ cur[field] = value
318
+ if cur:
319
+ out.append(cur)
320
+ return out
321
+
322
+
323
+ def render_criteria(items: list, path: Path | None = None) -> str:
324
+ header = (
325
+ criteria_header(path)
326
+ if path is not None
327
+ else "# Criteria - acceptance criteria with verification evidence\n"
328
+ )
329
+ out = [header, ""]
330
+ for c in items:
331
+ out.append(f"## {c['id']} · {one_line(c['text']) or 'criterion'}")
332
+ for field in ("status", "owner", "verified_by", "verified", "evidence", "rev"):
333
+ out.append(f"- {field}: {one_line(c.get(field, ''))}")
334
+ out.append("")
335
+ return "\n".join(out).rstrip() + "\n"
336
+
337
+
338
+ def write_criteria(path: Path, items: list) -> None:
339
+ atomic_write(path, render_criteria(items, path))
340
+
341
+
342
+ def find_criterion(items: list, handle: str):
343
+ want = (handle or "").strip().upper()
344
+ for i, c in enumerate(items):
345
+ if c["id"].upper() == want:
346
+ return i
347
+ for i, c in enumerate(items):
348
+ if want and want in c["text"].upper():
349
+ return i
350
+ return None
351
+
352
+
353
+ def new_test_record(
354
+ layer: str, area: str, owner: str = "", command: str = "", task: str = ""
355
+ ) -> dict:
356
+ return {
357
+ "id": "",
358
+ "layer": one_line(layer).lower(),
359
+ "area": one_line(area).lower(),
360
+ "owner": one_line(owner),
361
+ "command": one_line(command),
362
+ "task": one_line(task),
363
+ "status": "pending",
364
+ "evidence": "",
365
+ "baseline": "",
366
+ "artifacts": "",
367
+ "replay": "",
368
+ "rev": "",
369
+ "forced": "",
370
+ "updated": today_iso(),
371
+ "by": "",
372
+ }
373
+
374
+
375
+ def parse_tests(path: Path) -> list:
376
+ if not path.exists():
377
+ return []
378
+ out, cur = [], None
379
+ for raw in path.read_text(encoding="utf-8").splitlines():
380
+ m = re.match(r"^##\s+(TS-\d+)\s*(?:·|:)?\s*(.*)$", raw)
381
+ if m:
382
+ if cur:
383
+ out.append(cur)
384
+ title = m.group(2)
385
+ parts = [p.strip() for p in title.split("·")]
386
+ cur = new_test_record(parts[0] if parts else "", parts[1] if len(parts) > 1 else "")
387
+ cur["id"] = m.group(1)
388
+ continue
389
+ if cur is None:
390
+ continue
391
+ mm = META_RE.match(raw)
392
+ if mm:
393
+ field, value = mm.group(1), mm.group(2).strip()
394
+ if field in cur:
395
+ cur[field] = value
396
+ if cur:
397
+ out.append(cur)
398
+ return out
399
+
400
+
401
+ def render_tests(records: list, path: Path | None = None) -> str:
402
+ assign_ids(records, "TS-")
403
+ if path is not None:
404
+ f = link_fields(path, "ceo")
405
+ header = (
406
+ "# Tests - one record per test layer and area\n\n"
407
+ "> A (layer, area) pair appears once: that is what prevents duplicate\n"
408
+ "> testing. Each layer is designed, implemented and run by its owning role.\n\n"
409
+ f"Related: [test plan]({_rel(path, testplan_path(team_root_from(path)))}) | "
410
+ f"[criteria]({_rel(path, criteria_path(team_root_from(path)))}) | "
411
+ f"[reviews]({_rel(path, reviews_path(team_root_from(path)))}) | "
412
+ f"[archive ledger]({f['archive']}) | [team map]({f['map']})\n"
413
+ )
414
+ out = [header, ""]
415
+ order = {ly: i for i, ly in enumerate(TEST_LAYERS)}
416
+ for r in sorted(records, key=lambda x: (order.get(x["layer"], 9), x["area"], x["id"])):
417
+ out.append(f"## {r['id']} · {one_line(r['layer'])} · {one_line(r['area'])}")
418
+ for field in (
419
+ "layer",
420
+ "area",
421
+ "owner",
422
+ "status",
423
+ "task",
424
+ "command",
425
+ "evidence",
426
+ "baseline",
427
+ "artifacts",
428
+ "replay",
429
+ "rev",
430
+ "by",
431
+ "forced",
432
+ "updated",
433
+ ):
434
+ out.append(f"- {field}: {one_line(r.get(field, ''))}")
435
+ out.append("")
436
+ return "\n".join(out).rstrip() + "\n"
437
+
438
+
439
+ def write_tests(path: Path, records: list) -> None:
440
+ atomic_write(path, render_tests(records, path))
441
+
442
+
443
+ def parse_testplan(path: Path) -> dict:
444
+ """{area: [layers]} - which layers an area must cover."""
445
+ plan: dict = {}
446
+ if not path.exists():
447
+ return plan
448
+ area = None
449
+ for raw in path.read_text(encoding="utf-8").splitlines():
450
+ m = re.match(r"^##\s+(.+?)\s*$", raw)
451
+ if m:
452
+ area = m.group(1).strip().lower()
453
+ plan.setdefault(area, [])
454
+ continue
455
+ mm = META_RE.match(raw)
456
+ if mm and area and mm.group(1) == "layers":
457
+ value = mm.group(2).strip()
458
+ if value in ("", "(all staffed role layers)", ALL_LAYERS_SENTINEL):
459
+ plan[area] = [ALL_LAYERS_SENTINEL] if value else []
460
+ else:
461
+ plan[area] = [x.strip() for x in value.split(",") if x.strip()]
462
+ return plan
463
+
464
+
465
+ def render_testplan(plan: dict, path: Path | None = None) -> str:
466
+ lines = [
467
+ "# Test plan - required layers per area",
468
+ "",
469
+ "> Declared by the Architect with the CEO. `loop` refuses to STOP while a",
470
+ "> required layer is missing, pending or failing.",
471
+ "> Default when an area is not listed: the proportionate core set",
472
+ f"> ({', '.join(DEFAULT_REQUIRED_LAYERS)}) restricted to staffed roles; use",
473
+ "> `__all__` to demand every staffed role's layer for that area.",
474
+ "",
475
+ ]
476
+ if path is not None:
477
+ f = link_fields(path, "ceo")
478
+ lines.append(
479
+ f"Related: [tests]({_rel(path, tests_path(team_root_from(path)))}) | "
480
+ f"[criteria]({_rel(path, criteria_path(team_root_from(path)))}) | "
481
+ f"[architecture]({f['architecture']}) | [plan]({f['plan']})"
482
+ )
483
+ lines.append("")
484
+ for area in sorted(plan):
485
+ lines.append(f"## {area}")
486
+ value = ALL_LAYERS_SENTINEL if plan[area] == [ALL_LAYERS_SENTINEL] else ",".join(plan[area])
487
+ lines.append(f"- layers: {value}")
488
+ lines.append("")
489
+ return "\n".join(lines).rstrip() + "\n"
490
+
491
+
492
+ def write_testplan(path: Path, plan: dict) -> None:
493
+ atomic_write(path, render_testplan(plan, path))
494
+
495
+
496
+ def find_test(records: list, handle: str):
497
+ want = (handle or "").strip().upper()
498
+ for i, r in enumerate(records):
499
+ if r["id"].upper() == want:
500
+ return i
501
+ for i, r in enumerate(records):
502
+ if want and (want == r["area"].upper() or want in r["area"].upper()):
503
+ return i
504
+ return None
505
+
506
+
507
+ def new_task(
508
+ title: str,
509
+ priority: str = "P2",
510
+ shared: str = "",
511
+ deps: str = "",
512
+ tags: str = "",
513
+ status: str = "todo",
514
+ body: str = "",
515
+ evidence: str = "",
516
+ review: str = "required",
517
+ ) -> dict:
518
+ priority = (priority or "P2").upper()
519
+ if priority not in PRIORITY_ORDER:
520
+ priority = "P2"
521
+ return {
522
+ "id": "",
523
+ "title": one_line(title),
524
+ "priority": priority,
525
+ "status": status if status in STATUSES else "todo",
526
+ "deps": one_line(deps),
527
+ "shared": one_line(shared),
528
+ "tags": one_line(tags),
529
+ "area": "",
530
+ "kind": "",
531
+ "repro": "",
532
+ "repro_artifacts": "",
533
+ "repro_by": "",
534
+ "evidence": one_line(evidence),
535
+ "review": review if review in ("required", "na") else "required",
536
+ "review_status": "na" if review == "na" else "pending",
537
+ "self_check": "na" if review == "na" else "pending",
538
+ "self_note": "",
539
+ "self_artifacts": "",
540
+ "review_evidence": "",
541
+ "review_artifacts": "",
542
+ "reviewer": "",
543
+ "review_claim": "",
544
+ "review_claim_ts": "",
545
+ "claimed_by": "",
546
+ "branch": "",
547
+ "base": "",
548
+ "worktree": "",
549
+ "pr": "",
550
+ "merge_commit": "",
551
+ "review_fails": 0,
552
+ "findings": "",
553
+ "created": today_iso(),
554
+ "updated": today_iso(),
555
+ "body": body_text(body),
556
+ }
557
+
558
+
559
+ def parse_tasks(path: Path) -> list:
560
+ if not path.exists():
561
+ return []
562
+ tasks = []
563
+ current = None
564
+ in_body = False
565
+ for raw in path.read_text(encoding="utf-8", errors="replace").splitlines():
566
+ m = TASK_RE.match(raw)
567
+ if m:
568
+ if current:
569
+ tasks.append(current)
570
+ current = new_task(m.group(2))
571
+ current["id"] = m.group(1)
572
+ in_body = False
573
+ continue
574
+ if current is None:
575
+ continue
576
+ if not raw.strip():
577
+ # The renderer puts one blank line between metadata and body. From
578
+ # here on nothing is metadata: a body line such as
579
+ # ``- status: done`` must never forge the task state.
580
+ if in_body:
581
+ current["body"] = current["body"] + "\n"
582
+ else:
583
+ in_body = True
584
+ continue
585
+ if not in_body:
586
+ mm = META_RE.match(raw)
587
+ if mm:
588
+ field, value = mm.group(1), mm.group(2).strip()
589
+ if field in (
590
+ "priority",
591
+ "status",
592
+ "deps",
593
+ "shared",
594
+ "tags",
595
+ "created",
596
+ "updated",
597
+ "evidence",
598
+ "review",
599
+ "review_status",
600
+ "reviewer",
601
+ "review_claim",
602
+ "review_claim_ts",
603
+ "findings",
604
+ "claimed_by",
605
+ "self_check",
606
+ "self_note",
607
+ "review_evidence",
608
+ "branch",
609
+ "base",
610
+ "worktree",
611
+ "pr",
612
+ "merge_commit",
613
+ "forced",
614
+ "area",
615
+ "kind",
616
+ "repro",
617
+ "repro_artifacts",
618
+ "repro_by",
619
+ "self_artifacts",
620
+ "review_artifacts",
621
+ "review_fails",
622
+ ):
623
+ current[field] = value
624
+ continue
625
+ in_body = True
626
+ current["body"] = (current["body"] + "\n" + raw).strip()
627
+ if current:
628
+ tasks.append(current)
629
+ return [t for t in tasks if t["title"] or t["body"]]
630
+
631
+
632
+ def render_tasks(tasks: list, owner: str, path: Path | None = None) -> str:
633
+ assign_ids(tasks, "T-")
634
+ ordered = sorted(tasks, key=lambda t: (PRIORITY_ORDER.get(t["priority"], 9), t["id"]))
635
+ out = [header_for(F_TASKS, owner, path), ""]
636
+ for t in ordered:
637
+ out.append(f"## {t['id']} · {one_line(t['title']) or 'task'}")
638
+ meta = [
639
+ ("priority", t["priority"]),
640
+ ("status", t["status"]),
641
+ ("deps", t["deps"]),
642
+ ("shared", t["shared"]),
643
+ ("tags", t["tags"]),
644
+ ("area", t.get("area", "")),
645
+ ("kind", t.get("kind", "")),
646
+ ("repro", t.get("repro", "")),
647
+ ("repro_artifacts", t.get("repro_artifacts", "")),
648
+ ("repro_by", t.get("repro_by", "")),
649
+ ("review", t.get("review", "required")),
650
+ ("review_status", t.get("review_status", "pending")),
651
+ ("self_check", t.get("self_check", "pending")),
652
+ ("self_note", t.get("self_note", "")),
653
+ ("self_artifacts", t.get("self_artifacts", "")),
654
+ ("reviewer", t.get("reviewer", "")),
655
+ ("review_claim", t.get("review_claim", "")),
656
+ ("review_claim_ts", t.get("review_claim_ts", "")),
657
+ ("review_evidence", t.get("review_evidence", "")),
658
+ ("review_artifacts", t.get("review_artifacts", "")),
659
+ ("claimed_by", t.get("claimed_by", "")),
660
+ ("branch", t.get("branch", "")),
661
+ ("base", t.get("base", "")),
662
+ ("worktree", t.get("worktree", "")),
663
+ ("pr", t.get("pr", "")),
664
+ ("merge_commit", t.get("merge_commit", "")),
665
+ ("review_fails", str(t.get("review_fails") or 0)),
666
+ ("forced", t.get("forced", "")),
667
+ ("evidence", t.get("evidence", "")),
668
+ ("findings", t.get("findings", "")),
669
+ ("created", t["created"]),
670
+ ("updated", t["updated"]),
671
+ ]
672
+ for field, value in meta:
673
+ out.append(f"- {field}: {one_line(value)}")
674
+ out.append("")
675
+ if t["body"]:
676
+ out.append(body_text(t["body"]))
677
+ out.append("")
678
+ return "\n".join(out).rstrip() + "\n"
679
+
680
+
681
+ def write_tasks(path: Path, tasks: list, owner: str) -> None:
682
+ atomic_write(path, render_tasks(tasks, owner, path))
683
+
684
+
685
+ def merge_tasks(dst: list, incoming: list) -> tuple:
686
+ """Fold duplicate tasks (same lowercased title) into one."""
687
+ index = {t["title"].strip().lower(): t for t in dst if t["title"]}
688
+ added = 0
689
+ for src in incoming:
690
+ key = src["title"].strip().lower()
691
+ if key in index and key:
692
+ existing = index[key]
693
+ if PRIORITY_ORDER.get(src["priority"], 9) < PRIORITY_ORDER.get(existing["priority"], 9):
694
+ existing["priority"] = src["priority"]
695
+ for field, value in src.items():
696
+ if field in ("id", "title", "priority", "created", "updated"):
697
+ continue
698
+ if value in ("", None, [], 0) or value == "pending":
699
+ continue
700
+ if not existing.get(field):
701
+ existing[field] = value
702
+ if src["updated"] >= existing["updated"]:
703
+ existing["updated"] = src["updated"]
704
+ if existing["status"] in ("todo",) and src["status"] not in ("todo", "dropped"):
705
+ existing["status"] = src["status"]
706
+ continue
707
+ dst.append(dict(src))
708
+ if key:
709
+ index[key] = dst[-1]
710
+ added += 1
711
+ return dst, added
712
+
713
+
714
+ def find_task(tasks: list, handle: str):
715
+ if re.fullmatch(r"T-\d+", handle.upper()):
716
+ wanted = handle.upper()
717
+ for i, t in enumerate(tasks):
718
+ if t["id"].upper() == wanted:
719
+ return i
720
+ return None
721
+ for i, t in enumerate(tasks):
722
+ if t["title"].lower() == handle.lower():
723
+ return i
724
+ for i, t in enumerate(tasks):
725
+ if handle.lower() in (t["title"] + " " + t["body"] + " " + t["shared"]).lower():
726
+ return i
727
+ return None
728
+
729
+
730
+ def dep_satisfied(root: Path, dep: str, cache: dict) -> bool:
731
+ """Is a dependency satisfied yet?
732
+
733
+ A bare ``role:T-id`` waits for the whole task to be ``done``. Add ``@review``
734
+ to release as soon as the work is *submitted* (``review`` or ``done``) and
735
+ ``@started`` to release once the owner has picked it up, so a downstream lane
736
+ can begin against a frozen contract instead of idling for a full review round.
737
+ """
738
+ dep = dep.strip()
739
+ if not dep:
740
+ return True
741
+ if ":" in dep:
742
+ other_agent, other_id = dep.split(":", 1)
743
+ other_agent, other_id = other_agent.strip(), other_id.strip()
744
+ mode = "done"
745
+ if "@" in other_id:
746
+ other_id, mode = other_id.split("@", 1)
747
+ other_id, mode = other_id.strip(), mode.strip().lower()
748
+ if mode not in ("done", "review", "started"):
749
+ mode = "done"
750
+ key = ("x", other_agent, other_id, mode)
751
+ if key not in cache:
752
+ path = role_dir(root, other_agent) / F_TASKS
753
+ tasks = parse_tasks(path)
754
+ idx = find_task(tasks, other_id)
755
+ status = tasks[idx]["status"] if idx is not None else ""
756
+ wanted = {
757
+ "done": ("done",),
758
+ "review": ("review", "done"),
759
+ "started": ("doing", "selfcheck", "review", "done"),
760
+ }[mode]
761
+ cache[key] = status in wanted
762
+ return cache[key]
763
+ local_key = ("l", dep.lower())
764
+ if local_key not in cache:
765
+ cache[local_key] = None
766
+ return cache[local_key] is True # filled by caller for local deps
767
+
768
+
769
+ def local_dep_map(tasks: list) -> dict:
770
+ by_title = {}
771
+ for t in tasks:
772
+ by_title[t["id"].lower()] = t["status"]
773
+ if t["title"]:
774
+ by_title[t["title"].lower()] = t["status"]
775
+ return by_title
776
+
777
+
778
+ def executable(t: dict, lookup: dict, cache: dict, root: Path) -> bool:
779
+ if t["status"] != "todo":
780
+ return False
781
+ for dep in [d for d in re.split(r"[,\s]+", t["deps"]) if d.strip()]:
782
+ if ":" in dep:
783
+ if not dep_satisfied(root, dep, cache):
784
+ return False
785
+ else:
786
+ if lookup.get(dep.lower()) != "done":
787
+ return False
788
+ return True
789
+
790
+
791
+ def today() -> date:
792
+ return date.today()
793
+
794
+
795
+ def today_iso() -> str:
796
+ return today().isoformat()
797
+
798
+
799
+ def parse_date(value: str) -> date:
800
+ try:
801
+ return date.fromisoformat((value or "").strip())
802
+ except ValueError:
803
+ return today()
804
+
805
+
806
+ def days_since(value: str) -> int:
807
+ return max((today() - parse_date(value)).days, 0)
808
+
809
+
810
+ def new_entry(
811
+ body: str,
812
+ key: str = "",
813
+ importance: int = 2,
814
+ tags: str = "",
815
+ pinned: bool = False,
816
+ by: str = "",
817
+ src: str = "",
818
+ rev: str = "",
819
+ ) -> dict:
820
+ return {
821
+ "id": "",
822
+ "key": one_line(key),
823
+ "importance": max(0, min(int(importance), 5)),
824
+ "uses": 0,
825
+ "created": today_iso(),
826
+ "last_used": today_iso(),
827
+ "tags": one_line(tags),
828
+ "pinned": bool(pinned),
829
+ "body": body_text(body),
830
+ # provenance: who recorded it, for what, at which revision
831
+ "by": one_line(by),
832
+ "src": one_line(src),
833
+ "rev": one_line(rev),
834
+ }
835
+
836
+
837
+ def parse_memory(path: Path) -> list:
838
+ if not path.exists():
839
+ return []
840
+ entries = []
841
+ current = None
842
+ in_body = False
843
+ for raw in path.read_text(encoding="utf-8", errors="replace").splitlines():
844
+ m = ENTRY_RE.match(raw)
845
+ if m:
846
+ if current:
847
+ entries.append(current)
848
+ current = new_entry("")
849
+ current["id"] = m.group(1)
850
+ current["key"] = m.group(2).strip()
851
+ in_body = False
852
+ continue
853
+ if current is None:
854
+ continue
855
+ if not raw.strip():
856
+ if in_body:
857
+ current["body"] = current["body"] + "\n"
858
+ else:
859
+ in_body = True
860
+ continue
861
+ if not in_body:
862
+ mm = META_RE.match(raw)
863
+ if mm:
864
+ field, value = mm.group(1), mm.group(2).strip()
865
+ if field == "importance":
866
+ try:
867
+ current["importance"] = int(value)
868
+ except ValueError:
869
+ pass
870
+ elif field == "uses":
871
+ try:
872
+ current["uses"] = int(value)
873
+ except ValueError:
874
+ pass
875
+ elif field in ("created", "last_used", "tags", "key", "by", "src", "rev"):
876
+ current[field] = value
877
+ elif field == "pinned":
878
+ current["pinned"] = value.lower() in ("yes", "true", "1")
879
+ continue
880
+ in_body = True
881
+ current["body"] = (current["body"] + "\n" + raw).strip()
882
+ if current:
883
+ entries.append(current)
884
+ return [e for e in entries if e["body"] or e["key"]]
885
+
886
+
887
+ def render(entries: list, filename: str, owner: str, path: Path | None = None) -> str:
888
+ assign_ids(entries, "M-")
889
+ out = [header_for(filename, owner, path), ""]
890
+ for e in entries:
891
+ label = one_line(e["key"]) or "note"
892
+ out.append(f"## {e['id']} · {label}")
893
+ meta = [
894
+ ("key", e["key"]),
895
+ ("importance", str(e["importance"])),
896
+ ("uses", str(e["uses"])),
897
+ ("created", e["created"]),
898
+ ("last_used", e["last_used"]),
899
+ ("tags", e["tags"]),
900
+ ("pinned", "yes" if e["pinned"] else "no"),
901
+ ("by", e.get("by", "")),
902
+ ("src", e.get("src", "")),
903
+ ("rev", e.get("rev", "")),
904
+ ]
905
+ for field, value in meta:
906
+ out.append(f"- {field}: {one_line(value)}")
907
+ out.append("")
908
+ out.append(body_text(e["body"]).strip())
909
+ out.append("")
910
+ return "\n".join(out).rstrip() + "\n"
911
+
912
+
913
+ def write_memory(path: Path, entries: list, filename: str, owner: str) -> None:
914
+ atomic_write(path, render(entries, filename, owner, path))
915
+
916
+
917
+ def score(entry: dict) -> int:
918
+ d = days_since(entry["last_used"])
919
+ recency = 3 if d <= 1 else 2 if d <= 3 else 1 if d <= 7 else 0
920
+ return (
921
+ entry["importance"] * 10
922
+ + min(entry["uses"], 12) * 3
923
+ + recency
924
+ + (100 if entry["pinned"] else 0)
925
+ )
926
+
927
+
928
+ def assign_ids(items: list, prefix: str) -> None:
929
+ """Keep existing ids stable; allocate fresh, collision-free ids for the rest."""
930
+ used = set()
931
+ for it in items:
932
+ i = it.get("id", "")
933
+ if i and i not in used:
934
+ used.add(i)
935
+ else:
936
+ it["id"] = ""
937
+ counter = 0
938
+ for it in items:
939
+ if it["id"]:
940
+ try:
941
+ counter = max(counter, int(it["id"].split("-")[1]))
942
+ except (IndexError, ValueError):
943
+ pass
944
+ for it in items:
945
+ if it["id"]:
946
+ continue
947
+ while True:
948
+ counter += 1
949
+ candidate = f"{prefix}{counter:04d}"
950
+ if candidate not in used:
951
+ break
952
+ it["id"] = candidate
953
+ used.add(candidate)
954
+
955
+
956
+ def merge_entries(target: list, incoming: list, prefix: str = "M-") -> tuple:
957
+ """Merge incoming into target by key. Returns (target, merged, added)."""
958
+ merged = 0
959
+ added = 0
960
+ index: dict = {}
961
+ for e in target:
962
+ if e["key"]:
963
+ index.setdefault(e["key"], e)
964
+ for src in incoming:
965
+ existing = index.get(src["key"]) if src["key"] else None
966
+ if existing is None:
967
+ copy = dict(src)
968
+ copy["id"] = "" # reassigned by render/assign_ids
969
+ target.append(copy)
970
+ added += 1
971
+ if src["key"]:
972
+ index[src["key"]] = target[-1]
973
+ continue
974
+ existing["importance"] = max(existing["importance"], src["importance"])
975
+ existing["uses"] = existing["uses"] + src["uses"]
976
+ existing["pinned"] = existing["pinned"] or src["pinned"]
977
+ if parse_date(src["last_used"]) >= parse_date(existing["last_used"]):
978
+ existing["last_used"] = src["last_used"]
979
+ existing["body"] = src["body"] or existing["body"]
980
+ existing["tags"] = ",".join(
981
+ sorted({t for t in (existing["tags"] + "," + src["tags"]).split(",") if t.strip()})
982
+ )
983
+ merged += 1
984
+ return target, merged, added
985
+
986
+
987
+ def header_for(filename: str, owner: str, path: Path | None = None) -> str:
988
+ """Render a file header; link fields are real relative paths when we know the
989
+ file's location (which every writer does)."""
990
+ tpl = HEADERS.get(filename, "# Memory - {owner}\n")
991
+ if path is None:
992
+ die("internal: header_for needs the file path so its links resolve")
993
+ fields = link_fields(path, owner)
994
+ fields.update(owner=owner, cap=LONG_CAP, stale=STALE_DAYS)
995
+ return tpl.format(**fields)
996
+
997
+
998
+ def new_dispute(
999
+ topic: str,
1000
+ raised_by: str = "",
1001
+ against: str = "",
1002
+ evidence: str = "",
1003
+ position: str = "",
1004
+ task: str = "",
1005
+ ) -> dict:
1006
+ return {
1007
+ "id": "",
1008
+ "topic": one_line(topic),
1009
+ "status": "open",
1010
+ "raised_by": one_line(raised_by),
1011
+ "against": one_line(against),
1012
+ "task": one_line(task),
1013
+ "round": 0,
1014
+ "escalated": False,
1015
+ "created": today_iso(),
1016
+ "updated": today_iso(),
1017
+ "evidence": one_line(evidence),
1018
+ "position": one_line(position),
1019
+ "rounds": [],
1020
+ "signed": [],
1021
+ "decision": "",
1022
+ }
1023
+
1024
+
1025
+ def parse_disputes(path: Path) -> list:
1026
+ if not path.exists():
1027
+ return []
1028
+ out = []
1029
+ cur = None
1030
+ section = "meta"
1031
+ for raw in path.read_text(encoding="utf-8").splitlines():
1032
+ m = DISPUTE_RE.match(raw)
1033
+ if m:
1034
+ if cur:
1035
+ out.append(cur)
1036
+ cur = new_dispute(m.group(2))
1037
+ cur["id"] = m.group(1)
1038
+ section = "meta"
1039
+ continue
1040
+ if cur is None:
1041
+ continue
1042
+ if raw.startswith("### "):
1043
+ section = raw[4:].strip().lower()
1044
+ continue
1045
+ if section == "rounds":
1046
+ rm = ROUND_RE.match(raw)
1047
+ if rm:
1048
+ cur["rounds"].append(
1049
+ {
1050
+ "n": int(rm.group(1)),
1051
+ "by": rm.group(2),
1052
+ "date": rm.group(3),
1053
+ "text": rm.group(4).strip(),
1054
+ }
1055
+ )
1056
+ cur["round"] = max(cur["round"], int(rm.group(1)))
1057
+ continue
1058
+ if section == "ruling":
1059
+ if raw.startswith("- signed:"):
1060
+ cur["signed"] = [s.strip() for s in raw.split(":", 1)[1].split(",") if s.strip()]
1061
+ elif raw.startswith("- decision:"):
1062
+ cur["decision"] = raw.split(":", 1)[1].strip()
1063
+ continue
1064
+ mm = META_RE.match(raw)
1065
+ if mm:
1066
+ field, value = mm.group(1), mm.group(2).strip()
1067
+ if field in (
1068
+ "status",
1069
+ "raised_by",
1070
+ "against",
1071
+ "task",
1072
+ "created",
1073
+ "updated",
1074
+ "evidence",
1075
+ "position",
1076
+ ):
1077
+ cur[field] = value
1078
+ elif field == "round":
1079
+ try:
1080
+ cur["round"] = int(value)
1081
+ except ValueError:
1082
+ pass
1083
+ elif field == "escalated":
1084
+ cur["escalated"] = value.lower() in ("yes", "true", "1")
1085
+ if cur:
1086
+ out.append(cur)
1087
+ return out
1088
+
1089
+
1090
+ def render_disputes(disputes: list) -> str:
1091
+ assign_ids(disputes, "D-")
1092
+ out = [DISPUTE_HEADER.format(rounds=MAX_ROUNDS), ""]
1093
+ for d in sorted(disputes, key=lambda x: x["id"]):
1094
+ out.append(f"## {d['id']} · {one_line(d['topic']) or 'dispute'}")
1095
+ for field, value in (
1096
+ ("status", d["status"]),
1097
+ ("raised_by", d["raised_by"]),
1098
+ ("against", d["against"]),
1099
+ ("task", d["task"]),
1100
+ ("round", str(d["round"])),
1101
+ ("escalated", "yes" if d["escalated"] else "no"),
1102
+ ("created", d["created"]),
1103
+ ("updated", d["updated"]),
1104
+ ):
1105
+ out.append(f"- {field}: {one_line(value)}")
1106
+ out.append("")
1107
+ if d["position"]:
1108
+ out.append(f"- position: {one_line(d['position'])}")
1109
+ if d["evidence"]:
1110
+ out.append(f"- evidence: {one_line(d['evidence'])}")
1111
+ out.append("")
1112
+ out.append("### rounds")
1113
+ for r in d["rounds"]:
1114
+ out.append(
1115
+ f"- r{r['n']} {one_line(r['by'])} {one_line(r['date'])}: {one_line(r['text'])}"
1116
+ )
1117
+ out.append("")
1118
+ out.append("### ruling")
1119
+ out.append(f"- signed: {', '.join(one_line(s) for s in d['signed'])}")
1120
+ out.append(f"- decision: {one_line(d['decision'])}")
1121
+ out.append("")
1122
+ return "\n".join(out).rstrip() + "\n"
1123
+
1124
+
1125
+ def write_disputes(path: Path, disputes: list) -> None:
1126
+ atomic_write(path, render_disputes(disputes))
1127
+
1128
+
1129
+ def find_dispute(disputes: list, handle: str):
1130
+ if re.fullmatch(r"D-\d+", (handle or "").upper()):
1131
+ for i, d in enumerate(disputes):
1132
+ if d["id"].upper() == handle.upper():
1133
+ return i
1134
+ return None
1135
+ for i, d in enumerate(disputes):
1136
+ if handle.lower() in (d["topic"] + " " + d["position"]).lower():
1137
+ return i
1138
+ return None
1139
+
1140
+
1141
+ def new_skill(name: str, owner_role: str, description: str, by: str = "", path: str = "") -> dict:
1142
+ return {
1143
+ "id": "",
1144
+ "name": one_line(name),
1145
+ "owner_role": one_line(owner_role),
1146
+ "status": "draft",
1147
+ "description": one_line(description),
1148
+ "created_by": one_line(by),
1149
+ "path": one_line(path),
1150
+ "created": today_iso(),
1151
+ "updated": today_iso(),
1152
+ "shared_with": "",
1153
+ "notes": [],
1154
+ }
1155
+
1156
+
1157
+ def parse_skills(path: Path) -> list:
1158
+ if not path.exists():
1159
+ return []
1160
+ out, cur = [], None
1161
+ for raw in path.read_text(encoding="utf-8").splitlines():
1162
+ m = re.match(r"^##\s+(S-\d+)\s*(?:·|:)?\s*(.*)$", raw)
1163
+ if m:
1164
+ if cur:
1165
+ out.append(cur)
1166
+ cur = new_skill(m.group(2).strip(), "", "")
1167
+ cur["id"] = m.group(1)
1168
+ continue
1169
+ if cur is None:
1170
+ continue
1171
+ mm = META_RE.match(raw)
1172
+ if not mm:
1173
+ continue
1174
+ field, value = mm.group(1), mm.group(2).strip()
1175
+ if field in cur:
1176
+ cur[field] = value
1177
+ elif field == "note":
1178
+ cur["notes"].append(value)
1179
+ if cur:
1180
+ out.append(cur)
1181
+ return out
1182
+
1183
+
1184
+ def render_skills(items: list) -> str:
1185
+ assign_ids(items, "S-")
1186
+ out = [SKILLS_HEADER, ""]
1187
+ for s in items:
1188
+ out.append(f"## {s['id']} · {one_line(s['name'])}")
1189
+ for field in (
1190
+ "name",
1191
+ "status",
1192
+ "owner_role",
1193
+ "created_by",
1194
+ "path",
1195
+ "created",
1196
+ "updated",
1197
+ "shared_with",
1198
+ ):
1199
+ out.append(f"- {field}: {one_line(s.get(field, ''))}")
1200
+ out.append("")
1201
+ out.append(f"- description: {one_line(s['description'])}")
1202
+ for n in s["notes"]:
1203
+ out.append(f"- note: {one_line(n)}")
1204
+ out.append("")
1205
+ return "\n".join(out).rstrip() + "\n"
1206
+
1207
+
1208
+ def write_skills(path: Path, items: list) -> None:
1209
+ atomic_write(path, render_skills(items))
1210
+
1211
+
1212
+ def find_skill(items: list, handle: str):
1213
+ want = (handle or "").strip().lower()
1214
+ for i, s in enumerate(items):
1215
+ if s["id"].lower() == want or s["name"].lower() == want:
1216
+ return i
1217
+ for i, s in enumerate(items):
1218
+ if want and want in s["name"].lower():
1219
+ return i
1220
+ return None