diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 5fbde59..bf12d4d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ck", "description": "Code Katz personas and workflows for Claude Code: the product-definition pipeline (/ck:opportunity, market-research, brief, prd, team, roadmap, architecture, brand-guide, design), /ck:panel (three lenses on three models), /ck:plan and /ck:start (a staged plan, then one named session per stage), /ck:do (one small task, quick and in the chat), /ck:next, /ck:claude-md (a project CLAUDE.md from ck's template), /ck:cost (what a session cost, by lane and kind of token, also posted to its pull request at merge), the memory gate (a proposal and your approval before any memory is saved), a team per project (/ck:team invite and drop, kept in .claude/ck-team.conf), and the full persona roster, a complete cross-domain team, as ck: subagents and /ck: switch commands.", - "version": "0.1.26", + "version": "0.1.27", "author": { "name": "Code Katz" } } diff --git a/DEVLOG.md b/DEVLOG.md index 21e2b77..6b09448 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -5,6 +5,31 @@ Auto-maintained via [claude-devlog-skill](https://github.com/code-katz/claude-de --- +## [2026-11-05] 0.1.27: the model and effort follow the job + +**Category:** `feature` +**Tags:** `ck`, `plan`, `start`, `tiers`, `effort`, `cost`, `claude-md` +**Risk Level:** `low` +**Breaking Change:** `no` (a Who cell in the older form is still read, by its model's tier) + +### Summary +F1 of the releases page after 0.1.24, approved by Will as D1 on 2026-11-05. A stage ran on its owner's tier, so Akira worked on Fable whether the job was a design call or a rename, every stage inherited its coordinator's effort, and Will ruled on d20mob rows by hand. 45% of his spend ran on Fable and 62% at `xhigh`. The lanes test found one persona on the model that fits the job wrote better code in five of six blind verdicts, and the effort evidence (Anthropic's SWE-bench Pro runs, cost per solved task) set the point each kind of job runs at. + +### Detail +- **Job words.** Who reads `, `, one of `design` (Opus 5.5 at high), `build`, `review` and `write` (Opus at medium) and `edit` (Sonnet 5.5 at medium). Each is a `job` line in `tiers.conf`, so moving a job to another point is one line. `, , ` pins any other point on the four-model, five-effort grid. The persona lines stay for `/ck:do`, the workflows and `/ck:panel`. +- **Older plans.** `, ` is read by its tier: Fable as `design`, Opus as `build`, Sonnet or Haiku as `edit`. `plan.py check` adds one note line saying how many rows give that form. No plan file is rewritten. +- **The card.** `plan.py card` names the session `[P2-S4] [Akira design] `, prints `job`, the model's id and `effort`, and the model check now covers both. A build card carries Will's goal line, "As much coded as necessary, as little code as possible." `rows` and `ready` print the point, such as `Opus high`, where they printed the model word. +- **Opening and checking a stage.** `/ck:start` passes `model` and `effort` on the chip. A chip cannot open above its coordinator on either axis; when the point is above, the coordinator says so and the stage's own check stops it. A session reads its own effort with `get_session` and `self`, checked in this build. The board's refresh sets a running stage's effort as well as its model. +- **The Fable rule.** The plan contract and the draft workflow pin `Fable high` only for a stage that Opus at high has already fallen short on, or, on trial, for security or concurrency code, and never `xhigh` or `max` unless the author asks. +- **How we work 0.1.27.** The session title's tag is `[{persona} {job}]`, and "The stage's model" becomes "The stage's model and effort", with the five points in words for a brief that names none. +- **Cost by point.** `cost.py` keeps each reply's effort, logs the cost by model and effort with each pull request, and `/ck:cost history` adds a table of cost per point for pull requests merged from 0.1.27 on. + +### Open +- d20mob and hireling take How we work 0.1.27 with `/ck:claude-md`, on their own pull requests, when Will says so. Until then their own rule reads the tag's second word as a model; the card's check covers ck's cards. +- Left for later: re-running a failed build stage at high, moving the six Fable personas in `tiers.conf`, and job words in `/ck:do`. + +--- + ## [2026-11-05] 0.1.26: the drawing's link in How we work **Category:** `fix` diff --git a/README.md b/README.md index 01e47cc..a7033cb 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Phase one, first release in progress. What runs today: | `/ck:next` | Shows the whole pipeline as a table (each step in order, what it does, the document it writes, and where your project stands), then says what to run next in one sentence | Built | | `/ck:brief <idea>` | Toni runs a short market pass, River writes `docs/brief.md` (from your sentence, or from `docs/opportunity.md`), a checker validates it; you review on a page you can comment on | Built; drilled 2026-10-04 on a real project | | `/ck:plan [work]` | Quinn lays the work out as a staged plan: phases, then stages, one job and one owner per row, each with its state. With nothing after it, the whole release from the roadmap (`docs/build-plan.md`); with a piece of work after it, that one item (`docs/plan/`). You review it on a page you can comment on | Built; not yet run on a real project | -| `/ck:start [stage]` | Opens the plan's sessions and keeps its board. For a stage it offers a card that starts a session for that one job, named from the row, such as `[A31-P2-S1] [Akira Fable] Make the creature tables`; with nothing after it, it updates the State column from the sessions and their pull requests and offers every row that could start now. With `fix <stage> <what is wrong>` it offers a card for a session that fixes one fault in a stage that is done, named such as `[A31-P1-S5 fix] [Akira Fable] Mend the scan's natural 1`; the stage stays done. Every new session checks its own model before it works. You click each card | Built; not yet run on a real project | +| `/ck:start [stage]` | Opens the plan's sessions and keeps its board. For a stage it offers a card that starts a session for that one job, named from the row, such as `[A31-P2-S1] [Akira design] Make the creature tables`; with nothing after it, it updates the State column from the sessions and their pull requests and offers every row that could start now. With `fix <stage> <what is wrong>` it offers a card for a session that fixes one fault in a stage that is done, named such as `[A31-P1-S5 fix] [Akira design] Mend the scan's natural 1`; the stage stays done. The row's job word (design, build, edit, review, write) sets each session's model and effort, from `tiers.conf`, and every new session checks both before it works. You click each card | Built; not yet run on a real project | | `/ck:do <task>` | The quick path, all in the chat: a plan of a few lines (who does what), you say go, the right personas work in the background on their own tiers, a mockup comes back as a link when you ask for one first, and a build waits on its own branch until you say merge. No documents and no review pages | Built; tried in d20mob on 2026-10-27 | | `/ck:claude-md` | Writes or updates the "How we work" section of this project's `CLAUDE.md` from ck's template (`templates/how-we-work/`), tailored to the project: its lanes, main folder, records and secrets filled in, a rule that does not fit changed or left out. It sorts the project's memory into keep, retire, or move. You review the section on a page you can comment on; it is written on a branch with a draft pull request only when you approve. The rest of `CLAUDE.md` is left as it is, and nothing runs unless you type the command | Built; not yet run on a real project | | `/ck:cost [history \| <session id>]` | What this session has cost so far: a table with a row per lane (the main session, each persona, Claude Code's own background calls) and a column per kind of token (input, output, cache write, cache read), each cell in tokens and dollars, priced from Anthropic's pricing page and checked against Claude Code's own figure. Once the session has a pull request, a line after each answer shows the cost so far. At merge, the session gets one line with the pull request's cost, and the pull request gets a comment with the table and the numbers as hidden data. A pull request's cost is what the sessions that worked on its branch spent since their previous merge; a session that only merged it is shown apart. `history` lists each pull request's cost | Built; tried on d20mob sessions | diff --git a/scripts/cost.py b/scripts/cost.py index b4ddf64..3debb52 100644 --- a/scripts/cost.py +++ b/scripts/cost.py @@ -118,8 +118,10 @@ def add(row, t, dollars): def read(transcript, prices): - """{(lane, model): {kind: [tokens, dollars or None]}}, the title, Claude Code's record, the pull request link.""" + """{(lane, model): {kind: [tokens, dollars or None]}}, the title, Claude Code's record, the pull request link, and + {"<model> <effort>": [tokens, dollars or None]} over the transcripts' replies, by the effort each one ran at.""" rows = defaultdict(lambda: {k: [0, 0.0] for k in KINDS}) + points = defaultdict(lambda: {k: [0, 0.0] for k in KINDS}) names, state, pr, seen = {}, None, None, set() base = Path(transcript) files = [(base, "main")] @@ -138,19 +140,21 @@ def read(transcript, prices): continue kind = d.get("type") if kind == "assistant" and isinstance(d.get("message"), dict): - last[d["message"].get("id") or id(d)] = d["message"] + last[d["message"].get("id") or id(d)] = d["message"], d.get("effort") elif lane == "main" and kind in ("custom-title", "ai-title"): names[kind] = d.get("customTitle") or d.get("aiTitle") or names.get(kind) elif lane == "main" and kind == "cost-state": state = d elif lane == "main" and kind == "pr-link": pr = d - for key, m in last.items(): + for key, (m, effort) in last.items(): if key in seen or not isinstance(m.get("usage"), dict) or m.get("model") == "<synthetic>": continue seen.add(key) t = tokens(m["usage"]) - add(rows[(lane, m.get("model"))], t, priced(prices, m.get("model"), t)) + dollars = priced(prices, m.get("model"), t) + add(rows[(lane, m.get("model"))], t, dollars) + add(points[" ".join(filter(None, (short(m.get("model")), effort)))], t, dollars) for model, v in ((state or {}).get("modelUsage") or {}).items(): mine = combine(r for (_, mo), r in list(rows.items()) if mo == model) extra = {k: max(0, (v.get(f) or 0) - mine[k][0]) for k, f in zip(KINDS, FIELDS)} @@ -165,7 +169,7 @@ def read(transcript, prices): d["write"] = min(max(rest, d["write"]), hi) add(rows[("background", model)], t, d) title = names.get("custom-title") or names.get("ai-title") or "Session " + base.stem[:8] - return rows, title, state, pr + return rows, title, state, pr, {p: list(total(r)) for p, r in points.items()} def combine(rows): @@ -207,7 +211,7 @@ def short(model): def table(session, prices, source): - rows, title, state, _ = session + rows, title, state = session[:3] out = [f"**{title} cost summary**", "", "| Lane | Model " + HEAD, "|---|---|--:|--:|--:|--:|--:|"] for lane, model in sorted(rows, key=lambda r: (r[0] != "main", r[0] == "background", r[0], r[1] or "")): r = rows[(lane, model)] @@ -239,7 +243,7 @@ def newest(cwd): def stop(hook, prices, source): - rows, _, _, pr = read(hook["transcript_path"], prices) + rows, _, _, pr, _ = read(hook["transcript_path"], prices) if not pr: return models = {m: total(combine(r for (_, mo), r in rows.items() if mo == m)) for _, m in rows} @@ -347,7 +351,7 @@ def version(): def charge(path, prices, log): """One session's totals now, and what it spent since its previous row in the log.""" - rows, title, state, pr = read(path, prices) + rows, title, state, pr, points = read(path, prices) whole = combine(rows.values()) n, d = total(whole) prev = next((e for e in reversed(log) if e.get("session") == Path(path).stem), None) @@ -356,7 +360,8 @@ def charge(path, prices, log): return {"session": Path(path).stem, "title": title, "tokens": n, "dollars": d, "cost_tokens": n - (prev.get("tokens") or 0) if prev else n, "cost": minus(d, prev.get("dollars")) if prev else d, - "by_kind": {k: whole[k] for k in KINDS}, "by_model": models, "opening": opening, "requests": requests, + "by_kind": {k: whole[k] for k in KINDS}, "by_model": models, "by_point": points, + "opening": opening, "requests": requests, "table": (rows, title, state, pr)} @@ -387,7 +392,8 @@ def gh(*args): body += ["", table(b["table"], prices, source)] if b["cost"] != b["dollars"]: body.append(f"The table is the whole session; {money(b['cost'])} of it came after its previous merge.") - keep = ("session", "title", "cost", "cost_tokens", "dollars", "tokens", "by_kind", "by_model", "opening", "requests") + keep = ("session", "title", "cost", "cost_tokens", "dollars", "tokens", "by_kind", "by_model", "by_point", "opening", + "requests") data = {"pr": name, "url": info["url"], "commit": commit, "merged_at": info.get("mergedAt"), "cost": cost, "tokens": sum(b["cost_tokens"] for b in built), "ck": version(), "builders": [{k: b[k] for k in keep} for b in built], "merged_by": merged and {k: merged[k] for k in keep}} @@ -400,7 +406,8 @@ def gh(*args): for role, s in [("built", b) for b in built] + ([("merged", merged)] if merged else []): f.write(json.dumps({"date": time.strftime("%Y-%m-%d"), "url": info["url"], "commit": commit, "session": s["session"], "title": s["title"], "tokens": s["tokens"], "dollars": s["dollars"], "role": role, - "ck": data["ck"], "opening": s["opening"], "requests": s["requests"]}) + "\n") + "ck": data["ck"], "opening": s["opening"], "requests": s["requests"], + "by_point": s["by_point"]}) + "\n") if merged: line = (f"ck cost for {name}: {money(cost)}, built by {', '.join(b['title'] for b in built)}. " @@ -415,12 +422,17 @@ def gh(*args): def history(): """One row per pull request: its cost, and the merging session's when another session built it. A row in the log holds a session's running total, so each cost is that row minus the session's row before it.""" - log, last, prs = entries(), {}, {} + log, last, prs, points = entries(), {}, {}, defaultdict(float) for e in log: if not e.get("url") or not e.get("session"): continue prev = last.get(e["session"]) spent = minus(e.get("dollars"), prev.get("dollars")) if prev else e.get("dollars") + if e.get("role", "built") == "built" and "by_point" in e: + # A row holds running totals, so a point's cost is this row's figure less the session's previous one. + before = (prev or {}).get("by_point") or {} + for p, (_, d) in e["by_point"].items(): + points[p] = plus([points[p], minus(d, (before.get(p) or [0, 0.0])[1])]) last[e["session"]] = e p = prs.setdefault(e["url"], {"date": e.get("date"), "commit": e.get("commit"), "by": [], "cost": 0.0, "coord": None}) if e.get("role", "built") == "built": @@ -437,6 +449,9 @@ def history(): + ("" if p["coord"] is None else money(p["coord"])) + " |") built, coord = plus(p["cost"] for p in prs.values()), plus(p["coord"] for p in prs.values() if p["coord"] is not None) out += ["", f"Pull requests: {money(built)}. Coordination: {money(coord)}."] + if points: + out += ["", "**Pull requests by model and effort**, from ck 0.1.27 on", "", "| Model and effort | Cost |", "|---|--:|"] + out += [f"| {p} | {money(d)} |" for p, d in sorted(points.items(), key=lambda i: -(i[1] or 0))] prices, _ = pricing() after, sessions = 0.0, 0 for s, e in last.items(): diff --git a/scripts/plan.py b/scripts/plan.py index 6106e94..7014014 100755 --- a/scripts/plan.py +++ b/scripts/plan.py @@ -12,6 +12,10 @@ plan.py board FILE [--all] plan.py card FILE ID [--fix TEXT --title TITLE] [--plugin-root DIR] [--roster PATH] +A Who cell is You, Open, or <Persona>, <job>: one of the job words in tiers.conf, each a model and an +effort. <Persona>, <job>, <Model> <effort> pins another point. A cell in the older form, <Persona>, +<Model>, is read as the job of that model's tier: Fable as design, Opus as build, Sonnet or Haiku as edit. + Standard library only. Output goes to stdout. A usage error, an unreadable file, or a file that is not a staged plan is one plain line on stderr and exit status 2, never a traceback. A command that will not do what was asked of a plan it can read (a row it cannot write, a @@ -42,9 +46,12 @@ REQUIRED = LABELS[:7] KINDS = ['build', 'drawing', 'words', 'tests', 'check', 'design round', 'yours'] MODELS = ['Fable', 'Opus', 'Sonnet', 'Haiku'] +EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] +OLD_JOB = {'Fable': 'design', 'Opus': 'build', 'Sonnet': 'edit', 'Haiku': 'edit'} # the older Who form NAME_LIMIT = 59 WORD_LIMIT = 3500 ROSTER = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'profiles', 'ROSTER.md') +TIERS = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'tiers.conf') def off_team(path): @@ -68,7 +75,8 @@ def off_team(path): REF_RE = re.compile('(?:P' + N + '-)?S' + N) # S1 or P1-S3, as the contract writes them ID_RE = re.compile('(?:(.+)-)?P' + N + '-S' + N, re.I) # P2-S1 or A31-P2-S1, typed by a caller SEP_RE = re.compile(r',\s*and\b|,|\band\b') # between the names of a list -WHO_RE = re.compile(r'([^,\s][^,]*), (' + '|'.join(MODELS) + ')') +WHO_RE = re.compile(r'([^,\s][^,]*), (?:([a-z]+)(?:, (%s) (%s))?|(%s))' + % ('|'.join(MODELS), '|'.join(EFFORTS), '|'.join(MODELS))) DONE_RE = re.compile(r'done(?: \(([0-9]+)\))?') # the number is the stage's pull request APPROVED_RE = re.compile('approved [0-9]{4}-[0-9]{2}-[0-9]{2}') STRAY_RE = re.compile('[\ud800-\udfff]') # a byte the command line could not read as text @@ -88,13 +96,16 @@ def off_team(path): 'tests': 'This is a tests stage: tests only, and no change to product code.', 'check': 'This is a check stage: you change nothing, and you report what you find.', } -# One paragraph of every card's prompt. No tool can choose a new session's model before its first -# turn, so the session is told which model its stage is planned for and checks for itself. -MODEL_CHECK = ('Before anything else, check your own model. This stage is planned for %(model)s. If you can tell ' - 'that your model is not one of the %(model)s models, do no work: say "This session is on <your model>, and the plan ' - 'puts this stage on %(model)s. Pick %(model)s in the model menu, then say go." and stop. When the ' - 'author then says go, carry on, on whatever model you are then. If you cannot tell which model you ' - 'are, say so in one line and carry on.') +# One paragraph of every card's prompt. A chip cannot open above its coordinator's model or effort, +# so the session is told the point its stage is planned for and checks for itself. +MODEL_CHECK = ('Before anything else, check your own model and effort. This stage is planned for %(model)s at ' + '%(effort)s effort. If you can tell that your model is not one of the %(model)s models, or that your ' + 'effort is not %(effort)s, do no work: say "This session is on <your model> at <your effort> effort, ' + 'and the plan puts this stage on %(model)s at %(effort)s. Pick them in the model menu, then say go." ' + 'and stop. When the author then says go, carry on, on whatever model and effort you are then. If you ' + 'cannot tell your model or your effort, say so in one line and carry on.') +# A build card's line after its stage line: the author's own goal, aimed at over-building. +GOAL = 'As much coded as necessary, as little code as possible.' USAGE = { 'find': 'find [ID] [--root DIR]', 'check': 'check FILE [--roster PATH]', @@ -148,9 +159,40 @@ def is_rule(line): def who_parts(who): - """(persona, model) for a Who cell written <Persona>, <Model>; None for anything else.""" + """(persona, job, pinned model or None, pinned effort or None, True for the older form) for a Who cell + written <Persona>, <job>[, <Model> <effort>] or <Persona>, <Model>; None for anything else. The job word + is checked against tiers.conf by check and card, not here.""" m = WHO_RE.fullmatch(who) - return (m.group(1), m.group(2)) if m and m.group(1) == m.group(1).strip() else None + if not m or m.group(1) != m.group(1).strip(): + return None + if m.group(5): + return m.group(1), OLD_JOB[m.group(5)], None, None, True + return m.group(1), m.group(2), m.group(3), m.group(4), False + + +def read_tiers(path=TIERS): + """({job: (model word, effort)}, {model word: model id}) from tiers.conf; empty when it is not there.""" + jobs, ids = {}, {} + if os.path.isfile(path): + for line in read_text(path).split('\n'): + parts = line.split() + model = next((p for p in parts if p.startswith('claude-')), None) + word = next((w for w in MODELS if model and w.lower() in model), None) + if word: + ids.setdefault(word, model) + if len(parts) == 4 and parts[0] == 'job' and word and parts[3] in EFFORTS: + jobs[parts[1]] = (word, parts[3]) + return jobs, ids + + +def point(who, jobs): + """(model word, effort) a Who cell's stage runs at, or None when its job is not in tiers.conf.""" + parts = who_parts(who) + if not parts: + return None + if parts[2]: + return parts[2], parts[3] + return jobs.get(parts[1]) # ---------------------------------------------------------------- reading the plan @@ -198,11 +240,14 @@ def progress(self): @property def owner(self): - """(owner, model): You, Open, or the persona as written, and the model or None.""" + """(owner, job): You, Open, or the persona as written, and its job word or None.""" if self.who in ('You', 'Open'): return self.who, None - name, _, model = self.who.partition(',') - return name.strip(), model.strip() or None + parts = who_parts(self.who) + if parts: + return parts[0], parts[1] + name, _, rest = self.who.partition(',') + return name.strip(), rest.strip() or None class Entry: @@ -449,6 +494,10 @@ def join_list(names): return ', '.join(names[:-1]) + ' and ' + names[-1] if len(names) > 1 else ''.join(names) +def either(names): + return ', '.join(names[:-1]) + ' or ' + names[-1] + + def read_text(path): try: with open(path, 'rb') as handle: @@ -589,13 +638,18 @@ def check_rows(plan, roster, say): if count[key] > 1: say('%s appears %d times in the stage tables.' % (plan.shown(key), count[key])) # 5. Owners. + jobs = read_tiers()[0] for row in plan.rows: who = who_parts(row.who) if row.who in ('You', 'Open') or not row.sound: continue if not who: - say('%s: Who is "%s", which is not You, Open, or <Persona>, <Model> with a model of %s.' - % (tag(row), row.who, ', '.join(MODELS[:3]) + ', or ' + MODELS[3])) + say('%s: Who is "%s", which is not You, Open, or <Persona>, <job> with a job of %s, ' + 'and after it, if pinned, a model of %s and an effort of %s.' + % (tag(row), row.who, either(sorted(jobs) or ['design']), + either(MODELS), either(EFFORTS))) + elif who[1] not in jobs: + say('%s: Who is "%s", and %s is not a job word in tiers.conf.' % (tag(row), row.who, who[1])) elif roster is not None and who[0].lower() not in roster: say('%s: the persona %s is not on the roster.' % (tag(row), who[0])) @@ -744,14 +798,24 @@ def cmd_check(args, opts): if who and outside(who[0]): lines.append('note: %s: %s is not on this project\'s team; /ck:team invite %s adds them' % (tag(row), who[0], who[0].lower())) + old = [row for row in plan.rows if row.sound and (who_parts(row.who) or (0,) * 5)[4]] + if old: + lines.append('note: %d %s the older Who form, <Persona>, <Model>, read by its tier: Fable as design, Opus as ' + 'build, Sonnet or Haiku as edit' % (len(old), 'row gives' if len(old) == 1 else 'rows give')) if roster is None: lines.append('roster: not found, names not checked') return status, lines + ['words: %d' % plan.words] +def shown_point(row, jobs): + """The point a row runs at, as `Opus high`, or None.""" + at = point(row.who, jobs) + return ' '.join(at) if at else None + + def cmd_rows(args, opts): - plan = load(args[0]) - return 0, [tabbed([plan.full_id(row), row.stage, row.owner[0], row.owner[1], + plan, jobs = load(args[0]), read_tiers()[0] + return 0, [tabbed([plan.full_id(row), row.stage, row.owner[0], shown_point(row, jobs), row.entry.fields.get('Kind') if row.entry else None, # A State that is none of the four forms is not started and never ready. 'not started' if row.progress == 'unknown' else row.progress, @@ -760,8 +824,9 @@ def cmd_rows(args, opts): def cmd_ready(args, opts): - plan = load(args[0]) - return 0, [tabbed([plan.full_id(row), row.owner[0], row.owner[1], row.entry.fields.get('Kind'), row.entry.title]) + plan, jobs = load(args[0]), read_tiers()[0] + return 0, [tabbed([plan.full_id(row), row.owner[0], shown_point(row, jobs), row.entry.fields.get('Kind'), + row.entry.title]) for row in plan.rows if plan.ready(row)] # a ready row always has its entry @@ -901,7 +966,13 @@ def cmd_card(args, opts): if entry is None: return 1, ['%s has no entry under Stage by stage' % full] if not who_parts(row.who): - return 1, ['%s has no persona and model the plan allows: %s' % (full, row.who)] + return 1, ['%s has no persona and job the plan allows: %s' % (full, row.who)] + jobs, ids = read_tiers() + at = point(row.who, jobs) + if at is None: + return 1, ['%s: %s is not a job word in tiers.conf' % (full, row.owner[1])] + if at[0] not in ids: + return 1, ['%s: tiers.conf has no model id for %s' % (full, at[0])] if off_team(path)(row.owner[0]): return 1, ['%s: %s is not on this project\'s team. Type /ck:team invite %s, or give the row another owner ' 'with /ck:plan' % (full, row.owner[0], row.owner[0].lower())] @@ -914,10 +985,11 @@ def cmd_card(args, opts): if not fix and plan.follows(row) is None: # a fix never asks what its stage follows return 1, ['%s has a Follows that cannot be worked from (run check): %s' % (full, fields.get('Follows') or '(none)')] - persona, model = row.owner + persona, job = row.owner + check = MODEL_CHECK % {'model': at[0], 'effort': at[1]} if fix: words, title = fix - head = '[%s fix] [%s %s] ' % (full, persona, model) + head = '[%s fix] [%s %s] ' % (full, persona, job) session = head + title if len(session) > NAME_LIMIT: # a fix has no row, so check never sees its name return 1, ['the name "%s" is %d characters, %d over the %d a session name may have: give a title of ' @@ -965,7 +1037,7 @@ def cmd_card(args, opts): 'Stage %s: %s. It is done%s. This session fixes one fault in it: %s.' % ( full, entry.title, ', under pull request ' + number if number else '', title), '', - MODEL_CHECK % {'model': model}, + check, '', 'Do the work yourself in this session. Do not use helper agents, workflows, or other sessions unless ' 'the author tells you to. Fix this one fault and nothing else: the rest of the stage, and the stages ' @@ -1013,8 +1085,9 @@ def cmd_card(args, opts): '', ('Stage %s: %s. %s' % (full, entry.title, KIND_LINE.get(kind, 'This is a %s stage.' % kind if kind else '')) ).rstrip(), + ] + (['', GOAL] if kind == 'build' else []) + [ '', - MODEL_CHECK % {'model': model}, + check, '', 'Do the work yourself in this session. Do not use helper agents, workflows, or other sessions unless ' 'the author tells you to. Stay inside this stage: the stages before and after it belong to other ' @@ -1049,7 +1122,9 @@ def cmd_card(args, opts): 'id': full, 'title': session, 'persona': persona.lower(), - 'model': model, + 'job': job, + 'model': ids[at[0]], + 'effort': at[1], 'kind': kind, 'ready': ready, 'blocked_by': blocked, diff --git a/skills/plan-artifact/SKILL.md b/skills/plan-artifact/SKILL.md index 5c7f70e..b884fd8 100644 --- a/skills/plan-artifact/SKILL.md +++ b/skills/plan-artifact/SKILL.md @@ -46,8 +46,8 @@ One `### Phase <n>. <name>` heading per phase, numbered in order from 0 or from ``` | Stage | What | Who | State | |---|---|---|---| -| P2-S0 | The accounts screens, drawn in three versions for you to pick | Kai, Opus | not started; could start now | -| P2-S1 | The accounts screens, built from the one you picked | Sasha, Sonnet | not started; follows S0 | +| P2-S0 | The accounts screens, drawn in three versions for you to pick | Kai, design | not started; could start now | +| P2-S1 | The accounts screens, built from the one you picked | Sasha, build | not started; follows S0 | ``` 3. One line, `Ends when: <something the author can judge for themselves>`. @@ -56,7 +56,14 @@ The columns: - **Stage** is `P<phase>-S<stage>`. Stages are numbered from 0 within their phase, in order, with no gaps. Once a plan is approved a stage keeps its id: a stage that is added or split off later takes the next free number, at the end of its phase's table, and its Follows says where it sits in the order of work. In an item plan a stage's full id puts the item first, `A31-P2-S0`; the table still shows `P2-S0`. - **What** is the job in one plain phrase, with no file names. -- **Who** is one owner: `<Persona>, <Model>` with a persona from the roster and one of `Fable`, `Opus`, `Sonnet`, `Haiku`; or `You`, for a job that is the author's own (a decision, a seat to fill, a pick on a page); or `Open`, when nobody on the roster or the team document can own it yet. The model is the persona's own tier unless the author chose another for this job. +- **Who** is one owner: `<Persona>, <job>`, with a persona from the roster and one of five job words; or `You`, for a job that is the author's own (a decision, a seat to fill, a pick on a page); or `Open`, when nobody on the roster or the team document can own it yet. The job word, not the persona, sets the stage's model and effort, from its line in ck's `tiers.conf`: + - `design`, Opus at high effort: a design or research call, long reasoning across many files, a hard bug. + - `build`, Opus at medium: implementation with an approach to decide, such as a migration. + - `edit`, Sonnet at medium: a small or mechanical change, such as plain HTML, moving tests, or a rename. + - `review`, Opus at medium: reading a change or a plan for faults. + - `write`, Opus at medium: a document, a page, copy. + + To run a stage at another point, pin it after the job: `<Persona>, <job>, <Model> <effort>`, with a model of `Fable`, `Opus`, `Sonnet`, or `Haiku` and an effort of `low`, `medium`, `high`, `xhigh`, or `max`, such as `Akira, design, Fable high`. Pin Fable only for a stage that Opus at high has already fallen short on, or, on trial, for security or concurrency code; pin `xhigh` or `max` only when the author asks. A Who cell in the older form, `<Persona>, <Model>`, is read by its tier: Fable as `design`, Opus as `build`, Sonnet or Haiku as `edit`. - **State** is one of four, in these exact words: - `done`, or `done (<pull request number>)` - `running in its session` @@ -81,7 +88,7 @@ One entry per row, in the table's order, and no entry without a row: - Hands back: a draft pull request ``` -- The title after the id names the session: `[P2-S1] [Sasha Sonnet] Build the accounts screens`, or `[A31-P2-S1] [...]` in an item plan. That whole name must be 59 characters or fewer, so keep the title short. A fix to a stage that is done runs in a session of its own, named with ` fix` after the stage's id, the stage's owner, and a title for the fix: `[A31-P1-S5 fix] [Akira Fable] Mend the scan's natural 1`. It has no row and changes no State. +- The title after the id names the session: `[P2-S1] [Sasha build] Build the accounts screens`, or `[A31-P2-S1] [...]` in an item plan. That whole name must be 59 characters or fewer, so keep the title short. A fix to a stage that is done runs in a session of its own, named with ` fix` after the stage's id, the stage's owner, and a title for the fix: `[A31-P1-S5 fix] [Akira design] Mend the scan's natural 1`. It has no row and changes no State. - **Kind** is one of: `build` (changes the product and its tests), `drawing` (mockups only, no product code), `words` (one text file, no code), `tests` (tests only, no product code), `check` (changes nothing and reports), `design round` (the author types `/ck:design <feature>`; no session is opened for it), `yours` (a row whose owner is `You`). - **Does** is two sentences at most. - **Files** is what the session may change, by folder or file, or `none`. diff --git a/skills/start/SKILL.md b/skills/start/SKILL.md index 1902402..2584fc3 100644 --- a/skills/start/SKILL.md +++ b/skills/start/SKILL.md @@ -1,6 +1,6 @@ --- name: start -description: Open the sessions a staged plan calls for, and keep its board. With a stage after the command, such as P2-S1 or A31-P2-S1, it offers a card that starts a new session for that one job, named from the row, for example "[A31-P2-S1] [Akira Fable] Make the creature tables". With `fix`, a finished stage, and what is wrong, such as "fix A31-P1-S5 the scan fails on a natural 1", it offers a card for a session that fixes that one fault, named "[A31-P1-S5 fix] [Akira Fable] Mend the scan's natural 1". With nothing after it, it brings the plan's State column up to date, shows the board, and offers a card for each row that could start now, three at a time. You click each card; ck never starts a session by itself. Runs only when you type /ck:start. +description: Open the sessions a staged plan calls for, and keep its board. With a stage after the command, such as P2-S1 or A31-P2-S1, it offers a card that starts a new session for that one job, named from the row, for example "[A31-P2-S1] [Akira design] Make the creature tables". With `fix`, a finished stage, and what is wrong, such as "fix A31-P1-S5 the scan fails on a natural 1", it offers a card for a session that fixes that one fault, named "[A31-P1-S5 fix] [Akira design] Mend the scan's natural 1". With nothing after it, it brings the plan's State column up to date, shows the board, and offers a card for each row that could start now, three at a time. You click each card; ck never starts a session by itself. Runs only when you type /ck:start. disable-model-invocation: true argument-hint: "[a stage, such as P2-S1 or A31-P2-S1; fix <stage> <what is wrong>; nothing for the board]" --- @@ -49,7 +49,7 @@ Run `<plan.py> card "<the plan>" <id> --plugin-root "${CLAUDE_PLUGIN_ROOT}"`. - It refuses with a line: relay it. For the author's own job add "Say `done <id>` here when it is done."; for a design round, "Type `/ck:design <the feature>`, and say `done <id>` here when its spec is written."; for a row with no owner, "Type `/ck:plan` to give it an owner." Nothing is opened. - It prints `"ready": false`: ask "`<id>` waits on `<each id in blocked_by, with the item in front when the plan has one, joined by and>`. Open it anyway?" On any answer but "yes", say "Nothing is opened for `<id>`.", change nothing, and stop. -- Otherwise, before the first card or block of this command, when `git status --porcelain -- "<the plan>" 2>/dev/null` prints anything, say once "The plan has changes that are not committed, so a new session reads the last committed copy or works from its card alone. Say `save` to commit the plan." Put up a chip: call `mcp__ccd_session__start_session` with `initiation` "own_initiative", `context` "fresh", `use_worktree` true, the `title` and `prompt` exactly as printed, `background` "Stage `<id>` of the plan `<the plan>`, opened by /ck:start: `<tldr>`", and `model` the id in `${CLAUDE_PLUGIN_ROOT}/tiers.conf` that holds the card's model word. The author asked for chips by typing this command; nothing runs until the author clicks one. Say "Chip up: `<title>`. Click it to start it. It checks its own model before it works, and stops to tell you when it is on another. Say `running <id>` here once it is started." The author's click starts the session; you never start one. +- Otherwise, before the first card or block of this command, when `git status --porcelain -- "<the plan>" 2>/dev/null` prints anything, say once "The plan has changes that are not committed, so a new session reads the last committed copy or works from its card alone. Say `save` to commit the plan." Put up a chip: call `mcp__ccd_session__start_session` with `initiation` "own_initiative", `context` "fresh", `use_worktree` true, the `title` and `prompt` exactly as printed, `background` "Stage `<id>` of the plan `<the plan>`, opened by /ck:start: `<tldr>`", and the card's `model` and `effort`. A chip cannot open above this session's model or effort: when the card's point is above yours (`mcp__ccd_session_mgmt__get_session` with `self` gives both), it opens at yours, and add "It is planned for `<model>` at `<effort>`, above this session, so it will stop and ask you to pick them." The author asked for chips by typing this command; nothing runs until the author clicks one. Say "Chip up: `<title>`. Click it to start it. It checks its own model and effort before it works, and stops to tell you when they are not its stage's. Say `running <id>` here once it is started." The author's click starts the session; you never start one. ## 5. Never diff --git a/skills/start/fix.md b/skills/start/fix.md index f5c3860..206da0e 100644 --- a/skills/start/fix.md +++ b/skills/start/fix.md @@ -17,7 +17,7 @@ CK_FAULT ## Fix sessions on the board -A session whose `cwd` is the project's folder or inside it (as `refresh.md` finds it), and whose title's first brackets hold a row's full id followed by ` fix`, such as `[A31-P1-S5 fix] [Akira Fable] Mend the scan's natural 1`, is a fix to that stage: it belongs to no row and changes no State. Pass over one whose title starts with `[dropped]` or whose pull request is CLOSED. When its pull request is OPEN, say in one line "A fix for `<id>` is open (`<number>`)." When it is MERGED and its title does not start with `[done]`, rename it with `mcp__ccd_session_mgmt__set_session_title` to `[done] ` and the title it has. +A session whose `cwd` is the project's folder or inside it (as `refresh.md` finds it), and whose title's first brackets hold a row's full id followed by ` fix`, such as `[A31-P1-S5 fix] [Akira design] Mend the scan's natural 1`, is a fix to that stage: it belongs to no row and changes no State. Pass over one whose title starts with `[dropped]` or whose pull request is CLOSED. When its pull request is OPEN, say in one line "A fix for `<id>` is open (`<number>`)." When it is MERGED and its title does not start with `[done]`, rename it with `mcp__ccd_session_mgmt__set_session_title` to `[done] ` and the title it has. ## Back fix diff --git a/skills/start/refresh.md b/skills/start/refresh.md index ce08338..dee8c34 100644 --- a/skills/start/refresh.md +++ b/skills/start/refresh.md @@ -1,12 +1,12 @@ # Bring the board up to date from the sessions -The project's folder is what `dirname "$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)"` prints, the main checkout even from a worktree; when it prints `.`, it is the folder Claude Code was opened in. `<plan.py> rows` prints, per row: its full id, its stage as shown, the owner, the model word, the kind, `done`, `running` or `not started`, `yes` when it could start now, and its entry's title. +The project's folder is what `dirname "$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)"` prints, the main checkout even from a worktree; when it prints `.`, it is the folder Claude Code was opened in. `<plan.py> rows` prints, per row: its full id, its stage as shown, the owner, the point it runs at (a model word and an effort, such as `Opus high`), the kind, `done`, `running` or `not started`, `yes` when it could start now, and its entry's title. A session belongs to a row when its `cwd` is the project's folder or inside it and its title, less any `[done] ` in front, starts with `[<the row's full id>] [` and ends with the row's title. A title that only mentions an id, has more after the title, or has more inside the brackets belongs to no row. Pass over a title that starts with `[dropped]`, a session whose pull request is CLOSED, and a title that starts with `[done]` while its row is not done, which is an earlier plan's. When two sessions belong to one row, change nothing for it, name both, and ask which is the stage's. - Its pull request is MERGED and the row is not done: run `<plan.py> set "<the plan>" <id> done --pr <its number>`, and unless its title starts with `[done]`, rename it with `mcp__ccd_session_mgmt__set_session_title` to `[done] ` and the title it has. -- The row is not started: run `<plan.py> set "<the plan>" <id> running`, then check its model. +- The row is not started: run `<plan.py> set "<the plan>" <id> running`, then check its model and effort. -To check a session's model, read it with `mcp__ccd_session_mgmt__get_session`. It fits when the model's id holds the row's model word in lower case (`Fable` fits `claude-fable-5-1`). When it does not, set it with `mcp__ccd_session_mgmt__set_session_model` to the id in `${CLAUDE_PLUGIN_ROOT}/tiers.conf` that holds that word, or to the one the app lists for that family when it refuses that id. The app may ask the author first. +To check a session's model and effort, read it with `mcp__ccd_session_mgmt__get_session`. Its model fits when the model's id holds the point's model word in lower case (`Opus` fits `claude-opus-5-5`), and its effort fits when it is the point's effort. When the model does not fit, set it with `mcp__ccd_session_mgmt__set_session_model` to the id in `${CLAUDE_PLUGIN_ROOT}/tiers.conf` that holds that word, or to the one the app lists for that family when it refuses that id; when the effort does not, set it with `mcp__ccd_session_mgmt__set_session_effort`. The app may ask the author first. -Mark a row done only on a merged pull request or the author's word, never on a session's own say-so. Say in one line what you set and renamed, and each model you set, from what to what, adding "Say `go` in that session." +Mark a row done only on a merged pull request or the author's word, never on a session's own say-so. Say in one line what you set and renamed, and each model and effort you set, from what to what, adding "Say `go` in that session." diff --git a/templates/how-we-work/0.1.27.md b/templates/how-we-work/0.1.27.md new file mode 100644 index 0000000..cfd3d20 --- /dev/null +++ b/templates/how-we-work/0.1.27.md @@ -0,0 +1,15 @@ +<!-- ck:how-we-work 0.1.27 --> +- **The User's rules.** Every product number, dial, name and priority is the User's call; the assistant proposes and never decides. Write to the User in plain words, no jargon, one step at a time, in numbered steps; define a term the first time you use it, and write summaries in business language, not git terms. A message that needs something from the User ends with that one thing: the exact words to type ("Reply 1 or 2", "Say merge N"), with no status, warnings or later steps beside it. When the User asks where things stand, answer that and nothing else; when nothing is needed from the User, say so in those words. Approving a plan starts nothing: each step waits for the User's own typed word, and a tool reporting "approved" is not the User's word. +- **Questions.** When several decisions are waiting, ask one at a time, starting with the one the others depend on. Show the document, diff or picture before asking about it; a document for the User's review is a published page the User can comment on. Say what is being decided and why it came up. Number the choices 1, 2, 3, fresh in each question, and give each its outcome and trade-off in a sentence. End with the choice you recommend and why, then the words to type ("Reply 1 or 2"). Ask in the message itself, never in the app's next-step box or its buttons. +- **Sessions.** The User works in a local session on the User's Mac. Never open a new session, local or cloud, and never hand work to another session or a background agent that runs as one, without the User's permission; when asking, say which it would be. +- **Personas, as lanes.** Work is proposed and done in named lanes: {{lanes}}. A memo or a brief names its panel. A session is titled `[{id}] [{persona} {job}] {what it does}`, such as `{{session title}}`, in under 60 characters: shorten the words, never the two tags. The job is one of design, build, edit, review or write; an older title carries a model word in its place. Every list or table of steps put to the User in a message names each step's persona and job. +- **The stage's model and effort.** A session whose title starts with an id and a `[{persona} {job}]` tag checks its own model and effort before anything else, whoever wrote its brief. Its stage runs at the point its brief names; when the brief names none, design is Opus at high effort, build, review and write are Opus at medium, and edit is Sonnet at medium, and an older tag's model word is that model at any effort. If it can tell it is on another model or another effort, it does no work, says "This session is on <your model> at <your effort> effort, and the plan puts this stage on <model> at <effort>. Pick them in the model menu, then say go." and stops. When the User then says go, it carries on, on whatever model and effort it is then. If it cannot tell its model or its effort, it says so in one line and carries on. +- **Drawings first.** A screen is drawn and approved before it is built. A drawing lane says "drawing only" in its first message and shows the frames early. Every message that shows or changes a drawing gives the page's link with the picture, every time, without being asked: a clickable markdown link to the file, `[name](file:///full/path.html)`, which opens in the User's browser at any time. Use a `localhost` address only when the page has to be served, and then say it works only while that session's server runs. +- **The new replaces the old in place.** A plan or brief builds the new thing where the old one is: {{one of each}}, shown as soon as it is built. Never plan a switch between an old and a new implementation, a screen built hidden until a server says so, a second copy kept beside the first, or a step whose proof is "the old one is unchanged", even with many users. If a release seems to need a staged or hidden rollout, put the problem to the User in plain words. Safety comes from staging, a branch that can be thrown away, and database changes that only add. +- **Branches and merges.** A lane forks from fresh `origin/main` as `claude/{id}-{slug}`, where `{id}` is the session's id in lower case (`a31-p4-s3` for `[A31-P4-S3]`), and opens a draft pull request. Nothing merges except on the User's explicit word ("merge N"), in a message of its own; then mark it ready, squash-merge, and fast-forward main in the main folder, `{{main folder}}`. Records ({{records}}) are written on "log it", on a branch of their own, as a draft pull request titled `Log it: <what> (<lane>, #<PRs>)` ("(records only)" for a plan amendment) that merges on "merge N" like any lane, never pushed to `main`. +- **Finishing a session.** After a merge, fast-forward main, then check the branch can go: its pull request merged at the branch's tip, and no other session's folder holds it. Ask "Delete the branch?" in a message of its own. On the User's yes, delete it on GitHub and on the Mac, then say in one line that the session is finished and can be archived. If nothing else is left, say so; if something is, name it in one line and leave it to the coordinator; do not suggest the next lane or a command to run. +- **Fewest presses.** When a step needs the User's hands, give the User one command at a time, ready to run, and build it to find any value it needs (an id, a branch, a version) instead of asking the User to read one from a console or a log. +- **Fix it everywhere.** When you fix a fault, run one search (`git grep`) for the same pattern in files of the same kind, such as {{same kind}}. If it finds the same fault anywhere else, tell the User where, and ask before fixing it. +- **Secrets.** {{secrets}} are never pasted, screenshotted or logged. +- **Writing.** No em-dashes, anywhere. Dates in records and messages are the User's local day, taken from `date` on the Mac, not from a UTC time that {{hosts}} prints; code and the database keep UTC. No AI model's name or identifier in a commit, a pull request, a code comment or a file; the attribution trailer the tool appends is the one exception. +<!-- /ck:how-we-work --> diff --git a/tests/fixtures/plan/project/docs/build-plan.md b/tests/fixtures/plan/project/docs/build-plan.md index 912435a..06f8d78 100644 --- a/tests/fixtures/plan/project/docs/build-plan.md +++ b/tests/fixtures/plan/project/docs/build-plan.md @@ -16,7 +16,7 @@ The site gets a home. A reader notices nothing yet. | Stage | What | Who | State | |---|---|---|---| | P1-S0 | Pick the host for the rulebook site | You | done | -| P1-S1 | The site's home on the host, with the checks running on every change | Alex, Sonnet | done (12) | +| P1-S1 | The site's home on the host, with the checks running on every change | Alex, edit | done (12) | Ends when: the empty site opens at its address and a failing check stops a change. @@ -26,10 +26,10 @@ Every entry carries the icon the game uses for it. A reader sees the crown besid | Stage | What | Who | State | |---|---|---|---| -| P2-S0 | Each entry's icon beside its heading, drawn for you to OK | Kai, Opus | running in its session | -| P2-S1 | The icons beside the headings, built from the drawing | Sasha, Sonnet | not started; follows S0 | -| P2-S2 | A line of words for each icon, for readers who cannot see it | Travolta, Opus | not started; could start now | -| P2-S3 | The page checked against the drawing and the reading rules | Robin, Sonnet | not started; follows S1 and S2 | +| P2-S0 | Each entry's icon beside its heading, drawn for you to OK | Kai, build | running in its session | +| P2-S1 | The icons beside the headings, built from the drawing | Sasha, edit | not started; follows S0 | +| P2-S2 | A line of words for each icon, for readers who cannot see it | Travolta, build | not started; could start now | +| P2-S3 | The page checked against the drawing and the reading rules | Robin, edit | not started; follows S1 and S2 | | P2-S4 | Someone to own the search box | Open | not started; could start now | Ends when: you open the rulebook and every entry shows its icon, at phone width too. @@ -40,8 +40,8 @@ A reader can find an entry by typing part of its name. | Stage | What | Who | State | |---|---|---|---| -| P3-S0 | The search screens, in three versions for you to pick | Kai, Opus | not started; follows P2-S3 | -| P3-S1 | Search, built from the version you picked | Sasha, Sonnet | not started; follows S0 | +| P3-S0 | The search screens, in three versions for you to pick | Kai, build | not started; follows P2-S3 | +| P3-S1 | Search, built from the version you picked | Sasha, edit | not started; follows S0 | Ends when: typing "boss" in the search box brings up Bosses. diff --git a/tests/fixtures/plan/project/docs/plan/2026-10-11-encounter-refactor.md b/tests/fixtures/plan/project/docs/plan/2026-10-11-encounter-refactor.md index 837a7c5..aa539e0 100644 --- a/tests/fixtures/plan/project/docs/plan/2026-10-11-encounter-refactor.md +++ b/tests/fixtures/plan/project/docs/plan/2026-10-11-encounter-refactor.md @@ -16,8 +16,8 @@ The rules of the new fight are settled and its screen is drawn. Players notice n | Stage | What | Who | State | |---|---|---|---| -| P0-S0 | The new fight's rules, settled in a memo | Reiner, Opus | done (279) | -| P0-S1 | The new fight screen, drawn | Kai, Opus | running in its session | +| P0-S0 | The new fight's rules, settled in a memo | Reiner, build | done (279) | +| P0-S1 | The new fight screen, drawn | Kai, build | running in its session | Ends when: you have ruled on the memo and said OK to the drawing. @@ -27,9 +27,9 @@ Creatures live in their own tables and everything reads them from there. Players | Stage | What | Who | State | |---|---|---|---| -| P1-S0 | Today's roster saved in tests, before anything changes | Akira, Fable | not started; could start now | -| P1-S1 | The creature tables | Akira, Fable | not started; follows S0 | -| P1-S2 | The fight reads the new tables | Akira, Fable | not started; follows S1 and P0-S1 | +| P1-S0 | Today's roster saved in tests, before anything changes | Akira, design | not started; could start now | +| P1-S1 | The creature tables | Akira, design | not started; follows S0 | +| P1-S2 | The fight reads the new tables | Akira, design | not started; follows S1 and P0-S1 | Ends when: every test that passed before the refactor passes after it. diff --git a/tests/plan-cases.py b/tests/plan-cases.py index 2778aa4..08a582b 100644 --- a/tests/plan-cases.py +++ b/tests/plan-cases.py @@ -81,29 +81,30 @@ def roster_with_role(name, role): return roster_text([(row[0], role) + row[2:] if row[0] == name else row for row in ROSTER_ROWS]) -# What rows prints for the two examples: full id, stage, owner, model, kind, -# state, ready, title. -ROW_FIELDS = ("full id", "stage", "owner", "model", "kind", "state", "ready", "title") -READY_FIELDS = ("full id", "owner", "model", "kind", "title") +# What rows prints for the two examples: full id, stage, owner, the point the +# row runs at, kind, state, ready, title. The examples' Who cells are in the +# older form, <Persona>, <Model>, so each point is that tier's job's. +ROW_FIELDS = ("full id", "stage", "owner", "point", "kind", "state", "ready", "title") +READY_FIELDS = ("full id", "owner", "point", "kind", "title") RELEASE_ROWS = [ ("P1-S0", "P1-S0", "You", "-", "yours", "done", "no", "Pick the host"), - ("P1-S1", "P1-S1", "Alex", "Sonnet", "build", "done", "no", "Set up the site's home"), - ("P2-S0", "P2-S0", "Kai", "Opus", "drawing", "running", "no", "Draw the rulebook icons"), - ("P2-S1", "P2-S1", "Sasha", "Sonnet", "build", "not started", "no", "Build the rulebook icons"), - ("P2-S2", "P2-S2", "Travolta", "Opus", "words", "not started", "yes", "Words for each icon"), - ("P2-S3", "P2-S3", "Robin", "Sonnet", "check", "not started", "no", "Check the rulebook page"), + ("P1-S1", "P1-S1", "Alex", "Sonnet medium", "build", "done", "no", "Set up the site's home"), + ("P2-S0", "P2-S0", "Kai", "Opus medium", "drawing", "running", "no", "Draw the rulebook icons"), + ("P2-S1", "P2-S1", "Sasha", "Sonnet medium", "build", "not started", "no", "Build the rulebook icons"), + ("P2-S2", "P2-S2", "Travolta", "Opus medium", "words", "not started", "yes", "Words for each icon"), + ("P2-S3", "P2-S3", "Robin", "Sonnet medium", "check", "not started", "no", "Check the rulebook page"), ("P2-S4", "P2-S4", "Open", "-", "build", "not started", "yes", "Own the search box"), - ("P3-S0", "P3-S0", "Kai", "Opus", "design round", "not started", "no", "Draw the search screens"), - ("P3-S1", "P3-S1", "Sasha", "Sonnet", "build", "not started", "no", "Build search"), + ("P3-S0", "P3-S0", "Kai", "Opus medium", "design round", "not started", "no", "Draw the search screens"), + ("P3-S1", "P3-S1", "Sasha", "Sonnet medium", "build", "not started", "no", "Build search"), ] ITEM_ROWS = [ - ("A31-P0-S0", "P0-S0", "Reiner", "Opus", "words", "done", "no", "Rule the new round"), - ("A31-P0-S1", "P0-S1", "Kai", "Opus", "drawing", "running", "no", "Draw the new fight"), - ("A31-P1-S0", "P1-S0", "Akira", "Fable", "tests", "not started", "yes", "Save today's roster"), - ("A31-P1-S1", "P1-S1", "Akira", "Fable", "build", "not started", "no", "Make the creature tables"), - ("A31-P1-S2", "P1-S2", "Akira", "Fable", "build", "not started", "no", "The fight reads the tables"), + ("A31-P0-S0", "P0-S0", "Reiner", "Opus medium", "words", "done", "no", "Rule the new round"), + ("A31-P0-S1", "P0-S1", "Kai", "Opus medium", "drawing", "running", "no", "Draw the new fight"), + ("A31-P1-S0", "P1-S0", "Akira", "Opus high", "tests", "not started", "yes", "Save today's roster"), + ("A31-P1-S1", "P1-S1", "Akira", "Opus high", "build", "not started", "no", "Make the creature tables"), + ("A31-P1-S2", "P1-S2", "Akira", "Opus high", "build", "not started", "no", "The fight reads the tables"), ] REQUIRED_FIELDS = ("Kind", "Does", "Files", "Reads", "Follows", "Done when", "Hands back") @@ -134,19 +135,34 @@ def roster_with_role(name, role): ) FINISH_OTHER = "Hand back %s. Then stop." NO_FILES = "Nothing. You read, run the checks, and report." -CARD_KEYS = ("id", "title", "persona", "model", "kind", "ready", "blocked_by", "tldr", "prompt") +CARD_KEYS = ("id", "title", "persona", "job", "model", "effort", "kind", "ready", "blocked_by", "tldr", "prompt") -# The paragraph every card carries about the session's own model. %(model)s is -# the model word of the row's Who cell; "<your model>" is printed as it stands, -# for the session to fill in. +# The paragraph every card carries about the session's own model and effort. +# "<your model>" and "<your effort>" are printed as they stand, for the session +# to fill in. MODEL_CHECK = ( - "Before anything else, check your own model. This stage is planned for %(model)s. If you can " - 'tell that your model is not one of the %(model)s models, do no work: say "This session is on <your model>, and ' - 'the plan puts this stage on %(model)s. Pick %(model)s in the model menu, then say go." and stop. ' - "When the author then says go, carry on, on whatever model you are then. If you cannot tell which " - "model you are, say so in one line and carry on." + "Before anything else, check your own model and effort. This stage is planned for %(model)s at %(effort)s " + "effort. If you can tell that your model is not one of the %(model)s models, or that your effort is not " + '%(effort)s, do no work: say "This session is on <your model> at <your effort> effort, and the plan puts this ' + 'stage on %(model)s at %(effort)s. Pick them in the model menu, then say go." and stop. When the author then ' + "says go, carry on, on whatever model and effort you are then. If you cannot tell your model or your effort, " + "say so in one line and carry on." ) +GOAL = "As much coded as necessary, as little code as possible." MODELS = ("Fable", "Opus", "Sonnet", "Haiku") +# The job words in tiers.conf and the point each runs at, and the job an older +# Who cell's model word is read as. +POINTS = {"design": ("Opus", "high"), "build": ("Opus", "medium"), "edit": ("Sonnet", "medium"), + "review": ("Opus", "medium"), "write": ("Opus", "medium")} +OLD_JOB = {"Fable": "design", "Opus": "build", "Sonnet": "edit", "Haiku": "edit"} +IDS = {"Fable": "claude-fable-5-1", "Opus": "claude-opus-5-5", "Sonnet": "claude-sonnet-5-5"} +# The note check adds for a plan whose Who cells are in the older form. +OLD_FORM = "the older Who form" + + +def job_of(word): + """The job word for a Who cell's second part: a job word, or an older model word.""" + return OLD_JOB.get(word, word) # A fix card's fixed text, copied from the specification. FIX_FINISH_PR = ( @@ -315,7 +331,7 @@ def title_of(length): def session_name(full_id, persona, model, title): - return "[%s] [%s %s] %s" % (full_id, persona, model, title) + return "[%s] [%s %s] %s" % (full_id, persona, job_of(model), title) def phase_block(text, number): @@ -422,12 +438,14 @@ def project(self): return target def script_in(self, home): - """Copies the script to home/scripts/plan.py, to test what it finds beside itself.""" + """Copies the script to home/scripts/plan.py, and tiers.conf beside scripts/, to test what it finds + beside itself.""" if not os.path.isfile(self.script): raise Missing() target = os.path.join(home, "scripts", "plan.py") os.makedirs(os.path.dirname(target)) shutil.copyfile(self.script, target) + shutil.copyfile(os.path.join(REPO, "tiers.conf"), os.path.join(home, "tiers.conf")) return target def _show(self, arg): @@ -598,7 +616,7 @@ def problem_lines(t, res, text, roster_missing=False): t.bad("the last line should be %r, got %s" % (want, show(got[-1] if got else None)), res) else: got = got[:-1] - got = [line for line in got if not line.startswith("words:")] + got = [line for line in got if not line.startswith("words:") and OLD_FORM not in line] if ROSTER_MISSING in got: if not roster_missing: t.bad("says the roster was not found, though --roster names a file that is there", res) @@ -686,8 +704,11 @@ def card(t, path, stage, roster=None, root=None, cwd=None, fix=(), stdin=None): def has(t, data, res, **want): + """model: the Who cell's job word or older model word; the card's model is the id of that job's model.""" if data is None: return + if want.get("model") in MODELS or want.get("model") in POINTS: + want["model"] = IDS[POINTS[job_of(want["model"])][0]] for key in sorted(want): if key not in data: t.bad("the card has no %r" % key, res) @@ -727,8 +748,9 @@ def prompt_lacks(t, data, res, part, what): def model_check(model): - """The model-check paragraph, one line, for a row planned on this model.""" - return MODEL_CHECK % {"model": model} + """The model-check paragraph, one line, for a row whose Who cell gives this job word or older model word.""" + at = POINTS[job_of(model)] + return MODEL_CHECK % {"model": at[0], "effort": at[1]} def prompt(persona, role, model, plan, full, title, kind, does, done_when, path, reads, @@ -746,6 +768,7 @@ def prompt(persona, role, model, plan, full, title, kind, does, done_when, path, "", "Stage %s: %s. %s" % (full, title, KIND_SENTENCE[kind]), "", + ] + ([GOAL, ""] if kind == "build" else []) + [ model_check(model), "", "Do the work yourself in this session. Do not use helper agents, workflows, or other sessions unless the " @@ -804,7 +827,7 @@ def refuses(t, path, stage, sentence): def fix_name(full, persona, model, title): """The session name of a fix card.""" - return "[%s fix] [%s %s] %s" % (full, persona, model, title) + return "[%s fix] [%s %s] %s" % (full, persona, job_of(model), title) def fix_tldr(full, title): @@ -1121,11 +1144,11 @@ def _(t): @case("check: a persona that is not on the roster, once per row") def _(t): t.at("one row") - text = set_col(release_text(), "P2-S1", 3, "Zelda, Sonnet") + text = set_col(release_text(), "P2-S1", 3, "Zelda, edit") reported(t, check(t, text), text, ("p2-s1",), count=1) t.at("two rows") - text = set_col(text, "P3-S1", 3, "Zelda, Sonnet") + text = set_col(text, "P3-S1", 3, "Zelda, edit") res = check(t, text) lines = reported(t, res, text, ("p2-s1",), count=2) named(t, res, lines, "P3-S1") @@ -1142,7 +1165,7 @@ def _(t): @case("check: a roster that is not there is one line, and names go unchecked") def _(t): t.at("a good file but for an unknown persona") - text = set_col(release_text(), "P2-S1", 3, "Zelda, Sonnet") + text = set_col(release_text(), "P2-S1", 3, "Zelda, edit") res = check(t, text, roster=t.nowhere("ROSTER.md")) good(t, res, text, roster_missing=True) prints(t, res, [ROSTER_MISSING, words_line(text)]) @@ -1529,9 +1552,9 @@ def _(t): text = set_state(text, "P2-S3", "done") want = [ ("P1-S0", "You", "-", "yours", "Pick the host"), - ("P2-S2", "Travolta", "Opus", "words", "Words for each icon"), + ("P2-S2", "Travolta", "Opus medium", "words", "Words for each icon"), ("P2-S4", "Open", "-", "build", "Own the search box"), - ("P3-S0", "Kai", "Opus", "design round", "Draw the search screens"), + ("P3-S0", "Kai", "Opus medium", "design round", "Draw the search screens"), ] res = t.run("ready", t.plan(text)) exits(t, res, 0) @@ -1904,7 +1927,7 @@ def _(t): data, res = card(t, t.plan(item_text()), "A31-P1-S1") has(t, data, res, id="A31-P1-S1", - title="[A31-P1-S1] [Akira Fable] Make the creature tables", + title="[A31-P1-S1] [Akira design] Make the creature tables", persona="akira", model="Fable", kind="build", @@ -1969,7 +1992,7 @@ def _(t): data, res = card(t, path, "P2-S2", root=given) has(t, data, res, id="P2-S2", - title="[P2-S2] [Travolta Opus] Words for each icon", + title="[P2-S2] [Travolta build] Words for each icon", persona="travolta", model="Opus", kind="words", @@ -2003,7 +2026,7 @@ def _(t): data, res = card(t, path, "P2-S3") has(t, data, res, id="P2-S3", - title="[P2-S3] [Robin Sonnet] Check the rulebook page", + title="[P2-S3] [Robin edit] Check the rulebook page", persona="robin", model="Sonnet", kind="check", @@ -2079,11 +2102,11 @@ def _(t): def _(t): t.at("release example") data, res = card(t, t.plan(release_text()), "P2-S1") - has(t, data, res, id="P2-S1", title="[P2-S1] [Sasha Sonnet] Build the rulebook icons") + has(t, data, res, id="P2-S1", title="[P2-S1] [Sasha edit] Build the rulebook icons") t.at("item example") data, res = card(t, t.plan(item_text()), "P1-S2") - has(t, data, res, id="A31-P1-S2", title="[A31-P1-S2] [Akira Fable] The fight reads the tables") + has(t, data, res, id="A31-P1-S2", title="[A31-P1-S2] [Akira design] The fight reads the tables") @case("card: refuses a row that is the author's own job") @@ -2184,7 +2207,7 @@ def _(t): for typed in ("P1-S1", "A31-P1-S1", "a31-p1-s1", "p1-s1"): t.at(typed) data, res = card(t, t.plan(item_text()), typed) - has(t, data, res, id="A31-P1-S1", title="[A31-P1-S1] [Akira Fable] Make the creature tables") + has(t, data, res, id="A31-P1-S1", title="[A31-P1-S1] [Akira design] Make the creature tables") t.at("p2-s2 on the release example") data, res = card(t, t.plan(release_text()), "p2-s2") @@ -2218,15 +2241,47 @@ def _(t): "the line for a followed stage in another phase") -@case("card: the model check names the row's model word, each of the four") +@case("card: each job word, and each older model word read as its tier's job, sets the model check") def _(t): followed = [("P1-S0", "Save today's roster", "not started; could start now")] - for model in MODELS: - t.at("Akira, %s" % model) - path = t.plan(set_col(item_text(), "P1-S1", 3, "Akira, %s" % model)) + for word in list(POINTS) + list(MODELS): + t.at("Akira, %s" % word) + path = t.plan(set_col(item_text(), "P1-S1", 3, "Akira, %s" % word)) data, res = card(t, path, "A31-P1-S1") - has(t, data, res, model=model, title="[A31-P1-S1] [Akira %s] Make the creature tables" % model) - prompt_is(t, data, res, creature_prompt(path, followed, model=model)) + has(t, data, res, model=word, job=job_of(word), effort=POINTS[job_of(word)][1], + title="[A31-P1-S1] [Akira %s] Make the creature tables" % job_of(word)) + prompt_is(t, data, res, creature_prompt(path, followed, model=word)) + + +@case("card: a pinned point is the card's model and effort, under the job's name") +def _(t): + path = t.plan(set_col(item_text(), "P1-S1", 3, "Akira, design, Fable xhigh")) + data, res = card(t, path, "A31-P1-S1") + has(t, data, res, job="design", model="claude-fable-5-1", effort="xhigh", + title="[A31-P1-S1] [Akira design] Make the creature tables") + prompt_has(t, data, res, MODEL_CHECK % {"model": "Fable", "effort": "xhigh"}, "the pinned point's check") + + +@case("check and card: a job word that is not in tiers.conf, and a pin with no job") +def _(t): + for who, says in (("Akira, mend", "mend is not a job word in tiers.conf"), + ("Akira, Opus high", "is not You, Open, or <Persona>, <job>")): + t.at(who) + text = set_col(item_text(), "P1-S1", 3, who) + reported(t, check(t, text), text, [says], count=1) + + +@case("check: a note says how many rows give the older Who form, and only then") +def _(t): + res = check(t, item_text()) + if any(OLD_FORM in line for line in res.lines()): + t.bad("notes the older form in a plan that has none", res) + text = set_col(set_col(item_text(), "P1-S1", 3, "Akira, Fable"), "P1-S2", 3, "Akira, Opus") + res = check(t, text) + exits(t, res, 0) + if "note: 2 rows give the older Who form, <Persona>, <Model>, read by its tier: Fable as design, Opus as " \ + "build, Sonnet or Haiku as edit" not in res.lines(): + t.bad("no note for two rows in the older form", res) # ---- general rules -------------------------------------------------------- @@ -2564,9 +2619,9 @@ def _(t): exits(t, res, 0) quiet(t, res) table(t, res, [ - ("P1-S0", "P1-S0", "Kai", "Opus", "build", "done", "no", "Job 0"), - ("P1-S1", "P1-S1", "Sasha", "Sonnet", "build", "not started", "no", "Job 1"), - ("P1-S2", "P1-S2", "Robin", "Sonnet", "build", "not started", "no", "Job 2"), + ("P1-S0", "P1-S0", "Kai", "Opus medium", "build", "done", "no", "Job 0"), + ("P1-S1", "P1-S1", "Sasha", "Sonnet medium", "build", "not started", "no", "Job 1"), + ("P1-S2", "P1-S2", "Robin", "Sonnet medium", "build", "not started", "no", "Job 2"), ], ROW_FIELDS) @@ -2695,10 +2750,10 @@ def _(t): @case("attack: check notes a row that is done or running ahead of what it follows") def _(t): rows = [ - ("P1-S0", "First job", "Kai, Opus", "not started; could start now"), - ("P1-S1", "Second job", "Sasha, Sonnet", "done"), - ("P1-S2", "Third job", "Robin, Sonnet", "not started; could start now"), - ("P1-S3", "Fourth job", "Akira, Fable", "running in its session"), + ("P1-S0", "First job", "Kai, build", "not started; could start now"), + ("P1-S1", "Second job", "Sasha, edit", "done"), + ("P1-S2", "Third job", "Robin, edit", "not started; could start now"), + ("P1-S3", "Fourth job", "Akira, design", "running in its session"), ] text = mini(rows, {"P1-S1": "S0", "P1-S2": "S1", "P1-S3": "S0"}) res = check(t, text) @@ -2769,7 +2824,7 @@ def _(t): FIX_WORDS = "The memo never says who acts first on a tie." FIX_TITLE = "Settle ties in the round" ROUND_FULL = "A31-P0-S0" -ROUND_NAME = "[A31-P0-S0 fix] [Reiner Opus] " +ROUND_NAME = "[A31-P0-S0 fix] [Reiner build] " ROUND_TLDR = "Settle ties in the round. A fix to A31-P0-S0, which is done and stays done." STILL_RUNNING = "%s is still running in its session: tell that session what to fix" NOT_DONE = "%s is not done, so there is nothing to fix yet" @@ -2835,7 +2890,7 @@ def _(t): path, data, res = round_card(t) has(t, data, res, id="A31-P0-S0", - title="[A31-P0-S0 fix] [Reiner Opus] Settle ties in the round", + title="[A31-P0-S0 fix] [Reiner build] Settle ties in the round", persona="reiner", model="Opus", kind="words", @@ -2899,7 +2954,7 @@ def _(t): data, res = fix_card(t, path, "P1-S1", words, title) has(t, data, res, id="P1-S1", - title="[P1-S1 fix] [Alex Sonnet] Run the checks on forks too", + title="[P1-S1 fix] [Alex edit] Run the checks on forks too", persona="alex", model="Sonnet", kind="build", @@ -2924,12 +2979,12 @@ def _(t): )) -@case("card --fix: the model check names the stage's model word, each of the four") +@case("card --fix: each job word, and each older model word read as its tier's job, sets the model check") def _(t): - for model in MODELS: + for model in list(POINTS) + list(MODELS): t.at("Reiner, %s" % model) path, data, res = round_card(t, set_col(item_text(), "P0-S0", 3, "Reiner, %s" % model)) - has(t, data, res, model=model, title="[A31-P0-S0 fix] [Reiner %s] Settle ties in the round" % model) + has(t, data, res, model=model, title="[A31-P0-S0 fix] [Reiner %s] Settle ties in the round" % job_of(model)) prompt_has(t, data, res, model_check(model), "the model check, on one line") prompt_is(t, data, res, round_fix(path, model=model)) @@ -3107,7 +3162,7 @@ def _(t): @case("card --fix: a name of exactly 59 characters passes") def _(t): - title = title_of(29) + title = title_of(28) name = ROUND_NAME + title if len(name) != 59: raise Setup("the name is %d characters, not 59" % len(name)) @@ -3118,13 +3173,13 @@ def _(t): @case("card --fix: a name of 60 characters is refused, with its three numbers") def _(t): - title = title_of(30) + title = title_of(29) name = ROUND_NAME + title if len(name) != 60: raise Setup("the name is %d characters, not 60" % len(name)) fix_refuses(t, t.plan(item_text()), "P0-S0", 'the name "%s" is 60 characters, 1 over the 59 a session name may have: ' - "give a title of 29 characters or fewer" % name, + "give a title of 28 characters or fewer" % name, title=title) @@ -3133,32 +3188,32 @@ def _(t): t.at("the worked example's row, a title of 75") title = title_of(75) name = ROUND_NAME + title - if len(name) != 105: - raise Setup("the name is %d characters, not 105" % len(name)) + if len(name) != 106: + raise Setup("the name is %d characters, not 106" % len(name)) fix_refuses(t, t.plan(item_text()), "P0-S0", - 'the name "%s" is 105 characters, 46 over the 59 a session name may have: ' - "give a title of 29 characters or fewer" % name, + 'the name "%s" is 106 characters, 47 over the 59 a session name may have: ' + "give a title of 28 characters or fewer" % name, title=title) - t.at("P1-S1 of the release example, a title of 34") - title = title_of(34) - name = "[P1-S1 fix] [Alex Sonnet] " + title + t.at("P1-S1 of the release example, a title of 36") + title = title_of(36) + name = "[P1-S1 fix] [Alex edit] " + title if len(name) != 60: raise Setup("the name is %d characters, not 60" % len(name)) fix_refuses(t, t.plan(release_text()), "P1-S1", 'the name "%s" is 60 characters, 1 over the 59 a session name may have: ' - "give a title of 33 characters or fewer" % name, + "give a title of 35 characters or fewer" % name, title=title) - t.at("P1-S1 of the release example, a title of 33") - title = title_of(33) + t.at("P1-S1 of the release example, a title of 35") + title = title_of(35) data, res = fix_card(t, t.plan(release_text()), "P1-S1", FIX_WORDS, title) - has(t, data, res, title="[P1-S1 fix] [Alex Sonnet] " + title) + has(t, data, res, title="[P1-S1 fix] [Alex edit] " + title) @case("card --fix: a name too long whatever the title still prints the line, with a room below 1") def _(t): - for letters, length, over, room in ((35, 60, 1, 0), (36, 61, 2, -1)): + for letters, length, over, room in ((34, 60, 1, 0), (35, 61, 2, -1)): persona = "Reiner" + "x" * (letters - 6) t.at("a persona of %d letters" % letters) name = fix_name(ROUND_FULL, persona, "Opus", "y") @@ -3175,7 +3230,7 @@ def _(t): @case("card --fix: the 59 are characters, not bytes") def _(t): - title = " ".join(["\u00e9" * 5] * 5) + title = " ".join(["\u00e9" * 5] * 4 + ["\u00e9" * 4]) name = ROUND_NAME + title if len(name) != 59 or len(name.encode("utf-8")) <= 59: raise Setup("the name should be 59 characters and more than 59 bytes") @@ -3186,13 +3241,13 @@ def _(t): t.at("60 characters") fix_refuses(t, t.plan(item_text()), "P0-S0", 'the name "%s\u00e9" is 60 characters, 1 over the 59 a session name may have: ' - "give a title of 29 characters or fewer" % name, + "give a title of 28 characters or fewer" % name, title=title + "\u00e9") @case("card --fix: the 59 are counted on the title as tidied") def _(t): - tidy = title_of(29) + tidy = title_of(28) raw = " " + tidy.replace(" ", " \t ") + " \n" path, data, res = round_card(t, title=raw) has(t, data, res, title=ROUND_NAME + tidy) @@ -3516,7 +3571,7 @@ def _(t): raw = " Settle \t ties\n in the\r\n round \n" path, data, res = round_card(t, title=raw) has(t, data, res, - title="[A31-P0-S0 fix] [Reiner Opus] Settle ties in the round", + title="[A31-P0-S0 fix] [Reiner build] Settle ties in the round", tldr="Settle ties in the round. A fix to A31-P0-S0, which is done and stays done.") prompt_is(t, data, res, round_fix(path)) @@ -3639,7 +3694,7 @@ def _(t): data, res = fix_card(t, path, "P2-S2", words, title, root=given) has(t, data, res, id="P2-S2", - title="[P2-S2 fix] [Travolta Opus] Shorten the crown's line", + title="[P2-S2 fix] [Travolta build] Shorten the crown's line", persona="travolta", model="Opus", kind="words", @@ -3759,7 +3814,7 @@ def _(t): data, res = fix_card(t, path, "P2-S3", words, title) has(t, data, res, id="P2-S3", - title="[P2-S3 fix] [Robin Sonnet] Fail a misplaced icon", + title="[P2-S3 fix] [Robin edit] Fail a misplaced icon", persona="robin", model="Sonnet", kind="check", @@ -3790,7 +3845,7 @@ def _(t): # P3-S1 of the release example follows the design round, which is not started. path = t.plan(set_state(release_text(), "P3-S1", "done (50)")) data, res = fix_card(t, path, "P3-S1", "The box loses its text on a phone.", "Keep the search text") - has(t, data, res, id="P3-S1", title="[P3-S1 fix] [Sasha Sonnet] Keep the search text", + has(t, data, res, id="P3-S1", title="[P3-S1 fix] [Sasha edit] Keep the search text", ready=True, blocked_by=[]) prompt_lacks(t, data, res, "- Stage ", "a fix card lists no stage it follows") prompt_lacks(t, data, res, "P3-S0", "a fix card lists no stage it follows") @@ -3805,7 +3860,7 @@ def _(t): ): t.at(label) data, res = fix_card(t, t.plan(text), "P1-S0", FIX_WORDS, FIX_TITLE) - has(t, data, res, id="P1-S0", title="[P1-S0 fix] [Kai Opus] Settle ties in the round", + has(t, data, res, id="P1-S0", title="[P1-S0 fix] [Kai build] Settle ties in the round", ready=True, blocked_by=[]) diff --git a/tests/run.sh b/tests/run.sh index 5d6b5fe..ba3068f 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -111,9 +111,9 @@ opus=$(grep -c ' claude-opus-5-5$' tiers.conf) sonnet=$(grep -c ' claude-sonnet-5-5$' tiers.conf) if [[ "$tier_lines" == "${#personas[@]}" ]]; then ok "one tier line per persona ($tier_lines)"; else fail "tiers.conf has $tier_lines persona lines, expected ${#personas[@]}"; fi if [[ "$fable" == 6 && "$opus" == 11 && "$sonnet" == 4 ]]; then ok "six Fable 5.1, eleven Opus 5.5, four Sonnet 5.5"; else fail "tier counts: $fable Fable, $opus Opus, $sonnet Sonnet"; fi -unknown=$(grep -E '^[a-z]+ ' tiers.conf | awk '{print $1}' | while read -r n; do [[ -f "profiles/$n.md" ]] || echo "$n"; done) +unknown=$(grep -E '^[a-z]+ claude-' tiers.conf | awk '{print $1}' | while read -r n; do [[ -f "profiles/$n.md" ]] || echo "$n"; done) if [[ -z "$unknown" ]]; then ok "every tier line names a profile"; else fail "tier lines without a profile: $unknown"; fi -other=$(grep -E '^[a-z]+ ' tiers.conf | grep -vE ' claude-(fable-5-1|opus-5-5|sonnet-5-5)$' || true) +other=$(grep -E '^[a-z]+ ' tiers.conf | grep -v '^job ' | grep -vE ' claude-(fable-5-1|opus-5-5|sonnet-5-5)$' || true) if [[ -z "$other" ]]; then ok "every model is one of the three tier IDs"; else fail "unexpected models: $other"; fi # ─── 6. River's behaviors in the interactive skills ───────────────────────── @@ -689,7 +689,7 @@ for need in ('<plan.py> approve', '<plan.py> refresh', '<plan.py> check', 'docs/ # The script makes the fix card and puts the model check in every card; the contract names a fix's session. for need in ('check your own model', '--fix'): if script and need not in script: problems.append(f'scripts/plan.py lacks: {need}') -if '[A31-P1-S5 fix] [Akira Fable]' not in contract: problems.append('the contract lacks the name of a fix session, "[A31-P1-S5 fix] [Akira Fable]"') +if '[A31-P1-S5 fix] [Akira design]' not in contract: problems.append('the contract lacks the name of a fix session, "[A31-P1-S5 fix] [Akira design]"') for p in problems: print(p) sys.exit(1 if problems else 0) EOF @@ -721,7 +721,7 @@ CM="python3 scripts/claude-md.py" mkdir -p "$TMP/cm" state_of() { $CM status "$@" | sed -n 's/^state: //p'; } # A tailored draft: the current template with every blank filled. -sed 's/{{[^}]*}}/filled/g' templates/how-we-work/0.1.26.md > "$TMP/cm/draft.md" +sed 's/{{[^}]*}}/filled/g' templates/how-we-work/0.1.27.md > "$TMP/cm/draft.md" # The command runs only when the User types it, and ships no hook of its own. if grep -qx 'disable-model-invocation: true' skills/claude-md/SKILL.md && ! grep -q 'claude-md' hooks/hooks.json; then @@ -733,12 +733,12 @@ fi # A new section: the file is made with the heading and the draft, stamped with the template's version. $CM write "$TMP/cm/new.md" --section "$TMP/cm/draft.md" >/dev/null; rc=$? if [[ $rc -eq 0 && "$(cat "$TMP/cm/new.md")" == "$(printf '## How we work\n\n'; cat "$TMP/cm/draft.md")" && "$(state_of "$TMP/cm/new.md")" == stamped ]] \ - && $CM status "$TMP/cm/new.md" | grep -qx 'stamp: 0.1.26' && $CM status "$TMP/cm/new.md" | grep -qx 'behind: no'; then - ok "a new section is written under the heading, stamped 0.1.26, not behind" + && $CM status "$TMP/cm/new.md" | grep -qx 'stamp: 0.1.27' && $CM status "$TMP/cm/new.md" | grep -qx 'behind: no'; then + ok "a new section is written under the heading, stamped 0.1.27, not behind" else fail "a new section: exit $rc, state $(state_of "$TMP/cm/new.md")" fi -cp templates/how-we-work/0.1.26.md "$TMP/cm/blank.md" +cp templates/how-we-work/0.1.27.md "$TMP/cm/blank.md" out=$($CM write "$TMP/cm/blank2.md" --section "$TMP/cm/blank.md"); rc=$? [[ $rc -eq 1 && "$out" == *'{{lanes}}'* && ! -e "$TMP/cm/blank2.md" ]] && ok "a draft with a blank left in it is refused, and the blank is named" || fail "a draft with a blank: exit $rc, '$out'" diff --git a/tiers.conf b/tiers.conf index 9e4032c..68f605d 100644 --- a/tiers.conf +++ b/tiers.conf @@ -2,6 +2,17 @@ # The only place a persona's default model is set. Scripts may set a model per # stage on neutral agents; panel.js may move a lens down for decorrelation. # +# Job words for a staged plan's Who cell, <Persona>, <job>. One line per job: +# job <word> <model> <effort>. A stage session opens on its job's model and effort, +# whoever owns it; a row can pin another point, <Persona>, <job>, <Model> <effort>. +# The persona lines below stay for /ck:do, the workflows and /ck:panel. +# templates/how-we-work/0.1.27.md says the same five points in words. +job design claude-opus-5-5 high +job build claude-opus-5-5 medium +job edit claude-sonnet-5-5 medium +job review claude-opus-5-5 medium +job write claude-opus-5-5 medium +# # Judgment (Fable 5.1): the seats whose output is a decision. river claude-fable-5-1 akira claude-fable-5-1 diff --git a/workflows/plan-draft.js b/workflows/plan-draft.js index 4eeff8e..3548504 100644 --- a/workflows/plan-draft.js +++ b/workflows/plan-draft.js @@ -88,7 +88,7 @@ if (runs('draft')) { phase('Draft') doc = await agent( `The project repository is ${projectRoot}. ` + housekeeping + `${contractStep} It gives the file's shape, the four state forms, and the checklist. The sections, in order: ` + SECTIONS.map(s => '"' + s + '"').join(', ') + `. ${rosterStep}\n` + - `Length first: the plan is capped at 3,500 words. A What is one phrase, a Does is two sentences at most, and an entry's title is short enough that its session name, "[<stage>] [<Persona> <Model>] <title>", is 59 characters or fewer.\n` + + `Length first: the plan is capped at 3,500 words. A What is one phrase, a Does is two sentences at most, and an entry's title is short enough that its session name, "[<stage>] [<Persona> <job>] <title>", is 59 characters or fewer.\n` + (release ? `Write the release plan. Read: ${inputs.join(', ')}. ROADMAP.md is the spine: its steps, in their approved order, are what this plan stages, and the plan adds nothing to its scope. If ROADMAP.md does not exist, write nothing: return stages 0 and set stopReason to "Run /ck:roadmap first. It writes ROADMAP.md, the order this plan stages." That is the only case with a stopReason; otherwise leave the field out. From the architecture document take what must exist before what; from the team document take who owns each kind of work; from the PRD take the requirement numbers for each entry's Serves line; from the brand guide and the design folders see which screens have a chosen design.\n` : `Write an item plan for this piece of work, in the author's words: ${item}\n` + (a.itemId ? `Its id is ${a.itemId}.\n` : `Give it a short id, one capital letter followed by up to seven capitals or digits, and return it as itemId.\n`) + @@ -96,7 +96,7 @@ if (runs('draft')) { (a.existing ? `An earlier plan is at ${a.existing}. Read it and carry it over: keep its phases, its order, its owners, and its decisions. When it is a staged plan already, keep every stage's id as it is and keep the State of every row that is done or running; a stage that is new or split off takes the next free number in its phase, at the end of that phase's table, and its Follows says where it sits in the order of work. When it is not a staged plan, mark as done what it says is done.\n` : '') + `How to stage the work:\n` + `- One row is one job for one owner, sized so one session can finish it and it can be checked on its own. Drawing, building, writing, and checking are separate rows. A row never has two owners.\n` + - `- An owner is a persona from the team document when there is one, otherwise from the roster by what the job needs, written with the model word for that persona's tier (a tier model that starts with claude-fable is Fable, claude-opus is Opus, claude-sonnet is Sonnet, claude-haiku is Haiku). A job that is the author's own (a decision, a seat to fill, a pick on a page) is a row owned by You, with Kind yours. A job nobody on the team document or the roster can own is owned by Open, and goes under Open points with the stage it holds up.\n` + + `- An owner is a persona from the team document when there is one, otherwise from the roster by what the job needs, written with the job word that fits the stage, whoever owns it: design for a design or research call, long reasoning across many files, or a hard bug; build for implementation with an approach to decide; edit for a small or mechanical change; review for reading a change or a plan for faults; write for a document, a page, or copy. Pin a point after the job word (\"Akira, design, Fable high\") only for a stage that Opus at high has already fallen short on, or for security or concurrency code; never pin xhigh or max unless the author asked. A job that is the author's own (a decision, a seat to fill, a pick on a page) is a row owned by You, with Kind yours. A job nobody on the team document or the roster can own is owned by Open, and goes under Open points with the stage it holds up.\n` + `- Before a set of screens is built, it needs a chosen design. When docs/design/<feature>/spec.md does not exist for it, put a row of Kind "design round" before the build, owned by Kai: the author types /ck:design <feature> for it. When one small change to an existing screen is enough, a drawing row is enough.\n` + `- Put a tests row before a change that could break behavior nothing tests today, and a check row (Robin; Morgan when sign-in, payments, or personal data are touched) after the builds it checks.\n` + `- Phases are what the author can judge: each ends at something he can see or try for himself, said in its "Ends when" line. Order stages by what each one waits on, and say it in Follows; do not chain stages that could run side by side.\n` +