@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.
- package/CHANGELOG.md +835 -0
- package/LICENSE +21 -0
- package/README.md +356 -0
- package/claude/CLAUDE.md +40 -0
- package/claude/agents/aki-challenger.md +38 -0
- package/claude/agents/aki-conduct.md +54 -0
- package/claude/agents/aki-hands.md +59 -0
- package/claude/agents/aki-judge.md +37 -0
- package/claude/agents/aki-maker.md +36 -0
- package/claude/fragments/settings.akidoc.fragment.json +15 -0
- package/claude/hooks/aki-update-check.mjs +160 -0
- package/claude/hooks/aki_version_check.mjs +83 -0
- package/docs/ref/macos-codesign-tcc.md +59 -0
- package/install.mjs +1067 -0
- package/install.ps1 +11 -0
- package/install.sh +12 -0
- package/package.json +52 -0
- package/payload/GEMINI.md +147 -0
- package/payload/METHOD-audit-flow.md +147 -0
- package/payload/METHOD-audit-subtraction.md +67 -0
- package/payload/METHOD-audit-zero-trust.md +49 -0
- package/payload/METHOD-deep-think.md +172 -0
- package/payload/METHOD-proportionality.md +62 -0
- package/payload/METHOD-ux-psych.md +60 -0
- package/payload/RULE-agent-behavior.md +138 -0
- package/payload/RULE-biz.md +51 -0
- package/payload/RULE-coding.md +130 -0
- package/payload/RULE-content-write.md +54 -0
- package/payload/RULE-db-design.md +26 -0
- package/payload/RULE-docs.md +144 -0
- package/payload/RULE-pattern-core.md +80 -0
- package/payload/RULE-release.md +215 -0
- package/payload/RULE-seo.md +173 -0
- package/payload/RULE-stack-akiNuxtCf.md +179 -0
- package/payload/RULE-stack-tauri.md +59 -0
- package/payload/RULE-ui-pattern.md +167 -0
- package/payload/index.md +91 -0
- package/skills/aki-article-writer/SKILL.md +50 -0
- package/skills/aki-article-writer/references/article-workflow.md +377 -0
- package/skills/akidevsync-notes/SKILL.md +48 -0
- package/skills/akidevsync-notes/scripts/notes_cli.py +212 -0
- package/skills/akiflow/SKILL.md +221 -0
- package/skills/akiflow/references/harness-facts.md +215 -0
- package/skills/akiflow/scripts/council-cost.sh +4 -0
- package/skills/akiflow/scripts/council-open.sh +4 -0
- package/skills/akiflow/scripts/council-read.sh +4 -0
- package/skills/akiflow/scripts/council-verify.sh +4 -0
- package/skills/akiflow/scripts/council_cost.py +149 -0
- package/skills/akiflow/scripts/council_open.py +323 -0
- package/skills/akiflow/scripts/council_read.py +148 -0
- package/skills/akiflow/scripts/council_verify.py +315 -0
- package/skills/akiflow/scripts/scythe.py +307 -0
- package/skills/akiflow/scripts/scythe.sh +4 -0
- package/skills/akigitcommit/SKILL.md +85 -0
- package/skills/akihelp/SKILL.md +47 -0
- package/skills/akihtmlreport/SKILL.md +59 -0
- package/skills/akilint/SKILL.md +29 -0
- package/skills/akirule/SKILL.md +155 -0
- package/skills/akiship/SKILL.md +55 -0
- 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)
|