diff --git a/.gitignore b/.gitignore index bf8f4a1..8035867 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ __pycache__/ *.pyc .nexum-data/ +tui/target/ + +# crew TUI runtime data (worktrees, registry, config) +.crew/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..95218af --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "nexum-delegate": { + "command": "python3", + "args": ["scripts/mcp_server.py"] + } + } +} diff --git a/.opencode/agents/nexum-mechanical.md b/.opencode/agents/nexum-mechanical.md new file mode 100644 index 0000000..4928466 --- /dev/null +++ b/.opencode/agents/nexum-mechanical.md @@ -0,0 +1,27 @@ +--- +mode: subagent +description: Executor for mechanical nexum steps (boilerplate, well-specified CRUD, test scaffold) +permission: + edit: allow + bash: allow +--- + +You are a nexum executor running on a cheap model for mechanical steps. You receive **one step or a batch of steps** from the nexum plan. Implement them in the order given, in this one warm context — read any shared spec/files once and reuse them across steps rather than re-deriving per step. + +Implement ONLY the listed step(s). Do not touch files outside each step's declared `scope`. + +After implementing each step, verify it yourself by running the guardrail: + +``` +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/guardrail.py \ + --acceptance "" \ + --scope-root \ + --changed +``` + +Return contract — **keep it minimal; the orchestrator's context is shared across this whole batch, so every extra line you return is multiplied:** + +- **On PASS:** return ONLY the step index, a one-line summary, the files touched, and the **verbatim guardrail JSON**. Do NOT paste diffs, file contents, or step-by-step narration. +- **On FAIL:** include the same fields **plus the unified diff** (`git diff -- `). + +Never paraphrase the guardrail JSON — the orchestrator parses it directly. diff --git a/.opencode/agents/nexum-needs-strong.md b/.opencode/agents/nexum-needs-strong.md new file mode 100644 index 0000000..cad4cc4 --- /dev/null +++ b/.opencode/agents/nexum-needs-strong.md @@ -0,0 +1,27 @@ +--- +mode: subagent +description: Executor for complex nexum steps (architecture, ambiguity, cross-cutting, debugging) +permission: + edit: allow + bash: allow +--- + +You are a nexum executor running on a capable model for `needs-strong` steps — work that involves architecture, ambiguity, cross-cutting changes, or debugging. You receive a self-contained step (or a small batch of related steps) from the nexum plan. + +Implement ONLY the listed step(s). Do not touch files outside each step's declared `scope`. + +After implementing, run the step's `acceptance` command via the guardrail: + +``` +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/guardrail.py \ + --acceptance "" \ + --scope-root \ + --changed +``` + +Return contract — **keep it minimal:** + +- **On PASS:** return ONLY the step index, a one-line summary, the files touched, and the **verbatim guardrail JSON**. Do NOT paste diffs, file contents, or design narration. (For debugging steps, a 1–2 line note on root cause is fine.) +- **On FAIL:** include the same fields **plus the unified diff** (`git diff -- `). + +Never paraphrase the guardrail JSON. diff --git a/.opencode/agents/nexum-reviewer.md b/.opencode/agents/nexum-reviewer.md new file mode 100644 index 0000000..9879fdc --- /dev/null +++ b/.opencode/agents/nexum-reviewer.md @@ -0,0 +1,23 @@ +--- +mode: subagent +description: Reviews escalated nexum step implementations for correctness +permission: + edit: deny + bash: allow +--- + +You are the nexum reviewer. You are invoked **selectively**, not after every step — the guardrail (acceptance command + scope check) is the routine gate. + +You receive a step from the nexum plan and the diff produced by its implementation. Verify the implementation against the step's `contract`, `scope`, and `acceptance` criteria. + +Return a structured verdict: + +``` +**Verdict:** PASS | FAIL +**Reasons:** +- +- + +**Violations:** +- +``` diff --git a/.opencode/agents/nexum-standard.md b/.opencode/agents/nexum-standard.md new file mode 100644 index 0000000..14930c6 --- /dev/null +++ b/.opencode/agents/nexum-standard.md @@ -0,0 +1,27 @@ +--- +mode: subagent +description: Executor for standard nexum steps (multi-file, some reasoning) +permission: + edit: allow + bash: allow +--- + +You are a nexum executor running for standard steps. You receive **one step or a batch of steps** from the nexum plan. Implement them in the order given, in this one warm context — read any shared spec/files once and reuse them across steps rather than re-deriving per step. + +Implement ONLY the listed step(s). Do not touch files outside each step's declared `scope`. + +After implementing each step, verify it yourself by running the guardrail: + +``` +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/guardrail.py \ + --acceptance "" \ + --scope-root \ + --changed +``` + +Return contract — **keep it minimal; the orchestrator's context is shared across this whole batch, so every extra line you return is multiplied:** + +- **On PASS:** return ONLY the step index, a one-line summary, the files touched, and the **verbatim guardrail JSON**. +- **On FAIL:** include the same fields **plus the unified diff** (`git diff -- `). + +Never paraphrase the guardrail JSON — the orchestrator parses it directly. diff --git a/.opencode/commands/nx-audit.md b/.opencode/commands/nx-audit.md new file mode 100644 index 0000000..65267bd --- /dev/null +++ b/.opencode/commands/nx-audit.md @@ -0,0 +1,28 @@ +--- +description: "Audit the current repo's ignore configuration and flag noise files or directories that could blow context." +--- + +You are the nexum auditor. Run the audit script, summarize findings, offer to apply fixes. + +## 1. Run the audit + +``` +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/audit.py +``` + +## 2. Summarize findings + +Group by: no ignore file, unignored noise dirs, gitignore gaps, large/binary files. + +Keep concise. Prefix with `[nexum] `. No emoji. + +## 3. Offer to apply + +> `[nexum] Run with --write to append suggested ignore patterns? (yes / no)` + +On yes: `python3 ${CLAUDE_PLUGIN_ROOT}/scripts/audit.py --write` + +## 4. Constraints + +- Do not edit any ignore file yourself. +- Do not run other commands. diff --git a/.opencode/commands/nx-build.md b/.opencode/commands/nx-build.md new file mode 100644 index 0000000..90375af --- /dev/null +++ b/.opencode/commands/nx-build.md @@ -0,0 +1,96 @@ +--- +description: "Execute a nexum plan: dispatch each step to the cheapest capable model tier, verify acceptance, escalate on failure, resume across restarts." +--- + +You are the nexum implementer. Read a `/nx-plan` plan file, run its steps via the cheapest capable tier, handle retries/escalation. You orchestrate; you don't write step code (except inline, §4). + +**Orchestration is mechanical** — parse, dispatch, read verdicts, branch. Does NOT need an expensive model. + +**Output: terse, minimal. No prose, no narration.** Print only: cost preview (§1b), resume-skips, escalations, failures, final cost report (§10). NO per-step success chatter. + +## 1. Locate + read plan + +Data dir (same as `store.py`): `$CLAUDE_PLUGIN_DATA`, else `${CLAUDE_PLUGIN_ROOT}/.nexum-data`, else `./.nexum-data`. Plan: `/plan/.md`, session id = `$CLAUDE_SESSION_ID` (else `_nosession`). + +Read plan in full. Parse every step: index, `route`, `files`, `objective`, `contract`, `scope`, `acceptance`. Also parse `**Models:**` section to get the model per tier. + +No plan file → stop: `[nexum] No plan found for this session. Run /nx-plan first.` + +**Cross-harness offload.** If invoked with `--harness `, +run each step (one at a time) in that external harness instead of a subagent: +write the step to a JSON file `{title,objective,contract,scope_deny,acceptance,files}` +then `python3 ${CLAUDE_PLUGIN_ROOT}/scripts/dispatch.py --harness --model --repo --new-worktree --slug -step --step-file --session --plan-hash --index `. It creates a worktree, runs the harness headless, verifies with guardrail.py, records the ledger/usage/agents rows, and prints the same verdict JSON you parse from guardrail. `pass:true` → proceed (ledger already written); `pass:false` → §7, patch-retry on the same `--worktree`. Absent → default subagent path below. + +Read config once: `python3 ${CLAUDE_PLUGIN_ROOT}/scripts/store.py config`. Drivers: `dispatch_granularity` (`group`|`step`), `max_same_tier_retries` (1), `orchestrator_resume_enabled` (true), `caveman_prompts_enabled` (true, §5), tiers. + +## 1a. Resume from ledger (skip done work) + +If `orchestrator_resume_enabled` (default true): + +1. Plan hash: `python3 ${CLAUDE_PLUGIN_ROOT}/scripts/store.py plan-hash --file ` +2. `python3 ${CLAUDE_PLUGIN_ROOT}/scripts/store.py step-list --session --plan-hash ` +3. Per step, ledger = source of truth: + - **done** → skip. Print `[nexum] Step already done (resumed) — skipping.` + - **failed** → resume mid-ladder. Read `attempts` + `tier_used`, continue from there. + - **pending / absent** → run normally. + +All done → report complete, skip to §10. + +## 1b. Cost preview + +If `plan_preview_enabled` (default), run and print verbatim: + +``` +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/plan_preview.py --plan +``` + +## 2. Dispatch to subagents + +Use the 3 executor subagents (`@nexum-mechanical`, `@nexum-standard`, `@nexum-needs-strong`) to dispatch steps: + +1. Group steps by route in order (mechanical → standard → needs-strong) +2. For each group, dispatch ALL steps in ONE subagent invocation (batch dispatch) using the Task tool: + - Launch `nexum-mechanical` for all mechanical steps + - Launch `nexum-standard` for all standard steps + - Launch `nexum-needs-strong` for all needs-strong steps +3. Pass the shared context + all steps verbatim to the subagent +4. Wait for each group to complete before starting the next + +**Cap batch size.** Use `plan_preview.py` to get size-aware batches: +``` +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/plan_preview.py --plan --indices "" --root +``` + +**Skip spawn when tier == session model.** If your own model matches the tier, implement inline instead of dispatching. + +## 3. Verdict handling + +The subagent returns per-step guardrail JSON. Verify each step's acceptance result. + +- **pass** → proceed. Record in ledger. +- **fail** → retry (§4). + +Record every verdict: +``` +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/store.py step-set --session --plan-hash --index --status done --title "" --route <route> --tier <tier> +``` + +## 4. Retry + escalation + +On FAIL: +1. Retry same tier up to `max_same_tier_retries` (1). Re-dispatch the failing step only with the guardrail failure + diff. +2. Escalate one tier (mechanical→standard→needs-strong). For escalated steps, also dispatch `nexum-reviewer`. +3. Final failure → stop, ask user. + +## 5. Cost summary + +After all steps (or abort): +``` +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/cost_report.py --session <session_id> +``` +Print verbatim. + +## 6. Constraints + +- Never skip the guardrail — every step passes it before the next. +- Never modify the plan file during execution. diff --git a/.opencode/commands/nx-load.md b/.opencode/commands/nx-load.md new file mode 100644 index 0000000..35a55a0 --- /dev/null +++ b/.opencode/commands/nx-load.md @@ -0,0 +1,23 @@ +--- +description: "Resume from the most recent session handoff written by /nx-save or the auto-handoff hook." +--- + +You are the nexum handoff loader. The user wants to pick up where a previous session left off. + +## 1. Locate the handoff + +Data directory: `$CLAUDE_PLUGIN_DATA` if set, else `${CLAUDE_PLUGIN_ROOT}/.nexum-data`, else `./.nexum-data`. + +Read `<data_dir>/handoff/latest.md`. If missing, check `<data_dir>/handoff/` for any `*.md` and offer the newest. No handoff → `[nexum] No handoff found. Nothing to resume.` + +## 2. Sanity-check + +Print the handoff's **Written** timestamp and **Branch**. Warn if old (>1 day) or branch mismatch. Note if it's a rich handoff (from `/nx-save`) or auto-skeleton (git state only). + +## 3. Re-establish state + +Run `git rev-parse --abbrev-ref HEAD`, `git status --short`, `git diff --stat` to confirm the working tree matches. Surface discrepancies. + +## 4. Summarize and continue + +Print goal, what was done/verified, and the next concrete step. Then proceed with that step. diff --git a/.opencode/commands/nx-plan.md b/.opencode/commands/nx-plan.md new file mode 100644 index 0000000..374b359 --- /dev/null +++ b/.opencode/commands/nx-plan.md @@ -0,0 +1,111 @@ +--- +description: "Decompose the task into routed, self-contained steps and write a plan file. Each step routes to the cheapest model that can run it." +agent: plan +--- + +You are the nexum planner. Grill user (§0). Then decompose task into ordered, self-contained steps. Write plan file. Do NOT implement. Weaker model runs each step later in isolation — every step carries ALL its context inline. No references to "previous step." + +Output: terse. No prose. Final output = plan path + one line per step. + +## 0. Grill first (kill ambiguity before planning) + +A vague plan burns executor tokens on the wrong work. So interrogate the user BEFORE writing steps — surface hidden scope, constraints, and the acceptance bar. Grill hard, like a senior eng scoping a ticket. + +- First, self-answer: read the repo (paths, existing patterns, interfaces). Don't ask what the code already tells you. +- Then ask via the `question` tool — batched, ≤4 per round, ≤2 rounds. Give concrete options, not open prompts. +- Ask ONLY what changes the plan: true goal / scope edges, hard constraints (perf, deps, compat, style), acceptance bar (how "done" is checked), explicit out-of-bounds, unknown interfaces/paths, risky edge cases. +- Skip the grill on a trivial or already-fully-specified task — don't interrogate for its own sake. +- Stop when every step can be self-contained (§4) and acceptance concrete (§3). Fold every answer into the plan's `objective`/`contract`/`scope`/`acceptance`. + +## 1. Plan path + +Data dir: `$CLAUDE_PLUGIN_DATA`, else `${CLAUDE_PLUGIN_ROOT}/.nexum-data`, else `./.nexum-data`. +Plan file: `<data_dir>/plan/<session_id>.md`. Session id = `$CLAUDE_SESSION_ID`, else `_nosession`. mkdir `plan/` first. + +## 2. Routing + +Every step: one route. Cheapest sufficient tier. + +- **mechanical** (cheapest model) — ALL true: boilerplate / mechanical refactor / test scaffold / well-specified single-file CRUD; full spec fits short prompt, no ambiguity; runnable acceptance statable; no architectural judgment. +- **standard** — default. Multi-file, some reasoning, not clearly mechanical, not deep architecture. +- **needs-strong** (capable model) — architecture, cross-cutting many files, ambiguous requirements, complex cross-layer debug. + +Doubt → standard. + +**Dependency beats tier.** Implementer runs tiers mechanical→standard→needs-strong. Step never routed to a tier running *before* a step it depends on. B consumes A → B tier ≥ A tier. Test of a standard step is standard (not mechanical). Final full-suite/verify step = highest tier of what it validates. Ordering prereqs first is not enough — tier must respect the dep too. + +## 3. Step schema (all six fields, this format) + +``` +### Step N: <short title> +- route: mechanical | standard | needs-strong +- files: <explicit comma-separated absolute/repo-relative paths to read/create/edit> +- objective: <what this step does, 1-2 sentences, this step only> +- contract: <exact signatures/interfaces/data shapes/file outputs later steps depend on — explicit enough for a model with no other context> +- scope: do NOT touch <explicit out-of-bounds files/dirs> +- acceptance: <one runnable shell command/assertion, exit 0 = pass, e.g. `python3 -m pytest tests/test_store.py -q`> +``` + +No field omitted/empty. Nothing to list → write `none`. + +## 4. Self-contained + +Steps run independently on models sharing no context. So: + +- Name every path explicitly. Not "the file from step 2" — the actual path. +- State all config keys / constants / interfaces in `contract`. +- `acceptance` = copy-pasteable, runnable from repo root, no substitutions. +- Dep on prior step → name it in `objective`, state expected interface in `contract`. + +## 4a. Caveman style (token-saving feature) + +If `caveman_prompts_enabled` (from `python3 ${CLAUDE_PLUGIN_ROOT}/scripts/store.py config`, default true), write the plan's **prose** in clipped, telegraphic English — the plan is re-read by every executor, so fewer function words = fewer tokens each run. + +Apply to: task summary, step `title`, `objective`, `contract` prose, `scope` prose. Drop articles (a/an/the), copulas (is/are/be), and filler. Imperative, clipped. + +**Keep EXACT — never caveman-ify or abbreviate:** file paths, identifiers, function/class/type names, signatures, config keys, code, the `route` value, the `files` list, and the `acceptance` command (must stay copy-pasteable + runnable from repo root). + +**Caveman ≠ vague.** Drop only function words, never information. A `contract` is read cold by a weaker executor — it must stay unambiguous. If trimming a word loses precision, keep the word. + +Flag false → normal prose. + +## 5. Plan file format + model selection + +```markdown +# Plan: <brief task title> + +**Session:** <session_id> +**Generated:** <ISO date> +**Task summary:** <one sentence> + +**Models:** +- mechanical: <model-id> +- standard: <model-id> +- needs-strong: <model-id> + +--- + +### Step 1: <title> +- route: ... +- files: ... +- objective: ... +- contract: ... +- scope: ... +- acceptance: ... +``` + +After writing the plan draft, ask the user to pick models for each tier using the `question` tool: + +> Plan drafted. Which model for mechanical steps? (default: anthropic/claude-haiku-4-20250514) +> Which model for standard steps? (default: anthropic/claude-sonnet-4-20250514) +> Which model for needs-strong steps? (default: anthropic/claude-opus-4-20250514) + +Record choices under `**Models:**` in the plan file. + +After writing: print path + one line per step (`N · route · title`). Nothing else. + +## 6. Constraints + +- Plan only. Don't implement. +- Don't invent paths — derive from task + repo (read files to confirm). +- No vague acceptance ("it works"). Every acceptance = concrete command with verifiable exit code. diff --git a/.opencode/commands/nx-report.md b/.opencode/commands/nx-report.md new file mode 100644 index 0000000..ca38178 --- /dev/null +++ b/.opencode/commands/nx-report.md @@ -0,0 +1,18 @@ +--- +description: "Show this session's nexum digest: cost and wasted-context analysis." +--- + +You are the nexum reporter. Run the digest script and present it clearly. + +## 1. Run the digest + +``` +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/report.py --session "$CLAUDE_SESSION_ID" +``` + +## 2. Present + +- Cost: actual spend vs all-opus baseline, metered total +- Wasted context: efficiency grade, waste ratio, "drop X → save ~N tokens" suggestions + +Keep concise. Prefix with `[nexum] `. diff --git a/.opencode/commands/nx-save.md b/.opencode/commands/nx-save.md new file mode 100644 index 0000000..36a4b18 --- /dev/null +++ b/.opencode/commands/nx-save.md @@ -0,0 +1,49 @@ +--- +description: "Write a session handoff so you can continue in a fresh session once a context or plan limit is near." +--- + +You are the nexum handoff writer. Capture everything a NEW session (which shares none of this conversation's context) needs to resume this work cleanly, and write it to a handoff file. + +## 1. Resolve the handoff path + +Resolve the data directory: `$CLAUDE_PLUGIN_DATA` if set, else `${CLAUDE_PLUGIN_ROOT}/.nexum-data`, else `./.nexum-data`. Session id from `$CLAUDE_SESSION_ID` (or `_nosession`). + +Create `<data_dir>/handoff/` if needed. Write the handoff to BOTH: +- `<data_dir>/handoff/<session_id>.md` +- `<data_dir>/handoff/latest.md` + +## 2. Gather real state (do not guess) + +- `git rev-parse --abbrev-ref HEAD`, `git status --short`, `git diff --stat` +- Session task: `python3 -c "import sys; sys.path.insert(0,'${CLAUDE_PLUGIN_ROOT}/scripts'); import store; print(store.get_session_task('<session_id>') or '')"` +- Actual work done from the conversation + +## 3. Write the handoff (self-contained, concise) + +```markdown +# Handoff: <short task title> + +**Session:** <session_id> **Branch:** <branch> **Written:** <ISO datetime> +**Why now:** <context near limit | 5h plan limit near | user-requested> + +## Goal +<one or two sentences> + +## State now +- <what is DONE and verified> +- Uncommitted changes: <git diff --stat summary> +- <key decisions / findings> + +## Next steps (in order) +1. <concrete action, with file path / command> +2. ... + +## How to resume +- Read: <the 1-3 files most worth opening first> +- Run: <the command(s) that re-establish state> +- Watch out for: <gotchas> +``` + +## 4. Report + +Print the absolute path of the written handoff and a one-line summary. Also suggest `/compact` if context-triggered. diff --git a/.opencode/commands/nx-status.md b/.opencode/commands/nx-status.md new file mode 100644 index 0000000..6221f97 --- /dev/null +++ b/.opencode/commands/nx-status.md @@ -0,0 +1,11 @@ +--- +description: "Display nexum session usage stats." +--- + +Display a compact one-line summary of the current session's nexum stats: + +``` +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/report.py --session "$CLAUDE_SESSION_ID" +``` + +Extract and show: token savings, session cost, model, efficiency. Keep it to 1-3 lines. diff --git a/.opencode/plans/pi-dev-port.md b/.opencode/plans/pi-dev-port.md new file mode 100644 index 0000000..a08c509 --- /dev/null +++ b/.opencode/plans/pi-dev-port.md @@ -0,0 +1,114 @@ +# Plan: pi.dev CLI harness port + +**Session:** _nosession +**Generated:** 2026-07-04 +**Task summary:** Port all nexum features (hooks + commands + planner/executor) to pi.dev CLI harness via single TypeScript extension wrapping existing Python scripts. + +**Models:** +- mechanical: any free model (e.g. nvidia/nemotron) +- standard: current session model (inherit) +- needs-strong: current session model (inherit) + +--- + +### Step 1: Extension scaffolding + runScript helper +- route: mechanical +- files: `.pi/extensions/nexum-pi.ts`, `.pi/extensions/nexum-pi/package.json` +- objective: Create extension skeleton. Export default factory function. Resolve NEXUM_ROOT from extension path. Implement runScript() subprocess wrapper (spawn python3, pipe JSON stdin/stdout, fail-open). Track currentSessionId. +- contract: `.pi/extensions/nexum-pi/package.json` — empty package.json for jiti resolution. `.pi/extensions/nexum-pi.ts` — exports `export default function(pi: ExtensionAPI) { ... }`. Closes over `runScript(name, input, timeout?) → Promise<Record<string,any>>`, `NEXUM_ROOT` (abs path resolved from import.meta.url), `currentSessionId` (string, crypto.randomUUID() default). runScript sets `CLAUDE_PLUGIN_ROOT=NEXUM_ROOT` in subprocess env. +- scope: do NOT touch scripts/ dir, commands/ dir, agents/ dir, .opencode/ dir, hooks/ dir +- acceptance: `python3 -c " +import re +with open('.pi/extensions/nexum-pi.ts') as f: c = f.read() +assert 'export default function' in c +assert 'runScript' in c +assert 'NEXUM_ROOT' in c +assert 'currentSessionId' in c +print('PASS') +"` + +### Step 2: Session lifecycle + tool interceptor events +- route: standard +- files: `.pi/extensions/nexum-pi.ts` +- objective: Wire pi.dev events to nexum Python scripts. session_start → session_reset.py + resume_nudge.py + audit_nudge.py. session_before_compact → precompact.py. tool_call → scan_guard.py (block/limit via permissionDecision, updatedInput) + predup.py (block re-read via permissionDecision). tool_result → dedup.py (collapse duplicate output, modify result content). Handle pi.dev event shapes -> Python script contracts. +- contract: Inside factory function body, after step 1 hooks. `pi.on("session_start", handler)`: reads event.reason/event.previousSessionFile, calls session_reset/resume_nudge/audit_nudge. `pi.on("session_before_compact", handler)`: calls precompact.py. `pi.on("tool_call", handler)`: uses isToolCallEventType to narrow, mutates event.input for scan_guard updatedInput, returns {block} for denied calls. `pi.on("tool_result", handler)`: calls dedup.py, returns {content/details} patches. All handlers fail-open (try/catch, silent return on error). +- scope: do NOT touch scripts/ dir, commands/ dir, .opencode/ dir, hooks/ dir +- acceptance: `python3 -c " +with open('.pi/extensions/nexum-pi.ts') as f: c = f.read() +assert \"pi.on('session_start'\" in c or 'pi.on(\"session_start\"' in c +assert \"pi.on('session_before_compact'\" in c or c.count('session_before_compact') > 0 +assert \"pi.on('tool_call'\" in c or 'pi.on(\"tool_call\"' in c +assert \"pi.on('tool_result'\" in c or 'pi.on(\"tool_result\"' in c +print('PASS') +"` + +### Step 3: nx-plan command +- route: needs-strong +- files: `.pi/extensions/nexum-pi.ts` +- objective: Register /nx-plan command. Handler: reads repo state (git branch, uncommitted changes, project structure via pi.exec or Bash), asks clarifying questions via ctx.ui.select/confirm/input, writes plan file to .nexum-data/plan/<session_id>.md, prompts model selection per tier, records choices in plan models section. +- contract: `pi.registerCommand("nx-plan", { description: "...", handler: async (args, ctx) => { ... } })`. Plan file follows format: `# Plan: <title>\n**Session:** <sid>\n**Models:**\n- mechanical: <model>\n...\n---\n### Step N: <title>\n- route: ...\n- files: ...\n- objective: ...\n- contract: ...\n- scope: ...\n- acceptance: ...`. Plan path = `<data_dir>/plan/<currentSessionId>.md`. Uses runScript("store.py", args) for config/session ops. Uses ctx.ui for all user interaction (select/confirm/input/notify). +- scope: do NOT touch scripts/ dir, commands/ dir, .opencode/ dir, hooks/ dir +- acceptance: `python3 -c " +with open('.pi/extensions/nexum-pi.ts') as f: c = f.read() +assert 'nx-plan' in c +assert 'pi.registerCommand' in c +print('PASS') +"` + +### Step 4: nx-build command (orchestrator) +- route: needs-strong +- files: `.pi/extensions/nexum-pi.ts` +- objective: Register /nx-build command. Handler: reads plan file, parses steps + model selections, dispatches per tier group. For each group: pi.setModel(tierModel) → pi.sendUserMessage(step instructions) → ctx.waitForIdle() → run guardrail.py verification → record pass/fail. Retry same tier on fail (up to 1), escalate one tier (mechanical→standard→needs-strong). Final cost report via runScript("cost_report.py"). All step messages include command, objective, contract, scope, acceptance verbatim. +- contract: `pi.registerCommand("nx-build", { description: "...", handler: async (args, ctx) => { ... } })`. Plan read from `<data_dir>/plan/<currentSessionId>.md`. Parses plan models section. Step dispatch: for each step group (same route/tier), call `await pi.setModel(modelId); await pi.sendUserMessage(stepMsg); await ctx.waitForIdle();`. Acceptance check: `await pi.exec('python3 <NEXUM_ROOT>/scripts/guardrail.py --acceptance \"<cmd>\" --scope-root <root> --changed <files>')`. Retry: same tier up to 1 retry, escalate one tier if still failing. Cost summary: `await runScript("cost_report.py", { session_id })`. Print minimal output: failures, escalations, final cost. No per-step success chatter. +- scope: do NOT touch scripts/ dir, commands/ dir, .opencode/ dir, hooks/ dir +- acceptance: `python3 -c " +with open('.pi/extensions/nexum-pi.ts') as f: c = f.read() +assert 'nx-build' in c +assert 'setModel' in c or 'sendUserMessage' in c +print('PASS') +"` + +### Step 5: Terminal commands — nx-save, nx-load, nx-audit, nx-report, nx-status +- route: mechanical +- files: `.pi/extensions/nexum-pi.ts` +- objective: Register 5 terminal commands. Each wraps a Python script call. nx-save: write session handoff to .nexum-data/handoff/<session_id>.md. nx-load: read latest handoff, display summary. nx-audit: run audit.py, show findings, offer --write fix. nx-report: run report.py, display session digest. nx-status: run report.py with --session flag, display compact stats. +- contract: `pi.registerCommand("nx-save", handler)` — resolves data dir, calls handoff.py via runScript, writes to .nexum-data/handoff/. `pi.registerCommand("nx-load", handler)` — reads .nexum-data/handoff/latest.md, shows summary. `pi.registerCommand("nx-audit", handler)` — runs audit.py, displays findings, ctx.ui.confirm for --write. `pi.registerCommand("nx-report", handler)` — runs report.py --session, displays output. `pi.registerCommand("nx-status", handler)` — runs report.py --session, displays compact stats. All use `ctx.ui.notify` or `ctx.ui.setWidget` for TUI display. +- scope: do NOT touch scripts/ dir, commands/ dir, .opencode/ dir, hooks/ dir +- acceptance: `python3 -c " +with open('.pi/extensions/nexum-pi.ts') as f: c = f.read() +for cmd in ['nx-save','nx-load','nx-audit','nx-report','nx-status']: + assert cmd in c, f'{cmd} not found' +print('PASS') +"` + +### Step 6: Integration verification +- route: standard +- files: `.pi/extensions/nexum-pi.ts`, `.pi/extensions/nexum-pi/package.json` +- objective: Verify extension file is valid. All events + commands registered. Python scripts callable from extension with CLAUDE_PLUGIN_ROOT set. Enumerate every feature and confirm. +- contract: Assertions: (a) file exports default function, (b) runScript exists, (c) NEXUM_ROOT resolved, (d) session_start handler registered, (e) session_before_compact handler registered, (f) tool_call handler with isToolCallEventType, (g) tool_result handler, (h) 7 commands registered (nx-plan, nx-build, nx-save, nx-load, nx-audit, nx-report, nx-status). Verify Python scripts accept stdin JSON: `echo '{}' | python3 scripts/session_reset.py` exits 0. +- scope: do NOT touch scripts/ dir, commands/ dir, .opencode/ dir, hooks/ dir +- acceptance: `python3 -c " +import re +with open('.pi/extensions/nexum-pi.ts') as f: c = f.read() +checks = [ + ('default function', 'export default function' in c), + ('runScript', 'runScript' in c), + ('session_start', 'session_start' in c), + ('session_before_compact', 'session_before_compact' in c), + ('tool_call', \"tool_call\" in c or 'tool_call' in c), + ('tool_result', \"tool_result\" in c or 'tool_result' in c), + ('nx-plan', 'nx-plan' in c), + ('nx-build', 'nx-build' in c), + ('nx-save', 'nx-save' in c), + ('nx-load', 'nx-load' in c), + ('nx-audit', 'nx-audit' in c), + ('nx-report', 'nx-report' in c), + ('nx-status', 'nx-status' in c), + ('spawn import', 'spawn' in c), +] +fails = [name for name, ok in checks if not ok] +if fails: + print(f'FAIL: {fails}') + exit(1) +print('PASS') +"` diff --git a/.opencode/plugins/nexum-hooks.ts b/.opencode/plugins/nexum-hooks.ts new file mode 100644 index 0000000..4db56f0 --- /dev/null +++ b/.opencode/plugins/nexum-hooks.ts @@ -0,0 +1,120 @@ +import type { Plugin } from "@opencode-ai/plugin" +import path from "path" +import { spawn } from "child_process" + +const NEXUM_ROOT = path.resolve(import.meta.dirname, "../..") +const SCRIPTS_DIR = path.join(NEXUM_ROOT, "scripts") + +let currentSessionId = "_nosession" + +function runScript(scriptName: string, input: object, timeout = 10000): Promise<Record<string, any>> { + return new Promise((resolve) => { + const child = spawn("python3", [path.join(SCRIPTS_DIR, scriptName)], { + env: { ...process.env, CLAUDE_PLUGIN_ROOT: NEXUM_ROOT }, + stdio: ["pipe", "pipe", "pipe"], + }) + + const timer = setTimeout(() => { + child.kill() + resolve({}) + }, timeout) + + try { + child.stdin.write(JSON.stringify(input)) + child.stdin.end() + } catch { + child.kill() + clearTimeout(timer) + resolve({}) + return + } + + let stdout = "" + child.stdout.on("data", (d: Buffer) => { stdout += d.toString() }) + child.on("close", () => { + clearTimeout(timer) + try { resolve(JSON.parse(stdout)) } + catch { resolve({}) } + }) + child.on("error", () => { + clearTimeout(timer) + resolve({}) + }) + }) +} + +export const NexumHooks: Plugin = async (ctx) => { + return { + "shell.env": async (_input, output) => { + output.env.CLAUDE_PLUGIN_ROOT = NEXUM_ROOT + output.env.CLAUDE_SESSION_ID = currentSessionId + }, + + "session.created": async (input: any) => { + currentSessionId = input?.session_id ?? input?.sessionId ?? "_nosession" + await runScript("session_reset.py", { session_id: currentSessionId }) + await runScript("resume_nudge.py", { + session_id: currentSessionId, + source: input?.source ?? "startup", + cwd: input?.cwd ?? NEXUM_ROOT, + }) + await runScript("audit_nudge.py", { + session_id: currentSessionId, + source: input?.source ?? "startup", + cwd: input?.cwd ?? NEXUM_ROOT, + }) + }, + + "tool.execute.before": async (input: any, output: any) => { + const sharedInput = { + session_id: currentSessionId, + tool_name: input.tool, + tool_input: input.args ?? {}, + } + + const scanResult = await runScript("scan_guard.py", { + ...sharedInput, + tool_input: { ...sharedInput.tool_input }, + }) + + if (scanResult?.hookSpecificOutput?.permissionDecision) { + output.permissionDecision = scanResult.hookSpecificOutput.permissionDecision + output.permissionDecisionReason = scanResult.hookSpecificOutput.permissionDecisionReason + if (scanResult.hookSpecificOutput.updatedInput) { + Object.assign(output.args, scanResult.hookSpecificOutput.updatedInput) + } + return + } + + const predupResult = await runScript("predup.py", sharedInput) + if (predupResult?.hookSpecificOutput?.permissionDecision) { + output.permissionDecision = predupResult.hookSpecificOutput.permissionDecision + output.permissionDecisionReason = predupResult.hookSpecificOutput.permissionDecisionReason + } + }, + + "tool.execute.after": async (input: any, output: any) => { + const dedupResult = await runScript("dedup.py", { + session_id: currentSessionId, + tool_name: input.tool, + tool_input: input.args || {}, + tool_use_id: "", + transcript_path: "", + tool_response: input.result ?? "", + }) + + if (dedupResult?.hookSpecificOutput?.updatedToolOutput) { + output.result = dedupResult.hookSpecificOutput.updatedToolOutput + } + }, + + "session.compacted": async (input: any) => { + await runScript("precompact.py", { + session_id: currentSessionId, + cwd: input?.cwd ?? NEXUM_ROOT, + transcript_path: input?.transcript_path ?? "", + trigger: input?.trigger ?? "compacted", + }) + }, + } +} diff --git a/.pi/extensions/nexum-pi.ts b/.pi/extensions/nexum-pi.ts new file mode 100644 index 0000000..5a2f789 --- /dev/null +++ b/.pi/extensions/nexum-pi.ts @@ -0,0 +1,593 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { spawn } from "child_process"; +import path from "path"; +import crypto from "crypto"; +import fs from "fs"; + +// nexum repo root, resolved from: <repo>/.pi/extensions/nexum-pi.ts +const NEXUM_ROOT = path.resolve(import.meta.dirname, "../.."); +const SCRIPTS_DIR = path.join(NEXUM_ROOT, "scripts"); +const DATA_DIR = path.join(NEXUM_ROOT, ".nexum-data"); + +let currentSessionId = crypto.randomUUID(); + +/** + * Run a Python script via JSON-over-stdin/stdout (hook-compatible protocol). + * Writes `input` as JSON to stdin, reads JSON from stdout, fail-opens to {}. + */ +function runScript( + name: string, + input: Record<string, any>, + timeout = 10000, +): Promise<Record<string, any>> { + return new Promise((resolve) => { + const child = spawn("python3", [path.join(SCRIPTS_DIR, name)], { + env: { ...process.env, CLAUDE_PLUGIN_ROOT: NEXUM_ROOT }, + stdio: ["pipe", "pipe", "pipe"], + }); + + const timer = setTimeout(() => { + child.kill(); + resolve({}); + }, timeout); + + try { + child.stdin.write(JSON.stringify(input)); + child.stdin.end(); + } catch { + child.kill(); + clearTimeout(timer); + resolve({}); + return; + } + + let stdout = ""; + child.stdout.on("data", (d: Buffer) => { + stdout += d.toString(); + }); + child.on("close", () => { + clearTimeout(timer); + try { + resolve(JSON.parse(stdout)); + } catch { + resolve({}); + } + }); + child.on("error", () => { + clearTimeout(timer); + resolve({}); + }); + }); +} + +export default function (pi: ExtensionAPI) { + // --------------------------------------------------------------------------- + // Session lifecycle + // --------------------------------------------------------------------------- + pi.on("session_start", async (event: any, ctx: any) => { + try { + const sid = event?.session_id ?? event?.sessionId ?? currentSessionId; + currentSessionId = sid; + const cwd = event?.cwd ?? NEXUM_ROOT; + await runScript("session_reset.py", { session_id: sid }); + await runScript("resume_nudge.py", { session_id: sid, source: event?.reason ?? "startup", cwd }); + await runScript("audit_nudge.py", { session_id: sid, source: event?.reason ?? "startup", cwd }); + } catch { + // fail-open + } + }); + + pi.on("session_before_compact", async (event: any, ctx: any) => { + try { + const sid = currentSessionId; + const cwd = event?.cwd ?? NEXUM_ROOT; + await runScript("precompact.py", { + session_id: sid, + cwd, + transcript_path: event?.transcript_path ?? "", + trigger: event?.reason ?? "compacted", + }); + } catch { + // fail-open + } + }); + + // --------------------------------------------------------------------------- + // Tool call/result interceptors + // --------------------------------------------------------------------------- + pi.on("tool_call", async (event: any, ctx: any) => { + try { + if (event.toolName === "read" || event.toolName === "bash" || event.toolName === "grep" || event.toolName === "glob") { + const scanResult = await runScript("scan_guard.py", { + session_id: currentSessionId, + tool_name: event.toolName, + tool_input: event.input ?? {}, + }); + if (scanResult?.hookSpecificOutput?.permissionDecision) { + ctx.ui.notify?.({ title: "nexum", message: `Blocked ${event.toolName} call` }); + return { block: true, reason: scanResult.hookSpecificOutput.permissionDecisionReason ?? "Blocked by scan_guard" }; + } + if (scanResult?.hookSpecificOutput?.updatedInput) { + Object.assign(event.input, scanResult.hookSpecificOutput.updatedInput); + } + } + + const predupResult = await runScript("predup.py", { + session_id: currentSessionId, + tool_name: event.toolName, + tool_input: event.input ?? {}, + }); + if (predupResult?.hookSpecificOutput?.permissionDecision) { + return { block: true, reason: predupResult.hookSpecificOutput.permissionDecisionReason ?? "Blocked by predup" }; + } + } catch { + // fail-open + } + }); + + pi.on("tool_result", async (event: any, ctx: any) => { + try { + const dedupResult = await runScript("dedup.py", { + session_id: currentSessionId, + tool_name: event.toolName, + tool_input: event.input ?? {}, + tool_use_id: event.toolCallId ?? "", + transcript_path: "", + tool_response: event.content ?? "", + }); + if (dedupResult?.hookSpecificOutput?.updatedToolOutput) { + return { content: dedupResult.hookSpecificOutput.updatedToolOutput }; + } + } catch { + // fail-open + } + }); + + // --------------------------------------------------------------------------- + // nx-plan — interactive plan creation + // --------------------------------------------------------------------------- + pi.registerCommand("nx-plan", { + description: "Decompose current task into tiered, self-contained steps and write plan file", + handler: async (args: any, ctx: any) => { + if (!ctx.hasUI) return; + + const planDir = path.join(DATA_DIR, "plan"); + fs.mkdirSync(planDir, { recursive: true }); + + const sid = currentSessionId; + const planPath = path.join(planDir, `${sid}.md`); + + // Gather repo context + const branch = await pi.exec("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: NEXUM_ROOT }); + const gitStatus = await pi.exec("git", ["status", "--short"], { cwd: NEXUM_ROOT }); + + // Ask user clarifying questions + const goal = await ctx.ui.input("What task should nexum plan? Describe the goal."); + if (!goal) { ctx.ui.notify("nx-plan cancelled.", "warning"); return; } + + // Determine number of steps + const stepCountStr = await ctx.ui.input("How many steps? (2-8, or press Enter for auto-estimate)"); + const stepCount = stepCountStr ? Math.min(Math.max(parseInt(stepCountStr, 10) || 4, 2), 8) : 4; + + // Ask for scope and constraints + const constraints = await ctx.ui.input("Any constraints, out-of-bounds areas, or acceptance criteria?"); + + // Ask model selection per tier + const mechanicalModel = await ctx.ui.input("Model for mechanical steps? (default: haiku)") || "haiku"; + const standardModel = await ctx.ui.input("Model for standard steps? (default: sonnet)") || "sonnet"; + const strongModel = await ctx.ui.input("Model for needs-strong steps? (default: opus)") || "opus"; + + // Read last few script files to understand project structure + const files = (await pi.exec("ls", ["-la"], { cwd: NEXUM_ROOT })) || ""; + + // Write plan template + const plan = [ + `# Plan: ${goal}`, + "", + `**Session:** ${sid}`, + `**Generated:** ${new Date().toISOString().split("T")[0]}`, + `**Task summary:** ${goal}`, + "", + "**Models:**", + `- mechanical: ${mechanicalModel}`, + `- standard: ${standardModel}`, + `- needs-strong: ${strongModel}`, + "", + "---", + "", + ]; + for (let i = 1; i <= stepCount; i++) { + plan.push( + `### Step ${i}: `, + `- route: `, + `- files: `, + `- objective: `, + `- contract: `, + `- scope: `, + `- acceptance: `, + "", + ); + } + fs.writeFileSync(planPath, plan.join("\n")); + + ctx.ui.notify(`Plan template written: ${planPath}`); + ctx.ui.setWidget("nexum-plan", [ + `Goal: ${goal}`, + `Steps: ${stepCount}`, + `Models: mech=${mechanicalModel} std=${standardModel} strong=${strongModel}`, + `File: ${planPath}`, + ]); + }, + }); + + // --------------------------------------------------------------------------- + // nx-build — execute a nexum plan + // --------------------------------------------------------------------------- + pi.registerCommand("nx-build", { + description: "Execute a nexum plan: dispatch steps, verify acceptance, report cost", + handler: async (args: any, ctx: any) => { + const sid = currentSessionId; + const planDir = path.join(DATA_DIR, "plan"); + const planPath = path.join(planDir, `${sid}.md`); + + if (!fs.existsSync(planPath)) { + if (ctx.hasUI) ctx.ui.notify("No plan found. Run /nx-plan first.", "error"); + return; + } + + // Parse plan file for models and steps + const planContent = fs.readFileSync(planPath, "utf-8"); + const lines = planContent.split("\n"); + + // Extract models + const models: Record<string, string> = {}; + let inModels = false; + for (const line of lines) { + if (line.trim() === "---") break; + if (line.trim() === "**Models:**") { inModels = true; continue; } + if (inModels && line.startsWith("- ")) { + const m = line.match(/-\s*(\w+):\s*(.+)/); + if (m) models[m[1]] = m[2].trim(); + } + } + + // Extract steps + interface Step { + index: number; + route: string; + files: string; + objective: string; + contract: string; + scope: string; + acceptance: string; + } + const steps: Step[] = []; + let currentStep: Partial<Step> | null = null; + for (const line of lines) { + const stepMatch = line.match(/### Step (\d+):/); + if (stepMatch) { + if (currentStep && currentStep.index) steps.push(currentStep as Step); + currentStep = { index: parseInt(stepMatch[1], 10) }; + continue; + } + if (!currentStep) continue; + const routeMatch = line.match(/- route:\s*(.+)/); + if (routeMatch) { currentStep.route = routeMatch[1].trim(); continue; } + const filesMatch = line.match(/- files:\s*(.+)/); + if (filesMatch) { currentStep.files = filesMatch[1]; continue; } + const objMatch = line.match(/- objective:\s*(.+)/); + if (objMatch) { currentStep.objective = objMatch[1]; continue; } + const contractMatch = line.match(/- contract:\s*(.+)/); + if (contractMatch) { currentStep.contract = contractMatch[1]; continue; } + const scopeMatch = line.match(/- scope:\s*(.+)/); + if (scopeMatch) { currentStep.scope = scopeMatch[1]; continue; } + const accMatch = line.match(/- acceptance:\s*(.+)/); + if (accMatch) { currentStep.acceptance = accMatch[1]; continue; } + } + if (currentStep && currentStep.index) steps.push(currentStep as Step); + + if (steps.length === 0) { + if (ctx.hasUI) ctx.ui.notify("No steps found in plan.", "error"); + return; + } + + // Group by route (tier): mechanical → standard → needs-strong + const routeOrder = ["mechanical", "standard", "needs-strong"]; + const grouped: Record<string, Step[]> = { mechanical: [], standard: [], "needs-strong": [] }; + for (const s of steps) { + const r = s.route || "standard"; + if (!grouped[r]) grouped[r] = []; + grouped[r].push(s); + } + + // Resolve actual model from ctx.model (pi.dev) or fallback + const actualModelId = (ctx as any)?.model?.id || ""; + + interface StepResult { index: number; status: "pass" | "fail"; } + const results: StepResult[] = []; + + for (const tier of routeOrder) { + const tierSteps = grouped[tier]; + if (tierSteps.length === 0) continue; + + const planModel = models[tier]; + const resolvedModel = actualModelId || planModel || tier; + const isInherit = planModel === "current session model (inherit)"; + + if (!isInherit && planModel) { + try { await pi.setModel(planModel); } catch { /* best-effort */ } + } + + for (const step of tierSteps) { + // Build step message with full context + const stepMsg = [ + `## Execute step ${step.index}: ${step.objective}`, + "", + `**Files:** ${step.files}`, + `**Objective:** ${step.objective}`, + `**Contract:** ${step.contract || "see acceptance"}`, + `**Scope:** ${step.scope || "none"}`, + "", + `**Acceptance:** ${step.acceptance || "none"}`, + "", + `Implement this step. After implementation, verify acceptance.`, + `Do not modify files outside the declared scope.`, + ].join("\n"); + + // Send to agent and wait + await pi.sendUserMessage(stepMsg); + + // Wait for agent to become idle + try { await ctx.waitForIdle(); } catch { /* timeout or abort */ } + + // Run acceptance via guardrail + if (step.acceptance) { + const { exitCode } = await pi.exec("python3", [ + path.join(SCRIPTS_DIR, "guardrail.py"), + "--acceptance", step.acceptance, + "--scope-root", NEXUM_ROOT, + ...(step.files ? ["--changed", step.files] : []), + ], { cwd: NEXUM_ROOT }); + + const status = exitCode === 0 ? "pass" : "fail"; + results.push({ index: step.index, status }); + + // Record in ledger with actual model ID + await runScript("store.py", { + cmd: "step-set", + session: sid, + planHash: "", + index: step.index, + status: status === "pass" ? "done" : "failed", + title: step.objective, + route: tier, + tier: resolvedModel, + }); + + if (status === "fail") { + if (ctx.hasUI) ctx.ui.notify(`Step ${step.index} failed. Retrying...`, "warning"); + // Retry once + try { await pi.setModel(resolvedModel); } catch { /* best-effort */ } + await pi.sendUserMessage(`Retry step ${step.index}.\nPrevious attempt failed.\n\n${stepMsg}`); + try { await ctx.waitForIdle(); } catch { /* ignore */ } + + const retryExit = (await pi.exec("python3", [ + path.join(SCRIPTS_DIR, "guardrail.py"), + "--acceptance", step.acceptance, + "--scope-root", NEXUM_ROOT, + ...(step.files ? ["--changed", step.files] : []), + ], { cwd: NEXUM_ROOT })).exitCode; + + if (retryExit !== 0 && ctx.hasUI) { + ctx.ui.notify(`Step ${step.index} failed after retry. Escalate manually.`, "error"); + } + } + } else { + results.push({ index: step.index, status: "pass" }); + } + } + } + + // Final cost report + const costChild = spawn("python3", [ + path.join(SCRIPTS_DIR, "cost_report.py"), + "--session", sid, + ], { env: { ...process.env, CLAUDE_PLUGIN_ROOT: NEXUM_ROOT } }); + + let costOut = ""; + costChild.stdout.on("data", (d: Buffer) => { costOut += d.toString(); }); + costChild.on("close", () => { + if (!ctx.hasUI) return; + ctx.ui.setWidget("nexum-cost", costOut || "Cost report unavailable."); + }); + }, + }); + + // --------------------------------------------------------------------------- + // nx-save — write session handoff to .nexum-data/handoff/<session_id>.md + // --------------------------------------------------------------------------- + pi.registerCommand("nx-save", { + description: "Write a session handoff to .nexum-data/handoff/<session_id>.md", + handler: async (args: any, ctx: any) => { + const sessionId = args.session ?? currentSessionId; + const cwd = args.cwd ?? NEXUM_ROOT; + + const child = spawn("python3", [ + path.join(SCRIPTS_DIR, "handoff.py"), + "write", + "--session", + sessionId, + "--cwd", + cwd, + ...(args.tokens ? ["--tokens", String(args.tokens)] : []), + ], { env: { ...process.env, CLAUDE_PLUGIN_ROOT: NEXUM_ROOT } }); + + let stdout = ""; + child.stdout.on("data", (d: Buffer) => { stdout += d.toString(); }); + + return new Promise<void>((resolve) => { + child.on("close", () => { + try { + const result = JSON.parse(stdout.trim() || "{}"); + if (!ctx.hasUI) { resolve(); return; } + if (result.ok) { + ctx.ui.notify?.({ title: "nx-save", message: "Handoff written to .nexum-data/handoff/" }); + } else { + ctx.ui.notify?.({ title: "nx-save", message: "Handoff failed", type: "error" }); + } + } catch { + if (ctx.hasUI) ctx.ui.notify?.({ title: "nx-save", message: "Handoff failed", type: "error" }); + } + resolve(); + }); + child.on("error", () => { + if (ctx.hasUI) ctx.ui.notify?.({ title: "nx-save", message: "Handoff failed", type: "error" }); + resolve(); + }); + }); + }, + }); + + // --------------------------------------------------------------------------- + // nx-load — read latest handoff, display summary + // --------------------------------------------------------------------------- + pi.registerCommand("nx-load", { + description: "Read the latest handoff and display a summary", + handler: async (_args: any, ctx: any) => { + const latestPath = path.join(DATA_DIR, "handoff", "latest.md"); + let content: string; + try { + content = fs.readFileSync(latestPath, "utf-8"); + } catch { + if (ctx.hasUI) ctx.ui.notify?.({ title: "nx-load", message: "No handoff found", type: "warning" }); + return; + } + + if (!ctx.hasUI) return; + + // Show first 8 lines as a summary notification + const summary = content.split("\n").slice(0, 8).join("\n"); + ctx.ui.notify?.({ title: "nx-load", message: summary }); + ctx.ui.setWidget?.({ id: "nexum-handoff", content }); + }, + }); + + // --------------------------------------------------------------------------- + // nx-audit — run audit.py, show findings, offer --write fix + // --------------------------------------------------------------------------- + pi.registerCommand("nx-audit", { + description: "Run audit.py, show findings, offer --write fix", + handler: async (args: any, ctx: any) => { + const root = args.root ?? NEXUM_ROOT; + + // First pass: read-only audit + const child = spawn("python3", [ + path.join(SCRIPTS_DIR, "audit.py"), + "--root", root, + ], { env: { ...process.env, CLAUDE_PLUGIN_ROOT: NEXUM_ROOT } }); + + let stdout = ""; + child.stdout.on("data", (d: Buffer) => { stdout += d.toString(); }); + + return new Promise<void>((resolve) => { + child.on("close", async () => { + if (!ctx.hasUI) { resolve(); return; } + + // Show findings + ctx.ui.setWidget?.({ id: "nexum-audit", content: stdout || "Audit returned no output." }); + + // If issues found and user didn't already pass --write, offer to fix + if (!args.write && stdout.includes("issues found")) { + const confirmed = await ctx.ui.confirm?.("Apply suggested ignore-file patterns?"); + if (confirmed) { + const writer = spawn("python3", [ + path.join(SCRIPTS_DIR, "audit.py"), + "--root", root, "--write", + ], { env: { ...process.env, CLAUDE_PLUGIN_ROOT: NEXUM_ROOT } }); + let wOut = ""; + writer.stdout.on("data", (d: Buffer) => { wOut += d.toString(); }); + writer.on("close", () => { + ctx.ui.setWidget?.({ id: "nexum-audit-result", content: wOut || "Patterns applied." }); + ctx.ui.notify?.({ title: "nx-audit", message: "Ignore-file patterns applied." }); + resolve(); + }); + writer.on("error", () => { resolve(); }); + return; + } + ctx.ui.notify?.({ title: "nx-audit", message: "Audit complete (no changes)." }); + } else { + ctx.ui.notify?.({ title: "nx-audit", message: "Audit complete." }); + } + resolve(); + }); + child.on("error", () => { + if (ctx.hasUI) ctx.ui.notify?.({ title: "nx-audit", message: "Audit failed", type: "error" }); + resolve(); + }); + }); + }, + }); + + // --------------------------------------------------------------------------- + // nx-report — run report.py, display session digest + // --------------------------------------------------------------------------- + pi.registerCommand("nx-report", { + description: "Run report.py and display session digest", + handler: async (args: any, ctx: any) => { + const sessionArg = args.session ? ["--session", args.session] : []; + + const child = spawn("python3", [ + path.join(SCRIPTS_DIR, "report.py"), + ...sessionArg, + ], { env: { ...process.env, CLAUDE_PLUGIN_ROOT: NEXUM_ROOT } }); + + let stdout = ""; + child.stdout.on("data", (d: Buffer) => { stdout += d.toString(); }); + + return new Promise<void>((resolve) => { + child.on("close", () => { + if (!ctx.hasUI) { resolve(); return; } + ctx.ui.setWidget?.({ id: "nexum-report", content: stdout || "No report data." }); + ctx.ui.notify?.({ title: "nx-report", message: "Report generated." }); + resolve(); + }); + child.on("error", () => { + if (ctx.hasUI) ctx.ui.notify?.({ title: "nx-report", message: "Report failed", type: "error" }); + resolve(); + }); + }); + }, + }); + + // --------------------------------------------------------------------------- + // nx-status — run report.py --session, display compact stats + // --------------------------------------------------------------------------- + pi.registerCommand("nx-status", { + description: "Run report.py --session and display compact stats", + handler: async (args: any, ctx: any) => { + const sessionId = args.session ?? currentSessionId; + + const child = spawn("python3", [ + path.join(SCRIPTS_DIR, "report.py"), + "--session", sessionId, + ], { env: { ...process.env, CLAUDE_PLUGIN_ROOT: NEXUM_ROOT } }); + + let stdout = ""; + child.stdout.on("data", (d: Buffer) => { stdout += d.toString(); }); + + return new Promise<void>((resolve) => { + child.on("close", () => { + if (!ctx.hasUI) { resolve(); return; } + const lines = (stdout || "").split("\n").filter((l) => l.trim()); + const compact = lines.slice(0, 10).join("\n"); + ctx.ui.setWidget?.({ id: "nexum-status", content: compact || "No data." }); + resolve(); + }); + child.on("error", () => { + if (ctx.hasUI) ctx.ui.notify?.({ title: "nx-status", message: "Status failed", type: "error" }); + resolve(); + }); + }); + }, + }); +} diff --git a/.pi/extensions/nexum-pi/package.json b/.pi/extensions/nexum-pi/package.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/.pi/extensions/nexum-pi/package.json @@ -0,0 +1 @@ +{} diff --git a/SPEC.md b/SPEC.md index 35482dd..cbf2274 100644 --- a/SPEC.md +++ b/SPEC.md @@ -197,6 +197,9 @@ CREATE TABLE usage( session_id TEXT, model TEXT, input_tok INTEGER, ".next", "coverage", ".venv", "__pycache__"], "intent_guard_enabled": true, "intent_similarity_threshold": 0.25, + "worktree_enabled": true, + "worktree_copy": [], + "worktree_ignore": [], "statusline_compaction_warn_pct": 80, "statusline_compaction_warn_tokens": 80000, "dedup_cache_weight": 0.1, @@ -416,21 +419,34 @@ Two responsibilities in one hook: - **(b) Intent-change guard:** `prompt = data["prompt"]`. Derive a keyword/topic signature (lowercased word set minus stopwords; plus task-type keywords: fix/bug/error→"fix", add/implement/feature/new→"feature", refactor, test, docs). - Compare to stored session task signature via Jaccard similarity. If a stored task - exists AND task-type changed (e.g. fix→feature) AND similarity < - `intent_similarity_threshold` AND no `bypass_intent` flag set: - emit - `{"decision":"block","reason":"[nexum] This looks like a new task (<old>→<new>). A fresh session gives cleaner, cheaper context. Reply 'continue' to proceed here."}` - and set nothing yet. If the prompt is exactly `continue` (case-insensitive, - trimmed) → set `bypass_intent` flag and allow (`{}`). On first prompt of a session - (no stored task) → store signature, allow. + Compare to stored session task signature via Jaccard similarity. A divergence is + a stored task existing AND task-type changed (e.g. fix→feature) AND similarity < + `intent_similarity_threshold` AND no `bypass_intent` flag set. On divergence, + decide by the **working-tree state** (via `worktree.is_dirty(cwd)`), NOT by + session token size: + - **Dirty tree** (uncommitted work) AND `worktree_enabled`: create/reuse an + isolated worktree at `<repo>/.nexum-data/worktrees/<slug>` on branch + `nexum/<slug>` (`worktree.create_worktree`), copying `worktree_copy` globs + (minus `worktree_ignore`) so untracked helpers the new work needs come along, + then emit `{"decision":"block","reason":"[nexum] New task (<old>→<new>) with + uncommitted work in progress. Isolated it in a worktree:\n <path>\nOpen a + session there to keep this work separate, or reply 'continue' to work here + anyway."}`. + - **Clean tree** (or `worktree_enabled` false, or creation failed): the divergent + task is fine in the current context — adopt the new signature and allow (`{}`). + No more "start a fresh session" nag. + If the prompt is exactly `continue` (case-insensitive, trimmed) → set + `bypass_intent` flag and allow (`{}`). On first prompt of a session (no stored + task) → store signature, allow. - Respect `intent_guard_enabled`. Always update the stored task signature AFTER a non-blocked prompt. - **EDGE CASES:** empty prompt → allow; `continue` bypass must clear so the new task - becomes the session task; never block twice in a row for the same divergence. + becomes the session task; never block twice in a row for the same divergence; + all git/fs errors fail-open (no block). - **ACCEPTANCE:** first prompt allowed + stored; same-topic follow-up allowed; - fix→feature divergence blocked; `continue` bypasses then adopts new task; - threshold crossing emits systemMessage exactly once per window. + fix→feature divergence on a dirty tree creates a worktree + blocks; the same + divergence on a clean tree is allowed with no worktree; `continue` bypasses then + adopts new task; threshold crossing emits systemMessage exactly once per window. ### 5.3 `scripts/audit.py` + `commands/nx-audit.md` · TIER: standard - CLI `python3 audit.py [--root <dir>] [--write]`. Scans `--root` (default cwd): diff --git a/commands/nx-build.md b/commands/nx-build.md index 9f4d6ef..13cbf89 100644 --- a/commands/nx-build.md +++ b/commands/nx-build.md @@ -16,6 +16,9 @@ Read plan in full. Parse every step: index, `route`, `files`, `objective`, `cont No plan file → stop: `[nexum] No plan found for this session. Run /nx-plan first.` +**Args.** If the invocation includes `--harness <claude|opencode|cursor>`, every +step runs in that external harness via §4b instead of an in-session subagent. Absent → default path. + Read config once: `python3 ${CLAUDE_PLUGIN_ROOT}/scripts/store.py config`. Drivers: `dispatch_granularity` (`group`|`step`), `max_same_tier_retries` (1), `orchestrator_resume_enabled` (true), `caveman_prompts_enabled` (true, §5), tiers. ## 1a. Resume from ledger (skip done work) @@ -79,6 +82,40 @@ Subagent earns its keep only by running a *different* model without trashing the Doubt about session model → dispatch (redundant spawn < cache-trashing switch). +## 4b. Cross-harness offload (`--harness <claude|opencode|cursor>`) + +Invoked with `--harness <name>` → run every step in that **external harness** (a +headless `claude`/`opencode`/`cursor` process in its own git worktree) instead of +an in-session subagent. No `--harness` → default in-session path (§4/§5), unchanged. + +Per step (grouping is a Claude-subagent optimization only — under `--harness`, +dispatch **one step at a time**): write the step to a JSON file +`{title, objective, contract, scope_deny, acceptance, files}` where `scope_deny` +is each path from the step's `scope: do NOT touch`. Then: + +``` +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/dispatch.py \ + --harness <name> --model <tier's model> --repo <repo root> \ + --new-worktree --slug <plan-slug>-step<N> --step-file <path> \ + --session <session_id> --plan-hash <plan_hash> --index <N> +``` + +`dispatch.py` creates the worktree, runs the harness headless, verifies with +`guardrail.py`, records the `agents`/`step_ledger`/`usage` rows itself, and prints +the verdict JSON `{"pass", "diff", "scope_violations", "acceptance_rc", "tokens", +"cost_usd", "agent_id", "worktree", ...}` — the **same shape** you parse from +guardrail. Read `pass`: + +- **true** → proceed. `dispatch.py` already wrote the ledger + usage, so SKIP the + §6a `step-set`/`record-usage`; still record calibration (§6a). +- **false** → §7 ladder. A patch-retry re-runs `dispatch.py` on the **same** + worktree (`--worktree <path>` from the verdict instead of `--new-worktree`), + appending the guardrail failure + prior `diff` to the step objective. + +Route→model still applies; `--harness` only changes *where* it runs. (Auto +route→harness mapping is future work — for now the one `--harness` applies to all +steps.) + ## 5. Build delegation (stable-prefix-first) Shared/stable content first, variable last → longest cacheable common prefix: diff --git a/docs/.nojekyll b/docs/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/docs/crew/index.html b/docs/crew/index.html new file mode 100644 index 0000000..056d0fb --- /dev/null +++ b/docs/crew/index.html @@ -0,0 +1,239 @@ +<!doctype html> +<html lang=en data-theme=dark> +<head> +<meta charset=utf-8> +<meta name=viewport content="width=device-width, initial-scale=1"> +<title>crew — a terminal dashboard for coding agents + + + + +
+
+ + +
+ +
+

Run a crew of coding agents
from one terminal.

+

crew launches, chats with, and manages Claude Code, OpenCode & Cursor agents — each in its own git worktree — without leaving your terminal. Standalone Rust + ratatui.

+ +
crew · dashboard
 crew  nexum  tui-agent-flow ⚙ engine · 5 agents  ●2 ✓2 ✗1  $1.050  ◉ 2 marked
+╭ agents (5) ────────────────────╮╭ preview ───────────────────────────────────────────────────────╮
+│▶   ⠦ cursor     add b…$0.42 12s││add billing dashboard endpoint                                  │
+│  ◉ ⠦ claude     add a…$0.31 40s││                                                                │
+│  ◉ ✗ opencode   fix f…$0.08 2m ││status   ⠦ running                                              │
+│    ✓ claude     rewri…$0.05 10m││kind     agent                                                  │
+│    ✓ cursor     add d…$0.19 1h ││harness  cursor · sonnet                                        │
+│                                ││branch   crew/billing-api                                       │
+│                                ││remote   up to date                                             │
+│                                ││cost     $0.420                                                 │
+│                                ││running  12s ago                                                │
+│                                ││worktree .crew/worktrees/billing-api                            │
+│                                ││                                                                │
+│                                ││[d]iff [l]og [e]dit [!]shell [P]ush [R]etry [o]wt [x]rm         │
+│                                ││                                                                │
+│                                ││                                                                │
+│                                ││                                                                │
+│                                ││                                                                │
+│                                ││                                                                │
+│                                ││                                                                │
+│                                ││                                                                │
+│                                ││                                                                │
+│                                ││                                                                │
+│                                ││                                                                │
+│                                ││                                                                │
+│                                ││                                                                │
+│                                ││                                                                │
+│                                ││                                                                │
+╰────────────────────────────────╯╰────────────────────────────────────────────────────────────────╯
+[q]uit [jk]move [n]ew [enter]chat [d]iff [l]og [e]dit [P]ush [s]top [x]rm [1-4]filter [,]settings [?
+
+ +
+

Everything, one keystroke away

+

A master–detail dashboard that treats agents like a first-class resource.

+
🖥️

Agents inside the TUI

Every agent runs its harness REPL in a PTY rendered in an embedded pane. Select one, press enter, chat side-by-side. No tmux, no window juggling.

🌿

Isolated worktrees

Each agent runs in its own git worktree on a crew/<task> branch — created natively, no external tooling. Work never tangles.

🔀

Any harness

Claude Code, OpenCode, Cursor — launch, resume, and manage them side by side, each with its own model.

🔎

Diff & log viewers

Review a worktree diff (colored, searchable) or tail a log that live-follows like tail -f. Scrollbars, / search, n/N.

🚀

Ship from the TUI

P commits, pushes the branch, and opens a PR with gh — straight from the dashboard, lazygit-style.

✔️

Manage the fleet

Multi-select with space, bulk stop/remove, quick-filters 1-4, sort by status/cost/recent, jump between failed with ]/[.

+
+ +
+

How it compares

+

Inspired by the best terminal tools — built for agents.

+ + + +
Capabilitycrewclaude-squadlazygitk9s
Multiple agents in one view·
Embedded chat (no tmux)
Per-agent git worktree
Cross-harness (Claude/Cursor/OpenCode)·
Diff viewer·
Live log follow·
Commit · push · PR·
Multi-select bulk actions·
Runs standalone (no backend)
+
+ +
+

Screens

+

Real frames rendered by the binary.

+ +
+ +
+

Install & run

+

One binary. No backend required.

+
# build
+cd tui && cargo build --release
+
+# run from anywhere inside a git repo
+./target/release/crew
+
+# …or launch headless from the shell (creates a worktree, runs the harness)
+./target/release/crew --new "add billing endpoint" --harness cursor
+
+# in the dashboard: [n] launch · [enter] chat · [P] push+PR · [?] keymap
+

+
Standalone by default. crew creates worktrees natively and keeps its own registry under .crew/ — it needs only git and whichever agent CLI you launch. Drop in the optional nexum engine and it also surfaces observed sessions and headless delegation.
+
+ +
+ crew — a terminal dashboard for coding agents · Rust + ratatui +
+
+ + diff --git a/docs/index.md b/docs/index.md index ef966aa..fd86bc1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,3 +8,4 @@ nexum is a Claude Code plugin that cuts context tokens and model cost during you - [Install nexum](install.md) — add the marketplace and enable the plugin in two commands. - [Commands reference](commands.md) — what `/nx-plan`, `/nx-build`, `/nx-audit`, `/nx-status`, `/nx-save`, and `/nx-load` do and when to use them. +- [crew — the agent dashboard](crew/) — a standalone terminal TUI to run, chat with, and manage coding agents across harnesses (screenshots + feature comparison). diff --git a/docs/superpowers/specs/2026-07-04-nexum-opencode-port-design.md b/docs/superpowers/specs/2026-07-04-nexum-opencode-port-design.md new file mode 100644 index 0000000..207acb0 --- /dev/null +++ b/docs/superpowers/specs/2026-07-04-nexum-opencode-port-design.md @@ -0,0 +1,264 @@ +# Nexum → OpenCode Port + +**Date:** 2026-07-04 +**Status:** Design + +Port the nexum Claude Code plugin to OpenCode. Nexum is a context-token and model-cost optimization plugin. The port keeps the existing Python scripts as-is and wires them into OpenCode's plugin, command, and agent system. + +--- + +## 1. Architecture + +The Python scripts in `scripts/` are the engine — they stay entirely unchanged. The port is a **wiring layer**: + +``` +┌─────────────────────────────────────────────┐ +│ OpenCode (.opencode/) │ +│ │ +│ plugins/nexum-hooks.ts ← event → │──── calls ──→ scripts/*.py +│ commands/nx-*.md ← /nx-* │ (via subprocess) +│ agents/nexum-*.md ← @nexum-* │ +│ opencode.json ← config │ +└─────────────────────────────────────────────┘ +``` + +Every Python script reads JSON from stdin and writes JSON to stdout (the same contract they already follow). The TS plugin transforms OpenCode event objects to/from the nexum format. + +**Env var set by plugin (via `shell.env`):** + +| Var | Value | Used by | +|---|---|---| +| `NEXUM_ROOT` | Absolute path to nexum repo root | Commands/agents resolve `scripts/` | + +The plugin sets `NEXUM_ROOT` at startup so commands and agents can find the Python scripts without hardcoding paths. Session ID is passed per-call by the plugin (it has access to `sessionID` from the plugin context). + +`NEXUM_ROOT` is set by resolving `import.meta.dirname` (Bun) or `__dirname` minus the `.opencode/plugins/` suffix — the plugin always knows where it lives relative to the repo root. + +--- + +## 2. Plugin — event wiring (`.opencode/plugins/nexum-hooks.ts`) + +Single TypeScript file that wires Python scripts into OpenCode events. Each handler transforms the OpenCode event shape into nexum's stdin JSON format, calls the script, and applies the result. + +### Events mapped + +| OpenCode Event | Nexum Script | What it does | +|---|---|---| +| `tool.execute.before` | `scan_guard.py` | Block/limit unscoped reads, recursive searches | +| `tool.execute.before` | `predup.py` | Block re-read of already-loaded file content | +| `tool.execute.after` | `dedup.py` | Collapse repeated tool output + truncate oversized | +| `session.created` | `resume_nudge.py` | Nudge user about unfinished handoff | +| `session.created` | `audit_nudge.py` | Periodic ignore-file audit reminder | +| `session.created` | `session_reset.py` | Clear stale tool_calls, throttle retention prune | +| `session.compacted` | `precompact.py` | Clear tool_calls at compaction boundary | + +### Missing Claude Code hooks (no OpenCode equivalent) + +| Claude Hook | Script | Resolution | +|---|---|---| +| `UserPromptSubmit` | `context_watch.py` | **Skipped.** Intent-guard and auto-handoff-write are non-critical. The auto-handoff can run on `session.compacted` instead. | +| `SubagentStop` | `subagent_usage.py` | **Skipped.** OpenCode tracks subagent usage natively. | + +### Plugin contract + +```typescript +// NexumScript Input (from plugin handler, matching what each script expects): +// scan_guard: { tool_name: string, tool_input: object } +// predup: { tool_name: string, tool_input: object, session_id: string } +// dedup: { tool_name: string, tool_response: string|object, session_id: string } +// resume_nudge/audit_nudge/session_reset: { session_id: string } +// precompact: { session_id: string } + +// NexumScript Output (parsed from script stdout): +// scan_guard: { hookSpecificOutput: { permissionDecision?, permissionDecisionReason?, updatedInput? } } +// predup: { hookSpecificOutput: { permissionDecision?, permissionDecisionReason? } } +// dedup: { hookSpecificOutput: { updatedToolOutput? } } +// others: {} (fail-open) +``` + +The plugin handler: +1. Serializes the OpenCode event into the script's expected input shape +2. Spawns `python3 /scripts/