From d0130e1a0f14a13f58b49edb05cadafe06c4551c Mon Sep 17 00:00:00 2001 From: Will Curran Date: Fri, 30 Oct 2026 09:28:49 -0700 Subject: [PATCH] 0.1.20: a team per project Each project's ck team is the core (River, Quinn, Akira, Sasha, Kai, Robin, Alex, Morgan) plus the invites and drops in .claude/ck-team.conf. A project with no file has the core. scripts/team.py reads and writes it; the SessionStart hook names the team in one paragraph; /ck:do and the opportunity and plan workflows cast only from it; plan.py check notes, and plan.py card refuses, a row whose owner is off the team. /ck:team show, invite, and drop change the file; the full /ck:team invites its cast when it finishes. Re-scoped from the 2026-10-16 proposal on a proof Will approved 2026-10-28. Co-Authored-By: Claude Opus 5.5 --- .claude-plugin/plugin.json | 4 +- DEVLOG.md | 25 ++++ README.md | 2 +- hooks/hooks.json | 5 +- scripts/do.sh | 6 +- scripts/plan.py | 23 +++ scripts/team.py | 133 ++++++++++++++++++ skills/do/SKILL.md | 2 + skills/team/SKILL.md | 10 +- .../plan/project/.claude/ck-team.conf | 5 + tests/run.sh | 35 ++++- workflows/opportunity-draft.js | 2 +- workflows/plan-draft.js | 2 +- 13 files changed, 239 insertions(+), 15 deletions(-) create mode 100755 scripts/team.py create mode 100644 tests/fixtures/plan/project/.claude/ck-team.conf diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 6c0d85b..4a81c3c 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), and the full persona roster, a complete cross-domain team, as ck: subagents and /ck: switch commands.", - "version": "0.1.19", + "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.20", "author": { "name": "Code Katz" } } diff --git a/DEVLOG.md b/DEVLOG.md index de720e6..4286043 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-10-30] 0.1.20: a team per project + +**Category:** `feature` +**Tags:** `ck`, `team`, `personas`, `casting` +**Risk Level:** `low` +**Breaking Change:** `no` (a project with no team file now casts from the core; any seat is one invite away) + +### Summary +Each project's ck team is the core, eight seats (River, Quinn, Akira, Sasha, Kai, Robin, Alex, Morgan), plus the changes in `.claude/ck-team.conf`: one `invite ` or `drop ` per line. Every session, `/ck:do`, `/ck:opportunity`, `/ck:plan`, and `/ck:start`'s cards keep to it, so d20mob works with Reiner, Tracy, Travolta, and Piper and is never offered Noon. This rebuilds on main the proposal approved on 2026-10-16 (D1 to D4), re-scoped against 0.1.19 on a proof Will reviewed on 2026-10-28, where he set the goals: ck **never casts** a persona off the team (G1); a plan row naming one gets a note and **no card** (G2); a project with **no file has the core** (G3); and the full `/ck:team` invites its cast onto the team when it finishes (G4). + +### Detail +- **`scripts/team.py`**, the one reader and writer: `show`, `roster` (the roster rows of the team only), `context` (the session paragraph), `invite`, `drop`. The last line about a persona wins; `invite` and `drop` keep only what differs from the core, so inviting a dropped core seat or dropping an invited guest removes its line. The project is the nearest folder up with the file or `.git`. The core is a list in the script, not a second file. +- **Every session**: the SessionStart hook runs `team.py context`, one paragraph naming the team (about 70 tokens). +- **Casting**: `do.sh look` prints `roster:` lines for the team only, and `/ck:do` takes the nearest team seat when a default owner (Iris, Toni) is off it. The opportunity and plan workflows read `team.py roster` instead of the whole roster; a seat a prompt fixes (Toni and Akira in the opportunity) still runs (D4). +- **Plans**: `plan.py check` adds a `note:` line for an owner off the team, and `plan.py card` refuses that row until the persona is invited; `/ck:start` relays the refusal as it is, so `skills/start/` and the Who column are unchanged for lanes. Outside any repository with no team file, `plan.py` checks names against the roster only. +- **`/ck:team show | invite | drop`** run the script, offer to commit the file, and launch nothing. The full `/ck:team` ends by inviting each Cast seat of `docs/TEAM.md` that is off the team. +- **Cut from the proposal**: the warning line in the 21 switch commands (C7; `/ck:noon` still works everywhere), `core.conf`, `team.py has`, the "Invite Noon?" question mid-workflow, and changes to `team-draft.js` and `roadmap-draft.js`. +- **Tests**: section 23, ten cases on `team.py` and `plan.py` (no file, d20mob's file, an unknown name, invite and drop, a persona both invited and dropped, the paragraph, the hook, the note and the refusal); the plan fixture project carries d20mob's team file. 495 checks pass. + +### Open +- Not yet tried in a real session. The four checks to try in d20mob are in the pull request. +- A guest (one use, asked once) leaves no line, as the proposal assumed; one core for every kind of project. + +--- + ## [2026-10-11] 0.1.10: /ck:do, the quick path; /ck:plan and /ck:start, a staged plan and one named session per stage **Category:** `feature` diff --git a/README.md b/README.md index 32d9573..6b6897f 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Phase one, first release in progress. What runs today: | The memory gate | Not a command: it runs on its own. Before a session saves anything to memory, it shows you the text, whether it belongs in memory or in the project's `CLAUDE.md` and why, and any memory or `CLAUDE.md` line that already says it or says the opposite. You pick save to memory, add to `CLAUDE.md` (written at once on a branch with a draft pull request), or don't save. A hook makes Claude Code ask you before every write to a memory folder, in auto mode too | Built; not yet run on a real project | | `/ck:panel ` | River, Toni, and Kai argue one question on three different models; a memo shows where they disagree | Built | | `/ck:prd [--panel]` | River drafts `docs/PRD.md` from the brief, a checker validates, River adds a premortem; with `--panel`, three specialists on three models challenge it first and River rewrites; you review on a page you can comment on | Built; drilled 2026-10-05 on a real project | -| `/ck:team` | Who is on this product, who owns what, which seat is missing: `docs/TEAM.md`; you review on a page you can comment on | Built; drilled 2026-10-05 on a real project | +| `/ck:team [show \| invite [why] \| drop [why]]` | This project's team, kept in `.claude/ck-team.conf`: the core (River, Quinn, Akira, Sasha, Kai, Robin, Alex, Morgan) plus whoever is invited; ck suggests and casts only these seats. `show`, `invite`, and `drop` change the file and nothing else. With nothing after it: who is on this product, who owns what, which seat is missing, as `docs/TEAM.md`, reviewed on a page you can comment on; its cast is then invited onto the team | Built; drilled 2026-10-05 on a real project; the team file is new in 0.1.20 | | `/ck:architecture [--panel]` | Akira recommends an architecture from the PRD and roadmap and adds a premortem; with `--panel`, Morgan, Alex, and Jordan challenge it first on three models; you review on a page you can comment on | Built; drilled 2026-10-05 on a real project | | `/ck:opportunity ` | River frames the idea, Toni, Akira, and a domain seat write sourced sections, River assembles `docs/opportunity.md`; you review on a page you can comment on | Built; drilled 2026-10-01 | | `/ck:market-research [focus]` | Toni plans the questions, researchers answer them from the web in parallel, a cross-check marks what is unsourced or contradictory, Toni writes `docs/market-research.md` | Built; drilled 2026-10-01 | diff --git a/hooks/hooks.json b/hooks/hooks.json index 03cb2f0..188cf36 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -1,5 +1,5 @@ { - "description": "ck: persona usage log, a check that the old team tool is gone, the memory gate, and the session cost (a line after each answer once there is a pull request, and the table on the pull request at merge)", + "description": "ck: persona usage log, a check that the old team tool is gone, the memory gate, the project's team, and the session cost (a line after each answer once there is a pull request, and the table on the pull request at merge)", "hooks": { "SubagentStart": [ { @@ -13,7 +13,8 @@ { "hooks": [ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/check-prereqs.sh\"" }, - { "type": "command", "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/memory-gate.py\" rule" } + { "type": "command", "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/memory-gate.py\" rule" }, + { "type": "command", "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/team.py\" context" } ] } ], diff --git a/scripts/do.sh b/scripts/do.sh index 109c360..1937cd4 100755 --- a/scripts/do.sh +++ b/scripts/do.sh @@ -15,9 +15,9 @@ look) echo "root: $(git rev-parse --show-toplevel 2>/dev/null)" echo "branch: $(git branch --show-current 2>/dev/null)" echo "uncommitted: $(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')" - # The roster, one persona per line: name, role, and tier (fable, opus, sonnet). - awk -F' [|] ' '/^[|] [a-z]+ [|]/ { sub(/^[|] /, ""); split($3, m, "-"); print "roster: " $1 ", " $2 ", " m[2] }' \ - "$(dirname "$0")/../profiles/ROSTER.md" + # This project's team, one persona per line: name, role, and tier (fable, opus, sonnet). + python3 "$(dirname "$0")/team.py" roster | + awk -F' [|] ' '/^[|] [a-z]+ [|]/ { sub(/^[|] /, ""); split($3, m, "-"); print "roster: " $1 ", " $2 ", " m[2] }' for p in CLAUDE.md docs/TEAM.md docs/PRD.md docs/brand-guide.md docs/design; do if [ -e "$p" ]; then echo "$p"; fi done diff --git a/scripts/plan.py b/scripts/plan.py index d7b371e..6106e94 100755 --- a/scripts/plan.py +++ b/scripts/plan.py @@ -32,6 +32,11 @@ import re import sys +try: + import team # scripts/team.py, beside this script +except ImportError: + team = None + SECTIONS = ['What this covers', 'Phases', 'Stage by stage', 'Open points'] LABELS = ['Kind', 'Does', 'Files', 'Reads', 'Follows', 'Done when', 'Hands back', 'With', 'Serves'] REQUIRED = LABELS[:7] @@ -41,6 +46,15 @@ WORD_LIMIT = 3500 ROSTER = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'profiles', 'ROSTER.md') + +def off_team(path): + """A test of a persona name: true when the plan's project has a team and the name is not on it.""" + start = os.path.dirname(os.path.abspath(path)) + if team is None or team.project(start) is None: + return lambda name: False + members = team.team(start) + return lambda name: name.lower() not in members + # Numbers are written without padding (0, 7, 12, never 07) and are capped at six digits so no # input can make int() fail. No pattern here ends in a lazy run followed by optional spaces: # under fullmatch that takes time that grows with the square of a long line. @@ -724,6 +738,12 @@ def cmd_check(args, opts): pending = plan.pending(row) lines.append('note: %s is %s, but %s, which it follows, %s not done' % (row.stage, row.progress, plan.waits(row), 'is' if len(pending) == 1 else 'are')) + outside = off_team(args[0]) + for row in plan.rows: + who = who_parts(row.who) + 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())) if roster is None: lines.append('roster: not found, names not checked') return status, lines + ['words: %d' % plan.words] @@ -882,6 +902,9 @@ def cmd_card(args, opts): 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)] + 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())] if not entry.title: return 1, ['%s has an entry with no title, so its session has no name' % full] if row.progress == 'unknown': diff --git a/scripts/team.py b/scripts/team.py new file mode 100755 index 0000000..52c41d6 --- /dev/null +++ b/scripts/team.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""team.py: this project's ck team, the core below plus the changes in /.claude/ck-team.conf. + +Usage: + team.py show the team, who is invited or dropped and why, and who is not on it + team.py roster the roster's rows for the team only + team.py context SessionStart: one paragraph naming the team + team.py invite [why] put a persona on the team + team.py drop [why] take a persona off the team + +The file holds one line per change to the core, `invite ` or `drop `, and +`#` comments. The last line about a persona wins. With no file the team is the core. The project is the +nearest folder, from the current one up, that has .claude/ck-team.conf or .git. Standard library only; +an error is one plain line, never a traceback. +""" +import os +import re +import sys +from pathlib import Path + +ROSTER = Path(__file__).resolve().parent.parent / 'profiles' / 'ROSTER.md' +CORE = ['river', 'quinn', 'akira', 'sasha', 'kai', 'robin', 'alex', 'morgan'] +HEAD = '# ck team. The core comes from the plugin; this file invites and drops. Change it with /ck:team invite or drop.' +VERBS = ('invite', 'drop') + + +class Fail(Exception): + pass + + +def roster(): + """Persona name to its row in profiles/ROSTER.md, in the roster's order.""" + rows = {} + for line in ROSTER.read_text().splitlines(): + m = re.match(r'\| ([a-z]+) \|', line) + if m: + rows[m.group(1)] = line + return rows + + +def project(start=None): + """The project's folder, or None outside any repository with no team file.""" + here = Path(start or os.getcwd()).resolve() + for d in [here, *here.parents]: + if (d / '.claude' / 'ck-team.conf').is_file() or (d / '.git').exists(): + return d + return None + + +def team_file(start=None): + return (project(start) or Path(start or os.getcwd()).resolve()) / '.claude' / 'ck-team.conf' + + +def changes(path): + """Persona to (verb, why, line number), from the last line about each persona.""" + found = {} + if path.is_file(): + for n, line in enumerate(path.read_text().splitlines(), 1): + words = line.split(None, 2) + if len(words) > 1 and words[0] in VERBS: + found[words[1].lower()] = (words[0], words[2] if len(words) > 2 else '', n) + return found + + +def team(start=None): + """The team's persona names: the core that is not dropped, then those invited.""" + names, found = roster(), changes(team_file(start)) + members = [p for p in CORE if found.get(p, ('invite',))[0] == 'invite'] + return members + [p for p, c in found.items() if c[0] == 'invite' and p not in CORE and p in names] + + +def show(): + names, path = roster(), team_file() + found, members = changes(path), team() + out = ['team: ' + ', '.join(members)] + for verb in VERBS: + said = ['%s%s' % (p, ' (%s)' % c[1] if c[1] else '') for p, c in found.items() if c[0] == verb and p in names] + if said: + out.append('%s: %s' % ('invited' if verb == 'invite' else 'dropped', ', '.join(said))) + out.append('not on the team: ' + (', '.join(p for p in names if p not in members) or 'nobody')) + out.append('file: .claude/ck-team.conf' if path.is_file() else 'file: none, so the team is the core') + out += ['note: line %d names %s, who is not on the ck roster, so it is skipped' % (c[2], p) + for p, c in found.items() if p not in names] + return out + + +def context(): + names = ', '.join(p.capitalize() for p in team()) + return [("ck team for this project: %s. Suggest and cast only these seats. Before using anyone else, ask once; " + "/ck:team invite adds a lasting member. A pipeline command's own fixed seats always run." % names)] + + +def edit(verb, name, why): + name = name.lower() + if name not in roster(): + raise Fail('%s is not on the ck roster.' % name) + path = team_file() + before = name in team() + lines = path.read_text().splitlines() if path.is_file() else [HEAD] + lines = [l for l in lines if not (len(l.split()) > 1 and l.split()[0] in VERBS and l.split()[1].lower() == name)] + if (verb == 'invite') != (name in CORE): # the file holds only what differs from the core + lines.append(('%s %s %s' % (verb, name, why)).rstrip()) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('\n'.join(lines) + '\n') + after = name in team() + word = 'on' if after else 'off' + said = '%s is %s this project\'s team.' % (name, ('now ' if before != after else 'already ') + word) + return [said] + show() + + +def main(argv): + cmd, args = (argv[0] if argv else ''), argv[1:] + if cmd in ('show', 'roster', 'context') and not args: + if cmd == 'show': + return show() + if cmd == 'context': + return context() + rows = roster() + return ['| Persona | Role | Tier model | Domain |', '|---|---|---|---|'] + [rows[p] for p in rows if p in team()] + if cmd in VERBS and args: + return edit(cmd, args[0], ' '.join(args[1:])) + raise Fail('usage: team.py show | roster | context | invite [why] | drop [why]') + + +if __name__ == '__main__': + try: + print('\n'.join(main(sys.argv[1:]))) + except Fail as e: + print(e, file=sys.stderr) + sys.exit(1) + except Exception as e: # a hook must never fail a session + print('team.py: %s' % e, file=sys.stderr) + sys.exit(0 if sys.argv[1:2] == ['context'] else 2) diff --git a/skills/do/SKILL.md b/skills/do/SKILL.md index ea10a3b..b8dba19 100644 --- a/skills/do/SKILL.md +++ b/skills/do/SKILL.md @@ -32,6 +32,8 @@ At most three steps, in this order. The owner is a roster persona the author nam - Build: Sasha for screens, Akira for server and data, Alex for infrastructure. When the author asked for the change made. - Check: Robin; Morgan when sign-in, payments, or personal data are touched. When asked for, or one of those is touched. +A default owner must have a `roster:` line, since those are this project's team; when one does not, take the nearest seat that does and add " is not on this team; `/ck:team invite ` adds them." above "Go?". + Name each step with a verb, the work item's id if given, at most six words, and the owner, as in `Build: footer typo fix (Sasha)`, and use that name everywhere. Send the plan in this shape only, then stop: ``` diff --git a/skills/team/SKILL.md b/skills/team/SKILL.md index 626ff6c..9cb613a 100644 --- a/skills/team/SKILL.md +++ b/skills/team/SKILL.md @@ -1,12 +1,16 @@ --- name: team -description: Decide who is on this product and who owns what, with River. River nominates a cast from the roster, every nominee confirms or declines on its own model tier and names a missing seat, River writes docs/TEAM.md, a checker validates it, and you review it on a page you can comment on (or by editing the file). Runs only when you type /ck:team. +description: Show or change this project's team (show, invite, drop), or decide who is on this product and who owns what, with River. River nominates a cast from the roster, every nominee confirms or declines on its own model tier and names a missing seat, River writes docs/TEAM.md, a checker validates it, and you review it on a page you can comment on (or by editing the file). Runs only when you type /ck:team. disable-model-invocation: true -argument-hint: "" +argument-hint: "[show | invite [why] | drop [why]]" --- You are River for the whole of this skill. Read `${CLAUDE_PLUGIN_ROOT}/agents/river.md` for your voice and standards. Speak plainly. Never print a stack trace. Always name the file that holds the work so far. Always give exactly one next action. +## The team file + +This project's team is the core plus the changes in `.claude/ck-team.conf`. With `show`, `invite [why]`, or `drop [why]` after the command, run `python3 "${CLAUDE_PLUGIN_ROOT}/scripts/team.py"` with those words, relay what it prints, and stop; after an invite or a drop, offer to commit `.claude/ck-team.conf`. Read nothing else and launch no workflow. With nothing after the command, go on below. + `` below is the repository Claude Code was opened in. Every path is relative to it unless it starts with `${CLAUDE_PLUGIN_ROOT}` or ``. ## 0. Preconditions @@ -73,4 +77,4 @@ Set `status` to `review`. Read `docs/TEAM.md`. Review it per `${CLAUDE_PLUGIN_RO ## 6. Finish -Set `status` to `final` in `run.json`. Say: "Your team document is finished: `docs/TEAM.md`." If the devlog skill is installed, add that `/devlog` can record the decision. Then close per `${CLAUDE_PLUGIN_ROOT}/skills/next/SKILL.md`: the pipeline table with this project's status, and the one next action under it. +Set `status` to `final` in `run.json`. For each persona in the Cast of `docs/TEAM.md` that `team.py show` does not list under `team:`, run `team.py invite `, then offer to commit `.claude/ck-team.conf`. Say: "Your team document is finished: `docs/TEAM.md`." If the devlog skill is installed, add that `/devlog` can record the decision. Then close per `${CLAUDE_PLUGIN_ROOT}/skills/next/SKILL.md`: the pipeline table with this project's status, and the one next action under it. diff --git a/tests/fixtures/plan/project/.claude/ck-team.conf b/tests/fixtures/plan/project/.claude/ck-team.conf new file mode 100644 index 0000000..3986064 --- /dev/null +++ b/tests/fixtures/plan/project/.claude/ck-team.conf @@ -0,0 +1,5 @@ +# ck team for d20mob. The core comes from the plugin; this file adds and removes. +invite reiner the game's mechanics, the round, balance +invite tracy D&D canon and the SRD status of a name +invite travolta the narrator, lore, bestiary and sheet words +invite piper playtests of a new fight or scenario diff --git a/tests/run.sh b/tests/run.sh index f83658e..002efaf 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -594,11 +594,11 @@ if grep -q '^| `/ck:do `' README.md; then ok "README.md has a row for /ck: # scripts/do.sh in a plain folder, a repository, and a linked worktree. DOSH="$PWD/scripts/do.sh"; w="$TMP/do" do_git() { git -c user.name=t -c user.email=t@example.com -c init.defaultBranch=main "$@"; } -personas=$(grep -c '^| [a-z]* |' profiles/ROSTER.md) +personas=8 # a folder with no team file has the core mkdir -p "$w/plain" "$w/repo/docs" (cd "$w/plain" && bash "$DOSH" look) > "$w/out" 2> "$w/err"; rc=$? if [[ $rc -eq 0 && ! -s "$w/err" && "$(head -3 "$w/out")" == $'root: \nbranch: \nuncommitted: 0' && "$(grep -c '^roster: [a-z]*, .*, \(fable\|opus\|sonnet\|haiku\)$' "$w/out")" == "$personas" && "$(wc -l < "$w/out" | tr -d ' ')" == $((personas + 3)) ]] -then ok "look in a plain folder: three empty answers and the $personas personas, no documents" +then ok "look in a plain folder: three empty answers and the $personas core seats, no documents" else fail "look in a plain folder: exit $rc, '$(cat "$w/out" "$w/err" | tr '\n' ' ')'"; fi (cd "$w/repo" && do_git init -q && git config user.name t && git config user.email t@example.com && echo x > CLAUDE.md && echo t > docs/TEAM.md && echo p > page.html && do_git add -A && do_git commit -q -m first) root="$(cd "$w/repo" && pwd -P)"; base="$(git -C "$w/repo" rev-parse HEAD)" @@ -859,6 +859,37 @@ python3 -c 'import json; h=json.load(open("hooks/hooks.json"))["hooks"]; assert && grep -qx 'disable-model-invocation: true' skills/cost/SKILL.md \ && ok "hooks.json runs the cost hooks, and /ck:cost runs only when typed" || fail "the cost hooks or /ck:cost are not wired" +# ─── 23. The team ──────────────────────────────────────────────────────────── +section "23. A team per project: scripts/team.py" +TEAM="$PWD/scripts/team.py"; tw="$TMP/team" +mkdir -p "$tw/none" "$tw/d20/.claude"; git init -q "$tw/none" +cp tests/fixtures/plan/project/.claude/ck-team.conf "$tw/d20/.claude/" +names() { (cd "$1" && python3 "$TEAM" show) | sed -n 's/^team: //p'; } +[[ "$(names "$tw/none")" == 'river, quinn, akira, sasha, kai, robin, alex, morgan' ]] && ok "no file: the team is the core" || fail "no file: '$(names "$tw/none")'" +d20="$(names "$tw/d20")" +[[ "$d20" == 'river, quinn, akira, sasha, kai, robin, alex, morgan, reiner, tracy, travolta, piper' ]] && ok "d20mob's file: twelve seats, not Noon" || fail "d20mob's file: '$d20'" +[[ "$(cd "$tw/d20" && python3 "$TEAM" roster | grep -c '^| [a-z]* |')" == 12 ]] && ok "roster prints the twelve seats' rows" || fail "roster: $(cd "$tw/d20" && python3 "$TEAM" roster | tr '\n' ' ')" +cp "$tw/d20/.claude/ck-team.conf" "$tw/before" +out=$(cd "$tw/d20" && python3 "$TEAM" invite nooon 2>&1); rc=$? +[[ $rc -eq 1 && "$out" == 'nooon is not on the ck roster.' ]] && cmp -s "$tw/before" "$tw/d20/.claude/ck-team.conf" && ok "an unknown name is refused in one sentence and the file is unchanged" || fail "invite nooon: exit $rc, '$out'" +(cd "$tw/d20" && python3 "$TEAM" invite noon cyberpunk words >/dev/null) +[[ "$(diff "$tw/before" "$tw/d20/.claude/ck-team.conf" | grep -c '^[<>]')" == 1 ]] && [[ "$(names "$tw/d20")" == *', noon' ]] && ok "invite adds one line, and the team has Noon" || fail "invite noon: $(diff "$tw/before" "$tw/d20/.claude/ck-team.conf" | tr '\n' ' ')" +(cd "$tw/d20" && python3 "$TEAM" drop noon >/dev/null && python3 "$TEAM" drop morgan >/dev/null && python3 "$TEAM" invite morgan >/dev/null) +cmp -s "$tw/before" "$tw/d20/.claude/ck-team.conf" && ok "drop of an invited seat and invite of a dropped core seat leave no line" || fail "after drop and invite: $(diff "$tw/before" "$tw/d20/.claude/ck-team.conf" | tr '\n' ' ')" +printf '%s\n' 'invite noon yes' 'drop noon no' 'drop kai' 'invite kai' >> "$tw/d20/.claude/ck-team.conf" +d20="$(names "$tw/d20")" +[[ "$d20" != *noon* && "$d20" == *kai* ]] && ok "a persona both invited and dropped follows the last line" || fail "invited and dropped: '$d20'" +out=$(cd "$tw/d20" && python3 "$TEAM" context) +[[ "$out" == *Reiner* && "$out" != *Noon* && $(wc -l <<<"$out") -eq 1 ]] && ok "context is one paragraph naming the team" || fail "context: '$out'" +cp -R tests/fixtures/plan/project "$tw/plan"; (cd "$tw/plan" && python3 "$TEAM" drop travolta >/dev/null) +note=$(cd "$tw/plan" && python3 "$REPO_DIR/scripts/plan.py" check docs/build-plan.md | grep -c '^note: P2-S2: Travolta is not on') +card=$(cd "$tw/plan" && python3 "$REPO_DIR/scripts/plan.py" card docs/build-plan.md P2-S2); rc=$? +(cd "$tw/plan" && python3 "$TEAM" invite travolta >/dev/null) +(cd "$tw/plan" && python3 "$REPO_DIR/scripts/plan.py" card docs/build-plan.md P2-S2 >/dev/null); rc2=$? +[[ $note == 1 && $rc -eq 1 && "$card" == 'P2-S2: Travolta is not on'* && $rc2 -eq 0 ]] && ok "plan.py notes a row whose owner is off the team and refuses its card until they are invited" || fail "plan.py and the team: note $note, card exit $rc '$card', after invite exit $rc2" +python3 -c 'import json; h=json.load(open("hooks/hooks.json"))["hooks"]; assert any("team.py\" context" in x["command"] for e in h["SessionStart"] for x in e["hooks"])' \ + && ok "hooks.json prints the team at session start" || fail "hooks.json does not run team.py context" + # ─── Summary ───────────────────────────────────────────────────────────────── printf "\n%d passed, %d failed\n" "$PASS" "$FAIL" if (( FAIL > 0 )); then diff --git a/workflows/opportunity-draft.js b/workflows/opportunity-draft.js index 3b3dc3f..8927877 100644 --- a/workflows/opportunity-draft.js +++ b/workflows/opportunity-draft.js @@ -27,7 +27,7 @@ const contractStep = a.pluginRoot ? 'Read ' + a.pluginRoot + '/skills/opportunity-artifact/SKILL.md (the opportunity contract).' : 'Load the skill ck:opportunity-artifact with the Skill tool (the opportunity contract).' const rosterStep = a.pluginRoot - ? 'Read the roster: ' + a.pluginRoot + '/profiles/ROSTER.md (one line per persona: name, role, tier, domain).' + ? 'Read this project\'s team with the Bash tool: cd "' + projectRoot + '" && python3 "' + a.pluginRoot + '/scripts/team.py" roster (the roster rows of the seats on this project\'s team: name, role, tier, domain). Cast only from these rows, except a seat this prompt names as always taken.' : 'Load the skill ck:roster with the Skill tool (the roster: one line per persona with name, role, tier, domain).' const housekeeping = a.runDir ? '' : 'If ' + projectRoot + '/.git exists, run this with the Bash tool so the run cache stays out of git status: grep -qxF ".ck/" ' + projectRoot + '/.git/info/exclude 2>/dev/null || echo ".ck/" >> ' + projectRoot + '/.git/info/exclude . If it is refused, skip it and never mention it in a document. ' const ONE_WRITE = 'Write the whole file with one Write call; do not build it with piecemeal edits, and do not re-read, edit, count, or check it after writing: return as soon as it is written, because a checker runs next and names anything unmet. ' diff --git a/workflows/plan-draft.js b/workflows/plan-draft.js index 2473675..af97180 100644 --- a/workflows/plan-draft.js +++ b/workflows/plan-draft.js @@ -31,7 +31,7 @@ const contractStep = a.pluginRoot ? 'Read ' + a.pluginRoot + '/skills/plan-artifact/SKILL.md (the staged plan contract).' : 'Load the skill ck:plan-artifact with the Skill tool (the staged plan contract).' const rosterStep = a.pluginRoot - ? 'Read the roster: ' + a.pluginRoot + '/profiles/ROSTER.md (one line per persona: name, role, tier model, domain).' + ? 'Read this project\'s team with the Bash tool: cd "' + projectRoot + '" && python3 "' + a.pluginRoot + '/scripts/team.py" roster (the roster rows of the seats on this project\'s team: name, role, tier model, domain). Cast only from these rows, except a seat this prompt names as always taken.' : 'Load the skill ck:roster with the Skill tool (the roster: one line per persona with name, role, tier model, domain).' const housekeeping = a.runDir ? '' : 'If ' + projectRoot + '/.git exists, run this with the Bash tool so the run cache stays out of git status: grep -qxF ".ck/" ' + projectRoot + '/.git/info/exclude 2>/dev/null || echo ".ck/" >> ' + projectRoot + '/.git/info/exclude . If it is refused, skip it and never mention it in a document. ' const ONE_WRITE = 'Write the whole file with one Write call; do not build it with piecemeal edits, and do not re-read, edit, count, or check it after writing: return as soon as it is written, because a checker runs next and names anything unmet. '