@akinet/akidevrule 3.0.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 (60) hide show
  1. package/CHANGELOG.md +835 -0
  2. package/LICENSE +21 -0
  3. package/README.md +356 -0
  4. package/claude/CLAUDE.md +40 -0
  5. package/claude/agents/aki-challenger.md +38 -0
  6. package/claude/agents/aki-conduct.md +54 -0
  7. package/claude/agents/aki-hands.md +59 -0
  8. package/claude/agents/aki-judge.md +37 -0
  9. package/claude/agents/aki-maker.md +36 -0
  10. package/claude/fragments/settings.akidoc.fragment.json +15 -0
  11. package/claude/hooks/aki-update-check.mjs +160 -0
  12. package/claude/hooks/aki_version_check.mjs +83 -0
  13. package/docs/ref/macos-codesign-tcc.md +59 -0
  14. package/install.mjs +1067 -0
  15. package/install.ps1 +11 -0
  16. package/install.sh +12 -0
  17. package/package.json +52 -0
  18. package/payload/GEMINI.md +147 -0
  19. package/payload/METHOD-audit-flow.md +147 -0
  20. package/payload/METHOD-audit-subtraction.md +67 -0
  21. package/payload/METHOD-audit-zero-trust.md +49 -0
  22. package/payload/METHOD-deep-think.md +172 -0
  23. package/payload/METHOD-proportionality.md +62 -0
  24. package/payload/METHOD-ux-psych.md +60 -0
  25. package/payload/RULE-agent-behavior.md +138 -0
  26. package/payload/RULE-biz.md +51 -0
  27. package/payload/RULE-coding.md +130 -0
  28. package/payload/RULE-content-write.md +54 -0
  29. package/payload/RULE-db-design.md +26 -0
  30. package/payload/RULE-docs.md +144 -0
  31. package/payload/RULE-pattern-core.md +80 -0
  32. package/payload/RULE-release.md +215 -0
  33. package/payload/RULE-seo.md +173 -0
  34. package/payload/RULE-stack-akiNuxtCf.md +179 -0
  35. package/payload/RULE-stack-tauri.md +59 -0
  36. package/payload/RULE-ui-pattern.md +167 -0
  37. package/payload/index.md +91 -0
  38. package/skills/aki-article-writer/SKILL.md +50 -0
  39. package/skills/aki-article-writer/references/article-workflow.md +377 -0
  40. package/skills/akidevsync-notes/SKILL.md +48 -0
  41. package/skills/akidevsync-notes/scripts/notes_cli.py +212 -0
  42. package/skills/akiflow/SKILL.md +221 -0
  43. package/skills/akiflow/references/harness-facts.md +215 -0
  44. package/skills/akiflow/scripts/council-cost.sh +4 -0
  45. package/skills/akiflow/scripts/council-open.sh +4 -0
  46. package/skills/akiflow/scripts/council-read.sh +4 -0
  47. package/skills/akiflow/scripts/council-verify.sh +4 -0
  48. package/skills/akiflow/scripts/council_cost.py +149 -0
  49. package/skills/akiflow/scripts/council_open.py +323 -0
  50. package/skills/akiflow/scripts/council_read.py +148 -0
  51. package/skills/akiflow/scripts/council_verify.py +315 -0
  52. package/skills/akiflow/scripts/scythe.py +307 -0
  53. package/skills/akiflow/scripts/scythe.sh +4 -0
  54. package/skills/akigitcommit/SKILL.md +85 -0
  55. package/skills/akihelp/SKILL.md +47 -0
  56. package/skills/akihtmlreport/SKILL.md +59 -0
  57. package/skills/akilint/SKILL.md +29 -0
  58. package/skills/akirule/SKILL.md +155 -0
  59. package/skills/akiship/SKILL.md +55 -0
  60. package/skills/akithink/SKILL.md +59 -0
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env python3
2
+ # akiflow — tally token usage per seat for a closed council session, at Step 6 close-out.
3
+ #
4
+ # Usage: council_cost.py [<session-dir>] [--session <uuid>]
5
+ # <session-dir> a room under ~/.aki/agent-council/<project>/<session>/; its chat.md line 2 carries the `claude-session <uuid>` stamp council_open.py writes. Rooms opened before that stamp carry none.
6
+ # --session explicit session id — the only way to cost an unstamped room, and takes precedence over a parsed stamp when both are given.
7
+ #
8
+ # The harness writes each seat its own transcript, so a seat IS one ~/.claude/projects/<cwd-slug>/<session-id>/subagents/agent-*.jsonl and the lead is the plain <session-id>.jsonl beside it — no chain-walking to separate interleaved turns.
9
+ # Scope is the Claude meter, and that is the complete answer rather than a partial one — why a headless lane on another vendor's quota is left out instead of missing: docs/arch/akiflow.md § Close-out accounting.
10
+ # Dollar cost is deliberately not computed: per-model prices drift, and a hardcoded table in a distributed script rots.
11
+ import sys
12
+ import json
13
+ import re
14
+ from pathlib import Path
15
+ from collections import OrderedDict
16
+
17
+
18
+ USAGE = "usage: council_cost.py [<session-dir>] [--session <uuid>]"
19
+ STAMP_RE = re.compile(r"claude-session\s+([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})")
20
+
21
+
22
+ def fmt(n):
23
+ return f"{n:,}"
24
+
25
+
26
+ def read_jsonl(path):
27
+ rows = []
28
+ with path.open(encoding="utf-8") as fh:
29
+ for raw in fh:
30
+ raw = raw.strip()
31
+ if not raw:
32
+ continue
33
+ try:
34
+ rows.append(json.loads(raw))
35
+ except Exception:
36
+ continue
37
+ return rows
38
+
39
+
40
+ def seat_label(meta_path, seat_id):
41
+ try:
42
+ meta = json.loads(meta_path.read_text(encoding="utf-8"))
43
+ except Exception:
44
+ return f"unlabeled-{seat_id}"
45
+ agent_type = meta.get("agentType") or "unlabeled"
46
+ description = (meta.get("description") or "").strip()
47
+ if description and description.lower() != agent_type.lower():
48
+ return f"{agent_type}: {description}"
49
+ return agent_type
50
+
51
+
52
+ def tally(rows, label, agents):
53
+ for d in rows:
54
+ if d.get("type") != "assistant":
55
+ continue
56
+ msg = d.get("message", {})
57
+ usage = msg.get("usage") or {}
58
+ model = msg.get("model") or "?"
59
+ b = agents.setdefault(label, OrderedDict()).setdefault(model, {"turns": 0, "in": 0, "out": 0, "cw": 0, "cr": 0})
60
+ b["turns"] += 1
61
+ b["in"] += usage.get("input_tokens", 0)
62
+ b["out"] += usage.get("output_tokens", 0)
63
+ b["cw"] += usage.get("cache_creation_input_tokens", 0)
64
+ b["cr"] += usage.get("cache_read_input_tokens", 0)
65
+
66
+
67
+ def main():
68
+ args = sys.argv[1:]
69
+ session_dir_arg = None
70
+ session_override = None
71
+ i = 0
72
+ while i < len(args):
73
+ a = args[i]
74
+ if a == "--session":
75
+ i += 1
76
+ session_override = args[i] if i < len(args) else None
77
+ elif session_dir_arg is None:
78
+ session_dir_arg = a
79
+ else:
80
+ print(f"council_cost.py: unexpected argument: {a}", file=sys.stderr)
81
+ sys.exit(2)
82
+ i += 1
83
+
84
+ if not session_dir_arg and not session_override:
85
+ print(USAGE, file=sys.stderr)
86
+ sys.exit(2)
87
+
88
+ session_id = session_override
89
+ if not session_id:
90
+ session_dir = Path(session_dir_arg)
91
+ chat_path = session_dir / "chat.md"
92
+ if not chat_path.is_file():
93
+ print(USAGE, file=sys.stderr)
94
+ print(f"no chat.md in {session_dir} — not a council room.", file=sys.stderr)
95
+ sys.exit(2)
96
+ text = chat_path.read_text(encoding="utf-8", errors="replace")
97
+ m = STAMP_RE.search(text)
98
+ if not m:
99
+ print(f"session id unknown: {chat_path} carries no claude-session stamp — this room predates the stamp.", file=sys.stderr)
100
+ print("per-seat cost cannot be attributed without a session id: rerun with --session <uuid>.", file=sys.stderr)
101
+ sys.exit(1)
102
+ session_id = m.group(1)
103
+
104
+ slug = str(Path.cwd()).replace("/", "-")
105
+ project_dir = Path.home() / ".claude" / "projects" / slug
106
+ main_file = project_dir / f"{session_id}.jsonl"
107
+ if not main_file.is_file():
108
+ print(f"no transcript for session {session_id}: {main_file} does not exist.", file=sys.stderr)
109
+ print("confirm the session id and that this runs from the project's own working directory.", file=sys.stderr)
110
+ sys.exit(1)
111
+
112
+ print(f"session: {session_id}")
113
+ print(f"transcript: {main_file}")
114
+
115
+ subagents_dir = project_dir / session_id / "subagents"
116
+ seat_files = sorted(subagents_dir.glob("agent-*.jsonl")) if subagents_dir.is_dir() else []
117
+ if seat_files:
118
+ print(f"seats: {len(seat_files)} subagent transcript(s) under {subagents_dir}")
119
+ else:
120
+ print(f"seats: none under {subagents_dir} — LEAD-only is the whole session, not a partial table.")
121
+
122
+ agents = OrderedDict()
123
+ tally(read_jsonl(main_file), "LEAD", agents)
124
+ for seat_file in seat_files:
125
+ stem = seat_file.stem
126
+ seat_id = stem[len("agent-"):] if stem.startswith("agent-") else stem
127
+ label = seat_label(seat_file.with_suffix(".meta.json"), seat_id)
128
+ tally(read_jsonl(seat_file), label, agents)
129
+
130
+ header = f"{'agent':<32} {'model':<22} {'turns':>6} {'in':>10} {'out':>10} {'cache_w':>12} {'cache_r':>12}"
131
+ grand = {"turns": 0, "in": 0, "out": 0, "cw": 0, "cr": 0}
132
+ print()
133
+ print(header)
134
+ print("-" * len(header))
135
+ for label, models in agents.items():
136
+ for model, b in models.items():
137
+ print(f"{label:<32} {model:<22} {b['turns']:>6} {fmt(b['in']):>10} {fmt(b['out']):>10} {fmt(b['cw']):>12} {fmt(b['cr']):>12}")
138
+ for k in grand:
139
+ grand[k] += b[k]
140
+ print("-" * len(header))
141
+ print(f"{'TOTAL':<32} {'':<22} {grand['turns']:>6} {fmt(grand['in']):>10} {fmt(grand['out']):>10} {fmt(grand['cw']):>12} {fmt(grand['cr']):>12}")
142
+ print()
143
+ print("note: cache_w = cache_creation_input_tokens, cache_r = cache_read_input_tokens.")
144
+ print("cost = tokens x current per-model price (billed input = input + cache_creation; cache_read and output are priced separately) — prices drift, look them up, do not assume.")
145
+ print("seat labels come from each seat's meta.json sidecar (agentType/description), never guessed from prompt text — still worth a sanity-check against the declared roster.")
146
+
147
+
148
+ if __name__ == "__main__":
149
+ main()
@@ -0,0 +1,323 @@
1
+ #!/usr/bin/env python3
2
+ # akiflow — open a council session directory, and prune stale ones on the way in.
3
+ #
4
+ # Usage: council_open.py <slug> <owner-message> ('-' reads the message from stdin)
5
+ # council_open.py --dispatch <slug> <owner-message> same args, checklist seeded lane-shaped not item-shaped
6
+ # council_open.py --convene <session-dir> exit 1 unless the checklist is actually cut
7
+ # <slug> short, human-readable, covers the whole session. Slugified here, not validated.
8
+ # <owner-message> the owner's request VERBATIM — pinned as chat.md's first block; every REQ must quote a fragment of it. Why it is an argument and not a discipline: docs/research/akiflow-drift-diagnosis-aug6.md (root R1).
9
+ #
10
+ # Prints the session directory path on stdout (last line is always the path,
11
+ # so `dir=$(council_open.py foo | tail -1)` is safe).
12
+ #
13
+ # Env overrides:
14
+ # AKI_COUNCIL_ROOT default ~/.aki/agent-council
15
+ # AKI_COUNCIL_RETENTION_DAYS default 30 — same window Claude Code uses to
16
+ # clean ~/.claude/projects, so the two age out together.
17
+ import sys
18
+ import os
19
+ import re
20
+ import subprocess
21
+ import time
22
+ import shutil
23
+ from pathlib import Path
24
+ from datetime import datetime
25
+
26
+
27
+ def slugify(text: str) -> str:
28
+ text = text.lower()
29
+ text = re.sub(r"[^a-z0-9]+", "-", text)
30
+ text = text.strip("-")
31
+ return text
32
+
33
+
34
+ def rule_stamp() -> str:
35
+ """Which akidevrule the room ran under, from the installed copy: version per CHANGELOG (release.A3 SSoT), commit per .version."""
36
+ root = Path(os.environ.get("AKI_RULE_ROOT", Path.home() / ".aki" / "akidevrule"))
37
+ version = "?"
38
+ try:
39
+ for line in (root / "CHANGELOG.md").read_text(encoding="utf-8", errors="replace").splitlines():
40
+ match = re.match(r"^## \[(\d[^\]]*)\]", line)
41
+ if match:
42
+ version = match.group(1)
43
+ break
44
+ except OSError:
45
+ pass
46
+ commit = ""
47
+ try:
48
+ for line in (root / ".version").read_text(encoding="utf-8", errors="replace").splitlines():
49
+ if line.startswith("commit="):
50
+ commit = "@" + line.split("=", 1)[1].strip()
51
+ break
52
+ except OSError:
53
+ pass
54
+ return f"akidevrule {version}{commit}"
55
+
56
+
57
+ def read_mode(session_dir: Path) -> str:
58
+ """Mode is read from chat.md's stamp line, written once at open; no match — pre-dispatch room or malformed file — reads as council, never a crash."""
59
+ try:
60
+ lines = (session_dir / "chat.md").read_text(encoding="utf-8", errors="replace").splitlines()
61
+ except OSError:
62
+ return "council"
63
+ if len(lines) < 2:
64
+ return "council"
65
+ match = re.search(r"mode[ \t]+(\S+)", lines[1])
66
+ return match.group(1).rstrip("`") if match else "council"
67
+
68
+
69
+ ITEM_TRIGGER = re.compile(r"^[ \t]*ITEM[ \t]+\S")
70
+ # LANE's heading may carry the seeded markdown '#'s or not — either opens a new block.
71
+ LANE_TRIGGER = re.compile(r"^[ \t]*(#{1,6}[ \t]+)?LANE[ \t]+\S")
72
+
73
+
74
+ def _scan_blocks(text: str, trigger: re.Pattern) -> list[str]:
75
+ """Group lines into blocks starting at each `trigger` match, closed by the next trigger or the next '## ' heading — the one tolerance the ITEM and LANE gates share, only the trigger regex differs."""
76
+ blocks: list[str] = []
77
+ for line in text.splitlines():
78
+ if trigger.match(line):
79
+ blocks.append(line)
80
+ elif line.startswith("## "):
81
+ blocks.append("")
82
+ elif blocks:
83
+ blocks[-1] += "\n" + line
84
+ return blocks
85
+
86
+
87
+ def _convene_council(text: str) -> int:
88
+ blocks = _scan_blocks(text, ITEM_TRIGGER)
89
+
90
+ complete = sum(
91
+ 1 for b in blocks
92
+ if all(re.search(rf"{f}[ \t]*:?[ \t]*\S", b, re.IGNORECASE) for f in ("owner", "challenger", "closes"))
93
+ )
94
+
95
+ if complete == 0:
96
+ print("FAIL convene: checklist.md has no ITEM carrying all of owner / challenger / closes when.", file=sys.stderr)
97
+ print(" Decomposition is a precondition for the room, never a product of it — cut the items, then convene.", file=sys.stderr)
98
+ return 1
99
+ print(f"PASS convene: {complete} item(s) fully specified — roster may be spawned")
100
+ return 0
101
+
102
+
103
+ def _lane_name(block: str) -> str:
104
+ """The LANE heading line with leading '#'/whitespace stripped — the identifier a writes: collision message names."""
105
+ return re.sub(r"^[ \t]*#*[ \t]*", "", block.splitlines()[0]).strip() if block else ""
106
+
107
+
108
+ def _field_paths(block: str, field: str) -> list[str]:
109
+ """Comma-separated tokens after a `field:` label — the same list shape checklist.md already
110
+ uses for REQ runs. Anchored to the seed's own line shape (start of line, optional bullet,
111
+ mandatory colon) so prose mentioning the field name (e.g. 'rewrites' containing 'writes')
112
+ is never mistaken for a declaration."""
113
+ match = re.search(rf"(?m)^[ \t]*(?:[-*][ \t]+)?{field}[ \t]*:[ \t]*(\S.*)", block, re.IGNORECASE)
114
+ return [p.strip() for p in match.group(1).split(",") if p.strip()] if match else []
115
+
116
+
117
+ def _normalize_write_token(token: str) -> tuple[str, bool]:
118
+ """Strip a trailing /** or /* into a directory prefix; a plain token is its own literal.
119
+ Single-* basename globs (scripts/*.py) are deliberately kept as literal tokens — full glob
120
+ matching is out of scope, and the one false PASS this leaves is mixing such a pattern with
121
+ a literal path in the same directory."""
122
+ for suffix in ("/**", "/*"):
123
+ if token.endswith(suffix):
124
+ return token[: -len(suffix)], True
125
+ return token, False
126
+
127
+
128
+ def _path_has_prefix(prefix: str, path: str) -> bool:
129
+ """path == prefix, or prefix is a '/'-boundary-respecting ancestor of path — so 'docs'
130
+ does not swallow 'docs-old'."""
131
+ return path == prefix or path.startswith(prefix.rstrip("/") + "/")
132
+
133
+
134
+ def _writes_collide(a: str, b: str) -> bool:
135
+ """Two writes: tokens collide when literally equal, or when either side's /** or /*
136
+ prefix contains the other token (or the other's own prefix) — cheap prefix-aware overlap,
137
+ no glob engine."""
138
+ val_a, is_prefix_a = _normalize_write_token(a)
139
+ val_b, is_prefix_b = _normalize_write_token(b)
140
+ if val_a == val_b:
141
+ return True
142
+ if is_prefix_a and _path_has_prefix(val_a, val_b):
143
+ return True
144
+ if is_prefix_b and _path_has_prefix(val_b, val_a):
145
+ return True
146
+ return False
147
+
148
+
149
+ def _convene_dispatch(text: str) -> int:
150
+ blocks = _scan_blocks(text, LANE_TRIGGER)
151
+
152
+ claims: list[tuple[str, str]] = [] # (writes: token, lane name)
153
+ for block in blocks:
154
+ name = _lane_name(block)
155
+ for path in _field_paths(block, "writes"):
156
+ for other_path, other_name in claims:
157
+ if other_name != name and _writes_collide(path, other_path):
158
+ print(f"FAIL convene: writes: '{other_path}' ({other_name}) overlaps '{path}' ({name}) — must be exclusive to one lane.", file=sys.stderr)
159
+ print(" Two lanes writing overlapping paths is the exact hazard dispatch exists to prevent — repartition writes: before convening.", file=sys.stderr)
160
+ return 1
161
+ claims.append((path, name))
162
+
163
+ complete = sum(
164
+ 1 for b in blocks
165
+ if all(re.search(rf"(?m)^[ \t]*(?:[-*][ \t]+)?{f}[ \t]*:[ \t]*\S", b, re.IGNORECASE) for f in ("covers", "worker", "writes", "returns"))
166
+ )
167
+
168
+ if complete == 0:
169
+ print("FAIL convene: checklist.md has no LANE carrying all of covers / worker / writes / returns.", file=sys.stderr)
170
+ print(" Decomposition is a precondition for the room, never a product of it — cut the lanes, then convene.", file=sys.stderr)
171
+ return 1
172
+ print(f"PASS convene: {complete} lane(s) fully specified, no writes: overlap — roster may be spawned")
173
+ return 0
174
+
175
+
176
+ def convene(session_dir: Path) -> int:
177
+ """Gate the spawn batch on a real decomposition. The anchor is pinned at open time (R1 needs it
178
+ before the ledger can quote it), so the checklist cannot gate file creation — it gates convening,
179
+ which is where 'N agents circling an uncut question' actually costs money."""
180
+ checklist = session_dir / "checklist.md"
181
+ if not checklist.is_file():
182
+ print(f"council_open.py --convene: no checklist.md in {session_dir}", file=sys.stderr)
183
+ return 1
184
+
185
+ text = re.sub(r"<!--.*?-->", "", checklist.read_text(encoding="utf-8", errors="replace"), flags=re.DOTALL)
186
+
187
+ if read_mode(session_dir) == "dispatch":
188
+ return _convene_dispatch(text)
189
+ return _convene_council(text)
190
+
191
+
192
+ def main():
193
+ if len(sys.argv) == 3 and sys.argv[1] == "--convene":
194
+ sys.exit(convene(Path(sys.argv[2])))
195
+
196
+ root = Path(os.environ.get("AKI_COUNCIL_ROOT", Path.home() / ".aki" / "agent-council"))
197
+ retention_days = int(os.environ.get("AKI_COUNCIL_RETENTION_DAYS", "30"))
198
+
199
+ args = sys.argv[1:]
200
+ mode = "council"
201
+ if args and args[0] == "--dispatch":
202
+ mode = "dispatch"
203
+ args = args[1:]
204
+
205
+ if len(args) < 2:
206
+ print("usage: council_open.py [--dispatch] <slug> <owner-message> (use '-' to read the message from stdin)", file=sys.stderr)
207
+ print(" the owner's message is verbatim and mandatory — a room with no anchor cannot be opened", file=sys.stderr)
208
+ sys.exit(2)
209
+
210
+ slug_in = args[0]
211
+ anchor_in = args[1]
212
+
213
+ if anchor_in == "-":
214
+ anchor_in = sys.stdin.read()
215
+
216
+ if not anchor_in.strip():
217
+ print("council_open.py: the owner message is empty — nothing to anchor to", file=sys.stderr)
218
+ sys.exit(2)
219
+
220
+ head = ""
221
+ try:
222
+ result = subprocess.run(
223
+ ["git", "rev-parse", "--show-toplevel"],
224
+ capture_output=True,
225
+ text=True,
226
+ check=True,
227
+ )
228
+ project_raw = Path(result.stdout.strip()).name
229
+ head = "@" + subprocess.run(
230
+ ["git", "rev-parse", "--short", "HEAD"],
231
+ capture_output=True,
232
+ text=True,
233
+ check=True,
234
+ ).stdout.strip()
235
+ except Exception:
236
+ project_raw = Path.cwd().name
237
+
238
+ project = slugify(project_raw)
239
+ slug = slugify(slug_in)
240
+ stamp = datetime.now().strftime("%Y.%m.%d-%H%M")
241
+ session = f"{stamp}-{slug}"
242
+ session_dir = root / project / session
243
+
244
+ # --- prune first ---------------------------------------------------------
245
+ pruned = 0
246
+ if root.exists():
247
+ cutoff_time = time.time() - (retention_days * 86400)
248
+ for proj_dir in root.iterdir():
249
+ if not proj_dir.is_dir():
250
+ continue
251
+ for old in proj_dir.iterdir():
252
+ if not old.is_dir():
253
+ continue
254
+ if old.stat().st_mtime < cutoff_time:
255
+ shutil.rmtree(old)
256
+ pruned += 1
257
+ if proj_dir.is_dir() and not any(proj_dir.iterdir()):
258
+ proj_dir.rmdir()
259
+
260
+ # --- seed ----------------------------------------------------------------
261
+ session_dir.mkdir(parents=True, exist_ok=True)
262
+
263
+ chat_md = session_dir / "chat.md"
264
+ if not chat_md.exists():
265
+ chat_md.write_bytes(
266
+ f"""# council · {session}
267
+ `{rule_stamp()} · {project}{head} · claude-session {os.environ.get("CLAUDE_CODE_SESSION_ID") or "n/a"} · mode {mode}`
268
+ <!-- Written once at open, never updated: what this room ran under, not what is current. The session id is the harness transcript's filename, so close-out accounting reads one known file instead of guessing. -->
269
+
270
+ ## anchor
271
+ <!-- The owner's message, verbatim. IMMUTABLE: never edited, never paraphrased, never replaced by the lead's restatement. Every REQ in checklist.md quotes a fragment of the text below. -->
272
+
273
+ {anchor_in}
274
+
275
+ ## pinned
276
+
277
+ PROBLEM — (lead: one paragraph, what was actually asked. This is a working restatement and it is NOT the anchor; where the two disagree, the anchor above wins and the restatement is the thing that is wrong)
278
+ CONTEXT — (lead: what a specialist must know that it cannot read from the repo)
279
+ GOAL — (lead: what "done" looks like for the whole session)
280
+ ROSTER — (lead: every agent name, what it owns, and its turn-number block)
281
+
282
+ <!-- Lead appends CHECKPOINT lines here when the room drifts or gets expensive.
283
+ Everything below this block is the room itself, one turn per '### ' header. -->
284
+ """.encode("utf-8")
285
+ )
286
+
287
+ checklist_md = session_dir / "checklist.md"
288
+ if not checklist_md.exists():
289
+ ledger = """## requirement ledger
290
+ <!-- Lead-owned, filled before decomposition. One line per distinct owner requirement, numbered REQ-1.. — compressed, never weakened.
291
+ Each REQ carries a "quoted fragment" copied from chat.md's anchor block; that is what makes it a requirement rather than an interpretation.
292
+ Every item below names the REQs it covers. An uncovered REQ is a decomposition bug, not a footnote. -->
293
+ """
294
+ if mode == "dispatch":
295
+ work = """## lanes
296
+ <!-- Lead-owned. `writes:` is exclusive to its lane — a path named in two lanes' writes: is a gate failure, not a style nit.
297
+ Every REQ above must be covered by some lane.
298
+ `returns:` exists because the lead merges the lanes at the end and cannot merge a shape no lane ever specified.
299
+
300
+ ### LANE 1 · <short name>
301
+ - covers: REQ-1, REQ-2
302
+ - worker: <agent-type> (<tier>)
303
+ - writes: <exact file paths this lane may write — exclusive to it>
304
+ - reads: <paths and rule files named in its brief>
305
+ - returns: <the exact shape the lead will merge>
306
+ -->
307
+ """
308
+ else:
309
+ work = """## items
310
+ <!-- Lead-owned. Every item carries owner, challenger, closing criterion, the
311
+ REQs it covers, and a <=3 line rationale written at closure. This file is
312
+ what Phase B reads; the durable copy goes to docs/plan/ per RULE-docs.md B1. -->
313
+ """
314
+ checklist_md.write_bytes(f"# checklist · {session}\n\n{ledger}\n{work}".encode("utf-8"))
315
+
316
+ print(f"session: {session}")
317
+ if pruned > 0:
318
+ print(f"pruned: {pruned} stale session(s) older than {retention_days}d")
319
+ print(str(session_dir))
320
+
321
+
322
+ if __name__ == "__main__":
323
+ main()
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env python3
2
+ # akiflow — read chat.md without pulling the whole room into the lead's context.
3
+ #
4
+ # The room is a live meeting, read in time order like a human would. This script exists so that "in time order" does not have to mean "all of it, every time".
5
+ #
6
+ # Usage: council_read.py <chat.md> [options]
7
+ # --index list turn headers only (time, agent, turn no.)
8
+ # --pinned print the pinned header block only
9
+ # --stats turns and bytes per agent — the lead's drift/cost signal, read before deciding what to pull
10
+ # --grep <pattern> matching lines only, each tagged with the turn it came from — locate first, then read that turn
11
+ # --turn <n[,m..]> print exactly these turns, by number
12
+ # --agent <name> only turns by this agent
13
+ # --from <n> only turns numbered >= n
14
+ # --tail <n> only the last n matching turns
15
+ # Options combine; --index/--pinned/--stats/--grep are exclusive modes. --agent and --from narrow --grep too.
16
+ #
17
+ # A read is not a one-time cost: everything pulled into context is re-sent on every later turn, so a read of size S at turn t of a T-turn run is charged about S x (T - t). That is why locating with --grep and then pulling one turn beats reading the room.
18
+ import os
19
+ import re
20
+ import sys
21
+ from pathlib import Path
22
+ from collections import Counter
23
+
24
+
25
+ class Turn:
26
+ def __init__(self, header: str, line_no: int):
27
+ parts = header.split()
28
+ self.header = header
29
+ self.line_no = line_no
30
+ self.agent = parts[2] if len(parts) >= 3 else ""
31
+ self.number = next((int(p[1:]) for p in parts if p.startswith("#") and p[1:].isdigit()), 0)
32
+ self.body: list[str] = []
33
+
34
+ def text(self) -> str:
35
+ return "\n".join([self.header] + self.body)
36
+
37
+
38
+ def parse_turns(lines: list[str]) -> list[Turn]:
39
+ turns: list[Turn] = []
40
+ for idx, line in enumerate(lines, start=1):
41
+ if line.startswith("### "):
42
+ turns.append(Turn(line, idx))
43
+ elif turns:
44
+ turns[-1].body.append(line)
45
+ return turns
46
+
47
+
48
+ def main():
49
+ if len(sys.argv) < 2 or not Path(sys.argv[1]).is_file():
50
+ print("usage: council_read.py <chat.md> [--index|--pinned|--stats|--grep P] [--turn N,M] [--agent N] [--from N] [--tail N]", file=sys.stderr)
51
+ sys.exit(2)
52
+
53
+ file = Path(sys.argv[1])
54
+ mode = "blocks"
55
+ pattern = ""
56
+ wanted: set[int] = set()
57
+ agent = ""
58
+ from_turn = None
59
+ tail_n = None
60
+
61
+ i = 2
62
+ while i < len(sys.argv):
63
+ arg = sys.argv[i]
64
+ if arg == "--index":
65
+ mode = "index"
66
+ elif arg == "--pinned":
67
+ mode = "pinned"
68
+ elif arg == "--stats":
69
+ mode = "stats"
70
+ elif arg in ("--grep", "--turn", "--agent", "--from", "--tail"):
71
+ i += 1
72
+ if i >= len(sys.argv):
73
+ print(f"{arg} needs a value", file=sys.stderr)
74
+ sys.exit(2)
75
+ value = sys.argv[i]
76
+ if arg == "--grep":
77
+ mode = "grep"
78
+ pattern = value
79
+ elif arg == "--turn":
80
+ wanted = {int(n) for n in re.findall(r"[0-9]+", value)}
81
+ elif arg == "--agent":
82
+ agent = value
83
+ elif arg == "--from":
84
+ from_turn = int(value)
85
+ else:
86
+ tail_n = int(value)
87
+ else:
88
+ print(f"unknown option: {arg}", file=sys.stderr)
89
+ sys.exit(2)
90
+ i += 1
91
+
92
+ lines = file.read_text(encoding="utf-8", errors="replace").splitlines()
93
+ turns = parse_turns(lines)
94
+
95
+ if mode == "pinned":
96
+ for line in lines:
97
+ if line.startswith("### "):
98
+ break
99
+ print(line)
100
+ return
101
+
102
+ if mode == "index":
103
+ for turn in turns:
104
+ print(f"{turn.line_no}:{turn.header}")
105
+ return
106
+
107
+ if mode == "stats":
108
+ counts = Counter(t.agent for t in turns)
109
+ weight = Counter()
110
+ for turn in turns:
111
+ weight[turn.agent] += len(turn.text().encode())
112
+ print(f"{'turns':>7} {'bytes':>9} agent")
113
+ for name, count in counts.most_common():
114
+ print(f"{count:>7} {weight[name]:>9,} {name}")
115
+ print(f"turns: {len(turns)} bytes in turns: {sum(weight.values()):,} file: {file.stat().st_size:,}")
116
+ return
117
+
118
+ selected = [t for t in turns if (not agent or t.agent == agent) and (from_turn is None or t.number >= from_turn) and (not wanted or t.number in wanted)]
119
+
120
+ if mode == "grep":
121
+ try:
122
+ regex = re.compile(pattern, re.IGNORECASE)
123
+ except re.error as exc:
124
+ print(f"bad --grep pattern: {exc}", file=sys.stderr)
125
+ sys.exit(2)
126
+ hits = 0
127
+ for turn in selected:
128
+ for offset, line in enumerate(turn.body, start=1):
129
+ if regex.search(line):
130
+ hits += 1
131
+ print(f"#{turn.number} {turn.agent} L{turn.line_no + offset}: {line.strip()}")
132
+ print(f"-- {hits} line(s) in {len(selected)} turn(s) searched; read one with --turn <n>", file=sys.stderr)
133
+ return
134
+
135
+ if tail_n is not None and len(selected) > tail_n:
136
+ selected = selected[-tail_n:]
137
+
138
+ for turn in selected:
139
+ print(turn.text())
140
+
141
+
142
+ if __name__ == "__main__":
143
+ try:
144
+ main()
145
+ except BrokenPipeError:
146
+ # Piping into head/less is the intended use; without this the reader gets a traceback and goes back to reading the file whole.
147
+ os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
148
+ sys.exit(0)