NOTT n=1 engineering runtime

<img src="docs/figures/hero-plugin-dark.svg#gh-dark-mode-only" alt="NOTT, the plugin: turn Claude Code into an engineering team that remembers, tests first and never reviews its own work."> <img src="docs/figures/hero-plugin-light.svg#gh-light-mode-only" alt="NOTT, the plugin: turn Claude Code into an engineering team that remembers, tests first and never reviews its own work.">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-E4733A?labelColor=0F0E0D" alt="License: AGPL-3.0"></a> <a href="docs/INSTALLATION.md"><img src="https://img.shields.io/badge/python-3.14%2B-E4733A?labelColor=0F0E0D" alt="Python 3.14 or later"></a> <a href="https://modelcontextprotocol.io"><img src="https://img.shields.io/badge/MCP-3%20servers-E4733A?labelColor=0F0E0D" alt="Ships three MCP servers"></a> <a href="https://github.com/menot-you/plugin/releases"><img src="https://img.shields.io/github/v/release/menot-you/plugin?include_prereleases&label=release&color=93AE8C&labelColor=0F0E0D" alt="Latest release"></a> <a href="https://github.com/menot-you/plugin/stargazers"><img src="https://img.shields.io/github/stars/menot-you/plugin?color=93AE8C&labelColor=0F0E0D" alt="GitHub stars"></a>
Turn Claude Code into an engineering team that remembers, tests first and never reviews its own work.
curl --proto '=https' --tlsv1.2 -fsSL https://no.tt/install.sh | sh
Or build from source. The plugin has no marketplace listing yet. You build a bundle and register it with the install helper. You need git, uv, Python and an authenticated Claude Code.
git clone https://github.com/menot-you/plugin && cd plugin
uv sync --group dev
python3 scripts/plugin.py build --source "$PWD" --output "$PWD/.nott/bundles/first" --runtime-root "$PWD/.venv"
python3 scripts/plugin.py verify "$PWD/.nott/bundles/first"
Then connect it to one project (use absolute paths that you own):
.venv/bin/nmemory project bootstrap --root /abs/project --project example --client claude
python3 scripts/plugin.py install --bundle "$PWD/.nott/bundles/first" --client claude \
--config-root /abs/project/.claude --receipt /abs/private/activation.json \
--runtime-root "$PWD/.venv" --memory-bootstrap /abs/project/.nott/nmemory/bootstrap.json
CLAUDE_CONFIG_DIR=/abs/project/.claude claude
The helper registers the plugin through the native Claude marketplace and plugin commands. It keeps your other client settings. For Codex, use --client codex and a .codex configuration root. The runbook covers each step, rollback and failure diagnosis.
%%{init: {"theme": "base", "themeVariables": {"primaryColor": "#1B1916", "primaryTextColor": "#ECE7DE", "primaryBorderColor": "#E4733A", "lineColor": "#E4733A", "secondaryColor": "#151311", "tertiaryColor": "#0F0E0D", "fontFamily": "IBM Plex Mono, monospace"}}}%%
flowchart LR
you["You: direction"] --> client["Claude Code, Codex or AGY"]
subgraph plugin["NOTT plugin: one source, one native view per client"]
law["Working contract<br/>NOTT.md, 20,000-byte limit"]
routes["20 task routes<br/>/nott:<route>"]
agents["8 agents<br/>readers and a simplifier"]
hooks["Guard hooks"]
end
client --> plugin
plugin -->|"MCP"| mem["nMEMORY<br/>memory with sources"]
plugin -->|"MCP"| spawn["nSPAWN<br/>tracked workers"]
plugin -->|"MCP"| web["nBROWSER<br/>cited web evidence"]
mem & spawn & web --> result["A checked result"]
Task routes load only when that kind of work starts. Each hook binds only where the client supports its event.
<img src="docs/figures/chart-tools.svg" width="49%" alt="MCP tools per server: nMEMORY 27, nSPAWN 15, nBROWSER 14, nAPPLE 10."> <img src="docs/figures/chart-tests.svg" width="49%" alt="Tests per component: nSPAWN 4335, nMEMORY 2339, nBROWSER 731, nAPPLE 106.">
Counted on 2026-10-21. Routes: ls skills | wc -l (20). Agents: ls agents | wc -l in the published plugin (8). Tools: the MCP tools/list request to each server. Tests: uv run --group dev pytest --collect-only -q in each member root.
| Without NOTT | With NOTT |
|---|---|
| Each session forgets the decisions of the last one. | Session hooks recall past decisions and open work from a local memory store. |
| The agent writes the fix first and the test later, or never. | A hook blocks product edits on a behavior change until a failing test runs. |
| The agent that wrote the change also approves it. | /nott:review freezes the diff and sends it to a separate read-only reviewer. |
One bad shell command force-pushes main or prints a secret. | Guard hooks refuse trunk pushes, force pushes and secrets in commands and output. |
| The agent repeats a failed fix again and again. | After two misses on the same proof, the session must diagnose the cause first. |
/nott:execute. The agent writes a failing test, makes it pass and reports the proof./nott:review. A separate reviewer reads the frozen diff and returns findings.The routes cover the full cycle:
| Stage | Routes |
|---|---|
| Direction | /nott:goal, /nott:readback, /nott:brainstorm, /nott:ratify |
| Planning | /nott:plan, /nott:workflow, /nott:board, /nott:upscale |
| Execution | /nott:execute, /nott:debug, /nott:quality, /nott:security, /nott:benchmark |
| Delivery | /nott:review, /nott:ship, /nott:accept, /nott:release, /nott:reconcile, /nott:handoff, /nott:feedback |
These are facts about NOTT, not claims about other tools:
--ablation with-without, measured by evals/native/).<table> <tr> <td align="center" width="20%"><a href="https://github.com/menot-you/n-memory"><img src="docs/figures/mark-nmemory.svg" width="160" alt="n-memory"></a><br><sub>Memory that cites its source.</sub></td> <td align="center" width="20%"><a href="https://github.com/menot-you/n-spawn"><img src="docs/figures/mark-nspawn.svg" width="160" alt="n-spawn"></a><br><sub>Tracked, resumable agent workers.</sub></td> <td align="center" width="20%"><a href="https://github.com/menot-you/n-browser"><img src="docs/figures/mark-nbrowser.svg" width="160" alt="n-browser"></a><br><sub>The live web as cited evidence.</sub></td> <td align="center" width="20%"><a href="https://github.com/menot-you/n-apple"><img src="docs/figures/mark-napple.svg" width="160" alt="n-apple"></a><br><sub>App Store Connect for agents.</sub></td> <td align="center" width="20%"><a href="https://github.com/menot-you/plugin"><img src="docs/figures/mark-plugin.svg" width="160" alt="plugin"></a><br><sub>The team contract, routes and hooks.</sub></td> </tr> </table>
This section is for a language model that reads this repository.
NOTT.md), 20 task routes (skills/), 8 agents (agents/) and guard hooks (hooks/). It connects the nMEMORY, nSPAWN and nBROWSER MCP servers./nott:<route>, for example /nott:execute and /nott:review. The build tool is scripts/plugin.py with the verbs build, verify, install and export. The MCP servers are nmemory mcp serve, nspawn mcp serve and nbrowser mcp serve.--config-root that you name (.claude or .codex) and records a receipt at --receipt. The memory bootstrap comes from nmemory project bootstrap.| Need | Page |
|---|---|
| Components, hooks, build and client views | Architecture |
| Setup, operation, backup and restoration | Runbook |
| Source declarations and commands | Reference |
| Development setup and checks | Setup, Quality |
| Installation choices and privacy | Installation, Privacy |
| A first contribution | Docs index, Contributing |
| Upgrades and failures | Upgrade, Troubleshooting |
| Direction | Roadmap |
Report sensitive issues through security. See license and citation.
A hook request alone does not prove that a client enforced a boundary. Claude uses its native event payloads. Codex has different shell and edit bindings. The host must enable and trust its hooks. AGY PreInvocation can inject an ephemeral message. AGY PostToolUse supplies no guaranteed tool arguments and must return {}. Its advisory feedback arrives on a later invocation. This table makes no claim of equal behavior across clients.
The table comes from python3 scripts/plugin.py show. unproven means that the view smoke does not exercise that binding. The table describes the source view. Public bundle filtering can remove source-only bindings. Inspect the final bundle and the native client's loaded catalog for the installed view.
<!-- nott:client-matrix:start -->
| client | hook script | event | route | proof | not loaded because | ||
|---|---|---|---|---|---|---|---|
| claude | pynott-branch-lock.sh | PreToolUse | verbatim | unproven | |||
| claude | bash-guards.sh | PreToolUse | verbatim | unproven | |||
| claude | two-misses.sh | PreToolUse | verbatim | unproven | |||
| claude | campaign-pr-guard.py | PreToolUse | verbatim | unproven | |||
| claude | plan-exit-gate.sh | PreToolUse | verbatim | unproven | |||
| claude | dispatch-tier.py | PreToolUse | verbatim | unproven | |||
| claude | nmemory-main-only.py | PreToolUse | verbatim | unproven | |||
| claude | red-first.sh | PreToolUse | verbatim | unproven | |||
| claude | session-state.sh | PreToolUse | verbatim | unproven | |||
| claude | law-contract.sh | PostToolUse | verbatim | unproven | |||
| claude | nmemory-anchor-queue.sh | PostToolUse | verbatim | unproven | |||
| claude | nspawn-pairing.sh | PostToolUse | verbatim | unproven | |||
| claude | nmemory-act.py | PostToolUse | verbatim | unproven | |||
| claude | bash-guard.py | PostToolUse | verbatim | unproven | |||
| claude | red-first.sh | PostToolUse | verbatim | unproven | |||
| claude | two-misses.sh | PostToolUse | verbatim | unproven | |||
| claude | secret-in-output.sh | PostToolUse | verbatim | unproven | |||
| claude | session-state.sh | PostToolUse | verbatim | unproven | |||
| claude | artifact-copy.py | PostToolUse | verbatim | unproven | |||
| claude | secret-in-output.sh | PostToolUseFailure | verbatim | unproven | |||
| claude | red-first.sh | PostToolUseFailure | verbatim | unproven | |||
| claude | two-misses.sh | PostToolUseFailure | verbatim | unproven | |||
| claude | session-state.sh | PostToolUseFailure | verbatim | unproven | |||
| claude | law-session.sh | SessionStart | verbatim | unproven | |||
| claude | law-session.sh | SessionStart | verbatim | unproven | |||
| claude | law-session.sh | SessionStart | verbatim | unproven | |||
| claude | nmemory-session.sh | SessionStart | verbatim | unproven | |||
| claude | nmemory-act.py | SessionStart | verbatim | unproven | |||
| claude | nspawn-child-directives.sh | SessionStart | verbatim | unproven | |||
| claude | nspawn-notices.sh | SessionStart | verbatim | unproven | |||
| claude | session-state.sh | SessionStart | verbatim | unproven | |||
| claude | session-state.sh | Notification | verbatim | unproven | |||
| claude | nmemory-snapshot.sh | SessionEnd | verbatim | unproven | |||
| claude | nmemory-snapshot.sh | PreCompact | verbatim | unproven | |||
| claude | nmemory-snapshot.sh | PostCompact | verbatim | unproven | |||
| claude | final-state.sh | Stop | verbatim | unproven | |||
| claude | session-state.sh | Stop | verbatim | unproven | |||
| claude | nmemory-capture-gate.py | Stop | verbatim | unproven | |||
| claude | session-state.sh | UserPromptSubmit | verbatim | unproven | |||
| claude | fanout-reminder.sh | UserPromptSubmit | verbatim | unproven | |||
| claude | final-state.sh | UserPromptSubmit | verbatim | unproven | |||
| claude | system1-suggest.py | UserPromptSubmit | verbatim | unproven | |||
| claude | nmemory-recall.py | UserPromptSubmit | verbatim | unproven | |||
| claude | nmemory-act.py | UserPromptSubmit | verbatim | unproven | |||
| claude | subagent-law.py | SubagentStart | verbatim | unproven | |||
| claude | session-state.sh | SubagentStop | verbatim | unproven | |||
| claude | session-state.sh | TeammateIdle | verbatim | unproven | |||
| claude | session-state.sh | StopFailure | verbatim | unproven | |||
| codex | pynott-branch-lock.sh | PreToolUse | codex pre-shell | unproven | |||
| codex | bash-guards.sh | PreToolUse | codex pre-shell | bash-guards | |||
| codex | two-misses.sh | PreToolUse | codex pre-shell | two-misses | |||
| codex | campaign-pr-guard.py | PreToolUse | codex pre-shell | unproven | |||
| codex | plan-exit-gate.sh | PreToolUse | not projected | Codex has no ExitPlanMode tool, so neither half of the plan gate has an event to hang on | |||
| codex | dispatch-tier.py | PreToolUse | not projected | binds the Claude Agent and Workflow tools, which Codex does not have | |||
| codex | nmemory-main-only.py | PreToolUse | not projected | it denies a Claude subagent by the agent_id of a Claude payload. On Codex only an nSPAWN child is covered: nMEMORY refuses it by NOTT_NSPAWN_RUN_ID, which nSPAWN forwards. A Codex multi_agent sub-agent is not covered | |||
| codex | red-first.sh | PreToolUse | codex pre-shell | red-first | |||
| codex | session-state.sh | PreToolUse | codex session-state-pre | session-state | |||
| codex | law-contract.sh | PostToolUse | not projected | Claude runs it on PostToolUse Write\ | Edit\ | MultiEdit; the Codex post-edit advisory names the contract and does not run it | |
| codex | nmemory-anchor-queue.sh | PostToolUse | not projected | enqueues the EDITED path from a Claude Write\ | Edit payload (tool_input.file_path); Codex writes by piping a patch into apply_patch from the shell tool, so post-edit sees patch text, not a path, and has no codex-hook-runner.py action for it | ||
| codex | nspawn-pairing.sh | PostToolUse | not projected | reads a Claude payload shape and has no codex-hook-runner.py action; Codex serves the event, the handler is not projected | |||
| codex | nmemory-act.py | PostToolUse | not projected | its act is a Claude dispatch tool on PostToolUse, which Codex runs only through runner actions, so a turn start or a compaction here would have no act to reach | |||
| codex | bash-guard.py | PostToolUse | not projected | reads a Claude payload shape and has no codex-hook-runner.py action; Codex serves the event, the handler is not projected | |||
| codex | red-first.sh | PostToolUse | codex post-edit | red-first-tested | |||
| codex | two-misses.sh | PostToolUse | codex post-edit | two-misses | |||
| codex | secret-in-output.sh | PostToolUse | codex post-edit | secret-output | |||
| codex | session-state.sh | PostToolUse | codex session-state-post | session-state | |||
| codex | artifact-copy.py | PostToolUse | not projected | binds the Claude Artifact tool, which Codex does not have | |||
| codex | secret-in-output.sh | PostToolUseFailure | not projected | Codex has no such event. Both Claude handlers (secret-in-output's chain advisory, red-first's tested record) ride post-edit on PostToolUse, which Codex runs after a non-zero Bash exit too (measured 2026-09-14, #444) | |||
| codex | red-first.sh | PostToolUseFailure | not projected | Codex has no such event. Both Claude handlers (secret-in-output's chain advisory, red-first's tested record) ride post-edit on PostToolUse, which Codex runs after a non-zero Bash exit too (measured 2026-09-14, #444) | |||
| codex | two-misses.sh | PostToolUseFailure | not projected | Codex has no such event. Both Claude handlers (secret-in-output's chain advisory, red-first's tested record) ride post-edit on PostToolUse, which Codex runs after a non-zero Bash exit too (measured 2026-09-14, #444) | |||
| codex | session-state.sh | PostToolUseFailure | not projected | Codex has no such event. Both Claude handlers (secret-in-output's chain advisory, red-first's tested record) ride post-edit on PostToolUse, which Codex runs after a non-zero Bash exit too (measured 2026-09-14, #444) | |||
| codex | law-session.sh | SessionStart | verbatim | unproven | |||
| codex | law-session.sh | SessionStart | verbatim | unproven | |||
| codex | law-session.sh | SessionStart | verbatim | unproven | |||
| codex | nmemory-session.sh | SessionStart | verbatim | unproven | |||
| codex | nmemory-act.py | SessionStart | not projected | its act is a Claude dispatch tool on PostToolUse, which Codex runs only through runner actions, so a turn start or a compaction here would have no act to reach | |||
| codex | nspawn-child-directives.sh | SessionStart | verbatim | unproven | |||
| codex | nspawn-notices.sh | SessionStart | not projected | the nSPAWN notice drain is at-most-once and only a Claude Code session is a notice caller; draining here would take the notice the project's next Claude session is owed | |||
| codex | session-state.sh | SessionStart | verbatim | unproven | |||
| codex | session-state.sh | Notification | not projected | The Codex view has no native permission notification binding. Waiting tools use PreToolUse. | |||
| codex | nmemory-snapshot.sh | SessionEnd | verbatim | unproven | |||
| codex | nmemory-snapshot.sh | PreCompact | verbatim | unproven | |||
| codex | nmemory-snapshot.sh | PostCompact | verbatim | unproven | |||
| codex | final-state.sh | Stop | codex stop | voice | |||
| codex | session-state.sh | Stop | codex stop | session-state | |||
| codex | nmemory-capture-gate.py | Stop | codex stop | capture-gate | |||
| codex | session-state.sh | UserPromptSubmit | verbatim | unproven | |||
| codex | fanout-reminder.sh | UserPromptSubmit | verbatim | unproven | |||
| codex | final-state.sh | UserPromptSubmit | verbatim | unproven | |||
| codex | system1-suggest.py | UserPromptSubmit | not projected | its catalog is the Claude agent types under ~/.claude and the routes as Claude names them; a Codex session has neither that Agent tool nor those names | |||
| codex | nmemory-recall.py | UserPromptSubmit | verbatim | unproven | |||
| codex | nmemory-act.py | UserPromptSubmit | not projected | its act is a Claude dispatch tool on PostToolUse, which Codex runs only through runner actions, so a turn start or a compaction here would have no act to reach | |||
| codex | subagent-law.py | SubagentStart | not projected | Codex has no subagent events; the subagent law reaches Claude subagents only | |||
| codex | session-state.sh | SubagentStop | not projected | Codex has no subagent events; session-state.sh's dead[] record hangs on SubagentStop | |||
| codex | session-state.sh | TeammateIdle | not projected | Codex has no teams and no TeammateIdle event | |||
| codex | session-state.sh | StopFailure | not projected | Codex has no StopFailure event; a dead session is not announced there | |||
| agy | pynott-branch-lock.sh | PreToolUse | agy pre-tool-run-command | unproven | |||
| agy | bash-guards.sh | PreToolUse | agy pre-tool-run-command | bash-guards | |||
| agy | two-misses.sh | PreToolUse | not projected | its ledger fills from the command text after the run, which AGY's PostToolUse payload lacks, so its PreToolUse deny would never fire | |||
| agy | campaign-pr-guard.py | PreToolUse | agy pre-tool-run-command | unproven | |||
| agy | plan-exit-gate.sh | PreToolUse | not projected | AGY has no ExitPlanMode tool, so neither half of the plan gate has an event to hang on | |||
| agy | dispatch-tier.py | PreToolUse | not projected | binds the Claude Agent and Workflow tools, which AGY does not have | |||
| agy | nmemory-main-only.py | PreToolUse | not projected | it denies a Claude subagent by the agent_id of a Claude payload; AGY has no subagent events. nMEMORY refuses an AGY nSPAWN child when its MCP server inherits NOTT_NSPAWN_RUN_ID (not measured) | |||
| agy | red-first.sh | PreToolUse | not projected | both halves: its tested record needs the command text after the run, and AGY's PostToolUse payload is {stepIdx, error, ...common} (agy-customizations docs/hooks.md), so the deny half would call a tested session untested | |||
| agy | session-state.sh | PreToolUse | not projected | surface of the Claude statusline; AGY has no statusline and no subagent events, so a phase file would have no reader | |||
| agy | law-contract.sh | PostToolUse | agy post-tool-write | law-contract | |||
| agy | nmemory-anchor-queue.sh | PostToolUse | not projected | enqueues the EDITED path from a Claude Write\ | Edit payload (tool_input.file_path); AGY's PostToolUse payload is {stepIdx, error, ...common} with no path, so there is nothing to enqueue | ||
| agy | nspawn-pairing.sh | PostToolUse | not projected | reads a Claude MCP tool payload; AGY has no agy-hook-runner.py action for it | |||
| agy | nmemory-act.py | PostToolUse | not projected | AGY's PostToolUse payload is {stepIdx, error, ...common}, with no tool name or input, so no act can be matched | |||
| agy | bash-guard.py | PostToolUse | not projected | reads a Claude payload shape (tool_response) that AGY's PostToolUse does not carry | |||
| agy | red-first.sh | PostToolUse | not projected | both halves: its tested record needs the command text after the run, and AGY's PostToolUse payload is {stepIdx, error, ...common} (agy-customizations docs/hooks.md), so the deny half would call a tested session untested | |||
| agy | two-misses.sh | PostToolUse | not projected | its ledger fills from the command text after the run, which AGY's PostToolUse payload lacks, so its PreToolUse deny would never fire | |||
| agy | secret-in-output.sh | PostToolUse | not projected | no output text in AGY's PostToolUse payload | |||
| agy | session-state.sh | PostToolUse | not projected | surface of the Claude statusline; AGY has no statusline and no subagent events, so a phase file would have no reader | |||
| agy | artifact-copy.py | PostToolUse | not projected | binds the Claude Artifact tool, which AGY does not have | |||
| agy | secret-in-output.sh | PostToolUseFailure | not projected | AGY has no such event; its PostToolUse payload carries {stepIdx, error} and post-tool-run-command names the error in its advisory | |||
| agy | red-first.sh |
hooks/register.ts 9 lines1// The plugin's one hooks module: the engine loads a single module per plugin.
2import type { Register } from "claude-code";
3
4import { register as idleHandoff } from "./idle-handoff.ts";
5
6export const register: Register = (on, options) => {
7 idleHandoff(on, options);
8};
9hooks/idle-handoff.ts 257 lines1import type { Register } from "claude-code";
2
3const MIN = 60_000;
4const IDLE_DEFAULT = 50 * MIN;
5const MAX_PINGS = 3;
6const RETRY_MS = 30_000;
7const MAX_RETRIES = 3;
8const SELF = "nott";
9//: The owner's own channels, and a follow-up to his UI action; `unclassified` is not
10//: one: it carries the engine's idle notices and delivery receipts.
11const OWNER = new Set(["composer", "bridge", "slack-ping", "channel", "auto-continuation"]);
12
13const PING = "Reply with the single word: ok.";
14const COMPACT_INSTRUCTIONS =
15 "Keep the last state line (RUNNING/STOPPED/DELIVERED), the nMEMORY handoff capsule id " +
16 "and the next valid action verbatim; keep open decisions and their options.";
17
18function handoffPrompt(nonce: string): string {
19 return [
20 "The session has been idle and the prompt cache is about to expire.",
21 "Load /nott:handoff and record the cold-readable continuation in nMEMORY",
22 "(nmemory_session_finish or nmemory_capsule_ingest), then write the state line with",
23 "`~/.nott/bin/state.sh set \"STOPPED: <reason> · next action: <x>\"`.",
24 "Your whole answer is this exact line:",
25 `HANDOFF-DONE ${nonce}`,
26 ].join("\n");
27}
28
29//: Born in `register`, so each load (a session, a reload, a test) starts afresh.
30type State = {
31 timer: { cancel(): void } | null;
32 background: Set<string>;
33 pings: number;
34 dormant: boolean;
35 headless: boolean;
36 nonce: string | null;
37 handoffStarted: boolean;
38 ownerSeq: number;
39 seqAtFire: number;
40 compactOwed: boolean;
41 retries: number;
42 turnOpen: boolean;
43};
44
45function fresh(): State {
46 return {
47 timer: null,
48 background: new Set(),
49 pings: 0,
50 dormant: false,
51 headless: false,
52 nonce: null,
53 handoffStarted: false,
54 ownerSeq: 0,
55 seqAtFire: 0,
56 compactOwed: false,
57 retries: 0,
58 turnOpen: false,
59 };
60}
61
62function cancel(s: State): void {
63 s.timer?.cancel();
64 s.timer = null;
65}
66
67async function arm($: any, s: State): Promise<void> {
68 cancel(s);
69 const raw = Number(await $.env.get("NOTT_IDLE_HANDOFF_MS"));
70 const ms = Number.isFinite(raw) && raw > 0 ? raw : IDLE_DEFAULT;
71 s.timer = $.clock.after(ms, () => void fire($, s));
72}
73
74function compact($: any, s: State): void {
75 $.clock.after(0, () => void tryCompact($, s));
76}
77
78// ponytail: the owner check runs before the await; an owner prompt queued (not yet a
79// running turn) while the summarizer works is not seen. The engine offers no lock.
80async function tryCompact($: any, s: State): Promise<void> {
81 if (s.ownerSeq !== s.seqAtFire) {
82 s.compactOwed = false;
83 return;
84 }
85 try {
86 const result = await $.session.compact({ instructions: COMPACT_INSTRUCTIONS });
87 s.compactOwed = false;
88 if (result && "skip" in result && result.skip) {
89 $.ui.log("idle-handoff: a hook skipped the compaction", { to: "transcript" });
90 }
91 } catch (err) {
92 s.compactOwed = true;
93 $.ui.log(`idle-handoff: compaction refused (${String(err)}); retrying`, { to: "transcript" });
94 if (s.retries < MAX_RETRIES) {
95 s.retries += 1;
96 $.clock.after(RETRY_MS, () => void retryCompact($, s));
97 }
98 }
99}
100
101async function retryCompact($: any, s: State): Promise<void> {
102 if (s.compactOwed && !s.turnOpen) await tryCompact($, s);
103}
104
105function handedOff($: any, s: State): void {
106 s.nonce = null;
107 s.handoffStarted = false;
108 s.dormant = true;
109 cancel(s);
110 s.retries = 0;
111 compact($, s);
112}
113
114async function fire($: any, s: State): Promise<void> {
115 s.timer = null;
116 if (s.nonce !== null) {
117 //: The last handoff never ended with its marker: one per absence, so sleep until a
118 //: message not its own rather than ask again.
119 s.nonce = null;
120 s.handoffStarted = false;
121 s.dormant = true;
122 return;
123 }
124 if (s.background.size > 0 && s.pings < MAX_PINGS) {
125 s.pings += 1;
126 try {
127 const r = await $.model.fork({ prompt: PING });
128 $.ui.log(
129 r?.isAnswered
130 ? `idle-handoff: keep-warm ping ${s.pings}, cache read ${r.usage.cache_read_input_tokens}`
131 : `idle-handoff: keep-warm ping ${s.pings} not answered (${r?.reason ?? "no result"})`,
132 { to: "debug" },
133 );
134 } catch (err) {
135 //: A refused fork (a blocked model) must not stop the clock.
136 $.ui.log(`idle-handoff: keep-warm ping ${s.pings} refused (${String(err)})`, { to: "debug" });
137 }
138 await arm($, s);
139 return;
140 }
141 s.pings = 0;
142 s.nonce = crypto.randomUUID();
143 s.seqAtFire = s.ownerSeq;
144 await $.prompt.submit({ text: handoffPrompt(s.nonce) });
145}
146
147//: The marker is the message's last line, as the prompt asks: a message that only
148//: announces it mid-sentence does not end the handoff.
149function marked(s: State, text: unknown): boolean {
150 if (s.nonce === null || typeof text !== "string") return false;
151 const body = text.trimEnd();
152 const line = `HANDOFF-DONE ${s.nonce}`;
153 return body === line || body.endsWith(`\n${line}`);
154}
155
156//: A blocking Stop hook continues the same turn, and its final answer then loses the
157//: marker an earlier message of the turn carried. The prompt quotes the marker too,
158//: so only the assistant's messages count.
159async function answered($: any, s: State, e: any): Promise<boolean> {
160 if (e.reason !== "answer") return false;
161 if (marked(s, e.answer)) return true;
162 const messages = await $.session.messages();
163 return messages.some((m: any) => m.role === "assistant" && marked(s, m.text));
164}
165
166//: The id a launch left running in the background, per built-in tool; null when it
167//: ran in the foreground.
168function launched(tool: unknown, r: any): string | null {
169 const x = r?.result;
170 if (x === null || typeof x !== "object") return null;
171 switch (tool) {
172 case "Bash":
173 return x.backgroundTaskId ?? null;
174 case "Monitor":
175 case "Workflow":
176 return x.taskId ?? null;
177 case "Agent":
178 if (x.status === "async_launched") return x.agentId ?? null;
179 if (x.status === "remote_launched") return x.taskId ?? null;
180 return null;
181 default:
182 return null;
183 }
184}
185
186async function onSubmit($: any, e: any, next: any, s: State) {
187 const kind = e.origin?.kind;
188 if (kind === "sdk") s.headless = true;
189 if (kind === "task-notification" && typeof e.text === "string") {
190 for (const id of s.background) if (e.text.includes(`<task-id>${id}</task-id>`)) s.background.delete(id);
191 }
192 if (!(kind === "plugin" && e.origin?.name === SELF)) {
193 s.dormant = false;
194 s.pings = 0;
195 }
196 if (OWNER.has(kind)) {
197 s.ownerSeq += 1;
198 s.nonce = null;
199 s.handoffStarted = false;
200 s.compactOwed = false;
201 }
202 return next(e);
203}
204
205async function onStart($: any, e: any, next: any, s: State) {
206 cancel(s);
207 s.turnOpen = true;
208 //: The handoff turn starts with its own prompt: a notification ahead of it does not.
209 if (s.nonce !== null && typeof e.text === "string" && e.text.includes(s.nonce)) s.handoffStarted = true;
210 return next(e);
211}
212
213// ponytail: a task that ends without a notification stays recorded; MAX_PINGS bounds
214// what it costs, and the next handoff runs anyway.
215async function onTool($: any, e: any, next: any, s: State) {
216 const r = await next(e);
217 const id = launched(e.tool, r);
218 if (id !== null) s.background.add(id);
219 return r;
220}
221
222async function onSessionStart($: any, e: any, next: any, s: State) {
223 cancel(s);
224 Object.assign(s, fresh(), { headless: e.isInteractive === false });
225 return next(e);
226}
227
228async function onComplete($: any, e: any, next: any, s: State) {
229 if (e.agentId) return next(e);
230 s.turnOpen = false;
231 if (s.nonce !== null && (await answered($, s, e))) {
232 handedOff($, s);
233 } else if (s.handoffStarted) {
234 //: The handoff ran and ended without its marker (refused, aborted, failed, or the
235 //: line left out): one per absence, so sleep until a message not its own.
236 s.nonce = null;
237 s.handoffStarted = false;
238 s.dormant = true;
239 } else if (s.compactOwed && s.ownerSeq === s.seqAtFire) {
240 compact($, s);
241 } else if (!s.dormant && !s.headless) {
242 //: Also with a handoff pending: if it never ends with its marker (a hook rewrote its
243 //: prompt), the next fire finds the stale nonce and gives up instead of waiting forever.
244 await arm($, s);
245 }
246 return next(e);
247}
248
249export const register: Register = (on) => {
250 const s = fresh();
251 on("prompt.submit", ($: any, e: any, next: any) => onSubmit($, e, next, s));
252 on("turn.start", ($: any, e: any, next: any) => onStart($, e, next, s));
253 on("tool.call", ($: any, e: any, next: any) => onTool($, e, next, s));
254 on("turn.complete", ($: any, e: any, next: any) => onComplete($, e, next, s));
255 on("session.start", ($: any, e: any, next: any) => onSessionStart($, e, next, s));
256};
257types/index.d.ts 49 lines1// The values the nott plugin's hooks modules keep in `$.state`, read while drawing.
2
3//: One nSPAWN run as the fleet pane draws it.
4export type FleetRun = {
5 runId: string;
6 lane: string;
7 route: string;
8 phase: string;
9 updatedAt: number;
10 //: The child's last output, ms; silence is measured from it.
11 outputAt: number;
12 isLive: boolean;
13};
14
15//: The fleet as last read: when it was last looked at, when it last answered, and the
16//: runs worth showing.
17export type Fleet = { observedAt: number; readAt: number; runs: FleetRun[] };
18
19//: An agent of this session that is still at work.
20export type AgentRow = { id: string; type: string; description: string; status: string; parentId?: string };
21
22//: A workflow this session launched and whose task has not reported its end.
23export type WorkflowRow = { id: string; name: string; startedAt: number };
24
25//: The pull request of the session's branch, reduced to what the band draws.
26export type CampaignPr = {
27 number: number;
28 ok: number;
29 running: number;
30 failed: string[];
31 review: string;
32 merge: string;
33};
34
35//: The band's last reading and, when the latest gh call failed, why: the reading is then
36//: an older one, or null when gh has never answered.
37export type PrReading = { pr: CampaignPr | null; error: string | null };
38
39declare module "claude-code" {
40 interface PluginState {
41 nott: {
42 fleet: Fleet | null;
43 agents: AgentRow[];
44 workflows: WorkflowRow[];
45 pr: PrReading | null;
46 };
47 }
48}
49