@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.
- package/CHANGELOG.md +329 -0
- package/LICENSE +661 -0
- package/NOTICE +16 -0
- package/README.md +462 -2
- package/SECURITY.md +64 -0
- package/package.json +71 -4
- package/prompts/teamwork-pulse.md +12 -0
- package/prompts/teamwork-review.md +16 -0
- package/skills/teamwork/AGENTS.md +462 -0
- package/skills/teamwork/SKILL.md +795 -0
- package/skills/teamwork/references/Adapters.md +112 -0
- package/skills/teamwork/references/Audit.md +120 -0
- package/skills/teamwork/references/Concurrency.md +160 -0
- package/skills/teamwork/references/Dissent.md +85 -0
- package/skills/teamwork/references/Docs.md +74 -0
- package/skills/teamwork/references/Engineering.md +410 -0
- package/skills/teamwork/references/Evidence.md +140 -0
- package/skills/teamwork/references/Remotes.md +144 -0
- package/skills/teamwork/references/Roles.md +201 -0
- package/skills/teamwork/references/State.md +64 -0
- package/skills/teamwork/references/Testing.md +144 -0
- package/skills/teamwork/roles/architect.md +153 -0
- package/skills/teamwork/roles/ceo.md +218 -0
- package/skills/teamwork/roles/cfo.md +150 -0
- package/skills/teamwork/roles/coo.md +147 -0
- package/skills/teamwork/roles/cto.md +162 -0
- package/skills/teamwork/roles/data-engineer.md +120 -0
- package/skills/teamwork/roles/designer.md +349 -0
- package/skills/teamwork/roles/developer.md +166 -0
- package/skills/teamwork/roles/product-manager.md +151 -0
- package/skills/teamwork/roles/project-manager.md +159 -0
- package/skills/teamwork/roles/release-engineer.md +245 -0
- package/skills/teamwork/roles/reviewer.md +254 -0
- package/skills/teamwork/roles/security-auditor.md +157 -0
- package/skills/teamwork/roles/technical-writer.md +121 -0
- package/skills/teamwork/roles/tester.md +180 -0
- package/skills/teamwork/scripts/build_pi_package.py +200 -0
- package/skills/teamwork/scripts/privacy_scan.py +1185 -0
- package/skills/teamwork/scripts/sync_skill.py +108 -0
- package/skills/teamwork/scripts/teamwork.py +28 -0
- package/skills/teamwork/scripts/verify_release.py +502 -0
- package/skills/teamwork/src/teamwork/__init__.py +18 -0
- package/skills/teamwork/src/teamwork/__main__.py +10 -0
- package/skills/teamwork/src/teamwork/bundle.py +157 -0
- package/skills/teamwork/src/teamwork/cli.py +16 -0
- package/skills/teamwork/src/teamwork/config.py +396 -0
- package/skills/teamwork/src/teamwork/engine.py +10119 -0
- package/skills/teamwork/src/teamwork/gitutil.py +172 -0
- package/skills/teamwork/src/teamwork/imaging.py +124 -0
- package/skills/teamwork/src/teamwork/io.py +144 -0
- package/skills/teamwork/src/teamwork/layout.py +253 -0
- package/skills/teamwork/src/teamwork/names.py +191 -0
- package/skills/teamwork/src/teamwork/portable.py +79 -0
- package/skills/teamwork/src/teamwork/records.py +1220 -0
- package/skills/teamwork/src/teamwork/resources.py +120 -0
- package/skills/teamwork/src/teamwork/version.py +13 -0
- package/skills/teamwork/templates/AGENTS.md +460 -0
- package/skills/teamwork/templates/Docs/Architecture.md +40 -0
- package/skills/teamwork/templates/Docs/Archive.md +8 -0
- package/skills/teamwork/templates/Docs/Board.md +28 -0
- package/skills/teamwork/templates/Docs/Costs.md +30 -0
- package/skills/teamwork/templates/Docs/Decisions.md +26 -0
- package/skills/teamwork/templates/Docs/Design.md +106 -0
- package/skills/teamwork/templates/Docs/Plan.md +24 -0
- package/skills/teamwork/templates/Docs/Project.md +40 -0
- package/skills/teamwork/templates/Docs/Release.md +43 -0
- package/skills/teamwork/templates/Docs/Research.md +36 -0
- package/skills/teamwork/templates/Docs/Risks.md +18 -0
- package/skills/teamwork/templates/TeamMap.md +100 -0
- 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
|