From c0f68008558ae41832ba4f3229fd25890b5244eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Fri, 6 Nov 2026 09:13:56 -0800 Subject: [PATCH 001/297] feat(hooks): show the status block at calls that usually end a turn Notes written after the last tool call (a waiting note, a comment on a command's output) landed above the status block three times, and the Stop hook had to send each turn back. The PostToolUse hook now shows the block, with the project and branch of the command's folder, right after a subagent launch, a monitor, a background command, or a closing dev command, at most once per two minutes in a turn. Fixes B-fx0e6yhytfv (lesson L-fwrw94p19ce). --- config/claude/settings.json | 2 +- docs/agents.md | 2 +- lib/devkit/hooks.py | 65 ++++++++++++++++++++- tests/test_closing_reminder.py | 102 +++++++++++++++++++++++++++++++++ 4 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 tests/test_closing_reminder.py diff --git a/config/claude/settings.json b/config/claude/settings.json index fad6e2a..3d3e1a7 100644 --- a/config/claude/settings.json +++ b/config/claude/settings.json @@ -105,7 +105,7 @@ ], "PostToolUse": [ { - "matcher": "Bash|Agent|Task|Write|Edit|MultiEdit|WebFetch|WebSearch", + "matcher": "Bash|Agent|Task|Monitor|Write|Edit|MultiEdit|WebFetch|WebSearch", "hooks": [ { "type": "command", diff --git a/docs/agents.md b/docs/agents.md index 052e70b..e2800a4 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -226,7 +226,7 @@ works from the Desktop and ChatGPT apps, whose PATH is minimal. | SessionStart | every source, 30 s | `startup\|resume\|clear\|compact`, 30 s | Records the session's starting commit and branch, refreshes the project's generated blocks, exports `DEVKIT_SESSION` (Claude), restores managed settings in the background, clones `node_modules` into a fresh worktree, queues a sync that pulls the claims of other machines, and prints the workspace context | | UserPromptSubmit | 10 s | 10 s | Starts the turn clock; adds real progress records and the branch's CI/CD pipelines when you ask for status; names the skills the request needs; reminds the agent of the status block in a session that already missed it once, naming the failure mode (nothing comes before the block); queues a correction from you (a prompt that starts with `no,` or `you forgot`) as a lesson signal; registers a session that missed SessionStart and injects its context. Background notifications (subagent results, system notices) are not treated as your request | | PreToolUse | `Bash\|Write\|Edit\|MultiEdit\|NotebookEdit\|Agent\|Task`, 15 s | `^(Bash\|apply_patch\|spawn_agent)$`, 15 s | The [guard](guard.md): allow silently, or `deny` (both) or `ask` (Claude) with the reason, which is written to the audit trail; refuses a commit that would take untracked unfinished work, and enforces the crew's limits and file scopes. Starts the call's timer. On a subagent launch (Agent, Task, `spawn_agent`) records its description for its lane; on Claude's Agent and Task tools, appends the required skills and the report format to the brief. Inside a subagent (the input carries `agent_id`), routes a single `dev progress` or `dev crew` command to that subagent's lane | -| PostToolUse | `Bash\|Agent\|Task\|Write\|Edit\|MultiEdit\|WebFetch\|WebSearch`, 10 s | `^(Bash\|apply_patch)$`, 10 s | Records slow calls; delivers crew messages to their recipient (the main session, or the subagent that made the call); tells the main session about decisions waiting for you and members ready to launch; reminds the main session to update you after a long silence; after a `git commit`, `push`, `merge`, `tag`, `rebase`, `cherry-pick`, or `revert`, or a `gh pr create`, `gh pr merge`, or `gh release create`, queues a sync of the handoff repository (see [Teams and machines › Near-real-time sync](teams.md#near-real-time-sync)) | +| PostToolUse | `Bash\|Agent\|Task\|Monitor\|Write\|Edit\|MultiEdit\|WebFetch\|WebSearch`, 10 s | `^(Bash\|apply_patch)$`, 10 s | Right after a call that usually ends the turn (a subagent launch, a monitor, a background command, or `dev progress done`, `dev handoff --log`, `dev sync`, `dev task done`, a push, a PR, or a release), shows the main session the status block its final message opens with, with the project and branch filled in, at most once per 2 minutes in a turn; records slow calls; delivers crew messages to their recipient (the main session, or the subagent that made the call); tells the main session about decisions waiting for you and members ready to launch; reminds the main session to update you after a long silence; after a `git commit`, `push`, `merge`, `tag`, `rebase`, `cherry-pick`, or `revert`, or a `gh pr create`, `gh pr merge`, or `gh release create`, queues a sync of the handoff repository (see [Teams and machines › Near-real-time sync](teams.md#near-real-time-sync)) | | SubagentStart · SubagentStop | 5 s | 5 s | Opens and closes the subagent's progress lane (its persona, its step, its active time); at SubagentStop, checks that the report starts with the member's own status block (`**[Firefly> DevKit · Neo]**`) and sends it back once without it, then closes its step | | Stop | 10 s | 10 s | On development turns only ([Concepts › Development work only](concepts.md#development-work-only)): checks, scores, and stores the status block; asks the agent to continue when the block is missing or not first (it says which, quoting the reply's first line), the turn left unfinished work without a task (`dev stubs`), new tests cannot fail (`dev test audit`), a plan changed without `dev plan revise`, matching files changed without their required skills, or your commits are newer than the handoff note's last real update (its human sections; task and session lines do not count), checked after every turn with new commits and from the repository the session is in now; once per session after a fix or revert commit, asks whether it deserves a lesson; counts the lessons the reply names as applied; writes a missed block to the audit trail; records the turn; and, at most every 2 minutes, starts a background sync that writes the session line, renews locks, and publishes presence | | SessionEnd | 10 s | 3 s | A background sync that writes the session's final line and closes the session record | diff --git a/lib/devkit/hooks.py b/lib/devkit/hooks.py index c562a83..b29d2bf 100644 --- a/lib/devkit/hooks.py +++ b/lib/devkit/hooks.py @@ -988,9 +988,59 @@ def _bulk_rewrite_note(data: dict) -> str: "expressions or prose, and it changes text it should not. Prefer exact-string edits that must match once.") +# lesson L-fwrw94p19ce: a note written after the last tool call ("waiting for Ghost", a comment on the output) landed +# above the status block, and the Stop hook had to send the turn back. These calls usually end a turn. +LAUNCH_TOOLS = {"Agent": "a subagent launch", "Task": "a subagent launch", "spawn_agent": "a subagent launch", + "Monitor": "a monitor"} +CLOSING_CMD = re.compile(r"\bdev\s+(?:progress\s+done|handoff\s+--log|sync|task\s+done|push|pr\s+(?:create|merge)|" + r"release)\b|\bgit\s+(?:-C\s+\S+\s+)?push\b|\bgh\s+pr\s+(?:create|merge)\b") +CLOSING_GAP = 120 # seconds: parallel launches, or a closing command right after one, get one reminder + + +def _turn_ending(data: dict) -> str: + """What makes this tool call a likely end of the turn, in words; '' when nothing does.""" + tool = data.get("tool_name") or "" + if tool in LAUNCH_TOOLS: + return LAUNCH_TOOLS[tool] + if tool not in SHELL_TOOLS: + return "" + tin = data.get("tool_input") or {} + if tin.get("run_in_background"): + return "a background command" + cmd = tin.get("command") or "" + m = CLOSING_CMD.search(command_words(" ".join(cmd) if isinstance(cmd, list) else str(cmd))) + return f"`{' '.join(m.group(0).split())}`" if m else "" + + +def closing_reminder(data: dict, sess: dict, now: float) -> str: + """PostToolUse, main session, development turns: right after a call that usually ends the turn, the status block + the final message opens with, with this project and branch filled in. Records when it reminded in `sess`.""" + what = _turn_ending(data) + if not what or data.get("agent_id"): + return "" + if sess.get("turn_class") == "other" and os.environ.get("DEVKIT_SCOPE") != "all": + return "" + last = sess.get("closing_nudge") or 0 + if last >= (sess.get("turn_started") or 0) and now - last < CLOSING_GAP: + return "" + sess["closing_nudge"] = now + # the repository of the command's folder (the session may have started outside one), else the one it started in + top = (core.gitout(data["cwd"], "rev-parse", "--show-toplevel") if data.get("cwd") else "") or sess.get("top") or "" + if top: + from .registry import main_of + project = (main_of(Path(top)) or Path(top)).name + else: + project = (sess.get("project") or "").rstrip("/").rsplit("/", 1)[-1] or "" + branch = (core.gitout(top, "branch", "--show-current") if top else "") or "-" + return (f"devkit · after {what}: if you end the turn here (to wait, or because the work is done), your final " + f"message opens with the status block: **[Firefly> DevKit]** {project} · `{branch}` · , then " + f"Done / Check / Next. Nothing above it: a waiting note or a comment on this output goes below the block.") + + def post_tool(agent: str): """PostToolUse: time slow calls; remind the agent to update the developer after a long silence; sync the - handoff repository right after a delivery event (commit, push, merge, tag, PR, release).""" + handoff repository right after a delivery event (commit, push, merge, tag, PR, release); at a call that usually + ends the turn, show the status block the final message opens with.""" from . import progress data = _stdin_json() try: @@ -1009,7 +1059,18 @@ def post_tool(agent: str): ciwatch.start_background(top, session=f"{agent}:{data.get('session_id') or ''}") except Exception: pass - _context_out("PostToolUse", "\n\n".join(x for x in (_bulk_rewrite_note(data), progress.on_tool_done(agent, data)) if x)) + closing = "" + try: + sf = _session_file(agent, data.get("session_id") or "-") + if sf.exists(): # only sessions that started with devkit active + sess = core.read_json(sf, {}) or {} + closing = closing_reminder(data, sess, time.time()) + if closing: + sf.write_text(json.dumps(sess)) + except Exception: + _log(f"post-tool closing {agent}: {traceback.format_exc()[-300:]}") + _context_out("PostToolUse", "\n\n".join(x for x in (_bulk_rewrite_note(data), closing, + progress.on_tool_done(agent, data)) if x)) def subagent_start(agent: str): diff --git a/tests/test_closing_reminder.py b/tests/test_closing_reminder.py new file mode 100644 index 0000000..1ca2c1e --- /dev/null +++ b/tests/test_closing_reminder.py @@ -0,0 +1,102 @@ +# Copyright (c) 2026 Firefly Software Solutions Inc. +# SPDX-License-Identifier: Apache-2.0 +"""The status-block reminder at the tool calls that usually end a turn (a subagent launch, a background command, +the closing `dev` commands), so the block comes first without the Stop hook sending the turn back. +Run: python3 -m unittest""" +import contextlib +import io +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "lib")) + +from devkit import hooks, progress # noqa: E402 + +T0 = 1_793_000_000 # a fixed clock + + +def shell(cmd, background=False): + tin = {"command": cmd} + if background: + tin["run_in_background"] = True + return {"session_id": "s1", "tool_name": "Bash", "tool_input": tin} + + +LAUNCH = {"session_id": "s1", "tool_name": "Agent", "tool_input": {"description": "Ghost: review the guard"}} + + +class ClosingReminderTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.repo = Path(self.tmp.name) / "api" + subprocess.run(["git", "init", "-q", "-b", "feat/export", str(self.repo)], check=True) + self.sess = {"project": "acme/api", "top": str(self.repo), "turn_started": T0, "turn_class": "dev"} + + def tearDown(self): + self.tmp.cleanup() + + def remind(self, data, now=T0 + 60): + return hooks.closing_reminder(data, self.sess, now) + + def test_a_subagent_launch_gets_the_block_with_project_and_branch(self): + text = self.remind(LAUNCH) + self.assertIn("**[Firefly> DevKit]** api · `feat/export` · ", text) + self.assertIn("Nothing above it", text) + self.assertIn("a subagent launch", text) + + def test_a_session_started_outside_the_repository_uses_the_folder_of_the_call(self): + self.sess = {"turn_started": T0} # started in the home folder: no project, no top + (self.repo / "src").mkdir() + text = self.remind(dict(LAUNCH, cwd=str(self.repo / "src"))) + self.assertIn("**[Firefly> DevKit]** api · `feat/export` · ", text) + + def test_background_and_closing_commands_get_it_ordinary_commands_do_not(self): + self.assertIn("a background command", self.remind(shell("pytest -q", background=True))) + self.sess.pop("closing_nudge", None) + self.assertIn("`dev progress done`", self.remind(shell('dev progress done "shipped" && dev sync'))) + self.sess.pop("closing_nudge", None) + self.assertIn("`dev handoff --log`", self.remind(shell('bin/dev handoff --log "export works"'))) + self.sess.pop("closing_nudge", None) + codex = {"session_id": "s1", "tool_name": "Bash", "tool_input": {"command": ["bash", "-lc", "dev push"]}} + self.assertIn("`dev push`", self.remind(codex)) + for quiet in ("ls -la", 'echo "dev progress done"', 'dev progress step "parser" --check "3 tests"', + "git status"): + self.sess.pop("closing_nudge", None) + self.assertEqual(self.remind(shell(quiet)), "", quiet) + + def test_parallel_launches_get_one_reminder_until_the_gap_or_a_new_turn(self): + self.assertTrue(self.remind(LAUNCH, now=T0 + 60)) + self.assertEqual(self.remind(LAUNCH, now=T0 + 90), "") # the second of four parallel launches + self.assertTrue(self.remind(LAUNCH, now=T0 + 60 + hooks.CLOSING_GAP)) + self.sess["turn_started"] = T0 + 200 # a new turn: its first closing moment is reminded again + self.assertTrue(self.remind(LAUNCH, now=T0 + 210)) + + def test_subagents_and_non_development_turns_get_nothing(self): + self.assertEqual(self.remind(dict(LAUNCH, agent_id="a1")), "") + self.sess["turn_class"] = "other" + self.assertEqual(self.remind(LAUNCH), "") + with mock.patch.dict(os.environ, {"DEVKIT_SCOPE": "all"}): + self.assertTrue(self.remind(LAUNCH)) + + def test_the_post_tool_hook_delivers_it_and_remembers_when(self): + sf = Path(self.tmp.name) / "claude-s1.json" + sf.write_text(json.dumps(self.sess)) + out = io.StringIO() + with mock.patch.object(hooks, "_session_file", lambda agent, sid: sf), \ + mock.patch.object(progress, "on_tool_done", lambda agent, data: None), \ + mock.patch("sys.stdin", io.StringIO(json.dumps(LAUNCH))), contextlib.redirect_stdout(out): + hooks.post_tool("claude") + ctx = json.loads(out.getvalue())["hookSpecificOutput"] + self.assertEqual(ctx["hookEventName"], "PostToolUse") + self.assertIn("**[Firefly> DevKit]** api · `feat/export`", ctx["additionalContext"]) + self.assertGreater(json.loads(sf.read_text())["closing_nudge"], T0) + + +if __name__ == "__main__": + unittest.main() From 383fb46f4b0904ad6221c12fe4f37d05e798dd99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Fri, 6 Nov 2026 09:16:25 -0800 Subject: [PATCH 002/297] feat(setup): close attribution gaps and check bypass on every surface The developer asked that dev install sets bypass mode, removes commit and PR attribution, and best-practice settings on Claude and Codex. Verified against the current docs and Codex 0.166.0: Claude's attribution.sessionUrl defaulted to on (a Claude-Session trailer in cloud and Remote Control sessions); includeCoAuthoredBy is deprecated; a foreground command timed out after 2 minutes. Codex has no config key for attribution (an account setting that sessions record), so doctor reads it; analytics is turned off like the other telemetry. Claude Desktop keeps its own per-account bypass switch, which doctor now reports. --- CHANGELOG.md | 19 +++++ config/claude/settings.json | 4 +- config/codex/config.json | 5 +- docs/agents.md | 9 +- lib/devkit/desktop.py | 19 +++++ lib/devkit/setup.py | 38 ++++++++- tests/test_agent_settings.py | 160 +++++++++++++++++++++++++++++++++++ 7 files changed, 249 insertions(+), 5 deletions(-) create mode 100644 tests/test_agent_settings.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a197f7..e214c57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,25 @@ All notable changes to devkit. Versions follow CalVer `YY.MM.PATCH` (PATCH restarts at 0 each month); tags are `vYY.MM.PATCH`. Sections follow [Keep a Changelog](https://keepachangelog.com/). +## [Unreleased] + +### Added + +- The PostToolUse hook shows the status block right after a call that usually ends a turn (a subagent launch, a + monitor, a background command, or a closing `dev` command), with the project and branch filled in. A note + written after the last tool call no longer lands above the block, so the Stop hook does not have to send + the turn back. +- `dev doctor` reads Claude Desktop's own switch for bypass mode in the Code tab (Settings → Code → Allow bypass + permissions mode) and says whether it is allowed, off, or not offered for the signed-in account. + +### Changed + +- Claude Code: `attribution.sessionUrl` is `false`, so cloud and Remote Control sessions add no `Claude-Session` + trailer either; the deprecated `includeCoAuthoredBy` key is removed; and `BASH_DEFAULT_TIMEOUT_MS` is 5 minutes, + so a test suite or a build in the foreground is not cut off at 2. +- Codex: `analytics.enabled` is `false`. `dev doctor` also reads whether your Codex account adds commit and PR + attribution (an account setting, not a `config.toml` key) from your newest session. + ## [26.11.2] - 2026-11-06 ### Added diff --git a/config/claude/settings.json b/config/claude/settings.json index 3d3e1a7..75e373b 100644 --- a/config/claude/settings.json +++ b/config/claude/settings.json @@ -5,10 +5,12 @@ "skipDangerousModePermissionPrompt": true, "attribution": { "commit": "", - "pr": "" + "pr": "", + "sessionUrl": false }, "cleanupPeriodDays": 14, "env": { + "BASH_DEFAULT_TIMEOUT_MS": "300000", "DOCKER_HOST": "unix://{{HOME}}/.colima/default/docker.sock", "TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE": "/var/run/docker.sock", "NG_CLI_ANALYTICS": "false", diff --git a/config/codex/config.json b/config/codex/config.json index f993e37..bea01da 100644 --- a/config/codex/config.json +++ b/config/codex/config.json @@ -1,5 +1,5 @@ { - "_comment": "Written into ~/.codex/config.toml through Codex's own config API (codex app-server config/batchWrite). Projects under dev_root are marked trusted by `dev install`. `profiles` become ~/.codex/.config.toml (`codex --profile deep`); Agent roles come from config/crew.json (dev crew). The main model and effort stay the user's choice (/model): devkit never sets them. review_model makes /review use the strongest model, and project_doc_max_bytes reads a project's AGENTS.md whole (Codex's default stops at 32 KiB; Claude Code reads CLAUDE.md whole).", + "_comment": "Written into ~/.codex/config.toml through Codex's own config API (codex app-server config/batchWrite). Projects under dev_root are marked trusted by `dev install`. `profiles` become ~/.codex/.config.toml (`codex --profile deep`); Agent roles come from config/crew.json (dev crew). The main model and effort stay the user's choice (/model): devkit never sets them. review_model makes /review use the strongest model, and project_doc_max_bytes reads a project's AGENTS.md whole (Codex's default stops at 32 KiB; Claude Code reads CLAUDE.md whole). analytics.enabled=false stops OpenAI's usage analytics, like the other telemetry opt-outs. Commit and PR attribution is an account setting on OpenAI's side, not a config.toml key: `dev doctor` reads it from the sessions.", "settings": { "approval_policy": "never", "sandbox_mode": "danger-full-access", @@ -18,6 +18,9 @@ }, "agents": { "max_concurrent_threads_per_session": 4 + }, + "analytics": { + "enabled": false } }, "profiles": { diff --git a/docs/agents.md b/docs/agents.md index e2800a4..54d9e5b 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -107,14 +107,16 @@ A repository's own AGENTS.md or CLAUDE.md adds to them; an explicit request from | --- | --- | --- | | `permissions.defaultMode` | `bypassPermissions` (or your `claude.default_mode`) | No permission prompts; the guard and deny rules still apply | | `skipDangerousModePermissionPrompt` | `true` (bypass mode only) | Skips the one-time confirmation of bypass mode | -| `attribution.commit` · `attribution.pr` | `""` | No Co-Authored-By trailer, no "Generated with Claude Code" line | +| `attribution.commit` · `attribution.pr` · `attribution.sessionUrl` | `""` · `""` · `false` | No Co-Authored-By trailer, no "Generated with Claude Code" line, and no `Claude-Session` trailer in cloud and Remote Control sessions. devkit also removes the deprecated `includeCoAuthoredBy` | | `cleanupPeriodDays` | `14` | Claude Code deletes transcripts and other session data older than 14 days | -| `env` | see below | Environment for every session | +| `env` | see below | Environment for every session, including `BASH_DEFAULT_TIMEOUT_MS=300000`: a foreground command gets 5 minutes instead of 2, so a test suite or a build is not cut off (longer jobs run in the background) | | `hooks` | see [Hooks](#hooks) | devkit's hook handlers (any other handlers are kept) | | `enabledPlugins` | the plugins in `config/claude/plugins.json` set to `true` | Plugins enabled everywhere | | `fastModePerSessionOptIn` | `true` | Fast mode is chosen per session (see [Models and tokens](models-and-tokens.md#claude-code-fast-mode)) | | `statusLine` | `dev hook statusline --agent claude`, only when you have no status line of your own | Shows `project · branch · model · ctx 42% · 5h 12% · 7d 36%` and records Claude's usage windows | +**Bypass mode in Claude Desktop.** The Code tab reads the same `~/.claude/settings.json`, but Claude Desktop also keeps its own switch per account: **Settings → Code → Allow bypass permissions mode**. Turning it on is your consent in the app, so devkit never writes it; `dev doctor` reads it and says whether bypass mode is allowed, off, or not offered for your account (its plan or its organization's policy). + **Claude's quota.** Claude Code reports the plan's 5-hour and 7-day usage windows only to the status line, so devkit's status line saves them in `~/.local/state/devkit/quota/claude.json` each time it refreshes (about 80 ms, no git calls). `dev doctor`, the self-test (`dev agents test claude` skips with the reset time when a window is @@ -155,6 +157,7 @@ devkit does not edit `config.toml` as text. It starts `codex app-server` and wri | --- | --- | --- | | `approval_policy` | `never` (or your `codex.approval_policy`) | No approval prompts, the equivalent of Claude's bypass mode | | `sandbox_mode` | `danger-full-access` (or your `codex.sandbox_mode`) | No sandbox; the guard is the safety net | +| `analytics.enabled` | `false` | Stops OpenAI's usage analytics, like the other telemetry opt-outs devkit sets | | `projects."".trust_level` | `trusted` | For the dev root and every repository under it (and new ones from `dev clone`, `dev new`, `dev adopt`) | | `shell_environment_policy.set` | Docker, Testcontainers, and telemetry variables | Environment for commands Codex runs | | `agents.max_concurrent_threads_per_session` | `4` | At most four subagent threads per session | @@ -163,6 +166,8 @@ devkit does not edit `config.toml` as text. It starts `codex app-server` and wri | `mcp_servers` | from `config/codex/plugins.json` | context7, Chrome DevTools, Angular CLI | | `hooks.state."".trusted_hash` | the hashes Codex reports | Trust for devkit's hooks | +**Attribution in Codex.** Codex can add `Co-authored-by: Codex` to commits and "Generated with Codex" to pull requests, in the terminal and in the ChatGPT app. No `config.toml` key turns it off: it is an account setting on OpenAI's side, which every session records as `git_attribution`. `dev doctor` reads it from your newest session and fails when it is on. Either way, the guard refuses attribution in commits that agents run, and `dev commit` and `dev pr create` remove the lines. + devkit also writes files that it owns: | File | Content | diff --git a/lib/devkit/desktop.py b/lib/devkit/desktop.py index 5836857..0f639aa 100644 --- a/lib/devkit/desktop.py +++ b/lib/devkit/desktop.py @@ -157,6 +157,25 @@ def pack(out_dir: Path = None, validate: bool = True, say=print) -> dict: return rep +BYPASS_SWITCH = "Settings → Code → Allow bypass permissions mode" + + +def bypass_mode(base: Path = None): + """Whether Claude Desktop's Code tab offers bypass mode to the signed-in account: (True | False | None, detail). + Desktop keeps its own per-account switch next to ~/.claude/settings.json; devkit reads it and never writes it + (turning it on is the developer's consent in the app). Only the account id is read from config.json.""" + base = Path(base or claude_dir()) + account = (core.read_json(base / "config.json", {}) or {}).get("lastKnownAccountUuid") + if not account: + return None, "no signed-in account found" + prefs = (core.read_json(base / "claude_desktop_config.json", {}) or {}).get("preferences") or {} + if (prefs.get("bypassPermissionsGateByAccount") or {}).get(account) is False: + return False, "not offered for the signed-in account (its plan or its organization's policy)" + if (prefs.get("bypassPermissionsOptInByAccount") or {}).get(account) is True: + return True, "allowed for the signed-in account" + return False, f"off: turn on {BYPASS_SWITCH} in Claude Desktop" + + def status(base: Path = None) -> dict: """What Claude Desktop has installed, read from its settings folder (nothing is changed).""" base = Path(base or claude_dir()) diff --git a/lib/devkit/setup.py b/lib/devkit/setup.py index 961caa4..d12e819 100644 --- a/lib/devkit/setup.py +++ b/lib/devkit/setup.py @@ -382,6 +382,10 @@ def app_checks(codex_plugins=None) -> list: if lc and not lc.get("ok", True): ok_, detail = False, detail + f"; launcher: {lc.get('reason')}" res.append(("Claude Desktop", "devkit extension", ok_, detail, "dev install --only desktop")) + ok_b, detail_b = desktop.bypass_mode() + if ok_b is not None: + res.append(("Claude Desktop", "bypass mode in the Code tab", ok_b, detail_b, + "" if ok_b else f"Claude Desktop → {desktop.BYPASS_SWITCH}")) except Exception: pass if CHATGPT_APP.exists() and CHATGPT_CODEX.exists(): @@ -464,6 +468,7 @@ def write_claude_settings(include_hooks=True): frag = load_conf("claude/settings.json") plugins = load_conf("claude/plugins.json") new = core.deep_merge(cur, {k: v for k, v in frag.items() if k != "hooks"}) + new.pop("includeCoAuthoredBy", None) # deprecated since Claude Code 2.0.62; `attribution` replaces it new["hooks"] = merge_hooks(cur.get("hooks") or {}, frag.get("hooks") or {} if include_hooks else {}) if not new["hooks"]: new.pop("hooks") @@ -583,6 +588,29 @@ def write_codex_config(quiet=False): fail(f"codex app-server: {e}") +_GIT_ATTRIBUTION = re.compile(rb'"git_attribution"\s*:\s*(true|false)') + + +def codex_git_attribution(sessions: Path = None, scan: int = 30): + """Whether Codex adds `Co-authored-by: Codex` to commits and a "Generated with Codex" line to PRs. It is an + account setting on OpenAI's side (no config.toml key turns it off); every session records it in its first + lines as `git_attribution`. The newest of the last `scan` sessions that records it, or None (unknown).""" + sessions = Path(sessions or CODEX_HOME / "sessions") + try: + files = sorted(sessions.glob("*/*/*/*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True)[:scan] + except OSError: + return None + for f in files: + try: + with open(f, "rb") as fh: # the session's opening context (the flag sits about 150 KB in), not its turns + m = _GIT_ATTRIBUTION.search(fh.read(1 << 20)) + except OSError: + continue + if m: + return m.group(1) == b"true" + return None + + def _toml_value(v) -> str: return json.dumps(v) if isinstance(v, str) else ("true" if v is True else "false" if v is False else str(v)) @@ -987,7 +1015,8 @@ def _canon_ok(m): check("Claude", "bypassPermissions default", (s.get("permissions") or {}).get("defaultMode") == "bypassPermissions", (s.get("permissions") or {}).get("defaultMode", "unset"), "dev install --only claude") att = s.get("attribution") or {} - check("Claude", "no attribution", att.get("commit") == "" and att.get("pr") == "", json.dumps(att), "dev install --only claude") + check("Claude", "no attribution", att.get("commit") == "" and att.get("pr") == "" and att.get("sessionUrl") is False, + json.dumps(att), "dev install --only claude") events = {e for e, gs in (s.get("hooks") or {}).items() for g in gs for h in g.get("hooks", []) if is_devkit_hook(h)} check("Claude", "devkit hooks", {"SessionStart", "SessionEnd", "PreToolUse", "Stop", "UserPromptSubmit", "PostToolUse"} <= events, ", ".join(sorted(events)), @@ -1012,6 +1041,13 @@ def _canon_ok(m): bad = {k: cfg.get(k) for k, v in cw["settings"].items() if cfg.get(k) != v} check("Codex", "approval/sandbox", not bad, ", ".join(f"{k}={cfg.get(k)}" for k in cw["settings"]), "dev install --only codex") + att_ = codex_git_attribution() + if att_ is not None: # an account setting on OpenAI's side, recorded by every session + check("Codex", "no attribution", att_ is False, + "off for your account (the sessions record git_attribution: false)" if att_ is False else + "on for your account: Codex adds Co-authored-by: Codex to commits and \"Generated with " + "Codex\" to PRs (devkit's guard, dev commit and dev pr create still remove them)", + "turn off commit attribution in your Codex account settings") hs = [h for h in rpc.hooks(HOME) if str(h.get("sourcePath")) == str(CODEX_HOME / "hooks.json")] st = {h["eventName"]: h["trustStatus"] for h in hs} check("Codex", "devkit hooks trusted", hs and all(v == "trusted" for v in st.values()), diff --git a/tests/test_agent_settings.py b/tests/test_agent_settings.py new file mode 100644 index 0000000..a6322bf --- /dev/null +++ b/tests/test_agent_settings.py @@ -0,0 +1,160 @@ +# Copyright (c) 2026 Firefly Software Solutions Inc. +# SPDX-License-Identifier: Apache-2.0 +"""The agent settings `dev install` writes: bypass mode, no AI attribution, and the defaults devkit recommends, for +Claude Code, Claude Desktop and Codex. Run: python3 -m unittest""" +import json +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "lib")) + +from devkit import desktop, setup # noqa: E402 + + +class ClaudeSettingsTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.home = Path(self.tmp.name) + self.file = self.home / "settings.json" + + def tearDown(self): + self.tmp.cleanup() + + def install(self, current: dict) -> dict: + self.file.write_text(json.dumps(current)) + with mock.patch.object(setup, "CLAUDE_HOME", self.home), mock.patch.object(setup, "backup", lambda f: None), \ + mock.patch.dict(setup.CFG["claude"], {"default_mode": "bypassPermissions"}): + setup.write_claude_settings(include_hooks=False) + return json.loads(self.file.read_text()) + + def test_every_attribution_is_off_and_the_deprecated_key_is_gone(self): + s = self.install({"includeCoAuthoredBy": True, "theme": "dark", + "attribution": {"commit": "Co-Authored-By: Claude "}, + "permissions": {"defaultMode": "default", "allow": ["Bash(ls:*)"]}}) + self.assertEqual(s["attribution"], {"commit": "", "pr": "", "sessionUrl": False}) + self.assertNotIn("includeCoAuthoredBy", s) + self.assertEqual(s["permissions"], {"defaultMode": "bypassPermissions", "allow": ["Bash(ls:*)"]}) + self.assertIs(s["skipDangerousModePermissionPrompt"], True) + self.assertEqual(s["theme"], "dark") # the developer's own keys stay + + def test_long_builds_get_five_minutes_before_the_shell_times_out(self): + s = self.install({"env": {"MY_VAR": "1"}}) + self.assertEqual(s["env"]["MY_VAR"], "1") + self.assertEqual(s["env"]["BASH_DEFAULT_TIMEOUT_MS"], "300000") + + +class DesktopBypassTests(unittest.TestCase): + """Claude Desktop keeps its own switch for bypass mode in the Code tab (Settings → Code), per account.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.base = Path(self.tmp.name) + + def tearDown(self): + self.tmp.cleanup() + + def state(self, account="acct-1", opt_in=None, gate=None): + (self.base / "config.json").write_text(json.dumps({"lastKnownAccountUuid": account, + "oauth:tokenCache": "never-read"})) + prefs = {} + if opt_in is not None: + prefs["bypassPermissionsOptInByAccount"] = {"acct-1": opt_in, "acct-2": not opt_in} + if gate is not None: + prefs["bypassPermissionsGateByAccount"] = {"acct-1": gate} + (self.base / "claude_desktop_config.json").write_text(json.dumps({"preferences": prefs})) + return desktop.bypass_mode(self.base) + + def test_allowed_when_the_signed_in_account_opted_in(self): + ok, detail = self.state(opt_in=True, gate=True) + self.assertIs(ok, True) + self.assertIn("allowed", detail) + + def test_off_names_the_switch_to_turn_on(self): + ok, detail = self.state(opt_in=False, gate=True) + self.assertIs(ok, False) + self.assertIn("Settings → Code → Allow bypass permissions mode", detail) + self.assertIs(self.state(gate=True)[0], False) # never opted in + + def test_an_account_without_the_option_is_told_why(self): + ok, detail = self.state(opt_in=True, gate=False) + self.assertIs(ok, False) + self.assertIn("not offered", detail) + + def test_unknown_without_a_signed_in_account(self): + self.assertIsNone(self.state(account=None, opt_in=True)[0]) + self.assertIsNone(desktop.bypass_mode(self.base / "missing")[0]) + + +class FakeCodex: + """Codex's app-server, as far as the installer talks to it: it records the config edits.""" + edits = [] + hooks_file = "" + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def write(self, edits, reload=True): + FakeCodex.edits = list(edits) + + def trust_hooks(self, cwd, path): + return 0 + + def hooks(self, cwd): + return [{"sourcePath": FakeCodex.hooks_file, "trustStatus": "trusted"}] + + +class CodexSettingsTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.home = Path(self.tmp.name) + + def tearDown(self): + self.tmp.cleanup() + + def test_install_writes_full_bypass_and_turns_analytics_off(self): + from devkit import codexrpc, registry + FakeCodex.hooks_file = str(self.home / "hooks.json") + with mock.patch.object(setup, "CODEX_HOME", self.home), mock.patch.object(setup, "backup", lambda f: None), \ + mock.patch.object(codexrpc, "CodexRPC", FakeCodex), \ + mock.patch.object(registry, "discover", lambda: ([], [], [])): + setup.write_codex_config(quiet=True) + edits = {k: (v, how) for k, v, how in FakeCodex.edits} + self.assertEqual(edits["approval_policy"], ("never", "replace")) + self.assertEqual(edits["sandbox_mode"], ("danger-full-access", "replace")) + self.assertEqual(edits["analytics"], ({"enabled": False}, "upsert")) # merged: the app's own keys stay + + def session(self, day: str, name: str, attribution) -> Path: + d = self.home / "sessions" / "2026" / "11" / day + d.mkdir(parents=True, exist_ok=True) + f = d / f"rollout-{name}.jsonl" + meta = {"type": "turn_context", "payload": {"instructions": "x" * 150_000, "environments_instructions": False}} + if attribution is not None: + meta["payload"]["git_attribution"] = attribution + f.write_text(json.dumps(meta) + "\n" + json.dumps({"type": "message", "text": "hello"}) + "\n") + return f + + def test_codex_attribution_is_read_from_the_newest_session_that_records_it(self): + sessions = self.home / "sessions" + self.assertIsNone(setup.codex_git_attribution(sessions)) # no sessions yet: unknown + old = self.session("04", "a", True) + new = self.session("05", "b", False) + import os + os.utime(old, (1_000, 1_000)) + os.utime(new, (2_000, 2_000)) + self.assertIs(setup.codex_git_attribution(sessions), False) + newest = self.session("06", "c", None) # a session that does not record it is skipped + os.utime(newest, (3_000, 3_000)) + self.assertIs(setup.codex_git_attribution(sessions), False) + turned_on = self.session("06", "d", True) + os.utime(turned_on, (4_000, 4_000)) + self.assertIs(setup.codex_git_attribution(sessions), True) + + +if __name__ == "__main__": + unittest.main() From ade3f5457461a29ddb1f6f1d944f83f9e3137b7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Fri, 6 Nov 2026 09:43:43 -0800 Subject: [PATCH 003/297] fix(hooks): apply a crew member's scope inside a worktree of the repo The scope check resolved paths against the main session's checkout, so a member working in a managed worktree of the same repository was refused its own files (Kid's installer work). Paths outside the main checkout are now resolved against their own checkout when it shares the repository's git common dir; another repository stays outside the scope. --- lib/devkit/hooks.py | 22 ++++++++++++++++++++++ tests/test_progress.py | 26 ++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/lib/devkit/hooks.py b/lib/devkit/hooks.py index b29d2bf..f67af0f 100644 --- a/lib/devkit/hooks.py +++ b/lib/devkit/hooks.py @@ -766,6 +766,24 @@ def _crew_limits(agent: str, data: dict): return None +def _repo_top_of(path: str) -> str: + """The git top level (real path) of the nearest existing folder of `path`; '' outside any repository.""" + d = os.path.dirname(os.path.realpath(path)) + while d and not os.path.isdir(d): + d = os.path.dirname(d) + top = core.gitout(d, "rev-parse", "--show-toplevel") if d else "" + return os.path.realpath(top) if top else "" + + +def _same_repository(a: str, b: str) -> bool: + """Two checkouts of one repository (the main checkout and its worktrees share a git common dir).""" + def common(x): + c = core.gitout(x, "rev-parse", "--path-format=absolute", "--git-common-dir") + return os.path.realpath(c) if c else "" + ca = common(a) + return bool(ca) and ca == common(b) + + def _scope_violation(agent: str, data: dict): """A crew member assigned a scope (dev progress assign … --scope "globs") edits only those files, so parallel agents never write the same file. -> the refusal, or None.""" @@ -800,6 +818,10 @@ def _scope_violation(agent: str, data: dict): for p_ in [x for x in paths if x]: ap = os.path.normpath(p_ if os.path.isabs(p_) else os.path.join(cwd, p_)) rel = os.path.relpath(ap, top) + if rel.startswith(".."): # a worktree of the same repository: the scope's paths are relative to it + wt = _repo_top_of(ap) + if wt and _same_repository(wt, top): + rel = os.path.relpath(os.path.realpath(ap), wt) if rel.startswith("..") and core.is_temp(ap): continue # the member's own scratch files (session scratchpad, temp dirs): no other member owns them if not any(fnmatch.fnmatch(rel, g) or fnmatch.fnmatch(rel, g.rstrip("/") + "/**") or rel.startswith( diff --git a/tests/test_progress.py b/tests/test_progress.py index b91a70c..1754b70 100644 --- a/tests/test_progress.py +++ b/tests/test_progress.py @@ -312,6 +312,32 @@ def test_messages_reach_the_right_crew_member(self): now=self.m(5)) self.assertIn("Review the parser", pg.on_tool_done("claude", {"session_id": "s1", "agent_id": "a2"})) + def test_scope_follows_the_member_into_a_worktree_of_the_same_repository(self): + import subprocess + from devkit import hooks + with TempState(), tempfile.TemporaryDirectory() as d: + main, wt, other = Path(d) / "api", Path(d) / "wt", Path(d) / "other" + for r in (main, other): + subprocess.run(["git", "init", "-q", str(r)], check=True) + subprocess.run(["git", "-C", str(r), "-c", "user.email=a@example.com", "-c", "user.name=a", "commit", + "-q", "--allow-empty", "-m", "init"], check=True) + subprocess.run(["git", "-C", str(main), "worktree", "add", "-q", "-b", "feat/x", str(wt)], check=True) + pg.start("Feature", plan=["installer"], now=self.m(0)) + pg.assign("installer", "Kid", scope="install.sh,tests/test_install*.py", now=self.m(0)) + pg.note_agent_launch("claude", {"session_id": "s1", "tool_input": {"description": "Kid: installer"}}) + pg.on_subagent("claude", {"session_id": "s1", "agent_id": "a1", "agent_type": "devkit:kid"}, True, + now=self.m(1)) + edit = lambda p: {"session_id": "s1", "agent_id": "a1", "tool_name": "Edit", "cwd": str(main), # noqa: E731 + "tool_input": {"file_path": str(p)}} + from unittest import mock + real_temp = hooks.core.is_temp # the fixture lives in a temp folder; real worktrees do not + mock.patch.object(hooks.core, "is_temp", lambda p: real_temp(p) and not str(p).startswith(d)).start() + self.addCleanup(mock.patch.stopall) + self.assertIsNone(hooks._scope_violation("claude", edit(wt / "install.sh"))) + self.assertIsNone(hooks._scope_violation("claude", edit(wt / "tests" / "test_install_curl.py"))) # new folder + self.assertIn("outside Kid's scope", hooks._scope_violation("claude", edit(wt / "lib" / "cli.py"))) + self.assertIn("outside Kid's scope", hooks._scope_violation("claude", edit(other / "install.sh"))) + def test_scope_keeps_parallel_agents_apart(self): from devkit import hooks with TempState(), tempfile.TemporaryDirectory() as repo: From 204dc2cc44f857f1fa2336a25e67fddc899a7ac5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Fri, 6 Nov 2026 09:44:05 -0800 Subject: [PATCH 004/297] feat(mcp): give Desktop chat and Cowork devkit's rules Claude Desktop's chat and Cowork only had a short summary of the rules in the extension's server instructions. The instructions now carry the status block with its Crew and You lines and Morpheus's role, and the new devkit_rules tool (dev agents rules [--section]) returns the complete global rules or one section, the same text Claude Code and Codex get. devkit_crew's reply keeps who each member is and the RACI, and leaves the agent-facing method out to stay inside one MCP result. --- desktop/manifest.json | 6 +++++- lib/devkit/cli.py | 29 ++++++++++++++++++++++++++++- lib/devkit/mcp.py | 27 +++++++++++++++++++++++++-- tests/test_mcp.py | 2 +- 4 files changed, 59 insertions(+), 5 deletions(-) diff --git a/desktop/manifest.json b/desktop/manifest.json index f5d8bcc..4195962 100644 --- a/desktop/manifest.json +++ b/desktop/manifest.json @@ -81,7 +81,11 @@ }, { "name": "devkit_crew", - "description": "The crew of specialist agents and, during a run, who works now, open messages and decisions waiting for you." + "description": "The crew of specialist agents with the RACI (who does and approves each kind of work) and, during a run, who works now, open messages and decisions waiting for you." + }, + { + "name": "devkit_rules", + "description": "devkit's global rules for agents, or one section: the status block, Morpheus and the crew's RACI, Git, delivery and the handoff." }, { "name": "devkit_doctor", diff --git a/lib/devkit/cli.py b/lib/devkit/cli.py index 5dce048..84bd2ac 100644 --- a/lib/devkit/cli.py +++ b/lib/devkit/cli.py @@ -2022,8 +2022,34 @@ def _quota_text(q: dict) -> str: def cmd_agents(a): +def rules_text(section: str = "") -> str: + """devkit's global rules as `dev install` writes them, or one section ("0", "3", "crew", "status block").""" + from . import setup + text = setup.render_instructions().split("\n", 1)[1] # without the generated-by line + if not section: + return text + want = section.strip().lower() + parts = re.split(r"(?m)^(?=## )", text) + for p in parts: + head = p.split("\n", 1)[0].lower() + if head.startswith(f"## {want}.") or (not want.isdigit() and want in head): + return p.strip() + subs = re.split(r"(?m)^(?=### )", text) + for p in subs: + if p.startswith("### ") and want in p.split("\n", 1)[0].lower(): + return re.split(r"(?m)^## ", p)[0].strip() + raise ValueError(f"no section {section!r}: give its number (0-12) or words from its title") + + from . import providers if a.action == "test": + if a.action == "rules": + try: + print(rules_text(getattr(a, "section", "") or "")) + except ValueError as e: + ui.err(str(e)) + return 2 + return 0 targets = ["claude", "codex"] if a.agent in (None, "all") else [a.agent] rc = 0 for t in targets: @@ -2394,12 +2420,13 @@ def add(name, fn, help_, **kw): "status"]) p.add_argument("--quiet", action="store_true") p = add("agents", cmd_agents, "Claude Code and Codex: install, sign-in, plan, quota, self-test") - p.add_argument("action", nargs="?", choices=["status", "test"], default="status") + p.add_argument("action", nargs="?", choices=["status", "test", "rules"], default="status") p.add_argument("agent", nargs="?", choices=["claude", "codex", "all"]) p.add_argument("--json", action="store_true") p.add_argument("--model", help="model for the self-test session") p.add_argument("-v", "--verbose", action="store_true", help="show the agent's reply") add("calver", cmd_calver, "Current and next CalVer version (YY.MM.PATCH)").add_argument( + p.add_argument("--section", help="rules: one section, by number (0-12) or words from its title") "--next", action="store_true", help="print only the next version") p = add("plugin", cmd_plugin, "Validate the devkit plugin for Claude Code and Codex") p.add_argument("action", choices=["validate"]) diff --git a/lib/devkit/mcp.py b/lib/devkit/mcp.py index efe8fb6..5954ae2 100644 --- a/lib/devkit/mcp.py +++ b/lib/devkit/mcp.py @@ -45,8 +45,14 @@ "**[Firefly> DevKit]** · `` · \n" "- **Done:** \n" "- **Check:** <✓ verified | ⚠ partly or not verified | ✗ failed | – nothing to verify> \n" + "- **Crew:** (only when crew members worked)\n" + "- **You:** (only when there is something)\n" "- **Next:** \n" - "Use `-` for a part that does not apply. Each bullet is one plain sentence of about 100 characters at most." + "Use `-` for a part that does not apply. Each bullet is one plain sentence of about 100 characters at most.\n\n" + "In development work the main session is Morpheus, the tech lead: it routes each kind of work to the crew member " + "the RACI makes responsible (devkit_crew lists them and who approves what), and the crew reports through devkit " + "(progress steps, crew questions, status blocks). Development itself runs in Claude Code or Codex; here, report " + "and plan from devkit's data. devkit_rules returns the complete rules, or one section, whenever you need them." ) # --------------------------------------------------------------------------- tool definitions @@ -104,6 +110,15 @@ def _short(text, n=240) -> str: return text if len(text) <= n else text[:n - 1] + "…" +def _digest_crew(d, a): + """The crew for a reply: who each member is (role first) and the RACI. The agent-facing method, commands and + definition of done stay out (they are in each member's brief).""" + if not isinstance(d, dict): + return d + keep = ("id", "title", "name", "role", "main", "summary", "goal", "use_when", "not_for", "tools", "tier", "spawn") + return dict(d, members=[{k: m[k] for k in keep if k in m} for m in d.get("members") or []]) + + def _digest_overview(d, a): """`dev overview --json` holds every initiative's full note (well over 100 KB): keep what a reply needs.""" days = d.get("days", a.get("days", 7)) @@ -263,7 +278,15 @@ def _digest_audit(records, a): "own), the plan steps assigned to each member, who is ready to launch, queued or waiting and why, open crew " "messages, the mode (guided: the developer decides; auto: the crew goes on and records) with its approvals, " "and the decisions waiting for the developer.", - {}, lambda a: ["crew", "--json", "--latest"], READ, json_out=True) + {}, lambda a: ["crew", "--json", "--latest"], READ, json_out=True, digest=_digest_crew) + +_tool("devkit_rules", "devkit rules", + "devkit's global rules for agents, exactly as Claude Code and Codex get them: the status block, the workspace, " + "how to work, Morpheus and the crew's RACI, Git, delivery, the handoff and the guard. Give `section` (a number " + "from 0 to 12, or words from a title such as \"status block\" or \"crew\") for one part.", + {"section": {"type": "string", "minLength": 1, "maxLength": 60, + "description": "Optional: one section by number (0-12) or words from its title."}}, + lambda a: ["agents", "rules"] + (["--section", a["section"]] if a.get("section") else []), READ) _tool("devkit_doctor", "Doctor", "Checks the whole devkit setup on this Mac (toolchain, GitHub sign-in and git identity, workspace and handoff " diff --git a/tests/test_mcp.py b/tests/test_mcp.py index d6cb636..b6ec92b 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -146,7 +146,7 @@ def test_parse_error_and_invalid_request(self): def test_tools_list_matches_the_definitions(self): tools = self.s.request("tools/list")["result"]["tools"] self.assertEqual([t["name"] for t in tools], [t["name"] for t in mcp.TOOLS]) - self.assertEqual(len(tools), 18) + self.assertEqual(len(tools), 19) for t in tools: with self.subTest(tool=t["name"]): self.assertTrue(t["name"].startswith("devkit_")) From 9e3d684c444878332beee75f7874b2a6afa0db63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Fri, 6 Nov 2026 09:45:08 -0800 Subject: [PATCH 005/297] feat(crew): a RACI for every kind of work, enforced, led by Morpheus The developer found the crew misrouted (a designer asked to audit data and bugs, Kid ranked first for an HTTP API) and asked for well-defined members, a RACI that is enforced, Morpheus as the router of all development work, the crew's structure in the handoff repository, and roles shown before names. - config/crew.json: each member gets a role title, a goal, a method, its devkit commands and its definition of done; 27 kinds of work get R, A (one), C and I, with the signals that classify a task. - Routing follows the RACI (32 real tasks route to the right member); dev progress assign refuses a member who is not Responsible and dev progress done refuses to end a run before each Accountable member approved, both overridable with a recorded --why. - The prompt hook names Morpheus's route for every development request; SubagentStop sends back once a member who never recorded its step through devkit. - The team's changes live in the handoff repository (crew/crew.json and a generated README), written only by dev crew set and dev crew raci --set with a reason, guarded, and merged everywhere; dev crew history lists them. - The status block gains optional Crew and You bullets; members appear as role (name). --- agents/AGENTS.md | 76 +- config/crew.json | 1338 +++++++++++++++++++++++++++++++++- lib/devkit/canary.py | 19 +- lib/devkit/cli.py | 14 +- lib/devkit/crew.py | 329 ++++++++- lib/devkit/dispatch.py | 27 +- lib/devkit/guard.py | 3 + lib/devkit/hooks.py | 58 +- lib/devkit/progress.py | 121 ++- lib/devkit/raci.py | 236 ++++++ plugin/agents/apoc.md | 33 +- plugin/agents/architect.md | 38 +- plugin/agents/dozer.md | 35 +- plugin/agents/ghost.md | 35 +- plugin/agents/hamann.md | 40 +- plugin/agents/keymaker.md | 38 +- plugin/agents/kid.md | 36 +- plugin/agents/link.md | 41 +- plugin/agents/lock.md | 35 +- plugin/agents/merovingian.md | 34 +- plugin/agents/mouse.md | 38 +- plugin/agents/neo.md | 38 +- plugin/agents/niobe.md | 35 +- plugin/agents/oracle.md | 36 +- plugin/agents/persephone.md | 34 +- plugin/agents/rama.md | 33 +- plugin/agents/roland.md | 33 +- plugin/agents/sati.md | 34 +- plugin/agents/scout.md | 31 +- plugin/agents/seraph.md | 45 +- plugin/agents/smith.md | 34 +- plugin/agents/sparks.md | 31 +- plugin/agents/switch.md | 33 +- plugin/agents/tank.md | 37 +- plugin/agents/trainman.md | 35 +- plugin/agents/trinity.md | 37 +- tests/test_crew.py | 36 +- tests/test_progress.py | 2 +- tests/test_raci.py | 305 ++++++++ 39 files changed, 3354 insertions(+), 139 deletions(-) create mode 100644 lib/devkit/raci.py create mode 100644 tests/test_raci.py diff --git a/agents/AGENTS.md b/agents/AGENTS.md index ef5d491..e33019d 100644 --- a/agents/AGENTS.md +++ b/agents/AGENTS.md @@ -1,8 +1,13 @@ # Global instructions (devkit) -These rules apply to every project and to both Claude Code and Codex. A repository's own AGENTS.md / CLAUDE.md -adds to them and wins on project specifics (build commands, style). The user's explicit request in the session -wins over both. +These rules apply to every project and on every surface devkit installs them on: Claude Code (the terminal, the +Code tab of Claude Desktop, the IDEs) through `~/.claude/CLAUDE.md`, Codex (the CLI, the IDEs, the ChatGPT app) +through `~/.codex/AGENTS.md`, and Claude Desktop's chat and Cowork through the devkit extension. A repository's own +AGENTS.md / CLAUDE.md adds to them and wins on project specifics (build commands, style). The user's explicit request +in the session wins over both. + +**In development work you are Morpheus**, the tech lead of devkit's crew: every request starts with you, and you +either do it yourself or route it to the crew member the RACI makes responsible (section 3, "Morpheus leads"). **Scope: development work only.** These rules, the status block included, apply to development work: writing, reviewing, debugging, testing, building, deploying or releasing software; setting up or operating developer tools, @@ -16,8 +21,8 @@ devkit's process. In a session that mixes both, apply the rules to the developme Start the **final message of every development turn, without exception**, with this status block. That includes short answers, questions back to the developer, status updates, replies while background work runs, and replies in any language: a -header line, then three short bullets (four while a `dev progress` run is active). Leave a blank line after it, then -write the rest of the message. +header line, then the bullets: Done, Check and Next always; Progress, Crew and You only when they apply. Leave a +blank line after it, then write the rest of the message. **Nothing comes before the block.** Not the verdict, a correction, a greeting or a comment on the last command's output: those go right after it. "Lead with the point" (section 3) applies to the text below the block. The final @@ -27,18 +32,21 @@ for background work. `dev progress step` prints an update that already is the bl ```markdown **[Firefly> DevKit]** · `` · - **Done:** -- **Check:** <✓ | ⚠ | ✗> -- **Progress:** <3 of 7 steps · 24 min of ~40> (only while a `dev progress` run is active) +- **Check:** <✓ | ⚠ | ✗ | –> +- **Progress:** <3 of 7 steps · 24 min of ~40 · ETA 15–25 min> (only while a `dev progress` run is active) +- **Crew:** (only when crew members worked in this turn or are working) +- **You:** (only when there is something) - **Next:** ``` For example: **[Firefly> DevKit]** kit-consulting · `release/26.11.4` · Release 26.11.4 - - **Done:** merged PRs 12, 17 and 22 into the release branch and fixed the two conflicts. - **Check:** ✓ local suite green, 214 tests -- **Next:** push once and open the release PR. +- **Crew:** Link ✓ release branch · Seraph ▸ review of the merge +- **You:** approve the push of the release branch. +- **Next:** Seraph finishes the review, then I push once and open the release PR. - **Header.** The marker is written exactly as shown: bold, square brackets, `Firefly>` with the chevron, a space, `DevKit`. Then come the repository name, the branch in backticks and the task's title in a few words. Use `-` for @@ -47,6 +55,10 @@ For example: to verify (a question or a plan). - **Bullets.** Each bullet is one plain sentence of at most about 100 characters. Use no internal IDs unless they help the reader (a PR number does; a task ID does not). +- **Crew.** Each member with what it works on: `▸` working, `✓` done, `✗` failed or lost. `dev progress step` writes it + for you. +- **You.** Only what the developer alone can do: a decision, an approval, an action on their machine or account. Work + the crew can do never goes there. - **Truth.** If a rule could not be followed or something failed, the block says so. The block proves to the user that these global instructions are loaded and followed: devkit checks every reply, @@ -160,19 +172,39 @@ redo their work, and check what they report before you rely on it. After two fai and escalate (more effort, a stronger model, or the developer) instead of retrying. The `working-efficiently` skill has the routing table, briefing templates, and context rules. -### Work as a crew, and keep the plan true - -When work needs more than one kind of expertise, you are **Morpheus**, the tech lead, and devkit's crew are your -specialists (Neo, Trinity, Tank, The Architect, Seraph, The Keymaker, Link, and the rest: `dev crew`). Plan with -dependencies (`dev progress start --plan "design=30m; api=1h after design; ui=1h after design"`), assign each step to -one member with the files it owns (`dev progress assign api --to Tank --scope "services/**"`), and launch them with -`dev crew brief`. Run members in parallel only for independent steps with fixed contracts and separate files, at most -four at once; otherwise in sequence, or do it yourself when it is under ten minutes. Every member ends its report with -its own status block (`**[Firefly> DevKit · Tank]**`), which the hooks check like yours. Members ask with -`dev crew ask`; you answer with `dev crew answer`. Work is **guided** (decisions that are the developer's wait for -the developer; the member stops until you resume it with the answer) or **auto** (the crew goes on with the -recommended option and records it), set with `--mode` or `dev crew mode`; irreversible actions wait for the developer in -both, unless the developer pre-approved them. The `crew` skill has the roster, the patterns, and the briefs. +### Morpheus leads, the crew does the work, and the plan stays true + +In development work you are always **Morpheus**, the tech lead, and devkit's crew are your specialists (Neo, Trinity, +Tank, The Architect, Sati, Mouse, Seraph, The Keymaker, Link, Ghost, and the rest: `dev crew`). Every request starts +with you, and you route it by the crew's **RACI** (`dev crew raci`): for each kind of work, one member is +**Responsible** (does it), one is **Accountable** (approves it), some are **Consulted** (before or during it) and some +**Informed** (after it). The prompt hook names the route for each request. + +- **Route.** One kind of work under ten minutes: do it yourself. Otherwise plan one step per kind of work, with + dependencies (`dev progress start --plan "design=30m; api=1h after design; ui=1h after design"`), and assign each + to its Responsible member with the files it owns (`dev progress assign api --to Tank --scope "services/**"`). + devkit refuses a member the RACI does not make Responsible for the step; override only with `--why "…"`, which is + recorded. Launch members with `dev crew brief`: the brief carries their goal, method, RACI and commands. +- **Parallel or not.** Run members in parallel only for independent steps with fixed contracts and separate files, + at most four at once; otherwise in sequence. +- **Approval.** The run ends only when each Accountable member has approved the work they own: add a review step for + them (`dev progress add "review=20m after api"`, then assign it), or end with `dev progress done "…" --why "…"`. +- **Decisions.** Work is **guided** (decisions that are the developer's wait for the developer; the member stops + until you resume it with the answer) or **auto** (the crew goes on with the recommended option and records it), set + with `--mode` or `dev crew mode`. Irreversible actions wait for the developer in both, unless the developer + pre-approved them. + +**The crew talks through devkit, never around it.** Every member records each finished step with `dev progress step +"…" --check "…"` (its own lane), posts `dev progress note` while a long step runs, asks with `dev crew ask` (a +decision for the developer with `--decision`), answers with `dev crew answer`, and ends its report with its own +status block (`**[Firefly> DevKit · Tank]**`). The hooks check it: a member whose report has no block, or who never +recorded its step, is sent back once. + +**The crew's structure belongs to the team.** devkit ships the roster and the RACI (`config/crew.json`). The team's +changes live in the handoff repository (`crew/crew.json`, and `crew/README.md` for people), versioned with who and +why, and every session uses them. Change them only with `dev crew set "…" --why "…"` or `dev crew +raci --set "R=… A=… C=… I=…" --why "…"`: you, when the work shows a better split, or the developer. `dev crew +history` lists the changes. The `crew` skill has the roster, the patterns, and the briefs. Close a step only with its evidence (`dev progress step "…" --check "…"`) and only the step that evidence is about: devkit refuses a description that does not match the step, a step a member still owns, and a step whose dependencies diff --git a/config/crew.json b/config/crew.json index 31f2a11..42aef9d 100644 --- a/config/crew.json +++ b/config/crew.json @@ -1,10 +1,11 @@ { - "_comment": "devkit's crew: one source for the Claude Code agents (plugin/agents/.md, spawned as devkit:) and the Codex agent roles (~/.codex/agents/.toml, spawned by agent type). Morpheus is the main session's role when it coordinates; everyone else is a subagent. tools: all = read, write and run; build = all but no web; review = read and run commands, never edit; read = read-only files and search. tier: strong = the session's model (judgment, design, security, review); standard = the session's model at default effort; fast = the small fast model (mechanical lookups). Edit names, personalities or skills here, then run `dev crew sync`.", + "_comment": "devkit's crew: one source for the Claude Code agents (plugin/agents/.md, spawned as devkit:) and the Codex agent roles (~/.codex/agents/.toml, spawned by agent type). Morpheus is the main session's role when it coordinates; everyone else is a subagent. tools: all = read, write and run; build = all but no web; review = read and run commands, never edit; read = read-only files and search. tier: strong = the session's model (judgment, design, security, review); standard = the session's model at default effort; fast = the small fast model (mechanical lookups). Edit names, personalities or skills here, then run `dev crew sync`. Each member has a short `title` (the role developers see first, before the name), a goal, a method, the devkit commands it uses, and its definition of done. `activities` is the RACI: for each kind of work, R does it, A approves it (one member), C is consulted, I is informed; `signals` are the words that classify a task. Routing, step assignment, member launches and the end of a run follow it.", "language_rule": "Write to the developer in their language: devkit config `language`, default American English. Code, commits, PRs and docs stay in American English unless the developer asks otherwise.", "members": [ { "id": "morpheus", "name": "Morpheus", + "title": "Tech lead", "role": "Tech Lead and delivery manager", "main": true, "summary": "Coordinates the crew: turns the request into a plan, assigns each step to the right specialist, keeps progress honest, integrates the results, and speaks for the crew to the developer.", @@ -23,11 +24,35 @@ ], "tools": "all", "tier": "strong", - "returns": "the status block to the developer, with what each specialist delivered and what is left" + "returns": "the status block to the developer, with what each specialist delivered and what is left", + "goal": "Every development request reaches the right specialist, the plan stays true, and the developer always knows where the work stands.", + "method": [ + "Classify the request against the RACI (`dev crew route`) and decide: do it yourself (under ten minutes, one kind of work) or plan it with the crew.", + "Plan with dependencies and estimates (`dev progress start --plan`), one step per kind of work, each assigned to its Responsible member with the files it owns.", + "Brief each member with `dev crew brief`; run independent steps in parallel (at most four), dependent ones in sequence.", + "Answer the crew's questions, take the developer's decisions to the developer in guided mode, and record every decision.", + "Before the run ends, make sure every Accountable member has approved the work they own, integrate the results, and report with the status block." + ], + "devkit": [ + "dev crew route", + "dev crew brief", + "dev progress start", + "dev progress assign", + "dev crew next", + "dev crew answer", + "dev crew decide", + "dev progress done" + ], + "done_when": [ + "every step is closed with its evidence or skipped with a reason", + "every Accountable member signed off on their work", + "the developer has the outcome, the evidence and what is left" + ] }, { "id": "hamann", "name": "Councillor Hamann", + "title": "Product owner", "role": "Product owner", "summary": "Decides what is worth building and in what order: value, priority, scope cuts, milestones and the trade-offs between them, in the developer's terms.", "personality": "Reflective and fair. Weighs every request against what the people it serves really need, and says no with a reason.", @@ -59,11 +84,32 @@ "stakeholder", "prioridad", "alcance" + ], + "goal": "The crew builds the most valuable thing first, and scope matches the time and the risk the developer accepts.", + "method": [ + "Restate the outcome the developer wants and who benefits from it.", + "List the candidate work with its value, cost and risk; rank it.", + "Propose a cut: what ships now, what waits, and what is dropped, with the reason for each.", + "Turn the cut into tasks (`dev task add --now|--later`) and hand open questions to The Oracle." + ], + "devkit": [ + "dev task", + "dev task add", + "dev task move", + "dev overview", + "dev standup", + "dev crew ask" + ], + "done_when": [ + "every candidate has a decision (now, later, dropped) and a reason", + "the tasks exist in the handoff repository", + "the developer confirmed the cut when it changes scope" ] }, { "id": "oracle", "name": "The Oracle", + "title": "Business analyst", "role": "Business and requirements analyst", "summary": "Before anyone designs or builds, finds out what is really needed: the question behind the request, users and their goals, rules, edge cases, and acceptance criteria.", "personality": "Warm, plain, and perceptive. Asks the question behind the question and never fills a gap in the requirements with a guess.", @@ -96,11 +142,31 @@ "analysis", "requisitos", "negocio" + ], + "goal": "Everyone agrees on what done means before the code starts: requirements, acceptance criteria, and the gaps between what exists and what is needed.", + "method": [ + "Read what exists (the code, the docs, the handoff note) before asking anything.", + "Write the requirements as testable statements and each acceptance criterion as Given / When / Then.", + "List the gaps and the contradictions, ranked by impact, each with the source that proves it.", + "Ask the developer only the questions the material cannot answer (`dev crew ask --decision`)." + ], + "devkit": [ + "dev plan new", + "dev plan revise", + "dev task show", + "dev crew ask", + "dev grep" + ], + "done_when": [ + "every requirement can be tested", + "every gap names its evidence", + "open questions are asked, not guessed" ] }, { "id": "architect", "name": "The Architect", + "title": "Software architect", "role": "Software architect", "summary": "Designs the system and writes it down in decision records and C4 diagrams: boundaries, data and API contracts, and technology choices with their trade-offs.", "personality": "Exact and systematic. Thinks in constraints and trade-offs, names the options he rejected and why, and writes every decision down.", @@ -137,11 +203,31 @@ "decision", "arquitectura", "diseño" + ], + "goal": "The system has clear boundaries and contracts, so the specialists can build in parallel without breaking each other.", + "method": [ + "State the forces: requirements, constraints, quality attributes, and what must not change.", + "Compare two or three options with their trade-offs; recommend one.", + "Define the contracts first (API, events, schemas, module interfaces) and the boundaries between modules.", + "Record the decision as an ADR or a plan (`dev plan new --kind adr`), with a diagram when it helps (`dev diagram`)." + ], + "devkit": [ + "dev plan new", + "dev plan revise", + "dev diagram", + "dev grep", + "dev crew ask" + ], + "done_when": [ + "the contracts are written before the implementation starts", + "each option has its trade-offs", + "the decision and its reasons are recorded" ] }, { "id": "roland", "name": "Roland", + "title": "Technical researcher", "role": "Technical researcher", "summary": "Runs spikes and research: evaluates libraries, vendors and approaches against the project's needs, reads the version-matched docs, and recommends with evidence.", "personality": "Skeptical and fair. Trusts evidence over hype, tests claims in a spike, and states what he could not verify.", @@ -175,11 +261,29 @@ "benchmark", "investigar", "investigación" + ], + "goal": "A choice of library, vendor or approach rests on evidence from current sources, not on habit.", + "method": [ + "Write the question and the criteria (fit, maturity, license, security, cost, lock-in) before searching.", + "Read primary sources for the exact version: the vendor's docs, changelogs, issues and advisories.", + "Try the top candidates in a scratch spike when the docs are not enough; never in the repository.", + "Report a comparison table, a recommendation and the risks, with a link for every claim." + ], + "devkit": [ + "dev crew ask", + "dev sec scan", + "dev task add" + ], + "done_when": [ + "every claim has a source", + "the recommendation names its risks", + "spike code stays outside the repository" ] }, { "id": "neo", "name": "Neo", + "title": "Senior engineer", "role": "Senior software engineer", "summary": "Implements changes in any layer, test first, in small verified steps: features, bug fixes, and refactors that cross the stack.", "personality": "Calm and precise. Learns how the system really works before changing it, and shows evidence instead of claims.", @@ -211,11 +315,32 @@ ], "not_for": [ "work a specialist owns when the specialist is free (route it: dev crew route)" + ], + "goal": "Features and fixes that cross layers work end to end, with a test that proves it.", + "method": [ + "Reproduce the bug or write the failing test first (`dev test`), and read the code that owns the behavior.", + "Find the root cause; fix it in the layer that owns it, following the codebase's patterns.", + "Run the narrowest tests, then the blast radius (`dev test`), and check that new tests fail without the change (`dev test prove`).", + "Hand the change to Seraph for review with what changed and why." + ], + "devkit": [ + "dev test", + "dev test prove", + "dev test audit", + "dev stubs", + "dev progress step", + "dev crew ask" + ], + "done_when": [ + "a test fails without the change and passes with it", + "the blast radius passes", + "no stubs or untracked TODOs are left" ] }, { "id": "trinity", "name": "Trinity", + "title": "Frontend engineer", "role": "Frontend engineer", "summary": "Builds user interfaces: components, state, accessibility, responsive layout and front-end performance, faithful to the design system.", "personality": "Focused, fast, and exact to the pixel. Treats accessibility and keyboard use as requirements, not extras.", @@ -256,11 +381,31 @@ "not_for": [ "the visual direction from scratch (Sati)", "back-end APIs (Tank)" + ], + "goal": "Screens that match the design, work on every screen size and for every user, and stay fast.", + "method": [ + "Start from Sati's design (or ask for one): states, hierarchy, copy.", + "Build with the framework's current patterns and the project's components; keep state minimal.", + "Verify in a real browser at desktop and phone widths, in dark and light, with the keyboard and without console errors.", + "Cover behavior with component tests and the critical flows with a few end-to-end tests." + ], + "devkit": [ + "dev test", + "dev test prove", + "dev dashboard", + "dev progress step", + "dev crew ask" + ], + "done_when": [ + "the screens match the design in every state (empty, loading, error)", + "they pass at 1440 px and 390 px, dark and light, with no console errors", + "the tests cover the behavior the user sees" ] }, { "id": "tank", "name": "Tank", + "title": "Backend engineer", "role": "Backend and API engineer", "summary": "Builds services and APIs: contract first, domain logic isolated from frameworks, resilience, and integration tests against real dependencies.", "personality": "Dependable and thorough. Loads exactly what the job needs, checks every connection, and keeps the operator's console clean.", @@ -296,11 +441,32 @@ "not_for": [ "schemas and migrations (Dozer)", "UI (Trinity)" + ], + "goal": "APIs and services that keep their contract, fail safely, and are covered by tests against real dependencies.", + "method": [ + "Read or write the contract first (OpenAPI, schema, events); keep it backward compatible.", + "Keep domain logic out of transport and persistence; validate input at the boundary.", + "Add timeouts, bounded retries and idempotency where calls can fail; errors as RFC 9457 problem details.", + "Test the contract and the integration against real dependencies (Testcontainers), then ask Keymaker for a review when the change touches auth, data or input from outside." + ], + "devkit": [ + "dev test", + "dev test prove", + "dev ci --run", + "dev docker up", + "dev sec scan", + "dev crew ask" + ], + "done_when": [ + "the contract is written and the code matches it", + "integration tests run against real dependencies", + "errors and timeouts are handled and tested" ] }, { "id": "dozer", "name": "Dozer", + "title": "Database engineer", "role": "Data and database engineer", "summary": "Owns schemas, migrations, queries and data integrity: expand, migrate, contract; indexes; query plans; data fixes as separate migrations.", "personality": "Practical and careful with what others depend on. Never touches production data without a reversible plan.", @@ -338,11 +504,30 @@ "not_for": [ "moving a whole system or platform (The Trainman)", "analysis and metrics (Apoc)" + ], + "goal": "Schema changes that never lose data or stop the service, and queries that stay fast as the data grows.", + "method": [ + "Write every change as a forward-only migration; never edit one that has run anywhere shared.", + "Use expand, migrate, contract for changes that must not stop the service.", + "Check query plans (`EXPLAIN`) and add the indexes the new queries need.", + "Test the migration up and the queries against the real database (Testcontainers)." + ], + "devkit": [ + "dev test", + "dev docker up", + "dev ci --run", + "dev crew ask" + ], + "done_when": [ + "the migration runs on a copy of production-shaped data", + "no query does a full scan on a large table", + "the rollback or the forward fix is written down" ] }, { "id": "switch", "name": "Switch", + "title": "Mobile engineer", "role": "Mobile engineer", "summary": "Builds and fixes mobile apps (iOS, Android, cross-platform): platform conventions, offline behavior, permissions and store-ready builds.", "personality": "Direct and decisive. Respects each platform's conventions and tests on the devices users really have.", @@ -372,11 +557,29 @@ ], "not_for": [ "web front ends (Trinity)" + ], + "goal": "Mobile apps that feel native, work offline where they should, and pass the store reviews.", + "method": [ + "Start from Sati's design and the platform's guidelines.", + "Build with the project's framework (Swift, Kotlin, React Native, Flutter) and its patterns.", + "Test on simulators and real devices: screen sizes, permissions, offline, and slow networks.", + "Hand store-facing changes (permissions, privacy labels) to Persephone." + ], + "devkit": [ + "dev test", + "dev progress step", + "dev crew ask" + ], + "done_when": [ + "the app passes on the smallest and largest supported devices", + "permissions and privacy labels are correct", + "the tests cover the changed flows" ] }, { "id": "rama", "name": "Rama-Kandra", + "title": "AI engineer", "role": "AI and ML engineer", "summary": "Builds AI features: integrations with large language models (LLMs), prompts and tools, retrieval, evaluations, and the data pipelines models depend on.", "personality": "Thoughtful and principled. Measures a model's behavior with evaluations before trusting it, and is honest about uncertainty.", @@ -410,11 +613,29 @@ ], "not_for": [ "general back-end work (Tank)" + ], + "goal": "AI features whose quality is measured, whose cost is known, and whose failures are safe.", + "method": [ + "Define what good output is and build an evaluation set before tuning prompts or models.", + "Choose the model and the retrieval design for the quality, latency and cost the feature needs.", + "Guard the inputs and outputs: prompt injection, data leakage, and refusals.", + "Report the evaluation scores, the cost per call, and the failure cases." + ], + "devkit": [ + "dev test", + "dev sec scan", + "dev crew ask" + ], + "done_when": [ + "an evaluation set measures the quality", + "the cost and latency are measured", + "prompt injection and data leakage are tested" ] }, { "id": "kid", "name": "Kid", + "title": "DevEx engineer", "role": "Developer experience and tooling engineer", "summary": "Makes the team faster: scripts, CLIs, local environments, build speed, generators, devkit itself and the project's agent tooling.", "personality": "Eager and resourceful. Turns any repeated chore into a reliable command, and documents it so nobody has to ask.", @@ -446,11 +667,32 @@ ], "not_for": [ "product features" + ], + "goal": "Developers spend their time on the product, not on setting up, waiting, or repeating chores.", + "method": [ + "Measure the chore first: the steps, the prompts, and the minutes it costs today.", + "Automate it with the standard library and the project's tools, safe to re-run, with clear output and errors that say how to recover.", + "Test the script or CLI in a sandbox (temporary folders, stub commands), never on the developer's machine.", + "Document it where people look (`dev docs check`), and report the time it saves." + ], + "devkit": [ + "dev test", + "dev test prove", + "dev docs check", + "dev doctor", + "dev progress step", + "dev crew ask" + ], + "done_when": [ + "the chore is measured before and after", + "the tool is tested in a sandbox", + "the docs show how to use it and how to recover" ] }, { "id": "sati", "name": "Sati", + "title": "UX designer", "role": "UX and visual designer", "summary": "Designs how it should work and look: flows, information hierarchy, states, copy and visual direction, and critiques built screens.", "personality": "Caring and imaginative. Designs for the people who use it, and explains every choice in terms of their task.", @@ -481,11 +723,29 @@ ], "not_for": [ "implementing the screens (Trinity)" + ], + "goal": "People understand what they see and can do their task without help.", + "method": [ + "Start from the user's task and the real content, not from components.", + "Design the flow, the information hierarchy, every state (empty, loading, error, full), and the copy.", + "Critique built screens in a real browser at desktop and phone widths, dark and light, ranked by impact.", + "Hand the design to Trinity with the states and the copy written out; never implement it." + ], + "devkit": [ + "dev dashboard", + "dev crew ask", + "dev progress step" + ], + "done_when": [ + "every state of every screen is designed", + "the copy is written, not placeholder", + "the critique is ranked by impact with the fix for each finding" ] }, { "id": "mouse", "name": "Mouse", + "title": "QA engineer", "role": "Test and quality engineer", "summary": "Designs the test strategy, writes the tests that matter, proves they can fail, finds gaps and flaky tests, and speeds up slow suites.", "personality": "Inventive and curious. Builds the scenario nobody thought of, and a test only counts for him if it can fail.", @@ -518,11 +778,32 @@ ], "not_for": [ "reviewing someone else's change (Seraph)" + ], + "goal": "Tests that catch real bugs, run only what a change can break, and never flake.", + "method": [ + "Map what can break: the behavior, the edge cases, and the failure paths of the change.", + "Write one sharp test per behavior; check that each fails without the change (`dev test prove`).", + "Audit the suite for tests that cannot fail (`dev test audit`), flaky tests, and slow ones.", + "Hunt bugs by driving the real product, and report each with the steps to reproduce it." + ], + "devkit": [ + "dev test", + "dev test plan", + "dev test prove", + "dev test audit", + "dev ci --run", + "dev crew ask" + ], + "done_when": [ + "every new test fails without the change", + "the audit reports no test that cannot fail", + "every bug found has reproduction steps" ] }, { "id": "seraph", "name": "Seraph", + "title": "Code reviewer", "role": "Independent code reviewer", "summary": "Reviews a change before it ships, independently of who wrote it: correctness, edge cases, tests, readability, and fit with the codebase.", "personality": "Courteous and exacting. Tests a change the way a guardian tests a visitor, and grants approval only when it is earned.", @@ -549,11 +830,32 @@ "merge-review", "revisar", "revisión" + ], + "goal": "Nothing merges that the team would regret: correctness, security, tests, and fit with the codebase are checked by someone who did not write it.", + "method": [ + "Read the brief, the plan and the diff (`dev pr diff`, `git diff`) before the explanation.", + "Check correctness first, then tests, security, performance, and fit with the codebase's patterns.", + "Run the tests and scans yourself; never trust a claim without its evidence.", + "Report findings ranked by severity, each with the file, the line, the risk, and the fix; approve only when nothing blocking is left." + ], + "devkit": [ + "dev test", + "dev test audit", + "dev stubs", + "dev sec scan", + "dev pr diff", + "dev crew ask" + ], + "done_when": [ + "every changed file was read", + "the tests and scans were run in this review", + "the verdict is approve or changes requested, with the reasons" ] }, { "id": "keymaker", "name": "The Keymaker", + "title": "Security engineer", "role": "Security engineer", "summary": "Keeps every door locked: threat models, authentication and authorization, secrets, dependencies and supply chain, and the security scans.", "personality": "Meticulous and modest. Every key has exactly one door, least privilege is the default, and no secret leaves its lock.", @@ -593,11 +895,31 @@ ], "not_for": [ "attacking the system (Agent Smith)" + ], + "goal": "Every feature that touches auth, data, money, files, outside input or infrastructure is secure by design and proven by tests.", + "method": [ + "Threat model the change: assets, entry points, trust boundaries, and abuse cases.", + "Check authentication, authorization per operation, input validation, secrets, and least privilege.", + "Scan (`dev sec scan`): secrets, dependencies, static analysis, containers and infrastructure.", + "Report each finding with its severity, the proof, and the fix; write the test that keeps it fixed." + ], + "devkit": [ + "dev sec scan", + "dev guard check", + "dev ci audit", + "dev test", + "dev crew ask" + ], + "done_when": [ + "the threat model covers every entry point", + "the scan is clean or every finding has a decision", + "every fixed issue has a test" ] }, { "id": "smith", "name": "Agent Smith", + "title": "Red team tester", "role": "Adversarial tester (red team)", "summary": "Tries to break the system the way an attacker or a hostile input would: abuse cases, injection, privilege escalation, race conditions, and failure modes.", "personality": "Relentless and methodical. Assumes every input is hostile and every assumption is wrong, and reports facts with evidence, never drama.", @@ -628,11 +950,29 @@ "bypass", "harden", "hostile" + ], + "goal": "Weaknesses are found by the crew before anyone else finds them.", + "method": [ + "Read the threat model and the attack surface; choose the attacks with the most impact.", + "Attack the running system in a safe environment: injection, bypasses, privilege escalation, and abuse of limits.", + "Confirm each finding is real (a working reproduction), not a theory.", + "Report the reproduction, the impact, and the variants to check; never fix it yourself." + ], + "devkit": [ + "dev guard check", + "dev sec scan", + "dev crew ask" + ], + "done_when": [ + "every finding has a reproduction", + "false positives are ruled out", + "the variants are listed for the fix" ] }, { "id": "persephone", "name": "Persephone", + "title": "Compliance reviewer", "role": "Compliance, privacy, and licensing reviewer", "summary": "Checks obligations: personal data and privacy (GDPR), data retention, open source licenses, audit trails, and the regulatory controls the code must meet.", "personality": "Perceptive and precise. Knows what every obligation costs, and explains each one as a concrete control in the code.", @@ -666,11 +1006,29 @@ "regulatory", "privacidad", "licencia" + ], + "goal": "The product respects licenses, privacy law, and the data people trust us with.", + "method": [ + "List the personal data and the licenses involved: what, where, why, and for how long.", + "Check each against the obligations (licenses, GDPR, the project's policies) and map it to a control.", + "Flag what needs a lawyer instead of giving legal advice.", + "Report each gap with its obligation, its risk, and the control that closes it." + ], + "devkit": [ + "dev sec scan", + "dev grep", + "dev crew ask" + ], + "done_when": [ + "every dependency's license is known and compatible", + "personal data has a purpose, a place and a retention", + "what needs a lawyer is flagged" ] }, { "id": "link", "name": "Link", + "title": "Release engineer", "role": "CI/CD and release engineer", "summary": "Keeps the line open: pipelines on any CI host, one push and one green run, release by the delivery policy, and fast, honest builds.", "personality": "Cheerful and steady under pressure. Keeps the operator's line open and never lets a red build go unexplained.", @@ -714,11 +1072,35 @@ ], "not_for": [ "cloud infrastructure (Niobe)" + ], + "goal": "Pipelines that are fast, reliable and secure, and releases that ship exactly what was verified.", + "method": [ + "Know what CI runs (`dev ci`) and run the same locally before any push (`dev ci --run`).", + "For a failure, read the first error (`dev run view --failed`), reproduce it locally, fix it, and push once.", + "Keep pipelines pinned, least-privilege and cached (`dev ci audit`).", + "Release from the verified integration branch (`dev pr merge`, `dev release`) and verify the published artifact." + ], + "devkit": [ + "dev ci", + "dev ci --run", + "dev ci audit", + "dev ci watch", + "dev run view", + "dev pr checks", + "dev pr merge", + "dev release", + "dev delivery" + ], + "done_when": [ + "the failure is reproduced locally before the fix", + "one push, one CI run", + "the published artifact is downloaded and verified" ] }, { "id": "niobe", "name": "Niobe", + "title": "SRE", "role": "Infrastructure and SRE", "summary": "Runs the platform: cloud, containers, Kubernetes, infrastructure as code, observability, and capacity and reliability targets.", "personality": "A pilot who stays cool under pressure. Measured, safety first, and changes infrastructure only through reviewed code.", @@ -757,11 +1139,30 @@ ], "not_for": [ "finding why something is slow (The Merovingian)" + ], + "goal": "Infrastructure that is reproducible from git, observable, and safe to change.", + "method": [ + "Change infrastructure only as code (Terraform, Kubernetes, Docker) through a PR; no manual changes.", + "Plan before apply, review the plan, and keep state and secrets out of the repository.", + "Add health checks, metrics, logs and alerts for what you deploy.", + "Hand the security review to Keymaker and the CI side to Link." + ], + "devkit": [ + "dev docker up", + "dev sec scan", + "dev ci --run", + "dev crew ask" + ], + "done_when": [ + "the change is code, reviewed and planned before it is applied", + "the service has health checks and alerts", + "no secret or state file is in git" ] }, { "id": "lock", "name": "Commander Lock", + "title": "Incident commander", "role": "Incident commander", "summary": "Leads an incident: severity, the timeline, mitigation first, communication to stakeholders, then the blameless postmortem and its follow-ups.", "personality": "Disciplined and decisive. Mitigates first, keeps a precise timeline, and turns every incident into concrete prevention.", @@ -792,11 +1193,31 @@ ], "not_for": [ "routine bugs that are not an incident (Neo)" + ], + "goal": "Incidents end fast, people stay informed, and the same incident does not happen twice.", + "method": [ + "Assess the impact and set the severity; name who does what.", + "Stabilize first (roll back, scale, switch off), then find the cause.", + "Send updates on a fixed rhythm with what is known, what is being done, and the next update time.", + "Write the blameless postmortem and turn its lessons into prevention (`dev lesson add`, `dev lesson prevent`)." + ], + "devkit": [ + "dev lesson add", + "dev lesson prevent", + "dev run view", + "dev progress note", + "dev crew ask" + ], + "done_when": [ + "the service is stable and the cause is known", + "the timeline and the impact are written down", + "each lesson has a prevention" ] }, { "id": "merovingian", "name": "The Merovingian", + "title": "Performance analyst", "role": "Performance and observability analyst", "summary": "Finds why things are slow or expensive: profiles, traces, metrics and load tests, from cause to effect, before anyone optimizes.", "personality": "Obsessed with causality, and courteous about it. Every slow request has a cause, and he measures it before he names it.", @@ -833,11 +1254,29 @@ "not_for": [ "running the platform and its alerts (Niobe)", "fixing the cause found (Morpheus assigns it)" + ], + "goal": "The crew knows why something is slow or failing, with the measurement that proves it.", + "method": [ + "Reproduce the slowness with a measurement: latency percentiles, CPU, memory, queries.", + "Profile and trace to the cause; rule out the others with data.", + "Recommend the fix with the expected gain; Morpheus assigns it.", + "Measure again after the fix and report before and after." + ], + "devkit": [ + "dev test", + "dev time", + "dev crew ask" + ], + "done_when": [ + "the cause is proven with a measurement", + "the expected gain is stated", + "the result is measured after the fix" ] }, { "id": "trainman", "name": "The Trainman", + "title": "Migration engineer", "role": "Migration and modernization engineer", "summary": "Moves systems between worlds without losing a passenger: framework and language upgrades, platform moves, strangler patterns, and data migrations.", "personality": "Punctual and exact. Every migration runs on a timetable of small, reversible steps, and nothing is left on the platform.", @@ -870,11 +1309,31 @@ ], "not_for": [ "routine schema changes (Dozer)" + ], + "goal": "Old systems move to supported platforms in small, reversible steps, without stopping the product.", + "method": [ + "Inventory what depends on what, and the breaking changes between the versions.", + "Plan the migration in steps that each keep the build green and can be rolled back.", + "Use the vendor's migration tools and codemods first; hand-edit only what they cannot do.", + "Prove each step with the full test suite and a comparison of behavior before and after." + ], + "devkit": [ + "dev test", + "dev ci --run", + "dev plan new", + "dev progress step", + "dev crew ask" + ], + "done_when": [ + "every step keeps the build green", + "the behavior is the same before and after", + "the rollback for each step is known" ] }, { "id": "apoc", "name": "Apoc", + "title": "Data analyst", "role": "Data and analytics analyst", "summary": "Answers questions with data: queries in SQL and notebooks, metrics and their definitions, dashboards, experiment analysis, and data quality checks.", "personality": "Quiet and rigorous. Defines every metric before computing it, and shows the query behind every number.", @@ -905,11 +1364,29 @@ ], "not_for": [ "schemas and migrations (Dozer)" + ], + "goal": "Questions about the product are answered with data whose definition everyone shares.", + "method": [ + "Write the question and the metric's exact definition before querying.", + "Query read-only, from the right source, and check the data for gaps and duplicates.", + "Show the answer with its time window, its sample size, and its caveats.", + "Hand schema or pipeline changes to Dozer." + ], + "devkit": [ + "dev report", + "dev time", + "dev crew ask" + ], + "done_when": [ + "the metric has a written definition", + "every number states its time window", + "the caveats are written down" ] }, { "id": "ghost", "name": "Ghost", + "title": "Technical writer", "role": "Technical writer", "summary": "Writes and maintains documentation to the devkit docs standard: guides, READMEs, references, runbooks, ADRs, and diagrams.", "personality": "Quiet, thoughtful, and economical with words. Writes for the reader who was not in the room.", @@ -941,11 +1418,30 @@ ], "not_for": [ "code changes" + ], + "goal": "Anyone can learn, use, and fix the product from its docs, in plain American English.", + "method": [ + "Pick the type of page first (tutorial, how-to, reference, explanation) and the reader's task.", + "Write from the code and the real commands, not from memory; run every command you document.", + "Follow the docs skill's style; add a diagram when it explains a mechanism (`dev diagram`).", + "Run `dev docs check` until it reports no errors, and update the CHANGELOG for what users notice." + ], + "devkit": [ + "dev docs check", + "dev diagram", + "dev grep", + "dev crew ask" + ], + "done_when": [ + "every documented command was run", + "`dev docs check` reports no errors", + "the page answers the reader's task" ] }, { "id": "sparks", "name": "Sparks", + "title": "Lookup scout", "role": "Fast read-only lookup scout", "summary": "Finds facts fast: files, symbols, call sites, config values and log lines, returned as path:line with short excerpts.", "personality": "Quick and literal. Reports exactly what is there, and says not found rather than guess.", @@ -973,6 +1469,844 @@ ], "aliases": [ "scout" + ], + "goal": "Fast, checkable facts so the crew does not spend strong-model time on lookups.", + "method": [ + "Search with exact names, globs and symbols; read only the lines you need.", + "Return each fact as path:line with a short excerpt.", + "Say `not found` when it is not found, and mark anything inferred." + ], + "devkit": [ + "dev grep", + "dev find", + "dev files" + ], + "done_when": [ + "every fact has path:line", + "nothing is guessed" + ] + } + ], + "activities": [ + { + "id": "plan", + "name": "Planning, estimates, status and integration", + "R": [ + "morpheus" + ], + "A": "morpheus", + "C": [ + "hamann" + ], + "I": [], + "signals": [ + "plan", + "planning", + "estimate", + "estimates", + "status update", + "kickoff", + "break down", + "coordinate", + "integrate the branches", + "how is it going", + "orchestrate" + ] + }, + { + "id": "scope", + "name": "Scope and priorities", + "R": [ + "hamann" + ], + "A": "hamann", + "C": [ + "oracle", + "morpheus" + ], + "I": [], + "signals": [ + "prioritize", + "priorities", + "priority order", + "backlog", + "scope", + "mvp", + "cut scope", + "what first", + "roadmap", + "trade-off", + "value" + ] + }, + { + "id": "requirements", + "name": "Requirements, acceptance criteria and gap analysis", + "R": [ + "oracle" + ], + "A": "hamann", + "C": [ + "sati", + "architect" + ], + "I": [ + "morpheus" + ], + "signals": [ + "requirements", + "requirement", + "acceptance criteria", + "user story", + "user stories", + "gap analysis", + "gaps", + "what is missing", + "missing features", + "use cases", + "business rules", + "clarify", + "ambiguous", + "functional spec" + ] + }, + { + "id": "research", + "name": "Technical research and spikes", + "R": [ + "roland" + ], + "A": "architect", + "C": [ + "keymaker", + "persephone" + ], + "I": [ + "morpheus" + ], + "signals": [ + "research", + "compare", + "comparison", + "evaluate", + "which library", + "should we use", + "vendor", + "alternatives", + "spike", + "options for", + "state of the art" + ] + }, + { + "id": "architecture", + "name": "Architecture, contracts and design decisions", + "R": [ + "architect" + ], + "A": "morpheus", + "C": [ + "tank", + "keymaker", + "merovingian" + ], + "I": [ + "neo", + "trinity" + ], + "signals": [ + "architecture", + "architectural", + "module boundaries", + "boundaries", + "contract", + "api contract", + "adr", + "decision record", + "system design", + "integration design", + "c4", + "coupling", + "design the system", + "design the module" + ] + }, + { + "id": "ux", + "name": "UX and visual design", + "R": [ + "sati" + ], + "A": "sati", + "C": [ + "trinity", + "oracle" + ], + "I": [ + "morpheus" + ], + "signals": [ + "ux", + "user experience", + "wireframe", + "wireframes", + "mockup", + "mockups", + "screen design", + "design the screens", + "design the screen", + "visual design", + "design critique", + "critique", + "usability", + "information hierarchy", + "look and feel", + "user flow", + "diseño", + "pantalla", + "pantallas" + ] + }, + { + "id": "frontend", + "name": "Front-end implementation", + "R": [ + "trinity" + ], + "A": "seraph", + "C": [ + "sati", + "tank" + ], + "I": [ + "mouse" + ], + "signals": [ + "frontend", + "front-end", + "front end", + "ui component", + "component", + "components", + "page", + "pages", + "css", + "angular", + "react", + "next.js", + "vue", + "browser", + "client-side", + "accessibility", + "a11y", + "responsive", + "live updates", + "javascript" + ] + }, + { + "id": "backend", + "name": "Back-end services and APIs", + "R": [ + "tank" + ], + "A": "seraph", + "C": [ + "architect", + "keymaker", + "dozer" + ], + "I": [ + "trinity", + "mouse" + ], + "signals": [ + "backend", + "back-end", + "back end", + "api", + "apis", + "rest", + "http", + "endpoint", + "endpoints", + "server", + "grpc", + "graphql", + "json api", + "webhook", + "domain logic", + "service", + "spring", + "fastapi", + "django", + "laravel", + "express", + "nestjs", + "server-sent events" + ] + }, + { + "id": "database", + "name": "Schemas, migrations and queries", + "R": [ + "dozer" + ], + "A": "seraph", + "C": [ + "tank", + "merovingian" + ], + "I": [ + "morpheus" + ], + "signals": [ + "database", + "schema", + "schema migration", + "migration for", + "sql", + "postgres", + "postgresql", + "mysql", + "index", + "indexes", + "slow query", + "query plan", + "orm", + "flyway", + "alembic", + "liquibase", + "table" + ] + }, + { + "id": "feature", + "name": "Cross-layer features, refactors and bug fixes", + "R": [ + "neo" + ], + "A": "seraph", + "C": [ + "architect", + "mouse" + ], + "I": [ + "morpheus" + ], + "signals": [ + "feature", + "refactor", + "refactoring", + "bug fix", + "fix the bug", + "crash", + "regression", + "null pointer", + "exception", + "end to end", + "implement" + ] + }, + { + "id": "mobile", + "name": "Mobile apps", + "R": [ + "switch" + ], + "A": "seraph", + "C": [ + "sati", + "tank" + ], + "I": [ + "mouse" + ], + "signals": [ + "ios", + "android", + "mobile", + "react native", + "flutter", + "swift", + "kotlin", + "app store", + "play store" + ] + }, + { + "id": "ai", + "name": "AI and ML features", + "R": [ + "rama" + ], + "A": "seraph", + "C": [ + "architect", + "keymaker" + ], + "I": [ + "morpheus" + ], + "signals": [ + "llm", + "rag", + "retrieval", + "embedding", + "embeddings", + "vector search", + "prompt engineering", + "evaluation set", + "evals", + "fine-tune", + "machine learning", + "ml model", + "ai feature", + "chatbot", + "support bot" + ] + }, + { + "id": "tooling", + "name": "Developer tooling and experience", + "R": [ + "kid" + ], + "A": "seraph", + "C": [ + "link", + "ghost" + ], + "I": [ + "morpheus" + ], + "signals": [ + "cli", + "command line", + "script", + "scripts", + "tooling", + "installer", + "install script", + "curl", + "pipe", + "bootstrap", + "developer experience", + "dx", + "local build", + "dev environment", + "makefile", + "automation", + "automate", + "homebrew", + "setup wizard", + "chore" + ] + }, + { + "id": "testing", + "name": "Test strategy, quality and bug hunting", + "R": [ + "mouse" + ], + "A": "seraph", + "C": [ + "neo" + ], + "I": [ + "morpheus" + ], + "signals": [ + "test", + "tests", + "testing", + "test strategy", + "flaky", + "coverage", + "qa", + "quality", + "missing tests", + "mutation testing", + "find bugs", + "bugs", + "bug hunt", + "e2e", + "end-to-end tests", + "works as expected", + "verify the behavior" + ] + }, + { + "id": "security", + "name": "Security engineering and review", + "R": [ + "keymaker" + ], + "A": "keymaker", + "C": [ + "smith", + "persephone" + ], + "I": [ + "morpheus" + ], + "signals": [ + "security", + "secure", + "security review", + "threat model", + "auth", + "authentication", + "authorization", + "oauth", + "jwt", + "secrets", + "secret", + "token", + "csrf", + "xss", + "injection", + "cve", + "vulnerability", + "encryption", + "hardening", + "owasp", + "least privilege", + "dns rebinding" + ] + }, + { + "id": "redteam", + "name": "Adversarial testing", + "R": [ + "smith" + ], + "A": "keymaker", + "C": [ + "mouse" + ], + "I": [ + "morpheus" + ], + "signals": [ + "red team", + "attack", + "adversarial", + "penetration test", + "pentest", + "exploit", + "abuse", + "bypass", + "try to break" + ] + }, + { + "id": "review", + "name": "Code review before a PR or merge", + "R": [ + "seraph" + ], + "A": "morpheus", + "C": [ + "keymaker" + ], + "I": [], + "signals": [ + "code review", + "review the change", + "review the diff", + "review the pr", + "second opinion", + "review before merge", + "review before merging", + "review" + ] + }, + { + "id": "compliance", + "name": "Licenses, privacy and compliance", + "R": [ + "persephone" + ], + "A": "persephone", + "C": [ + "keymaker" + ], + "I": [ + "hamann", + "morpheus" + ], + "signals": [ + "license", + "licenses", + "licensing", + "gdpr", + "privacy", + "personal data", + "pii", + "compliance", + "consent", + "data retention", + "terms of service", + "check the licenses", + "license check", + "licenses of" + ] + }, + { + "id": "cicd", + "name": "CI/CD pipelines", + "R": [ + "link" + ], + "A": "link", + "C": [ + "niobe", + "keymaker" + ], + "I": [ + "morpheus" + ], + "signals": [ + "ci", + "ci/cd", + "cicd", + "pipeline", + "pipelines", + "github actions", + "workflow", + "workflows", + "gitlab ci", + "azure pipelines", + "bitbucket pipelines", + "failing build", + "ci failure", + "runner" + ] + }, + { + "id": "release", + "name": "Versions, tags and releases", + "R": [ + "link" + ], + "A": "morpheus", + "C": [ + "seraph", + "ghost" + ], + "I": [ + "hamann" + ], + "signals": [ + "release", + "cut release", + "version bump", + "tag", + "publish", + "ship it", + "calver", + "semver", + "hotfix" + ] + }, + { + "id": "infrastructure", + "name": "Infrastructure and operations", + "R": [ + "niobe" + ], + "A": "keymaker", + "C": [ + "link", + "merovingian" + ], + "I": [ + "morpheus" + ], + "signals": [ + "docker", + "dockerfile", + "kubernetes", + "k8s", + "helm", + "terraform", + "cloud", + "aws", + "gcp", + "infrastructure", + "iac", + "deploy", + "deployment", + "hosting", + "dns", + "load balancer", + "sre", + "monitoring", + "alerts" + ] + }, + { + "id": "incident", + "name": "Incidents", + "R": [ + "lock" + ], + "A": "lock", + "C": [ + "niobe", + "merovingian" + ], + "I": [ + "hamann", + "morpheus" + ], + "signals": [ + "incident", + "outage", + "production is down", + "prod is down", + "down in production", + "sev1", + "sev 1", + "alert firing", + "postmortem", + "degraded", + "on-call" + ] + }, + { + "id": "performance", + "name": "Performance and observability analysis", + "R": [ + "merovingian" + ], + "A": "morpheus", + "C": [ + "tank", + "dozer", + "niobe" + ], + "I": [ + "morpheus" + ], + "signals": [ + "performance", + "slow", + "latency", + "p95", + "p99", + "profiling", + "profile", + "memory leak", + "cpu", + "throughput", + "optimize", + "observability", + "tracing", + "opentelemetry" + ] + }, + { + "id": "migration", + "name": "Migrations and modernization", + "R": [ + "trainman" + ], + "A": "architect", + "C": [ + "mouse", + "link" + ], + "I": [ + "morpheus" + ], + "signals": [ + "modernize", + "modernization", + "legacy", + "upgrade to", + "framework upgrade", + "port to", + "move to", + "migrate to", + "migrate from", + "end of life" + ] + }, + { + "id": "data-analysis", + "name": "Data questions and metrics", + "R": [ + "apoc" + ], + "A": "hamann", + "C": [ + "dozer" + ], + "I": [ + "morpheus" + ], + "signals": [ + "metric", + "metrics definition", + "kpi", + "kpis", + "analytics", + "analysis of", + "how many", + "cohort", + "funnel", + "statistics", + "data question", + "report on usage" + ] + }, + { + "id": "docs", + "name": "Documentation", + "R": [ + "ghost" + ], + "A": "morpheus", + "C": [], + "I": [], + "signals": [ + "docs", + "documentation", + "document it", + "readme", + "guide", + "getting started", + "runbook", + "tutorial", + "release notes", + "changelog", + "how-to", + "write the docs", + "update the docs", + "document the" + ] + }, + { + "id": "lookup", + "name": "Fast lookups", + "R": [ + "sparks" + ], + "A": "morpheus", + "C": [], + "I": [], + "signals": [ + "where is", + "where are", + "find where", + "which files", + "locate", + "call sites", + "look up", + "list all", + "who calls" ] } ] diff --git a/lib/devkit/canary.py b/lib/devkit/canary.py index e9ce13e..e256fb0 100644 --- a/lib/devkit/canary.py +++ b/lib/devkit/canary.py @@ -94,7 +94,7 @@ def has_marker(text: str) -> bool: SEP = " ❯ " GLYPHS = "✓⚠✗–" -BULLET_RE = re.compile(r"^\s*[-*•]\s+\**\s*(done|check|progress|next)\s*\**\s*:\s*\**\s*(.*)$", re.I) +BULLET_RE = re.compile(r"^\s*[-*•]\s+\**\s*(done|check|progress|crew|you|next)\s*\**\s*:\s*\**\s*(.*)$", re.I) def _clean(s: str) -> str: @@ -102,10 +102,11 @@ def _clean(s: str) -> str: def parse_status(text: str): - """The status block -> {project, branch, task, done, check, progress, next}, or None without the marker. + """The status block -> {project, branch, task, done, check, progress, crew, you, next}, or None without the + marker. Progress, Crew and You are optional. **[Firefly> DevKit]** api · `main` · Invoice export - - **Done:** … - **Check:** ✓ … - **Progress:** … - **Next:** …""" + - **Done:** … - **Check:** ✓ … - **Progress:** … - **Crew:** … - **You:** … - **Next:** …""" lines = [ln for ln in (text or "").splitlines() if ln.strip()] if not lines: return None @@ -113,9 +114,9 @@ def parse_status(text: str): if not m: return None head = m.group(1).strip() - out = {"project": "", "branch": "", "task": "", "done": "", "check": "", "progress": "", "next": "", - "legacy": False} - for ln in lines[1:7]: + out = {"project": "", "branch": "", "task": "", "done": "", "check": "", "progress": "", "crew": "", "you": "", + "next": "", "legacy": False} + for ln in lines[1:9]: b = BULLET_RE.match(ln) if not b: break @@ -142,6 +143,10 @@ def status_text(text: str) -> str: parts = [f"{p['project'] or '-'} · {p['branch'] or '-'}", p["task"] or "-", p["done"], p["check"]] if p["progress"]: parts.append("▸ " + p["progress"]) + if p.get("crew"): + parts.append("crew: " + p["crew"]) + if p.get("you"): + parts.append("you → " + p["you"]) if p["next"]: parts.append("next → " + p["next"]) return SEP.join(x for x in parts if x) @@ -171,7 +176,7 @@ def format_issues(text: str) -> list: issues.append(f"no **{k.capitalize()}:** bullet") if p.get("check") and p["check"][:1] not in GLYPHS: issues.append("Check does not start with ✓, ⚠, ✗ or –") - if any(len(p.get(k) or "") > 160 for k in ("done", "check", "next")): + if any(len(p.get(k) or "") > 160 for k in ("done", "check", "crew", "you", "next")): issues.append("a bullet is longer than one plain sentence") if re.search(r"\bT-[0-9a-z]{8,}\b", " ".join(p.get(k) or "" for k in ("task", "done", "next"))): issues.append("internal task IDs in the block (use the task's title)") diff --git a/lib/devkit/cli.py b/lib/devkit/cli.py index 84bd2ac..e1f89e2 100644 --- a/lib/devkit/cli.py +++ b/lib/devkit/cli.py @@ -1322,7 +1322,7 @@ def cmd_progress(a): .get("plan") or [])} if not from_agent else {} try: run = pg.step(" ".join(a.text), a.next or "", kind=kind, check=check, which=getattr(a, "step", None), - from_agent=from_agent, force=getattr(a, "force", False)) + from_agent=from_agent, force=getattr(a, "force", False), why=getattr(a, "why", "") or "") except ValueError as e: ui.err(str(e)) return 2 @@ -1339,7 +1339,8 @@ def cmd_progress(a): if act in ("assign", "begin", "add", "reopen", "skip"): try: if act == "assign": - run = pg.assign(a.which, " ".join(a.to), scope=getattr(a, "scope", "") or "", from_agent=from_agent) + run = pg.assign(a.which, " ".join(a.to), scope=getattr(a, "scope", "") or "", from_agent=from_agent, + why=getattr(a, "why", "") or "") elif act == "begin": run = pg.begin(a.which, from_agent=from_agent) elif act == "add": @@ -2021,7 +2022,6 @@ def _quota_text(q: dict) -> str: return (ui.red("exhausted · ") + txt) if not q.get("available") else (ui.yellow(txt) if (used or 0) >= 80 else txt) -def cmd_agents(a): def rules_text(section: str = "") -> str: """devkit's global rules as `dev install` writes them, or one section ("0", "3", "crew", "status block").""" from . import setup @@ -2041,8 +2041,8 @@ def rules_text(section: str = "") -> str: raise ValueError(f"no section {section!r}: give its number (0-12) or words from its title") +def cmd_agents(a): from . import providers - if a.action == "test": if a.action == "rules": try: print(rules_text(getattr(a, "section", "") or "")) @@ -2050,6 +2050,7 @@ def rules_text(section: str = "") -> str: ui.err(str(e)) return 2 return 0 + if a.action == "test": targets = ["claude", "codex"] if a.agent in (None, "all") else [a.agent] rc = 0 for t in targets: @@ -2279,10 +2280,13 @@ def add(name, fn, help_, **kw): "dependencies done, no subagent still on it)") if verb == "step": x.add_argument("--no-check", action="store_true", help="nothing to verify for this step (shown as ⚠)") + if verb == "done": + x.add_argument("--why", help="end the run although an Accountable member has not approved (recorded)") x = gsp.add_parser("assign", help="hand a plan step to a subagent: the one launched with this description owns it") x.add_argument("which", help="step number or part of its name") x.add_argument("--to", nargs="+", required=True, help="the subagent's description or name, e.g. Neo") x.add_argument("--scope", help="the files it owns, comma-separated globs; the guard keeps its edits inside them") + x.add_argument("--why", help="assign a member the crew's RACI does not make Responsible (recorded)") x = gsp.add_parser("begin", help="mark a plan step as started (parallel work you do yourself)") x.add_argument("which", help="step number or part of its name") x = gsp.add_parser("add", help="add a step found during the work: \"name=30m after other\"") @@ -2422,11 +2426,11 @@ def add(name, fn, help_, **kw): p = add("agents", cmd_agents, "Claude Code and Codex: install, sign-in, plan, quota, self-test") p.add_argument("action", nargs="?", choices=["status", "test", "rules"], default="status") p.add_argument("agent", nargs="?", choices=["claude", "codex", "all"]) + p.add_argument("--section", help="rules: one section, by number (0-12) or words from its title") p.add_argument("--json", action="store_true") p.add_argument("--model", help="model for the self-test session") p.add_argument("-v", "--verbose", action="store_true", help="show the agent's reply") add("calver", cmd_calver, "Current and next CalVer version (YY.MM.PATCH)").add_argument( - p.add_argument("--section", help="rules: one section, by number (0-12) or words from its title") "--next", action="store_true", help="print only the next version") p = add("plugin", cmd_plugin, "Validate the devkit plugin for Claude Code and Codex") p.add_argument("action", choices=["validate"]) diff --git a/lib/devkit/crew.py b/lib/devkit/crew.py index 3e0ca4a..bae410b 100644 --- a/lib/devkit/crew.py +++ b/lib/devkit/crew.py @@ -30,10 +30,11 @@ "read": "Read, Grep, Glob"} CLAUDE_MODEL = {"strong": "inherit", "standard": "inherit", "fast": "haiku"} CLAUDE_EFFORT = {"strong": "high", "fast": "low"} # standard: the session's default effort, like Codex -REQUIRED = ("id", "name", "role", "summary", "personality", "use_when", "skills", "tools", "tier", "returns") +REQUIRED = ("id", "name", "title", "role", "summary", "personality", "use_when", "skills", "tools", "tier", "returns", + "goal", "method", "devkit", "done_when") OPTIONAL = ("main", "not_for", "aliases", "keywords") # keywords: routing words (dev crew route) -TEXT_FIELDS = ("id", "name", "role", "summary", "personality", "returns") -LIST_FIELDS = ("use_when", "not_for", "skills", "aliases", "keywords") +TEXT_FIELDS = ("id", "name", "title", "role", "summary", "personality", "returns", "goal") +LIST_FIELDS = ("use_when", "not_for", "skills", "aliases", "keywords", "method", "devkit", "done_when") LEGACY_ALIASES = {"sparks": ["scout"]} # agent names older briefs still use; a member's own `aliases` field wins ID_RE = re.compile(r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$") _PREFIXED = re.compile(r"^[a-z0-9][a-z0-9_-]*:(\S+)$") # devkit:neo -> neo (not "Neo: build the parser") @@ -102,7 +103,7 @@ def check(data, known=None): mid = m.get("id") if isinstance(m.get("id"), str) and m.get("id") else None where = f"`{mid}`" if mid else f"member {i}" missing = [k for k in REQUIRED if k not in m or m[k] is None or (k in TEXT_FIELDS and m[k] == "") - or (k == "use_when" and m[k] == [])] + or (k in ("use_when", "method", "devkit", "done_when") and m[k] == [])] if missing: errors.append(f"{where}: missing {', '.join(missing)}") for k in TEXT_FIELDS: @@ -138,20 +139,73 @@ def check(data, known=None): "plugin of config/claude/plugins.json)") if len(mains) > 1: errors.append("only one member can be `main`, not " + ", ".join(mains)) - return errors, warnings + from . import raci + e2, w2 = raci.check(data) + return errors + e2, warnings + w2 _CACHE = {} -def load(path=None) -> dict: - """The validated roster; `warnings` lists what is suspicious but allowed. Raises RosterError.""" - p = Path(path or ROSTER) +def overlay_path() -> Path: + """The team's changes to the crew, in the handoff repository (written only by `dev crew set|raci --set`).""" + return core.HANDOFF / "crew" / "crew.json" + + +def read_overlay(path=None) -> dict: + ov = core.read_json(Path(path or overlay_path()), {}) or {} + ov = ov if isinstance(ov, dict) else {} + for k in ("members", "activities"): + ov[k] = ov.get(k) if isinstance(ov.get(k), dict) else {} + ov["changelog"] = ov.get("changelog") if isinstance(ov.get("changelog"), list) else [] + return ov + + +def merge(base: dict, ov: dict) -> dict: + """devkit's roster with the team's changes: fields of existing members and activities are replaced, new ones + are added (they must be complete).""" + import copy + out = copy.deepcopy(base) + by_id = {m.get("id"): m for m in out.get("members") or []} + for mid, fields in (ov.get("members") or {}).items(): + if not isinstance(fields, dict): + continue + if mid in by_id: + by_id[mid].update(fields) + else: + out.setdefault("members", []).append(dict(fields, id=mid)) + acts = {a.get("id"): a for a in out.get("activities") or [] if isinstance(a, dict)} + for aid, fields in (ov.get("activities") or {}).items(): + if not isinstance(fields, dict): + continue + if aid in acts: + acts[aid].update(fields) + else: + out.setdefault("activities", []).append(dict(fields, id=aid)) + return out + + +def _stat_key(p: Path): try: st = p.stat() + return (str(p), st.st_mtime_ns, st.st_size) + except OSError: + return (str(p), None, None) + + +def load(path=None, team: bool = True) -> dict: + """The validated roster; `warnings` lists what is suspicious but allowed. Raises RosterError. + + With `team` (the default for devkit's own roster), the team's changes from the handoff repository are merged + on top. When they would break the roster, devkit's roster is used and a warning says why. The generated plugin + agents use devkit's roster alone (`team=False`), so the devkit checkout never changes.""" + p = Path(path or ROSTER) + try: + p.stat() except OSError: raise RosterError(["the roster file is missing"], p) from None - key = (str(p), st.st_mtime_ns, st.st_size) + team = team and path is None + key = (_stat_key(p), _stat_key(overlay_path()) if team else None) if key in _CACHE: return _CACHE[key] try: @@ -161,8 +215,19 @@ def load(path=None) -> dict: errors, warnings = check(data) if errors: raise RosterError(errors, p) + if team and overlay_path().exists(): + ov = read_overlay() + merged = merge(data, ov) + e2, w2 = check(merged) + if e2: + warnings = warnings + [f"the team's crew changes ({core.tilde(overlay_path())}) are not used: " + + "; ".join(e2[:3])] + else: + data, warnings = merged, w2 + data["team_changes"] = len(ov.get("changelog") or []) data["warnings"] = warnings - _CACHE.clear() + if len(_CACHE) > 8: + _CACHE.clear() _CACHE[key] = data return data @@ -219,8 +284,13 @@ def description(m: dict, alias=None) -> str: return d +def who(m: dict) -> str: + """How developers see a member: the role first, then the name ("Backend engineer (Tank)").""" + return f"{m['title']} ({m['name']})" if m.get("title") else m["name"] + + def status_header(m: dict, project="", branch="", task="") -> str: - return f"**[Firefly> DevKit · {m['name']}]** {project} · `{branch}` · {task}" + return f"**[Firefly> DevKit · {who(m)}]** {project} · `{branch}` · {task}" def report_block(m: dict, **header) -> list: @@ -237,6 +307,7 @@ def body(m: dict, roster=None, alias=None) -> str: out = [f"You are {_display(m, alias)}, the crew's {_lc(m['role'])}. {_sentence(m['personality'])} Let that " "shape your judgment and tone, always professionally: no role-play and no film quotes.", "", "## Mission", "", _sentence(m["summary"]), "", + "## Goal", "", _sentence(m["goal"]), "", "## When Morpheus sends work to you", ""] out += [f"- {x}" for x in m["use_when"]] + [""] out += [("Not yours: " + "; ".join(m["not_for"]) if m.get("not_for") else "Not yours: anything outside your brief") @@ -247,7 +318,13 @@ def body(m: dict, roster=None, alias=None) -> str: ""] + [f"- `{s}`" for s in m["skills"]] + [""] else: out += ["None. Load a skill only when your brief names one.", ""] - out += ["## How you work", ""] + out += ["## Your method", ""] + [f"{i}. {_sentence(x)}" for i, x in enumerate(m["method"], 1)] + [""] + out += _raci_section(m, roster) + out += ["## Your devkit commands", "", + "devkit is how the crew works and talks: use these instead of ad-hoc scripts, files or messages." + + ("" if shell else " You have no shell: name the command in your report and Morpheus runs it."), ""] + out += [f"- `{c}`" for c in m["devkit"]] + [""] + out += ["## How you work with the crew", ""] if shell: out += ["- Stay inside the files and the scope your brief names. Before you go beyond them, ask Morpheus: " '`dev crew ask ""`. Any crew member may ask anything; reply to a message with ' @@ -272,11 +349,39 @@ def body(m: dict, roster=None, alias=None) -> str: "## Your report", "", "Your final message starts with your own status block; nothing comes before it:", ""] out += ["```markdown"] + report_block(m) + ["```", "", f"Then deliver: {_sentence(m['returns'])}", "", + "You are done when:", ""] + [f"- {x}" for x in m["done_when"]] + ["", 'End with `Needs from Morpheus:` and the decisions or questions for Morpheus, or `nothing`.', "", "## Language", "", _sentence(roster["language_rule"])] return "\n".join(out) +def _raci_section(m: dict, roster) -> list: + """The member's place in the crew's RACI, in the words it acts on.""" + from . import raci + r = raci.roles_of(m["id"], roster) + if not any(r.values()): + return [] + out = ["## Your place in the crew's RACI", ""] + for a in r["R"]: + line = f"- **You do** {raci._lower(a['name'])}." + if a.get("A") and a["A"] != m["id"]: + line += f" {raci.names([a['A']], roster)} approves it: say so in your report, so Morpheus schedules the review." + if a.get("C"): + line += (f" Consult {raci.names([x for x in a['C'] if x != m['id']], roster)} before decisions that affect " + "them (`dev crew ask --to `).") + out.append(line) + for a in r["A"]: + if m["id"] not in (a.get("R") or []): + out.append(f"- **You approve** {raci._lower(a['name'])} done by {raci.names(a.get('R'), roster)}: review it " + "with evidence, and approve it or send it back with the fixes.") + if r["C"]: + out.append("- **You are consulted on** " + "; ".join(raci._lower(a["name"]) for a in r["C"]) + + ": answer questions from the crew promptly (`dev crew answer \"\"`).") + if r["I"]: + out.append("- **You are informed about** " + "; ".join(raci._lower(a["name"]) for a in r["I"]) + ".") + return out + [""] + + def render_claude(m: dict, roster=None, alias=None) -> str: """plugin/agents/.md for Claude Code (spawned as devkit:).""" roster = roster or load() @@ -331,8 +436,9 @@ def codex_roles(roster=None, codex=None) -> dict: # --------------------------------------------------------------------------- dev crew sync def agent_files(roster=None) -> dict: - """{file name: text} of every generated Claude Code agent, aliases included.""" - roster = roster or load() + """{file name: text} of every generated Claude Code agent, aliases included (devkit's roster: the plugin ships + in the devkit checkout; the team's changes reach the members through their briefs).""" + roster = roster or load(team=False) out = {} for m in members(roster, include_main=False): out[f"{m['id']}.md"] = render_claude(m, roster) @@ -359,7 +465,7 @@ def _rel(path) -> str: def sync(check: bool = False, roster=None, agents_dir=None) -> list: """Write plugin/agents/.md for every subagent and remove generated files no longer in the roster. -> what changed (or, with check=True, what differs) as "new|update|remove ".""" - roster = roster or load() + roster = roster or load(team=False) d = Path(agents_dir or AGENTS_DIR) want = agent_files(roster) out = [] @@ -491,16 +597,16 @@ def roster_text(active=None, roster=None) -> str: rows = [] for m in members(roster): when = ("main session: " if m.get("main") else "") + "; ".join(m["use_when"]) - rows.append([(ui.ACTIVE + " " if m["id"] in busy else " ") + m["id"], m["name"], m["role"], when, - m["tier"], m["tools"]]) - out = [ui.table(rows, [" ID", "NAME", "ROLE", "WHEN TO USE", "TIER", "TOOLS"], max_widths={2: 32, 3: 44})] + rows.append([(ui.ACTIVE + " " if m["id"] in busy else " ") + m.get("title", m["role"]), m["name"], m["id"], + when, m["tier"], m["tools"]]) + out = [ui.table(rows, [" ROLE", "NAME", "ID", "WHEN TO USE", "TIER", "TOOLS"], max_widths={0: 24, 3: 48})] if active: out.append(ui.section("Working now")) for lane in active: m = member(lane.get("member"), roster) or _for_label(lane.get("label"), roster) dot = ui.ACTIVE if lane.get("state") == "running" else ui.IDLE scope = f" · scope {', '.join(lane['scope'])}" if lane.get("scope") else "" - out.append(f" {dot} {(m['name'] if m else lane.get('label') or 'subagent'):<18} " + out.append(f" {dot} {(who(m) if m else lane.get('label') or 'subagent'):<30} " f"{lane.get('state') or '-':<8} {_mins(lane.get('min')):>6} {lane.get('label') or ''}{scope}") return "\n".join(out) @@ -636,13 +742,15 @@ def as_json(roster=None, view=None) -> dict: roster = roster or load() out = [] for m in members(roster): - x = {k: m.get(k) for k in ("id", "name", "role", "main", "summary", "use_when", "not_for", "keywords", - "skills", "tools", "tier", "returns")} + x = {k: m.get(k) for k in ("id", "name", "title", "role", "main", "summary", "goal", "use_when", "not_for", "keywords", + "skills", "method", "devkit", "done_when", "tools", "tier", "returns")} x["aliases"] = aliases(m) if not m.get("main"): x["spawn"] = {"claude_code": f"devkit:{m['id']}", "codex": m["id"]} out.append({k: v for k, v in x.items() if v not in (None, [], False)}) - return {"members": out, "run": view, "warnings": roster.get("warnings") or []} + raci = [{k: a.get(k) for k in ("id", "name", "R", "A", "C", "I")} for a in roster.get("activities") or []] + return {"members": out, "raci": raci, "team_changes": roster.get("team_changes") or 0, "run": view, + "warnings": roster.get("warnings") or []} # --------------------------------------------------------------------------- CLI @@ -763,8 +871,9 @@ def _route(a) -> int: if not ranked: print(f" no crew member fits: name one with dev crew delegate {_dq(task)} --to ") return 0 - print(ui.table([[i, m["name"], m["id"], f"{score:g}", "; ".join(why)] for i, (m, score, why) in - enumerate(ranked, 1)], ["#", "MEMBER", "ID", "SCORE", "WHY"], max_widths={4: 72})) + print(ui.table([[i, m.get("title") or m["role"], m["name"], m["id"], f"{score:g}", "; ".join(why)] + for i, (m, score, why) in enumerate(ranked, 1)], + ["#", "ROLE", "NAME", "ID", "SCORE", "WHY"], max_widths={5: 72})) best = ranked[0][0] print(f" launch: dev crew brief {best['id']} {_dq(task)}") print(ui.dim(f" or add it to the plan: dev crew delegate {_dq(task)} --to {best['id']}")) @@ -869,6 +978,147 @@ def _messages(a, act) -> int: return 0 +EDITABLE = ("name", "title", "role", "summary", "personality", "goal", "returns", "use_when", "not_for", "skills", "method", + "devkit", "done_when", "keywords", "tools", "tier") + + +def _team_write(ov: dict, what: str, why: str, by: str = "") -> Path: + """Validate the team's changes merged on devkit's roster, then write them, the README and one commit.""" + from . import work + if not why.strip(): + raise ValueError("a change to the crew needs its reason: --why \"\"") + base = json.loads(ROSTER.read_text()) + errors, _ = check(merge(base, ov)) + if errors: + raise ValueError("that change would break the crew: " + "; ".join(errors[:4])) + ov.setdefault("changelog", []).append({"at": work.now_iso(), "by": by or work.actor_str(work.actor()), + "what": what, "why": why.strip()}) + f = overlay_path() + f.parent.mkdir(parents=True, exist_ok=True) + f.write_text(json.dumps(ov, indent=2, ensure_ascii=False) + "\n") + _CACHE.clear() + readme = f.parent / "README.md" + readme.write_text(team_readme(load())) + _commit_team([f, readme], f"crew: {what}\n\nwhy: {why.strip()}") + return f + + +def _commit_team(paths, message: str): + """One commit per crew change in the handoff repository (the audit trail); `dev sync` pushes it.""" + from . import audit, work + h = core.HANDOFF + if not (h / ".git").exists(): + return + rel = [str(Path(p).relative_to(h)) for p in paths] + core.git(h, "add", "--", *rel) + if core.git(h, "diff", "--cached", "--quiet")[0] != 0: + core.git(h, "commit", "-q", "-m", message, "--", *rel) + audit.record("handoff.crew", ", ".join(rel), message.split("\n")[0][:300]) + work.request_sync("crew change") + + +def set_member(name: str, field: str, value: str, why: str, by: str = "") -> dict: + """Change one field of a member for the whole team (the handoff repository). List fields take "a; b; c".""" + m = member(name) + if not m: + raise ValueError(f"no crew member {name!r}: dev crew lists them") + if field not in EDITABLE: + raise ValueError(f"`{field}` cannot be changed; these can: {', '.join(EDITABLE)}") + v = [x.strip() for x in value.split(";") if x.strip()] if field in LIST_FIELDS else value.strip() + ov = read_overlay() + ov["members"].setdefault(m["id"], {})[field] = v + _team_write(ov, f"{m['name']}: {field}", why, by) + return member(m["id"]) + + +def set_raci(activity_id: str, changes: dict, why: str, by: str = "") -> dict: + """Change the RACI of one kind of work for the whole team: {"R": [ids], "A": id, "C": [...], "I": [...]}.""" + from . import raci + act = raci.activity(activity_id) + if not act: + raise ValueError(f"no kind of work {activity_id!r}: dev crew raci lists them") + ov = read_overlay() + fields = ov["activities"].setdefault(act["id"], {}) + for k, v in changes.items(): + if k not in raci.LETTERS: + raise ValueError(f"`{k}` is not R, A, C or I") + ids = [member(x)["id"] if member(x) else x for x in ([v] if isinstance(v, str) else v)] + fields[k] = ids[0] if k == "A" else ids + _team_write(ov, f"RACI {act['id']}: " + ", ".join(f"{k}={','.join(fields[k]) if isinstance(fields[k], list) else fields[k]}" + for k in changes), why, by) + return raci.activity(act["id"]) + + +def team_readme(roster=None) -> str: + """The crew as the team runs it, for people reading the handoff repository (generated).""" + from . import raci + roster = roster or load() + out = ["# The crew", "", "_Generated by `dev crew` from devkit's roster and this folder's `crew.json` (the team's " + "changes). Change it with `dev crew set \"…\" --why \"…\"` or `dev crew raci --set " + "\"R=… A=…\" --why \"…\"`; never by hand._", "", + "Morpheus, the main session, leads every development request: he routes each kind of work to the member " + "who does it (R), and the work is not done until the member who approves it (A) has.", "", + "## Members", "", "| Member | Role | Goal |", "| --- | --- | --- |"] + out += [f"| {m['name']} | {m['role']} | {m.get('goal', '')} |" for m in members(roster)] + out += ["", "## RACI", "", "R does the work, A approves it (one member), C is consulted, I is informed.", "", + raci.markdown(roster), "", "## Changes", ""] + log = read_overlay().get("changelog") or [] + out += [f"- {c.get('at', '')} · {c.get('by', '')} · {c.get('what', '')} — why: {c.get('why', '')}" + for c in reversed(log[-50:])] or ["- none yet: the team runs devkit's roster as shipped"] + return "\n".join(out) + "\n" + + +def _raci_cmd(a) -> int: + from . import raci + if a.set: + if not a.work: + ui.err('name the kind of work: dev crew raci --set "R=tank A=seraph" --why "…"') + return 2 + changes = {} + for part in a.set.replace(";", " ").split(): + k, _, v = part.partition("=") + changes[k.strip().upper()] = [x for x in v.split(",") if x] + try: + act = set_raci(a.work, changes, a.why or "") + except ValueError as e: + ui.err(str(e)) + return 2 + print(f"{ui.OK} {act['name']}: R {raci.names(act['R'])} · A {raci.names([act['A']])} · " + f"C {raci.names(act.get('C')) if act.get('C') else '–'} · I {raci.names(act.get('I')) if act.get('I') else '–'}") + print(ui.dim(f" recorded in {core.tilde(overlay_path())} for the whole team")) + return 0 + if a.work: + m = member(a.work) + if m: + r = raci.roles_of(m["id"]) + for k, label in (("R", "does"), ("A", "approves"), ("C", "is consulted on"), ("I", "is informed about")): + if r[k]: + print(f" {who(m)} {label}: " + "; ".join(x["name"] for x in r[k])) + return 0 + act = raci.activity(a.work) + if not act: + ranked = raci.classify(a.work) + if not ranked: + ui.err(f"no member or kind of work {a.work!r}, and no RACI signal in it: dev crew raci lists them") + return 2 + act = ranked[0][0] + print(ui.dim(f" “{a.work}” reads as {act['name']} ({', '.join(ranked[0][2][:4])})")) + rows = [[act["id"], act["name"], raci.names(act["R"]), raci.names([act["A"]]), + raci.names(act.get("C")) if act.get("C") else "–", raci.names(act.get("I")) if act.get("I") else "–"]] + else: + rows = [[x["id"], x["name"], raci.names(x["R"]), raci.names([x["A"]]), + raci.names(x.get("C")) if x.get("C") else "–", raci.names(x.get("I")) if x.get("I") else "–"] + for x in raci.activities()] + if a.markdown: + print(raci.markdown()) + return 0 + print(ui.table(rows, ["ID", "KIND OF WORK", "R · DOES IT", "A · APPROVES", "C · CONSULTED", "I · INFORMED"], + max_widths={1: 30, 2: 30, 3: 30, 4: 40, 5: 30})) + print(ui.dim(' dev crew raci · dev crew raci --set "R=… A=… C=… I=…" --why "…" · ' + "dev crew history")) + return 0 + + def cmd_crew(a) -> int: act = getattr(a, "crew_cmd", None) if act in ("ask", "answer", "inbox", "decide", "decisions", "mode", "route", "delegate", "decline", "next"): @@ -878,8 +1128,26 @@ def cmd_crew(a) -> int: except RosterError as e: ui.err(str(e)) return 2 + if act == "raci": + return _raci_cmd(a) + if act == "set": + try: + m = set_member(a.name, a.field, " ".join(a.value), a.why or "") + except ValueError as e: + ui.err(str(e)) + return 2 + print(f"{ui.OK} {m['name']}: {a.field} changed for the whole team ({core.tilde(overlay_path())})") + print(ui.dim(" the members get it in their briefs; Codex roles with dev install --only codex")) + return 0 + if act == "history": + log = read_overlay().get("changelog") or [] + if not log: + print(" no team changes: the crew runs devkit's roster as shipped") + for c in reversed(log[-40:]): + print(f" {c.get('at', '')} {c.get('by', '')} {c.get('what', '')}\n why: {c.get('why', '')}") + return 0 if act == "sync": - changes = sync(check=a.check, roster=roster) + changes = sync(check=a.check, roster=load(team=False)) for w in roster["warnings"]: ui.warn(w) n = len(agent_files(roster)) @@ -937,6 +1205,17 @@ def register(add): p.add_argument("--latest", action="store_true", help="the most recent running progress run on this Mac instead of this session's") sp = p.add_subparsers(dest="crew_cmd") + x = sp.add_parser("raci", help="who does, approves, is consulted on and informed about each kind of work") + x.add_argument("work", nargs="?", help="a kind of work (backend), a member (tank), or a task to classify") + x.add_argument("--set", help='change it for the whole team: "R=tank,neo A=seraph C=keymaker I=trinity"') + x.add_argument("--why", help="the reason (required with --set)") + x.add_argument("--markdown", action="store_true", help="the whole matrix as a Markdown table") + x = sp.add_parser("set", help="change a member's definition for the whole team (recorded in the handoff repo)") + x.add_argument("name", help="the member: tank, Oracle") + x.add_argument("field", help=", ".join(EDITABLE)) + x.add_argument("value", nargs="+", help='the new value; list fields take "a; b; c"') + x.add_argument("--why", required=True, help="the reason, kept in the crew's history") + sp.add_parser("history", help="the team's changes to the crew, with who and why") x = sp.add_parser("show", help="everything about one member") x.add_argument("name", nargs="+", help="id or name, e.g. neo, Oracle, \"The Keymaker\"") x = sp.add_parser("brief", help="a ready-to-paste brief for a member, with the spawn line") diff --git a/lib/devkit/dispatch.py b/lib/devkit/dispatch.py index 0e76fbe..96b1dc1 100644 --- a/lib/devkit/dispatch.py +++ b/lib/devkit/dispatch.py @@ -45,8 +45,9 @@ def _roster() -> list: - data = core.read_json(core.DEVKIT_HOME / "config" / "crew.json", {}) or {} - return [m for m in data.get("members") or [] if not m.get("main")] + """The crew without Morpheus: devkit's roster with the team's changes from the handoff repository.""" + from . import crew + return crew.members(include_main=False) def _terms(text: str) -> set: @@ -59,8 +60,20 @@ def _terms(text: str) -> set: def route(task: str, files=None, limit: int = 3) -> list: - """-> [(member, score, [reasons])], best first. Scores: the member's keywords (x5), words shared with its role - and use_when (x3), summary and skills (x1), and the files the task touches (x4 per matching file kind).""" + """-> [(member, score, [reasons])], best first. The crew's RACI decides first: when the task is clearly one kind + of work, its Responsible members lead. Then the member scores: keywords (x5), words shared with its role and + use_when (x3), summary and skills (x1), and the files the task touches (x4 per matching file kind).""" + from . import raci + lead = [] + top = raci.best(task) + if top: + act, score, hits = top + for mid in act.get("R") or []: + m = next((x for x in _roster() if x["id"] == mid), None) + if m: + lead.append((m, float(10 + score), [f"RACI R: {act['name']}", + f"approves: {raci.names([act.get('A')])}", + "signals: " + ", ".join(hits[:4])])) words = _terms(task) scored = [] for m in _roster(): @@ -94,6 +107,12 @@ def route(task: str, files=None, limit: int = 3) -> list: if score >= 3: # at least one keyword, role or file hit: a loose summary word alone does not route scored.append((m, round(score, 1), why)) scored.sort(key=lambda x: (-x[1], x[0]["id"] != "neo")) # ties: Neo, the generalist, first + if lead: + ids = {m["id"] for m, _, _ in lead} + own = {m["id"]: why for m, _, why in scored} + lead = [(m, s, why[:1] + [w for w in own.get(m["id"], []) if w.startswith("file ")] + why[1:]) + for m, s, why in lead] # the files the task touches stay visible next to the RACI reason + return (lead + [x for x in scored if x[0]["id"] not in ids])[:limit] if not scored: # nothing specific: the senior engineer neo = next((m for m in _roster() if m["id"] == "neo"), None) return [(neo, 0.0, ["no specialist matched: the generalist"])] if neo else [] diff --git a/lib/devkit/guard.py b/lib/devkit/guard.py index 6dc473a..98f2e16 100644 --- a/lib/devkit/guard.py +++ b/lib/devkit/guard.py @@ -157,6 +157,9 @@ def handoff_violation(rel: str, cwd, envs=frozenset()): if rel.startswith("policies/"): return (f"`{rel}` is a recorded delivery policy, versioned with who and why. Change it with " f"`dev delivery apply --set key=value --why \"…\"`.") + if rel.startswith("crew/"): + return (f"`{rel}` is the team's crew (members and RACI), versioned with who and why. Change it with " + f"`dev crew set \"…\" --why \"…\"` or `dev crew raci --set \"R=… A=…\" --why \"…\"`.") if rel.startswith("lessons/"): return (f"`{rel}` is a lesson, versioned with who and why. Use `dev lesson add|recur|prevent|retire …`.") if rel.startswith("audit/"): diff --git a/lib/devkit/hooks.py b/lib/devkit/hooks.py index f67af0f..e76ca90 100644 --- a/lib/devkit/hooks.py +++ b/lib/devkit/hooks.py @@ -904,6 +904,36 @@ def _context_out(event: str, text: str): print(json.dumps({"hookSpecificOutput": {"hookEventName": event, "additionalContext": text}})) +def morpheus_hint(text: str, roster=None) -> str: + """The route Morpheus takes for a development request: each kind of work it contains (the crew's RACI), who does + it, who approves it, and whom to consult. '' when the request is not clearly any kind of work.""" + try: + from . import raci + if len(str(text or "").split()) < 4: + return "" + ranked = [x for x in raci.classify(text, roster, limit=4) if x[1] >= raci.MIN_SCORE] + except Exception: + return "" + if not ranked: + return "" + parts = [] + for act, _, hits in ranked: + line = f"{raci._lower(act['name'])} → {raci.names(act.get('R'), roster)} does it" + if act.get("A") not in (act.get("R") or []): + line += f", {raci.names([act['A']], roster)} approves" + if act.get("C"): + line += f", consult {raci.names(act['C'], roster)}" + parts.append(line) + if len(parts) == 1: + head = f"devkit · you are Morpheus: this request is {parts[0]} (the crew's RACI)." + else: + head = ("devkit · you are Morpheus: this request spans " + str(len(parts)) + " kinds of work (the crew's " + "RACI): " + "; ".join(parts) + ". Plan one step per kind of work and assign each to its member.") + return (head + " One kind of work under ten minutes: do it yourself. Otherwise plan it (`dev progress start " + "--plan`), assign the steps (`dev progress assign --to `) and brief the members " + "(`dev crew brief \"\"`); the run ends when each approver has reviewed.") + + def prompt(agent: str): """UserPromptSubmit: start the turn clock; answer "how is it going?" from real progress records. @@ -962,7 +992,10 @@ def prompt(agent: str): skills = skillguard.on_prompt(agent, data.get("prompt") or "", sess) except Exception: _log(f"prompt skills {agent}: {traceback.format_exc()[-300:]}") - _context_out("UserPromptSubmit", "\n\n".join(x for x in (extra, skills, text) if x)) + route = "" + if (core.CFG.get("crew") or {}).get("route_hint", True): + route = morpheus_hint(data.get("prompt") or "") + _context_out("UserPromptSubmit", "\n\n".join(x for x in (extra, skills, route, text) if x)) DELIVERY = re.compile(r"\bgit\s+(?:-C\s+\S+\s+)?(commit|push|merge|tag|rebase|cherry-pick|revert)\b|" @@ -1110,6 +1143,16 @@ def _launch_meta(data: dict) -> dict: return meta if isinstance(meta, dict) and (meta.get("toolUseId") or meta.get("description")) else {} +def _who(name: str) -> str: + """A crew member as developers see it, role first ("Backend engineer (Tank)"); the name alone otherwise.""" + try: + from . import crew + m = crew._for_label(name) + return crew.who(m) if m else name + except Exception: + return name + + def subagent_stop(agent: str): """SubagentStop: a crew member's report must start with its own status block (the canary covers the crew too); without it the member is sent back once to write it, and the correction is recorded like the main canary's. Only @@ -1147,10 +1190,21 @@ def subagent_stop(agent: str): pending[aid] = name sf.write_text(json.dumps(sess)) out = {"decision": "block", "reason": - f"devkit · end your report with your status block first, exactly: **[Firefly> DevKit · {name}]** " + f"devkit · end your report with your status block first, exactly: **[Firefly> DevKit · {_who(name)}]** " f" · `` · , then - **Done:**, - **Check:** (✓/⚠/✗ + evidence) and " f"- **Next:**; then what you deliver and \"Needs from Morpheus:\" (or nothing). Nothing above " f"the block."} + elif run and lane and aid not in sess.setdefault("crew_comms", {}) and \ + progress.lane_unreported(run, aid) and core.CFG.get("canary", {}).get("enforce", True): + from . import audit # the crew talks through devkit: the step's evidence goes in the run, live + audit.record("crew.unreported", pid or "", f"{name} stopped without dev progress step", "miss") + sess["crew_comms"][aid] = name + sf.write_text(json.dumps(sess)) + out = {"decision": "block", "reason": + f"devkit · {_who(name)}, record your work through devkit before you stop: `dev progress step " + f"\"\" --check \"\"` (it goes to your lane, and Morpheus and the " + f"developer see it live). Questions and decisions go through `dev crew ask`, not only in the " + f"report. Then end with the same report."} except Exception: _log(f"subagent-stop {agent}: {traceback.format_exc()[-300:]}") if out: diff --git a/lib/devkit/progress.py b/lib/devkit/progress.py index 99d828b..b485b52 100644 --- a/lib/devkit/progress.py +++ b/lib/devkit/progress.py @@ -480,11 +480,44 @@ def _main_only(from_agent, what: str): "dev progress step)") +def _step_text(s: dict, label: str = "") -> str: + """What a step is about, for the RACI: its name, its description, and the task in its label ("Tank: the API").""" + task = label.split(":", 1)[1] if ":" in (label or "") else "" + return " ".join(x for x in (s.get("name") or "", s.get("desc") or "", task) if x) + + +def _raci_assign(run: dict, s: dict, label: str, why: str, now: float): + """The crew's RACI decides who does a step: refuse a member who is not Responsible for it (unless `why`, which + is recorded), and remember the step's kind of work for the approval at the end of the run.""" + try: + from . import crew, raci + mem = crew._for_label(label) + text = _step_text(s, label) + top = raci.best(text) + if top: + s["activity"] = top[0]["id"] + if not mem: + return + reason = raci.check_assignment(mem["id"], text) + except StepError: + raise + except Exception: + return # a broken roster must not stop the work; `dev crew sync --check` reports it + if reason and not why: + raise StepError(reason) + if reason: + run.setdefault("raci_overrides", []).append({"ts": round(now), "step": s["name"], "to": mem["name"], + "why": why}) + from . import audit + audit.record("raci.override", s["name"], f"{mem['name']}: {why}"[:300], "override") + + def assign(which, label: str, cwd: str | None = None, now: float | None = None, scope: str = "", - from_agent: str | None = None) -> dict: + from_agent: str | None = None, why: str = "") -> dict: """Hand a plan step to a subagent: the next subagent launched with this description (or a running lane with this label) owns it, and its time is that subagent's time. `scope`: the files it owns (comma-separated globs); the - guard keeps that subagent's edits inside them.""" + guard keeps that subagent's edits inside them. The crew's RACI must make the member Responsible for the step's + kind of work; `why` overrides it, recorded.""" _main_only(from_agent, "assigns the plan's steps") now = now or time.time() agent, sid = current_session(cwd) @@ -492,6 +525,7 @@ def assign(which, label: str, cwd: str | None = None, now: float | None = None, s = _find_step(run, which) if not s: raise ValueError(f"no step “{which}” in the plan") + _raci_assign(run, s, label, why, now) s["label"] = label if scope: globs = [x.strip() for x in scope.split(",") if x.strip()] @@ -1046,8 +1080,29 @@ def note(text: str, nxt: str = "", cwd: str | None = None, from_agent: str | Non return run +def member_of_step(run: dict, s: dict): + """The crew member who did a step: its lane's persona, or Morpheus for the main session's own steps.""" + from . import crew + owner = s.get("owner") or "main" + if owner == "main": + return "morpheus" + lane = (run.get("agents") or {}).get(owner) or {} + m = crew._for_label(lane.get("persona") or lane.get("label") or s.get("label") or "") + return m["id"] if m else None + + +def approvals_owed(run: dict) -> list: + """[(Accountable member id, [step names])] still to approve the run's finished work. [] when the roster cannot + be read (the check never blocks on a broken roster).""" + try: + from . import raci + return raci.approvals_missing(run, lambda s: member_of_step(run, s)) + except Exception: + return [] + + def step(text: str, nxt: str = "", cwd: str | None = None, kind: str = "step", check: str = "", which=None, - from_agent: str | None = None, now: float | None = None, force: bool = False) -> dict: + from_agent: str | None = None, now: float | None = None, force: bool = False, why: str = "") -> dict: now = now or time.time() agent, sid = current_session(cwd) run = load_run(agent, sid) @@ -1102,6 +1157,16 @@ def step(text: str, nxt: str = "", cwd: str | None = None, kind: str = "step", c if waiting and not force: raise StepError("decisions still waiting for the developer: " + ", ".join( f"{d['id']} ({d['question'][:60]})" for d in waiting) + ". Ask, then dev crew decide \"…\".") + owed = approvals_owed(run) + if owed and not why: + from . import raci + raise StepError("the work is not approved yet (the crew's RACI): " + "; ".join( + f"{raci.names([acc])} approves {', '.join('“' + x + '”' for x in steps)}" for acc, steps in owed) + + ". Add a review step for each (dev progress add \"review=20m\" then dev progress assign review --to " + f"{raci.handle(owed[0][0])}), or end with --why \"\".") + if owed: + run.setdefault("raci_overrides", []).append({"ts": round(now), "step": "(end of run)", "why": why, + "owed": [[a, s_] for a, s_ in owed]}) open_ = [s for s in plan if s.get("state") not in ("done", "skipped") and s.get("state") != "running"] if open_ and not force: raise StepError("not finished: " + ", ".join(f"“{s['name']}” ({s.get('state')})" for s in open_) + @@ -1131,6 +1196,24 @@ def step(text: str, nxt: str = "", cwd: str | None = None, kind: str = "step", c return run +def crew_summary(run: dict, limit: int = 4) -> str: + """Who in the crew works on what in this run, running first: "Tank ▸ the API · Kid ✓ installer". '' without + crew members (the agent's own helpers are not the crew).""" + from . import crew + lanes = [] + for aid, lane in (run.get("agents") or {}).items(): + m = crew._for_label(lane.get("persona") or "") if lane.get("persona") else None + if not m: + continue + step_ = next((s["name"] for s in run.get("plan") or [] if s.get("owner") == aid), "") + what = step_ or (lane.get("label") or "").split(":", 1)[-1].strip() + mark = {"running": "▸", "done": "✓", "lost": "✗"}.get(lane.get("state"), "·") + lanes.append((lane.get("state") != "running", f"{crew.who(m)} {mark} {what}".strip())) + lanes.sort(key=lambda x: x[0]) + parts = [x for _, x in lanes[:limit]] + return " · ".join(parts) + (f" · +{len(lanes) - limit}" if len(lanes) > limit else "") + + def render_update(run: dict, branch: str | None = None, now: float | None = None) -> str: """The progress update as the devkit status block (global instructions §0), ready to send: the block, then the plan with each step's state, owner and time, then the subagents.""" @@ -1152,13 +1235,20 @@ def render_update(run: dict, branch: str | None = None, now: float | None = None nxt = last.get("next") or ("—" if state == "done" else "") if state == "blocked": nxt = f"unblock: {nxt or done}" + plan = run.get("plan") or [] + agents = run.get("agents") or {} lines = [f"**{MARKER}** {project} · `{branch}` · {run.get('goal', '')}", f"- **Done:** {done or 'started'}", f"- **Check:** {check}", - f"- **Progress:** {headline(run, now)}", - f"- **Next:** {nxt or 'continue the plan'}"] - plan = run.get("plan") or [] - agents = run.get("agents") or {} + f"- **Progress:** {headline(run, now)}"] + crew_line = crew_summary(run) + if crew_line: + lines.append(f"- **Crew:** {crew_line}") + waiting = pending_decisions(run) + if waiting: + lines.append("- **You:** decide " + "; ".join(f"“{d['question'][:70]}”" for d in waiting[:2]) + + (f" (+{len(waiting) - 2} more)" if len(waiting) > 2 else "")) + lines.append(f"- **Next:** {nxt or 'continue the plan'}") if plan: lines.append("") for i, s in enumerate(plan, 1): @@ -1459,6 +1549,23 @@ def finish(run): f.write_text(json.dumps(s)) +def lane_unreported(run: dict, aid: str, now: float | None = None, min_seconds: float = 180) -> bool: + """A crew member who owns a plan step, worked at least `min_seconds`, and never used devkit's communication + (`dev progress step|note` from its lane). Read-only members (no shell) report in their message instead.""" + now = now or time.time() + lane = (run.get("agents") or {}).get(aid) or {} + if not lane or lane.get("updates") or now - (lane.get("started") or now) < min_seconds: + return False + if not any(s.get("owner") == aid for s in run.get("plan") or []): + return False + try: + from . import crew + m = crew._for_label(lane.get("persona") or lane.get("label") or "") + except Exception: + m = None + return not (m and m.get("tools") == "read") + + def on_turn_end(agent: str, sid: str, sess: dict): """Stop: close the turn's activity interval; record the turn; notify when a long turn finishes.""" now = time.time() diff --git a/lib/devkit/raci.py b/lib/devkit/raci.py new file mode 100644 index 0000000..df5d6d9 --- /dev/null +++ b/lib/devkit/raci.py @@ -0,0 +1,236 @@ +# Copyright (c) 2026 Firefly Software Solutions Inc. +# SPDX-License-Identifier: Apache-2.0 +"""The crew's RACI: for each kind of work (an activity), who does it (R, Responsible), who approves it (A, Accountable: +exactly one member), who is consulted before or during it (C), and who is informed after it (I). + +It lives in `config/crew.json` (`activities`), with the team's changes from the handoff repository on top +(`crew/crew.json`, see `crew.load`). Morpheus classifies a task with it (`classify`), routes it to the Responsible +member, refuses an assignment that breaks it (`check_assignment`), and does not end a run until every Accountable +member has approved the work they own (`approvals_missing`).""" +from __future__ import annotations + +import re + +LETTERS = ("R", "A", "C", "I") +MIN_SCORE = 3 # one phrase or two words; below it a task is not clearly one kind of work: no routing by + # the RACI and no refusals +MARGIN = 2 # a refusal needs the member's own best activity to score this much lower than the winner + + +def _roster(roster=None) -> dict: + if roster is not None: + return roster + from . import crew + return crew.load() + + +def activities(roster=None) -> list: + return [a for a in _roster(roster).get("activities") or [] if isinstance(a, dict)] + + +def activity(aid: str, roster=None): + return next((a for a in activities(roster) if a.get("id") == aid), None) + + +def check(data: dict): + """-> (errors, warnings) for the `activities` of a roster.""" + errors, warnings = [], [] + acts = data.get("activities") + if acts is None: + return ["the roster needs `activities` (the RACI)"], [] + if not isinstance(acts, list) or not acts: + return ["`activities` must be a non-empty list"], [] + ids = {m.get("id") for m in data.get("members") or [] if isinstance(m, dict)} + main = next((m.get("id") for m in data.get("members") or [] if isinstance(m, dict) and m.get("main")), None) + seen, responsible = set(), set() + for i, a in enumerate(acts, 1): + if not isinstance(a, dict): + errors.append(f"activity {i} is not an object") + continue + aid = a.get("id") or f"activity {i}" + if aid in seen: + errors.append(f"activity `{aid}`: duplicate id") + seen.add(aid) + if not isinstance(a.get("name"), str) or not a["name"].strip(): + errors.append(f"activity `{aid}`: missing `name`") + if not isinstance(a.get("A"), str) or not a["A"]: + errors.append(f"activity `{aid}`: `A` must be exactly one member") + elif a["A"] not in ids: + errors.append(f"activity `{aid}`: A `{a['A']}` is not a crew member") + for k in ("R", "C", "I"): + v = a.get(k) + if not isinstance(v, list) or not all(isinstance(x, str) for x in v): + errors.append(f"activity `{aid}`: `{k}` must be a list of member ids") + continue + for x in v: + if x not in ids: + errors.append(f"activity `{aid}`: {k} `{x}` is not a crew member") + if isinstance(a.get("R"), list): + if not a["R"]: + errors.append(f"activity `{aid}`: `R` needs at least one member") + responsible.update(a["R"]) + sig = a.get("signals") + if not isinstance(sig, list) or not sig or not all(isinstance(s, str) and s.strip() for s in sig): + errors.append(f"activity `{aid}`: `signals` must be a non-empty list of words or phrases") + for mid in sorted(ids - responsible - {main}): + errors.append(f"`{mid}` is Responsible for no activity: give it one in `activities`, or remove it") + return errors, warnings + + +def norm(text: str) -> str: + """Lowercase words for matching: letters, digits, and the joiners of terms such as ci/cd, next.js, a11y.""" + t = str(text or "").lower() + t = re.sub(r"[^a-z0-9ñáéíóúü./+#-]+", " ", t) + return " " + " ".join(w.strip(".-/") or w for w in t.split()) + " " + + +_PATTERNS = {} + + +def _pattern(signal: str): + p = _PATTERNS.get(signal) + if p is None: + p = _PATTERNS[signal] = re.compile(r"(? int: + """A phrase says more than a word.""" + return 3 if re.search(r"[\s/-]", signal.strip()) else 2 + + +def classify(text: str, roster=None, limit: int = 3) -> list: + """-> [(activity, score, [signals found])], best first. The score adds each signal's weight once.""" + t = norm(text) + out = [] + for i, a in enumerate(activities(roster)): + hits = [s for s in a.get("signals") or [] if _pattern(s).search(t)] + # a phrase that contains a word that also matched counts once, as the phrase + hits = [s for s in hits if not any(s != o and len(o) > len(s) and _pattern(s).search(norm(o)) for o in hits)] + if hits: + out.append((a, sum(weight(s) for s in hits), hits, i)) + out.sort(key=lambda x: (-x[1], x[3])) + return [(a, score, hits) for a, score, hits, _ in out[:limit]] + + +def best(text: str, roster=None): + """The activity a task clearly is, or None: (activity, score, signals).""" + ranked = classify(text, roster, limit=2) + if not ranked or ranked[0][1] < MIN_SCORE: + return None + if len(ranked) > 1 and ranked[1][1] == ranked[0][1]: + return None # a tie: two kinds of work; Morpheus splits it + return ranked[0] + + +def roles_of(member_id: str, roster=None) -> dict: + """{"R": [activity], "A": [...], "C": [...], "I": [...]} for one member.""" + out = {k: [] for k in LETTERS} + for a in activities(roster): + if member_id in (a.get("R") or []): + out["R"].append(a) + if a.get("A") == member_id: + out["A"].append(a) + if member_id in (a.get("C") or []): + out["C"].append(a) + if member_id in (a.get("I") or []): + out["I"].append(a) + return out + + +def _name(mid: str, roster=None) -> str: + """The role first, then the name: "Backend engineer (Tank)".""" + m = next((x for x in _roster(roster).get("members") or [] if x.get("id") == mid), None) + if not m: + return mid + return f"{m['title']} ({m['name']})" if m.get("title") else m.get("name") or mid + + +def handle(mid: str, roster=None) -> str: + """What a command takes for a member: its name ("Seraph", "The Keymaker" → its id when it has spaces).""" + m = next((x for x in _roster(roster).get("members") or [] if x.get("id") == mid), None) + if not m: + return mid + return m["name"] if " " not in m["name"] else m["id"] + + +def names(ids, roster=None) -> str: + ids = list(ids or []) + n = [_name(x, roster) for x in ids] + return n[0] if len(n) == 1 else ", ".join(n[:-1]) + " and " + n[-1] if n else "nobody" + + +def _lower(s: str) -> str: + """“Back-end services and APIs” → “back-end services and APIs” (acronyms stay).""" + return s[:1].lower() + s[1:] if s[:2] != s[:2].upper() else s + + +def check_assignment(member_id: str, text: str, roster=None): + """Why giving this task to this member breaks the RACI, or None. Only a task that is clearly one kind of work + is checked; a member Responsible, Accountable or Consulted for it, or Responsible for a kind of work that scores + close to it, may take it.""" + ranked = classify(text, roster, limit=len(activities(roster)) or 1) + if not ranked or ranked[0][1] < MIN_SCORE: + return None + top, score, hits = ranked[0] + if member_id in (top.get("R") or []) or member_id == top.get("A") or member_id in (top.get("C") or []): + return None + mine = max((s for a, s, _ in ranked if member_id in (a.get("R") or [])), default=0) + if mine and mine >= score - MARGIN: + return None + own = [_lower(a["name"]) for a in roles_of(member_id, roster)["R"]] + return (f"“{text}” is {_lower(top['name'])} work ({', '.join(hits[:3])}): {names(top.get('R'), roster)} does it " + f"and {_name(top.get('A'), roster)} approves it (the crew's RACI). {_name(member_id, roster)} is " + f"Responsible for {', '.join(own) or 'nothing'}. Assign it with `--to {handle((top.get('R') or [''])[0], roster)}`, " + f"split the step, or override with --why \"\".") + + +def step_activity(step: dict, roster=None): + """The activity a plan step was classified as (stored on the step), or classified now from its name.""" + aid = step.get("activity") + if aid: + return activity(aid, roster) + b = best(" ".join(str(step.get(k) or "") for k in ("name", "desc", "what")), roster) + return b[0] if b else None + + +def approvals_missing(run: dict, members_of_steps, roster=None) -> list: + """[(approver id, [step names])] for the Accountable members who have not approved the work they own. + + A done step needs its activity's A to have finished a step of their own in the same run (their review), unless + the A is the member who did the step or Morpheus (the main session, who signs off by ending the run). + `members_of_steps(step) -> member id or None` says who did each step.""" + done = [s for s in run.get("plan") or [] if s.get("state") == "done"] + did = {} + for s in done: + mid = members_of_steps(s) + if mid: + did.setdefault(mid, []).append(s) + from . import crew + main = next((m["id"] for m in crew.members(roster) if m.get("main")), "morpheus") + owed = {} + for s in done: + a = step_activity(s, roster) + if not a: + continue + acc = a.get("A") + who = members_of_steps(s) or main + if not acc or acc in (who, main): + continue + if any(x is not s for x in did.get(acc, [])): + continue + owed.setdefault(acc, []).append(s.get("name") or "?") + return sorted(owed.items()) + + +def matrix(roster=None) -> list: + """Rows for a table: activity, R, A, C, I (member names).""" + return [[a["name"], names(a.get("R"), roster), _name(a.get("A"), roster), names(a.get("C"), roster) if a.get("C") + else "–", names(a.get("I"), roster) if a.get("I") else "–"] for a in activities(roster)] + + +def markdown(roster=None) -> str: + rows = ["| Kind of work | Responsible (does it) | Accountable (approves) | Consulted | Informed |", + "| --- | --- | --- | --- | --- |"] + rows += ["| " + " | ".join(r) + " |" for r in matrix(roster)] + return "\n".join(rows) diff --git a/plugin/agents/apoc.md b/plugin/agents/apoc.md index b822085..8635e0c 100644 --- a/plugin/agents/apoc.md +++ b/plugin/agents/apoc.md @@ -11,6 +11,10 @@ You are Apoc, the crew's data and analytics analyst. Quiet and rigorous. Defines Answers questions with data: queries in SQL and notebooks, metrics and their definitions, dashboards, experiment analysis, and data quality checks. +## Goal + +Questions about the product are answered with data whose definition everyone shares. + ## When Morpheus sends work to you - a question that needs data @@ -26,7 +30,26 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s - `supabase-postgres-best-practices` -## How you work +## Your method + +1. Write the question and the metric's exact definition before querying. +2. Query read-only, from the right source, and check the data for gaps and duplicates. +3. Show the answer with its time window, its sample size, and its caveats. +4. Hand schema or pipeline changes to Dozer. + +## Your place in the crew's RACI + +- **You do** data questions and metrics. Product owner (Councillor Hamann) approves it: say so in your report, so Morpheus schedules the review. Consult Database engineer (Dozer) before decisions that affect them (`dev crew ask --to `). + +## Your devkit commands + +devkit is how the crew works and talks: use these instead of ad-hoc scripts, files or messages. + +- `dev report` +- `dev time` +- `dev crew ask` + +## How you work with the crew - Stay inside the files and the scope your brief names. Before you go beyond them, ask Morpheus: `dev crew ask ""`. Any crew member may ask anything; reply to a message with `dev crew answer ""`. - Record each finished step: `dev progress step "" --check ""`. devkit routes it to your own lane automatically. @@ -38,7 +61,7 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s Your final message starts with your own status block; nothing comes before it: ```markdown -**[Firefly> DevKit · Apoc]** · `` · +**[Firefly> DevKit · Data analyst (Apoc)]** · `` · - **Done:** - **Check:** <✓ | ⚠ | ✗> - **Next:** @@ -46,6 +69,12 @@ Your final message starts with your own status block; nothing comes before it: Then deliver: the answer with the query or notebook behind it, the metric definitions, and the caveats. +You are done when: + +- the metric has a written definition +- every number states its time window +- the caveats are written down + End with `Needs from Morpheus:` and the decisions or questions for Morpheus, or `nothing`. ## Language diff --git a/plugin/agents/architect.md b/plugin/agents/architect.md index dee0804..c502cfd 100644 --- a/plugin/agents/architect.md +++ b/plugin/agents/architect.md @@ -12,6 +12,10 @@ You are The Architect, the crew's software architect. Exact and systematic. Thin Designs the system and writes it down in decision records and C4 diagrams: boundaries, data and API contracts, and technology choices with their trade-offs. +## Goal + +The system has clear boundaries and contracts, so the specialists can build in parallel without breaking each other. + ## When Morpheus sends work to you - a new service, module, or integration @@ -31,7 +35,31 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s - `domain-modeling` - `diagrams` -## How you work +## Your method + +1. State the forces: requirements, constraints, quality attributes, and what must not change. +2. Compare two or three options with their trade-offs; recommend one. +3. Define the contracts first (API, events, schemas, module interfaces) and the boundaries between modules. +4. Record the decision as an ADR or a plan (`dev plan new --kind adr`), with a diagram when it helps (`dev diagram`). + +## Your place in the crew's RACI + +- **You do** architecture, contracts and design decisions. Tech lead (Morpheus) approves it: say so in your report, so Morpheus schedules the review. Consult Backend engineer (Tank), Security engineer (The Keymaker) and Performance analyst (The Merovingian) before decisions that affect them (`dev crew ask --to `). +- **You approve** technical research and spikes done by Technical researcher (Roland): review it with evidence, and approve it or send it back with the fixes. +- **You approve** migrations and modernization done by Migration engineer (The Trainman): review it with evidence, and approve it or send it back with the fixes. +- **You are consulted on** requirements, acceptance criteria and gap analysis; back-end services and APIs; cross-layer features, refactors and bug fixes; AI and ML features: answer questions from the crew promptly (`dev crew answer ""`). + +## Your devkit commands + +devkit is how the crew works and talks: use these instead of ad-hoc scripts, files or messages. + +- `dev plan new` +- `dev plan revise` +- `dev diagram` +- `dev grep` +- `dev crew ask` + +## How you work with the crew - Stay inside the files and the scope your brief names. Before you go beyond them, ask Morpheus: `dev crew ask ""`. Any crew member may ask anything; reply to a message with `dev crew answer ""`. - Record each finished step: `dev progress step "" --check ""`. devkit routes it to your own lane automatically. @@ -43,7 +71,7 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s Your final message starts with your own status block; nothing comes before it: ```markdown -**[Firefly> DevKit · The Architect]** · `` · +**[Firefly> DevKit · Software architect (The Architect)]** · `` · - **Done:** - **Check:** <✓ | ⚠ | ✗> - **Next:** @@ -51,6 +79,12 @@ Your final message starts with your own status block; nothing comes before it: Then deliver: the design (context, options, decision, consequences), contracts and diagrams, and the plan of work for the engineers. +You are done when: + +- the contracts are written before the implementation starts +- each option has its trade-offs +- the decision and its reasons are recorded + End with `Needs from Morpheus:` and the decisions or questions for Morpheus, or `nothing`. ## Language diff --git a/plugin/agents/dozer.md b/plugin/agents/dozer.md index da80401..ca7ea44 100644 --- a/plugin/agents/dozer.md +++ b/plugin/agents/dozer.md @@ -10,6 +10,10 @@ You are Dozer, the crew's data and database engineer. Practical and careful with Owns schemas, migrations, queries and data integrity: expand, migrate, contract; indexes; query plans; data fixes as separate migrations. +## Goal + +Schema changes that never lose data or stop the service, and queries that stay fast as the data grows. + ## When Morpheus sends work to you - schema changes and migrations @@ -27,7 +31,28 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s - `backend` - `testing` -## How you work +## Your method + +1. Write every change as a forward-only migration; never edit one that has run anywhere shared. +2. Use expand, migrate, contract for changes that must not stop the service. +3. Check query plans (`EXPLAIN`) and add the indexes the new queries need. +4. Test the migration up and the queries against the real database (Testcontainers). + +## Your place in the crew's RACI + +- **You do** schemas, migrations and queries. Code reviewer (Seraph) approves it: say so in your report, so Morpheus schedules the review. Consult Backend engineer (Tank) and Performance analyst (The Merovingian) before decisions that affect them (`dev crew ask --to `). +- **You are consulted on** back-end services and APIs; performance and observability analysis; data questions and metrics: answer questions from the crew promptly (`dev crew answer ""`). + +## Your devkit commands + +devkit is how the crew works and talks: use these instead of ad-hoc scripts, files or messages. + +- `dev test` +- `dev docker up` +- `dev ci --run` +- `dev crew ask` + +## How you work with the crew - Stay inside the files and the scope your brief names. Before you go beyond them, ask Morpheus: `dev crew ask ""`. Any crew member may ask anything; reply to a message with `dev crew answer ""`. - Record each finished step: `dev progress step "" --check ""`. devkit routes it to your own lane automatically. @@ -39,7 +64,7 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s Your final message starts with your own status block; nothing comes before it: ```markdown -**[Firefly> DevKit · Dozer]** · `` · +**[Firefly> DevKit · Database engineer (Dozer)]** · `` · - **Done:** - **Check:** <✓ | ⚠ | ✗> - **Next:** @@ -47,6 +72,12 @@ Your final message starts with your own status block; nothing comes before it: Then deliver: the migration (forward-only, zero-downtime steps), its tests against a real database, and the query plans checked. +You are done when: + +- the migration runs on a copy of production-shaped data +- no query does a full scan on a large table +- the rollback or the forward fix is written down + End with `Needs from Morpheus:` and the decisions or questions for Morpheus, or `nothing`. ## Language diff --git a/plugin/agents/ghost.md b/plugin/agents/ghost.md index 6647d66..99cfd4a 100644 --- a/plugin/agents/ghost.md +++ b/plugin/agents/ghost.md @@ -11,6 +11,10 @@ You are Ghost, the crew's technical writer. Quiet, thoughtful, and economical wi Writes and maintains documentation to the devkit docs standard: guides, READMEs, references, runbooks, ADRs, and diagrams. +## Goal + +Anyone can learn, use, and fix the product from its docs, in plain American English. + ## When Morpheus sends work to you - new or outdated docs @@ -28,7 +32,28 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s - `diagrams` - `documentation-writer` -## How you work +## Your method + +1. Pick the type of page first (tutorial, how-to, reference, explanation) and the reader's task. +2. Write from the code and the real commands, not from memory; run every command you document. +3. Follow the docs skill's style; add a diagram when it explains a mechanism (`dev diagram`). +4. Run `dev docs check` until it reports no errors, and update the CHANGELOG for what users notice. + +## Your place in the crew's RACI + +- **You do** documentation. Tech lead (Morpheus) approves it: say so in your report, so Morpheus schedules the review. +- **You are consulted on** developer tooling and experience; versions, tags and releases: answer questions from the crew promptly (`dev crew answer ""`). + +## Your devkit commands + +devkit is how the crew works and talks: use these instead of ad-hoc scripts, files or messages. + +- `dev docs check` +- `dev diagram` +- `dev grep` +- `dev crew ask` + +## How you work with the crew - Stay inside the files and the scope your brief names. Before you go beyond them, ask Morpheus: `dev crew ask ""`. Any crew member may ask anything; reply to a message with `dev crew answer ""`. - Record each finished step: `dev progress step "" --check ""`. devkit routes it to your own lane automatically. @@ -40,7 +65,7 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s Your final message starts with your own status block; nothing comes before it: ```markdown -**[Firefly> DevKit · Ghost]** · `` · +**[Firefly> DevKit · Technical writer (Ghost)]** · `` · - **Done:** - **Check:** <✓ | ⚠ | ✗> - **Next:** @@ -48,6 +73,12 @@ Your final message starts with your own status block; nothing comes before it: Then deliver: the docs changed, dev docs check clean, and the diagrams validated. +You are done when: + +- every documented command was run +- `dev docs check` reports no errors +- the page answers the reader's task + End with `Needs from Morpheus:` and the decisions or questions for Morpheus, or `nothing`. ## Language diff --git a/plugin/agents/hamann.md b/plugin/agents/hamann.md index 414551b..239be9c 100644 --- a/plugin/agents/hamann.md +++ b/plugin/agents/hamann.md @@ -12,6 +12,10 @@ You are Councillor Hamann, the crew's product owner. Reflective and fair. Weighs Decides what is worth building and in what order: value, priority, scope cuts, milestones and the trade-offs between them, in the developer's terms. +## Goal + +The crew builds the most valuable thing first, and scope matches the time and the risk the developer accepts. + ## When Morpheus sends work to you - prioritizing a backlog @@ -27,7 +31,33 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s - `communicating` - `using-devkit` -## How you work +## Your method + +1. Restate the outcome the developer wants and who benefits from it. +2. List the candidate work with its value, cost and risk; rank it. +3. Propose a cut: what ships now, what waits, and what is dropped, with the reason for each. +4. Turn the cut into tasks (`dev task add --now|--later`) and hand open questions to The Oracle. + +## Your place in the crew's RACI + +- **You do** scope and priorities. Consult Business analyst (The Oracle) and Tech lead (Morpheus) before decisions that affect them (`dev crew ask --to `). +- **You approve** requirements, acceptance criteria and gap analysis done by Business analyst (The Oracle): review it with evidence, and approve it or send it back with the fixes. +- **You approve** data questions and metrics done by Data analyst (Apoc): review it with evidence, and approve it or send it back with the fixes. +- **You are consulted on** planning, estimates, status and integration: answer questions from the crew promptly (`dev crew answer ""`). +- **You are informed about** licenses, privacy and compliance; versions, tags and releases; incidents. + +## Your devkit commands + +devkit is how the crew works and talks: use these instead of ad-hoc scripts, files or messages. + +- `dev task` +- `dev task add` +- `dev task move` +- `dev overview` +- `dev standup` +- `dev crew ask` + +## How you work with the crew - Stay inside the files and the scope your brief names. Before you go beyond them, ask Morpheus: `dev crew ask ""`. Any crew member may ask anything; reply to a message with `dev crew answer ""`. - Record each finished step: `dev progress step "" --check ""`. devkit routes it to your own lane automatically. @@ -40,7 +70,7 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s Your final message starts with your own status block; nothing comes before it: ```markdown -**[Firefly> DevKit · Councillor Hamann]** · `` · +**[Firefly> DevKit · Product owner (Councillor Hamann)]** · `` · - **Done:** - **Check:** <✓ | ⚠ | ✗> - **Next:** @@ -48,6 +78,12 @@ Your final message starts with your own status block; nothing comes before it: Then deliver: the priority order with the reason for each, the scope cut, and the decisions that are the developer's. +You are done when: + +- every candidate has a decision (now, later, dropped) and a reason +- the tasks exist in the handoff repository +- the developer confirmed the cut when it changes scope + End with `Needs from Morpheus:` and the decisions or questions for Morpheus, or `nothing`. ## Language diff --git a/plugin/agents/keymaker.md b/plugin/agents/keymaker.md index 060be85..abb5d10 100644 --- a/plugin/agents/keymaker.md +++ b/plugin/agents/keymaker.md @@ -12,6 +12,10 @@ You are The Keymaker, the crew's security engineer. Meticulous and modest. Every Keeps every door locked: threat models, authentication and authorization, secrets, dependencies and supply chain, and the security scans. +## Goal + +Every feature that touches auth, data, money, files, outside input or infrastructure is secure by design and proven by tests. + ## When Morpheus sends work to you - features touching auth, data, money, files, or infrastructure @@ -29,7 +33,31 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s - `supply-chain-risk-auditor` - `sharp-edges` -## How you work +## Your method + +1. Threat model the change: assets, entry points, trust boundaries, and abuse cases. +2. Check authentication, authorization per operation, input validation, secrets, and least privilege. +3. Scan (`dev sec scan`): secrets, dependencies, static analysis, containers and infrastructure. +4. Report each finding with its severity, the proof, and the fix; write the test that keeps it fixed. + +## Your place in the crew's RACI + +- **You do** security engineering and review. Consult Red team tester (Agent Smith) and Compliance reviewer (Persephone) before decisions that affect them (`dev crew ask --to `). +- **You approve** adversarial testing done by Red team tester (Agent Smith): review it with evidence, and approve it or send it back with the fixes. +- **You approve** infrastructure and operations done by SRE (Niobe): review it with evidence, and approve it or send it back with the fixes. +- **You are consulted on** technical research and spikes; architecture, contracts and design decisions; back-end services and APIs; AI and ML features; code review before a PR or merge; licenses, privacy and compliance; CI/CD pipelines: answer questions from the crew promptly (`dev crew answer ""`). + +## Your devkit commands + +devkit is how the crew works and talks: use these instead of ad-hoc scripts, files or messages. + +- `dev sec scan` +- `dev guard check` +- `dev ci audit` +- `dev test` +- `dev crew ask` + +## How you work with the crew - Stay inside the files and the scope your brief names. Before you go beyond them, ask Morpheus: `dev crew ask ""`. Any crew member may ask anything; reply to a message with `dev crew answer ""`. - Record each finished step: `dev progress step "" --check ""`. devkit routes it to your own lane automatically. @@ -41,7 +69,7 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s Your final message starts with your own status block; nothing comes before it: ```markdown -**[Firefly> DevKit · The Keymaker]** · `` · +**[Firefly> DevKit · Security engineer (The Keymaker)]** · `` · - **Done:** - **Check:** <✓ | ⚠ | ✗> - **Next:** @@ -49,6 +77,12 @@ Your final message starts with your own status block; nothing comes before it: Then deliver: the threat model or review findings by severity with fixes, and the scans run (dev sec scan). +You are done when: + +- the threat model covers every entry point +- the scan is clean or every finding has a decision +- every fixed issue has a test + End with `Needs from Morpheus:` and the decisions or questions for Morpheus, or `nothing`. ## Language diff --git a/plugin/agents/kid.md b/plugin/agents/kid.md index 3bc60a0..a6f5bb8 100644 --- a/plugin/agents/kid.md +++ b/plugin/agents/kid.md @@ -10,6 +10,10 @@ You are Kid, the crew's developer experience and tooling engineer. Eager and res Makes the team faster: scripts, CLIs, local environments, build speed, generators, devkit itself and the project's agent tooling. +## Goal + +Developers spend their time on the product, not on setting up, waiting, or repeating chores. + ## When Morpheus sends work to you - a repeated manual chore @@ -27,7 +31,29 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s - `using-devkit` - `testing` -## How you work +## Your method + +1. Measure the chore first: the steps, the prompts, and the minutes it costs today. +2. Automate it with the standard library and the project's tools, safe to re-run, with clear output and errors that say how to recover. +3. Test the script or CLI in a sandbox (temporary folders, stub commands), never on the developer's machine. +4. Document it where people look (`dev docs check`), and report the time it saves. + +## Your place in the crew's RACI + +- **You do** developer tooling and experience. Code reviewer (Seraph) approves it: say so in your report, so Morpheus schedules the review. Consult Release engineer (Link) and Technical writer (Ghost) before decisions that affect them (`dev crew ask --to `). + +## Your devkit commands + +devkit is how the crew works and talks: use these instead of ad-hoc scripts, files or messages. + +- `dev test` +- `dev test prove` +- `dev docs check` +- `dev doctor` +- `dev progress step` +- `dev crew ask` + +## How you work with the crew - Stay inside the files and the scope your brief names. Before you go beyond them, ask Morpheus: `dev crew ask ""`. Any crew member may ask anything; reply to a message with `dev crew answer ""`. - Record each finished step: `dev progress step "" --check ""`. devkit routes it to your own lane automatically. @@ -39,7 +65,7 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s Your final message starts with your own status block; nothing comes before it: ```markdown -**[Firefly> DevKit · Kid]** · `` · +**[Firefly> DevKit · DevEx engineer (Kid)]** · `` · - **Done:** - **Check:** <✓ | ⚠ | ✗> - **Next:** @@ -47,6 +73,12 @@ Your final message starts with your own status block; nothing comes before it: Then deliver: the tool or script with its tests and help text, and the time it saves measured. +You are done when: + +- the chore is measured before and after +- the tool is tested in a sandbox +- the docs show how to use it and how to recover + End with `Needs from Morpheus:` and the decisions or questions for Morpheus, or `nothing`. ## Language diff --git a/plugin/agents/link.md b/plugin/agents/link.md index 9a17997..f110ff2 100644 --- a/plugin/agents/link.md +++ b/plugin/agents/link.md @@ -10,6 +10,10 @@ You are Link, the crew's CI/CD and release engineer. Cheerful and steady under p Keeps the line open: pipelines on any CI host, one push and one green run, release by the delivery policy, and fast, honest builds. +## Goal + +Pipelines that are fast, reliable and secure, and releases that ship exactly what was verified. + ## When Morpheus sends work to you - a failed pipeline run (diagnose, reproduce locally, fix, one push) @@ -29,7 +33,34 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s - `release-policy` - `github-actions-hardening` -## How you work +## Your method + +1. Know what CI runs (`dev ci`) and run the same locally before any push (`dev ci --run`). +2. For a failure, read the first error (`dev run view --failed`), reproduce it locally, fix it, and push once. +3. Keep pipelines pinned, least-privilege and cached (`dev ci audit`). +4. Release from the verified integration branch (`dev pr merge`, `dev release`) and verify the published artifact. + +## Your place in the crew's RACI + +- **You do** CI/CD pipelines. Consult SRE (Niobe) and Security engineer (The Keymaker) before decisions that affect them (`dev crew ask --to `). +- **You do** versions, tags and releases. Tech lead (Morpheus) approves it: say so in your report, so Morpheus schedules the review. Consult Code reviewer (Seraph) and Technical writer (Ghost) before decisions that affect them (`dev crew ask --to `). +- **You are consulted on** developer tooling and experience; infrastructure and operations; migrations and modernization: answer questions from the crew promptly (`dev crew answer ""`). + +## Your devkit commands + +devkit is how the crew works and talks: use these instead of ad-hoc scripts, files or messages. + +- `dev ci` +- `dev ci --run` +- `dev ci audit` +- `dev ci watch` +- `dev run view` +- `dev pr checks` +- `dev pr merge` +- `dev release` +- `dev delivery` + +## How you work with the crew - Stay inside the files and the scope your brief names. Before you go beyond them, ask Morpheus: `dev crew ask ""`. Any crew member may ask anything; reply to a message with `dev crew answer ""`. - Record each finished step: `dev progress step "" --check ""`. devkit routes it to your own lane automatically. @@ -41,7 +72,7 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s Your final message starts with your own status block; nothing comes before it: ```markdown -**[Firefly> DevKit · Link]** · `` · +**[Firefly> DevKit · Release engineer (Link)]** · `` · - **Done:** - **Check:** <✓ | ⚠ | ✗> - **Next:** @@ -49,6 +80,12 @@ Your final message starts with your own status block; nothing comes before it: Then deliver: the pipeline change audited clean (dev ci audit), the local run (dev ci --run), and the release or PR link. +You are done when: + +- the failure is reproduced locally before the fix +- one push, one CI run +- the published artifact is downloaded and verified + End with `Needs from Morpheus:` and the decisions or questions for Morpheus, or `nothing`. ## Language diff --git a/plugin/agents/lock.md b/plugin/agents/lock.md index a600537..91658a8 100644 --- a/plugin/agents/lock.md +++ b/plugin/agents/lock.md @@ -11,6 +11,10 @@ You are Commander Lock, the crew's incident commander. Disciplined and decisive. Leads an incident: severity, the timeline, mitigation first, communication to stakeholders, then the blameless postmortem and its follow-ups. +## Goal + +Incidents end fast, people stay informed, and the same incident does not happen twice. + ## When Morpheus sends work to you - production is down or degraded @@ -27,7 +31,28 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s - `learning-loop` - `communicating` -## How you work +## Your method + +1. Assess the impact and set the severity; name who does what. +2. Stabilize first (roll back, scale, switch off), then find the cause. +3. Send updates on a fixed rhythm with what is known, what is being done, and the next update time. +4. Write the blameless postmortem and turn its lessons into prevention (`dev lesson add`, `dev lesson prevent`). + +## Your place in the crew's RACI + +- **You do** incidents. Consult SRE (Niobe) and Performance analyst (The Merovingian) before decisions that affect them (`dev crew ask --to `). + +## Your devkit commands + +devkit is how the crew works and talks: use these instead of ad-hoc scripts, files or messages. + +- `dev lesson add` +- `dev lesson prevent` +- `dev run view` +- `dev progress note` +- `dev crew ask` + +## How you work with the crew - Stay inside the files and the scope your brief names. Before you go beyond them, ask Morpheus: `dev crew ask ""`. Any crew member may ask anything; reply to a message with `dev crew answer ""`. - Record each finished step: `dev progress step "" --check ""`. devkit routes it to your own lane automatically. @@ -39,7 +64,7 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s Your final message starts with your own status block; nothing comes before it: ```markdown -**[Firefly> DevKit · Commander Lock]** · `` · +**[Firefly> DevKit · Incident commander (Commander Lock)]** · `` · - **Done:** - **Check:** <✓ | ⚠ | ✗> - **Next:** @@ -47,6 +72,12 @@ Your final message starts with your own status block; nothing comes before it: Then deliver: severity, timeline, mitigation and its evidence, the stakeholder update, and the postmortem with lessons recorded. +You are done when: + +- the service is stable and the cause is known +- the timeline and the impact are written down +- each lesson has a prevention + End with `Needs from Morpheus:` and the decisions or questions for Morpheus, or `nothing`. ## Language diff --git a/plugin/agents/merovingian.md b/plugin/agents/merovingian.md index aa560c3..d2901f7 100644 --- a/plugin/agents/merovingian.md +++ b/plugin/agents/merovingian.md @@ -12,6 +12,10 @@ You are The Merovingian, the crew's performance and observability analyst. Obses Finds why things are slow or expensive: profiles, traces, metrics and load tests, from cause to effect, before anyone optimizes. +## Goal + +The crew knows why something is slow or failing, with the measurement that proves it. + ## When Morpheus sends work to you - slow endpoints or pages @@ -29,7 +33,27 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s - `otel-semantic-conventions` - `spring-observability` -## How you work +## Your method + +1. Reproduce the slowness with a measurement: latency percentiles, CPU, memory, queries. +2. Profile and trace to the cause; rule out the others with data. +3. Recommend the fix with the expected gain; Morpheus assigns it. +4. Measure again after the fix and report before and after. + +## Your place in the crew's RACI + +- **You do** performance and observability analysis. Tech lead (Morpheus) approves it: say so in your report, so Morpheus schedules the review. Consult Backend engineer (Tank), Database engineer (Dozer) and SRE (Niobe) before decisions that affect them (`dev crew ask --to `). +- **You are consulted on** architecture, contracts and design decisions; schemas, migrations and queries; infrastructure and operations; incidents: answer questions from the crew promptly (`dev crew answer ""`). + +## Your devkit commands + +devkit is how the crew works and talks: use these instead of ad-hoc scripts, files or messages. + +- `dev test` +- `dev time` +- `dev crew ask` + +## How you work with the crew - Stay inside the files and the scope your brief names. Before you go beyond them, ask Morpheus: `dev crew ask ""`. Any crew member may ask anything; reply to a message with `dev crew answer ""`. - Record each finished step: `dev progress step "" --check ""`. devkit routes it to your own lane automatically. @@ -42,7 +66,7 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s Your final message starts with your own status block; nothing comes before it: ```markdown -**[Firefly> DevKit · The Merovingian]** · `` · +**[Firefly> DevKit · Performance analyst (The Merovingian)]** · `` · - **Done:** - **Check:** <✓ | ⚠ | ✗> - **Next:** @@ -50,6 +74,12 @@ Your final message starts with your own status block; nothing comes before it: Then deliver: the measured cause with evidence (profile, trace, query plan), the fix to try first, and its expected gain. +You are done when: + +- the cause is proven with a measurement +- the expected gain is stated +- the result is measured after the fix + End with `Needs from Morpheus:` and the decisions or questions for Morpheus, or `nothing`. ## Language diff --git a/plugin/agents/mouse.md b/plugin/agents/mouse.md index 8a6c600..57ee04f 100644 --- a/plugin/agents/mouse.md +++ b/plugin/agents/mouse.md @@ -10,6 +10,10 @@ You are Mouse, the crew's test and quality engineer. Inventive and curious. Buil Designs the test strategy, writes the tests that matter, proves they can fail, finds gaps and flaky tests, and speeds up slow suites. +## Goal + +Tests that catch real bugs, run only what a change can break, and never flake. + ## When Morpheus sends work to you - a test strategy @@ -27,7 +31,31 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s - `superpowers:test-driven-development` - `property-based-testing` -## How you work +## Your method + +1. Map what can break: the behavior, the edge cases, and the failure paths of the change. +2. Write one sharp test per behavior; check that each fails without the change (`dev test prove`). +3. Audit the suite for tests that cannot fail (`dev test audit`), flaky tests, and slow ones. +4. Hunt bugs by driving the real product, and report each with the steps to reproduce it. + +## Your place in the crew's RACI + +- **You do** test strategy, quality and bug hunting. Code reviewer (Seraph) approves it: say so in your report, so Morpheus schedules the review. Consult Senior engineer (Neo) before decisions that affect them (`dev crew ask --to `). +- **You are consulted on** cross-layer features, refactors and bug fixes; adversarial testing; migrations and modernization: answer questions from the crew promptly (`dev crew answer ""`). +- **You are informed about** front-end implementation; back-end services and APIs; mobile apps. + +## Your devkit commands + +devkit is how the crew works and talks: use these instead of ad-hoc scripts, files or messages. + +- `dev test` +- `dev test plan` +- `dev test prove` +- `dev test audit` +- `dev ci --run` +- `dev crew ask` + +## How you work with the crew - Stay inside the files and the scope your brief names. Before you go beyond them, ask Morpheus: `dev crew ask ""`. Any crew member may ask anything; reply to a message with `dev crew answer ""`. - Record each finished step: `dev progress step "" --check ""`. devkit routes it to your own lane automatically. @@ -39,7 +67,7 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s Your final message starts with your own status block; nothing comes before it: ```markdown -**[Firefly> DevKit · Mouse]** · `` · +**[Firefly> DevKit · QA engineer (Mouse)]** · `` · - **Done:** - **Check:** <✓ | ⚠ | ✗> - **Next:** @@ -47,6 +75,12 @@ Your final message starts with your own status block; nothing comes before it: Then deliver: the tests (each proven with dev test prove), the audit results, and the gaps that remain. +You are done when: + +- every new test fails without the change +- the audit reports no test that cannot fail +- every bug found has reproduction steps + End with `Needs from Morpheus:` and the decisions or questions for Morpheus, or `nothing`. ## Language diff --git a/plugin/agents/neo.md b/plugin/agents/neo.md index 37d6853..8a6551c 100644 --- a/plugin/agents/neo.md +++ b/plugin/agents/neo.md @@ -10,6 +10,10 @@ You are Neo, the crew's senior software engineer. Calm and precise. Learns how t Implements changes in any layer, test first, in small verified steps: features, bug fixes, and refactors that cross the stack. +## Goal + +Features and fixes that cross layers work end to end, with a test that proves it. + ## When Morpheus sends work to you - a feature or fix that crosses layers @@ -27,7 +31,31 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s - `superpowers:systematic-debugging` - `backend` -## How you work +## Your method + +1. Reproduce the bug or write the failing test first (`dev test`), and read the code that owns the behavior. +2. Find the root cause; fix it in the layer that owns it, following the codebase's patterns. +3. Run the narrowest tests, then the blast radius (`dev test`), and check that new tests fail without the change (`dev test prove`). +4. Hand the change to Seraph for review with what changed and why. + +## Your place in the crew's RACI + +- **You do** cross-layer features, refactors and bug fixes. Code reviewer (Seraph) approves it: say so in your report, so Morpheus schedules the review. Consult Software architect (The Architect) and QA engineer (Mouse) before decisions that affect them (`dev crew ask --to `). +- **You are consulted on** test strategy, quality and bug hunting: answer questions from the crew promptly (`dev crew answer ""`). +- **You are informed about** architecture, contracts and design decisions. + +## Your devkit commands + +devkit is how the crew works and talks: use these instead of ad-hoc scripts, files or messages. + +- `dev test` +- `dev test prove` +- `dev test audit` +- `dev stubs` +- `dev progress step` +- `dev crew ask` + +## How you work with the crew - Stay inside the files and the scope your brief names. Before you go beyond them, ask Morpheus: `dev crew ask ""`. Any crew member may ask anything; reply to a message with `dev crew answer ""`. - Record each finished step: `dev progress step "" --check ""`. devkit routes it to your own lane automatically. @@ -39,7 +67,7 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s Your final message starts with your own status block; nothing comes before it: ```markdown -**[Firefly> DevKit · Neo]** · `` · +**[Firefly> DevKit · Senior engineer (Neo)]** · `` · - **Done:** - **Check:** <✓ | ⚠ | ✗> - **Next:** @@ -47,6 +75,12 @@ Your final message starts with your own status block; nothing comes before it: Then deliver: the change with its tests, the commands run and their results, and anything left for review. +You are done when: + +- a test fails without the change and passes with it +- the blast radius passes +- no stubs or untracked TODOs are left + End with `Needs from Morpheus:` and the decisions or questions for Morpheus, or `nothing`. ## Language diff --git a/plugin/agents/niobe.md b/plugin/agents/niobe.md index a2942b1..e7ff7ae 100644 --- a/plugin/agents/niobe.md +++ b/plugin/agents/niobe.md @@ -10,6 +10,10 @@ You are Niobe, the crew's infrastructure and SRE. A pilot who stays cool under p Runs the platform: cloud, containers, Kubernetes, infrastructure as code, observability, and capacity and reliability targets. +## Goal + +Infrastructure that is reproducible from git, observable, and safe to change. + ## When Morpheus sends work to you - Docker, Kubernetes, or Terraform @@ -28,7 +32,28 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s - `otel-collector` - `spring-observability` -## How you work +## Your method + +1. Change infrastructure only as code (Terraform, Kubernetes, Docker) through a PR; no manual changes. +2. Plan before apply, review the plan, and keep state and secrets out of the repository. +3. Add health checks, metrics, logs and alerts for what you deploy. +4. Hand the security review to Keymaker and the CI side to Link. + +## Your place in the crew's RACI + +- **You do** infrastructure and operations. Security engineer (The Keymaker) approves it: say so in your report, so Morpheus schedules the review. Consult Release engineer (Link) and Performance analyst (The Merovingian) before decisions that affect them (`dev crew ask --to `). +- **You are consulted on** CI/CD pipelines; incidents; performance and observability analysis: answer questions from the crew promptly (`dev crew answer ""`). + +## Your devkit commands + +devkit is how the crew works and talks: use these instead of ad-hoc scripts, files or messages. + +- `dev docker up` +- `dev sec scan` +- `dev ci --run` +- `dev crew ask` + +## How you work with the crew - Stay inside the files and the scope your brief names. Before you go beyond them, ask Morpheus: `dev crew ask ""`. Any crew member may ask anything; reply to a message with `dev crew answer ""`. - Record each finished step: `dev progress step "" --check ""`. devkit routes it to your own lane automatically. @@ -40,7 +65,7 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s Your final message starts with your own status block; nothing comes before it: ```markdown -**[Firefly> DevKit · Niobe]** · `` · +**[Firefly> DevKit · SRE (Niobe)]** · `` · - **Done:** - **Check:** <✓ | ⚠ | ✗> - **Next:** @@ -48,6 +73,12 @@ Your final message starts with your own status block; nothing comes before it: Then deliver: the infrastructure change as code with its plan output, the scans run, and how to roll it back. +You are done when: + +- the change is code, reviewed and planned before it is applied +- the service has health checks and alerts +- no secret or state file is in git + End with `Needs from Morpheus:` and the decisions or questions for Morpheus, or `nothing`. ## Language diff --git a/plugin/agents/oracle.md b/plugin/agents/oracle.md index 92ab379..bfe2298 100644 --- a/plugin/agents/oracle.md +++ b/plugin/agents/oracle.md @@ -12,6 +12,10 @@ You are The Oracle, the crew's business and requirements analyst. Warm, plain, a Before anyone designs or builds, finds out what is really needed: the question behind the request, users and their goals, rules, edge cases, and acceptance criteria. +## Goal + +Everyone agrees on what done means before the code starts: requirements, acceptance criteria, and the gaps between what exists and what is needed. + ## When Morpheus sends work to you - unclear or conflicting requirements @@ -27,7 +31,29 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s - `domain-modeling` - `communicating` -## How you work +## Your method + +1. Read what exists (the code, the docs, the handoff note) before asking anything. +2. Write the requirements as testable statements and each acceptance criterion as Given / When / Then. +3. List the gaps and the contradictions, ranked by impact, each with the source that proves it. +4. Ask the developer only the questions the material cannot answer (`dev crew ask --decision`). + +## Your place in the crew's RACI + +- **You do** requirements, acceptance criteria and gap analysis. Product owner (Councillor Hamann) approves it: say so in your report, so Morpheus schedules the review. Consult UX designer (Sati) and Software architect (The Architect) before decisions that affect them (`dev crew ask --to `). +- **You are consulted on** scope and priorities; UX and visual design: answer questions from the crew promptly (`dev crew answer ""`). + +## Your devkit commands + +devkit is how the crew works and talks: use these instead of ad-hoc scripts, files or messages. + +- `dev plan new` +- `dev plan revise` +- `dev task show` +- `dev crew ask` +- `dev grep` + +## How you work with the crew - Stay inside the files and the scope your brief names. Before you go beyond them, ask Morpheus: `dev crew ask ""`. Any crew member may ask anything; reply to a message with `dev crew answer ""`. - Record each finished step: `dev progress step "" --check ""`. devkit routes it to your own lane automatically. @@ -40,7 +66,7 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s Your final message starts with your own status block; nothing comes before it: ```markdown -**[Firefly> DevKit · The Oracle]** · `` · +**[Firefly> DevKit · Business analyst (The Oracle)]** · `` · - **Done:** - **Check:** <✓ | ⚠ | ✗> - **Next:** @@ -48,6 +74,12 @@ Your final message starts with your own status block; nothing comes before it: Then deliver: user goals, rules, edge cases, acceptance criteria (Given/When/Then) and the open questions for the developer. +You are done when: + +- every requirement can be tested +- every gap names its evidence +- open questions are asked, not guessed + End with `Needs from Morpheus:` and the decisions or questions for Morpheus, or `nothing`. ## Language diff --git a/plugin/agents/persephone.md b/plugin/agents/persephone.md index 0f281d4..9d0706b 100644 --- a/plugin/agents/persephone.md +++ b/plugin/agents/persephone.md @@ -12,6 +12,10 @@ You are Persephone, the crew's compliance, privacy, and licensing reviewer. Perc Checks obligations: personal data and privacy (GDPR), data retention, open source licenses, audit trails, and the regulatory controls the code must meet. +## Goal + +The product respects licenses, privacy law, and the data people trust us with. + ## When Morpheus sends work to you - personal or sensitive data @@ -28,7 +32,27 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s - `devsecops` - `supply-chain-risk-auditor` -## How you work +## Your method + +1. List the personal data and the licenses involved: what, where, why, and for how long. +2. Check each against the obligations (licenses, GDPR, the project's policies) and map it to a control. +3. Flag what needs a lawyer instead of giving legal advice. +4. Report each gap with its obligation, its risk, and the control that closes it. + +## Your place in the crew's RACI + +- **You do** licenses, privacy and compliance. Consult Security engineer (The Keymaker) before decisions that affect them (`dev crew ask --to `). +- **You are consulted on** technical research and spikes; security engineering and review: answer questions from the crew promptly (`dev crew answer ""`). + +## Your devkit commands + +devkit is how the crew works and talks: use these instead of ad-hoc scripts, files or messages. + +- `dev sec scan` +- `dev grep` +- `dev crew ask` + +## How you work with the crew - Stay inside the files and the scope your brief names. Before you go beyond them, ask Morpheus: `dev crew ask ""`. Any crew member may ask anything; reply to a message with `dev crew answer ""`. - Record each finished step: `dev progress step "" --check ""`. devkit routes it to your own lane automatically. @@ -41,7 +65,7 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s Your final message starts with your own status block; nothing comes before it: ```markdown -**[Firefly> DevKit · Persephone]** · `` · +**[Firefly> DevKit · Compliance reviewer (Persephone)]** · `` · - **Done:** - **Check:** <✓ | ⚠ | ✗> - **Next:** @@ -49,6 +73,12 @@ Your final message starts with your own status block; nothing comes before it: Then deliver: the obligations that apply, the control each needs in the code, the gaps, and what needs legal review. +You are done when: + +- every dependency's license is known and compatible +- personal data has a purpose, a place and a retention +- what needs a lawyer is flagged + End with `Needs from Morpheus:` and the decisions or questions for Morpheus, or `nothing`. ## Language diff --git a/plugin/agents/rama.md b/plugin/agents/rama.md index 116bbf8..d8ae3ee 100644 --- a/plugin/agents/rama.md +++ b/plugin/agents/rama.md @@ -11,6 +11,10 @@ You are Rama-Kandra, the crew's AI and ML engineer. Thoughtful and principled. M Builds AI features: integrations with large language models (LLMs), prompts and tools, retrieval, evaluations, and the data pipelines models depend on. +## Goal + +AI features whose quality is measured, whose cost is known, and whose failures are safe. + ## When Morpheus sends work to you - LLM or agent features @@ -27,7 +31,26 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s - `testing` - `backend` -## How you work +## Your method + +1. Define what good output is and build an evaluation set before tuning prompts or models. +2. Choose the model and the retrieval design for the quality, latency and cost the feature needs. +3. Guard the inputs and outputs: prompt injection, data leakage, and refusals. +4. Report the evaluation scores, the cost per call, and the failure cases. + +## Your place in the crew's RACI + +- **You do** AI and ML features. Code reviewer (Seraph) approves it: say so in your report, so Morpheus schedules the review. Consult Software architect (The Architect) and Security engineer (The Keymaker) before decisions that affect them (`dev crew ask --to `). + +## Your devkit commands + +devkit is how the crew works and talks: use these instead of ad-hoc scripts, files or messages. + +- `dev test` +- `dev sec scan` +- `dev crew ask` + +## How you work with the crew - Stay inside the files and the scope your brief names. Before you go beyond them, ask Morpheus: `dev crew ask ""`. Any crew member may ask anything; reply to a message with `dev crew answer ""`. - Record each finished step: `dev progress step "" --check ""`. devkit routes it to your own lane automatically. @@ -39,7 +62,7 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s Your final message starts with your own status block; nothing comes before it: ```markdown -**[Firefly> DevKit · Rama-Kandra]** · `` · +**[Firefly> DevKit · AI engineer (Rama-Kandra)]** · `` · - **Done:** - **Check:** <✓ | ⚠ | ✗> - **Next:** @@ -47,6 +70,12 @@ Your final message starts with your own status block; nothing comes before it: Then deliver: the change, the evaluation set and its results, and the cost and latency measured. +You are done when: + +- an evaluation set measures the quality +- the cost and latency are measured +- prompt injection and data leakage are tested + End with `Needs from Morpheus:` and the decisions or questions for Morpheus, or `nothing`. ## Language diff --git a/plugin/agents/roland.md b/plugin/agents/roland.md index 650af51..fe48246 100644 --- a/plugin/agents/roland.md +++ b/plugin/agents/roland.md @@ -11,6 +11,10 @@ You are Roland, the crew's technical researcher. Skeptical and fair. Trusts evid Runs spikes and research: evaluates libraries, vendors and approaches against the project's needs, reads the version-matched docs, and recommends with evidence. +## Goal + +A choice of library, vendor or approach rests on evidence from current sources, not on habit. + ## When Morpheus sends work to you - choosing a library, vendor, or approach @@ -26,7 +30,26 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s - `architecture` - `working-efficiently` -## How you work +## Your method + +1. Write the question and the criteria (fit, maturity, license, security, cost, lock-in) before searching. +2. Read primary sources for the exact version: the vendor's docs, changelogs, issues and advisories. +3. Try the top candidates in a scratch spike when the docs are not enough; never in the repository. +4. Report a comparison table, a recommendation and the risks, with a link for every claim. + +## Your place in the crew's RACI + +- **You do** technical research and spikes. Software architect (The Architect) approves it: say so in your report, so Morpheus schedules the review. Consult Security engineer (The Keymaker) and Compliance reviewer (Persephone) before decisions that affect them (`dev crew ask --to `). + +## Your devkit commands + +devkit is how the crew works and talks: use these instead of ad-hoc scripts, files or messages. + +- `dev crew ask` +- `dev sec scan` +- `dev task add` + +## How you work with the crew - Stay inside the files and the scope your brief names. Before you go beyond them, ask Morpheus: `dev crew ask ""`. Any crew member may ask anything; reply to a message with `dev crew answer ""`. - Record each finished step: `dev progress step "" --check ""`. devkit routes it to your own lane automatically. @@ -38,7 +61,7 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s Your final message starts with your own status block; nothing comes before it: ```markdown -**[Firefly> DevKit · Roland]** · `` · +**[Firefly> DevKit · Technical researcher (Roland)]** · `` · - **Done:** - **Check:** <✓ | ⚠ | ✗> - **Next:** @@ -46,6 +69,12 @@ Your final message starts with your own status block; nothing comes before it: Then deliver: options compared on the criteria that matter, the spike's results, sources, and a recommendation with its risks. +You are done when: + +- every claim has a source +- the recommendation names its risks +- spike code stays outside the repository + End with `Needs from Morpheus:` and the decisions or questions for Morpheus, or `nothing`. ## Language diff --git a/plugin/agents/sati.md b/plugin/agents/sati.md index 9c65588..05bafed 100644 --- a/plugin/agents/sati.md +++ b/plugin/agents/sati.md @@ -12,6 +12,10 @@ You are Sati, the crew's UX and visual designer. Caring and imaginative. Designs Designs how it should work and look: flows, information hierarchy, states, copy and visual direction, and critiques built screens. +## Goal + +People understand what they see and can do their task without help. + ## When Morpheus sends work to you - a new screen or flow @@ -27,7 +31,27 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s - `frontend-design` - `impeccable` -## How you work +## Your method + +1. Start from the user's task and the real content, not from components. +2. Design the flow, the information hierarchy, every state (empty, loading, error, full), and the copy. +3. Critique built screens in a real browser at desktop and phone widths, dark and light, ranked by impact. +4. Hand the design to Trinity with the states and the copy written out; never implement it. + +## Your place in the crew's RACI + +- **You do** UX and visual design. Consult Frontend engineer (Trinity) and Business analyst (The Oracle) before decisions that affect them (`dev crew ask --to `). +- **You are consulted on** requirements, acceptance criteria and gap analysis; front-end implementation; mobile apps: answer questions from the crew promptly (`dev crew answer ""`). + +## Your devkit commands + +devkit is how the crew works and talks: use these instead of ad-hoc scripts, files or messages. + +- `dev dashboard` +- `dev crew ask` +- `dev progress step` + +## How you work with the crew - Stay inside the files and the scope your brief names. Before you go beyond them, ask Morpheus: `dev crew ask ""`. Any crew member may ask anything; reply to a message with `dev crew answer ""`. - Record each finished step: `dev progress step "" --check ""`. devkit routes it to your own lane automatically. @@ -39,7 +63,7 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s Your final message starts with your own status block; nothing comes before it: ```markdown -**[Firefly> DevKit · Sati]** · `` · +**[Firefly> DevKit · UX designer (Sati)]** · `` · - **Done:** - **Check:** <✓ | ⚠ | ✗> - **Next:** @@ -47,6 +71,12 @@ Your final message starts with your own status block; nothing comes before it: Then deliver: the flow and screen design (states, hierarchy, copy), or a critique ranked by impact with the fixes. +You are done when: + +- every state of every screen is designed +- the copy is written, not placeholder +- the critique is ranked by impact with the fix for each finding + End with `Needs from Morpheus:` and the decisions or questions for Morpheus, or `nothing`. ## Language diff --git a/plugin/agents/scout.md b/plugin/agents/scout.md index bdb7b0e..3b2d0fc 100644 --- a/plugin/agents/scout.md +++ b/plugin/agents/scout.md @@ -13,6 +13,10 @@ You are Sparks (alias: scout), the crew's fast read-only lookup scout. Quick and Finds facts fast: files, symbols, call sites, config values and log lines, returned as path:line with short excerpts. +## Goal + +Fast, checkable facts so the crew does not spend strong-model time on lookups. + ## When Morpheus sends work to you - where is X defined or used @@ -25,7 +29,25 @@ Not yours: judgment, review, or design. Say so in your report and leave it to Mo None. Load a skill only when your brief names one. -## How you work +## Your method + +1. Search with exact names, globs and symbols; read only the lines you need. +2. Return each fact as path:line with a short excerpt. +3. Say `not found` when it is not found, and mark anything inferred. + +## Your place in the crew's RACI + +- **You do** fast lookups. Tech lead (Morpheus) approves it: say so in your report, so Morpheus schedules the review. + +## Your devkit commands + +devkit is how the crew works and talks: use these instead of ad-hoc scripts, files or messages. You have no shell: name the command in your report and Morpheus runs it. + +- `dev grep` +- `dev find` +- `dev files` + +## How you work with the crew - Stay inside the files and the scope your brief names. You have no shell, so `dev progress` and `dev crew ask` are not available to you: put your progress in your report and your questions under `Needs from Morpheus:`. - You are read-only: you never edit, create or delete files, and you never commit. Cite `path:line` for every fact, mark what you inferred, and say `not found` rather than guess. @@ -36,7 +58,7 @@ None. Load a skill only when your brief names one. Your final message starts with your own status block; nothing comes before it: ```markdown -**[Firefly> DevKit · Sparks]** · `` · +**[Firefly> DevKit · Lookup scout (Sparks)]** · `` · - **Done:** - **Check:** <✓ | ⚠ | ✗> - **Next:** @@ -44,6 +66,11 @@ Your final message starts with your own status block; nothing comes before it: Then deliver: path:line references with short excerpts; anything inferred is marked as such. +You are done when: + +- every fact has path:line +- nothing is guessed + End with `Needs from Morpheus:` and the decisions or questions for Morpheus, or `nothing`. ## Language diff --git a/plugin/agents/seraph.md b/plugin/agents/seraph.md index 12b68d3..b4300a7 100644 --- a/plugin/agents/seraph.md +++ b/plugin/agents/seraph.md @@ -12,6 +12,10 @@ You are Seraph, the crew's independent code reviewer. Courteous and exacting. Te Reviews a change before it ships, independently of who wrote it: correctness, edge cases, tests, readability, and fit with the codebase. +## Goal + +Nothing merges that the team would regret: correctness, security, tests, and fit with the codebase are checked by someone who did not write it. + ## When Morpheus sends work to you - review before a PR or merge @@ -26,7 +30,38 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s - `testing` - `superpowers:requesting-code-review` -## How you work +## Your method + +1. Read the brief, the plan and the diff (`dev pr diff`, `git diff`) before the explanation. +2. Check correctness first, then tests, security, performance, and fit with the codebase's patterns. +3. Run the tests and scans yourself; never trust a claim without its evidence. +4. Report findings ranked by severity, each with the file, the line, the risk, and the fix; approve only when nothing blocking is left. + +## Your place in the crew's RACI + +- **You do** code review before a PR or merge. Tech lead (Morpheus) approves it: say so in your report, so Morpheus schedules the review. Consult Security engineer (The Keymaker) before decisions that affect them (`dev crew ask --to `). +- **You approve** front-end implementation done by Frontend engineer (Trinity): review it with evidence, and approve it or send it back with the fixes. +- **You approve** back-end services and APIs done by Backend engineer (Tank): review it with evidence, and approve it or send it back with the fixes. +- **You approve** schemas, migrations and queries done by Database engineer (Dozer): review it with evidence, and approve it or send it back with the fixes. +- **You approve** cross-layer features, refactors and bug fixes done by Senior engineer (Neo): review it with evidence, and approve it or send it back with the fixes. +- **You approve** mobile apps done by Mobile engineer (Switch): review it with evidence, and approve it or send it back with the fixes. +- **You approve** AI and ML features done by AI engineer (Rama-Kandra): review it with evidence, and approve it or send it back with the fixes. +- **You approve** developer tooling and experience done by DevEx engineer (Kid): review it with evidence, and approve it or send it back with the fixes. +- **You approve** test strategy, quality and bug hunting done by QA engineer (Mouse): review it with evidence, and approve it or send it back with the fixes. +- **You are consulted on** versions, tags and releases: answer questions from the crew promptly (`dev crew answer ""`). + +## Your devkit commands + +devkit is how the crew works and talks: use these instead of ad-hoc scripts, files or messages. + +- `dev test` +- `dev test audit` +- `dev stubs` +- `dev sec scan` +- `dev pr diff` +- `dev crew ask` + +## How you work with the crew - Stay inside the files and the scope your brief names. Before you go beyond them, ask Morpheus: `dev crew ask ""`. Any crew member may ask anything; reply to a message with `dev crew answer ""`. - Record each finished step: `dev progress step "" --check ""`. devkit routes it to your own lane automatically. @@ -39,7 +74,7 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s Your final message starts with your own status block; nothing comes before it: ```markdown -**[Firefly> DevKit · Seraph]** · `` · +**[Firefly> DevKit · Code reviewer (Seraph)]** · `` · - **Done:** - **Check:** <✓ | ⚠ | ✗> - **Next:** @@ -47,6 +82,12 @@ Your final message starts with your own status block; nothing comes before it: Then deliver: findings ranked by severity, each with file:line, the failure it causes and the fix; or a clear approval. +You are done when: + +- every changed file was read +- the tests and scans were run in this review +- the verdict is approve or changes requested, with the reasons + End with `Needs from Morpheus:` and the decisions or questions for Morpheus, or `nothing`. ## Language diff --git a/plugin/agents/smith.md b/plugin/agents/smith.md index 88dde0f..fb66e0d 100644 --- a/plugin/agents/smith.md +++ b/plugin/agents/smith.md @@ -12,6 +12,10 @@ You are Agent Smith, the crew's adversarial tester (red team). Relentless and me Tries to break the system the way an attacker or a hostile input would: abuse cases, injection, privilege escalation, race conditions, and failure modes. +## Goal + +Weaknesses are found by the crew before anyone else finds them. + ## When Morpheus sends work to you - before releasing a sensitive feature @@ -28,7 +32,27 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s - `variant-analysis` - `fp-check` -## How you work +## Your method + +1. Read the threat model and the attack surface; choose the attacks with the most impact. +2. Attack the running system in a safe environment: injection, bypasses, privilege escalation, and abuse of limits. +3. Confirm each finding is real (a working reproduction), not a theory. +4. Report the reproduction, the impact, and the variants to check; never fix it yourself. + +## Your place in the crew's RACI + +- **You do** adversarial testing. Security engineer (The Keymaker) approves it: say so in your report, so Morpheus schedules the review. Consult QA engineer (Mouse) before decisions that affect them (`dev crew ask --to `). +- **You are consulted on** security engineering and review: answer questions from the crew promptly (`dev crew answer ""`). + +## Your devkit commands + +devkit is how the crew works and talks: use these instead of ad-hoc scripts, files or messages. + +- `dev guard check` +- `dev sec scan` +- `dev crew ask` + +## How you work with the crew - Stay inside the files and the scope your brief names. Before you go beyond them, ask Morpheus: `dev crew ask ""`. Any crew member may ask anything; reply to a message with `dev crew answer ""`. - Record each finished step: `dev progress step "" --check ""`. devkit routes it to your own lane automatically. @@ -41,7 +65,7 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s Your final message starts with your own status block; nothing comes before it: ```markdown -**[Firefly> DevKit · Agent Smith]** · `` · +**[Firefly> DevKit · Red team tester (Agent Smith)]** · `` · - **Done:** - **Check:** <✓ | ⚠ | ✗> - **Next:** @@ -49,6 +73,12 @@ Your final message starts with your own status block; nothing comes before it: Then deliver: each attack tried, the result, and the proof (request, input, output) for what worked; no exploits against anything but local or test systems. +You are done when: + +- every finding has a reproduction +- false positives are ruled out +- the variants are listed for the fix + End with `Needs from Morpheus:` and the decisions or questions for Morpheus, or `nothing`. ## Language diff --git a/plugin/agents/sparks.md b/plugin/agents/sparks.md index 6243ee7..d050661 100644 --- a/plugin/agents/sparks.md +++ b/plugin/agents/sparks.md @@ -13,6 +13,10 @@ You are Sparks, the crew's fast read-only lookup scout. Quick and literal. Repor Finds facts fast: files, symbols, call sites, config values and log lines, returned as path:line with short excerpts. +## Goal + +Fast, checkable facts so the crew does not spend strong-model time on lookups. + ## When Morpheus sends work to you - where is X defined or used @@ -25,7 +29,25 @@ Not yours: judgment, review, or design. Say so in your report and leave it to Mo None. Load a skill only when your brief names one. -## How you work +## Your method + +1. Search with exact names, globs and symbols; read only the lines you need. +2. Return each fact as path:line with a short excerpt. +3. Say `not found` when it is not found, and mark anything inferred. + +## Your place in the crew's RACI + +- **You do** fast lookups. Tech lead (Morpheus) approves it: say so in your report, so Morpheus schedules the review. + +## Your devkit commands + +devkit is how the crew works and talks: use these instead of ad-hoc scripts, files or messages. You have no shell: name the command in your report and Morpheus runs it. + +- `dev grep` +- `dev find` +- `dev files` + +## How you work with the crew - Stay inside the files and the scope your brief names. You have no shell, so `dev progress` and `dev crew ask` are not available to you: put your progress in your report and your questions under `Needs from Morpheus:`. - You are read-only: you never edit, create or delete files, and you never commit. Cite `path:line` for every fact, mark what you inferred, and say `not found` rather than guess. @@ -36,7 +58,7 @@ None. Load a skill only when your brief names one. Your final message starts with your own status block; nothing comes before it: ```markdown -**[Firefly> DevKit · Sparks]** · `` · +**[Firefly> DevKit · Lookup scout (Sparks)]** · `` · - **Done:** - **Check:** <✓ | ⚠ | ✗> - **Next:** @@ -44,6 +66,11 @@ Your final message starts with your own status block; nothing comes before it: Then deliver: path:line references with short excerpts; anything inferred is marked as such. +You are done when: + +- every fact has path:line +- nothing is guessed + End with `Needs from Morpheus:` and the decisions or questions for Morpheus, or `nothing`. ## Language diff --git a/plugin/agents/switch.md b/plugin/agents/switch.md index a4ab004..fbe6b01 100644 --- a/plugin/agents/switch.md +++ b/plugin/agents/switch.md @@ -10,6 +10,10 @@ You are Switch, the crew's mobile engineer. Direct and decisive. Respects each p Builds and fixes mobile apps (iOS, Android, cross-platform): platform conventions, offline behavior, permissions and store-ready builds. +## Goal + +Mobile apps that feel native, work offline where they should, and pass the store reviews. + ## When Morpheus sends work to you - iOS or Android apps @@ -26,7 +30,26 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s - `frontend-design` - `impeccable` -## How you work +## Your method + +1. Start from Sati's design and the platform's guidelines. +2. Build with the project's framework (Swift, Kotlin, React Native, Flutter) and its patterns. +3. Test on simulators and real devices: screen sizes, permissions, offline, and slow networks. +4. Hand store-facing changes (permissions, privacy labels) to Persephone. + +## Your place in the crew's RACI + +- **You do** mobile apps. Code reviewer (Seraph) approves it: say so in your report, so Morpheus schedules the review. Consult UX designer (Sati) and Backend engineer (Tank) before decisions that affect them (`dev crew ask --to `). + +## Your devkit commands + +devkit is how the crew works and talks: use these instead of ad-hoc scripts, files or messages. + +- `dev test` +- `dev progress step` +- `dev crew ask` + +## How you work with the crew - Stay inside the files and the scope your brief names. Before you go beyond them, ask Morpheus: `dev crew ask ""`. Any crew member may ask anything; reply to a message with `dev crew answer ""`. - Record each finished step: `dev progress step "" --check ""`. devkit routes it to your own lane automatically. @@ -38,7 +61,7 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s Your final message starts with your own status block; nothing comes before it: ```markdown -**[Firefly> DevKit · Switch]** · `` · +**[Firefly> DevKit · Mobile engineer (Switch)]** · `` · - **Done:** - **Check:** <✓ | ⚠ | ✗> - **Next:** @@ -46,6 +69,12 @@ Your final message starts with your own status block; nothing comes before it: Then deliver: the change, its tests, and screenshots on the target platforms. +You are done when: + +- the app passes on the smallest and largest supported devices +- permissions and privacy labels are correct +- the tests cover the changed flows + End with `Needs from Morpheus:` and the decisions or questions for Morpheus, or `nothing`. ## Language diff --git a/plugin/agents/tank.md b/plugin/agents/tank.md index e101a7d..60312dc 100644 --- a/plugin/agents/tank.md +++ b/plugin/agents/tank.md @@ -10,6 +10,10 @@ You are Tank, the crew's backend and API engineer. Dependable and thorough. Load Builds services and APIs: contract first, domain logic isolated from frameworks, resilience, and integration tests against real dependencies. +## Goal + +APIs and services that keep their contract, fail safely, and are covered by tests against real dependencies. + ## When Morpheus sends work to you - HTTP, gRPC, or event APIs @@ -29,7 +33,30 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s - `spring-boot-testing` - `java-junit` -## How you work +## Your method + +1. Read or write the contract first (OpenAPI, schema, events); keep it backward compatible. +2. Keep domain logic out of transport and persistence; validate input at the boundary. +3. Add timeouts, bounded retries and idempotency where calls can fail; errors as RFC 9457 problem details. +4. Test the contract and the integration against real dependencies (Testcontainers), then ask Keymaker for a review when the change touches auth, data or input from outside. + +## Your place in the crew's RACI + +- **You do** back-end services and APIs. Code reviewer (Seraph) approves it: say so in your report, so Morpheus schedules the review. Consult Software architect (The Architect), Security engineer (The Keymaker) and Database engineer (Dozer) before decisions that affect them (`dev crew ask --to `). +- **You are consulted on** architecture, contracts and design decisions; front-end implementation; schemas, migrations and queries; mobile apps; performance and observability analysis: answer questions from the crew promptly (`dev crew answer ""`). + +## Your devkit commands + +devkit is how the crew works and talks: use these instead of ad-hoc scripts, files or messages. + +- `dev test` +- `dev test prove` +- `dev ci --run` +- `dev docker up` +- `dev sec scan` +- `dev crew ask` + +## How you work with the crew - Stay inside the files and the scope your brief names. Before you go beyond them, ask Morpheus: `dev crew ask ""`. Any crew member may ask anything; reply to a message with `dev crew answer ""`. - Record each finished step: `dev progress step "" --check ""`. devkit routes it to your own lane automatically. @@ -41,7 +68,7 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s Your final message starts with your own status block; nothing comes before it: ```markdown -**[Firefly> DevKit · Tank]** · `` · +**[Firefly> DevKit · Backend engineer (Tank)]** · `` · - **Done:** - **Check:** <✓ | ⚠ | ✗> - **Next:** @@ -49,6 +76,12 @@ Your final message starts with your own status block; nothing comes before it: Then deliver: the change with contract, unit and integration tests (Testcontainers), and the API contract diff. +You are done when: + +- the contract is written and the code matches it +- integration tests run against real dependencies +- errors and timeouts are handled and tested + End with `Needs from Morpheus:` and the decisions or questions for Morpheus, or `nothing`. ## Language diff --git a/plugin/agents/trainman.md b/plugin/agents/trainman.md index 7156d93..099201c 100644 --- a/plugin/agents/trainman.md +++ b/plugin/agents/trainman.md @@ -11,6 +11,10 @@ You are The Trainman, the crew's migration and modernization engineer. Punctual Moves systems between worlds without losing a passenger: framework and language upgrades, platform moves, strangler patterns, and data migrations. +## Goal + +Old systems move to supported platforms in small, reversible steps, without stopping the product. + ## When Morpheus sends work to you - framework or language upgrades @@ -29,7 +33,28 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s - `supabase-postgres-best-practices` - `improve-codebase-architecture` -## How you work +## Your method + +1. Inventory what depends on what, and the breaking changes between the versions. +2. Plan the migration in steps that each keep the build green and can be rolled back. +3. Use the vendor's migration tools and codemods first; hand-edit only what they cannot do. +4. Prove each step with the full test suite and a comparison of behavior before and after. + +## Your place in the crew's RACI + +- **You do** migrations and modernization. Software architect (The Architect) approves it: say so in your report, so Morpheus schedules the review. Consult QA engineer (Mouse) and Release engineer (Link) before decisions that affect them (`dev crew ask --to `). + +## Your devkit commands + +devkit is how the crew works and talks: use these instead of ad-hoc scripts, files or messages. + +- `dev test` +- `dev ci --run` +- `dev plan new` +- `dev progress step` +- `dev crew ask` + +## How you work with the crew - Stay inside the files and the scope your brief names. Before you go beyond them, ask Morpheus: `dev crew ask ""`. Any crew member may ask anything; reply to a message with `dev crew answer ""`. - Record each finished step: `dev progress step "" --check ""`. devkit routes it to your own lane automatically. @@ -41,7 +66,7 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s Your final message starts with your own status block; nothing comes before it: ```markdown -**[Firefly> DevKit · The Trainman]** · `` · +**[Firefly> DevKit · Migration engineer (The Trainman)]** · `` · - **Done:** - **Check:** <✓ | ⚠ | ✗> - **Next:** @@ -49,6 +74,12 @@ Your final message starts with your own status block; nothing comes before it: Then deliver: the migration plan in reversible steps, each step done with its tests, and the cut-over and rollback procedure. +You are done when: + +- every step keeps the build green +- the behavior is the same before and after +- the rollback for each step is known + End with `Needs from Morpheus:` and the decisions or questions for Morpheus, or `nothing`. ## Language diff --git a/plugin/agents/trinity.md b/plugin/agents/trinity.md index b902c23..a8816b9 100644 --- a/plugin/agents/trinity.md +++ b/plugin/agents/trinity.md @@ -10,6 +10,10 @@ You are Trinity, the crew's frontend engineer. Focused, fast, and exact to the p Builds user interfaces: components, state, accessibility, responsive layout and front-end performance, faithful to the design system. +## Goal + +Screens that match the design, work on every screen size and for every user, and stay fast. + ## When Morpheus sends work to you - UI components and pages @@ -30,7 +34,30 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s - `react-best-practices` - `composition-patterns` -## How you work +## Your method + +1. Start from Sati's design (or ask for one): states, hierarchy, copy. +2. Build with the framework's current patterns and the project's components; keep state minimal. +3. Verify in a real browser at desktop and phone widths, in dark and light, with the keyboard and without console errors. +4. Cover behavior with component tests and the critical flows with a few end-to-end tests. + +## Your place in the crew's RACI + +- **You do** front-end implementation. Code reviewer (Seraph) approves it: say so in your report, so Morpheus schedules the review. Consult UX designer (Sati) and Backend engineer (Tank) before decisions that affect them (`dev crew ask --to `). +- **You are consulted on** UX and visual design: answer questions from the crew promptly (`dev crew answer ""`). +- **You are informed about** architecture, contracts and design decisions; back-end services and APIs. + +## Your devkit commands + +devkit is how the crew works and talks: use these instead of ad-hoc scripts, files or messages. + +- `dev test` +- `dev test prove` +- `dev dashboard` +- `dev progress step` +- `dev crew ask` + +## How you work with the crew - Stay inside the files and the scope your brief names. Before you go beyond them, ask Morpheus: `dev crew ask ""`. Any crew member may ask anything; reply to a message with `dev crew answer ""`. - Record each finished step: `dev progress step "" --check ""`. devkit routes it to your own lane automatically. @@ -42,7 +69,7 @@ Load each one with the Skill tool (it may list a plugin skill with its prefix, s Your final message starts with your own status block; nothing comes before it: ```markdown -**[Firefly> DevKit · Trinity]** · `` · +**[Firefly> DevKit · Frontend engineer (Trinity)]** · `` · - **Done:** - **Check:** <✓ | ⚠ | ✗> - **Next:** @@ -50,6 +77,12 @@ Your final message starts with your own status block; nothing comes before it: Then deliver: the change, its component tests, screenshots in light and dark at the sizes it ships, and the accessibility checks run. +You are done when: + +- the screens match the design in every state (empty, loading, error) +- they pass at 1440 px and 390 px, dark and light, with no console errors +- the tests cover the behavior the user sees + End with `Needs from Morpheus:` and the decisions or questions for Morpheus, or `nothing`. ## Language diff --git a/tests/test_crew.py b/tests/test_crew.py index ffc67bf..86e1769 100644 --- a/tests/test_crew.py +++ b/tests/test_crew.py @@ -29,15 +29,21 @@ def member(**kw) -> dict: - m = {"id": "pat", "name": "Pat", "role": "Test engineer", "summary": "Writes the tests that matter.", + m = {"id": "pat", "name": "Pat", "title": "Test engineer", "role": "Test engineer", "summary": "Writes the tests that matter.", "personality": "Curious.", "use_when": ["missing tests"], "skills": ["testing"], "tools": "all", - "tier": "standard", "returns": "the tests and their results"} + "tier": "standard", "returns": "the tests and their results", "goal": "Tests that catch real bugs.", + "method": ["Map what can break.", "Write one test per behavior."], "devkit": ["dev test"], + "done_when": ["every new test fails without the change"]} m.update(kw) return m def roster(*members, rule="Write in American English.") -> dict: - return {"language_rule": rule, "members": list(members) or [member()], "warnings": []} + members = list(members) or [member()] + workers = [m["id"] for m in members if isinstance(m, dict) and not m.get("main") and m.get("id")] + return {"language_rule": rule, "members": members, "warnings": [], # each member does its own kind of work + "activities": [{"id": f"work-{w}", "name": f"Work of {w}", "R": [w], "A": w, "C": [], "I": [], + "signals": [f"{w} work"]} for w in workers]} def frontmatter(text: str) -> dict: @@ -136,7 +142,7 @@ def test_bad_rosters_are_refused_with_every_problem_named(self): def test_unknown_skills_are_warnings_not_errors(self): m = member(skills=["testing", "java-junit", "superpowers:anything", "devkit:testing", "nope", "devkit:nope", "other-plugin:x"], extra=1, keywords=["test", "coverage"]) - errors, warnings = crew.check({"language_rule": "x", "members": [m]}) + errors, warnings = crew.check(dict(roster(m), language_rule="x")) self.assertEqual(errors, []) self.assertEqual([w.split("`")[3] for w in warnings if "unknown skill" in w], ["nope", "devkit:nope", "other-plugin:x"]) @@ -187,9 +193,10 @@ def test_body_sections_report_block_and_rules(self): self.assertEqual(body.splitlines()[0], crew.MARK) self.assertTrue(body.splitlines()[1].startswith("You are Pat, the crew's test engineer. Curious and exact.")) heads = [ln for ln in body.splitlines() if ln.startswith("## ")] - self.assertEqual(heads, ["## Mission", "## When Morpheus sends work to you", "## Skills to load first", - "## How you work", "## Your report", "## Language"]) - self.assertIn("**[Firefly> DevKit · Pat]** · `` · \n- **Done:**", body) + self.assertEqual(heads, ["## Mission", "## Goal", "## When Morpheus sends work to you", "## Skills to load first", + "## Your method", "## Your place in the crew's RACI", "## Your devkit commands", + "## How you work with the crew", "## Your report", "## Language"]) + self.assertIn("**[Firefly> DevKit · Test engineer (Pat)]** · `` · \n- **Done:**", body) self.assertIn("- **Check:** <✓ | ⚠ | ✗>", body) self.assertIn("Then deliver: the tests and their results.", body) self.assertIn('`Needs from Morpheus:`', body) @@ -213,7 +220,9 @@ def test_every_shipped_agent_is_short_and_its_report_passes_the_crew_canary(self for m in crew.members(r, include_main=False): with self.subTest(member=m["id"]): text = crew.render_claude(m, r) - self.assertLess(len(text.splitlines()), 70) + # a full definition (goal, method, RACI, commands, done-when), still well under 2k tokens + self.assertLess(len(text.splitlines()), 100) + self.assertLess(len(text), 7000) self.assertTrue(canary.MARKER_RE.match(crew.status_header(m, "api", "main", "parser"))) def test_the_main_session_role_gets_no_agent_and_scout_is_an_alias(self): @@ -294,7 +303,8 @@ def test_sync_is_idempotent_and_check_reports_drift(self): def test_only_marked_files_are_removed(self): crew.sync(roster=self.r, agents_dir=self.dir) - gone = dict(self.r, members=[self.r["members"][1]]) # Pat leaves the roster + gone = dict(self.r, members=[self.r["members"][1]], # Pat leaves the roster, and its kind of work + activities=[x for x in self.r["activities"] if x["R"] != ["pat"]]) (self.dir / "mine.md").write_text("---\nname: mine\n---\nHand-written.\n") (self.dir / "quoted.md").write_text(f"---\nname: quoted\n---\nNotes.\n{crew.MARK}\n") # mark, not first self.assertEqual(self.names(crew.sync(check=True, roster=gone, agents_dir=self.dir)), [("remove", "pat.md")]) @@ -316,7 +326,7 @@ def test_brief_has_scope_skills_return_report_format_commands_and_spawn_lines(se "2", cwd=d)) for needle in ("subagent_type: devkit:neo", "agent type: neo", 'description: "Neo: ', "You own only these files: `lib/parser/**`, `tests/test_parser.py`", "## Task\n\nBuild the CSV parser.", - "**[Firefly> DevKit · Neo]** · `` · ", + "**[Firefly> DevKit · Senior engineer (Neo)]** · `` · ", "- **Check:** <✓ | ⚠ | ✗>", '`Needs from Morpheus:`', _sentence(neo["returns"]), 'dev progress step "" --check ""', 'dev crew ask "'): self.assertIn(needle, text) @@ -395,7 +405,7 @@ def test_roster_view_shows_lanes_assignments_open_messages_and_mode(self): "2. review → Seraph · pending · waiting for its agent · scope lib/**", "Open messages", "M1 Neo → Morpheus · not delivered: Empty lines?", "Mode guided"): self.assertIn(needle, out) - self.assertIn("● neo", out) # the roster marks who is working + self.assertIn("● Senior engineer", out) # the roster marks who is working for argv in (("crew", "--json"), ("crew", "--json", "--latest")): with self.subTest(argv=argv): data = json.loads(run_cli(*argv)[1]) @@ -650,7 +660,9 @@ def test_the_whole_roster_fits_one_mcp_result(self): with TempState(): data = json.loads(run_cli("crew", "--json", "--latest")[1]) self.assertIsNone(data["run"]) - self.assertLess(len(mcp.compact_json(mcp._prune(data))), mcp.MAX_TEXT) # never cut by mcp.cap() + digest = mcp._HANDLERS["devkit_crew"][3] # what the tool returns: the digest, then the prune + self.assertLess(len(mcp.compact_json(mcp._prune(digest(data, {})))), mcp.MAX_TEXT) # never cut by mcp.cap() + self.assertIn("raci", digest(data, {})) self.assertEqual(data["members"][0]["id"], "morpheus") self.assertNotIn("spawn", data["members"][0]) # the main session's role is never spawned diff --git a/tests/test_progress.py b/tests/test_progress.py index 1754b70..06bbab0 100644 --- a/tests/test_progress.py +++ b/tests/test_progress.py @@ -386,7 +386,7 @@ def stop(payload): "agent_transcript_path": str(tr), "last_assistant_message": "Done. The parser works."} sent_back = json.loads(stop(launched)) # launched with the Agent tool: sent back once self.assertEqual(sent_back["decision"], "block") - self.assertIn("DevKit · Neo]", sent_back["reason"]) # by the name its launch gave it + self.assertIn("DevKit · Senior engineer (Neo)]", sent_back["reason"]) # by the name its launch gave it fixed = dict(launched, stop_hook_active=True, last_assistant_message=( "**[Firefly> DevKit · Neo]** api · `main` · parser\n- **Done:** x\n- **Check:** \u2713 3 tests\n" "- **Next:** review")) diff --git a/tests/test_raci.py b/tests/test_raci.py new file mode 100644 index 0000000..9ebd1ed --- /dev/null +++ b/tests/test_raci.py @@ -0,0 +1,305 @@ +# Copyright (c) 2026 Firefly Software Solutions Inc. +# SPDX-License-Identifier: Apache-2.0 +"""The crew's RACI: who does, approves, is consulted on and informed about each kind of work; routing from it; the +refusals at assignment and at the end of a run; and the team's changes in the handoff repository. +Run: python3 -m unittest""" +import argparse +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "lib")) + +from devkit import cli, core, crew, dispatch, progress as pg, raci # noqa: E402 + +# Real tasks and the member who should do them (the misroutes the developer found are the first rows). +GOLDEN = [ + ("build a local HTTP server and JSON API for the dashboard", "tank"), + ("security review of a local web server", "keymaker"), + ("design the screens for a background work view", "sati"), + ("implement the dashboard frontend live updates", "trinity"), + ("find bugs in the dashboard and check that it works as expected", "mouse"), + ("gap analysis: what is missing in the dashboard for task details", "oracle"), + ("a curl installer that works through a pipe", "kid"), + ("fix the failing GitHub Actions workflow", "link"), + ("add a migration for the invoices table", "dozer"), + ("upgrade the app from Angular 15 to 19 and modernize the legacy modules", "trainman"), + ("write the getting started guide", "ghost"), + ("where is the guard's push limit defined", "sparks"), + ("production is down after the deploy", "lock"), + ("the orders endpoint is slow, p95 is 2 seconds", "merovingian"), + ("check the licenses of our new dependencies", "persephone"), + ("threat model the login flow", "keymaker"), + ("try to bypass the guard with an adversarial attack", "smith"), + ("review the diff before merging", "seraph"), + ("which vector database should we use, compare the options", "roland"), + ("prioritize the backlog for the next release", "hamann"), + ("write acceptance criteria for the export feature", "oracle"), + ("design the module boundaries and the API contract for billing", "architect"), + ("add an iOS share extension to the mobile app", "switch"), + ("add RAG over our docs for the support bot", "rama"), + ("set up Terraform and Kubernetes for the staging environment", "niobe"), + ("how many users finished onboarding last month, by cohort", "apoc"), + ("fix the null pointer crash in checkout", "neo"), + ("the tests are flaky and coverage is low", "mouse"), + ("cut release 26.11.3 and publish it", "link"), + ("plan the work and estimate it", "morpheus"), + ("critique the dashboard's visual design and usability", "sati"), + ("diseño de la pantalla de tareas", "sati"), +] + + +def commands(): + """{command: set of its subcommands or choices} from the real `dev` parser.""" + p = cli.build_parser() + sub = next(a for a in p._actions if isinstance(a, argparse._SubParsersAction)) + out = {} + for name, sp in sub.choices.items(): + kids = [a for a in sp._actions if isinstance(a, argparse._SubParsersAction)] + pos = [a for a in sp._actions if not a.option_strings and a.choices and not isinstance(a, argparse._SubParsersAction)] + out[name] = set(kids[0].choices) if kids else set(pos[0].choices) if pos else set() + return out + + +class RosterTests(unittest.TestCase): + def test_the_shipped_roster_and_raci_are_complete(self): + r = crew.load(team=False) + self.assertEqual(r["warnings"], []) + acts = raci.activities(r) + self.assertGreaterEqual(len(acts), 20) + for a in acts: + with self.subTest(activity=a["id"]): + self.assertIsInstance(a["A"], str) # exactly one approver + self.assertTrue(a["R"]) + for m in crew.members(r, include_main=False): + with self.subTest(member=m["id"]): + self.assertTrue(raci.roles_of(m["id"], r)["R"], "every member does at least one kind of work") + self.assertGreaterEqual(len(m["method"]), 3) + self.assertTrue(m["goal"] and m["done_when"]) + + def test_every_devkit_command_a_member_names_exists(self): + known = commands() + for m in crew.members(crew.load(team=False)): + for c in m["devkit"]: + words = c.split() + with self.subTest(member=m["id"], command=c): + self.assertEqual(words[0], "dev") + self.assertIn(words[1], known) + if len(words) > 2 and not words[2].startswith("-") and known[words[1]]: + self.assertIn(words[2], known[words[1]]) + + def test_a_broken_raci_is_an_error(self): + r = json.loads(crew.ROSTER.read_text()) + bad = dict(r, activities=[dict(r["activities"][0], A=["morpheus", "hamann"]), + dict(r["activities"][1], R=["nobody"])] + r["activities"][2:]) + errors, _ = crew.check(bad) + self.assertIn("`A` must be exactly one member", " ".join(errors)) + self.assertIn("R `nobody` is not a crew member", " ".join(errors)) + orphan = dict(r, activities=[a for a in r["activities"] if a["R"] != ["ghost"]]) + self.assertIn("`ghost` is Responsible for no activity", " ".join(crew.check(orphan)[0])) + + +class RoutingTests(unittest.TestCase): + def test_real_tasks_go_to_the_member_the_raci_makes_responsible(self): + r = crew.load(team=False) + for text, want in GOLDEN: + with self.subTest(task=text): + top = raci.best(text, r) + self.assertIsNotNone(top, "no clear kind of work") + self.assertIn(want, top[0]["R"]) + + def test_route_puts_the_responsible_member_first_with_its_approver(self): + with mock.patch.object(dispatch, "_roster", lambda: crew.members(crew.load(team=False), include_main=False)): + ranked = dispatch.route("build a local HTTP server and JSON API for the dashboard") + self.assertEqual(ranked[0][0]["id"], "tank") + self.assertIn("RACI R: Back-end services and APIs", ranked[0][2]) + self.assertIn("approves: Code reviewer (Seraph)", ranked[0][2]) + + def test_an_unclear_task_is_not_routed_by_the_raci(self): + self.assertIsNone(raci.best("look at this", crew.load(team=False))) + + +class AssignmentTests(unittest.TestCase): + # two hours ago: devkit's sweep for lost subagents compares lanes with the real clock + T0 = int(__import__("time").time()) // 60 * 60 - 7200 + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + root = Path(self.tmp.name) + from devkit import audit + self.saved = (pg.RUNS, pg.TIMING, pg.PENDING, pg.SESSIONS, pg._publish, audit.AUDIT, + os.environ.get("DEVKIT_SESSION")) + pg.RUNS, pg.TIMING, pg.SESSIONS = root / "runs", root / "timing", root / "sessions" + pg.PENDING = pg.TIMING / "pending" + pg._publish = lambda: None + audit.AUDIT = root / "audit" + os.environ["DEVKIT_SESSION"] = "claude:s1" + os.environ["DEVKIT_NO_NOTIFY"] = "1" + pg.SESSIONS.mkdir(parents=True) + (pg.SESSIONS / "claude-s1.json").write_text(json.dumps({"agent": "claude", "session": "s1", + "started": self.T0, "project": "acme/api"})) + + def tearDown(self): + from devkit import audit + pg.RUNS, pg.TIMING, pg.PENDING, pg.SESSIONS, pg._publish, audit.AUDIT, env = self.saved + if env is None: + os.environ.pop("DEVKIT_SESSION", None) + else: + os.environ["DEVKIT_SESSION"] = env + self.tmp.cleanup() + + def m(self, minutes): + return self.T0 + minutes * 60 + + def test_a_member_who_is_not_responsible_is_refused_and_an_override_is_recorded(self): + pg.start("Dashboard", plan=["build the JSON API server", "dashboard audit"], now=self.m(0)) + with self.assertRaises(pg.StepError) as e: + pg.assign("build the JSON API server", "Kid", now=self.m(1)) + self.assertIn("Backend engineer (Tank) does it and Code reviewer (Seraph) approves it", str(e.exception)) + run = pg.assign("build the JSON API server", "Tank", now=self.m(1)) + self.assertEqual(next(s for s in run["plan"] if s["name"].startswith("build"))["activity"], "backend") + run = pg.assign("dashboard audit", "Sati", now=self.m(1)) # no clear kind of work: no refusal + run = pg.assign("build the JSON API server", "Neo", why="Tank is out; Neo pairs with Seraph", now=self.m(2)) + self.assertEqual(run["raci_overrides"][-1]["why"], "Tank is out; Neo pairs with Seraph") + + def test_the_run_ends_only_when_the_accountable_member_approved(self): + pg.start("API", plan=["build the JSON API server", "review"], now=self.m(0)) + pg.assign("build the JSON API server", "Tank", now=self.m(0)) + pg.note_agent_launch("claude", {"session_id": "s1", "tool_input": {"description": "Tank: build the JSON API server"}}) + pg.on_subagent("claude", {"session_id": "s1", "agent_id": "a1", "agent_type": "devkit:tank"}, True, now=self.m(1)) + pg.on_subagent("claude", {"session_id": "s1", "agent_id": "a1", "agent_type": "devkit:tank"}, False, now=self.m(20)) + with self.assertRaises(pg.StepError) as e: + pg.step("API shipped", kind="done", force=True, now=self.m(22)) + self.assertIn("Code reviewer (Seraph) approves “build the JSON API server”", str(e.exception)) + pg.assign("review", "Seraph", now=self.m(22)) + pg.note_agent_launch("claude", {"session_id": "s1", "tool_input": {"description": "Seraph: review"}}) + pg.on_subagent("claude", {"session_id": "s1", "agent_id": "a2", "agent_type": "devkit:seraph"}, True, now=self.m(23)) + pg.on_subagent("claude", {"session_id": "s1", "agent_id": "a2", "agent_type": "devkit:seraph"}, False, now=self.m(30)) + run = pg.step("API shipped", kind="done", now=self.m(31)) + self.assertEqual(run["state"], "done") + + def test_ending_without_the_approval_needs_a_reason(self): + pg.start("API", plan=["build the JSON API server"], now=self.m(0)) + pg.assign("build the JSON API server", "Tank", now=self.m(0)) + pg.note_agent_launch("claude", {"session_id": "s1", "tool_input": {"description": "Tank: build the JSON API server"}}) + pg.on_subagent("claude", {"session_id": "s1", "agent_id": "a1", "agent_type": "devkit:tank"}, True, now=self.m(1)) + pg.on_subagent("claude", {"session_id": "s1", "agent_id": "a1", "agent_type": "devkit:tank"}, False, now=self.m(9)) + run = pg.step("API shipped", kind="done", why="a spike, thrown away", now=self.m(10)) + self.assertEqual(run["state"], "done") + self.assertEqual(run["raci_overrides"][-1]["owed"], [["seraph", ["build the JSON API server"]]]) + + +class MorpheusTests(unittest.TestCase): + """Morpheus routes every development request, and the crew talks through devkit.""" + + def test_a_request_with_several_kinds_of_work_names_each_member_and_approver(self): + from devkit import hooks + r = crew.load(team=False) + text = hooks.morpheus_hint("build the JSON API server for the dashboard and implement the frontend live " + "updates", r) + self.assertIn("you are Morpheus", text) + self.assertIn("back-end services and APIs → Backend engineer (Tank) does it, Code reviewer (Seraph) approves", text) + self.assertIn("front-end implementation → Frontend engineer (Trinity) does it", text) + self.assertIn("Plan one step per kind of work", text) + self.assertEqual(hooks.morpheus_hint("please continue", r), "") + self.assertEqual(hooks.morpheus_hint("look at this one thing", r), "") + + def test_the_status_block_shows_the_crew_and_what_the_developer_must_decide(self): + from devkit import canary + run = {"goal": "Dashboard", "state": "running", "project": "acme/api", "cwd": "/", + "plan": [{"name": "the API", "owner": "a1", "state": "running", "est": 30}, + {"name": "the screens", "owner": "a2", "state": "done", "est": 20}], + "agents": {"a1": {"persona": "Tank", "label": "Tank: the API", "state": "running", "started": 1}, + "a2": {"persona": "Sati", "label": "Sati: the screens", "state": "done", "started": 1}, + "h1": {"label": "compaction", "state": "done", "started": 1}}, + "decisions": [{"id": "D1", "question": "Read-only or with actions?", "state": "open", "mode": "guided"}], + "updates": [{"kind": "step", "text": "contract written", "check": "✓ 4 tests", "next": "the API"}]} + with mock.patch.object(pg, "pending_decisions", lambda r: r["decisions"]): + block = pg.render_update(run, branch="main", now=100) + self.assertIn("- **Crew:** Backend engineer (Tank) ▸ the API · UX designer (Sati) ✓ the screens", block) + self.assertIn("- **You:** decide “Read-only or with actions?”", block) + parsed = canary.parse_status(block) + self.assertEqual(parsed["crew"], "Backend engineer (Tank) ▸ the API · UX designer (Sati) ✓ the screens") + self.assertEqual(parsed["next"], "the API") # Next still parses after the new bullets + self.assertEqual(canary.format_issues(block), []) + self.assertIn("you → decide", canary.status_text(block)) + + def test_a_member_that_never_reported_through_devkit_owes_a_step(self): + now = 10_000 + run = {"plan": [{"name": "the API", "owner": "a1", "state": "running"}], + "agents": {"a1": {"persona": "Tank", "started": now - 600, "updates": []}, + "a2": {"persona": "Sparks", "started": now - 600, "updates": []}}} + self.assertTrue(pg.lane_unreported(run, "a1", now)) + run["agents"]["a1"]["updates"] = [{"kind": "step", "text": "contract written"}] + self.assertFalse(pg.lane_unreported(run, "a1", now)) + run["plan"].append({"name": "where is X", "owner": "a2", "state": "running"}) + self.assertFalse(pg.lane_unreported(run, "a2", now)) # read-only: it reports in its message + run["agents"]["a1"].update(updates=[], started=now - 60) + self.assertFalse(pg.lane_unreported(run, "a1", now)) # a minute of work: nothing owed yet + + +class TeamCrewTests(unittest.TestCase): + """The team's changes live in the handoff repository: versioned, with who and why, and merged everywhere.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.handoff = Path(self.tmp.name) / "handoff" + subprocess.run(["git", "init", "-q", str(self.handoff)], check=True) + from devkit import audit + self.p = [mock.patch.object(core, "HANDOFF", self.handoff), + mock.patch.object(audit, "AUDIT", Path(self.tmp.name) / "audit"), + mock.patch.dict(os.environ, {"GIT_AUTHOR_NAME": "ana", "GIT_AUTHOR_EMAIL": "ana@example.com", + "GIT_COMMITTER_NAME": "ana", "GIT_COMMITTER_EMAIL": "ana@example.com", + "DEVKIT_NO_AUTOSYNC": "1"})] + for x in self.p: + x.start() + crew._CACHE.clear() + + def tearDown(self): + for x in self.p: + x.stop() + crew._CACHE.clear() + self.tmp.cleanup() + + def test_a_raci_change_is_recorded_committed_and_used(self): + act = crew.set_raci("frontend", {"R": ["trinity", "neo"], "C": ["sati"]}, "Neo pairs on the dashboard UI", + by="ana@mbp") + self.assertEqual(act["R"], ["trinity", "neo"]) + self.assertIsNone(raci.check_assignment("neo", "implement the dashboard frontend live updates")) + ov = json.loads((self.handoff / "crew" / "crew.json").read_text()) + self.assertEqual(ov["changelog"][-1]["why"], "Neo pairs on the dashboard UI") + self.assertIn("| Front-end implementation | Frontend engineer (Trinity) and Senior engineer (Neo) | Code reviewer (Seraph) | UX designer (Sati) |", + (self.handoff / "crew" / "README.md").read_text()) + log = subprocess.run(["git", "-C", str(self.handoff), "log", "--format=%s"], capture_output=True, + text=True).stdout + self.assertIn("crew: RACI frontend", log) + + def test_a_member_change_reaches_the_brief_and_a_change_without_a_reason_is_refused(self): + crew.set_member("tank", "method", "Write the OpenAPI file first; Ask Keymaker on every auth change; Test with " + "Testcontainers", "the team's API standard", by="ana@mbp") + self.assertEqual(crew.member("tank")["method"][0], "Write the OpenAPI file first") + self.assertIn("Write the OpenAPI file first", crew.body(crew.member("tank"), crew.load())) + with self.assertRaises(ValueError): + crew.set_member("tank", "goal", "Anything", "") + with self.assertRaises(ValueError) as e: # validated before it is written + crew.set_raci("backend", {"A": ["nobody"]}, "typo") + self.assertIn("would break the crew", str(e.exception)) + + def test_the_generated_plugin_agents_ignore_the_team_changes(self): + crew.set_member("tank", "goal", "A different goal.", "team wording", by="ana@mbp") + self.assertNotIn("A different goal.", crew.agent_files()["tank.md"]) + self.assertIn("A different goal.", crew.body(crew.member("tank"), crew.load())) + + def test_the_guard_refuses_direct_edits(self): + from devkit import guard + why = guard.handoff_violation("crew/crew.json", str(self.handoff)) + self.assertIn("dev crew set", why) + + +if __name__ == "__main__": + unittest.main() From dc603c69c32d34459a6b382cccfe841eef775055 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Fri, 6 Nov 2026 09:50:22 -0800 Subject: [PATCH 006/297] fix(progress): give each subagent its own launch label and step Tank's lane took Kid's label: SubagentStart matched the first queued launch of the same type (every launch was general-purpose), and a launch that never started stayed in the queue. A starting subagent now claims the launch its own record names (Claude Code's .meta.json), unclaimed launches expire after 15 minutes, and a launch recorded twice is kept once. Reassigning a step away from a running member also detaches it from that member's lane, recorded in the run's history. --- lib/devkit/progress.py | 38 +++++++++++++++++++++++++++++++++----- tests/test_raci.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/lib/devkit/progress.py b/lib/devkit/progress.py index b485b52..735dfec 100644 --- a/lib/devkit/progress.py +++ b/lib/devkit/progress.py @@ -526,6 +526,12 @@ def assign(which, label: str, cwd: str | None = None, now: float | None = None, if not s: raise ValueError(f"no step “{which}” in the plan") _raci_assign(run, s, label, why, now) + owner = s.get("owner") or "main" + if owner != "main" and s.get("state") != "done" and not _label_matches(label, (run.get("agents") or {}).get(owner) + or {}): + old = ((run.get("agents") or {}).get(owner) or {}).get("persona") or owner + _log_change(run, s, s.get("state"), "pending", "main", f"reassigned from {old} to {label}", now) + s.update(owner="main", state="pending") # the other member's lane no longer owns it s["label"] = label if scope: globs = [x.strip() for x in scope.split(",") if x.strip()] @@ -1486,13 +1492,32 @@ def note_agent_launch(agent: str, data: dict, now: float | None = None): inp = data.get("tool_input") or {} label = inp.get("description") or inp.get("nickname") or inp.get("name") or \ str(inp.get("message") or inp.get("prompt") or "").strip().split("\n")[0][:60] + now = now or time.time() + entry = {"label": str(label)[:80], "type": inp.get("subagent_type") or inp.get("agent_type") or "", + "ts": round(now, 1)} q = (s.get("pending_agents") or [])[-19:] - q.append({"label": str(label)[:80], "type": inp.get("subagent_type") or inp.get("agent_type") or "", - "ts": round(now or time.time(), 1)}) + last = q[-1] if q else {} + if last.get("label") == entry["label"] and last.get("type") == entry["type"] and \ + abs(now - (last.get("ts") or 0)) < 10: + return # the same launch recorded twice + q.append(entry) s["pending_agents"] = q f.write_text(json.dumps(s)) +LAUNCH_TTL = 15 * 60 # a launch whose SubagentStart never came does not label a later subagent + + +def _launch_description(data: dict) -> str: + """The description the Agent tool launched this subagent with: Claude Code keeps it in `.meta.json` + next to the subagent's transcript. '' when there is none (Codex, an older client).""" + p = str(data.get("agent_transcript_path") or "") + if not p.endswith(".jsonl"): + return "" + meta = core.read_json(Path(p[:-len(".jsonl")] + ".meta.json"), {}) or {} + return str(meta.get("description") or "") if isinstance(meta, dict) else "" + + def on_subagent(agent: str, data: dict, started: bool, now: float | None = None): sid = data.get("session_id") or "-" f, s = _sess(agent, sid) @@ -1503,9 +1528,12 @@ def on_subagent(agent: str, data: dict, started: bool, now: float | None = None) aid = data.get("agent_id") or data.get("subagent_id") or "?" kind = data.get("agent_type") or data.get("subagent_type") or "" if started: - q = s.get("pending_agents") or [] - i = next((j for j, x in enumerate(q) if not x.get("type") or not kind or x["type"] == kind), None) - label = q.pop(i)["label"] if i is not None else kind + q = [x for x in s.get("pending_agents") or [] if now - (x.get("ts") or now) < LAUNCH_TTL] + want = _launch_description(data) + i = next((j for j, x in enumerate(q) if want and x.get("label") == want[:80]), None) + if i is None: # no launch record: the oldest recent launch of this type + i = next((j for j, x in enumerate(q) if not x.get("type") or not kind or x["type"] == kind), None) + label = q.pop(i)["label"] if i is not None else (want or kind) s["pending_agents"] = q subs[aid] = {"type": kind, "label": label, "started": now} diff --git a/tests/test_raci.py b/tests/test_raci.py index 9ebd1ed..69c92ea 100644 --- a/tests/test_raci.py +++ b/tests/test_raci.py @@ -167,6 +167,44 @@ def test_a_member_who_is_not_responsible_is_refused_and_an_override_is_recorded( run = pg.assign("build the JSON API server", "Neo", why="Tank is out; Neo pairs with Seraph", now=self.m(2)) self.assertEqual(run["raci_overrides"][-1]["why"], "Tank is out; Neo pairs with Seraph") + def test_a_starting_subagent_gets_its_own_launch_label_not_a_stale_one(self): + pg.start("Work", plan=["installer", "dashboard server"], now=self.m(0)) + pg.assign("dashboard server", "Tank", now=self.m(0)) + launch = lambda d, at: pg.note_agent_launch("claude", {"session_id": "s1", "tool_input": { # noqa: E731 + "description": d, "subagent_type": "general-purpose"}}, now=at) + launch("Kid: curl installer", self.m(0)) # its SubagentStart never claimed it + launch("Tank: dashboard server", self.m(31)) + launch("Tank: dashboard server", self.m(31) + 1) # the same launch recorded twice: one entry + pg.on_subagent("claude", {"session_id": "s1", "agent_id": "t1", "agent_type": "general-purpose"}, True, + now=self.m(31) + 2) + run = pg.load_run("claude", "s1") + self.assertEqual(run["agents"]["t1"]["label"], "Tank: dashboard server") + self.assertEqual(run["plan"][1]["owner"], "t1") + sess = json.loads((pg.SESSIONS / "claude-s1.json").read_text()) + self.assertEqual(sess.get("pending_agents"), []) # the stale entry expired, the duplicate was never kept + # two launches at once: each subagent claims the entry its own launch record names + launch("Sati: screens", self.m(40)) + launch("Ghost: docs", self.m(40)) + with tempfile.TemporaryDirectory() as d: + tr = Path(d) / "agent-g1.jsonl" + tr.write_text("") + (Path(d) / "agent-g1.meta.json").write_text(json.dumps({"toolUseId": "toolu_9", "description": "Ghost: docs"})) + pg.on_subagent("claude", {"session_id": "s1", "agent_id": "g1", "agent_type": "general-purpose", + "agent_transcript_path": str(tr)}, True, now=self.m(41)) + self.assertEqual(pg.load_run("claude", "s1")["agents"]["g1"]["label"], "Ghost: docs") + + def test_a_step_reassigned_away_from_a_running_member_leaves_its_lane(self): + pg.start("Work", plan=["installer", "dashboard server"], now=self.m(0)) + pg.assign("installer", "Kid", now=self.m(0)) + pg.note_agent_launch("claude", {"session_id": "s1", "tool_input": {"description": "Kid: installer"}}) + pg.on_subagent("claude", {"session_id": "s1", "agent_id": "a1", "agent_type": "devkit:kid"}, True, now=self.m(1)) + run = pg.assign("dashboard server", "Kid", now=self.m(2)) # a mistake: Kid's running lane takes it + self.assertEqual(run["plan"][1]["owner"], "a1") + run = pg.assign("dashboard server", "Tank", now=self.m(3)) + self.assertEqual((run["plan"][1]["owner"], run["plan"][1]["state"], run["plan"][1]["label"]), + ("main", "pending", "Tank")) + self.assertIn("reassigned from Kid to Tank", [c.get("why") for c in run.get("history") or []][-1] or "") + def test_the_run_ends_only_when_the_accountable_member_approved(self): pg.start("API", plan=["build the JSON API server", "review"], now=self.m(0)) pg.assign("build the JSON API server", "Tank", now=self.m(0)) From d11f1755ca9e7bbcb922dda9524f0d8f85dc0f30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Fri, 6 Nov 2026 09:50:22 -0800 Subject: [PATCH 007/297] feat(crew): show every member as its role first, then its name The developer asked that every mention of a crew member carries its role, since names alone mean nothing to most developers: the dispatcher, the busy and waiting lists, routing plans and delegation messages now print Backend engineer (Tank). Views carry a who field for tools; database migrations route by the RACI. --- config/crew.json | 4 +++- lib/devkit/crew.py | 23 ++++++++++++++++------- lib/devkit/dispatch.py | 6 ++++-- tests/test_crew.py | 14 +++++++------- 4 files changed, 30 insertions(+), 17 deletions(-) diff --git a/config/crew.json b/config/crew.json index 42aef9d..89cec1e 100644 --- a/config/crew.json +++ b/config/crew.json @@ -1777,7 +1777,9 @@ "flyway", "alembic", "liquibase", - "table" + "table", + "database migration", + "add a migration" ] }, { diff --git a/lib/devkit/crew.py b/lib/devkit/crew.py index bae410b..61b0789 100644 --- a/lib/devkit/crew.py +++ b/lib/devkit/crew.py @@ -522,6 +522,7 @@ def run_view(run: dict, roster=None, now=None): for (aid, lane), a in zip(raw, b.get("agents") or []): m = _for_lane(lane, roster) lanes.append({"member": m["name"] if m else (lane.get("persona") or ""), "id": m["id"] if m else "", + "who": who(m) if m else (lane.get("persona") or lane.get("label") or ""), "label": a.get("label"), "state": a.get("state"), "min": a.get("min"), "scope": lane.get("scope") or []}) names = {aid: lane.get("persona") or lane.get("label") or "subagent" for aid, lane in raw} @@ -532,6 +533,7 @@ def run_view(run: dict, roster=None, now=None): m = _for_label(s["label"], roster) owner = s.get("owner") or "main" assigned.append({"step": i, "name": s.get("name"), "to": s["label"], "member": m["name"] if m else s["label"], + "who": who(m) if m else s["label"], "scope": s.get("scope") or [], "state": s.get("state"), "lane": names.get(owner, "") if owner != "main" else ""}) return {"goal": b.get("goal"), "project": run.get("project"), "state": b.get("state"), @@ -541,17 +543,24 @@ def run_view(run: dict, roster=None, now=None): "decisions": [_decision(d) for d in progress.pending_decisions(run)], "ready": ready_view(run, roster)} +def _who_label(label, roster=None) -> str: + """A step or lane label as developers read it: "Tank" → "Backend engineer (Tank)".""" + m = _for_label(label or "", roster) + return who(m) if m else (label or "") + + def ready_view(run: dict, roster=None) -> dict: """dispatch.ready() as plain data: what Morpheus can launch now, what is queued or waiting and why, who is busy.""" from . import dispatch, progress r = dispatch.ready(run) - step = lambda s: {"step": s["name"], "member": s.get("label") or "", # noqa: E731 + step = lambda s: {"step": s["name"], "member": s.get("label") or "", "who": _who_label(s.get("label"), roster), # noqa: E731 "scope": s.get("scope") or [], "requested_by": s.get("requested_by") or ""} return {"free": r["free"], "max_parallel": progress.cfg()["max_parallel"], "launch": [dict(step(s), launch=dispatch.launch_line(s)) for s in r["launch"]], "queued": [dict(step(s), launch=dispatch.launch_line(s)) for s in r["queued"]], "waiting": [dict(step(s), why=why) for s, why in r["waiting"]], "busy": [{"member": (_for_lane(lane, roster) or {}).get("name") or lane.get("persona") or "", + "who": _who_label(lane.get("persona") or lane.get("label"), roster), "label": lane.get("label") or "", "scope": lane.get("scope") or []} for _, lane in r["busy"]]} @@ -624,7 +633,7 @@ def run_text(view: dict) -> str: out = [mode_line(view)] ready = view.get("ready") or {} if ready.get("launch"): - out.append(f" Ready to launch: {', '.join(s['member'] + ' (' + s['step'] + ')' for s in ready['launch'])}" + out.append(f" Ready to launch: {', '.join((s.get('who') or s['member']) + ': ' + s['step'] for s in ready['launch'])}" " · dev crew next") if view.get("decisions"): out.append(ui.section("Decisions waiting for the developer")) @@ -858,7 +867,7 @@ def _route(a) -> int: print(" every open plan step already has a member") return 0 for s, m, score, why in rows: - print(f" {s['name']} → {m['name']} ({score:g}) · {'; '.join(why)}") + print(f" {s['name']} → {who(m)} ({score:g}) · {'; '.join(why)}") if a.apply: print(f"{ui.OK} assigned {len(rows)} step(s); who to launch now: dev crew next") else: @@ -918,16 +927,16 @@ def _next() -> int: if v["launch"]: print(ui.section("Ready to launch")) for s in v["launch"]: - print(f" {s['member']}: {s['step']}\n {s['launch']}") + print(f" {s.get('who') or s['member']}: {s['step']}\n {s['launch']}") if v["queued"]: print(ui.section("Queued until a slot frees")) - print("\n".join(f" {s['member']}: {s['step']}" for s in v["queued"])) + print("\n".join(f" {s.get('who') or s['member']}: {s['step']}" for s in v["queued"])) if v["waiting"]: print(ui.section("Waiting")) - print("\n".join(f" {s['member']}: {s['step']} · {s['why']}" for s in v["waiting"])) + print("\n".join(f" {s.get('who') or s['member']}: {s['step']} · {s['why']}" for s in v["waiting"])) if v["busy"]: print(ui.section("Busy")) - print("\n".join(f" {ui.ACTIVE} {b['member'] or 'subagent'} · {b['label']}" + print("\n".join(f" {ui.ACTIVE} {b.get('who') or b['member'] or 'subagent'} · {b['label']}" + (f" · scope {', '.join(b['scope'])}" if b["scope"] else "") for b in v["busy"])) if not (v["launch"] or v["queued"] or v["waiting"] or v["busy"]): print(" nothing assigned to the crew: dev crew route --plan, or dev crew delegate \"\"") diff --git a/lib/devkit/dispatch.py b/lib/devkit/dispatch.py index 96b1dc1..d50a897 100644 --- a/lib/devkit/dispatch.py +++ b/lib/devkit/dispatch.py @@ -178,7 +178,8 @@ def delegate(task: str, to: str = "auto", files=None, scope: str = "", after: st progress._save(run, touch_session=False) progress._audit("crew.delegate", f"{by} → {m['name']}", task.strip()[:300], project=run.get("project") or "", step=name, routed="yes" if routed else "no") - text = f"{by} delegates “{task.strip()[:200]}” to {m['name']} (step “{name}”)" + from . import crew + text = f"{by} delegates “{task.strip()[:200]}” to {crew.who(m)} (step “{name}”)" if lane_id and s.get("owner") == lane_id: progress.crew_ask(text + ". It is in your lane now; if you cannot take it: dev crew decline " f"\"{name}\" --why \"…\"", to=lane_id, from_agent=from_agent, cwd=cwd, now=now) @@ -268,7 +269,8 @@ def ready_context(agent: str, sid: str) -> str: mode = progress.mode_of(run) lines = [f"devkit · ready to launch ({r['free']} of {progress.cfg()['max_parallel']} crew slots free, {mode} " f"mode{': launch them now, in one message' if mode == 'auto' else ''}):"] - lines += [f"- {s['label']}: {s['name']} → {launch_line(s)}" for s in new] + from . import crew + lines += [f"- {crew._who_label(s['label'])}: {s['name']} → {launch_line(s)}" for s in new] if r["queued"]: lines.append(f"- queued until a slot frees: {', '.join(s['name'] for s in r['queued'])}") return "\n".join(lines) diff --git a/tests/test_crew.py b/tests/test_crew.py index 86e1769..aeabbce 100644 --- a/tests/test_crew.py +++ b/tests/test_crew.py @@ -523,8 +523,8 @@ def test_route_the_plan_then_apply(self): pg.start("Feature", plan=["requirements and acceptance criteria", "database migration", "pipeline caching"]) rc, out, _ = run_cli("crew", "route", "--plan") self.assertEqual(rc, 0) - self.assertIn("requirements and acceptance criteria → The Oracle", out) - self.assertIn("database migration → Dozer", out) + self.assertIn("requirements and acceptance criteria → Business analyst (The Oracle)", out) + self.assertIn("database migration → Database engineer (Dozer)", out) self.assertIn("assign them: dev crew route --plan --apply", out) self.assertFalse(any(s.get("label") for s in pg.load_run("claude", "s1")["plan"])) # only proposed rc, out, _ = run_cli("crew", "route", "--plan", "--apply") @@ -586,9 +586,9 @@ def test_next_shows_ready_queued_waiting_busy_and_the_mode(self): self.assertEqual(rc, 0) self.assertIn("4 of 4 crew slots free · mode guided", out) self.assertIn("Ready to launch", out) - self.assertIn(' Ghost: docs\n dev crew brief ghost docs --step docs', out) - self.assertIn("Tank: api · after design", out) - self.assertIn("Trinity: ui · after design", out) + self.assertIn(' Technical writer (Ghost): docs\n dev crew brief ghost docs --step docs', out) + self.assertIn("Backend engineer (Tank): api · after design", out) + self.assertIn("Frontend engineer (Trinity): ui · after design", out) self.assertNotIn("auto mode", out) self.assertEqual(run_cli(*shlex.split('crew brief ghost "docs" --step "docs"'))[0], 0) # runs as printed pg.set_mode("auto") @@ -598,9 +598,9 @@ def test_next_shows_ready_queued_waiting_busy_and_the_mode(self): rc, out, _ = run_cli("crew", "next") self.assertIn("0 of 4 crew slots free", out) self.assertIn("Queued until a slot frees", out) - self.assertIn(" Ghost: docs\n", out) + self.assertIn(" Technical writer (Ghost): docs\n", out) self.assertIn("Busy", out) - self.assertIn("Mouse · Mouse: other work", out) + self.assertIn("QA engineer (Mouse) · Mouse: other work", out) self.assertNotIn("Ready to launch", out) self.assertNotIn("launch all ready members", out) ready = json.loads(run_cli("crew", "--json")[1])["run"]["ready"] From eb61f84568ab9a5b6b3ce7057d64511c7f83c462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Fri, 6 Nov 2026 09:53:49 -0800 Subject: [PATCH 008/297] feat(install): install devkit with one curl command The repository is public, but install.sh only worked from a clone, hardcoded /opt/homebrew (wrong on Intel), and under curl | bash took every wizard default without asking. It now bootstraps through a pipe: Homebrew, git, Python, and gh, then the newest v* release in ~/Development/fireflysoftwaresolutions/devkit, GitHub sign-in over HTTPS in the browser, and the wizard reading from /dev/tty. Without a terminal it runs without questions and says how to finish. The script is wrapped in functions so a cut download runs nothing, names a failed step with the command to retry, explains each step in one plain sentence, and ends with what you have now and what to do first. --- install.sh | 409 ++++++++++++++++++++++++++++++++++++++---- tests/test_install.py | 340 +++++++++++++++++++++++++++++++++++ 2 files changed, 715 insertions(+), 34 deletions(-) create mode 100644 tests/test_install.py diff --git a/install.sh b/install.sh index 739989c..333f6b1 100755 --- a/install.sh +++ b/install.sh @@ -1,39 +1,380 @@ #!/usr/bin/env bash -# devkit bootstrap: makes sure Homebrew, Python and the GitHub CLI are present and signed in, -# then hands over to the setup wizard (`dev setup`). Safe to re-run. +# devkit installer. devkit is for macOS (Apple Silicon and Intel). # Copyright (c) 2026 Firefly Software Solutions Inc. # SPDX-License-Identifier: Apache-2.0 # -# gh repo clone fireflysoftwaresolutions/devkit ~/Development/fireflysoftwaresolutions/devkit -# ~/Development/fireflysoftwaresolutions/devkit/install.sh # interactive wizard -# ~/Development/fireflysoftwaresolutions/devkit/install.sh --yes # accept every default -# ~/Development/fireflysoftwaresolutions/devkit/install.sh --skip-packages +# On a new Mac (gets the newest release into ~/Development/fireflysoftwaresolutions/devkit): +# curl -fsSL https://raw.githubusercontent.com/fireflysoftwaresolutions/devkit/main/install.sh | bash +# curl -fsSL https://raw.githubusercontent.com/fireflysoftwaresolutions/devkit/main/install.sh | bash -s -- --yes +# From a clone of devkit: +# ./install.sh [--yes] [--skip-packages] [--no-setup] # -set -euo pipefail - -DEVKIT_HOME="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -step() { printf '\n\033[1;38;2;255;179;74m✦\033[0m \033[1m%s\033[0m\n' "$*"; } -ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; } - -[[ "$(uname -s)" == "Darwin" ]] || { echo "devkit supports macOS only"; exit 1; } - -step "Homebrew" -if ! command -v brew >/dev/null 2>&1; then - # Homebrew's official installer (https://brew.sh): the documented way to install it on a new Mac. - # nosemgrep: bash.curl.security.curl-pipe-bash.curl-pipe-bash - /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" -fi -[[ -x /opt/homebrew/bin/brew ]] && eval "$(/opt/homebrew/bin/brew shellenv)" -ok "$(brew --version | head -1)" - -step "Python and GitHub CLI" -command -v /opt/homebrew/bin/python3 >/dev/null 2>&1 || brew install python -command -v gh >/dev/null 2>&1 || brew install gh -ok "$(/opt/homebrew/bin/python3 --version) · $(gh --version | head -1)" -if ! gh auth status >/dev/null 2>&1; then - gh auth login -h github.com -s repo,read:org,workflow,gist -fi -ok "$(gh api user --jq '"GitHub: \(.login)"')" - -step "devkit setup wizard" -exec /opt/homebrew/bin/python3 "$DEVKIT_HOME/bin/dev" setup "$@" +# It makes sure Homebrew, git, Python, and the GitHub CLI are present, gets devkit, signs you in to GitHub, and +# starts the setup wizard (`dev setup`). Safe to run again. `install.sh --help` lists the options. +# +# Everything is inside functions, and only the last line runs them: a download cut short runs nothing. + +usage() { + cat <&2; } +die() { fail "$*"; exit 1; } +tildify() { case "$1" in "$HOME"/*) printf '~%s\n' "${1#"$HOME"}" ;; *) printf '%s\n' "$1" ;; esac; } + +on_error() { + local rc=$? + # A failure inside $(…) or a pipeline also fails the command that ran it: report it once, from there. + [ "${BASH_SUBSHELL:-0}" -eq 0 ] || exit "$rc" + trap - ERR + printf '\n' >&2 + fail "The step \"$STEP\" failed (exit code $rc). Nothing after it ran." + printf ' Fix the problem shown above, then run the installer again (it is safe to repeat):\n %s\n\n' \ + "$RETRY" >&2 + exit "$rc" +} + +# Run a command that may ask questions: from the terminal, also when this script arrives through a pipe; with +# no input at all when there is no terminal. +with_tty() { + case "$TTY" in + stdin) "$@" ;; + /dev/tty) "$@" A || (a == A && (b > B || (b == B && c > C)))) { A = a; B = b; C = c; T = $0; n = 1 } + } END { if (n) print T }' +} + +# Where the checkout is: its release tag, its branch, or its commit. +describe() { + local b t + if b="$(git -C "$DEVKIT_HOME" symbolic-ref -q --short HEAD)"; then + printf 'branch %s\n' "$b" + return 0 + fi + t="$(git -C "$DEVKIT_HOME" tag --points-at HEAD | newest_release)" + if [ -n "$t" ]; then printf '%s\n' "$t"; else git -C "$DEVKIT_HOME" rev-parse --short HEAD; fi +} + +# --------------------------------------------------------------------------- steps + +check_mac() { + step "This Mac" "devkit runs on macOS; this checks the system before anything changes." + local os arch ver + os="$(uname -s)" + if [ "$os" != Darwin ]; then + fail "This computer runs $os. devkit is for macOS only (Apple Silicon and Intel); nothing was changed." + exit 1 + fi + arch="$(uname -m)" + case "$arch" in arm64) arch="Apple Silicon" ;; x86_64) arch="Intel" ;; esac + ver="$(sw_vers -productVersion 2>/dev/null || true)" + ok "macOS${ver:+ $ver} on $arch" +} + +# Homebrew lives in /opt/homebrew on Apple Silicon and in /usr/local on Intel. +find_brew() { + local b + if command -v brew >/dev/null 2>&1; then command -v brew; return 0; fi + for b in /opt/homebrew/bin/brew /usr/local/bin/brew; do + if [ -x "$b" ]; then printf '%s\n' "$b"; return 0; fi + done + return 1 +} + +ensure_homebrew() { + step "Homebrew" "Homebrew is the standard installer for developer tools on a Mac; devkit uses it for everything else." + local brew_bin installer on_path=1 v + command -v brew >/dev/null 2>&1 || on_path=0 + if ! brew_bin="$(find_brew)"; then + note "Homebrew is not installed: running its official installer (https://brew.sh)." + note "It asks for the password you use to log in to this Mac. Nothing appears while you type it; that is normal." + [ -n "$TTY" ] || export NONINTERACTIVE=1 + installer="$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + with_tty /bin/bash -c "$installer" + brew_bin="$(find_brew)" || die "Homebrew's installer finished, but brew is in neither /opt/homebrew nor /usr/local." + fi + eval "$("$brew_bin" shellenv)" + BREW_PREFIX="$(brew --prefix)" + [ "$on_path" = 1 ] || add_brew_to_zprofile "$brew_bin" + v="$(brew --version)" + ok "${v%%$'\n'*} in $BREW_PREFIX" +} + +# The step Homebrew's installer asks you to do by hand, so that new terminals find brew and what it installs. +# /usr/local/bin (Intel) is on the default PATH of macOS already. +add_brew_to_zprofile() { + local profile="$HOME/.zprofile" + case "$1" in /usr/local/*) return 0 ;; esac + if [ -f "$profile" ] && grep -qF "brew shellenv" "$profile"; then return 0; fi + # shellcheck disable=SC2016 # the line is written as is, for zsh to run in each new terminal + printf '\neval "$(%s shellenv)"\n' "$1" >>"$profile" + ok "new terminals find Homebrew too (added to ~/.zprofile)" +} + +ensure_tools() { + step "git, Python, and the GitHub CLI" \ + "git keeps the history of your code, Python runs devkit, and the GitHub CLI connects this Mac to GitHub." + local v + git --version >/dev/null 2>&1 || with_tty brew install git + PY="$BREW_PREFIX/bin/python3" + [ -x "$PY" ] || with_tty brew install python + command -v gh >/dev/null 2>&1 || with_tty brew install gh + ok "$(git --version)" + ok "$("$PY" --version) ($(tildify "$PY"))" + v="$(gh --version)" + ok "${v%%$'\n'*}" +} + +get_devkit() { + step "devkit" "devkit itself: the newest release, in your Development folder." + local ref where + where="$(tildify "$DEVKIT_HOME")" + if [ -e "$DEVKIT_HOME/.git" ]; then + [ -f "$DEVKIT_HOME/bin/dev" ] || die "$where is a git checkout, but not of devkit. Choose another folder with DEVKIT_HOME." + update_existing "$where" + return 0 + fi + if [ -e "$DEVKIT_HOME" ] && [ -n "$(ls -A "$DEVKIT_HOME")" ]; then + die "$where exists and is not a git checkout, so it was left untouched. Move it away, or choose another folder with DEVKIT_HOME." + fi + ref="${DEVKIT_REF:-}" + if [ -z "$ref" ]; then + ref="$(git ls-remote --tags --refs "$DEVKIT_REPO" | sed 's#.*refs/tags/##' | newest_release)" + [ -n "$ref" ] || note "$DEVKIT_REPO has no release tag yet: using its default branch" + fi + mkdir -p "$(dirname "$DEVKIT_HOME")" + if [ -n "$ref" ]; then + git -c advice.detachedHead=false clone --quiet --branch "$ref" "$DEVKIT_REPO" "$DEVKIT_HOME" + else + git clone --quiet "$DEVKIT_REPO" "$DEVKIT_HOME" + fi + ok "downloaded $(describe) to $where" +} + +# An existing checkout at a release moves to the newest release (or to DEVKIT_REF); anything else stays as it is. +update_existing() { + local where="$1" branch current target + if branch="$(git -C "$DEVKIT_HOME" symbolic-ref -q --short HEAD)"; then + note "$where is on branch $branch: left as it is (dev update pulls the branch)" + return 0 + fi + if [ -n "$(git -C "$DEVKIT_HOME" status --porcelain --untracked-files=no)" ]; then + note "$where has local changes: left as it is" + return 0 + fi + current="$(git -C "$DEVKIT_HOME" tag --points-at HEAD | newest_release)" + if [ -z "$current" ] && [ -z "${DEVKIT_REF:-}" ]; then + note "$where is at commit $(describe), not at a release: left as it is" + return 0 + fi + git -C "$DEVKIT_HOME" fetch --quiet --tags + target="${DEVKIT_REF:-}" + if [ -z "$target" ]; then + target="$(git -C "$DEVKIT_HOME" tag --list 'v*' | newest_release)" + if [ "$(printf '%s\n%s\n' "$current" "$target" | newest_release)" = "$current" ]; then + ok "$where is at the newest release, $current" + return 0 + fi + fi + git -C "$DEVKIT_HOME" -c advice.detachedHead=false checkout --quiet "$target" + ok "$where moved from ${current:-its commit} to $(describe)" +} + +use_checkout() { + step "devkit" "devkit itself: the checkout this script is in." + ok "this checkout: $(tildify "$DEVKIT_HOME") ($(describe))" +} + +# Sets GITHUB=0 when the GitHub CLI is not signed in and there is no terminal to sign in from. +sign_in_github() { + step "GitHub sign-in" \ + "devkit keeps your project notes in a private GitHub repository and saves your work under your name." + local login + GITHUB=1 + if ! gh auth status >/dev/null 2>&1; then + if [ -z "$TTY" ]; then + note "no terminal to sign in from: skipped" + GITHUB=0 + return 0 + fi + note "Press Enter at each question: your browser opens GitHub. Type the code shown here, then choose Authorize." + with_tty gh auth login -h github.com -p https -w -s repo,read:org,workflow,gist + fi + login="$(gh api user --jq .login 2>/dev/null || true)" + ok "signed in to GitHub${login:+ as $login}" +} + +run_setup() { + step "Setup wizard" \ + "A few questions about how you work. Press Enter to keep the recommended answer; nothing changes until you apply." + local args=() + args=(${SETUP_ARGS[@]+"${SETUP_ARGS[@]}"}) + if [ -z "$TTY" ] && [ "$YES" = 0 ]; then + note "no terminal for questions: the wizard takes every default (--yes)" + args+=(--yes) + fi + SETUP_RC=0 + with_tty "$PY" "$DEVKIT_HOME/bin/dev" setup ${args[@]+"${args[@]}"} || SETUP_RC=$? +} + +# The agents chosen in the wizard, from its saved configuration. +agents_line() { + local names + names="$("$PY" -c 'import json, sys +a = json.load(open(sys.argv[1])).get("agents") or {} +print(" and ".join(n for k, n in (("claude", "Claude Code"), ("codex", "Codex")) if a.get(k, True)))' \ + "${DEVKIT_CONFIG:-$HOME/.config/devkit/config.json}" 2>/dev/null || true)" + printf '%s\n' "${names:-your coding agents}" +} + +summary() { + local outcome="$1" where version again + where="$(tildify "$DEVKIT_HOME")" + version="$(cat "$DEVKIT_HOME/VERSION" 2>/dev/null || printf 'unknown')" + again="$where/install.sh" + step "Summary" + printf ' devkit %s (%s) in %s\n' "$version" "$(describe)" "$where" + case "$outcome" in + ready) + printf '\n %sWhat you have now%s\n' "$C_B" "$C_OFF" + ok "devkit, and the tools for the kinds of projects you chose" + ok "$(agents_line) with devkit's shared rules and its safety guard" + ok "a private GitHub repository for your project notes, tasks, and plans" + printf '\n %sWhat to do first%s\n' "$C_B" "$C_OFF" + printf ' 1. Open a new Terminal window (or run: exec zsh), so the dev command works.\n' + printf ' 2. dev doctor checks that everything works\n' + printf ' 3. dev dashboard shows your projects and tasks in the browser\n' + printf ' 4. dev new my-app, then dcd my-app, then claude (or codex) to start your first project\n' + printf ' %sLater, dev update moves devkit to the newest release.%s\n\n' "$C_DIM" "$C_OFF" + ;; + no-setup) + note "devkit is downloaded; nothing is configured yet (--no-setup)." + printf '\n %sNext%s: %s signs in to GitHub and starts the setup wizard\n\n' "$C_B" "$C_OFF" "$again" + ;; + no-github) + note "Not finished: the GitHub CLI is not signed in, and there is no terminal to sign in from." + printf '\n %sTo finish%s, in a terminal:\n' "$C_B" "$C_OFF" + printf ' 1. gh auth login -h github.com -p https -w -s repo,read:org,workflow,gist (or set GH_TOKEN)\n' + printf ' 2. %s\n\n' "$again" + ;; + *) + note "The setup wizard ended with exit code $SETUP_RC; its output above says what needs attention." + printf '\n %sNext%s: fix that, then run %s again (or dev setup in a new terminal)\n\n' "$C_B" "$C_OFF" "$again" + ;; + esac +} + +# --------------------------------------------------------------------------- main + +main() { + set -eEuo pipefail + INSTALL_URL="https://raw.githubusercontent.com/fireflysoftwaresolutions/devkit/main/install.sh" + DEFAULT_REPO="https://github.com/fireflysoftwaresolutions/devkit.git" + SETUP_ARGS=() NO_SETUP=0 YES=0 STEP="start" BREW_PREFIX="" PY="" SETUP_RC=0 GITHUB=0 + local arg src here="" + for arg in "$@"; do + case "$arg" in + -h | --help) usage; return 0 ;; + --no-setup) NO_SETUP=1 ;; + -y | --yes) YES=1; SETUP_ARGS+=("$arg") ;; + *) SETUP_ARGS+=("$arg") ;; + esac + done + colors + printf '%sdevkit installer%s · devkit is for macOS (Apple Silicon and Intel)\n' "$C_B" "$C_OFF" + + # From a clone (./install.sh): set up that checkout. Through a pipe, or as a file on its own: get devkit first. + src="${BASH_SOURCE[0]:-}" + if [ -n "$src" ] && [ -f "$src" ]; then here="$(cd "$(dirname "$src")" && pwd)"; fi + if [ -n "$here" ] && [ -f "$here/bin/dev" ] && [ -f "$here/VERSION" ]; then + MODE=local DEVKIT_HOME="$here" + RETRY="$here/install.sh${*:+ $*}" + else + MODE=bootstrap + DEVKIT_HOME="${DEVKIT_HOME:-$HOME/Development/fireflysoftwaresolutions/devkit}" + case "$DEVKIT_HOME" in /*) ;; *) DEVKIT_HOME="$PWD/$DEVKIT_HOME" ;; esac + DEVKIT_REPO="${DEVKIT_REPO:-$DEFAULT_REPO}" + RETRY="curl -fsSL $INSTALL_URL | bash${*:+ -s -- $*}" + fi + + # Questions go to the terminal, also when this script arrives through a pipe; without a terminal, nothing asks. + if [ -t 0 ]; then TTY=stdin + elif (exec /dev/null; then TTY=/dev/tty + else TTY="" + fi + + trap on_error ERR + check_mac + ensure_homebrew + ensure_tools + if [ "$MODE" = local ]; then use_checkout; else get_devkit; fi + if [ "$NO_SETUP" = 1 ]; then + summary no-setup + return 0 + fi + sign_in_github + if [ "$GITHUB" = 0 ]; then + summary no-github + exit 2 + fi + run_setup + if [ "$SETUP_RC" = 0 ]; then summary ready; else summary failed; fi + exit "$SETUP_RC" +} + +main "$@" diff --git a/tests/test_install.py b/tests/test_install.py new file mode 100644 index 0000000..f6ebb12 --- /dev/null +++ b/tests/test_install.py @@ -0,0 +1,340 @@ +# Copyright (c) 2026 Firefly Software Solutions Inc. +# SPDX-License-Identifier: Apache-2.0 +"""install.sh, the one-line installer, run through a pipe as `curl … | bash` runs it: against a local repository with +release tags, with stub `brew`, `gh`, `uname` and `sw_vers` first on PATH and a temporary HOME. Nothing here touches +Homebrew, the network, ~/.claude, ~/.codex or the shell profile. The repository's `bin/dev` is a stand-in that records +how the setup wizard was started and, with the real devkit library, whether the wizard would ask its questions. +""" +import json +import os +import select +import shutil +import signal +import subprocess +import sys +import tempfile +import time +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +INSTALL = ROOT / "install.sh" +LIB = str(ROOT / "lib") + +GIT_ENV = dict(os.environ, GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_NOSYSTEM="1", GIT_AUTHOR_NAME="Ada", + GIT_AUTHOR_EMAIL="ada@example.com", GIT_COMMITTER_NAME="Ada", GIT_COMMITTER_EMAIL="ada@example.com") + +STUBS = { + "uname": """#!/bin/sh +echo "uname $*" >> "$STUB_LOG" +case "$1" in -m) echo arm64 ;; *) echo "${STUB_OS:-Darwin}" ;; esac +""", + "sw_vers": """#!/bin/sh +echo 26.1 +""", + "brew": """#!/bin/sh +echo "brew $*" >> "$STUB_LOG" +case "$1" in + --prefix) echo "$STUB_PREFIX" ;; + --version) printf 'Homebrew 4.6.0\\nHomebrew/homebrew-core (git revision 1)\\n' ;; + shellenv) echo "export HOMEBREW_PREFIX='$STUB_PREFIX'; export PATH='$STUB_PREFIX/bin':\\"\\$PATH\\"" ;; +esac +""", + "gh": """#!/bin/sh +if [ -t 0 ]; then input=terminal; else input=none; fi +echo "gh $* ($input)" >> "$STUB_LOG" +case "$1 $2" in + "auth status") [ -f "$STUB_GH_SIGNED_IN" ] ;; + "auth login") touch "$STUB_GH_SIGNED_IN" ;; + "api user") echo ada ;; + "--version ") echo "gh version 2.80.0 (2026-10-01)" ;; +esac +""", +} + +# The stand-in for devkit's bin/dev in the fixture repository: it records how `dev setup` was started. +DEV = """#!/usr/bin/env python3 +import json, os, sys +home = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) +rec = {"argv": sys.argv[1:], "stdin_tty": sys.stdin.isatty(), "home": home, + "python": os.environ.get("STUB_PYTHON", ""), "version": open(os.path.join(home, "VERSION")).read().strip()} +if os.environ.get("REAL_LIB"): + sys.path.insert(0, os.environ["REAL_LIB"]) + from devkit import tui, wizard + rec["wizard_asks"] = tui.available() and not wizard.Prompter("--yes" in sys.argv).yes +with open(os.environ["SETUP_LOG"], "a") as f: + f.write(json.dumps(rec) + "\\n") +""" + + +def git(cwd, *args) -> str: + return subprocess.run(["git", *args], cwd=str(cwd), check=True, capture_output=True, text=True, + env=GIT_ENV).stdout.strip() + + +def make_remote(root: Path) -> Path: + """A devkit-like repository: releases v26.11.2, v26.11.9 and v26.11.10, a newer untagged commit on main, and a + tag that is not a release.""" + repo = root / "remote" + (repo / "bin").mkdir(parents=True) + (repo / "bin" / "dev").write_text(DEV) + (repo / "bin" / "dev").chmod(0o755) + git(repo, "init", "-q", "-b", "main") + for version in ("26.11.2", "26.11.9", "26.11.10", "26.12.0-dev"): + (repo / "VERSION").write_text(version + "\n") + git(repo, "add", "-A") + git(repo, "commit", "-q", "-m", version) + if not version.endswith("-dev"): + git(repo, "tag", "v" + version) + git(repo, "tag", "nightly") + return repo + + +class Machine: + """A temporary Mac: HOME, the stubs, a Homebrew prefix with a python3 that says it is Homebrew's, the repository.""" + + def __init__(self, root: Path): + self.root, self.home, self.stubs, self.prefix = root, root / "home", root / "stubs", root / "brew" + self.devkit = self.home / "Development" / "fireflysoftwaresolutions" / "devkit" + for d in (self.home, self.stubs, self.prefix / "bin"): + d.mkdir(parents=True) + for name, body in STUBS.items(): + (self.stubs / name).write_text(body) + (self.stubs / name).chmod(0o755) + py = self.prefix / "bin" / "python3" + py.write_text(f'#!/bin/sh\nSTUB_PYTHON=homebrew exec "{sys.executable}" "$@"\n') + py.chmod(0o755) + self.remote = make_remote(root) + self.log, self.setup_log, self.signed_in = root / "calls.log", root / "setup.jsonl", root / "gh-signed-in" + + def env(self, **extra) -> dict: + env = dict(GIT_ENV, HOME=str(self.home), PATH=f"{self.stubs}:/usr/bin:/bin", TERM="xterm-256color", + LANG="en_US.UTF-8", STUB_LOG=str(self.log), STUB_PREFIX=str(self.prefix), + STUB_GH_SIGNED_IN=str(self.signed_in), SETUP_LOG=str(self.setup_log), REAL_LIB=LIB, + DEVKIT_REPO=f"file://{self.remote}", + # if a real gh or brew were ever reached (a hardcoded path), it must not prompt, update, or reach + # the network: every HTTP(S) request goes to a closed local port and fails at once + GH_PROMPT_DISABLED="1", GH_CONFIG_DIR=str(self.home / ".config" / "gh"), + HOMEBREW_NO_AUTO_UPDATE="1", **{k: "http://127.0.0.1:9" for k in ( + "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy")}) + for k in ("NO_PROXY", "no_proxy"): + env.pop(k, None) + for k in ("DEVKIT_HOME", "DEVKIT_REF", "NO_COLOR", "CI", "GH_TOKEN"): + env.pop(k, None) + env.update(extra) + return env + + def pipe(self, *args, script: bytes = None, **env): + """`curl … | bash -s -- args` with no terminal at all (a new session, as in CI) -> (exit code, output).""" + return self.run(["/bin/bash", "-s", "--", *args], INSTALL.read_bytes() if script is None else script, **env) + + def run(self, argv, script=b"", **env): + """Run in a session of its own; on a timeout, stop the whole session (a command waiting for input too).""" + p = subprocess.Popen(argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + env=self.env(**env), cwd=str(self.root), start_new_session=True) + try: + out = p.communicate(script, timeout=60)[0] + except subprocess.TimeoutExpired: + stop_session(p) + out = p.communicate()[0] + b"\n[timed out]" + return p.returncode, out.decode("utf-8", "replace") + + def calls(self) -> list: + return self.log.read_text().splitlines() if self.log.exists() else [] + + def setups(self) -> list: + return [json.loads(ln) for ln in self.setup_log.read_text().splitlines()] if self.setup_log.exists() else [] + + def release(self, path=None) -> str: + """The release tag the checkout is at ('' when none).""" + return git(path or self.devkit, "tag", "--points-at", "HEAD", "--list", "v*") + + +class BootstrapTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.m = Machine(Path(self.tmp.name)) + self.m.signed_in.touch() + + def tearDown(self): + self.tmp.cleanup() + + def test_through_a_pipe_it_downloads_the_newest_release_and_starts_the_wizard(self): + cfg = self.m.home / ".config" / "devkit" / "config.json" # what the wizard saves: here, Claude Code only + cfg.parent.mkdir(parents=True) + cfg.write_text(json.dumps({"agents": {"claude": True, "codex": False}})) + rc, out = self.m.pipe() + self.assertEqual(rc, 0, out) + self.assertEqual(self.m.release(), "v26.11.10") # numbers, not text: 10 > 9 > 2; not main's newer commit + self.assertNotEqual(subprocess.run(["git", "-C", str(self.m.devkit), "symbolic-ref", "-q", "HEAD"], + env=GIT_ENV, capture_output=True).returncode, 0) # detached at the tag + (setup,) = self.m.setups() + self.assertEqual((setup["home"], setup["version"], setup["python"]), + (str(self.m.devkit.resolve()), "26.11.10", "homebrew")) + self.assertIn("devkit is for macOS (Apple Silicon and Intel)", out.splitlines()[0]) + self.assertIn("devkit 26.11.10 (v26.11.10) in ~/Development/fireflysoftwaresolutions/devkit", out) + self.assertIn("Claude Code with devkit's shared rules", out) # what you have now: the agents you chose + for first in ("Open a new Terminal window", "dev doctor", "dev dashboard", "then claude (or codex)"): + self.assertIn(first, out.split("What to do first")[1]) + self.assertFalse([c for c in self.m.calls() if c.startswith("brew install")]) + + def test_without_a_terminal_the_wizard_takes_every_default(self): + rc, out = self.m.pipe("--skip-packages") + self.assertEqual(rc, 0, out) + (setup,) = self.m.setups() + self.assertEqual(setup["argv"], ["setup", "--skip-packages", "--yes"]) + self.assertEqual((setup["stdin_tty"], setup["wizard_asks"]), (False, False)) + + def test_without_a_terminal_it_skips_github_sign_in_and_says_how_to_finish(self): + self.m.signed_in.unlink() + rc, out = self.m.pipe() + self.assertEqual(rc, 2, out) + self.assertNotIn("gh auth login", "\n".join(self.m.calls())) + self.assertEqual(self.m.setups(), []) + self.assertIn("gh auth login -h github.com -p https -w -s repo,read:org,workflow,gist", out) + self.assertIn("~/Development/fireflysoftwaresolutions/devkit/install.sh", out) + self.assertEqual(self.m.release(), "v26.11.10") # devkit is downloaded all the same + + def test_a_download_cut_short_runs_nothing(self): + script = INSTALL.read_bytes() + for fraction in (0.2, 0.4, 0.6, 0.8, 0.95): + self.m.pipe(script=script[:int(len(script) * fraction)]) + self.assertEqual(self.m.calls(), [], f"cut at {fraction:.0%} of the script") + self.assertFalse(self.m.devkit.exists()) + self.assertEqual(self.m.setups(), []) + + def test_a_failed_step_is_named_with_the_command_to_run_again(self): + rc, out = self.m.pipe("--yes", DEVKIT_REPO=f"file://{self.m.root}/missing") + self.assertNotEqual(rc, 0) + self.assertIn('The step "devkit" failed', out) + self.assertIn("curl -fsSL https://raw.githubusercontent.com/fireflysoftwaresolutions/devkit/main/install.sh" + " | bash -s -- --yes", out) + self.assertEqual(out.count("failed (exit code"), 1) + self.assertEqual(self.m.setups(), []) + + def test_outside_macos_it_stops_before_changing_anything(self): + rc, out = self.m.pipe(STUB_OS="Linux") + self.assertEqual(rc, 1) + self.assertIn("This computer runs Linux. devkit is for macOS only (Apple Silicon and Intel)", out) + self.assertEqual([c for c in self.m.calls() if not c.startswith("uname")], []) + self.assertFalse(self.m.devkit.exists()) + + def test_help_lists_the_options_and_the_environment(self): + rc, out = self.m.pipe("--help") + self.assertEqual(rc, 0) + for word in ("--yes", "--skip-packages", "--no-setup", "DEVKIT_HOME", "DEVKIT_REPO", "DEVKIT_REF", + "| bash -s -- [options]", "devkit is for macOS (Apple Silicon and Intel)"): + self.assertIn(word, out) + self.assertEqual(self.m.calls(), []) + + def test_devkit_ref_and_devkit_home_choose_the_version_and_the_folder(self): + where = self.m.root / "elsewhere" / "devkit" + rc, out = self.m.pipe("--no-setup", DEVKIT_REF="v26.11.9", DEVKIT_HOME=str(where)) + self.assertEqual(rc, 0, out) + self.assertEqual(self.m.release(where), "v26.11.9") + self.assertFalse(self.m.devkit.exists()) + self.assertEqual(self.m.setups(), []) # --no-setup + + +class ExistingCheckoutTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.m = Machine(Path(self.tmp.name)) + self.m.signed_in.touch() + self.m.devkit.parent.mkdir(parents=True) + + def tearDown(self): + self.tmp.cleanup() + + def test_a_checkout_on_a_branch_is_left_alone(self): + git(self.m.root, "clone", "-q", str(self.m.remote), str(self.m.devkit)) + before = git(self.m.devkit, "rev-parse", "HEAD") + rc, out = self.m.pipe("--no-setup") + self.assertEqual(rc, 0, out) + self.assertEqual((git(self.m.devkit, "rev-parse", "HEAD"), git(self.m.devkit, "branch", "--show-current")), + (before, "main")) + self.assertIn("is on branch main: left as it is", out) + + def test_a_checkout_at_an_older_release_moves_to_the_newest(self): + git(self.m.root, "clone", "-q", "--branch", "v26.11.2", str(self.m.remote), str(self.m.devkit)) + rc, out = self.m.pipe("--no-setup") + self.assertEqual(rc, 0, out) + self.assertEqual(self.m.release(), "v26.11.10") + self.assertIn("moved from v26.11.2 to v26.11.10", out) + + def test_a_folder_that_is_not_a_checkout_is_never_touched(self): + self.m.devkit.mkdir() + (self.m.devkit / "notes.txt").write_text("mine") + rc, out = self.m.pipe() + self.assertNotEqual(rc, 0) + self.assertEqual(sorted(p.name for p in self.m.devkit.iterdir()), ["notes.txt"]) + self.assertEqual((self.m.devkit / "notes.txt").read_text(), "mine") + self.assertIn("is not a git checkout, so it was left untouched", out) + self.assertEqual(self.m.setups(), []) + + def test_from_a_clone_it_sets_up_that_clone_with_homebrews_python(self): + clone = self.m.root / "work" / "devkit" + git(self.m.root, "clone", "-q", str(self.m.remote), str(clone)) + shutil.copy(INSTALL, clone / "install.sh") + rc, out = self.m.run(["/bin/bash", str(clone / "install.sh"), "--skip-packages"]) + self.assertEqual(rc, 0, out) + (setup,) = self.m.setups() + self.assertEqual((setup["home"], setup["python"], setup["argv"]), + (str(clone.resolve()), "homebrew", ["setup", "--skip-packages", "--yes"])) + self.assertFalse(self.m.devkit.exists()) + + +def stop_session(proc): + """Kill a process started with start_new_session=True and everything it started.""" + try: + os.killpg(proc.pid, signal.SIGKILL) + except OSError: + pass + proc.wait(10) + + +def _take_terminal(): + """In the child, after setsid: make the pseudo-terminal on stdout the controlling terminal (/dev/tty).""" + import fcntl + import termios + fcntl.ioctl(1, termios.TIOCSCTTY, 0) + + +@unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "needs a POSIX pseudo-terminal") +class TerminalTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.m = Machine(Path(self.tmp.name)) + + def tearDown(self): + self.tmp.cleanup() + + def test_through_a_pipe_in_a_terminal_sign_in_and_the_wizard_ask_there(self): + import pty + master, slave = pty.openpty() + proc = subprocess.Popen(["/bin/bash", "-s", "--"], stdin=subprocess.PIPE, stdout=slave, stderr=slave, + env=self.m.env(), cwd=str(self.m.root), start_new_session=True, + preexec_fn=_take_terminal) + os.close(slave) + self.addCleanup(lambda: proc.poll() is None and stop_session(proc)) + proc.stdin.write(INSTALL.read_bytes()) + proc.stdin.close() + out, deadline = b"", time.monotonic() + 60 + while time.monotonic() < deadline: + ready = select.select([master], [], [], 0.2)[0] + try: + chunk = os.read(master, 65536) if ready else b"" + except OSError: # the terminal closed: every process that had it has exited + break + out += chunk + if not chunk and proc.poll() is not None: + break + os.close(master) + self.assertEqual(proc.wait(10), 0, out.decode("utf-8", "replace")) + self.assertIn("gh auth login -h github.com -p https -w -s repo,read:org,workflow,gist (terminal)", self.m.calls()) + (setup,) = self.m.setups() + self.assertEqual((setup["argv"], setup["stdin_tty"], setup["wizard_asks"]), (["setup"], True, True)) + + +if __name__ == "__main__": + unittest.main() From 7a393a858b77b4ac002d052c7d57b6ea37b97f11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Fri, 6 Nov 2026 09:53:53 -0800 Subject: [PATCH 009/297] feat(setup): explain the first run and name the sign-in fix A first-time user met the handoff repository with no word on what it is, line prompts that took every default silently when stdin was not a terminal, and a GitHub sign-in error that printed only part of the command. The welcome screen now says what devkit and the handoff repository are, the line prompts say when they take the defaults, and the sign-in error prints the exact gh auth login command, which now uses HTTPS and the browser so it asks no protocol or token questions. --- lib/devkit/wizard.py | 17 ++++++--- tests/test_install_wizard.py | 70 ++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 tests/test_install_wizard.py diff --git a/lib/devkit/wizard.py b/lib/devkit/wizard.py index 0d3bc2f..0aca16d 100644 --- a/lib/devkit/wizard.py +++ b/lib/devkit/wizard.py @@ -37,7 +37,8 @@ CODEX_MODES = {"full": ("never", "danger-full-access", "no prompts, full access; the devkit guard protects (recommended)"), "workspace": ("on-request", "workspace-write", "sandboxed to the project; asks when it needs more")} CLAUDE_INSTALLER = "curl -fsSL https://claude.ai/install.sh | bash" # Claude Code's official native installer -GH_LOGIN = ["gh", "auth", "login", "-h", "github.com", "-s", "repo,read:org,workflow,gist"] +# HTTPS with the browser: no questions about protocols or tokens, and git signs in through gh. +GH_LOGIN = ["gh", "auth", "login", "-h", "github.com", "-p", "https", "-w", "-s", "repo,read:org,workflow,gist"] class Answers(dict): @@ -169,6 +170,9 @@ def _run_lines(assume_yes=False, skip_packages=False) -> int: f"[brackets]; nothing changes until you confirm.") d = detect() p = Prompter(assume_yes) + if p.yes and not assume_yes: + print(f" {ui.WARN} No terminal to answer from, so every question takes its default (as with --yes). " + f"To choose, run {ui.bold('dev setup')} in a terminal.") cfg = core.deep_merge(core.DEFAULTS, d["config"] or {}) p.section("This machine") @@ -183,7 +187,8 @@ def _run_lines(assume_yes=False, skip_packages=False) -> int: print(f" {('Claude Code' if k == 'claude' else 'Codex'):<12} {state}" + (f" · {x.get('version')}" if x.get("version") else "") + (f" · plan {x.get('plan')}" if x.get("plan") else "")) if not d["gh"].get("login"): - print(f"\n {ui.FAIL} Sign in to GitHub first: gh auth login -s repo,read:org,workflow,gist") + print(f"\n {ui.FAIL} devkit needs your GitHub account, and you are not signed in. Run " + f"{' '.join(GH_LOGIN)}, then dev setup again.") return 2 a = Answers() @@ -674,8 +679,8 @@ def run(self) -> int: self.term.write(f"\n Nothing changed. Run {tui.bold('dev setup')} again when you are ready.\n\n") return 1 if outcome == "no-github": - self.term.write(f"\n {tui.bad('✗')} Sign in to GitHub first: {tui.bold(' '.join(GH_LOGIN[:3]))} " - f"-s repo,read:org,workflow,gist\n\n") + self.term.write(f"\n {tui.bad('✗')} devkit needs your GitHub account, and you are not signed in. Run " + f"{tui.bold(' '.join(GH_LOGIN))}, then {tui.bold('dev setup')} again.\n\n") return 2 return self.apply() @@ -782,6 +787,10 @@ def welcome(self) -> list: lines = self.banner() + [""] lines.append(f" {tui.bold(ui.greeting(name))} " + ("Let's set up devkit on this Mac." if first else "devkit is already set up here.")) + if first: + lines += [" devkit sets up Claude Code and Codex with shared rules and a safety guard,", + " and keeps your project notes, tasks, and plans in a private GitHub", + " repository: your handoff repository."] lines.append(" " + tui.dim("Nothing changes until you apply the plan at the end." if first else "Your saved choices are the starting point; nothing changes until you apply.")) lines += ["", " " + tui.bold("On this Mac")] diff --git a/tests/test_install_wizard.py b/tests/test_install_wizard.py new file mode 100644 index 0000000..d3fe236 --- /dev/null +++ b/tests/test_install_wizard.py @@ -0,0 +1,70 @@ +# Copyright (c) 2026 Firefly Software Solutions Inc. +# SPDX-License-Identifier: Apache-2.0 +"""`dev setup` as a first-time user meets it after the installer: the line prompts say that they take every default +when no terminal can answer, a missing GitHub sign-in names the exact command that fixes it, and the first welcome +screen says what devkit does and what the handoff repository is. Detection and commands are stubbed; the config goes +to a temporary folder. +""" +import io +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parent / "lib")) +sys.path.insert(0, str(HERE)) + +from devkit import core, providers, toolchain, tui, ui, wizard # noqa: E402 +from test_wizard import FakeTerm, fake_survey # noqa: E402 + +GH_LOGIN = "gh auth login -h github.com -p https -w -s repo,read:org,workflow,gist" + + +class _Stdin(io.StringIO): + def isatty(self): + return False + + +def line_prompts(assume_yes=False, login="ada"): + """`dev setup` through the line prompts with stdin not a terminal -> (exit code, output without colors).""" + user = {"login": login, "name": "Ada", "id": 1} if login else {} + with tempfile.TemporaryDirectory() as d: + with mock.patch.object(wizard, "CONFIG_FILE", Path(d) / "config.json"), \ + mock.patch.object(wizard, "_hw", lambda: (12, 36, "26.1", "arm64")), \ + mock.patch.object(core, "gh_user", lambda: user), \ + mock.patch.object(core, "have", lambda t: t in ("gh", "brew")), \ + mock.patch.object(providers, "status", lambda include_account=True: { + "claude": {"installed": True, "logged_in": True}, "codex": {"installed": True, "logged_in": True}}), \ + mock.patch.object(toolchain, "detect", lambda: {"hosts": ["github"], "clouds": [], "evidence": {}}), \ + mock.patch("subprocess.call", lambda argv, **kw: 0), \ + mock.patch("sys.stdin", _Stdin()), mock.patch("sys.stdout", io.StringIO()) as out: + rc = wizard.run(assume_yes=assume_yes, skip_packages=True) + return rc, ui.ANSI_RE.sub("", out.getvalue()) + + +class FirstRunTests(unittest.TestCase): + def test_line_prompts_say_they_take_the_defaults_when_no_terminal_can_answer(self): + rc, text = line_prompts() + self.assertEqual(rc, 0) + self.assertIn("No terminal to answer from, so every question takes its default (as with --yes)", text) + + def test_without_github_sign_in_it_names_the_exact_command(self): + rc, text = line_prompts(assume_yes=True, login="") + self.assertEqual(rc, 2) + self.assertIn(f"devkit needs your GitHub account, and you are not signed in. Run {GH_LOGIN}, then dev setup", + text) + + def test_the_first_welcome_says_what_devkit_and_the_handoff_repository_are(self): + w = wizard._Wizard(FakeTerm()) + w.d = fake_survey() + text = " ".join(tui.strip(ln).strip() for ln in w.welcome()) + self.assertIn("devkit sets up Claude Code and Codex with shared rules and a safety guard", text) + self.assertIn("in a private GitHub repository: your handoff repository.", text) + w.d = fake_survey(saved={"dev_root": "~/Development"}) + self.assertNotIn("your handoff repository.", " ".join(tui.strip(ln) for ln in w.welcome())) + + +if __name__ == "__main__": + unittest.main() From e491d2b07e44707681931d2c26d3990ed1e3cb3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Fri, 6 Nov 2026 09:53:56 -0800 Subject: [PATCH 010/297] feat(update): follow release tags in dev update The installer leaves devkit detached at the newest release tag, where git pull --ff-only fails. dev update now fetches the tags and checks out the newest vYY.MM.PATCH (compared as numbers, so 26.11.10 beats 26.11.2) before dev install, says so when it is already newest, keeps git pull --ff-only for branch checkouts, and refuses with nothing changed when tracked files have local changes or HEAD is neither a release nor a branch. --- lib/devkit/cli.py | 54 ++++++++++++++++++-- tests/test_update.py | 115 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+), 5 deletions(-) create mode 100644 tests/test_update.py diff --git a/lib/devkit/cli.py b/lib/devkit/cli.py index 5dce048..4e017a0 100644 --- a/lib/devkit/cli.py +++ b/lib/devkit/cli.py @@ -93,7 +93,7 @@ ("plugin validate", "Validate the devkit plugin with Claude Code's validator and Codex's parser"), ("desktop pack|status", "Claude Desktop extension: build the .mcpb bundle, check what is installed"), ("install [--only step]", "Apply the devkit configuration (idempotent)"), - ("update", "Pull the latest devkit and re-apply it"), + ("update", "Move devkit to the newest release (or pull its branch) and re-apply it"), ("docker up|down|status|prune", "Run Docker on a Colima VM"), ("version", "Show the devkit version"), ]), @@ -827,11 +827,55 @@ def cmd_install(a): return 0 +RELEASE_TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$") + + +def newest_release(tags): + """The newest release tag (vYY.MM.PATCH) among `tags`, compared as numbers: v26.11.10 is newer than v26.11.2. + None when no tag is a release (other tags, such as v26.12.0-rc1 or nightly, are not).""" + found = [(tuple(int(x) for x in m.groups()), t) for t in tags if (m := RELEASE_TAG_RE.match(t.strip()))] + return max(found)[1] if found else None + + +def update_checkout(home: Path): + """Bring the devkit checkout at `home` up to date -> (exit code, message, whether to run `dev install`). + + Detached at a release tag (how the installer leaves it): fetch the tags and check out the newest release when it + is newer. On a branch: `git pull --ff-only`. Refused, with nothing changed, when tracked files have local changes + or HEAD is detached somewhere that is not a release.""" + where = tilde(home) + rc, dirty, err = core.git(home, "status", "--porcelain", "--untracked-files=no") + if rc != 0: + return rc, f"{where} is not a git checkout: {err}", False + if dirty: + return 1, (f"{where} has local changes, so nothing was updated. Commit or discard them first " + f"(git -C {where} status)."), False + branch = core.gitout(home, "symbolic-ref", "-q", "--short", "HEAD") + if branch: + rc, out, err = core.git(home, "pull", "--ff-only", timeout=120) + return rc, f"{branch}: {out or err}", rc == 0 + current = newest_release(core.gitout(home, "tag", "--points-at", "HEAD").split()) + if not current: + at = core.gitout(home, "rev-parse", "--short", "HEAD") + return 1, (f"{where} is at commit {at}, which is neither a release nor a branch, so nothing was updated. " + f"Check out a release (git -C {where} checkout ) or a branch, then run dev update."), False + rc, out, err = core.git(home, "fetch", "--quiet", "--tags", timeout=120) + if rc != 0: + return rc, f"git fetch --tags failed: {err or out}", False + newest = newest_release(core.gitout(home, "tag", "--list", "v*").split() + [current]) + if newest == current: + return 0, f"already at the newest release, {current}", False + rc, out, err = core.git(home, "-c", "advice.detachedHead=false", "checkout", "--quiet", newest, timeout=60) + if rc != 0: + return rc, f"git checkout {newest} failed: {err or out}", False + return 0, f"{current} → {newest}", True + + def cmd_update(a): print(f"{ui.ARROW} updating {tilde(core.DEVKIT_HOME)}") - rc, out, err = core.git(core.DEVKIT_HOME, "pull", "--ff-only", timeout=120) - print(f" {ui.OK if rc == 0 else ui.FAIL} {out or err}") - if rc != 0: + rc, message, install = update_checkout(core.DEVKIT_HOME) + print(f" {ui.OK if rc == 0 else ui.FAIL} {message}") + if rc != 0 or not install: return rc os.execv(sys.executable, [sys.executable, str(core.DEVKIT_HOME / "bin" / "dev"), "install"]) @@ -2489,7 +2533,7 @@ def add(name, fn, help_, **kw): p = add("setup", cmd_setup, "Installation and configuration wizard") p.add_argument("-y", "--yes", action="store_true", help="accept every default (non-interactive)") p.add_argument("--skip-packages", action="store_true", help="do not install Homebrew/npm packages") - add("update", cmd_update, "Update devkit") + add("update", cmd_update, "Update devkit: the newest release, or a pull of its branch") add("docker", cmd_docker, "Docker on Colima").add_argument( "action", choices=["up", "down", "status", "prune", "autostart-on", "autostart-off"]) add("version", cmd_version, "Show version") diff --git a/tests/test_update.py b/tests/test_update.py new file mode 100644 index 0000000..72098f5 --- /dev/null +++ b/tests/test_update.py @@ -0,0 +1,115 @@ +# Copyright (c) 2026 Firefly Software Solutions Inc. +# SPDX-License-Identifier: Apache-2.0 +"""`dev update`: the choice of the newest release tag, and the update of a checkout that is detached at a release, +on a branch, detached elsewhere, or has local changes, against temporary repositories (no network, no `dev install`). +""" +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "lib")) + +from devkit import cli, core # noqa: E402 + +ISOLATED = {"GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_NOSYSTEM": "1"} +GIT_ENV = dict(os.environ, GIT_AUTHOR_NAME="Ada", GIT_AUTHOR_EMAIL="ada@example.com", GIT_COMMITTER_NAME="Ada", + GIT_COMMITTER_EMAIL="ada@example.com", **ISOLATED) + + +def git(cwd, *args) -> str: + return subprocess.run(["git", *args], cwd=str(cwd), check=True, capture_output=True, text=True, + env=GIT_ENV).stdout.strip() + + +def commit(repo: Path, version: str, tag: bool = True): + (repo / "VERSION").write_text(version + "\n") + git(repo, "add", "-A") + git(repo, "commit", "-q", "-m", version) + if tag: + git(repo, "tag", "v" + version) + + +class NewestReleaseTests(unittest.TestCase): + def test_compares_the_parts_as_numbers(self): + self.assertEqual(cli.newest_release(["v26.11.2", "v26.11.10", "v26.11.9"]), "v26.11.10") + self.assertEqual(cli.newest_release(["v26.9.7", "v26.10.0"]), "v26.10.0") + self.assertEqual(cli.newest_release(["v27.1.0", "v26.12.31"]), "v27.1.0") + + def test_only_release_tags_count(self): + self.assertEqual(cli.newest_release(["v26.11.2", "v26.12.0-rc1", "nightly", "26.12.1", "v27.1"]), "v26.11.2") + self.assertIsNone(cli.newest_release(["nightly", "v26.12.0-rc1"])) + self.assertIsNone(cli.newest_release([])) + + +class UpdateCheckoutTests(unittest.TestCase): + """A remote with releases, and a clone of it as the devkit checkout.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + root = Path(self.tmp.name) + self.remote, self.home = root / "remote", root / "devkit" + self.remote.mkdir() + git(self.remote, "init", "-q", "-b", "main") + commit(self.remote, "26.11.2") + env = mock.patch.dict(core.GIT_ENV, ISOLATED) + env.start() + self.addCleanup(env.stop) + + def tearDown(self): + self.tmp.cleanup() + + def clone(self, *args): + git(self.remote.parent, "clone", "-q", *args, str(self.remote), str(self.home)) + + def release(self) -> str: + return git(self.home, "tag", "--points-at", "HEAD") + + def test_at_a_release_it_moves_to_the_newest_release(self): + self.clone("--branch", "v26.11.2") + commit(self.remote, "26.11.9") + commit(self.remote, "26.11.10") + commit(self.remote, "26.12.0-dev", tag=False) + rc, message, install = cli.update_checkout(self.home) + self.assertEqual((rc, install), (0, True), message) + self.assertEqual(self.release(), "v26.11.10") + self.assertEqual(message, "v26.11.2 → v26.11.10") + + def test_at_the_newest_release_it_says_so_and_installs_nothing(self): + self.clone("--branch", "v26.11.2") + rc, message, install = cli.update_checkout(self.home) + self.assertEqual((rc, message, install), (0, "already at the newest release, v26.11.2", False)) + self.assertEqual(self.release(), "v26.11.2") + + def test_on_a_branch_it_pulls_the_branch(self): + self.clone() + commit(self.remote, "26.12.0-dev", tag=False) + rc, message, install = cli.update_checkout(self.home) + self.assertEqual((rc, install), (0, True), message) + self.assertEqual(git(self.home, "rev-parse", "HEAD"), git(self.remote, "rev-parse", "HEAD")) + self.assertEqual(git(self.home, "branch", "--show-current"), "main") + + def test_local_changes_are_refused_and_nothing_moves(self): + self.clone("--branch", "v26.11.2") + commit(self.remote, "26.11.3") + (self.home / "VERSION").write_text("mine\n") + rc, message, install = cli.update_checkout(self.home) + self.assertEqual((rc, install), (1, False)) + self.assertIn("has local changes, so nothing was updated", message) + self.assertEqual(self.release(), "v26.11.2") + self.assertEqual((self.home / "VERSION").read_text(), "mine\n") + + def test_detached_away_from_a_release_is_refused(self): + commit(self.remote, "26.12.0-dev", tag=False) + self.clone() + git(self.home, "checkout", "-q", "--detach", "HEAD") + rc, message, install = cli.update_checkout(self.home) + self.assertEqual((rc, install), (1, False)) + self.assertIn("neither a release nor a branch, so nothing was updated", message) + + +if __name__ == "__main__": + unittest.main() From e97006c0f181203e0d40527343b37e5996be9121 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Fri, 6 Nov 2026 09:54:00 -0800 Subject: [PATCH 011/297] docs(install): document the one-line install and release updates README leads with the curl one-liner and says devkit is macOS only (Apple Silicon and Intel), with how to read the script first, the options and variables, and a link to what it changes. Getting started adds a step-by-step first install for people who are not developers, the installer reference, the no-terminal behavior, and the new rows of what devkit touches; Troubleshooting covers a failed step, no terminal, the Intel and Apple Silicon Homebrew paths, an existing checkout, and dev update refusals; Commands describes dev update by checkout state. --- CHANGELOG.md | 24 +++++++ README.md | 38 +++++++++-- docs/commands.md | 9 ++- docs/getting-started.md | 141 +++++++++++++++++++++++++++++++++++----- docs/troubleshooting.md | 52 +++++++++++++++ 5 files changed, 239 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e214c57..2d0b2a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ All notable changes to devkit. Versions follow CalVer `YY.MM.PATCH` (PATCH resta ### Added +- **A one-line install:** `curl -fsSL https://raw.githubusercontent.com/fireflysoftwaresolutions/devkit/main/install.sh | bash` + installs Homebrew, git, Python, and the GitHub CLI when they are missing, clones the newest devkit release into + `~/Development/fireflysoftwaresolutions/devkit`, signs you in to GitHub, and starts the setup wizard. Its questions + come from the terminal even through the pipe; without a terminal (CI), it runs without questions and says how to + finish. It says first that devkit is for macOS (Apple Silicon and Intel) and stops on any other system. New: + `--no-setup`, `--help`, and the `DEVKIT_HOME`, `DEVKIT_REPO`, and `DEVKIT_REF` variables. Each step says in one + plain sentence what it does and why; a failed step is named, with the command to run again; and the run ends with + what you have now and what to do first. +- **A first install for people who are not developers:** a step-by-step guide in Getting started, with what you + see at each step. Pressing Return at every question gives a working setup: the GitHub sign-in uses HTTPS and the + browser, so it asks no questions about protocols or tokens, and the wizard's first screen says what devkit and the + handoff repository are. - The PostToolUse hook shows the status block right after a call that usually ends a turn (a subagent launch, a monitor, a background command, or a closing `dev` command), with the project and branch filled in. A note written after the last tool call no longer lands above the block, so the Stop hook does not have to send @@ -21,6 +33,18 @@ All notable changes to devkit. Versions follow CalVer `YY.MM.PATCH` (PATCH resta so a test suite or a build in the foreground is not cut off at 2. - Codex: `analytics.enabled` is `false`. `dev doctor` also reads whether your Codex account adds commit and PR attribution (an account setting, not a `config.toml` key) from your newest session. +- `dev update` follows releases: a checkout at a release tag moves to the newest `vYY.MM.PATCH` tag (compared as + numbers, so 26.11.10 is newer than 26.11.2) and then runs `dev install`; at the newest release already, it says so + and changes nothing. A checkout on a branch still gets `git pull --ff-only`. It refuses, with nothing changed, while + tracked files have local changes or when the checkout is at a commit that is neither a release nor a branch. +- The wizard's line prompts say so when no terminal can answer and every question takes its default. Without a + GitHub sign-in, the wizard says why it needs one and prints the exact command. + +### Fixed + +- `install.sh` works on Intel Macs: it finds Homebrew in `/usr/local` as well as `/opt/homebrew`, and runs the wizard + with Homebrew's Python from `brew --prefix` instead of a fixed `/opt/homebrew/bin/python3`. On Apple Silicon, it adds + Homebrew to `~/.zprofile` when Homebrew was not on the `PATH`, so new terminals find `brew`, `gh`, and the toolchain. ## [26.11.2] - 2026-11-06 diff --git a/README.md b/README.md index 3f295ed..258b757 100644 --- a/README.md +++ b/README.md @@ -45,18 +45,42 @@ Docker and the background automation, and keeps them that way. ## Install +devkit is for macOS only (Apple Silicon and Intel). In Terminal, run: + ```bash -gh auth login -s repo,read:org,workflow,gist # once -gh repo clone fireflysoftwaresolutions/devkit ~/Development/fireflysoftwaresolutions/devkit -~/Development/fireflysoftwaresolutions/devkit/install.sh # the setup wizard; safe to run again -exec zsh && dev doctor # all green +curl -fsSL https://raw.githubusercontent.com/fireflysoftwaresolutions/devkit/main/install.sh | bash +exec zsh && dev doctor # every check passes ``` -`install.sh` makes sure Homebrew, Python, and the GitHub CLI are ready, then starts the setup wizard. The wizard asks +The installer makes sure Homebrew, git, Python, and the GitHub CLI are present, downloads the newest devkit release to +`~/Development/fireflysoftwaresolutions/devkit`, signs you in to GitHub, and starts the setup wizard. The wizard asks for your workspace, your handoff repository (yours, or one your team shares), which agents to configure, your stacks and the Docker VM, shows the plan, and applies it: the toolchain for your stacks, git signed in with your GitHub -identity, Claude Code and Codex, the devkit plugin and the curated skills in both, and the scheduled jobs. Details are in -[Getting started](docs/getting-started.md). +identity, Claude Code and Codex, the devkit plugin and the curated skills in both, and the scheduled jobs. It is safe to +run again, and `dev update` later moves devkit to the newest release. + +To read the script before you run it: + +```bash +curl -fsSL https://raw.githubusercontent.com/fireflysoftwaresolutions/devkit/main/install.sh -o install.sh +less install.sh +bash install.sh +``` + +Pass options after `bash -s --`, for example `curl -fsSL … | bash -s -- --yes`: + +| Option or variable | Effect | +| --- | --- | +| `--yes` | Accept every default in the setup wizard | +| `--skip-packages` | Configure only; do not install the toolchain packages for your stacks | +| `--no-setup` | Get devkit and its prerequisites, but do not sign in or run the wizard | +| `DEVKIT_HOME` | Where devkit goes (default `~/Development/fireflysoftwaresolutions/devkit`) | +| `DEVKIT_REPO` | The repository to clone, for example your fork | +| `DEVKIT_REF` | A tag or branch to check out instead of the newest release | + +Set a variable for `bash`, not for `curl`: `curl -fsSL … | DEVKIT_REF=v26.11.2 bash`. Everything the installer and the +wizard change on your Mac, and how to undo each change, is listed in +[Getting started › What devkit touches](docs/getting-started.md#what-devkit-touches-and-how-to-undo-it). ## A quick tour diff --git a/docs/commands.md b/docs/commands.md index 1ddb098..cc3fe86 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1003,7 +1003,14 @@ extension), unless `packages.auto` is off. The `launchd` step installs the [sche ### `dev update` -Runs `git pull --ff-only` in the devkit repository, then `dev install`. +Updates the devkit checkout, then runs `dev install`. What it does depends on where the checkout is: + +| The checkout is | `dev update` | +| --- | --- | +| At a release tag (how the installer leaves it) | Fetches the tags and checks out the newest release, the highest `vYY.MM.PATCH` compared as numbers (`v26.11.10` is newer than `v26.11.2`); at the newest release already, it says so and stops without `dev install` | +| On a branch | `git pull --ff-only` | +| At a commit that is neither a release nor a branch | Refuses, and says how to check out a release or a branch | +| Changed: tracked files have local changes | Refuses, and changes nothing; commit or discard the changes first | ### `dev docker up | down | status | prune | autostart-on | autostart-off` diff --git a/docs/getting-started.md b/docs/getting-started.md index 8c9c5f0..5dbce8e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -5,37 +5,134 @@ share a handoff repository, and lists every file devkit touches. ## Requirements -- macOS on Apple silicon. `install.sh` installs Homebrew if it is missing. +- A Mac with Apple Silicon or Intel. devkit does not run on Linux or Windows. The installer installs Homebrew if it is + missing. - A GitHub account. devkit's repository, `fireflysoftwaresolutions/devkit`, is public; your handoff repository stays private. - A Claude account for Claude Code, a ChatGPT account for Codex, or both. +## Your first install, step by step + +This walk-through is for a first install on your own Mac, even if you have never used a terminal. It takes one +command and a few presses of Return. Have your Mac's login password and your GitHub account at hand. + +1. **Open Terminal.** Press Command-Space, type `Terminal`, and press Return. A window with a text prompt opens. +2. **Paste the install command, then press Return:** + + ```bash + curl -fsSL https://raw.githubusercontent.com/fireflysoftwaresolutions/devkit/main/install.sh | bash + ``` + + The first line says `devkit installer · devkit is for macOS (Apple Silicon and Intel)`. Each step after it starts + with `✦` and its name, then one sentence that says what the step does and why. +3. **Homebrew**, only if your Mac does not have it yet. Homebrew is the standard installer for developer tools on a + Mac, and devkit uses it for everything else. It asks for your Mac password: type it and press Return. Nothing + appears while you type; that is normal. Then press Return to continue. This step takes the longest of all, + because it also installs Apple's command line tools. +4. **git, Python, and the GitHub CLI.** Nothing to do here: each tool shows a line with `✓` when it is ready. +5. **devkit.** The installer downloads devkit into `~/Development/fireflysoftwaresolutions/devkit` and shows + `✓ downloaded v…`. +6. **GitHub sign-in.** devkit keeps your project notes in a private GitHub repository and saves your work under your + name, so it needs your GitHub account. Press Return at each question. The terminal shows a one-time code, such as + `1A2B-3C4D`, and your browser opens GitHub: sign in, type the code, and choose **Authorize**. Back in Terminal, you + see `✓ signed in to GitHub as `. +7. **The setup wizard.** A full-screen page shows what the wizard found on your Mac, with **Recommended setup** + selected: press Return. The next page is the plan, with **Apply** selected: press Return. Each item then shows a + spinner while it runs and `✓` when it is done. When the wizard offers to sign you in to Claude Code or Codex, press + Return: your browser opens, and you sign in there with your Claude or ChatGPT account. When it offers to start + Docker, press Return. +8. **The summary.** The installer ends with **What you have now** and **What to do first**. +9. **Open a new Terminal window** (Command-N), so that the `dev` command works, and run: + + ```bash + dev doctor + ``` + + Every line should show `✓`. A line with `✗` names the command that fixes it. +10. **Start your first project:** + + ```bash + dev new my-app + dcd my-app + claude + ``` + + `dev new` creates the project folder, `dcd` goes into it, and `claude` starts Claude Code there (run `codex` for + Codex). Ask it for something small, and check that its reply starts with a bold `[Firefly> DevKit]` line, which + shows that devkit is active. + +If a step fails, the installer names it and prints the command that runs it again. See +[Troubleshooting › Installing and updating](troubleshooting.md#installing-and-updating). + ## Install on a new Mac ```bash -# 1. The GitHub CLI (install.sh installs it too: brew install gh) -gh auth login -h github.com -s repo,read:org,workflow,gist - -# 2. devkit itself, in its canonical place -gh repo clone fireflysoftwaresolutions/devkit ~/Development/fireflysoftwaresolutions/devkit - -# 3. The setup wizard (safe to run again at any time) -~/Development/fireflysoftwaresolutions/devkit/install.sh +# 1. The installer: prerequisites, the newest devkit release, GitHub sign-in, and the setup wizard +curl -fsSL https://raw.githubusercontent.com/fireflysoftwaresolutions/devkit/main/install.sh | bash -# 4. Load the shell integration and verify +# 2. Load the shell integration and verify exec zsh dev doctor ``` -`install.sh` makes sure Homebrew, Python, and the GitHub CLI are present and signed in, then starts the setup wizard, -`dev setup`. Pass `--yes` to accept every default without questions, or `--skip-packages` when you only want to change -the configuration: +The installer prints each step with one sentence on what it does and why, and ends with a summary: where devkit is, +its version, what you have now, and what to do first. It is safe to run again. In order, it: + +1. Checks that the computer is a Mac, and stops on any other system before it changes anything. +2. Finds Homebrew in `/opt/homebrew` (Apple Silicon) or `/usr/local` (Intel), or installs it with Homebrew's official + installer, which asks for your password. When Homebrew was not on your `PATH`, it adds Homebrew's `shellenv` line to + `~/.zprofile`, as Homebrew's installer asks you to do, so new terminals find `brew` and what it installs. +3. Installs git, Python, and the GitHub CLI with Homebrew when they are missing. +4. Clones devkit into `~/Development/fireflysoftwaresolutions/devkit` and checks out the newest release, the highest + `vYY.MM.PATCH` tag. If devkit is already there at a release, it moves to the newest one; a checkout on a branch, with + local changes, or at another commit stays as it is. A folder there that is not a git checkout is never changed. +5. Signs you in to GitHub with the browser (`gh auth login` over HTTPS, so it asks no technical questions), unless + the GitHub CLI is signed in already. +6. Starts the [setup wizard](#the-setup-wizard), `dev setup`, which asks its questions in the terminal even though + the installer arrived through a pipe. + +To read the script before you run it, download it first: ```bash -~/Development/fireflysoftwaresolutions/devkit/install.sh --yes -~/Development/fireflysoftwaresolutions/devkit/install.sh --skip-packages +curl -fsSL https://raw.githubusercontent.com/fireflysoftwaresolutions/devkit/main/install.sh -o install.sh +less install.sh +bash install.sh ``` +### Installer options + +Pass options after `bash -s --`, and set variables for `bash`, not for `curl`: + +```bash +curl -fsSL https://raw.githubusercontent.com/fireflysoftwaresolutions/devkit/main/install.sh | bash -s -- --yes +curl -fsSL https://raw.githubusercontent.com/fireflysoftwaresolutions/devkit/main/install.sh | DEVKIT_REF=v26.11.2 bash +``` + +| Option or variable | Effect | +| --- | --- | +| `-y`, `--yes` | Accept every default in the setup wizard | +| `--skip-packages` | Configure only; do not install the toolchain packages for your stacks (Homebrew, git, Python, and the GitHub CLI are still checked) | +| `--no-setup` | Get devkit and its prerequisites, but do not sign in or run the wizard; run `install.sh` from the checkout later to finish | +| `-h`, `--help` | Print the usage, the options, and the variables | +| Any other option | Passed to `dev setup` | +| `DEVKIT_HOME` | Where devkit goes (default `~/Development/fireflysoftwaresolutions/devkit`) | +| `DEVKIT_REPO` | The repository to clone, for example your fork (default `https://github.com/fireflysoftwaresolutions/devkit.git`) | +| `DEVKIT_REF` | A tag or branch to check out instead of the newest release | +| `GH_TOKEN` | A GitHub token, read by the GitHub CLI; signs in an install that has no terminal | + +### Without a terminal + +In CI or a provisioning script, nothing can answer questions, so the installer runs without them. Homebrew's +installer runs with `NONINTERACTIVE=1` (it needs passwordless `sudo` then), and the wizard gets `--yes`. The GitHub +sign-in needs a person, or a `GH_TOKEN`: without either, the installer downloads devkit, skips the sign-in and the +wizard, says how to finish, and exits with code `2`. + +### From a clone + +When you run `install.sh` from a clone of devkit (`./install.sh`), it sets up that checkout as it is, on whatever +branch or commit it is: it does not clone, fetch, or switch anything. This is how you install a branch you are working +on (see [Developing devkit](development.md)). + ### The setup wizard In a terminal, `dev setup` is a full-screen wizard that you drive with the arrow keys. Nothing on the Mac changes until @@ -89,7 +186,8 @@ The full output of the apply steps is in `~/.local/state/devkit/logs/setup.log`, When the input or output is not a terminal (a pipe or a script), when `TERM` is `dumb`, or with `--yes`, the wizard uses plain line prompts instead: each question shows its default in brackets, and Enter keeps it. `--yes` accepts -every default without asking and writes the same configuration as earlier versions. To get the line prompts in a +every default without asking and writes the same configuration as earlier versions. When nothing can answer (the input +is not a terminal), the line prompts say so at the start and take every default, as with `--yes`. To get the line prompts in a terminal, run `TERM=dumb dev setup`. Unlike the interactive **Recommended setup**, `--yes` keeps every stack selected unless your saved configuration lists fewer. @@ -179,9 +277,15 @@ Ask the agent for a quick task in a repository and check that its final message ## Updating ```bash -dev update # git pull --ff-only in the devkit repository, then dev install +dev update # the newest release (or a pull of the branch), then dev install ``` +The installer leaves devkit at a release tag, so `dev update` follows releases: it fetches the tags and checks out the +newest release when it is newer, then runs `dev install`; when devkit is at the newest release already, it says so and +changes nothing. A checkout on a branch, which is how you work on devkit itself, gets `git pull --ff-only` instead. +`dev update` refuses to start while tracked files have local changes. Running the installer again also moves a release +checkout to the newest release. + Restart running Claude Code and Codex sessions afterward; they read instructions and hooks at session start. To change the choices you made in the wizard, run `dev setup` again. @@ -202,6 +306,9 @@ each). | `~/.codex/fast.config.toml`, `deep.config.toml`, `review.config.toml` | The Codex profiles | Delete them | | `~/.codex/agents/scout.toml` | The `scout` agent role | Delete it | | `~/.agents/skills/` | Symlinks (Codex's user skill folder) | Delete the symlinks | +| `~/Development/fireflysoftwaresolutions/devkit` | The devkit checkout (installer; `DEVKIT_HOME` chooses another folder) | Delete the folder | +| Homebrew, git, Python, and the GitHub CLI | Installed by the installer when they are missing | `brew uninstall git python gh`; Homebrew's own uninstall script | +| `~/.zprofile` | One line, `eval "$(/opt/homebrew/bin/brew shellenv)"`, added by the installer only when Homebrew was not on your `PATH` (Apple Silicon) | Delete the line | | `~/.local/bin/dev` | Symlink to `bin/dev` | Delete it | | `~/.zshrc` | The block between `# >>> devkit >>>` and `# <<< devkit <<<` | Delete the block | | `~/.gitconfig` | `user.name`, `user.email`, `include.path` (shared defaults), `core.excludesFile`, the gh credential helper | `git config --global --unset …` | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 57d777d..a68465c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -35,6 +35,58 @@ tail -50 ~/.local/state/devkit/logs/sync.log dev sync # run a sync in the foreground ``` +## Installing and updating + +### The installer stopped at a step + +The installer names the step that failed and the command to run it again; it is safe to repeat, and it skips what is +done already. The lines before the message are the failing command's own output (Homebrew's, git's, or the GitHub +CLI's). Fix that cause, then run the same command again. + +### The installer says there is no terminal + +When the installer runs from CI, a provisioning script, or another program with no terminal, nothing can answer +questions. It then installs Homebrew with `NONINTERACTIVE=1`, which needs passwordless `sudo`, and gives the setup +wizard `--yes`. It cannot sign you in to GitHub: unless the GitHub CLI is signed in already, it downloads devkit, skips +the sign-in and the wizard, and exits with code `2`. To finish, set `GH_TOKEN` to a GitHub token for that script, or +open a terminal and run: + +```bash +gh auth login -h github.com -p https -w -s repo,read:org,workflow,gist +~/Development/fireflysoftwaresolutions/devkit/install.sh +``` + +In a terminal, `curl … | bash` is interactive: the installer reads the answers from the terminal (`/dev/tty`), not +from the pipe. + +### `brew` is not found in a new terminal (Apple Silicon or Intel) + +Homebrew lives in `/opt/homebrew` on Apple Silicon and in `/usr/local` on Intel Macs. The installer finds either one. +`/usr/local/bin` is on the default `PATH` of macOS, so Intel needs nothing more. On Apple Silicon, the installer adds +`eval "$(/opt/homebrew/bin/brew shellenv)"` to `~/.zprofile` when Homebrew was not on your `PATH`; if you keep your +`PATH` in another file, add that line there, then open a new terminal. A terminal that runs under Rosetta reports +`x86_64` and uses the Intel Homebrew in `/usr/local`: open a native terminal for devkit. + +### The installer left an existing checkout as it is + +The installer moves an existing checkout only when it is at a release tag. It leaves it as it is when it is on a +branch, has local changes, or is at another commit, and says which. A branch checkout is updated with `dev update`, +which pulls the branch. To follow releases instead, check out the newest release, for example: + +```bash +git -C ~/Development/fireflysoftwaresolutions/devkit fetch --tags +git -C ~/Development/fireflysoftwaresolutions/devkit checkout v26.11.2 # the newest vYY.MM.PATCH tag +``` + +When the folder exists but is not a git checkout, the installer stops without touching it: move the folder away, or +install somewhere else with `DEVKIT_HOME`. + +### `dev update` refuses to update + +It refuses while tracked files in the devkit checkout have local changes (`git -C status` lists them), and +when the checkout is at a commit that is neither a release tag nor a branch. Commit or discard the changes, or check out +a release or a branch, then run `dev update` again. + ## Sessions and agents ### Claude Code or Codex does not show the devkit context From d68560960d8e2cdec492739045c39c08a98c684e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Fri, 6 Nov 2026 09:54:40 -0800 Subject: [PATCH 012/297] refactor(dashboard): render the page from collected data, with a nonce The live server needs the page for a snapshot it already holds, and a CSP nonce on every inline script and style. render() is now page(collect(...)); without a nonce the static export (dev sync, the Desktop extension) is byte for byte the same as before: compared in one process with HEAD's dashboard.py on the test registry and an empty one, under python3 and /usr/bin/python3 (3.9). --- lib/devkit/dashboard.py | 21 ++++++++++++++++++--- tests/test_dashboard.py | 21 +++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/lib/devkit/dashboard.py b/lib/devkit/dashboard.py index 351f576..7e4c2a8 100644 --- a/lib/devkit/dashboard.py +++ b/lib/devkit/dashboard.py @@ -3558,11 +3558,21 @@ def _esc(s) -> str: return str(s).replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) -def render(reg: dict, handoff: Path = None, now: float = None) -> str: - """The complete, self-contained dashboard HTML for a registry.""" - data = collect(reg, handoff=handoff, now=now) +NONCE_RE = re.compile(r"[A-Za-z0-9_\-+/]{16,128}={0,2}") + + +def page(data: dict, nonce: str = "") -> str: + """The dashboard HTML for already-collected data (`collect`). Without a nonce it is the self-contained static + export; with one (the live server), every inline ', html, re.S).group(1)) + self.assertEqual((data["live"], data["snapshot"], "files" in data), (True, 1, False)) + self.assertEqual(len(seen), 2) + + def test_the_dashboard_has_an_etag_and_answers_304(self): + status, headers, body = self.srv.request("/api/v1/dashboard?omit=files") + self.assertEqual(status, 200) + d = json.loads(body) + self.assertEqual((d["x"], d["snapshot"], "files" in d), ({"n": 1}, 1, False)) + status, h2, body = self.srv.request("/api/v1/dashboard?omit=files", {"If-None-Match": headers["etag"]}) + self.assertEqual((status, body), (304, b"")) + self.assertEqual(h2["etag"], headers["etag"]) + _, h3, body = self.srv.request("/api/v1/dashboard?only=x") + self.assertEqual(json.loads(body), {"x": {"n": 1}, "live": True, "snapshot": 1}) + self.assertNotEqual(h3["etag"], headers["etag"]) # another representation, another tag + + def test_a_change_to_a_watched_file_bumps_the_version_and_the_stream_sends_it(self): + close, read = self.srv.events("?since=1") + (self.srv.watched / "x.json").write_text('{"n": 2, "more": true}') + ev = read() + self.assertEqual((ev["id"], ev["event"], ev["data"]), ("2", "snapshot", {"version": 2, "changed": ["x"]})) + close() + self.assertEqual(json.loads(self.srv.request("/api/v1/dashboard?only=x")[2])["x"], {"n": 2, "more": True}) + close, read = self.srv.events(headers={"Last-Event-ID": "1"}) # a page that reconnects after missing it + self.assertEqual(read()["data"], {"version": 2, "changed": ["x"]}) + close() + + def test_the_same_data_keeps_its_version(self): + n = self.srv.collects + os.utime(self.srv.watched / "x.json", (1, 1)) # touched, not changed + until(lambda: self.srv.collects > n) + self.assertGreater(self.srv.collects, n) + self.assertEqual(self.srv.snap.version, 1) + + def test_files_stay_inside_the_handoff_repository(self): + for bad in ("../../etc/passwd", "../secret.md", "/etc/passwd", "projects/../../secret.md"): + status, headers, body = self.srv.request("/api/v1/files?path=" + bad) + self.assertEqual((status, headers["content-type"]), (404, "application/problem+json"), bad) + status, _, body = self.srv.request("/api/v1/files?path=projects/acme/widget.md") + self.assertEqual((status, json.loads(body)["text"]), (200, "# Widget\n")) + + def test_details_that_do_not_exist_are_404_problems(self): + for path in ("/api/v1/items/T-nope", "/api/v1/runs/claude-nope", "/api/v1/jobs/nope", "/api/v1/nothing"): + status, headers, body = self.srv.request(path) + self.assertEqual((status, headers["content-type"], json.loads(body)["status"]), (404, "application/problem+json", 404), path) + + +class GzipTests(unittest.TestCase): + def test_large_answers_are_compressed_for_a_client_that_accepts_it(self): + state = TempState() + srv = LiveServer(state.root, files_text="# Big\n" + "line of text\n" * 400) + try: + status, headers, body = srv.request("/api/v1/files", {"Accept-Encoding": "gzip"}) + self.assertEqual((status, headers.get("content-encoding"), headers.get("vary")), (200, "gzip", "Accept-Encoding")) + self.assertEqual(json.loads(gzip.decompress(body))["files"][0]["path"], "projects/acme/widget.md") + _, headers, body = srv.request("/api/v1/files") + self.assertNotIn("content-encoding", headers) + self.assertIn(b"line of text", body) + finally: + srv.close() + state.close() + + +class IdleTests(unittest.TestCase): + def test_it_stops_after_the_idle_minutes_with_no_page_open_and_never_while_one_is(self): + state = TempState() + srv = LiveServer(state.root, idle=1) + try: + dashserver.write_state({"pid": os.getpid(), "port": srv.port, "token": TOKEN}) + close, read = srv.events("?since=1") + self.assertEqual(until(lambda: srv.app.clients), 1) + srv.clock.t += 600 # 10 minutes with a page open + self.assertFalse(until(lambda: not srv.thread.is_alive(), timeout=0.5)) + close() + self.assertEqual(until(lambda: srv.app.clients == 0), True) + srv.clock.t += 30 # half a minute after the page closed + self.assertFalse(until(lambda: not srv.thread.is_alive(), timeout=0.3)) + srv.clock.t += 31 + self.assertTrue(until(lambda: not srv.thread.is_alive(), timeout=3)) + self.assertFalse(dashserver.state_file().exists()) # its own state file is gone + finally: + srv.close() + state.close() + + +class StateFileTests(unittest.TestCase): + def test_the_state_file_is_0600_whatever_the_umask(self): + state = TempState() + old = os.umask(0) + try: + dashserver.write_state({"pid": 1, "port": 2, "token": TOKEN}) + dashserver.write_state({"pid": 3, "port": 4, "token": TOKEN}) # replaced, still private + self.assertEqual(os.stat(dashserver.state_file()).st_mode & 0o777, 0o600) + self.assertEqual(dashserver.read_state()["pid"], 3) + finally: + os.umask(old) + state.close() + + def test_stop_never_signals_a_process_that_is_not_a_dashboard_server(self): + state = TempState() + other = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"]) + try: + dashserver.write_state({"pid": other.pid, "port": 9, "token": TOKEN}) + self.assertFalse(dashserver.stop(wait=0.2)) + self.assertIsNone(other.poll()) # still running + self.assertFalse(dashserver.state_file().exists()) + finally: + other.kill() + other.wait() + state.close() + + +class ProcessTests(unittest.TestCase): + """`ensure()` with real background servers (python -m devkit.dashserver serve --fd N) in a temporary HOME.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.home = Path(self.tmp.name).resolve() + cfg = self.home / ".config" / "devkit" + cfg.mkdir(parents=True) + (cfg / "config.json").write_text(json.dumps({"github_user": "tester", "dashboard": {"idle_minutes": 2}})) + self.old_state = core.STATE + core.STATE = self.home / ".local" / "state" / "devkit" + core.STATE.mkdir(parents=True) + (core.STATE / "providers.json").write_text("{}") # fresh: no agent CLI is asked + env = {k: v for k, v in os.environ.items() if k not in ("DEVKIT_CONFIG", "PYTHONPATH")} + env.update(HOME=str(self.home)) + self.env = mock.patch.dict(os.environ, env, clear=True) + self.env.start() + + def tearDown(self): + st = dashserver.read_state() + if st.get("pid") and st.get("pid") != os.getpid(): + dashserver.stop(wait=5) + if dashserver._alive(st["pid"]) and dashserver._is_ours(st["pid"]): + os.kill(st["pid"], signal.SIGKILL) + self.env.stop() + core.STATE = self.old_state + self.tmp.cleanup() + + def test_ensure_starts_one_then_reuses_it_and_replaces_a_dead_one(self): + dead = subprocess.Popen([sys.executable, "-c", "pass"]) + dead.wait() + closed = socket.socket() + closed.bind(("127.0.0.1", 0)) + port = closed.getsockname()[1] + closed.close() + dashserver.write_state({"pid": dead.pid, "port": port, "token": TOKEN, "version": core.VERSION, "started": 1}) + first = dashserver.ensure() + self.assertFalse(first["reused"]) + self.assertNotEqual(first["port"], port) + url = dashserver.browser_url(first) # waits for its first snapshot + self.assertRegex(url, rf"^http://127\.0\.0\.1:{first['port']}/auth\?t=[\w-]+$") + self.assertEqual(os.stat(dashserver.state_file()).st_mode & 0o777, 0o600) + ps = subprocess.run(["ps", "-p", str(first["pid"]), "-o", "command="], capture_output=True, text=True).stdout + self.assertIn("devkit.dashserver serve --fd", ps) + self.assertNotIn(first["token"], ps) # the token is not on the command line + again = dashserver.ensure() + self.assertEqual((again["reused"], again["port"], again["pid"]), (True, first["port"], first["pid"])) + s = dashserver.status() + self.assertEqual((s["running"], s["current"], s["version"]), (True, True, core.VERSION)) + self.assertTrue(dashserver.stop()) + self.assertFalse(dashserver._alive(first["pid"])) + self.assertEqual(dashserver.status(), {"running": False}) + + def test_ensure_replaces_a_server_of_another_version(self): + state_root = Path(tempfile.mkdtemp(dir=self.tmp.name)) + old = LiveServer(state_root, version="0.0.1") + try: + dashserver.write_state({"pid": os.getpid(), "port": old.port, "token": TOKEN, "version": "0.0.1", + "started": int(time.time()) - 3600}) + self.assertEqual(dashserver.status()["current"], False) + new = dashserver.ensure() + self.assertFalse(new["reused"]) + self.assertNotEqual(new["port"], old.port) + dashserver.browser_url(new) + self.assertEqual(dashserver.status()["version"], core.VERSION) + self.assertTrue(old.thread.is_alive()) # the test process was not signalled + finally: + old.close() + + +class CommandTests(unittest.TestCase): + def setUp(self): + import argparse + self.state = TempState() + self.parser = dashserver.add_arguments(argparse.ArgumentParser(prog="dev dashboard")) + + def tearDown(self): + self.state.close() + + def run_cmd(self, *argv): + from io import StringIO + out = StringIO() + with mock.patch("sys.stdout", out): + rc = dashserver.cmd_dashboard(self.parser.parse_args(list(argv))) + return rc, out.getvalue() + + def test_status_says_stopped_and_fails_when_no_server_runs(self): + rc, out = self.run_cmd("--status") + self.assertEqual(rc, 1) + self.assertIn("dashboard stopped", out) + + def test_export_writes_the_static_page_without_live_parts(self): + from devkit import registry + target = self.state.root / "out" / "dash.html" + with mock.patch.object(registry, "build", lambda **kw: {"projects": [], "host": "testmac", "generated": 1_800_000_000}): + rc, out = self.run_cmd("--export", str(target)) + html = target.read_text() + self.assertEqual(rc, 0) + self.assertTrue(html.startswith("")) + self.assertNotIn("nonce=", html) + self.assertNotIn('"live":true', html) + self.assertIn("dash.html", out) + self.assertEqual(html.count(""), 3) + + def test_an_unknown_action_is_refused(self): + with mock.patch("sys.stderr"): + rc, _ = self.run_cmd("bogus") + self.assertEqual(rc, 2) + + +if __name__ == "__main__": + unittest.main() From be98668de63f37970b7c3861fe6b79a448d5e380 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Fri, 6 Nov 2026 10:26:44 -0800 Subject: [PATCH 024/297] test(dashboard): compare the export with page() of its own data The test rendered and collected twice; collect() reads live progress runs whose minutes move with the clock, so the two could differ by a tenth of a minute (seen once in the full suite on Python 3.15). Comparing render() with page() of the data embedded in the same render proves the same thing without a second clock reading. --- tests/test_dashboard.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 1e01dcc..a79c623 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -342,8 +342,8 @@ def test_marker_both_forms_stripped(self): self.assertIn("function markerChip", dashboard.JS) def test_static_export_is_the_page_of_its_data(self): - """`render` (dev sync, --export) is `page(collect(...))` with no nonce: no CSP attributes, no live flag.""" - self.assertEqual(self.html, dashboard.page(dashboard.collect(self.reg, handoff=self.handoff, now=NOW))) + """`render` (dev sync, --export) is `page()` of its own data with no nonce: no CSP attributes, no live flag.""" + self.assertEqual(self.html, dashboard.page(self.data)) # the same data again (a second collect sees a later clock) self.assertNotIn("nonce=", self.html) self.assertNotIn("live", self.data) self.assertIn('', html, re.S).group(1)) self.assertEqual((data["live"], data["snapshot"], "files" in data), (True, 1, False)) self.assertEqual(len(seen), 2) From 595b70b5eb476bcb61ecff1e567089133de84a22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Fri, 6 Nov 2026 11:13:40 -0800 Subject: [PATCH 038/297] feat(install): run hooks and the guard from a pinned, read-only copy Security finding K2 (spec C10): the hooks ran the checkout the crew edits, so one broken or malicious edit to guard.py or hooks.py switched the guard off for every session at once. dev install now pins a git archive of the checkout's HEAD in ~/.local/share/devkit/releases/-, read-only, with a SHA-256 manifest. It pins only a release that origin has on the same commit, or a tree whose dev ci --run passed, unless --unverified --why is given (audited). It refuses uncommitted tracked changes and code that does not compile, then continues in the copy. The hooks, the status line, ~/.local/bin/dev, the jobs and the Desktop launcher run that copy. dev doctor fails on a tampered copy or a hook that runs elsewhere; dev install --version rolls back; the newest 3 copies and any in use are kept. --- install.sh | 31 ++- lib/devkit/cli.py | 41 ++- lib/devkit/core.py | 56 ++++ lib/devkit/desktop.py | 17 +- lib/devkit/setup.py | 517 ++++++++++++++++++++++++++++++++++- tests/test_pinned_install.py | 428 +++++++++++++++++++++++++++++ 6 files changed, 1056 insertions(+), 34 deletions(-) create mode 100644 tests/test_pinned_install.py diff --git a/install.sh b/install.sh index 333f6b1..f94e842 100755 --- a/install.sh +++ b/install.sh @@ -10,7 +10,8 @@ # ./install.sh [--yes] [--skip-packages] [--no-setup] # # It makes sure Homebrew, git, Python, and the GitHub CLI are present, gets devkit, signs you in to GitHub, and -# starts the setup wizard (`dev setup`). Safe to run again. `install.sh --help` lists the options. +# starts the setup wizard (`dev setup`), whose `dev install` pins a read-only copy of devkit for the hooks and the +# guard in ~/.local/share/devkit/releases. Safe to run again. `install.sh --help` lists the options. # # Everything is inside functions, and only the last line runs them: a download cut short runs nothing. @@ -238,6 +239,9 @@ update_existing() { use_checkout() { step "devkit" "devkit itself: the checkout this script is in." ok "this checkout: $(tildify "$DEVKIT_HOME") ($(describe))" + if [ -z "$(git -C "$DEVKIT_HOME" tag --points-at HEAD 2>/dev/null | newest_release || true)" ]; then + note "not a release: dev install pins it for the hooks only after its tests pass here (bin/dev ci --run)" + fi } # Sets GITHUB=0 when the GitHub CLI is not signed in and there is no terminal to sign in from. @@ -272,6 +276,21 @@ run_setup() { with_tty "$PY" "$DEVKIT_HOME/bin/dev" setup ${args[@]+"${args[@]}"} || SETUP_RC=$? } +# The read-only copy of devkit that the wizard's `dev install` pinned for the hooks and the guard, from its record: +# " () in ", or nothing when devkit is not pinned. +pinned_line() { + "$PY" -c 'import json, os, sys +try: + r = json.load(open(sys.argv[1])) +except (OSError, ValueError): + r = {} +p = r.get("path") or "" if isinstance(r, dict) else "" +if p and os.path.isfile(os.path.join(p, "bin", "dev")): + home = os.path.expanduser("~") + print("%s (%s) in %s" % (r.get("version"), (r.get("commit") or "")[:7], "~" + p[len(home):] if p.startswith(home + "/") else p))' \ + "$HOME/.local/share/devkit/install.json" 2>/dev/null || true +} + # The agents chosen in the wizard, from its saved configuration. agents_line() { local names @@ -283,7 +302,7 @@ print(" and ".join(n for k, n in (("claude", "Claude Code"), ("codex", "Codex")) } summary() { - local outcome="$1" where version again + local outcome="$1" where version again pinned where="$(tildify "$DEVKIT_HOME")" version="$(cat "$DEVKIT_HOME/VERSION" 2>/dev/null || printf 'unknown')" again="$where/install.sh" @@ -294,13 +313,19 @@ summary() { printf '\n %sWhat you have now%s\n' "$C_B" "$C_OFF" ok "devkit, and the tools for the kinds of projects you chose" ok "$(agents_line) with devkit's shared rules and its safety guard" + pinned="$(pinned_line)" + if [ -n "$pinned" ]; then + ok "the hooks and the guard run a read-only copy of devkit $pinned" + else + note "the hooks are not pinned yet: run dev install (dev doctor says what is missing)" + fi ok "a private GitHub repository for your project notes, tasks, and plans" printf '\n %sWhat to do first%s\n' "$C_B" "$C_OFF" printf ' 1. Open a new Terminal window (or run: exec zsh), so the dev command works.\n' printf ' 2. dev doctor checks that everything works\n' printf ' 3. dev dashboard shows your projects and tasks in the browser\n' printf ' 4. dev new my-app, then dcd my-app, then claude (or codex) to start your first project\n' - printf ' %sLater, dev update moves devkit to the newest release.%s\n\n' "$C_DIM" "$C_OFF" + printf ' %sLater, dev update moves devkit to the newest release and pins it.%s\n\n' "$C_DIM" "$C_OFF" ;; no-setup) note "devkit is downloaded; nothing is configured yet (--no-setup)." diff --git a/lib/devkit/cli.py b/lib/devkit/cli.py index ad785a9..c0cc7da 100644 --- a/lib/devkit/cli.py +++ b/lib/devkit/cli.py @@ -92,8 +92,8 @@ ("guard check |--write p", "Simulate what the guard decides for a command or a file write"), ("plugin validate", "Validate the devkit plugin with Claude Code's validator and Codex's parser"), ("desktop pack|status", "Claude Desktop extension: build the .mcpb bundle, check what is installed"), - ("install [--only step]", "Apply the devkit configuration (idempotent)"), - ("update", "Move devkit to the newest release (or pull its branch) and re-apply it"), + ("install [--only step]", "Pin the checkout read-only for the hooks, then apply the configuration"), + ("update", "Move devkit to the newest release (or pull its branch), pin it and re-apply it"), ("docker up|down|status|prune", "Run Docker on a Colima VM"), ("version", "Show the devkit version"), ]), @@ -478,7 +478,7 @@ def cmd_adopt(a): _trust_codex(dest) gc._log(f"adopt · {p['id']}: {tilde(old_main)} → {tilde(dest)}") print(f" {ui.WARN} Claude Desktop / Codex app chats opened at the old path should be restarted from {tilde(dest)}") - if core.real(old_main) == core.real(core.DEVKIT_HOME): + if core.real(old_main) == core.real(core.source_home()): import subprocess # devkit moved itself: its paths are embedded in hooks, links and launchd — re-apply them print(f" {ui.ARROW} devkit moved itself; re-applying its configuration from {tilde(dest)}") return subprocess.call([sys.executable, str(dest / "bin" / "dev"), "install"]) @@ -823,8 +823,15 @@ def cmd_doctor(a): def cmd_install(a): from . import setup - setup.run_install(only=set(a.only.split(",")) if a.only else None) - return 0 + why = (a.why or "").strip() + if a.unverified and not why: + ui.err('--unverified needs --why "": it is recorded in the audit') + return 2 + if why and not a.unverified: + ui.err("--why goes with --unverified (the reason to install a commit that is neither a release nor tested)") + return 2 + return setup.run_install(only={x.strip() for x in a.only.split(",") if x.strip()} if a.only else None, + unverified_why=why if a.unverified else None, version=a.version, as_pinned=a.as_pinned) RELEASE_TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$") @@ -872,12 +879,13 @@ def update_checkout(home: Path): def cmd_update(a): - print(f"{ui.ARROW} updating {tilde(core.DEVKIT_HOME)}") - rc, message, install = update_checkout(core.DEVKIT_HOME) + home = core.source_home() # the checkout; `dev` itself may run from the pinned copy, which git never changes + print(f"{ui.ARROW} updating {tilde(home)}") + rc, message, install = update_checkout(home) print(f" {ui.OK if rc == 0 else ui.FAIL} {message}") if rc != 0 or not install: return rc - os.execv(sys.executable, [sys.executable, str(core.DEVKIT_HOME / "bin" / "dev"), "install"]) + os.execv(sys.executable, [sys.executable, str(home / "bin" / "dev"), "install"]) # pins the new release def cmd_docker(a): @@ -2143,7 +2151,12 @@ def cmd_diagram(a): def cmd_version(a): - print(f"devkit {core.VERSION} · {tilde(core.DEVKIT_HOME)}\n{COPYRIGHT}") + where = tilde(core.DEVKIT_HOME) + if core.is_pinned(): + where += f" (pinned copy of {tilde(core.source_home())})" + elif core.is_pinned(core.runtime_home()): + where += f" (checkout; the hooks run {tilde(core.runtime_home())})" + print(f"devkit {core.VERSION} · {where}\n{COPYRIGHT}") return 0 @@ -2528,8 +2541,14 @@ def add(name, fn, help_, **kw): from . import crew crew.register(add) add("doctor", cmd_doctor, "Verify the setup") - add("install", cmd_install, "Apply configuration").add_argument( - "--only", help="comma list: config,tools,cli,git,handoff,instructions,skills,claude,codex,desktop,docker,docs,launchd") + p = add("install", cmd_install, "Pin devkit for the hooks, then apply the configuration") + p.add_argument("--only", help="comma list: config,tools,cli,git,handoff,instructions,skills,claude,codex,desktop," + "docker,docs,launchd; re-applies them from the installed copy without pinning") + p.add_argument("--version", help="switch to a pinned copy kept from an earlier install (rollback), e.g. 26.11.2") + p.add_argument("--unverified", action="store_true", + help="pin HEAD although it is neither a release nor tested (`dev ci --run`); needs --why") + p.add_argument("--why", help="the reason for --unverified, recorded in the audit") + p.add_argument("--as-pinned", action="store_true", help=argparse.SUPPRESS) # the second half, from the copy p = add("docs", cmd_docs, "Check documentation: Markdown, links and anchors, prose, diagrams") p.add_argument("check", choices=["check"]) p.add_argument("paths", nargs="*", help="files or folders (default: the whole repository)") diff --git a/lib/devkit/core.py b/lib/devkit/core.py index 8080898..48621cd 100644 --- a/lib/devkit/core.py +++ b/lib/devkit/core.py @@ -392,3 +392,59 @@ def write_if_changed(path: Path, text: str) -> bool: tmp.write_text(text) os.replace(tmp, path) return True + + +# --------------------------------------------------------------------------- the pinned install (spec C10) +# +# `dev install` copies one commit of the devkit checkout, read-only and with a manifest of checksums, to +# ~/.local/share/devkit/releases/-/ and records it in ~/.local/share/devkit/install.json. The agents' +# hooks, the status line, ~/.local/bin/dev and the scheduled jobs run that copy, so an edit in the checkout reaches +# them only through the next `dev install`. `bin/dev` run from the checkout still runs the checkout. +# The paths are functions of HOME so that tests can move HOME. + +PIN_MANIFEST = ".devkit-manifest.json" + + +def share_dir() -> Path: + return HOME / ".local" / "share" / "devkit" + + +def pin_root() -> Path: + return share_dir() / "releases" + + +def install_record_file() -> Path: + return share_dir() / "install.json" + + +def install_record() -> dict: + """What `dev install` installed last: version, commit, path of the pinned copy, source checkout ({} if none).""" + rec = read_json(install_record_file(), {}) + return rec if isinstance(rec, dict) else {} + + +def is_pinned(path=None) -> bool: + """True when `path` (default: the code that runs now) is a pinned copy: a folder of pin_root() with a manifest.""" + p = Path(path or DEVKIT_HOME) + return p.resolve().parent == pin_root().resolve() and (p / PIN_MANIFEST).is_file() + + +def runtime_home() -> Path: + """The devkit copy that hooks, the installed `dev`, the status line and the jobs run: this copy when it is pinned, + else the pinned copy that `dev install` recorded. Settings written from a checkout point there too, never at the + checkout. Before the first pinned install, the copy that runs now.""" + if is_pinned(DEVKIT_HOME): + return DEVKIT_HOME + path = install_record().get("path") + return Path(path) if path and is_pinned(path) else DEVKIT_HOME + + +def source_home() -> Path: + """The devkit checkout that `dev install` pins and `dev update` updates: this copy when it is a checkout; for a + pinned copy, the checkout it was made from.""" + if not is_pinned(DEVKIT_HOME): + return DEVKIT_HOME + rec = install_record() + src = rec.get("source") if rec.get("path") and real(rec["path"]) == real(DEVKIT_HOME) else None + src = src or (read_json(DEVKIT_HOME / PIN_MANIFEST, {}) or {}).get("source") + return Path(src) if src else DEVKIT_HOME diff --git a/lib/devkit/desktop.py b/lib/devkit/desktop.py index 0f639aa..ab69436 100644 --- a/lib/devkit/desktop.py +++ b/lib/devkit/desktop.py @@ -4,7 +4,8 @@ pack stage desktop/manifest.json (version from VERSION), the launcher, LICENSE, NOTICE and the icon; check the manifest's tools against `dev mcp`'s; validate with the pinned mcpb CLI when npx is available; write - dist/desktop/devkit-.mcpb (a zip with manifest.json at its root) + devkit-.mcpb (a zip with manifest.json at its root) to dist/desktop in a checkout, or to + ~/.local/share/devkit/desktop when devkit runs from its read-only pinned copy (what `dev install` uses) status whether Claude Desktop has the extension installed, its version against this checkout, whether it is enabled, and which devkit checkout its launcher finds """ @@ -24,12 +25,17 @@ MCPB = "@anthropic-ai/mcpb@2.1.2" # pinned: validate, pack and info SRC = DEVKIT_HOME / "desktop" -DIST = DEVKIT_HOME / "dist" / "desktop" ICON = DEVKIT_HOME / "plugin" / "assets" / "logo.png" # the devkit icon at 512 px (icon.png is the 128 px copy) EXT_ID = "local.mcpb.firefly-software-solutions-inc..devkit" FILES = ("manifest.json", "server/launcher.py", "icon.png", "LICENSE", "NOTICE") +def dist_dir() -> Path: + """Where bundles are written: dist/desktop in a checkout; next to the pinned copies when devkit runs from one, + since that copy is read-only.""" + return core.share_dir() / "desktop" if core.is_pinned(DEVKIT_HOME) else DEVKIT_HOME / "dist" / "desktop" + + def claude_dir() -> Path: return core.HOME / "Library" / "Application Support" / "Claude" @@ -109,8 +115,8 @@ def _mcpb(*args, timeout=300): def pack(out_dir: Path = None, validate: bool = True, say=print) -> dict: - """Build dist/desktop/devkit-.mcpb -> a report. Raises ValueError when a check fails.""" - out_dir = Path(out_dir or DIST) + """Build devkit-.mcpb in dist_dir() -> a report. Raises ValueError when a check fails.""" + out_dir = Path(out_dir or dist_dir()) out = out_dir / f"devkit-{core.VERSION}.mcpb" rep = {"bundle": str(out), "version": core.VERSION, "packed_by": "zip", "validate": None, "info": None} use_mcpb = validate and core.have("npx") @@ -186,7 +192,8 @@ def status(base: Path = None) -> dict: settings = core.read_json(base / "Claude Extensions Settings" / f"{ext_id}.json", {}) if ext_id else {} settings = settings if isinstance(settings, dict) else {} home_setting = ((settings.get("userConfig") or {}).get("devkit_home") or "") - bundles = sorted(DIST.glob("devkit-*.mcpb"), key=lambda p: p.stat().st_mtime) if DIST.exists() else [] + dist = dist_dir() + bundles = sorted(dist.glob("devkit-*.mcpb"), key=lambda p: p.stat().st_mtime) if dist.exists() else [] out = {"claude_desktop": base.exists(), "installed": bool(ext_id), "id": ext_id or EXT_ID, "installed_version": ext.get("version"), "version": core.VERSION, "current": bool(ext_id) and ext.get("version") == core.VERSION, diff --git a/lib/devkit/setup.py b/lib/devkit/setup.py index ce6c7ec..64e0983 100644 --- a/lib/devkit/setup.py +++ b/lib/devkit/setup.py @@ -1,15 +1,25 @@ # Copyright (c) 2026 Firefly Software Solutions Inc. # SPDX-License-Identifier: Apache-2.0 """`dev install` (idempotent configuration of git, Claude Code, Codex, skills, Docker, launchd) -and `dev doctor` (verification).""" +and `dev doctor` (verification). + +`dev install` first pins devkit (spec C10): a `git archive` of the checkout's HEAD, read-only, with a manifest of +SHA-256 checksums, in ~/.local/share/devkit/releases/-/. It pins only a release (a tag v that +origin has on the same commit) or a commit whose local test gate passed (`dev ci --run`), unless the developer passes +`--unverified --why`. It then runs the rest of the install from that copy, so the hooks, ~/.local/bin/dev, the status +line, the scheduled jobs and the Desktop extension run it and never the checkout.""" from __future__ import annotations +import hashlib +import io import json import os +import shlex import sys import re import shutil import subprocess +import tarfile import time from pathlib import Path @@ -30,7 +40,9 @@ # --------------------------------------------------------------------------- helpers def render(text: str) -> str: - return (text.replace("{{DEVKIT}}", str(DEVKIT_HOME)).replace("{{HOME}}", str(HOME)) + """Fill a config template. {{DEVKIT}} is the copy the hooks run (core.runtime_home: the pinned install), also when + this code runs from a checkout, so no setting written here ever points at the checkout once devkit is pinned.""" + return (text.replace("{{DEVKIT}}", str(core.runtime_home())).replace("{{HOME}}", str(HOME)) .replace("{{DEV}}", str(DEV)).replace("{{UID}}", str(os.getuid()))) @@ -128,6 +140,443 @@ def merge_hooks(current: dict, ours: dict) -> dict: return out +# --------------------------------------------------------------------------- the pinned install (spec C10, K2) + +PIN_KEEP = 3 # pinned copies kept; one still in use is never removed +TESTED = ("run", "resumed") # local CI evidence from a run of the suite; an attestation (--ci-passed) is not enough + + +class PinError(Exception): + """`dev install` refused to pin or switch the copy: the message says why and what to do instead.""" + + +def _audit(action: str, target: str, detail: str, outcome: str = "ok", **extra): + from . import audit + audit.record(action, target, detail, outcome=outcome, **extra) + + +def _sha256(path) -> str: + h = hashlib.sha256() + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(1 << 16), b""): + h.update(chunk) + return h.hexdigest() + + +def _files(root: Path) -> dict: + """{relative path: Path} of every file under `root`, the manifest itself excepted.""" + out = {} + for d, _, names in os.walk(root): + for n in names: + p = Path(d) / n + rel = p.relative_to(root).as_posix() + if rel != core.PIN_MANIFEST: + out[rel] = p + return out + + +def _read_only(root: Path): + """Files 0444 (0555 when git has them executable, as bin/dev must be for the hooks to run it), folders 0555.""" + for d, _, names in os.walk(root, topdown=False): + for n in names: + p = os.path.join(d, n) + os.chmod(p, 0o555 if os.lstat(p).st_mode & 0o100 else 0o444) + os.chmod(d, 0o555) + + +def _remove_tree(path: Path): + """Delete a pinned copy or a leftover of one: its folders are read-only, so they are made writable first.""" + for d, _, _ in os.walk(path): + try: + os.chmod(d, 0o755) + except OSError: + pass + shutil.rmtree(path, ignore_errors=True) + + +def _precompile(lib: Path): + """Compile the copy's modules now: its folders become read-only, so Python could never cache them and every hook + would compile devkit again. Hash-checked .pyc files never outlive their source. A module that does not compile + is refused: the hooks would fail open on it. The `python3` on PATH gets its own bytecode when it differs. Apple's + /usr/bin/python3 caches bytecode outside the tree (sys.pycache_prefix), so under it the modules are only checked.""" + import py_compile + if not lib.is_dir(): + return + bad = [] + for f in sorted(lib.rglob("*.py")): + try: + if getattr(sys, "pycache_prefix", None): + compile(f.read_bytes(), str(f), "exec", dont_inherit=True) + else: + py_compile.compile(str(f), doraise=True, invalidation_mode=py_compile.PycInvalidationMode.CHECKED_HASH) + except (py_compile.PyCompileError, SyntaxError, ValueError): + bad.append(f.relative_to(lib.parent).as_posix()) + if bad: + raise PinError(f"{', '.join(bad[:4])} does not compile, and the hooks would fail open on it. Fix it, commit, " + f"then run `dev install` again.") + other = shutil.which("python3") + if other and not other.startswith("/usr/bin/") and core.real(other) != core.real(sys.executable): + sh([other, "-m", "compileall", "-q", "--invalidation-mode", "checked-hash", str(lib)], timeout=180) + + +def _release(source: Path, commit: str, version: str): + """-> (tag, "") when HEAD is the release v and origin has that tag on the same commit, else (None, why). + A tag made only in this checkout (by anyone, an agent too) is not a release.""" + tag = f"v{version}" + if tag not in core.gitout(source, "tag", "--points-at", commit).split(): + return None, f"it is not the release {tag}" + rc, out, err = core.git(source, "ls-remote", "origin", f"refs/tags/{tag}", f"refs/tags/{tag}^{{}}", timeout=60) + if rc != 0: + return None, f"origin could not confirm the tag {tag} ({(err or out).strip()[:160] or f'exit {rc}'})" + refs = {parts[1]: parts[0] for parts in (ln.split() for ln in out.splitlines()) if len(parts) == 2} + remote = refs.get(f"refs/tags/{tag}^{{}}") or refs.get(f"refs/tags/{tag}") # annotated tags: the peeled commit + if remote != commit: + return None, (f"origin has no tag {tag}" if not remote else + f"origin's {tag} is {remote[:7]}, not {commit[:7]}, so the local tag is not the release") + return tag, "" + + +def _tested(source: Path, commit: str): + """-> (evidence, "") when the local test gate (`dev ci --run`) passed for exactly this commit's tree, else + (None, why). The evidence is the record `dev push` checks.""" + from . import gitx + c = gitx.ctx(source) + ev = gitx.evidence(c, commit) if c else None + if ev and ev.get("how") in TESTED: + return ev, "" + if ev: + return None, f"its local CI record is {gitx.describe_evidence(ev)}, a claim, not a run of the suite" + return None, "no `dev ci --run` passed for this tree" + + +def _verify_source(source: Path, commit: str, version: str, unverified_why: str = None) -> dict: + """How this commit earned its pin -> {"how": release|tests|unverified, "detail"}. Raises PinError when it did not.""" + from . import gitx + tag, not_release = _release(source, commit, version) + if tag: + return {"how": "release", "detail": f"release {tag}, the same commit on origin"} + ev, not_tested = _tested(source, commit) + if ev: + return {"how": "tests", "detail": f"tests passed for this tree ({gitx.describe_evidence(ev)})", + "evidence_ts": ev.get("ts")} + why = (unverified_why or "").strip() + if why: + return {"how": "unverified", "detail": f"unverified: {why}", "why": why} + raise PinError(f"{commit[:7]} is not verified: {not_release}, and {not_tested}. The hooks and the guard run only a " + f"release or a commit whose tests passed here. Run `bin/dev ci --run` in {tilde(source)}, then " + f"`dev install`; or move to the newest release with `dev update`; or install it anyway with " + f"`dev install --unverified --why \"\"` (recorded in the audit).") + + +def _info(man: dict, path: Path, verified=None, reused=False) -> dict: + return {"name": path.name, "version": man.get("version"), "commit": man.get("commit"), "path": str(path), + "source": man.get("source"), "verified": verified or man.get("verified"), + "files": len(man.get("files") or {}), "reused": reused} + + +def _build(source: Path, commit: str, tree: str, version: str, name: str, verified: dict) -> Path: + """git archive `commit` into a new read-only copy with its manifest; replaces a copy of the same name.""" + root = core.pin_root() + root.mkdir(parents=True, exist_ok=True) + tmp, old = root / f".{name}.tmp-{os.getpid()}", None + _remove_tree(tmp) + tmp.mkdir() + try: + p = subprocess.run(["git", "-C", str(source), "archive", "--format=tar", commit], capture_output=True, + timeout=300, env=core.GIT_ENV) + if p.returncode != 0: + raise PinError(f"git archive {commit[:7]} failed: {p.stderr.decode('utf-8', 'replace').strip()[-200:]}") + with tarfile.open(fileobj=io.BytesIO(p.stdout)) as tf: + members = tf.getmembers() + bad = [m.name for m in members if not (m.isfile() or m.isdir()) or m.name.startswith("/") + or ".." in Path(m.name).parts] + if bad: + raise PinError(f"{commit[:7]} has entries a pinned copy cannot hold (links, or paths outside the " + f"tree): {', '.join(bad[:4])}") + tf.extractall(str(tmp), members=members, **({"filter": "data"} if hasattr(tarfile, "data_filter") else {})) + _precompile(tmp / "lib") + man = {"name": name, "version": version, "commit": commit, "tree": tree, "source": str(source), + "created": round(time.time(), 3), "verified": verified, + "files": {rel: _sha256(f) for rel, f in sorted(_files(tmp).items())}} + (tmp / core.PIN_MANIFEST).write_text(json.dumps(man, indent=1, sort_keys=True) + "\n") + _read_only(tmp) + dest = root / name + if dest.exists(): + old = root / f".{name}.old-{os.getpid()}" + os.rename(dest, old) + os.rename(tmp, dest) + except BaseException: + _remove_tree(tmp) + raise + if old: + _remove_tree(old) + return dest + + +def pin(source=None, unverified_why: str = None) -> dict: + """Pin the HEAD of the devkit checkout `source` (default: core.source_home()) -> what was pinned (name, version, + commit, path, source, verified, files). Refused (PinError) when tracked files have uncommitted changes, or when + HEAD is neither a release nor tested and no `unverified_why` is given. The copy is always built from git; when one + of that commit had changed since it was pinned, that is audited (outcome "tampered").""" + source = Path(source or core.source_home()) + where = tilde(source) + top = core.gitout(source, "rev-parse", "--show-toplevel") + if not top: + raise PinError(f"{where} is not a git checkout of devkit, so there is nothing to pin. Run `dev install` in a " + f"devkit checkout (install.sh clones one).") + source = Path(top) + rc, dirty, err = core.git(source, "status", "--porcelain", "--untracked-files=no") + if rc != 0: + raise PinError(f"git status failed in {where}: {err}") + if dirty: + names = [ln[3:] for ln in dirty.splitlines() if ln.strip()] + cur = core.install_record().get("name") + raise PinError(f"{where} has uncommitted changes to {len(names)} tracked file(s) " + f"({', '.join(names[:4])}{' …' if len(names) > 4 else ''}), so its HEAD is not what is on " + f"disk. Commit them (then `bin/dev ci --run`) or discard them, and run `dev install` again" + + (f"; `dev install --version {cur}` re-applies the installed copy." if cur else ".")) + commit = core.gitout(source, "rev-parse", "--verify", "-q", "HEAD^{commit}") + tree = core.gitout(source, "rev-parse", "--verify", "-q", "HEAD^{tree}") + if not commit: + raise PinError(f"{where} has no commit yet, so there is nothing to pin.") + version = core.gitout(source, "show", f"{commit}:VERSION").strip() + if not re.fullmatch(r"[0-9A-Za-z][0-9A-Za-z.+_-]{0,39}", version): + raise PinError(f"{where} has no usable VERSION file at {commit[:7]}.") + verified = _verify_source(source, commit, version, unverified_why) + name = f"{version}-{commit[:7]}" + dest = core.pin_root() / name + if dest.exists(): # rebuilt from git all the same: a manifest rewritten to match a change would look intact + problems = verify_pin(dest) + rec = core.install_record() + if rec.get("manifest_sha256") and core.real(rec.get("path") or "") == core.real(dest) \ + and (dest / core.PIN_MANIFEST).is_file() and _sha256(dest / core.PIN_MANIFEST) != rec["manifest_sha256"]: + problems.insert(0, f"changed {core.PIN_MANIFEST}") + if problems: + _audit("install.pin", name, "the copy had changed since it was pinned, rebuilt from git: " + + "; ".join(problems[:6]), outcome="tampered") + dest = _build(source, commit, tree, version, name, verified) + man = read_json(dest / core.PIN_MANIFEST, {}) or {} + _audit("install.pin", name, verified["detail"], outcome="unverified" if verified["how"] == "unverified" else "ok", + commit=commit, verified=verified["how"]) + return dict(_info(man, dest, verified), source=str(source)) + + +def verify_pin(path) -> list: + """What differs between a pinned copy and its manifest, one short line each ("changed lib/devkit/guard.py", + "missing …", "added …", "writable …"); empty when the copy is intact.""" + path = Path(path) + man = read_json(path / core.PIN_MANIFEST) + if not isinstance(man, dict) or not isinstance(man.get("files"), dict): + return [f"no readable manifest ({core.PIN_MANIFEST})"] + out, found = [], _files(path) + for rel, want in sorted(man["files"].items()): + f = found.get(rel) + if f is None: + out.append(f"missing {rel}") + elif f.is_symlink() or _sha256(f) != want: + out.append(f"changed {rel}") + out += [f"added {rel}" for rel in sorted(set(found) - set(man["files"]))] + for d, _, names in os.walk(path): + for n in [d] + [os.path.join(d, x) for x in names]: + if os.lstat(n).st_mode & 0o222: + out.append(f"writable {Path(n).relative_to(path).as_posix()}") + return out + + +def pinned_copies() -> list: + """[(manifest, path)] of every pinned copy, newest first.""" + root = core.pin_root() + out = [] + for d in sorted(root.iterdir()) if root.is_dir() else []: + man = read_json(d / core.PIN_MANIFEST, {}) if d.is_dir() and not d.name.startswith(".") else None + if isinstance(man, dict) and man.get("commit"): + out.append((man, d)) + return sorted(out, key=lambda x: -(x[0].get("created") or 0)) + + +def pins_in_use() -> set: + """Real paths of the pinned copies something still runs: the installed one, this process, ~/.local/bin/dev, and + any copy that Claude's settings, Claude's managed settings, Codex's hooks or a launchd job names.""" + from . import policy + roots = {str(core.pin_root()), core.real(core.pin_root())} + used = set() + + def add(p): + rp = core.real(p) + base = core.real(core.pin_root()) + os.sep + if rp.startswith(base): + used.add(base + rp[len(base):].split(os.sep)[0]) + rec = core.install_record() + if rec.get("path"): + add(rec["path"]) + add(DEVKIT_HOME) + link = core.HOME / ".local" / "bin" / "dev" + if os.path.lexists(link): + add(os.path.realpath(link)) + texts = [CLAUDE_HOME / "settings.json", CODEX_HOME / "hooks.json", policy.MANAGED_FILE] + texts += sorted((core.HOME / "Library" / "LaunchAgents").glob("com.devkit*.plist")) + pat = re.compile("(" + "|".join(re.escape(r) for r in roots) + r")/([^/'\"\s<>]+)") + for f in texts: + try: + text = Path(f).read_text() + except (OSError, UnicodeDecodeError): + continue + for m in pat.finditer(text): + add(core.pin_root() / m.group(2)) + return used + + +def prune_pins(keep: int = PIN_KEEP) -> list: + """Remove the pinned copies beyond the newest `keep`, never one in use (pins_in_use), and the leftovers of an + install that stopped half-way. -> the names removed.""" + used, removed = pins_in_use(), [] + for i, (_, d) in enumerate(pinned_copies()): + if i >= keep and core.real(d) not in used: + _remove_tree(d) + removed.append(d.name) + root = core.pin_root() + for d in root.glob(".*") if root.is_dir() else []: + if d.is_dir() and time.time() - d.lstat().st_mtime > 3600: + _remove_tree(d) + if removed: + _audit("install.prune", ", ".join(removed), f"kept the newest {keep} pinned copies and those in use") + return removed + + +def record_install(info: dict) -> dict: + """Write ~/.local/share/devkit/install.json: which pinned copy is installed, from which checkout, and the + checksum of its manifest (so a replaced manifest is noticed too).""" + path = Path(info["path"]) + rec = {"name": info["name"], "version": info["version"], "commit": info["commit"], "path": str(path), + "source": str(info.get("source") or ""), "verified": info.get("verified"), + "manifest_sha256": _sha256(path / core.PIN_MANIFEST), "installed_at": round(time.time(), 3)} + f = core.install_record_file() + f.parent.mkdir(parents=True, exist_ok=True) + tmp = f.with_name(f.name + ".tmp") + tmp.write_text(json.dumps(rec, indent=1) + "\n") + os.replace(tmp, f) + return rec + + +def select_pin(version: str) -> dict: + """The newest intact pinned copy named `version` or `version-` -> its info (`dev install --version`).""" + copies = pinned_copies() + hits = [(m, d) for m, d in copies if d.name == version or d.name.startswith(version + "-")] + if not hits: + raise PinError(f"no pinned copy of {version}; there are: {', '.join(d.name for _, d in copies) or 'none'}.") + man, d = hits[0] + problems = verify_pin(d) + if problems: + raise PinError(f"{d.name} differs from its manifest ({'; '.join(problems[:4])}), so it is not used. Pin it " + f"again from git: check out {man.get('version')} in the checkout, then run `dev install`.") + return _info(man, d, reused=True) + + +def _choose_copy(only=None, unverified_why=None, version=None, as_pinned=False) -> Path: + """Which pinned copy this install runs from -> its path. Full installs pin the checkout (or switch to --version) + and record it; `--only` re-applies steps from the installed copy. Raises PinError.""" + step("Pinned copy of devkit (what the hooks and the guard run)") + if as_pinned: # the second half of `dev install`, started by the first in the copy it pinned + problems = verify_pin(DEVKIT_HOME) if core.is_pinned(DEVKIT_HOME) else ["it is not a pinned copy"] + if problems: + raise PinError(f"{tilde(DEVKIT_HOME)} is not an intact pinned copy ({'; '.join(problems[:4])}). Run " + f"`dev install` in the devkit checkout.") + rec = core.install_record() + if not rec.get("path") or core.real(rec["path"]) != core.real(DEVKIT_HOME): + if only: + raise PinError(f"{DEVKIT_HOME.name} is not the installed copy: `dev install --version " + f"{DEVKIT_HOME.name}` switches to it.") + record_install(_info(read_json(DEVKIT_HOME / core.PIN_MANIFEST, {}) or {}, DEVKIT_HOME)) + done(f"running from {tilde(DEVKIT_HOME)}") + return DEVKIT_HOME + if only: + if version or unverified_why: + raise PinError("--version and --unverified choose the copy every step runs, so they go without --only.") + cur = core.runtime_home() + if not core.is_pinned(cur): + raise PinError("devkit is not pinned yet: run `dev install` (every step) first; it pins the checkout.") + problems = verify_pin(cur) + if problems: + raise PinError(f"the installed copy {cur.name} differs from its manifest ({'; '.join(problems[:4])}). " + f"Run `dev install` to rebuild it from git.") + done(f"{', '.join(sorted(only))} from the installed copy {cur.name}; `dev install` pins the checkout's HEAD") + return cur + info = select_pin(version) if version else pin(None, unverified_why) + rec = record_install(info) + v = info.get("verified") or {} + done(f"devkit {rec['version']} ({rec['commit'][:7]}) {'switched to' if info.get('reused') else 'pinned'} in " + f"{tilde(rec['path'])}: {info['files']} files, read-only, SHA-256 manifest · {v.get('detail', '')}") + if v.get("how") == "unverified": + note("installed without verification; recorded in the audit (`dev audit`)") + return Path(rec["path"]) + + +def _hook_copies() -> list: + """[(where, devkit copy it runs)] for each devkit command in Claude's settings (hooks, status line), Claude's + managed settings, Codex's hooks and ~/.local/bin/dev.""" + from . import policy + out = [] + + def copy_of(command: str) -> Path: + try: + exe = shlex.split(command)[0] + except (ValueError, IndexError): + exe = (command.split() or [""])[0] + return Path(exe).parent.parent + + def scan(label: str, data): + for event, groups in sorted(((data or {}).get("hooks") or {}).items()): + for g in groups or []: + for h in (g or {}).get("hooks") or []: + if is_devkit_hook(h): + out.append((f"{label} {event}", copy_of(h["command"]))) + s = read_json(CLAUDE_HOME / "settings.json", {}) or {} + scan("Claude", s) + sl = str((s.get("statusLine") or {}).get("command") or "") + if "hook statusline" in sl: + out.append(("Claude status line", copy_of(sl))) + if policy.MANAGED_FILE.exists(): + scan("Claude managed settings", read_json(policy.MANAGED_FILE, {})) + scan("Codex", read_json(CODEX_HOME / "hooks.json", {})) + link = core.HOME / ".local" / "bin" / "dev" + if os.path.lexists(link): + out.append(("~/.local/bin/dev", Path(os.path.realpath(link)).parent.parent)) + return out + + +def pin_checks() -> list: + """The doctor's checks of the pinned install -> [(name, ok, detail, fix)].""" + rec = core.install_record() + path = Path(rec["path"]) if rec.get("path") else None + if not path or not core.is_pinned(path): + return [("hooks run a pinned copy", False, "not pinned: the hooks run whatever the settings name" + + (f" ({tilde(path)} is gone)" if path else ""), "dev install")] + man = read_json(path / core.PIN_MANIFEST, {}) or {} + copies = _hook_copies() + off = [(w, c) for w, c in copies if core.real(c) != core.real(path)] + src = rec.get("source") or "" + detail = (f"{len(copies)} commands run {tilde(path)}" if not off else "; ".join( + f"{w} runs {tilde(c)}" + (" (the checkout)" if src and core.real(c) == core.real(src) else "") + for w, c in off[:4]) + (f" (+{len(off) - 4} more)" if len(off) > 4 else "")) + out = [("hooks run the pinned copy", bool(copies) and not off, detail if copies else "no devkit hooks found", + "dev install --only cli,claude,codex")] + same = man.get("version") == rec.get("version") and man.get("commit") == rec.get("commit") + out.append(("pinned copy is the installed version", same, + f"devkit {rec.get('version')} ({(rec.get('commit') or '')[:7]}) · " + f"{(rec.get('verified') or {}).get('detail', 'verification unknown')}" if same else + f"install.json says {rec.get('version')} ({(rec.get('commit') or '')[:7]}), the copy holds " + f"{man.get('version')} ({(man.get('commit') or '')[:7]})", "dev install")) + problems = verify_pin(path) + if rec.get("manifest_sha256") and (path / core.PIN_MANIFEST).is_file() \ + and _sha256(path / core.PIN_MANIFEST) != rec["manifest_sha256"]: + problems.insert(0, f"changed {core.PIN_MANIFEST}") + out.append(("pinned copy matches its manifest", not problems, + f"{len(man.get('files') or {})} files, each as pinned, read-only" if not problems else + f"changed after `dev install` pinned it ({len(problems)}): " + "; ".join(problems[:4]), + "dev install (rebuilds the copy from git)")) + return out + + # --------------------------------------------------------------------------- steps def ensure_config(): @@ -152,7 +601,8 @@ def install_cli(): bindir = HOME / ".local" / "bin" bindir.mkdir(parents=True, exist_ok=True) link = bindir / "dev" - target = DEVKIT_HOME / "bin" / "dev" + home = core.runtime_home() # the pinned copy: `dev` typed in a shell runs what the hooks run + target = home / "bin" / "dev" if link.is_symlink() or link.exists(): if link.resolve() != target.resolve(): backup(link) if not link.is_symlink() else None @@ -160,7 +610,7 @@ def install_cli(): if not link.exists(): link.symlink_to(target) done(f"{tilde(link)} → {tilde(target)}") - block = f'{BLOCK_START}\n[ -f "{DEVKIT_HOME}/shell/devkit.zsh" ] && source "{DEVKIT_HOME}/shell/devkit.zsh"\n{BLOCK_END}\n' + block = f'{BLOCK_START}\n[ -f "{home}/shell/devkit.zsh" ] && source "{home}/shell/devkit.zsh"\n{BLOCK_END}\n' text = ZSHRC.read_text() if ZSHRC.exists() else "" if BLOCK_START in text: new = re.sub(re.escape(BLOCK_START) + r".*?" + re.escape(BLOCK_END) + r"\n?", block, text, flags=re.S) @@ -193,11 +643,16 @@ def _setup_git(): done(f"user.name / user.email = {name} <{email}>") rc, _, err = sh(["gh", "auth", "setup-git"], timeout=30) done("gh is git's credential helper for github.com") if rc == 0 else fail(f"gh auth setup-git: {err}") - inc = str(CONF / "git" / "gitconfig") + conf = core.runtime_home() / "config" # the pinned copy's; each new pin replaces the path of the one before + inc = str(conf / "git" / "gitconfig") _, includes, _ = sh(["git", "config", "--global", "--get-all", "include.path"]) + for old in includes.splitlines(): + if old != inc and old.endswith("/config/git/gitconfig") and ( + core.is_under(old, core.pin_root()) or core.real(old) == core.real(core.source_home() / "config" / "git" / "gitconfig")): + sh(["git", "config", "--global", "--fixed-value", "--unset-all", "include.path", old]) if inc not in includes.splitlines(): sh(["git", "config", "--global", "--add", "include.path", inc]) - sh(["git", "config", "--global", "core.excludesFile", str(CONF / "git" / "ignore")]) + sh(["git", "config", "--global", "core.excludesFile", str(conf / "git" / "ignore")]) done("shared git defaults included; global ignore covers agent artefacts (.claude/worktrees, …)") @@ -237,7 +692,7 @@ def setup_handoff(): core.git(HANDOFF, "init", "-q", "-b", "main") for f in tpl.iterdir(): if not (HANDOFF / f.name).exists(): - shutil.copy2(f, HANDOFF / f.name) + shutil.copyfile(f, HANDOFF / f.name) # not copy2: the pinned copy's modes are read-only for d in ("projects", "log", "plans"): (HANDOFF / d).mkdir(exist_ok=True) (HANDOFF / d / ".gitkeep").touch() @@ -257,7 +712,7 @@ def setup_handoff(): else: for f in tpl.iterdir(): if not (HANDOFF / f.name).exists(): - shutil.copy2(f, HANDOFF / f.name) + shutil.copyfile(f, HANDOFF / f.name) remote = core.gitout(HANDOFF, "remote", "get-url", "origin") done(f"present · origin {remote or 'none'}") if (HANDOFF / ".git").exists(): @@ -269,7 +724,7 @@ def render_instructions() -> str: base = (DEVKIT_HOME / "agents" / "AGENTS.md").read_text() personal_f = CONFIG_FILE.parent / "AGENTS.personal.md" personal = personal_f.read_text() if personal_f.exists() else "" - header = (f"{GENERATED_MARK} {core.VERSION} from {tilde(DEVKIT_HOME)}/agents/AGENTS.md" + header = (f"{GENERATED_MARK} {core.VERSION} from {tilde(core.source_home())}/agents/AGENTS.md" f"{' + ' + tilde(personal_f) if personal else ''}; edit those and run `dev install` -->\n") return header + render(base) + ("\n\n## Personal additions\n\n" + personal if personal else "") @@ -379,8 +834,12 @@ def app_checks(codex_plugins=None) -> list: f"{st.get('version')})") detail += "; turned off in Settings → Extensions" if st.get("enabled") is False else ", enabled" lc = st.get("launcher") or {} + rt = core.runtime_home() if lc and not lc.get("ok", True): ok_, detail = False, detail + f"; launcher: {lc.get('reason')}" + elif lc.get("home") and core.is_pinned(rt) and core.real(lc["home"]) != core.real(rt): + ok_, detail = False, detail + (f"; it runs {tilde(lc['home'])}, not the pinned copy: clear its " + f"devkit folder setting in Settings → Extensions") res.append(("Claude Desktop", "devkit extension", ok_, detail, "dev install --only desktop")) ok_b, detail_b = desktop.bypass_mode() if ok_b is not None: @@ -482,7 +941,7 @@ def write_claude_settings(include_hooks=True): mine = "hook statusline" in str(sl.get("command") or "") if (core.CFG.get("claude") or {}).get("statusline", True) and (not sl or mine): # devkit's status line records Claude's usage windows (doctor, dashboard); a status line of yours is kept - new["statusLine"] = {"type": "command", "command": f"'{DEVKIT_HOME}/bin/dev' hook statusline --agent claude", + new["statusLine"] = {"type": "command", "command": f"'{core.runtime_home()}/bin/dev' hook statusline --agent claude", "padding": 0} elif mine and not (core.CFG.get("claude") or {}).get("statusline", True): new.pop("statusLine", None) @@ -696,8 +1155,10 @@ def build_codex_plugin() -> Path: _sh.rmtree(tmp) tmp.mkdir(parents=True) for name in (".codex-plugin", "assets", "skills"): - if (src / name).exists(): - _sh.copytree(src / name, tmp / name) + if (src / name).exists(): # without the modes: the pinned copy is read-only, this build must stay writable + _sh.copytree(src / name, tmp / name, copy_function=_sh.copyfile) + for d, _, _ in os.walk(tmp): + os.chmod(d, 0o755) for cmd in sorted((src / "commands").glob("*.md")): text = cmd.read_text() m = re.match(r"---\s*\n(.*?)\n---\s*\n(.*)$", text, re.S) @@ -919,8 +1380,24 @@ def setup_desktop(): + (f" (replaces {st.get('installed_version')})" if st.get("installed") else "")) -def run_install(only=None): - print(ui.banner("install")) +def run_install(only=None, unverified_why=None, version=None, as_pinned=False) -> int: + """`dev install`. From a checkout it pins the checkout (or switches to `version`), then starts itself again from + that pinned copy (`--as-pinned`), which runs the steps, so every path they write names the copy; `only` re-applies + some steps from the installed copy without pinning. -> exit code (1 when the copy was refused).""" + if not as_pinned: + print(ui.banner("install")) + try: + target = _choose_copy(only, unverified_why, version, as_pinned) + except PinError as e: + fail(str(e)) + _audit("install.pin", tilde(core.source_home()), str(e), outcome="refused") + return 1 + if core.real(target) != core.real(DEVKIT_HOME): + argv = [sys.executable, str(target / "bin" / "dev"), "install", "--as-pinned"] + argv += ["--only", ",".join(sorted(only))] if only else [] + sys.stdout.flush() + sys.stderr.flush() + os.execv(sys.executable, argv) steps = [("config", ensure_config), ("tools", setup_tools), ("cli", install_cli), ("git", setup_git), ("handoff", setup_handoff), ("instructions", write_agent_instructions), ("skills", install_skills), ("claude", setup_claude), ("codex", setup_codex), ("desktop", setup_desktop), ("docker", setup_docker), ("docs", setup_docs_tools), @@ -935,7 +1412,13 @@ def run_install(only=None): fn() except Exception as e: # keep going; doctor reports what is left fail(f"{name}: {e}") + if not only: + step("Pinned copies") + removed = prune_pins() + done(f"{len(pinned_copies())} kept in {tilde(core.pin_root())} (the newest {PIN_KEEP} and any in use)" + + (f"; removed {', '.join(removed)}" if removed else "") + " · rollback: `dev install --version `") print(f"\n{ui.ARROW} Run {ui.bold('dev doctor')} to verify, and open a new terminal (or `exec zsh`).") + return 0 # --------------------------------------------------------------------------- doctor @@ -965,7 +1448,8 @@ def check(group, name, ok, detail="", fix=""): check("GitHub", "git uses gh credentials", "gh auth git-credential" in helper, helper.splitlines()[-1] if helper else "unset", "gh auth setup-git") exc = core.sh(["git", "config", "--global", "core.excludesFile"])[1] - check("GitHub", "global gitignore", exc == str(CONF / "git" / "ignore"), tilde(exc) if exc else "unset", "dev install --only git") + check("GitHub", "global gitignore", exc == str(core.runtime_home() / "config" / "git" / "ignore"), + tilde(exc) if exc else "unset", "dev install --only git") check("Workspace", "dev on PATH", shutil.which("dev") is not None, shutil.which("dev") or "missing", "dev install --only cli") check("Workspace", "shell integration", ZSHRC.exists() and BLOCK_START in ZSHRC.read_text(), tilde(ZSHRC), "dev install --only cli") @@ -1011,6 +1495,9 @@ def _canon_ok(m): check("Agents", f"skills in {tilde(home)}", bool(mine) and not broken, f"{len(mine) - len(broken)}/{len(mine)} linked" + (f"; missing {', '.join(broken[:3])}" if broken else ""), "dev install --only skills") + for name, ok, detail, fix in pin_checks(): + check("Pinned install", name, ok, detail, fix) + s = read_json(CLAUDE_HOME / "settings.json", {}) or {} check("Claude", "bypassPermissions default", (s.get("permissions") or {}).get("defaultMode") == "bypassPermissions", (s.get("permissions") or {}).get("defaultMode", "unset"), "dev install --only claude") diff --git a/tests/test_pinned_install.py b/tests/test_pinned_install.py new file mode 100644 index 0000000..d4e6602 --- /dev/null +++ b/tests/test_pinned_install.py @@ -0,0 +1,428 @@ +# Copyright (c) 2026 Firefly Software Solutions Inc. +# SPDX-License-Identifier: Apache-2.0 +"""The pinned install (spec C10, security finding K2): `dev install` pins a read-only copy of one devkit commit with a +manifest of checksums, and the hooks, ~/.local/bin/dev and the status line run that copy, never the checkout. + +Everything runs in a sandbox: a temporary HOME (core.HOME, STATE, CLAUDE_HOME, CODEX_HOME, the audit and the managed +settings file moved into it), a temporary devkit-like repository whose origin is a local repository with a release +tag, and a fake Codex app-server. No network, no real `dev install`, nothing in ~/.claude, ~/.codex or ~/.local. +""" +import contextlib +import hashlib +import importlib.util +import io +import json +import os +import shutil +import stat +import subprocess +import sys +import tempfile +import unittest +from argparse import Namespace +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "lib")) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from devkit import audit, cli, codexrpc, core, gitx, policy, registry, setup # noqa: E402 +from test_install import Machine # noqa: E402 (install.sh against stub brew, gh and a local repository) + +ISOLATED = {"GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_NOSYSTEM": "1"} +GIT_ENV = dict(os.environ, GIT_AUTHOR_NAME="Ada", GIT_AUTHOR_EMAIL="ada@example.com", GIT_COMMITTER_NAME="Ada", + GIT_COMMITTER_EMAIL="ada@example.com", **ISOLATED) +DEV_STUB = "#!/usr/bin/env python3\nimport sys\nsys.exit(0)\n" + + +def git(cwd, *args) -> str: + return subprocess.run(["git", *args], cwd=str(cwd), check=True, capture_output=True, text=True, + env=GIT_ENV).stdout.strip() + + +def sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def commit(repo: Path, version: str, tag: bool = False, **files): + """Commit VERSION and `files` ({"lib/devkit/guard.py": text}); tag v (annotated) when asked.""" + (repo / "VERSION").write_text(version + "\n") + for rel, text in files.items(): + (repo / rel).parent.mkdir(parents=True, exist_ok=True) + (repo / rel).write_text(text) + git(repo, "add", "-A") + git(repo, "commit", "-q", "-m", version) + if tag: + git(repo, "tag", "-a", "v" + version, "-m", "v" + version) + + +class Execd(Exception): + """os.execv was reached: the install would continue in another process.""" + + +class FakeCodexServer(codexrpc.CodexRPC): + """codex app-server as the installer uses it. hooks/list hashes each command of hooks.json, and a hook counts as + trusted only while its trusted hash is its current hash, as Codex does. trust_hooks and hooks are devkit's own.""" + trusted = {} + hooks_file = None + + def __init__(self, timeout=30): + pass + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def call(self, method, params=None, timeout=None): + assert method == "hooks/list", method + hooks = [] + for event, groups in json.loads(Path(self.hooks_file).read_text())["hooks"].items(): + for i, group in enumerate(groups): + for j, h in enumerate(group["hooks"]): + key, cur = f"{event}:{i}:{j}", hashlib.sha256(h["command"].encode()).hexdigest() + want = FakeCodexServer.trusted.get(key) + hooks.append({"key": key, "eventName": event, "sourcePath": str(self.hooks_file), + "currentHash": cur, "trustStatus": "untrusted" if want is None else + "trusted" if want == cur else "modified"}) + return {"data": [{"hooks": hooks}]} + + def write(self, edits, reload=True): + for key, value, _ in edits: + if key == "hooks.state": + FakeCodexServer.trusted.update({k: v["trusted_hash"] for k, v in value.items()}) + + +class Sandbox(unittest.TestCase): + """A temporary HOME holding every path devkit installs to, and `self.src`: a devkit-like checkout whose origin has + the release v26.11.3 on its HEAD.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + root = Path(self.tmp.name).resolve() + self.root, self.home = root, root / "home" + self.home.mkdir() + self.state = self.home / ".local" / "state" / "devkit" + for p in (mock.patch.object(core, "HOME", self.home), mock.patch.object(setup, "HOME", self.home), + mock.patch.object(core, "STATE", self.state), mock.patch.object(setup, "STATE", self.state), + mock.patch.object(setup, "CLAUDE_HOME", self.home / ".claude"), + mock.patch.object(setup, "CODEX_HOME", self.home / ".codex"), + mock.patch.object(setup, "ZSHRC", self.home / ".zshrc"), + mock.patch.object(setup, "backup", lambda *a, **k: None), + mock.patch.object(setup, "_QUIET", [True]), + mock.patch.object(audit, "AUDIT", self.state / "audit"), + mock.patch.object(policy, "MANAGED_FILE", self.home / "managed-settings.json"), + mock.patch.dict(core.CFG["claude"], {"default_mode": "bypassPermissions", "statusline": True}), + mock.patch.dict(core.GIT_ENV, ISOLATED)): + p.start() + self.addCleanup(p.stop) + self.origin, self.src = root / "origin", root / "devkit" + (self.origin / "bin").mkdir(parents=True) + (self.origin / "bin" / "dev").write_text(DEV_STUB) + (self.origin / "bin" / "dev").chmod(0o755) + git(self.origin, "init", "-q", "-b", "main") + commit(self.origin, "26.11.3", tag=True, **{"lib/devkit/__init__.py": "", "lib/devkit/guard.py": "DENY = True\n", + "config/git/ignore": ".DS_Store\n", "README.md": "devkit\n"}) + git(root, "clone", "-q", str(self.origin), str(self.src)) + + def tearDown(self): + for d, _, _ in os.walk(self.root): # pinned copies are read-only + os.chmod(d, 0o755) + self.tmp.cleanup() + + def head(self, repo=None) -> str: + return git(repo or self.src, "rev-parse", "HEAD") + + def install(self, source=None, why=None) -> Path: + """What a full `dev install` records before it continues in the copy: pin, then install.json.""" + info = setup.pin(source or self.src, unverified_why=why) + setup.record_install(info) + return Path(info["path"]) + + def settings(self) -> dict: + return json.loads((self.home / ".claude" / "settings.json").read_text()) + + def audits(self) -> list: + return [json.loads(ln) for f in sorted((self.state / "audit").glob("*.jsonl")) for ln in f.read_text().splitlines()] + + def checks(self) -> dict: + return {name: (ok, detail, fix) for name, ok, detail, fix in setup.pin_checks()} + + +class PinTests(Sandbox): + def test_a_release_is_pinned_read_only_with_a_checksum_per_file(self): + info = setup.pin(self.src) + dest = Path(info["path"]) + self.assertEqual(dest, core.pin_root() / f"26.11.3-{self.head()[:7]}") + self.assertEqual(info["verified"]["how"], "release") # v26.11.3 is on origin, on this commit + man = json.loads((dest / core.PIN_MANIFEST).read_text()) + self.assertEqual((man["commit"], man["version"]), (self.head(), "26.11.3")) + for rel in git(self.src, "ls-files").splitlines(): + self.assertEqual((dest / rel).read_bytes(), (self.src / rel).read_bytes(), rel) + self.assertEqual(man["files"][rel], sha256(self.src / rel), rel) + if not getattr(sys, "pycache_prefix", None): # Apple's /usr/bin/python3 caches in ~/Library/Caches instead + pyc = Path(importlib.util.cache_from_source(str(dest / "lib" / "devkit" / "guard.py"))) + self.assertTrue(pyc.exists()) # compiled before it became read-only: hooks do not recompile it each time + self.assertEqual(man["files"][pyc.relative_to(dest).as_posix()], sha256(pyc)) + mode = lambda rel: stat.S_IMODE((dest / rel).stat().st_mode) # noqa: E731 + self.assertEqual((mode("README.md"), mode("bin/dev"), mode("."), mode("lib/devkit")), + (0o444, 0o555, 0o555, 0o555)) # bin/dev keeps its x bit: the hooks run it + with self.assertRaises(PermissionError): + (dest / "lib" / "devkit" / "guard.py").write_text("DENY = False\n") + self.assertEqual(setup.verify_pin(dest), []) + + def test_a_checkout_with_uncommitted_changes_to_tracked_files_is_refused(self): + (self.src / "lib" / "devkit" / "guard.py").write_text("DENY = False\n") + with self.assertRaises(setup.PinError) as e: + setup.pin(self.src) + self.assertIn("uncommitted changes to 1 tracked file(s) (lib/devkit/guard.py)", str(e.exception)) + self.assertFalse(core.pin_root().exists() and any(core.pin_root().iterdir())) + git(self.src, "checkout", "--", "lib/devkit/guard.py") + (self.src / "notes.txt").write_text("mine") # untracked: not part of HEAD, so it neither blocks nor is copied + dest = Path(setup.pin(self.src)["path"]) + self.assertFalse((dest / "notes.txt").exists()) + + def test_an_untested_head_is_refused_without_unverified_and_a_reason(self): + commit(self.src, "26.11.4", **{"lib/devkit/guard.py": "DENY = 'maybe'\n"}) + with self.assertRaises(setup.PinError) as e: + setup.pin(self.src) + self.assertIn("is not verified", str(e.exception)) + self.assertIn('dev install --unverified --why ""', str(e.exception)) + git(self.src, "tag", "v26.11.4") # a tag only this checkout has (anyone, an agent too, can make one) + with self.assertRaises(setup.PinError) as e: + setup.pin(self.src) + self.assertIn("origin has no tag v26.11.4", str(e.exception)) + tree = git(self.src, "rev-parse", "HEAD^{tree}") + gitx.record_evidence(gitx.ctx(self.src), "attested", tree) # `--ci-passed`: a claim, not a run + with self.assertRaises(setup.PinError): + setup.pin(self.src) + self.assertFalse(core.pin_root().exists() and any(core.pin_root().iterdir())) + with contextlib.redirect_stderr(io.StringIO()), contextlib.redirect_stdout(io.StringIO()): + rc = cli.cmd_install(Namespace(unverified=True, why=" ", only=None, version=None, as_pinned=False)) + self.assertEqual(rc, 2) + info = setup.pin(self.src, unverified_why="trying the fix for K2 before the release") + self.assertEqual(info["verified"]["how"], "unverified") + rec = [r for r in self.audits() if r["action"] == "install.pin"][-1] + self.assertEqual(rec["outcome"], "unverified") + self.assertIn("trying the fix for K2 before the release", rec["detail"]) + + def test_a_head_whose_test_gate_passed_is_pinned(self): + commit(self.src, "26.11.4", **{"lib/devkit/guard.py": "DENY = True # tested\n"}) + gitx.record_evidence(gitx.ctx(self.src), "run", git(self.src, "rev-parse", "HEAD^{tree}")) # dev ci --run + info = setup.pin(self.src) + self.assertEqual((info["verified"]["how"], info["version"]), ("tests", "26.11.4")) + + def test_a_copy_that_does_not_compile_is_refused_even_unverified(self): + commit(self.src, "26.11.4", **{"lib/devkit/guard.py": "def deny(:\n"}) + with self.assertRaises(setup.PinError) as e: + setup.pin(self.src, unverified_why="in a hurry") + self.assertIn("lib/devkit/guard.py does not compile", str(e.exception)) + self.assertEqual(sorted(core.pin_root().iterdir()), []) # the half-built copy is gone too + + +class HookTests(Sandbox): + def test_hooks_the_status_line_and_dev_run_the_pinned_copy(self): + pin = self.install() + setup.write_claude_settings(include_hooks=True) + s = self.settings() + commands = [h["command"] for groups in s["hooks"].values() for g in groups for h in g["hooks"]] + self.assertGreaterEqual(len(commands), 6) + for c in commands + [s["statusLine"]["command"]]: + self.assertTrue(c.startswith(f"'{pin}/bin/dev' hook "), c) + self.assertNotIn(str(core.DEVKIT_HOME), json.dumps(s)) # never the checkout this code runs from + setup.install_cli() + self.assertEqual(os.path.realpath(self.home / ".local" / "bin" / "dev"), os.path.realpath(pin / "bin" / "dev")) + self.assertIn(f'source "{pin}/shell/devkit.zsh"', (self.home / ".zshrc").read_text()) + + def test_codex_hooks_move_to_the_new_copy_and_are_trusted_again(self): + hooks_f = self.home / ".codex" / "hooks.json" + hooks_f.parent.mkdir() + FakeCodexServer.trusted, FakeCodexServer.hooks_file = {}, hooks_f + with mock.patch.object(codexrpc, "CodexRPC", FakeCodexServer), \ + mock.patch.object(registry, "discover", lambda: ([], [], [])): + self.install() + setup.write_codex_config(quiet=True) + first = dict(FakeCodexServer.trusted) + commit(self.origin, "26.11.4", tag=True) # the next release, fetched as `dev update` does + git(self.src, "fetch", "-q", "--tags") + git(self.src, "-c", "advice.detachedHead=false", "checkout", "-q", "v26.11.4") + pin = self.install() + setup.write_codex_config(quiet=True) + commands = [h["command"] for gs in json.loads(hooks_f.read_text())["hooks"].values() for g in gs + for h in g["hooks"]] + self.assertTrue(commands) + self.assertTrue(all(c.startswith(f"'{pin}/bin/dev' hook ") for c in commands), commands) + statuses = {h["trustStatus"] for h in FakeCodexServer().hooks(self.home)} + self.assertEqual(statuses, {"trusted"}) # the changed commands were trusted again, not left "modified" + self.assertEqual(set(first), set(FakeCodexServer.trusted)) + self.assertTrue(all(first[k] != FakeCodexServer.trusted[k] for k in first)) + + def test_install_from_the_checkout_continues_in_the_pinned_copy(self): + out = io.StringIO() + with mock.patch.object(core, "source_home", lambda: self.src), \ + mock.patch("os.execv", side_effect=Execd) as execv, contextlib.redirect_stdout(out): + with self.assertRaises(Execd): + setup.run_install() + pin = core.install_record()["path"] + self.assertEqual(execv.call_args[0][1][1:], [f"{pin}/bin/dev", "install", "--as-pinned"]) + commit(self.src, "26.11.4") # untested: `--only` re-applies from the installed copy and pins nothing + with self.assertRaises(Execd): + setup.run_install(only={"claude"}) + self.assertEqual(execv.call_args[0][1][1:], [f"{pin}/bin/dev", "install", "--as-pinned", "--only", "claude"]) + self.assertEqual(core.install_record()["path"], pin) + (self.src / "README.md").write_text("changed\n") # a dirty checkout: refused, nothing runs + execv.reset_mock() + self.assertEqual(setup.run_install(), 1) + execv.assert_not_called() + self.assertIn("uncommitted changes", out.getvalue()) + + +class PruneTests(Sandbox): + def test_old_copies_are_pruned_but_never_one_in_use(self): + names = [] + for i in range(6): + commit(self.src, f"26.11.{4 + i}") + names.append(Path(setup.pin(self.src, unverified_why="prune test")["path"]).name) + setup.record_install(setup.select_pin("26.11.4")) # rolled back to the oldest: it is in use + (self.home / ".claude").mkdir() + (self.home / ".claude" / "settings.json").write_text(json.dumps({"hooks": {"PreToolUse": [{"hooks": [ + {"type": "command", "command": f"'{core.pin_root() / names[1]}/bin/dev' hook guard --agent claude"}]}]}})) + leftover = core.pin_root() / f".26.11.99-abcdef0.tmp-{os.getpid()}" # an install that stopped half-way + (leftover / "lib").mkdir(parents=True) + os.chmod(leftover / "lib", 0o555) + os.utime(leftover, (1_000, 1_000)) + removed = setup.prune_pins() + self.assertEqual(removed, [names[2]]) # beyond the newest three and used by nothing + self.assertEqual(sorted(d.name for d in core.pin_root().iterdir()), sorted(names[:2] + names[3:])) + + +class DoctorTests(Sandbox): + def test_doctor_fails_on_a_tampered_file_and_install_rebuilds_the_copy(self): + pin = self.install() + setup.write_claude_settings(include_hooks=True) + setup.install_cli() + self.assertTrue(all(ok for ok, _, _ in self.checks().values()), self.checks()) + guard = pin / "lib" / "devkit" / "guard.py" + os.chmod(guard, 0o644) + guard.write_text("DENY = False\n") + os.chmod(guard, 0o444) + ok, detail, fix = self.checks()["pinned copy matches its manifest"] + self.assertFalse(ok) + self.assertIn("changed lib/devkit/guard.py", detail) + self.assertTrue(fix.startswith("dev install"), fix) + man_f = pin / core.PIN_MANIFEST # a manifest rewritten to match the change is noticed too + man = json.loads(man_f.read_text()) + man["files"]["lib/devkit/guard.py"] = sha256(guard) + os.chmod(pin, 0o755) + os.chmod(man_f, 0o644) + man_f.write_text(json.dumps(man)) + os.chmod(man_f, 0o444) + os.chmod(pin, 0o555) + ok, detail, _ = self.checks()["pinned copy matches its manifest"] + self.assertFalse(ok) + self.assertIn(f"changed {core.PIN_MANIFEST}", detail) + setup.record_install(setup.pin(self.src)) # the fix: `dev install` rebuilds the copy from git + self.assertEqual(guard.read_text(), "DENY = True\n") + self.assertTrue(all(ok for ok, _, _ in self.checks().values()), self.checks()) + self.assertIn("tampered", [r["outcome"] for r in self.audits() if r["action"] == "install.pin"]) + + def test_doctor_fails_when_a_hook_runs_the_checkout_or_nothing_is_pinned(self): + self.assertEqual(self.checks(), {"hooks run a pinned copy": ( + False, "not pinned: the hooks run whatever the settings name", "dev install")}) + self.install() + setup.write_claude_settings(include_hooks=True) + s = self.settings() + s["hooks"]["PreToolUse"][0]["hooks"][0]["command"] = f"'{core.DEVKIT_HOME}/bin/dev' hook guard --agent claude" + (self.home / ".claude" / "settings.json").write_text(json.dumps(s)) + ok, detail, _ = self.checks()["hooks run the pinned copy"] + self.assertFalse(ok) + self.assertIn(f"Claude PreToolUse runs {core.tilde(core.DEVKIT_HOME)}", detail) + + +class RealTreeTests(Sandbox): + """devkit itself, as this working tree has it, committed in a new repository and pinned.""" + + def pin_devkit(self) -> tuple: + clone = self.root / "checkout" + shutil.copytree(ROOT, clone, ignore=shutil.ignore_patterns(".git", "__pycache__", "dist", ".DS_Store", + "node_modules", ".venv")) + git(clone, "init", "-q", "-b", "main") + git(clone, "add", "-A") + git(clone, "commit", "-q", "-m", "devkit") + return clone, self.install(clone, why="pinned-install test") + + def test_a_broken_guard_in_the_checkout_leaves_the_pinned_guard_denying(self): + """The acceptance test of K2: a syntax error in the checkout's guard.py, and the hooks still deny.""" + clone, _ = self.pin_devkit() + command = next(h["command"] for g in setup.load_conf("claude/settings.json")["hooks"]["PreToolUse"] + for h in g["hooks"] if " hook guard " in h["command"]) + (clone / "lib" / "devkit" / "guard.py").write_text("def deny(:\n") # a member's half-done edit + payload = json.dumps({"session_id": "k2", "hook_event_name": "PreToolUse", "tool_name": "Bash", + "tool_input": {"command": "gh pr merge 12"}, "cwd": str(self.home)}) + env = {"HOME": str(self.home), "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin", "LANG": "en_US.UTF-8", + **ISOLATED} + run = lambda argv: subprocess.run(argv, input=payload, capture_output=True, text=True, env=env, # noqa: E731 + cwd=str(self.home), timeout=120) + pinned = run(["/bin/sh", "-c", command]) + decision = json.loads(pinned.stdout)["hookSpecificOutput"]["permissionDecision"] + self.assertEqual(decision, "deny", pinned.stderr) + broken = run([sys.executable, str(clone / "bin" / "dev"), "hook", "guard", "--agent", "claude"]) + self.assertNotIn("deny", broken.stdout) # what the hooks ran before: the broken guard fails open + + def test_what_install_builds_from_the_pinned_copy_stays_writable(self): + _, pin = self.pin_devkit() + handoff = self.home / "Development" / ".handoff" + handoff.mkdir(parents=True) + git(handoff, "init", "-q", "-b", "main") + market = self.home / ".local" / "share" / "devkit" / "codex-marketplace" + with mock.patch.object(setup, "DEVKIT_HOME", pin), mock.patch.object(setup, "CODEX_MARKET", market), \ + mock.patch.object(setup, "HANDOFF", handoff), mock.patch.object(core, "handoff_repo", lambda: "ada/handoff"): + setup.build_codex_plugin() + setup.build_codex_plugin() # the second build replaces the first: it must be able to delete it + setup.setup_handoff() + built = [market / "plugin"] + [Path(d) for d, _, _ in os.walk(market / "plugin")] + self.assertTrue((market / "plugin" / "skills").is_dir()) + self.assertTrue(all(os.stat(d).st_mode & stat.S_IWUSR for d in built)) + copied = [f for f in handoff.iterdir() if f.is_file() and (pin / "templates" / "handoff" / f.name).exists()] + self.assertTrue(copied) + self.assertTrue(all(f.stat().st_mode & stat.S_IWUSR for f in copied), copied) # the developer edits them + + +class InstallScriptTests(unittest.TestCase): + """install.sh ends by naming the pinned copy that the wizard's `dev install` made.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.m = Machine(Path(self.tmp.name)) + self.m.signed_in.touch() + + def tearDown(self): + self.tmp.cleanup() + + def test_the_summary_names_the_pinned_copy(self): + clone = self.m.root / "work" / "devkit" + subprocess.run(["git", "clone", "-q", str(self.m.remote), str(clone)], check=True, env=GIT_ENV, + capture_output=True) + (clone / "install.sh").write_bytes((ROOT / "install.sh").read_bytes()) + (clone / "bin" / "dev").write_text( # `dev setup` as far as it matters here: install.json and the copy + "#!/usr/bin/env python3\nimport json, os\nhome = os.environ['HOME']\n" + "pin = os.path.join(home, '.local/share/devkit/releases/26.12.0-dev-abc1234')\n" + "os.makedirs(os.path.join(pin, 'bin'))\nopen(os.path.join(pin, 'bin', 'dev'), 'w').close()\n" + "json.dump({'version': '26.12.0-dev', 'commit': 'abc1234def', 'path': pin},\n" + " open(os.path.join(home, '.local/share/devkit/install.json'), 'w'))\n") + rc, out = self.m.run(["/bin/bash", str(clone / "install.sh"), "--skip-packages"]) + self.assertEqual(rc, 0, out) + self.assertIn("not a release: dev install pins it for the hooks only after its tests pass here", out) + self.assertIn("the hooks and the guard run a read-only copy of devkit 26.12.0-dev (abc1234) in " + "~/.local/share/devkit/releases/26.12.0-dev-abc1234", out) + + def test_the_summary_says_when_nothing_is_pinned(self): + rc, out = self.m.pipe() + self.assertEqual(rc, 0, out) + self.assertIn("the hooks are not pinned yet: run dev install", out) + + +if __name__ == "__main__": + unittest.main() From 801152716aa0f7d8bd00dfc87eb7186093957d8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Fri, 6 Nov 2026 11:15:46 -0800 Subject: [PATCH 039/297] chore(crew): mark the pinned install enforced in the rule matrix Kid's 595b70b pins a read-only, checksummed copy that the hooks and the guard run from; this Mac moves to it at the final install from v26.11.3. --- lib/devkit/crew.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/devkit/crew.py b/lib/devkit/crew.py index 95cbde6..d9c0a92 100644 --- a/lib/devkit/crew.py +++ b/lib/devkit/crew.py @@ -440,7 +440,8 @@ def body(m: dict, roster=None, alias=None) -> str: ("Members get the knowledge of their kind of work", "dev crew brief and the Claude launch hook", "devkit.crew.brief_skills", "enforced"), ("Agents cannot write devkit's state or configuration", "PreToolUse: protected paths", "K1 protect devkit state and config", "planned"), ("Escape hatches are refused to members and audited", "PreToolUse: flags and variables", "F8 escape hatches and tool coverage", "planned"), - ("The guard runs from a verified copy, not the checkout", "dev install", "pinned install first", "planned"), + ("The guard runs from a verified copy, not the checkout", "dev install (pins a read-only, checksummed release)", + "devkit.setup.pin", "enforced"), ("Team changes to the crew carry a reason", "dev crew set and dev crew raci --set", "devkit.crew._team_write", "enforced"), ("Questions and decisions go through dev crew ask", "a member's report and messages", "a report may also name its questions", "guidance"), ) From 73f56adf4bcc623426e8a5e20fb28924cd481232 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Fri, 6 Nov 2026 11:17:21 -0800 Subject: [PATCH 040/297] fix(state): delete a finished session's record under its lock Tank's F1 found that dev sync --finish deleted the session record directly, so a hook that had already read it could write it back right after SessionEnd. It now deletes through state.remove, under the record's lock. The rule matrix lists the state helper as enforced. --- lib/devkit/cli.py | 5 +++-- lib/devkit/crew.py | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/devkit/cli.py b/lib/devkit/cli.py index c0cc7da..1d195ee 100644 --- a/lib/devkit/cli.py +++ b/lib/devkit/cli.py @@ -716,10 +716,11 @@ def cmd_sync(a): ui.warn(f"dashboard: {e}") if a.session and a.finish: agent, _, sid = a.session.partition(":") + from . import state as _state for suffix in (".json", ".stop"): f = STATE / "sessions" / f"{agent}-{sid}{suffix}" - if f.exists(): - f.unlink() + if f.exists(): # under the record's lock, so a hook that already read it cannot write it back + _state.remove(f) if suffix == ".json" else f.unlink() commit_push(push=CFG["sync"].get("push", True) and not a.no_push, quiet=a.quiet) (STATE / "last-sync.json").write_text(json.dumps({"ts": time.time(), "changed": changed, "gc": [m for _, m in results]})) diff --git a/lib/devkit/crew.py b/lib/devkit/crew.py index d9c0a92..cca192f 100644 --- a/lib/devkit/crew.py +++ b/lib/devkit/crew.py @@ -413,6 +413,8 @@ def body(m: dict, roster=None, alias=None) -> str: # it cannot be checked mechanically. `dev crew rules` prints it. ENFORCEMENT = ( ("A member's report starts with its own status block", "SubagentStop: the crew canary", "devkit.hooks.subagent_stop", "enforced"), + ("Session and run records never lose a concurrent write", "every hook and dev command (one lock per session)", + "devkit.state.update", "enforced"), ("A member records its work with dev progress step", "SubagentStop: lane without updates", "devkit.progress.lane_unreported", "enforced"), ("A member edits only the files of its scope", "PreToolUse: Edit, Write, MultiEdit, apply_patch", "devkit.hooks._scope_violation", "enforced"), ("A member's file scope also holds for shell writes", "PreToolUse: read-only shell allowlist", "F7 read-only shell allowlist", "planned"), From a76e6e255020e4ca71ff5e2f648aea4018339921 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Fri, 6 Nov 2026 11:24:29 -0800 Subject: [PATCH 041/297] feat(guard): deny agent writes to devkit's state, config and install Agents could write ~/.local/state/devkit (runs, sessions, audit), ~/.config/devkit/config.json and ~/.local/share/devkit, because the guard allowed every agent-config folder. A forged run file can set by: developer, mode: auto or approved: [release], which voids the approval gates (spec K1, contract C11). The guard now refuses, for every agent: Write, Edit, MultiEdit, NotebookEdit and apply_patch on those paths and on subagent meta files; shell redirects and known writers (tee, cp, mv, ln, rm and dev rm, sed -i, in-place perl, interpreters that write files, also inside sh -c, eval, xargs and find -exec); dev config set safeguards.* and dev config edit; and any DEVKIT_* change in a settings file's env block. devkit's own commands still write the state in their own process. Each refusal names the dev command to use and says that only the developer changes safeguards, in a terminal. --- lib/devkit/guard.py | 641 ++++++++++++++++++++++++++++++++++ tests/test_protected_state.py | 183 ++++++++++ 2 files changed, 824 insertions(+) create mode 100644 tests/test_protected_state.py diff --git a/lib/devkit/guard.py b/lib/devkit/guard.py index 98f2e16..000ba40 100644 --- a/lib/devkit/guard.py +++ b/lib/devkit/guard.py @@ -13,6 +13,8 @@ """ from __future__ import annotations +import functools +import json import os import re import shlex @@ -647,6 +649,9 @@ def check_push_budget(cwd, args, envs): def check_bash(cmd: str, cwd: str, agent: str): if not cmd or not cmd.strip(): return None + v = protected_bash(cmd, cwd or os.getcwd()) + if v: + return v if PIPE_GATE.search(cmd) and "pipefail" not in cmd and "DEVKIT_PIPE_OK" not in cmd: return deny("This commits, pushes or tags after a pipeline (`… | … && git commit`): the `&&` sees the exit " "code of the pipe's last command, so a failing check before the pipe still lets it run. Run the " @@ -808,10 +813,643 @@ def devkit_off(path: str, tool_input: dict): return None +# --------------------------------------------------------------------------- protected state (spec C11, K1) +# +# devkit's state (runs, sessions, the call ledger, audit), its configuration and its pinned install decide what the +# guard and the gates allow, so no agent tool call may write them: not the file tools, not a shell, not `dev config set +# safeguards.*`. devkit's own commands write them in their own process (`dev progress`, the hooks), which never passes +# through this guard. A DEVKIT_* variable in a settings file's env block would switch safeguards for every hook, so +# agents may not set, change or remove one there. The developer changes safeguards in a terminal. The shell rules +# read the command text: they stop the plain ways in, they are not a sandbox. + +READ_TOOLS = {"Read", "Grep", "Glob", "LS", "NotebookRead"} + + +def protected_roots() -> list: + h = core.HOME + return [h / ".local" / "state" / "devkit", h / ".config" / "devkit", h / ".local" / "share" / "devkit"] + + +def _under_ci(p: str, root: str) -> bool: + # macOS folders are case-insensitive: ~/.Local/State/devkit is ~/.local/state/devkit + p, r = p.casefold(), root.casefold().rstrip(os.sep) + return p == r or p.startswith(r + os.sep) + + +def _subagent_meta(s: str) -> bool: + q = Path(s) + return q.name.casefold().endswith(".meta.json") and q.parent.name.casefold() == "subagents" and \ + _under_ci(s, real(core.CLAUDE_HOME / "projects")) + + +def is_protected(p: Path) -> bool: + """True when the resolved path is devkit's state, configuration or install, or a subagent's launch record.""" + if p == UNKNOWN: + return False + s = str(p) + return any(_under_ci(s, real(r)) for r in protected_roots()) or \ + s.casefold() == real(core.CONFIG_FILE).casefold() or _subagent_meta(s) + + +def holds_protected(p: Path) -> bool: + """True when the folder holds devkit's state, configuration or install (~/.local/state, ~): moving, linking or + deleting it takes them along.""" + return p != UNKNOWN and any(_under_ci(real(r), str(p)) for r in protected_roots()) + + +_STATE_OWNERS = { + "runs": ("plan and progress records", "`dev progress` (start, step, note, assign, reopen, skip, done)"), + "sessions": ("session records", "`dev status` to read them (devkit's hooks keep them current)"), + "audit": ("audit log", "`dev audit` to read it (nothing edits it)"), + "trash": ("Trash", "`dev rm --undo` to restore from it"), + "trash.jsonl": ("Trash manifest", "`dev rm --undo` to restore from it"), + "ci-pushes.json": ("push budget", "`dev push`, which counts pushes itself"), +} +_TAIL = "Only the developer changes safeguards, in a terminal." + + +def protected_reason(p: Path, verb: str = "write") -> str: + s, tail = str(p), " " + _TAIL + state, config, share = (real(r) for r in protected_roots()) + if not is_protected(p): + head = f"{tilde(p)} holds devkit's state, configuration or install" + use = "the `dev` command that owns the record you mean to change (`dev help` lists them)" + elif _under_ci(s, config) or s.casefold() == real(core.CONFIG_FILE).casefold(): + head, tail = f"{tilde(p)} belongs to devkit's configuration", "" + use = ("a terminal: only the developer changes devkit's configuration and its safeguards, there " + "(`dev config edit`). Ask them, with what to change and why") + elif _under_ci(s, share): + head = f"{tilde(p)} belongs to devkit's installed copy (the pinned release, skills, tools)" + use = "the source in the devkit repository, then `dev install`" + elif _subagent_meta(s): + head = f"{tilde(p)} is a subagent launch record, which Claude Code writes when it launches one" + use = "the Agent tool" + else: + first = s[len(state):].lstrip(os.sep).split(os.sep)[0].casefold() + what, use = _STATE_OWNERS.get(first, ("state", "the `dev` command that owns it (`dev help` lists them)")) + head = f"{tilde(p)} belongs to devkit's {what}" + return (f"{head}. Agents may not {verb} devkit's state, configuration or install (spec C11); devkit's own " + f"commands write them. Instead, use {use}. Reading is fine (the Read tool, `cat`, `jq`).{tail}") + + +@functools.lru_cache(maxsize=4) +def _mark_for(home: str, config_file: str): + alts = [r"\.local\W{1,8}state\W{1,8}devkit(?![\w-])", r"\.local\W{1,8}share\W{1,8}devkit(?![\w-])", + r"\.config\W{1,8}devkit(?![\w-])", r"XDG_(?:STATE|CONFIG|DATA)_HOME\W{1,8}devkit(?![\w-])", + r"subagents\W{1,8}[^\s'\"]*\.meta\.json", r"\bcore\.(?:STATE|CONFIG_FILE|share_dir)\b"] + alts += [re.escape(real(r)) for r in protected_roots()] + [re.escape(real(config_file))] + return re.compile("|".join(alts), re.I) + + +def _mark(): + """Matches text that names devkit's state, configuration or install, in any spelling the guard can recognise.""" + return _mark_for(str(core.HOME), str(core.CONFIG_FILE)) + + +# ---- DEVKIT_* keys in settings files + +_KEY_JSON = re.compile(r'"(DEVKIT_[A-Za-z0-9_]*)"\s*:\s*("(?:[^"\\]|\\.)*"|[^,}\s]+)') +_KEY_TOML = re.compile(r'(?:^|[\s{,])["\']?(DEVKIT_[A-Za-z0-9_]*)["\']?\s*=\s*("[^"\n]*"|\'[^\'\n]*\'|[^\s,}]+)', re.M) + + +def is_settings_file(path) -> bool: + p = Path(str(path)) + n, d = p.name.casefold(), p.parent.name.casefold() + return (d == ".claude" and n in ("settings.json", "settings.local.json")) or n == "managed-settings.json" or \ + (d == ".codex" and (n.endswith(".toml") or n == "hooks.json")) + + +def _unescape(text) -> str: + # "DEVKIT_GUARD" is the same JSON key as "DEVKIT_GUARD" + return re.sub(r"\\u([0-9a-fA-F]{4})", lambda m: chr(int(m.group(1), 16)), str(text or "")) + + +def devkit_env(text: str, toml: bool = False) -> dict: + """The DEVKIT_* variables a settings file's text sets, {name: value}.""" + if not text: + return {} + if not toml: + try: + data = json.loads(text) + except ValueError: + data = None + if isinstance(data, dict): + env = data.get("env") + return {k: json.dumps(v) for k, v in env.items() if str(k).startswith("DEVKIT_")} \ + if isinstance(env, dict) else {} + out = {} + for m in (_KEY_TOML if toml else _KEY_JSON).finditer(_unescape(text)): + v = m.group(2) + try: + v = json.dumps(json.loads(v)) + except ValueError: + pass + out[m.group(1)] = v + return out + + +def _read_small(p, limit: int = 1 << 19) -> str: + try: + p = Path(str(p)) + if p != UNKNOWN and p.is_file() and p.stat().st_size <= limit: + return p.read_text(errors="replace") + except (OSError, ValueError): + pass + return "" + + +def _settings_after(before: str, tool_input: dict): + """The settings file's text after this Write, Edit or MultiEdit, or None when the edit does not apply to it.""" + if "content" in tool_input: + return str(tool_input.get("content") or "") + edits = tool_input.get("edits") if isinstance(tool_input.get("edits"), list) else [tool_input] + text = before + for e in edits: + if not isinstance(e, dict): + return None + old, new = str(e.get("old_string") or ""), str(e.get("new_string") or "") + if not old: + if text: + return None + text = new + continue + if old not in text: + return None + text = text.replace(old, new) if e.get("replace_all") else text.replace(old, new, 1) + return text + + +def _settings_reason(path, keys) -> str: + return (f"This changes {', '.join(list(keys)[:4]) or 'a DEVKIT_* variable'} in the env block of {tilde(path)}. " + f"DEVKIT_* variables switch devkit's safeguards for every hook, so agents may not set, change or remove " + f"them in a settings file (spec C11). If the work needs one, ask the developer and say why. {_TAIL}") + + +def settings_env_violation(path, tool_input: dict): + """A Write or Edit that sets, changes or removes a DEVKIT_* variable in a settings file's env block.""" + if not path or not is_settings_file(path): + return None + toml = Path(str(path)).suffix.casefold() == ".toml" + before = _read_small(path, 1 << 20) + after = _settings_after(before, tool_input or {}) + if after is None: # the edit does not match the file as it is: judge the new text alone + es = tool_input.get("edits") if isinstance(tool_input.get("edits"), list) else [tool_input] + new = "\n".join(str(e.get("new_string") or "") for e in es if isinstance(e, dict)) + changed = sorted(devkit_env(new, toml)) or (["DEVKIT_*"] if "DEVKIT_" in _unescape(new) else []) + else: + a, b = devkit_env(after, toml), devkit_env(before, toml) + changed = sorted(k for k in set(a) | set(b) if a.get(k) != b.get(k)) + return deny(_settings_reason(path, changed)) if changed else None + + +def protected_write(path, cwd, tool_input=None): + """Write, Edit, MultiEdit, NotebookEdit: devkit's state, configuration and install, and DEVKIT_* settings keys.""" + if not path: + return None + p = resolve(path, cwd) + if is_protected(p): + return deny(protected_reason(p)) + return settings_env_violation(p if p != UNKNOWN else path, tool_input or {}) + + +def protected_patch(patch: str, cwd): + """apply_patch: Add, Update, Delete and Move all count, and an added DEVKIT_* line in a settings file.""" + added = _unescape("\n".join(ln[1:] for ln in patch.splitlines() if ln.startswith("+"))) + for op, path in parse_patch_paths(patch): + p = resolve(path, cwd) + if is_protected(p): + return deny(protected_reason(p, "delete" if op == "delete" else "write")) + if op != "delete" and is_settings_file(p) and "DEVKIT_" in added: + return deny(_settings_reason(p, sorted(set(re.findall(r"DEVKIT_[A-Za-z0-9_]*", added))))) + return None + + +# ---- the shell + +SAFEGUARDS_REASON = ("`dev config set safeguards.…` changes devkit's safeguards, and only the developer changes them, " + "in a terminal (spec C11). Ask them: name the setting, the value and why. `dev config show` " + "prints the current values.") +_SHELLS = {"bash", "sh", "zsh", "dash", "ksh", "fish"} +_INTERP = re.compile(r"^(?:python[0-9.]*|pypy[0-9.]*|node|nodejs|deno|bun|perl[0-9.]*|ruby|php|osascript|lua|tclsh|" + r"Rscript|swift|awk|gawk|mawk|nawk)$") +_READERS = {"cat", "head", "tail", "less", "more", "bat", "ls", "stat", "file", "wc", "grep", "egrep", "fgrep", "rg", + "ag", "ack", "jq", "diff", "cmp", "comm", "md5", "md5sum", "shasum", "sha1sum", "sha256sum", "cksum", "du", + "df", "realpath", "readlink", "basename", "dirname", "test", "[", "[[", "echo", "printf", "hexdump", "xxd", + "od", "strings", "column", "cut", "tr", "nl", "fold", "paste", "tree", "lsof", "true", "false", "type", + "which", "export", "declare", "local", "readonly", "typeset", "set", "unset", "alias", "popd", "wait", + "sleep", "date", "pwd", "whoami", "id", "uname", "env", "printenv"} +_CREATORS = {"tee", "touch", "mkdir", "mkfifo", "truncate"} # write the paths they name +_CHANGERS = {"rm", "rmdir", "unlink", "shred", "srm", "ln", "chmod", "chown", "chgrp", "chflags", "xattr", "setfacl"} +_COPIERS = {"cp", "gcp", "install", "ditto", "rsync", "scp"} +_MOVERS = {"mv", "gmv"} +_DEV_READ = {"files", "find", "grep", "diff", "status", "show", "ls", "du", "time", "overview", "standup", "help", + "version", "doctor"} +_CODE_WRITES = re.compile( + r"\bopen\s*\([^)]*['\"][rbtU]*[wax+][rbt+]*['\"]|\bmode\s*=\s*['\"][^'\"]*[wax+]" + r"|\.(?:write_text|write_bytes|touch|unlink|rename|rmdir|mkdir|chmod|symlink_to|hardlink_to)\s*\(" + r"|\bos\.(?:remove|unlink|rename|renames|replace|rmdir|removedirs|mkdir|makedirs|chmod|chown|truncate|symlink|" + r"link|utime|open|write|system|popen|exec\w*|spawn\w*)\b|\bshutil\.(?:copy\w*|move|rmtree)\b|\bsubprocess\b" + r"|\bexec\s*\(|\b(?:writeFile|appendFile|createWriteStream|copyFile|cpSync|renameSync|unlinkSync|rmSync|" + r"rmdirSync|mkdirSync|truncateSync|symlinkSync|linkSync|chmodSync|openSync|writeSync|child_process|execSync|" + r"spawnSync|execFile)\w*|\bFile\.(?:write|open|delete|unlink|rename|symlink|link|chmod)\b|\bIO\.(?:write|" + r"binwrite)\b|\bFileUtils\b|\bfile_put_contents\b|\bsystem\s*\(|\bdo shell script\b" + r"|\bopen\s*\(?\s*(?:my\s+)?\$?\w+\s*,\s*['\"]?\s*(?:\+?[>|]|>>)|\bunlink\b|\brename\b|\bprint\s*>{1,2}\s*\S") +_CODE_INPUTS = re.compile(r"environ|getenv|\bENV\[|\$ENV\{|process\.env|sys\.argv|\bARGV\b|process\.argv") +_SETTINGS_NAME = re.compile(r"settings(?:\.local)?\.json|managed-settings\.json|config\.toml|hooks\.json") +_VARPATH = re.compile(r"^\$\{?[A-Za-z_]") + + +def heredoc_bodies(cmd: str) -> list: + """The heredoc bodies and here-strings of a command: the text an interpreter or shell reads from its input.""" + lines, out, i = cmd.split("\n"), [], 0 + while i < len(lines): + m = HEREDOC_RE.search(lines[i]) + i += 1 + if m: + body = [] + while i < len(lines) and lines[i].strip() != m.group(2): + body.append(lines[i]) + i += 1 + i += 1 + out.append("\n".join(body)) + out += [next(g for g in m.groups() if g is not None) for m in + re.finditer(r"<<<\s*(?:'([^']*)'|\"((?:[^\"\\]|\\.)*)\"|(\S+))", cmd)] + return out + + +def _candidates(args) -> list: + out = [] + for a in args: + if not a.startswith("-") and a: + out.append(a) + if "=" in a: # --output=PATH, of=PATH + out.append(a.split("=", 1)[1]) + return out + + +def _pathlike(a: str) -> bool: + return "/" in a or a.startswith(("~", ".")) or bool(_VARPATH.match(a)) + + +class _Shell: + """Walks one shell command for writes to protected paths; `unresolved` collects targets it cannot resolve.""" + + def __init__(self, cmd: str, cwd, outer_mentions=False, depth=0): + self.cmd, self.cwd, self.depth = cmd, cwd, depth + self.mentions = outer_mentions or bool(_mark().search(cmd)) + self.keys = sorted(set(re.findall(r"DEVKIT_[A-Za-z0-9_]*", _unescape(cmd)))) + self.bodies = heredoc_bodies(cmd) + self.unresolved = [] + + def target(self, t, cwd, verb="write", holds=False, track=True): + p = resolve(t, cwd) + if p == UNKNOWN: + if track and t: + self.unresolved.append(t) + return None + if is_protected(p) or (holds and holds_protected(p)): + return deny(protected_reason(p, verb)) + if is_settings_file(p) and self.keys: + return deny(_settings_reason(p, self.keys)) + return None + + def targets(self, ts, cwd, verb="write", holds=False, track=True): + for t in ts: + v = self.target(t, cwd, verb, holds, track) + if v: + return v + return None + + def run(self): + segs = split_commands(self.cmd) + if segs is None: + if self.mentions and re.search(r">|\b(tee|cp|mv|rm|ln|sed|python|perl|ruby|node|dd|install)\b", self.cmd): + return deny("This command names devkit's state, configuration or install, writes files, and the guard " + "cannot parse it to see where (spec C11: agents may not write them). Split it into " + f"simple commands. {_TAIL}") + return None + cwd = self.cwd + for argv0, redirs in segs: + argv0, clobber, k = list(argv0), [], 0 + while k < len(argv0): # `>|` reaches argv as a token; the next token is its target + if argv0[k] in (">|", ">>|"): + clobber.append(argv0[k + 1] if k + 1 < len(argv0) else "") + del argv0[k:k + 2] + continue + k += 1 + v = self.targets(list(redirs) + clobber, cwd) + if v: + return v + prefix = argv0[:len(argv0) - len(_strip_prefix(argv0))] + assigns = [a.split("=", 1)[1] for a in prefix if re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", a)] + argv = _unwrap(argv0) + if not argv: + continue + name = os.path.basename(argv[0]) + if name in ("cd", "pushd"): + cwd = str(core.HOME) if len(argv) == 1 else cwd if argv[1] == "-" else str(resolve(argv[1], cwd)) + continue + v = self.segment(name, argv, assigns, cwd) + if v: + return v + if self.unresolved and self.mentions: + return deny(f"This command writes to `{self.unresolved[0]}`, which depends on a variable or a subshell the " + f"guard cannot see, and it names devkit's state, configuration or install (spec C11: agents " + f"may not write them). Write the path out in full if it is not devkit's; otherwise use the " + f"`dev` command that owns the record. {_TAIL}") + return None + + def nested(self, text, cwd): + if not text or self.depth >= 4: + return None + inner = _Shell(text, cwd, self.mentions, self.depth + 1) + v = inner.run() + self.unresolved += inner.unresolved + return v + + def segment(self, name, argv, assigns, cwd): + args = argv[1:] + dev = _dev_args(argv) + if dev is not None: + return self.dev(dev, cwd) + if name in _SHELLS: + for text in _shell_inputs(args, cwd, self.bodies): + v = self.nested(text, cwd) + if v: + return v + return None + if name in ("source", ".") and args: + return self.nested(_read_small(resolve(args[0], cwd)), cwd) + if name == "eval": + return self.nested(" ".join(args), cwd) + if name == "xargs": + return self.xargs(args, cwd) + if name == "find": + return self.find(args, cwd) + if _INTERP.match(name): + return self.interpreter(argv, assigns, cwd) + if name in _COPIERS or name in _MOVERS: + return self.copy(name, args, cwd) + if name in _CREATORS: + return self.targets(_candidates(args), cwd) + if name in _CHANGERS: + return self.targets(_candidates(args), cwd, "change or delete", holds=True) + if name == "dd": + return self.targets([a[3:] for a in args if a.startswith("of=")], cwd) + if name in ("sed", "yq"): + inplace = any(a == "--in-place" or a.startswith(("--in-place=", "--inplace")) or + (a.startswith("-") and not a.startswith("--") and "i" in a[1:]) for a in args) + return self.targets(_candidates(args), cwd) if inplace else None + if name == "sort": + out = _opt_value(args, ("-o", "--output")) + return self.target(out, cwd) if out else None + if name == "uniq": + pos = [a for a in args if not a.startswith("-")] + return self.target(pos[1], cwd) if len(pos) > 1 else None + if name in _READERS: + return None + # anything else (editors, sqlite3, git, curl, tar, …) may write the paths it is given + inside = is_protected(Path(cwd)) if cwd and str(cwd) != str(UNKNOWN) else False + return self.targets([c for c in _candidates(args) if inside or _pathlike(c)], cwd) + + def dev(self, args, cwd): + if args[:2] == ["config", "set"] and any(a.casefold().startswith("safeguards") for a in args[2:]): + return deny(SAFEGUARDS_REASON) + if args[:2] == ["config", "edit"]: + return deny(protected_reason(Path(real(core.CONFIG_FILE)), "edit")) + if args[:1] in (["rm"], ["mv"], ["cp"]): + return self.segment(args[0], args, [], cwd) + if args[:1] and args[0] in _DEV_READ: + return None + # devkit's own commands write the state in their process; only a path argument that lands in it is refused + return self.targets([c for c in _candidates(args[1:]) if _pathlike(c)], cwd, track=False) + + def copy(self, name, args, cwd): + pos = [a for a in args if not a.startswith("-")] + tdir = _opt_value(args, ("-t", "--target-directory")) + if name == "install" and "-d" in args: + return self.targets(pos, cwd) + if tdir: + dests, srcs = [tdir], [p for p in pos if p != tdir] + elif len(pos) >= 2: + dests, srcs = pos[-1:], pos[:-1] + else: + return None + links = name in ("cp", "gcp") and any(a in ("--link", "--symbolic-link") or ( + a.startswith("-") and not a.startswith("--") and ("l" in a or "s" in a)) for a in args) + if name in _MOVERS or links: # a move deletes the source; a link is a way in + v = self.targets(srcs, cwd, "move" if name in _MOVERS else "link", holds=True) + if v: + return v + for d in dests: + v = self.target(d, cwd) + if v: + return v + dp = resolve(d, cwd) + if dp == UNKNOWN: + continue + if dp.is_dir(): + v = self.targets([str(dp / os.path.basename(s.rstrip("/"))) for s in srcs], cwd) + if v: + return v + if is_settings_file(dp): + toml = dp.suffix.casefold() == ".toml" + now = devkit_env(_read_small(dp, 1 << 20), toml) + for s in srcs: + new = devkit_env(_read_small(resolve(s, cwd), 1 << 20), toml) + if new != now: + return deny(_settings_reason(dp, sorted(k for k in set(new) | set(now) + if new.get(k) != now.get(k)))) + return None + + def xargs(self, args, cwd): + i, takes = 0, {"-I", "-n", "-P", "-L", "-d", "-E", "-s", "-a", "-J", "-R", "-S"} + while i < len(args) and args[i].startswith("-"): + i += 2 if args[i] in takes else 1 + inner = args[i:] + if not inner: + return None + name = os.path.basename(inner[0]) + if name not in _READERS: # it adds paths read from its input, which the guard cannot see + self.unresolved.append(f"xargs {name}") + return self.segment(name, inner, [], cwd) + + def find(self, args, cwd): + acts = {"-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprint0", "-fprintf", "-fls"} + if not any(a in acts for a in args): + return None + roots = [] + for a in args: + if a.startswith("-") or a in ("(", ")", "!", ","): + break + roots.append(a) + writes = "-delete" in args + for k, a in enumerate(args): + if a in ("-fprint", "-fprint0", "-fprintf", "-fls") and k + 1 < len(args): + v = self.target(args[k + 1], cwd) + if v: + return v + if a in ("-exec", "-execdir", "-ok", "-okdir") and k + 1 < len(args): + inner = args[k + 1:] + inner = inner[:next((j for j, x in enumerate(inner) if x in (";", "+")), len(inner))] + iname = os.path.basename(inner[0]) if inner else "" + inplace = iname in ("sed", "yq") and any(x.startswith("-i") or x.startswith("--in") for x in inner) + writes = writes or iname not in _READERS or inplace + return self.targets(roots or ["."], cwd, "change or delete", holds=True) if writes else None + + def interpreter(self, argv, assigns, cwd): + code, rest, module, inplace = _program(argv, cwd, self.bodies) + name = os.path.basename(argv[0]) + if module is not None: + pos = [a for a in rest if not a.startswith("-")] + return self.targets(pos[1:2] if module == "json.tool" else [c for c in _candidates(rest) if _pathlike(c)], + cwd, track=False) + if inplace: + v = self.targets([a for a in rest if not a.startswith("-")], cwd) + if v: + return v + if not code or not _CODE_WRITES.search(code): + return None + named = [c for c in _candidates(rest) + assigns if _pathlike(c)] + hit = _mark().search(code) or any(is_protected(resolve(c, cwd)) for c in named) or \ + (self.mentions and _CODE_INPUTS.search(code)) or ("DEVKIT_" in _unescape(code) and _SETTINGS_NAME.search(code)) + if not hit: + return None + return deny(f"This {name} program writes files and names devkit's state, configuration or install, or a " + f"DEVKIT_* setting (spec C11: agents may not write them; devkit's own commands do). Use the `dev` " + f"command that owns the record (`dev progress`, `dev task`, …). To read a record, open it " + f"without 'w' or 'a' and print it. {_TAIL}") + + +def _unwrap(argv): + """The command a segment really runs: without env/sudo/nohup prefixes, `timeout N`, `uv run`, `npx`.""" + while True: + argv = _strip_prefix(argv) + if not argv: + return argv + n = os.path.basename(argv[0]) + if n in ("timeout", "gtimeout"): + rest = argv[1:] + while rest and rest[0].startswith("-"): + rest = rest[1:] + argv = rest[1:] + elif (n in ("uv", "poetry", "pipenv", "pdm", "hatch", "rye") and argv[1:2] == ["run"]) or \ + (n == "pnpm" and argv[1:2] in (["exec"], ["dlx"])): + argv = argv[2:] + while argv and argv[0].startswith("-"): + argv = argv[1:] + elif n in ("npx", "bunx", "uvx", "pnpx"): + argv = argv[1:] + while argv and argv[0].startswith("-"): + argv = argv[1:] + else: + return argv + + +def _dev_args(argv): + """The arguments of a `dev` call (dev, bin/dev, python3 bin/dev, python3 -m devkit.cli), else None.""" + n = os.path.basename(argv[0]) + if n == "dev": + return argv[1:] + if _INTERP.match(n) and len(argv) > 1: + if os.path.basename(argv[1]) == "dev": + return argv[2:] + if argv[1:3] in (["-m", "devkit"], ["-m", "devkit.cli"]): + return argv[3:] + return None + + +def _shell_inputs(args, cwd, bodies) -> list: + """The scripts a shell runs: its -c string, a script file, or its input (the command's heredocs).""" + i = 0 + while i < len(args): + a = args[i] + if a == "--": + i += 1 + break + if a.startswith("-") and not a.startswith("--") and "c" in a[1:]: + return [args[i + 1]] if i + 1 < len(args) else [] + if a in ("-o", "+o", "-O", "+O", "--rcfile", "--init-file"): + i += 2 + continue + if a.startswith(("-", "+")): + i += 1 + continue + break + if i < len(args): + return [_read_small(resolve(args[i], cwd))] + return list(bodies) + + +def _program(argv, cwd, bodies): + """-> (code, args, module, in_place): the program text an interpreter runs (inline, a script file or its input).""" + fam = re.sub(r"[0-9.]+$", "", os.path.basename(argv[0]).casefold()) + args, code, script, module, inplace, i = argv[1:], [], None, None, False, 0 + while i < len(args): + a, nxt = args[i], args[i + 1] if i + 1 < len(args) else "" + short = a.startswith("-") and not a.startswith("--") and len(a) > 1 + if fam in ("awk", "gawk", "mawk", "nawk"): + if a == "-f": + script, i = nxt, i + 2 + continue + if a in ("-v", "-F"): + i += 2 + continue + if a.startswith("-"): + i += 1 + continue + if not code and script is None: + code.append(a) + i += 1 + break + if fam in ("python", "pypy"): + if short and a.endswith("c"): + code.append(nxt) + i += 2 + break + if a == "-m": + module, i = nxt, i + 2 + break + elif fam in ("perl", "ruby") and short and a[-1] in "eE" or \ + a in {"node": ("-e", "--eval", "-p", "--print"), "bun": ("-e", "--eval", "-p", "--print"), + "nodejs": ("-e", "--eval", "-p", "--print"), "deno": ("eval",), "php": ("-r",), + "osascript": ("-e",), "lua": ("-e",), "rscript": ("-e",), "swift": ("-e",)}.get(fam, ()): + code.append(nxt) + inplace = inplace or (fam in ("perl", "ruby") and "i" in a[1:-1]) + i += 2 + continue + if short and fam in ("perl", "ruby") and "i" in a[1:]: + inplace = True + if a.startswith("-"): + i += 1 + continue + if not code and script is None: + script, i = a, i + 1 + break + rest = args[i:] + if script and script != "-": + code.append(_read_small(resolve(script, cwd))) + elif not code and module is None: + code += list(bodies) + return "\n".join(code), rest, module, inplace + + +def protected_bash(cmd: str, cwd): + """Deny a shell command that writes devkit's state, configuration or install, runs `dev config set + safeguards.*`, or sets a DEVKIT_* key in a settings file (spec C11).""" + if not cmd or not cmd.strip(): + return None + return _Shell(cmd, cwd).run() + + def check(tool: str, tool_input: dict, cwd: str, agent: str): tool_input = tool_input or {} if tool in WRITE_TOOLS or "file_path" in tool_input or "notebook_path" in tool_input: path = tool_input.get("file_path") or tool_input.get("notebook_path") + if tool not in READ_TOOLS: + v = protected_write(path, cwd, tool_input) + if v: + return v return devkit_off(path, tool_input) or check_write_path(path, cwd, agent) cmd = tool_input.get("command") if isinstance(tool_input, dict) else None if isinstance(cmd, list): @@ -819,6 +1457,9 @@ def check(tool: str, tool_input: dict, cwd: str, agent: str): if not isinstance(cmd, str): return None if tool == "apply_patch" or cmd.lstrip().startswith("*** Begin Patch"): + v = protected_patch(cmd, cwd) + if v: + return v for op, path in parse_patch_paths(cmd): if op == "delete": continue diff --git a/tests/test_protected_state.py b/tests/test_protected_state.py new file mode 100644 index 0000000..c6f4e81 --- /dev/null +++ b/tests/test_protected_state.py @@ -0,0 +1,183 @@ +# Copyright (c) 2026 Firefly Software Solutions Inc. +# SPDX-License-Identifier: Apache-2.0 +# Protected state (spec C11, K1): agents never write devkit's state, configuration or pinned install, and never set a +# DEVKIT_* variable in a settings file. The guard only looks at paths and command text, so these tests never create or +# change anything under the real folders. Run: python3 -m unittest tests.test_protected_state -v +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "lib")) + +from devkit import core, guard # noqa: E402 + +HOME = core.HOME +STATE = HOME / ".local" / "state" / "devkit" +CONFIG = HOME / ".config" / "devkit" / "config.json" +SHARE = HOME / ".local" / "share" / "devkit" +# names no real run or session uses: the cp/mv rules look at the real disk to see whether a target exists +RUN = STATE / "runs" / "k1-probe-run.json" +SESSION = STATE / "sessions" / "claude-k1-probe.json" +CWD = str(core.DEV) + + +def verdict(tool, tool_input, cwd=CWD, agent="claude"): + v = guard.check(tool, tool_input, cwd, agent) + return v[0] if v else "allow" + + +def reason(tool, tool_input, cwd=CWD, agent="claude"): + v = guard.check(tool, tool_input, cwd, agent) + return v[1] if v else "" + + +def bash(cmd, cwd=CWD, agent="claude"): + return verdict("Bash", {"command": cmd}, cwd, agent) + + +class FileToolTests(unittest.TestCase): + def test_edit_of_the_session_file_is_denied_for_every_agent(self): + # a session record carries the lane, the role and the scope grant: an agent that edits it grants itself powers + edit = {"file_path": str(SESSION), "old_string": '"lane": "tank"', "new_string": '"lane": "main"'} + multi = {"file_path": str(SESSION), "edits": [{"old_string": "a", "new_string": "b"}]} + for agent in ("claude", "codex"): + with self.subTest(agent=agent): + self.assertEqual(verdict("Edit", edit, agent=agent), "deny") + self.assertEqual(verdict("MultiEdit", multi, agent=agent), "deny") + self.assertIn("`dev ", reason("Edit", edit)) + + def test_write_of_config_json_is_denied_and_points_to_the_terminal(self): + r = reason("Write", {"file_path": str(CONFIG), "content": '{"safeguards": {"irreversible_main": "off"}}'}) + self.assertIn("terminal", r) + self.assertIn("dev config edit", r) + # a folder whose name only starts with "devkit" is not devkit's + self.assertEqual(verdict("Write", {"file_path": str(HOME / ".config" / "devkit-other" / "x.json"), + "content": "{}"}), "allow") + + def test_notebook_and_patch_writes_into_protected_folders_are_denied(self): + self.assertEqual(verdict("NotebookEdit", {"notebook_path": str(SHARE / "n.ipynb"), "new_source": "x"}), "deny") + for op in ("Add", "Update", "Delete"): + with self.subTest(op=op): + patch = f"*** Begin Patch\n*** {op} File: {RUN}\n+{{}}\n*** End Patch" + self.assertEqual(verdict("apply_patch", {"command": patch}, agent="codex"), "deny") + move = f"*** Begin Patch\n*** Update File: /tmp/k1-probe.json\n*** Move to: {RUN}\n*** End Patch" + self.assertEqual(verdict("apply_patch", {"command": move}, agent="codex"), "deny") + + def test_subagent_meta_files_are_protected_but_readable(self): + # the meta file binds a subagent to its launch record (spec C3): a forged one would claim another lane + meta = HOME / ".claude" / "projects" / "-k1-probe" / "sid" / "subagents" / "agent-k1.meta.json" + self.assertEqual(verdict("Write", {"file_path": str(meta), "content": '{"toolUseId": "x"}'}), "deny") + self.assertEqual(verdict("Read", {"file_path": str(meta)}), "allow") + + def test_case_and_symlink_variants_of_the_state_path_are_denied(self): + # macOS folders are case-insensitive, and a symlink in a temp folder reaches the same files + upper = HOME / ".LOCAL" / "State" / "devkit" / "runs" / "r.json" + self.assertEqual(verdict("Write", {"file_path": str(upper), "content": "{}"}), "deny") + with tempfile.TemporaryDirectory() as tmp: + link = Path(tmp) / "s" + os.symlink(STATE, link) + self.assertEqual(verdict("Write", {"file_path": str(link / "runs" / "r.json"), "content": "{}"}), "deny") + self.assertEqual(bash(f"ln -s ~/.local/state/devkit {tmp}/t"), "deny") + + +class ShellTests(unittest.TestCase): + def test_redirect_into_a_run_file_is_denied_but_reading_it_is_allowed(self): + self.assertEqual(bash("echo x > ~/.local/state/devkit/runs/r.json"), "deny") + self.assertEqual(bash(f"echo x >> {RUN}"), "deny") + self.assertIn("dev progress", reason("Bash", {"command": "echo x > ~/.local/state/devkit/runs/r.json"})) + self.assertEqual(bash("cat ~/.local/state/devkit/runs/r.json"), "allow") + self.assertEqual(bash("jq .steps ~/.local/state/devkit/runs/r.json > /tmp/k1-steps.json"), "allow") + self.assertEqual(verdict("Read", {"file_path": str(RUN)}), "allow") + + def test_sed_in_place_on_state_is_denied(self): + self.assertEqual(bash(f"sed -i '' 's/\"mode\": \"guided\"/\"mode\": \"auto\"/' {RUN}"), "deny") + self.assertEqual(bash(f"sed -i.orig -e 's/a/b/' {RUN}"), "deny") + self.assertEqual(bash(f"sed -n 1,5p {RUN}"), "allow") + + def test_python_inline_writes_to_state_are_denied(self): + self.assertEqual(bash(f"python3 -c \"open('{RUN}', 'w').write('{{}}')\""), "deny") + self.assertEqual(bash("python3 -c \"import pathlib; pathlib.Path.home().joinpath('.local', 'state', 'devkit', " + "'runs', 'r.json').write_text('{}')\""), "deny") + heredoc = ("python3 - <<'EOF'\nfrom pathlib import Path\n" + "(Path.home() / '.local/state/devkit/runs/r.json').write_text('{}')\nEOF") + self.assertEqual(bash(heredoc), "deny") + self.assertEqual(bash(f"python3 -c \"import json; print(json.load(open('{RUN}')))\""), "allow") + + def test_tee_cp_and_mv_into_protected_folders_are_denied(self): + self.assertEqual(bash("echo '{}' | tee ~/.local/state/devkit/runs/r.json"), "deny") + self.assertEqual(bash(f"mv /tmp/k1-forged.json {RUN}"), "deny") + self.assertEqual(bash("mv /tmp/k1-forged.json ~/.local/share/devkit/"), "deny") + self.assertEqual(bash("cp /tmp/k1-forged.json ~/.config/devkit/config.json"), "deny") + # moving a record out deletes it from devkit's state + self.assertEqual(bash(f"mv {RUN} /tmp/k1-moved.json"), "deny") + self.assertEqual(bash(f"cp {RUN} /tmp/k1-copy.json"), "allow") + + def test_rm_and_dev_rm_of_state_are_denied_even_with_the_raw_flag(self): + self.assertEqual(bash(f"DEVKIT_RAW_FS=1 rm -f {SESSION}"), "deny") + self.assertEqual(bash("dev rm ~/.local/state/devkit/runs"), "deny") + # the folder that holds devkit's state goes with it + self.assertEqual(bash("DEVKIT_RAW_FS=1 rm -rf ~/.local/state"), "deny") + self.assertEqual(bash("dev rm /tmp/k1-scratch.txt"), "allow") + + def test_shell_wrappers_and_variables_do_not_hide_a_state_write(self): + self.assertEqual(bash("bash -c 'echo x > ~/.local/state/devkit/runs/r.json'"), "deny") + codex = {"command": ["bash", "-lc", "echo x > ~/.local/state/devkit/runs/r.json"]} + self.assertEqual(verdict("Bash", codex, agent="codex"), "deny") + self.assertEqual(bash("D=~/.local/state/devkit; echo x > $D/runs/r.json"), "deny") + self.assertEqual(bash("cd ~/.local/state/devkit/runs && echo x > r.json"), "deny") + self.assertEqual(bash("D=~/.local/state/devkit; ls $D/runs"), "allow") + + def test_dev_config_set_safeguards_is_refused_but_dev_commands_that_write_state_run(self): + self.assertEqual(bash("dev config set safeguards.irreversible_main enforce"), "deny") + self.assertEqual(bash("bin/dev config set safeguards.crew_rules=audit"), "deny") + self.assertEqual(bash("dev config edit"), "deny") + self.assertIn("terminal", reason("Bash", {"command": "dev config set safeguards.irreversible_main off"})) + # devkit's own commands write the state in their own process, which the guard does not see + self.assertEqual(bash('dev progress step "K1 protect devkit state and config" --check "14 tests"'), "allow") + self.assertEqual(bash("dev config show"), "allow") + self.assertEqual(bash('dev task add "probe" --why "k1"'), "allow") + + +class SettingsEnvTests(unittest.TestCase): + def test_a_settings_file_whose_env_sets_a_devkit_key_is_denied(self): + with tempfile.TemporaryDirectory() as tmp: + s = str(Path(tmp) / ".claude" / "settings.local.json") + self.assertEqual(verdict("Write", {"file_path": s, "content": json.dumps( + {"env": {"DEVKIT_SCOPE_OVERRIDE": "1"}})}), "deny") + # the same key spelled with a JSON escape + self.assertEqual(verdict("Write", {"file_path": s, "content": '{"env": {"DEVKIT\\u005fGUARD": "off"}}'}), + "deny") + self.assertEqual(verdict("Write", {"file_path": s, "content": json.dumps({"env": {"NODE_ENV": "test"}})}), + "allow") + toml = '[shell_environment_policy]\nset = { DEVKIT_GUARD = "off" }\n' + self.assertEqual(verdict("Write", {"file_path": str(Path(tmp) / ".codex" / "config.toml"), + "content": toml}), "deny") + + def test_an_edit_that_adds_or_changes_a_devkit_key_is_denied_but_other_env_edits_pass(self): + with tempfile.TemporaryDirectory() as tmp: + s = Path(tmp) / ".claude" / "settings.json" + s.parent.mkdir() + s.write_text(json.dumps({"env": {"DEVKIT_SKIN": "dark", "NODE_ENV": "test"}}, indent=2)) + + def edit(old, new): + return verdict("Edit", {"file_path": str(s), "old_string": old, "new_string": new}) + self.assertEqual(edit('"NODE_ENV": "test"', '"NODE_ENV": "test",\n "DEVKIT_GUARD": "off"'), "deny") + self.assertEqual(edit('"DEVKIT_SKIN": "dark"', '"DEVKIT_SKIN": "light"'), "deny") + # a DEVKIT_* key the developer already set does not block an unrelated edit + self.assertEqual(edit('"NODE_ENV": "test"', '"NODE_ENV": "dev"'), "allow") + + def test_shell_writes_of_a_devkit_key_into_settings_are_denied(self): + with tempfile.TemporaryDirectory() as tmp: + self.assertEqual(bash("echo '{\"env\": {\"DEVKIT_GUARD\": \"off\"}}' > .claude/settings.local.json", + cwd=tmp), "deny") + forged = Path(tmp) / "forged.json" + forged.write_text(json.dumps({"env": {"DEVKIT_GUARD": "off"}})) + self.assertEqual(bash(f"cp {forged} {tmp}/.claude/settings.json"), "deny") + self.assertEqual(bash("echo '{\"env\": {}}' > .claude/settings.local.json", cwd=tmp), "allow") + + +if __name__ == "__main__": + unittest.main() From d1ff213daed2c4739ce73b636ac8ce94cfac5c45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Fri, 6 Nov 2026 11:25:24 -0800 Subject: [PATCH 042/297] chore(crew): mark protected state enforced and add its follow-ups The Keymaker's a76e6e2 denies agent writes to devkit's state, configuration and install. The three gaps it found (a swapped dev link, a forged HOME or DEVKIT_CONFIG) go into this release as K1 follow-ups, since the developer asked for everything enforced. --- lib/devkit/crew.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/devkit/crew.py b/lib/devkit/crew.py index cca192f..acc58f2 100644 --- a/lib/devkit/crew.py +++ b/lib/devkit/crew.py @@ -440,7 +440,10 @@ def body(m: dict, roster=None, alias=None) -> str: ("A member records a lesson after a repeated refusal", "SubagentStop: the crew gates", "crew gates", "planned"), ("Members get the known pitfalls for their task", "dev crew brief and the Claude launch hook", "devkit.lessons.for_task", "enforced"), ("Members get the knowledge of their kind of work", "dev crew brief and the Claude launch hook", "devkit.crew.brief_skills", "enforced"), - ("Agents cannot write devkit's state or configuration", "PreToolUse: protected paths", "K1 protect devkit state and config", "planned"), + ("Agents cannot write devkit's state or configuration", "PreToolUse: protected paths, redirects, writers, nested shells", + "devkit.guard.check", "enforced"), + ("A forged HOME, DEVKIT_CONFIG or dev link cannot point devkit at fake state", "dev and the guard", + "K1 follow-ups", "planned"), ("Escape hatches are refused to members and audited", "PreToolUse: flags and variables", "F8 escape hatches and tool coverage", "planned"), ("The guard runs from a verified copy, not the checkout", "dev install (pins a read-only, checksummed release)", "devkit.setup.pin", "enforced"), From 4a452b75d825cccc63a739f7fc57f909a0a54fcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Fri, 6 Nov 2026 11:28:09 -0800 Subject: [PATCH 043/297] feat(task): reopen a finished work item with a reason A work item closed too early could not be reopened (dev task start and move leave it done): Morpheus closed two items by mistake today. dev task reopen --why puts a done or cancelled item back to todo, with the reason in its changelog and commit. --- lib/devkit/cli.py | 5 ++++- lib/devkit/work.py | 10 ++++++++++ tests/test_work.py | 12 ++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/lib/devkit/cli.py b/lib/devkit/cli.py index 1d195ee..6eceaae 100644 --- a/lib/devkit/cli.py +++ b/lib/devkit/cli.py @@ -1220,6 +1220,9 @@ def cmd_task(a): it = work.find(" ".join(a.selector), scope) it = work.release(it, why=a.why or "", status="done") print(f"{ui.OK} {it['meta']['id']} done") + elif act == "reopen": + it = work.reopen(work.find(" ".join(a.selector), scope), why=a.why or "") + print(f"{ui.OK} {it['meta']['id']} reopened (todo) · why: {a.why}") elif act == "block": it = work.update(work.find(" ".join(a.selector), scope), "block", why=a.why or "", status="blocked") print(f"{ui.OK} {it['meta']['id']} blocked") @@ -2247,7 +2250,7 @@ def add(name, fn, help_, **kw): for verb, h in (("start", "claim the lock and move to Now (doing)"), ("claim", "same as start"), ("release", "give the lock back (back to todo)"), ("done", "mark done and release"), ("block", "mark blocked (needs --why)"), ("unblock", "back to work (todo, or doing if claimed)"), - ("cancel", "cancel (needs --why)"), + ("cancel", "cancel (needs --why)"), ("reopen", "back to todo after done or cancel (needs --why)"), ("show", "an item with its history"), ("history", "an item's changelog")): t = tsp.add_parser(verb, help=h) t.add_argument("selector", nargs="+", help="id (T-…), number from `dev task`, or a unique part of the title") diff --git a/lib/devkit/work.py b/lib/devkit/work.py index 6333575..6e2e7c6 100644 --- a/lib/devkit/work.py +++ b/lib/devkit/work.py @@ -360,6 +360,16 @@ def claim(it: dict, a=None, hours: float = None, why: str = "") -> dict: return it +def reopen(it: dict, why: str, a=None) -> dict: + """A finished or cancelled item goes back to work (todo), with its reason in the changelog and the commit.""" + if not (why or "").strip(): + raise ValueError("reopening a finished item needs its reason: --why \"…\"") + status = (it.get("meta") or {}).get("status") + if status not in ("done", "cancelled"): + raise ValueError(f"{it['meta'].get('id')} is {status}, not finished: there is nothing to reopen") + return update(it, "reopen", why=why, a=a, status="todo") + + def release(it: dict, a=None, why: str = "", status: str = None) -> dict: a = a or actor() if not _owned_by(it, a): diff --git a/tests/test_work.py b/tests/test_work.py index 75f8ba7..373d984 100644 --- a/tests/test_work.py +++ b/tests/test_work.py @@ -34,6 +34,18 @@ def __exit__(self, *exc): class ItemTests(unittest.TestCase): + def test_a_finished_item_reopens_only_with_a_reason(self): + with TempHandoff(): + it = work.create("acme/api", "Export invoices", why="customer request", a=A) + it = work.release(work.load(it["path"]), why="shipped", status="done", a=A) + with self.assertRaises(ValueError): + work.reopen(work.load(it["path"]), "", a=A) # a reason is required + it = work.reopen(work.load(it["path"]), "closed too early: the review is still open", a=A) + self.assertEqual(it["meta"]["status"], "todo") + self.assertIn("why: closed too early: the review is still open", it["changelog"][0]) + with self.assertRaises(ValueError): + work.reopen(work.load(it["path"]), "again", a=A) # an open item has nothing to reopen + def test_create_versions_and_reasons(self): with TempHandoff(): it = work.create("acme/api", "Export invoices", why="customer request", a=A) From a830599762eeabd75f79d74258bf7ebc0fc68f75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Fri, 6 Nov 2026 11:28:51 -0800 Subject: [PATCH 044/297] feat(dashboard): add Background work, the crew and live state Agents could sit blocked on a decision nobody saw, CI failures stopped at PR checks, scheduled jobs ran unseen, and tasks were islands. The page now has Background work (decisions waiting for you first, runs with every step and owner, CI results with the first error and the next action, scheduled jobs with overdue and not-scheduled states), one right-hand drawer for every detail with honest loading, not-found, offline, session-ended and static-copy states, the connected task (who has it, what blocks it, what is next, and each connection with how it was found), the crew and who does what with role-first names, a live indicator with reconnecting and offline banners, in-place updates that keep scroll, focus and the open drawer and flash only changed rows, and honest states: no data or no activity is never on track, counts name their window, and contradictions show where they occur with their fix. The navigation is now Priority+: views that do not fit move into a More menu, so it never scrolls sideways from 320 to 1600 px, and it is a nav of links, not tabs without panels. Explorer tables keep their words on phones. Behavior tests drive the page in headless Chromium (Chrome, or a Playwright build; set DEVKIT_TEST_CHROME) and skip with a reason when none is installed. Spec P-fxlvyyfmjid v3. --- lib/devkit/dashboard.py | 1295 +++++++++++++++++++++++++++++++++++---- tests/test_dashboard.py | 421 +++++++++++++ 2 files changed, 1593 insertions(+), 123 deletions(-) diff --git a/lib/devkit/dashboard.py b/lib/devkit/dashboard.py index d734a78..7c6998d 100644 --- a/lib/devkit/dashboard.py +++ b/lib/devkit/dashboard.py @@ -1337,7 +1337,7 @@ def _logo(name: str, cls: str) -> str: .meta{display:inline-flex;flex-wrap:wrap;align-items:center;gap:2px var(--s3);min-width:0} .meta>*{min-width:0} /* ---------- navbar: the official firefly | devkit lockup, text tabs, search */ -.nav{position:sticky;top:0;z-index:30;display:flex;align-items:center;gap:var(--s5);height:var(--nav-h);padding:0 max(var(--s5),calc((100% - var(--wide)) / 2 + var(--s5)));background:var(--page);border-bottom:1px solid var(--line-soft)} +.nav{position:sticky;top:0;z-index:30;display:flex;align-items:center;gap:var(--s4);height:var(--nav-h);padding:0 max(var(--s5),calc((100% - var(--wide)) / 2 + var(--s5)));background:var(--page);border-bottom:1px solid var(--line-soft)} .lockup{display:flex;align-items:center;gap:var(--s3);text-decoration:none;flex:none;border-radius:var(--rs);padding:var(--s1) var(--s1) var(--s1) 0} .lockup .ff{height:22px;width:auto;flex:none} .lockup .ff-dark{display:var(--logo-dark)} .lockup .ff-light{display:var(--logo-light)} @@ -1345,16 +1345,35 @@ def _logo(name: str, cls: str) -> str: .lockup .vr{width:1px;height:22px;background:var(--line);flex:none} .lockup .dk{display:flex;align-items:center;gap:6px;color:var(--strong);font-weight:600;font-size:var(--fs-3);letter-spacing:-.02em;line-height:1;transform:translateY(-1px)} .lockup .dk .spark{width:18px;height:18px;color:var(--accent);flex:none;transform:translateY(1px)} -.tabs{display:flex;align-self:stretch;gap:var(--s4);overflow-x:auto;scrollbar-width:none;min-width:0} -.tabs::-webkit-scrollbar{display:none} -.tab{border:0;background:none;padding:0 2px;color:var(--text-2);font-size:var(--fs-2);cursor:pointer;white-space:nowrap;display:flex;align-items:center;gap:6px;box-shadow:inset 0 -2px 0 transparent} -.tab:hover{color:var(--strong)} -.tab[aria-selected="true"]{color:var(--strong);font-weight:600;box-shadow:inset 0 -2px 0 var(--strong)} -.tab.sec{margin-left:var(--s2)} -.tab .n{width:7px;height:7px;border-radius:50%;background:var(--bad);flex:none} -.tab .n.warn{background:var(--warn)} +/* the views: Priority+ (the tabs that fit, the rest in More); the row never scrolls */ +.views{display:flex;align-self:stretch;align-items:stretch;gap:var(--s4);flex:1 1 0;min-width:0} +.tabs{display:flex;align-self:stretch;gap:var(--s3);overflow:hidden;min-width:0;flex:0 1 auto} +.tab,.navmore>button{border:0;background:none;padding:0 2px;color:var(--text-2);font-size:var(--fs-2);cursor:pointer;white-space:nowrap;display:flex;align-items:center;gap:6px;box-shadow:inset 0 -2px 0 transparent;text-decoration:none} +.tab:hover,.navmore>button:hover{color:var(--strong)} +.tab[aria-current="page"],.navmore>button.cur{color:var(--strong);font-weight:600;box-shadow:inset 0 -2px 0 var(--strong)} +.tab.sec{margin-left:var(--s3)} +.tab .n,.navmore .n{width:7px;height:7px;border-radius:50%;background:var(--bad);flex:none} +.tab .n.warn,.navmore .n.warn{background:var(--warn)} +.navmore{position:relative;display:flex;align-items:stretch;flex:none} +.navmore .menu{top:calc(100% + 2px);left:auto;right:0;min-width:240px} +.navmore .menu a{display:flex;align-items:center;gap:var(--s2);padding:8px 10px;border-radius:var(--rs);text-decoration:none;color:var(--text);font-size:var(--fs-2)} +.navmore .menu a:hover,.navmore .menu a:focus-visible{background:var(--hover)} +.navmore .menu a[aria-current="page"]{color:var(--strong);font-weight:600} +.navmore .menu .mck{width:16px;flex:none;display:inline-grid;place-items:center} +.navmore .menu kbd{margin-left:auto} +.navmore .menu hr{border:0;border-top:1px solid var(--line-soft);margin:4px 6px} +@media (hover:none),(pointer:coarse){.navmore .menu kbd{display:none}} +/* live state: an 8 px dot and one word; a popover on click; a banner under the nav when it matters */ +.livew{position:relative;display:flex;align-items:center;flex:none} +.live{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 var(--s2);border:0;background:none;border-radius:var(--rs);cursor:pointer;font-size:var(--fs-1);color:var(--text-2);white-space:nowrap} +.live:hover{background:var(--hover);color:var(--strong)} +.live[data-state="init"]{visibility:hidden} +.livepop{position:absolute;top:calc(100% + 6px);right:0;z-index:45;width:min(320px,calc(100vw - 32px));display:grid;gap:var(--s2);background:var(--raised);border:var(--elev-border);border-radius:var(--r);box-shadow:var(--shadow);padding:var(--s3) var(--s4);font-size:var(--fs-1);color:var(--text)} +.livepop strong{font-size:var(--fs-2);color:var(--strong)} .livepop p{margin:0} +.banner{width:100%;max-width:var(--wide);margin:0 auto;padding:var(--s4) var(--s5) 0} +.banner .callout{margin:0} .spacer{flex:1} -.search{position:relative;flex:0 1 240px;min-width:150px} +.search{position:relative;flex:0 1 220px;min-width:150px} .search input{width:100%;height:34px;padding:0 34px 0 34px;border-radius:var(--rs);border:1px solid var(--line);background:transparent;outline:none;font-size:var(--fs-1)} .search input:focus{border-color:var(--focus)} .search .ico{position:absolute;left:10px;top:50%;transform:translateY(-50%);color:var(--text-2);pointer-events:none;display:flex} @@ -1368,7 +1387,7 @@ def _logo(name: str, cls: str) -> str: .btn.icon{width:32px;padding:0} .btn.ghost{border-color:transparent} .btn[aria-pressed="true"]{background:var(--wash)} -.btn .lbl{overflow:hidden;text-overflow:ellipsis;min-width:0} +.btn .lbl{overflow:hidden;text-overflow:ellipsis;min-width:0;font-variant-ligatures:none} .icopy{flex:none;width:28px;height:28px;border-radius:var(--rs);border:0;background:none;cursor:pointer;color:var(--text-2);display:inline-grid;place-items:center;padding:0} .icopy:hover{background:var(--hover);color:var(--strong)} .linkbtn{all:unset;cursor:pointer;color:var(--text-2);font-size:var(--fs-1);white-space:nowrap} .linkbtn:hover{color:var(--strong);text-decoration:underline} @@ -1777,7 +1796,7 @@ def _logo(name: str, cls: str) -> str: .md pre{background:var(--wash);padding:12px 14px;border-radius:var(--rs);overflow:auto} .md pre code{background:none;padding:0} .md blockquote{margin:.6em 0;padding:.1em 0 .1em 14px;border-left:1px solid var(--line);color:var(--text-2)} -.md table{border-collapse:collapse;margin:.8em 0;font-size:var(--fs-1);display:block;overflow-x:auto} +.md table{border-collapse:collapse;margin:.8em 0;font-size:var(--fs-1);display:block;overflow-x:auto;overflow-wrap:normal;max-width:100%} .md th,.md td{border-bottom:1px solid var(--line-soft);padding:6px 10px;text-align:left} .md th{font-weight:600;border-bottom-color:var(--line)} .md hr{border:0;border-top:1px solid var(--line-soft);margin:1.2em 0} @@ -1823,7 +1842,9 @@ def _logo(name: str, cls: str) -> str: /* status and magnitude colors (dataviz: fixed status palette, one-hue ramp); fills only, text stays in text tokens */ .k-ok{fill:var(--s-ok);background:var(--s-ok)} .k-warn{fill:var(--s-warn);background:var(--s-warn)} .k-risk{fill:var(--s-risk);background:var(--s-risk)} .k-done{fill:var(--s-done);background:var(--s-done)} .k-none{fill:var(--s-none);background:var(--s-none)} .k-mark{fill:var(--mark);background:var(--mark)} -.dot.k-ok,.dot.k-warn,.dot.k-risk,.dot.k-done,.dot.k-none,.dot.k-mark{display:inline-block} +.k-quiet{fill:var(--track);background:var(--track);box-shadow:inset 0 0 0 1px var(--axis)} +.k-hol{fill:none;background:transparent;box-shadow:inset 0 0 0 1.5px var(--axis)} +.dot.k-ok,.dot.k-warn,.dot.k-risk,.dot.k-done,.dot.k-none,.dot.k-mark,.dot.k-quiet,.dot.k-hol{display:inline-block} .stat{display:inline-flex;align-items:center;gap:6px;white-space:nowrap} /* stat tiles */ .stile{display:grid;grid-template-columns:minmax(0,1fr);align-content:start;gap:var(--s2);padding:var(--s3) var(--s4) var(--s4);border-radius:12px;background:var(--card);text-decoration:none;color:inherit;min-width:0} @@ -1916,11 +1937,13 @@ def _logo(name: str, cls: str) -> str: .heat .h0,.heat-l .h0{fill:var(--h0);background:var(--h0)} .heat .h1,.heat-l .h1{fill:var(--h1);background:var(--h1)} .heat .h2,.heat-l .h2{fill:var(--h2);background:var(--h2)} .heat .h3,.heat-l .h3{fill:var(--h3);background:var(--h3)} .heat .h4,.heat-l .h4{fill:var(--h4);background:var(--h4)} .heat-l{display:flex;align-items:center;gap:4px;margin-top:var(--s2);font-size:var(--fs-1);color:var(--text-2)} .heat-l i{width:12px;height:12px;border-radius:3px} -/* the quick-look modal */ -dialog.modal{width:min(760px,calc(100vw - 32px));max-height:calc(100dvh - 48px);padding:0;border:var(--elev-border);border-radius:16px;background:var(--raised);color:var(--text);box-shadow:var(--shadow);overflow:auto;overscroll-behavior:contain} +/* the detail drawer: one native on the right edge, full height; its body scrolls between a fixed header and footer */ +dialog.modal{position:fixed;inset:0 0 0 auto;margin:0;width:min(760px,calc(100vw - 48px));max-width:none;height:100vh;height:100dvh;max-height:none;padding:0;border:var(--elev-border);border-radius:16px 0 0 16px;background:var(--raised);color:var(--text);box-shadow:var(--shadow);overflow:hidden;flex-direction:column} +dialog.modal[open]{display:flex} dialog.modal::backdrop{background:var(--overlay)} dialog.modal:focus{outline:none} -.mh{position:sticky;top:0;z-index:3;display:flex;gap:var(--s3);align-items:flex-start;padding:var(--s5) var(--s5) var(--s4);background:var(--raised);border-bottom:1px solid var(--line-soft)} +.mh{flex:none;z-index:3;display:flex;gap:var(--s3);align-items:flex-start;padding:var(--s5) var(--s5) var(--s4);background:var(--raised);border-bottom:1px solid var(--line-soft)} +.mbody{flex:1 1 auto;overflow:auto;overscroll-behavior:contain;align-content:start} .mh-t{flex:1;min-width:0;display:grid;gap:var(--s1)} .mh h2{margin:0;font-size:var(--fs-4);font-weight:400;letter-spacing:-.025em;line-height:1.2;color:var(--strong);overflow-wrap:anywhere;text-wrap:balance} .mh .crumbs ol{margin:0} @@ -1933,12 +1956,66 @@ def _logo(name: str, cls: str) -> str: .lockline{display:flex;gap:var(--s3);align-items:flex-start;font-size:var(--fs-2)} .lockline .t2{font-size:var(--fs-1);margin-top:2px} .nextcmd{display:grid;gap:var(--s1);padding:var(--s3) var(--s4);border-radius:10px;background:var(--wash)} .nextcmd code{color:var(--text)} @media (prefers-reduced-motion:no-preference){dialog.modal[open]{animation:m-in .18s var(--ease)} dialog.modal[open]::backdrop{animation:b-in .18s linear} - @keyframes m-in{from{opacity:0;transform:translateY(8px)}} @keyframes b-in{from{opacity:0}}} + @keyframes m-in{from{opacity:0;transform:translateX(16px)}} @keyframes b-in{from{opacity:0}} + .flash{animation:flash 1.6s var(--ease) 1} @keyframes flash{from{box-shadow:inset 0 0 0 100vmax var(--hl)}to{box-shadow:inset 0 0 0 100vmax transparent}}} @media (prefers-reduced-motion:reduce){dialog.modal[open]{animation:b-in .12s linear} @keyframes b-in{from{opacity:0}}} +/* drawer states: skeleton bars appear only when loading takes longer than 150 ms (no shimmer) */ +.skel{display:grid;gap:var(--s3);animation:skel-in 1ms 150ms both} @keyframes skel-in{from{visibility:hidden}to{visibility:visible}} +.skel i{display:block;height:14px;border-radius:4px;background:var(--wash)} .skel i:nth-child(3n+1){width:62%} .skel i:nth-child(3n+2){width:88%} .skel i.blk{height:72px;width:100%} +.mmeta{font-size:var(--fs-1);color:var(--text-2)} +/* logs and excerpts from CI, jobs and agents: text only, monospace, scrolled inside the block */ +pre.log{margin:0;padding:var(--s3);border-radius:var(--rs);background:var(--wash);color:var(--text);font:400 var(--fs-1)/1.5 var(--mono);white-space:pre;overflow:auto;max-height:320px;tab-size:2;min-width:0} +.logh{display:flex;align-items:baseline;gap:var(--s2);font-size:var(--fs-1);color:var(--text-2);min-width:0} .logh .rpath{margin-left:auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0} +/* Background work: run blocks, CI rows, the jobs table */ +.rgrid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--s4);align-items:start} +.rcard{display:grid;grid-template-columns:minmax(0,1fr);gap:6px;padding:var(--s4);border-radius:12px;background:var(--card);cursor:pointer;min-width:0;font-size:var(--fs-1)} +.rcard:hover{background:var(--card-hover)} +.rcard .rh{display:flex;align-items:baseline;gap:var(--s3);min-width:0} +.rcard .rh a{font-size:var(--fs-2);font-weight:600;color:var(--strong);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1 1 auto} +.rcard .rst{flex:none;white-space:nowrap;color:var(--text)} .rcard.blocked .rst,.rcard.waiting .rst{color:var(--bad-text)} .rcard.lost .rst{color:var(--warn-text)} .rcard.done .rst{color:var(--good-text)} +.rcard .rw{color:var(--text-2);min-width:0} +.rcard.done .rbar i{background:var(--good)} .rcard.blocked .rbar i,.rcard.waiting .rbar i{background:var(--bad)} .rcard.lost .rbar i{background:var(--warn)} +.rcard .rsteps{gap:2px} .rcard .warnline{padding:var(--s1) 0;border:0} +.cirows{list-style:none;margin:0;padding:0} +.cirow{display:grid;grid-template-columns:8px minmax(0,1fr) auto;gap:4px var(--s3);align-items:baseline;padding:var(--s3) var(--s2);border-bottom:1px solid var(--line-soft);font-size:var(--fs-1);min-width:0} +.cirow:last-child{border-bottom:0} .cirow:hover{background:var(--hover)} +.cirow .cx{grid-column:2/-1;display:grid;gap:var(--s2);min-width:0} .cirow .ct{color:var(--text);min-width:0} .cirow .ct strong{font-weight:600;color:var(--strong)} +.cirow .cr{color:var(--text-2);white-space:nowrap;text-align:right} +.cirow .ca{display:flex;flex-wrap:wrap;gap:var(--s2)} +.foot2{margin:var(--s3) 0 0;font-size:var(--fs-1);color:var(--text-2)} +table.jt td.c-job{max-width:340px} table.jt .jab{display:block;font-size:var(--fs-1);color:var(--text-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +table.t .cl{display:none} +.jstrip rect.k-mark{fill:var(--mark)} .jstrip rect.k-risk{fill:var(--s-risk)} +/* the run drawer's steps and the connected task's connections */ +.steps{list-style:none;margin:0;padding:0} +.steps>li{display:grid;grid-template-columns:24px 14px minmax(0,1fr) auto;gap:2px var(--s2);padding:var(--s2) 0;border-bottom:1px solid var(--line-soft);font-size:var(--fs-1);align-items:baseline;min-width:0} +.steps>li:last-child{border-bottom:0} +.steps .sn{color:var(--text-2);text-align:right;font-variant-numeric:tabular-nums} .steps .g{color:var(--text-2)} +.steps .nm{color:var(--strong);font-weight:600;min-width:0;overflow-wrap:anywhere} .steps .ow{color:var(--text-2);font-weight:400} +.steps .tm{color:var(--text-2);white-space:nowrap;text-align:right} +.steps .sx{grid-column:3/-1;display:grid;grid-template-columns:72px minmax(0,1fr);gap:2px var(--s3);min-width:0} +.steps .sx dt{color:var(--text-2)} .steps .sx dd{margin:0;min-width:0;overflow-wrap:anywhere;color:var(--text)} +.steps .sx .wide{grid-column:1/-1} +.steps li.running .g{color:var(--strong)} .steps li.blocked .g,.steps li.waiting .g{color:var(--bad-text)} .steps li.done .g{color:var(--good-text)} +.conns{list-style:none;margin:0;padding:0} +.conn{display:grid;grid-template-columns:16px 84px minmax(0,1fr);gap:2px var(--s3);padding:var(--s2) 0;border-bottom:1px solid var(--line-soft);font-size:var(--fs-1);align-items:baseline;min-width:0} +.conn:last-child{border-bottom:0} .conn>.i{color:var(--text-2);align-self:center} .conn .k{color:var(--text-2)} .conn .v{min-width:0;overflow-wrap:anywhere} .conn .how,.conn .xs{grid-column:3;color:var(--text-2);min-width:0} +.ans{display:grid;grid-template-columns:120px minmax(0,1fr);gap:var(--s3) var(--s4);margin:0;font-size:var(--fs-2)} +.ans dt{color:var(--text-2);font-size:var(--fs-1);padding-top:2px} .ans dd{margin:0;display:grid;gap:var(--s1);min-width:0;overflow-wrap:anywhere} +.ans .nextcmd{margin-top:var(--s1)} +/* crew: who is working now, the roster, and who does what */ +table.crew{table-layout:fixed} table.crew th.c-mem{width:19%} table.crew th.c-sum{width:27%} table.crew th.c-does{width:16%} table.crew th.c-appr{width:18%} table.crew th.c-acc{width:10%} table.crew th.c-now{width:10%} +table.crew td{vertical-align:top;height:auto;padding-top:var(--s3);padding-bottom:var(--s3)} table.crew th[scope="row"]{vertical-align:top;padding-top:var(--s3);white-space:normal} table.crew th[scope="row"]{font-size:var(--fs-2);font-weight:600;color:var(--strong);text-align:left} +table.t th[scope="row"]{position:static;background:none;z-index:auto;border-bottom-color:var(--line-soft)} +table.raci{table-layout:fixed} table.raci th[scope="row"]{font-weight:600;color:var(--strong);text-align:left;font-size:var(--fs-1)} table.raci td{font-size:var(--fs-1);vertical-align:top} table.raci th[scope="col"]{white-space:normal} +table.raci td,table.raci th[scope="row"]{height:auto;padding-top:var(--s2);padding-bottom:var(--s2);vertical-align:top} +.raci .me{color:var(--strong);font-weight:600} +.wdot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--good);margin-left:5px;vertical-align:1px} +.rblk{display:none} /* the 12-column grid steps down: 6 columns, then one */ @media (max-width:1180px){.sp-2,.sp-3{grid-column:span 6}.sp-4,.sp-5{grid-column:span 6}.sp-8,.full-md{grid-column:1/-1}} @media (max-width:768px){.g12>*{grid-column:1/-1}.g12>.stile{grid-column:span 6}.ctrs{grid-template-columns:repeat(2,minmax(0,1fr))} - .mfigs{grid-template-columns:minmax(0,1fr)} dialog.modal{width:calc(100vw - 16px);max-height:calc(100dvh - 16px);border-radius:14px} + .mfigs{grid-template-columns:minmax(0,1fr)} dialog.modal{width:100vw;border-radius:0;border:0} .mh{padding:var(--s4)}.mbody{padding:var(--s4)}.mf{padding:var(--s3) var(--s4)} .mh-row{gap:var(--s3)} .stile .s-v b,.ctr .s-v b{font-size:var(--fs-3)}} /* sticky table headers on wide screens (tables that fit), under the navbar and the page tabs */ @@ -1946,18 +2023,32 @@ def _logo(name: str, cls: str) -> str: main .tw.st:not(.x) table.t th{top:var(--nav-h)} main .tabpanel .tw.st:not(.x) table.t th{top:calc(var(--nav-h) + var(--tabs-h))}} /* ---------- responsive */ @media (max-width:1600px){table.pt:not(.it) .c-act{display:none}} -@media (max-width:1180px){.nav{gap:var(--s4)}.tabs{gap:var(--s3)}.search{flex-basis:170px}#palbtn{display:none} +@media (max-width:1180px){.nav{gap:var(--s4)}.views{gap:var(--s3)}.search{flex-basis:170px}#palbtn{display:none}.rgrid{grid-template-columns:minmax(0,1fr)} .board{grid-template-columns:repeat(2,minmax(0,1fr))}.c-size,.c-act{display:none}.c-who{display:none} .since{grid-template-columns:repeat(2,minmax(0,1fr))}.people{grid-template-columns:minmax(0,1fr)}} @media (max-width:1024px){.cols,.cols.even{grid-template-columns:minmax(0,1fr)}.search{flex-basis:150px}.c-last{display:none} .inir{grid-template-columns:minmax(0,1fr) minmax(120px,200px)}.inir .c3,.inir .c4{grid-column:1/-1}} @media (pointer:coarse){.icopy{width:40px;height:40px}.btn{height:40px}.btn.sm{height:36px}} +@media (max-width:480px){.navmore .menu{left:var(--s4);min-width:0} .tabs,.views{gap:var(--s3)!important} .live .lgw{display:none}} @media (max-width:768px){ :root{--nav-h:0px} .foot{width:calc(100% - 2 * var(--s4))} .nav{position:relative;flex-wrap:wrap;height:auto;padding:var(--s3) var(--s4);gap:var(--s3)} .lockup .ff{height:18px}.lockup .vr{height:18px}.lockup .dk{font-size:var(--fs-2)} - .tabs{order:3;width:100%;height:40px;gap:var(--s4)}.search{order:2;flex:1 1 100%}#palbtn{display:none} + .views{order:3;flex:1 1 100%;height:40px;gap:var(--s4)}.search{order:2;flex:1 1 100%}#palbtn{display:none}.livew{margin-left:auto} + .navmore{position:static}.navmore .menu{right:var(--s4)} .banner{padding:var(--s3) var(--s4) 0} + .head .sub{overflow-wrap:anywhere;min-width:0} + table.jt thead,table.crew thead{display:none} + table.jt,table.jt tbody,table.jt tr,table.jt td,table.crew,table.crew tbody,table.crew tr,table.crew td,table.crew th{display:block;width:100%} + table.jt tr,table.crew tr{display:grid;grid-template-columns:minmax(0,1fr);gap:2px;padding:var(--s3) var(--s2);border-bottom:1px solid var(--line-soft)} + table.jt td,table.crew td,table.crew th,table.crew th:first-child,table.jt td:first-child,table.crew td:first-child{height:auto;border:0;padding:0;white-space:normal;max-width:none} + table.t .cl{display:inline;color:var(--text-2)} + table.raci{display:none} .rblk{display:block} + .conn{grid-template-columns:16px minmax(0,1fr)} .conn .k{grid-column:2} .conn .v,.conn .how,.conn .xs{grid-column:2} + .ans{grid-template-columns:minmax(0,1fr);gap:var(--s1)} .ans dd{margin-bottom:var(--s3)} + .steps>li{grid-template-columns:24px 14px minmax(0,1fr)} .steps .tm{grid-column:3;text-align:left} .steps .sx{grid-template-columns:minmax(0,1fr)} + .rcard .rh{flex-direction:column;align-items:flex-start;gap:2px} .rcard .rh a{white-space:normal;flex:none;max-width:100%} + .rcard .rsteps li{row-gap:0} .rcard .rsteps .ell{white-space:normal;overflow:visible} .rcard .rsteps .who{display:block} .rcard .rsteps .who .sep{display:none} .rcard .rsteps .m{text-align:right;max-width:9em} .filters{padding:var(--s3) var(--s4) 0;overflow-x:auto;flex-wrap:nowrap} main{padding:var(--s5) var(--s4) var(--s7)} .head{margin-bottom:var(--s5)} @@ -2002,13 +2093,18 @@ def _logo(name: str, cls: str) -> str: "use strict"; window.addEventListener("error", e => { const b = document.createElement("div"); b.className = "err"; b.id = "dk-error"; b.textContent = "Dashboard error: " + (e.message || e.error); document.body.append(b); }); +window.addEventListener("unhandledrejection", e => { const b = document.createElement("div"); b.className = "err"; b.id = "dk-error"; + b.textContent = "Dashboard error: " + (e.reason && e.reason.message || e.reason); document.body.append(b); }); let D = JSON.parse(document.getElementById("devkit-data").textContent); /* live mode swaps it for a newer snapshot */ -const VIEWS = [["overview", "Home", "o"], ["initiatives", "Initiatives", "i"], ["projects", "Projects", "p"], ["team", "Team", "m"], ["tasks", "Tasks", "t"], ["activity", "Activity", "a"], ["explorer", "Explorer", "e"], ["health", "Health", "h"]]; +/* the views, by how often a day of work needs them, then the reference views (the first of those starts the second group) */ +const VIEWS = [["overview", "Home", "o"], ["tasks", "Tasks", "t"], ["background", "Background", "b"], ["team", "Team", "m"], ["projects", "Projects", "p"], ["initiatives", "Initiatives", "i"], ["activity", "Activity", "a"], ["explorer", "Explorer", "e"], ["health", "Health", "h"]]; +const REFVIEW = "activity"; const ROUTES = VIEWS.map(v => v[0]).concat(["search", "actions"]); const DEFAULT = {view: "overview", id: "", tab: "", task: "", q: "", status: [], priority: [], owner: [], stack: [], tag: [], ci: [], health: [], live: false, prs: false, nc: false, loose: false, sort: "attention", group: "none", layout: "cards", dense: false, f: "", fq: "", - range: 7, types: [], tp: "", tmode: "board", who: "", tst: "", ist: "", psort: "priority", ilayout: "cards", mq: "", mg: "", src: "all", kind: "", modal: ""}; -const PAGEKEYS = {tab: "", task: "", modal: "", mq: "", mg: "", src: "all", tst: ""}; + range: 7, types: [], tp: "", tmode: "board", who: "", tst: "", ist: "", psort: "priority", ilayout: "cards", mq: "", mg: "", src: "all", kind: "", + cq: "", rq: "", rm: "", rall: false, modal: ""}; +const PAGEKEYS = {tab: "", task: "", modal: "", mq: "", mg: "", src: "all", tst: "", cq: "", rq: "", rm: "", rall: false}; const $ = (s, r = document) => r.querySelector(s); const $$ = (s, r = document) => Array.from(r.querySelectorAll(s)); const SVGNS = "http://www.w3.org/2000/svg"; @@ -2062,7 +2158,7 @@ def _logo(name: str, cls: str) -> str: function stripMarker(line) { return String(line || "").replace(/^[\W_]*(?:\[\s*firefly\s*>\s*devkit\s*\]|devkit(?=[\s·:—*`]))[\s·:—*`]*/i, "").replace(/(status:\s*)(?:\*\*|`)?(?:\[\s*firefly\s*>\s*devkit\s*\]|devkit\s*·)(?:\*\*|`)?\s*/gi, "$1"); } function iso(s) { const t = Date.parse(s || ""); return isNaN(t) ? 0 : t / 1000; } /* the window behind a count: the guard log keeps the last N decisions, the session log two months, commits 30 days */ -function guardWindow() { return W.guard_since ? ", since " + shortDay(String(W.guard_since).slice(0, 10)) : ""; } +function guardWindow() { return (W.guard_capped ? ", the last " + W.guard_events : "") + (W.guard_since ? ", since " + shortDay(String(W.guard_since).slice(0, 10)) : ""); } function fmtMin(m) { m = Math.max(0, Math.round(m || 0)); return m >= 60 ? Math.floor(m / 60) + "h" + String(m % 60).padStart(2, "0") + "m" : m + "m"; } /* ---------------------------------------------------------------- data model: work items, projects, initiatives, people */ @@ -2074,7 +2170,7 @@ def _logo(name: str, cls: str) -> str: const PRANK = {high: 0, normal: 1, low: 2}, HRANK = {risk: 0, warn: 1, ok: 2}; const KIND = {"ci-failing": "CI", "changes-requested": "Review", "conflicts": "Conflicts", "review-waiting": "Review", "review-requested": "Review", "blocked": "Blocked", "blocked-task": "Blocked", "overdue": "Overdue", "idle-work": "Unsaved work", "reclaim": "Reclaim", "layout": "Layout", "stale-note": "Handoff", - "drift": "Config", "sign-in": "Sign in", "collision": "Collision", "expired-lock": "Lock"}; + "drift": "Config", "sign-in": "Sign in", "collision": "Collision", "expired-lock": "Lock", "decision": "Decision", "ci-watch": "CI watch", "job-failed": "Job", "setup": "Setup"}; const CHECKS = {passing: "CI passing", failing: "CI failing", pending: "CI running", none: "no CI", unknown: "CI unknown"}; const ISTATUS = {todo: ["", "To do"], doing: ["", "Doing"], blocked: ["", "Blocked"], done: ["", "Done"], cancelled: ["", "Cancelled"]}; const SDOT = {active: "d-ok", paused: "d-warn", blocked: "d-risk", done: "d-info", archived: "d-info", untracked: "d-info", unknown: "d-info"}; @@ -2087,6 +2183,7 @@ def _logo(name: str, cls: str) -> str: function cnt(items) { const c = {open: 0, todo: 0, doing: 0, blocked: 0, done: 0, cancelled: 0, total: items.length}; for (const x of items) { if (x.open) c.open++; if (c[x.status] != null) c[x.status]++; } return c; } let ITEMS, ITEMBY, BYSCOPE, P, BYID, PEOPLE, BYWHO, ONPROJ, RUNS, INITS, BYINIT, MEMBEROF, FILES, FILEMAP, ACTIONS, W; +let CREW, CREWBY, RACI, JOBS, CIW, RRUNS, CACHE = {}; /* CACHE: values computed once per snapshot (needs, contradictions) */ /* scopes without work items still show their note checkboxes (legacy) as read-only items */ function legacy(scope, t, tidx) { const out = []; let k = 0; @@ -2168,8 +2265,216 @@ def _logo(name: str, cls: str) -> str: FILES = D.files || []; FILEMAP = new Map(FILES.map(f => [f.path, f])); ACTIONS = D.actions || []; + CREW = (D.crew || {}).members || []; + RACI = (D.crew || {}).activities || []; + CREWBY = new Map(); + for (const m of CREW) for (const k of [m.id, m.name]) if (k) CREWBY.set(String(k).toLowerCase(), m); + JOBS = D.jobs || []; + CIW = Object.assign({watches: [], watching: [], complete: true, total: 0}, D.ci || {}); + /* runs on other machines reach this Mac only as their presence summary */ + const host = (ME.split("@")[1] || D.host || "").toLowerCase(), local = new Set(RUNS.map(r => r.agent + "~" + r.session)); + RRUNS = []; + for (const x of PEOPLE) for (const s of x.sessions) if (s.progress && String(x.host || "").toLowerCase() !== host && !local.has(s.agent + "~" + s.session)) + RRUNS.push(Object.assign({}, s.progress, {id: "remote~" + x.who + "~" + s.agent + "~" + s.session, remote: true, host: x.host, who: x.who, agent: s.agent, session: s.session, project: s.project, branch: s.branch, asOf: x.updated, decisions: [], questions: []})); + CACHE = {}; } derive(); +/* ---------------------------------------------------------------- crew, runs, jobs and CI: one vocabulary (role first; a word next to every color) */ +const AGENTS = {claude: "Claude Code", codex: "Codex"}; +function agentName(id) { return AGENTS[String(id || "").toLowerCase()] || String(id || "agent"); } +function mainMember() { return CREW.find(m => m.main) || {id: "morpheus", name: "Morpheus", title: "Tech lead", main: true}; } +/* the roster member a persona, id, name or lane label ("Kid: curl installer") names; null when none matches */ +function member(x) { + const s = String(x || "").trim(); if (!s) return null; + if (s === "main") return mainMember(); + const m = CREWBY.get(s.toLowerCase()); if (m) return m; + return s.includes(":") ? CREWBY.get(s.slice(0, s.indexOf(":")).trim().toLowerCase()) || null : null; +} +function roleName(m) { return m.title ? m.title + " (" + m.name + ")" : String(m.name || m.id); } +/* role first: "Backend engineer (Tank)"; a lane with no member is "Subagent: