From 84b85f4eb961223de5c76e682879be11114f5023 Mon Sep 17 00:00:00 2001 From: Rahul Tyagi Date: Wed, 17 Jun 2026 00:27:06 +0530 Subject: [PATCH 1/4] Overhaul /nexum-implement request cost, add metered cost capture, fix review findings Task-handling (model dispatch) overhaul: - Add agents/nexum-impl-opus.md so needs-strong step content is delegated to an Opus-tier executor instead of forcing the whole orchestrator onto Opus. - Batch steps by tier into one warm executor dispatch (dispatch_granularity: group) instead of one cold start per step; executors self-run guardrail.py and return its verdict, so the orchestrator skips a round-trip. - Gate the reviewer to escalation / needs-strong / many-file steps; retry ladder is one same-tier patch attempt then escalate. Orchestration no longer assumes Opus. New config: dispatch_granularity, max_same_tier_retries. Metered cost + cache-aware savings: - Snapshot Claude Code's own cost.total_cost_usd into a session_cost table; the cost report prints this cache-accurate total beside the per-tier breakdown. - Weight dedup savings by dedup_cache_weight (repeated reads bill at cache-read rate); record raw + effective tokens. - test_determinism.py / test_metering.py. Review-finding fixes: - truncate.py: drop unreachable hard-cut else branch. - audit.py: drop condition subsumed by the "# nexum" prefix check. - store.py: corruption fallback uses a shared-cache in-memory DB (held open by a module keeper) so separate db() calls share state instead of silently becoming no-ops; drop the no-op foreign_keys pragma. - dedup.py: measure pointer-collapse savings from the recorded shrunk token count (what the model actually saw), not the original output. - scan_guard.py: fix _under_deny normalization (lstrip("./") mangled dot-leading paths like .git); remove the now-redundant raw fallback and dead FLAGS_WITH_VALUE. - context_watch.py: derive task type over sorted words so the intent-guard decision is stable across PYTHONHASHSEED; collapse identical if/else branch. - hooks.json: drop redundant truncate.py PostToolUse hook (dedup re-applies shrink). --- CHANGELOG.md | 9 ++ SPEC.md | 69 ++++++++++---- agents/nexum-impl-haiku.md | 15 ++- agents/nexum-impl-opus.md | 20 ++++ agents/nexum-impl-sonnet.md | 17 +++- agents/nexum-reviewer.md | 6 +- commands/nexum-implement.md | 126 ++++++++++++------------- commands/nexum-plan.md | 2 +- hooks/hooks.json | 5 - scripts/audit.py | 2 +- scripts/context_watch.py | 17 ++-- scripts/cost_report.py | 53 ++++++++++- scripts/dedup.py | 15 ++- scripts/scan_guard.py | 32 +++---- scripts/statusline.py | 28 ++++++ scripts/store.py | 180 ++++++++++++++++++++++++++++++++---- scripts/truncate.py | 8 +- tests/test_determinism.py | 109 ++++++++++++++++++++++ tests/test_metering.py | 61 ++++++++++++ 19 files changed, 623 insertions(+), 151 deletions(-) create mode 100644 agents/nexum-impl-opus.md create mode 100644 tests/test_determinism.py create mode 100644 tests/test_metering.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 44596c7..8066548 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- **Metered cost capture for API-key Claude Code.** The status line now snapshots Claude Code's own `cost.total_cost_usd` and cumulative token counts into a new `session_cost` table (`store.upsert_session_cost`). `cost_report.py` prints this authoritative, cache-accurate total alongside the per-tier breakdown — on API-key billing it matches the invoice, capturing prompt-cache writes/reads that a token-count reconstruction cannot see. +- **Cache-aware savings.** Dedup pointer-collapses are now weighted by `dedup_cache_weight` (default 0.1), because a repeated tool read would bill at the cache-read rate (~0.1×), not full price. `record_saving` records raw + effective tokens; `session_savings` (and the status-line "saved" figure) report the dollar-equivalent effective number. Truncation of fresh output stays at full weight. +- **`tests/test_determinism.py`** — asserts `truncate.shrink` and the dedup hook emit byte-identical output across repeated calls, protecting the auto-cached conversation prefix from invalidation. +- **`agents/nexum-impl-opus.md`** — Opus-tier executor so `needs-strong` step content can be delegated instead of forcing the whole orchestrator onto Opus. +- New config keys: `dispatch_granularity` (`group` | `step`, default `group`), `max_same_tier_retries` (default 1), `dedup_cache_weight` (default 0.1). + +### Changed +- **`/nexum-implement` request-cost overhaul.** Steps are now batched by tier into one warm executor dispatch (`dispatch_granularity: group`) instead of one cold-start dispatch per step; executors run `guardrail.py` themselves and return its verdict (no separate orchestrator round-trip); the reviewer is gated to escalation/`needs-strong`/many-file steps; the retry ladder is 1 same-tier retry (patching the failed diff, not reimplementing) then escalate; and a step whose tier matches the current session model is implemented inline rather than spawned. Orchestration no longer assumes Opus. ## [0.2.1] - 2026-06-15 ### Added diff --git a/SPEC.md b/SPEC.md index 80b888c..11e2853 100644 --- a/SPEC.md +++ b/SPEC.md @@ -148,7 +148,12 @@ CREATE TABLE usage( session_id TEXT, model TEXT, input_tok INTEGER, "scan_deny_paths": ["node_modules", ".git", "dist", "build", "target", "vendor", ".next", "coverage", ".venv", "__pycache__"], "intent_guard_enabled": true, - "intent_similarity_threshold": 0.25 + "intent_similarity_threshold": 0.25, + "statusline_compaction_warn_pct": 80, + "statusline_compaction_warn_tokens": 80000, + "dedup_cache_weight": 0.1, + "dispatch_granularity": "group", + "max_same_tier_retries": 1 } ``` @@ -227,22 +232,38 @@ mechanical refactor, test scaffold, well-specified single-file CRUD, *with a tes standard→Sonnet (default); needs-strong→Opus (architecture, ambiguity, cross-cutting, debugging). -### 4.2 `agents/nexum-impl-haiku.md` (`model: haiku`), `agents/nexum-impl-sonnet.md` (`model: sonnet`), `agents/nexum-reviewer.md` (`model: sonnet`) · TIER: mechanical -Subagent definitions. Haiku/Sonnet executors take ONE self-contained step and -implement it, returning the diff/summary. Reviewer takes a step + the produced diff -and verifies it against `contract`/`scope`/`acceptance`, returning PASS/FAIL + -reasons. Each file: frontmatter (`name`, `description`, `model`) + a body stating: -"You receive one fully-specified step. Implement ONLY it. Do not touch files outside -`scope`. Run the `acceptance` check and report its result." +### 4.2 `agents/nexum-impl-haiku.md` (`model: haiku`), `agents/nexum-impl-sonnet.md` (`model: sonnet`), `agents/nexum-impl-opus.md` (`model: opus`), `agents/nexum-reviewer.md` (`model: sonnet`) · TIER: mechanical +Subagent definitions. The three executors (one per tier) take **one step or a +batch of steps** and implement them in a single warm context. Each executor runs +`guardrail.py` itself as its final action per step and returns the **verbatim +guardrail JSON** per step (orchestrator parses it — no separate guardrail +round-trip). Reviewer takes a step + the produced diff and verifies it against +`contract`/`scope`/`acceptance`, returning PASS/FAIL — invoked **selectively** +(escalation, `needs-strong`, or many-file steps), not after every step. Each +file: frontmatter (`name`, `description`, `model`) + a body stating the +implement-only-listed-steps / stay-in-scope / run-guardrail-and-return-JSON +contract. ### 4.3 `commands/nexum-implement.md` · TIER: standard -Body orchestrates: read the plan file; **group steps by route** (run all -`mechanical` together, then all `standard`) to keep each model's cache warm; -for each step build a delegation that puts **shared context first, the step last** -(stable-prefix-first for caching); dispatch to the matching executor subagent; then -run `guardrail.py`; on FAIL retry same tier ≤2, then escalate haiku→sonnet→opus. -This is prompt-driven (uses the Task/subagent mechanism), referencing the agents in -4.2. +Body orchestrates (cost levers in order of impact): +- **Group by route** (`mechanical`→`standard`→`needs-strong`) to keep each + model's cache warm. +- **Batch by tier** (`dispatch_granularity: group`, default): send a whole route + group to ONE executor dispatch — shared spec/files read once, one cached + prefix — instead of one cold-start dispatch per step. `step` granularity is + the opt-in per-step mode. +- **Skip the spawn when the step's tier == the current session model** — + implement inline rather than spawning, since a subagent only earns its keep by + running a *different* model without trashing the main cache. +- **Cheap orchestrator:** orchestration does not require Opus; only `needs-strong` + *content* is delegated to `nexum-impl-opus`. +- Build each delegation **shared context first, steps last** (stable-prefix-first). +- **Executors self-run the guardrail**; the orchestrator reads the returned JSON + and only spot-checks implausible passes. +- On FAIL: retry same tier up to `max_same_tier_retries` (default 1) **handing the + failed diff back to patch** (not reimplement), then escalate + haiku→sonnet→opus once; never demote a `needs-strong` step. +Prompt-driven (uses the Task/subagent mechanism), referencing the agents in 4.2. ### 4.4 `scripts/guardrail.py` · TIER: standard - CLI: `python3 guardrail.py --acceptance "" --scope-root --changed ` @@ -259,9 +280,21 @@ This is prompt-driven (uses the Task/subagent mechanism), referencing the agents actual $ (PRICING) and an **all-opus baseline** $ (same tokens priced at opus), reports `$ saved`, per-model breakdown, and **token yield** if shipped-token data is recorded (else note "yield needs shipped-token tagging"). Pure read + print. -- Usage data source: prefer OTel if `CLAUDE_CODE_ENABLE_TELEMETRY` is set (document - the metric names but DO NOT build a collector in v1); otherwise rely on rows that - the workflow wrote via `store.add_usage`. v1: just consume `store` rows. +- Usage data sources: (1) per-call `usage` rows written via `store.add_usage` + (tiering breakdown); (2) the **`session_cost` snapshot** written by the + statusLine via `store.upsert_session_cost` — Claude Code's own metered, + cache-accurate total (`cost.total_cost_usd` + cumulative tokens). On API-key + billing the snapshot is the number that matches the invoice; it captures the + prompt-cache writes/reads a token-count reconstruction cannot see. A full OTel + collector remains out of scope — the statusLine snapshot is the reliable + stdlib-only spend signal. +- **Cache-aware savings:** dedup pointer-collapses are weighted by + `dedup_cache_weight` (default 0.1) because a repeated read would bill at the + cache-read rate, not full price; truncation of fresh output stays at full + weight. `record_saving` stores raw + effective tokens; `session_savings` sums + effective. This is a prompt-cache invariant — hooks must rewrite tool output + **deterministically** (see `tests/test_determinism.py`) or they invalidate the + cached prefix and cost more than they save. - **ACCEPTANCE:** with seeded usage rows, prints correct actual vs baseline and savings. diff --git a/agents/nexum-impl-haiku.md b/agents/nexum-impl-haiku.md index cd76bac..fd3dbb9 100644 --- a/agents/nexum-impl-haiku.md +++ b/agents/nexum-impl-haiku.md @@ -4,4 +4,17 @@ description: Implementation executor for Haiku-tier (mechanical) nexum steps model: haiku --- -You receive one fully-specified step from the nexum plan. Implement ONLY it. Do not touch files outside the `scope` listed in the step. Run the `acceptance` check and report its result. +You are a nexum executor running on Haiku 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 — do not hand verification back to the orchestrator: + +``` +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/guardrail.py \ + --acceptance "" \ + --scope-root \ + --changed +``` + +Return, **per step**: the step index, a one-line summary of changes, the files touched, and the **verbatim guardrail JSON** (`{"pass": ..., "acceptance_rc": ..., "scope_violations": [...], "log": "..."}`). Do not paraphrase the guardrail output — the orchestrator parses it directly. If a step fails its guardrail, report the failure and continue to the next independent step (the orchestrator decides on retry/escalation). diff --git a/agents/nexum-impl-opus.md b/agents/nexum-impl-opus.md new file mode 100644 index 0000000..f76ac4c --- /dev/null +++ b/agents/nexum-impl-opus.md @@ -0,0 +1,20 @@ +--- +name: nexum-impl-opus +description: Implementation executor for needs-strong nexum steps (architecture, ambiguity, cross-cutting, debugging) +model: opus +--- + +You are a nexum executor running on Opus 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 and report the result. Concretely, run: + +``` +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/guardrail.py \ + --acceptance "" \ + --scope-root \ + --changed +``` + +Return, for each step: a brief summary of changes made, the list of files touched, and the **verbatim guardrail JSON** (`{"pass": ..., "acceptance_rc": ..., "scope_violations": [...], "log": "..."}`). Do not paraphrase the guardrail output — the orchestrator parses it directly. diff --git a/agents/nexum-impl-sonnet.md b/agents/nexum-impl-sonnet.md index d2855e1..f56081d 100644 --- a/agents/nexum-impl-sonnet.md +++ b/agents/nexum-impl-sonnet.md @@ -1,7 +1,20 @@ --- name: nexum-impl-sonnet -description: Implementation executor for standard and strong nexum steps +description: Implementation executor for standard nexum steps model: sonnet --- -You receive one fully-specified step from the nexum plan. Implement ONLY it. Do not touch files outside the `scope` listed in the step. Run the `acceptance` check and report its result. +You are a nexum executor running on Sonnet 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 — do not hand verification back to the orchestrator: + +``` +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/guardrail.py \ + --acceptance "" \ + --scope-root \ + --changed +``` + +Return, **per step**: the step index, a one-line summary of changes, the files touched, and the **verbatim guardrail JSON** (`{"pass": ..., "acceptance_rc": ..., "scope_violations": [...], "log": "..."}`). Do not paraphrase the guardrail output — the orchestrator parses it directly. If a step fails its guardrail, report the failure and continue to the next independent step (the orchestrator decides on retry/escalation). diff --git a/agents/nexum-reviewer.md b/agents/nexum-reviewer.md index a88b1f4..0c06c79 100644 --- a/agents/nexum-reviewer.md +++ b/agents/nexum-reviewer.md @@ -1,7 +1,9 @@ --- name: nexum-reviewer -description: Reviewer for completed nexum step implementations +description: Reviewer for completed nexum step implementations (escalation / high-risk only) model: sonnet --- -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 PASS or FAIL with reasons. +You are the nexum reviewer. You are invoked **selectively**, not after every step — the guardrail (acceptance command + scope check) is the routine gate, so a step that passes its guardrail normally does not need you. The orchestrator calls you only for: steps that failed and were escalated, `needs-strong` steps, or steps that touched many files. + +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 PASS or FAIL with concise reasons. diff --git a/commands/nexum-implement.md b/commands/nexum-implement.md index 0dd8fa4..284ee4b 100644 --- a/commands/nexum-implement.md +++ b/commands/nexum-implement.md @@ -1,8 +1,10 @@ --- -description: "Execute a nexum plan file by dispatching each step to the appropriate model tier, verifying acceptance, and escalating on failure." +description: "Execute a nexum plan file by dispatching steps to the cheapest capable model tier, verifying acceptance, and escalating on failure." --- -You are the nexum implementer. You read a plan file produced by `/nexum-plan`, execute each step by delegating to the correct subagent, run the guardrail check after each step, and handle retries and escalation. You do not write code yourself — you orchestrate. +You are the nexum implementer. You read a plan file produced by `/nexum-plan`, execute its steps by delegating to the cheapest capable model tier, and handle retries and escalation. You orchestrate; you do not write step code yourself (except inline, see §4). + +**Orchestration is mechanical** — parsing the plan, dispatching, reading verdicts, and branching do **not** require Opus. Do not assume this command runs on Opus; only `needs-strong` *step content* needs Opus, and that is delegated to a subagent (§4). Keeping the driver cheap is a standing saving on every run. ## 1. Locate and read the plan file @@ -12,96 +14,95 @@ Read the plan file in full. Parse out every step: its index, `route`, `files`, ` If the plan file does not exist, stop and tell the user: `[nexum] No plan found for this session. Run /nexum-plan first.` -## 2. Group steps by route for cache efficiency +Read the effective config once: `python3 ${CLAUDE_PLUGIN_ROOT}/scripts/store.py config`. The keys that drive this command are `dispatch_granularity` (`group` | `step`), `max_same_tier_retries` (default 1), and the model tiers. + +## 2. Group steps by route + +Partition steps into three ordered groups and execute them in this order: + +1. **mechanical** (Haiku tier) +2. **standard** (Sonnet tier) +3. **needs-strong** (Opus tier) + +Grouping keeps each model's prompt prefix stable, maximising cache hits. + +## 3. Dispatch granularity — batch by tier (default) vs per-step + +**This is the main cost lever.** Read `dispatch_granularity` from config: + +- **`group` (default):** Send the *entire* route group to **one** executor dispatch. The executor reads the shared spec and source files once and reuses them across every step in the group — one warm context, one cached prefix, instead of N cold starts that each re-derive the same context. The executor returns a per-step result list. +- **`step`:** Send one dispatch per step. More isolation (a mid-batch failure can't affect siblings), but pays a cold start and re-reads context for every step. Use only when steps in a tier are large or risky enough that isolation outweighs the re-derivation cost. + +## 4. Skip the spawn when the tier already matches the session model -Before dispatching anything, partition steps into three ordered groups: +A subagent exists to run a step on a **different** model than the main session without invalidating the main conversation's prompt cache. If a step's tier is the **same** model you (the orchestrator) are already running, spawning buys nothing — it only adds a cold start. In that case, implement the step(s) **inline** in this conversation instead of dispatching. -1. **mechanical** steps (all of them, in plan order) -2. **standard** steps (all of them, in plan order) -3. **needs-strong** steps (all of them, in plan order) +Determine the current session model from context (e.g. the model shown in the status line). Then: -Execute groups in that order. Within each group, execute steps sequentially (one at a time, wait for completion and guardrail before proceeding to the next). Grouping keeps each model's prompt prefix stable across steps, which maximises cache hit rate and reduces cost. +| Step route | If session model ≠ tier | If session model = tier | +|---|---|---| +| `mechanical` | dispatch `nexum-impl-haiku` | implement inline | +| `standard` | dispatch `nexum-impl-sonnet` | implement inline | +| `needs-strong` | dispatch `nexum-impl-opus` | implement inline | -## 3. Build each delegation (stable-prefix-first for caching) +When in doubt about the session model, dispatch — a redundant spawn is cheaper than a cache-trashing model switch. -For each step, construct the subagent prompt in this order — **shared/stable content first, variable content last** — so the longest common prefix is cacheable: +## 5. Build each delegation (stable-prefix-first for caching) + +For a dispatched group (or step), construct the prompt **shared/stable content first, variable content last**, so the longest common prefix is cacheable: ``` -[SHARED CONTEXT — same for all steps in this group] -You are a nexum executor. Implement exactly one step of a plan. +[SHARED CONTEXT — identical for every step in this group] +You are a nexum executor. Implement the step(s) below, in order, in this one context. Global constraints (apply to every step): -- Python 3.9+ stdlib only. No pip installs. No third-party libraries. -- All scripts must fail-open: wrap everything in try/except; on any internal error print `{}` to stdout and exit 0. -- Use json.dumps(obj, sort_keys=True) for any JSON you emit. -- Do not touch files outside the step's declared scope. -- After implementing, run the acceptance command and report its exit code and output. +- +- Fail-open where the plan requires it; emit deterministic JSON where required. +- Do not touch files outside each step's declared scope. +- After each step, run guardrail.py for that step and return its verbatim JSON. -[STEP-SPECIFIC CONTENT — goes last] +[STEP-SPECIFIC CONTENT — all steps in this group, each copied verbatim] ### Step : - files: <...> - objective: <...> - contract: <...> - scope: do NOT touch <...> - acceptance: <...> - -Implement this step now. Touch only the listed files. Run the acceptance check. Return: a brief summary of changes made, the acceptance command you ran, its exit code, and its stdout/stderr tail (last 20 lines). ``` -Do not summarise or compress the step fields — copy them verbatim from the plan so the executor sees the full specification. - -## 4. Dispatch to the matching subagent - -| route | subagent | -|---|---| -| `mechanical` | `nexum-impl-haiku` (model: haiku) | -| `standard` | `nexum-impl-sonnet` (model: sonnet) | -| `needs-strong` | execute inline on this (Opus) conversation — do not delegate | - -Use the Task/subagent dispatch mechanism to invoke `nexum-impl-haiku` or `nexum-impl-sonnet` with the prompt built in step 3. For `needs-strong` steps, execute them directly without delegating. +Copy the step fields verbatim from the plan — do not summarise or compress them. -## 5. Run the guardrail after each step - -After the subagent returns, run: - -``` -python3 ${CLAUDE_PLUGIN_ROOT}/scripts/guardrail.py \ - --acceptance "<acceptance command from step>" \ - --scope-root <repo root> \ - --changed <comma-separated list of files the subagent reported touching> -``` +## 6. The executor runs the guardrail; you read the verdict -Parse the JSON output `{"pass": bool, "acceptance_rc": int, "scope_violations": [...], "log": "..."}`. +Executors run `guardrail.py` themselves as their final action per step and return the **verbatim JSON** (`{"pass": bool, "acceptance_rc": int, "scope_violations": [...], "log": "..."}`). Do **not** re-run the guardrail from the orchestrator for passing steps — that is a redundant round-trip. `guardrail.py` is deterministic, so the returned JSON is trustworthy. -**If `pass` is true:** proceed to the next step. Record usage via `python3 ${CLAUDE_PLUGIN_ROOT}/scripts/store.py` if usage data is available from the subagent response. +For each returned step verdict: -**If `pass` is false:** follow the retry/escalation ladder below. +- **`pass` is true:** proceed. (Spot-check by re-running the guardrail yourself only if a verdict looks implausible — e.g. claims pass on a step it also reports it could not complete.) +- **`pass` is false:** follow the retry/escalation ladder in §7. -## 6. Retry and escalation ladder +## 7. Retry and escalation ladder -On guardrail FAIL: +On a FAIL: -1. **Retry same tier, attempt 2:** re-dispatch the same step to the same subagent with the guardrail failure appended to the prompt: include `acceptance_rc`, `scope_violations`, and the `log` tail so the executor can self-correct. Wait for result; re-run guardrail. +1. **Retry same tier, up to `max_same_tier_retries` (default 1).** Re-dispatch the *failing step only* to the same tier, appending the guardrail failure (`acceptance_rc`, `scope_violations`, `log` tail) **and the diff the previous attempt produced**, instructing the executor to *patch* that diff rather than reimplement from the spec. Patching is fewer tokens and lands first-try more often. -2. **Retry same tier, attempt 3:** repeat once more with the updated failure context. +2. **Escalate one tier and retry once** (haiku → sonnet → opus). Hand the higher tier the failed diff plus the guardrail output, again instructing it to patch. For an escalated/`needs-strong` step, also invoke `nexum-reviewer` on the produced diff (see §8). -3. **Escalate one tier** (haiku → sonnet → opus) and retry once: - - haiku step that keeps failing → dispatch to `nexum-impl-sonnet` - - sonnet step that keeps failing → execute inline on Opus - - needs-strong (already Opus) → stop, report failure to user with full context +3. **If the escalated attempt also fails** (or a `needs-strong` step on Opus keeps failing): stop that step, report the full guardrail output to the user, and ask whether to skip or abort. Never silently continue past a failing step. Never delegate a `needs-strong` step to a weaker model. -4. **If escalated attempt also fails:** stop this step, report to the user with the full guardrail output, and ask whether to skip or abort. Do not silently continue past a failing step. +## 8. Gate the reviewer -Append to the step prompt on each retry: `Previous attempt failed. Guardrail output: <json>. Fix the issue and re-implement.` +The guardrail (acceptance + scope) is the routine review, so a step that passes its guardrail does **not** get a separate reviewer pass. Invoke `nexum-reviewer` only for: steps that failed and were escalated, `needs-strong` steps, or steps that touched many files. This avoids doubling requests on the common path. -## 7. Progress reporting +## 9. Progress reporting -After each step completes successfully, print a one-line status to the user: -`[nexum] Step <N> done (<route>, <subagent or inline>) — acceptance passed.` +After each step (or group) succeeds, print one line: +`[nexum] Step <N> done (<route>, <inline | haiku | sonnet | opus>) — acceptance passed.` -On escalation, print: +On escalation: `[nexum] Step <N> escalated from <old tier> to <new tier> after <N> failures.` -## 8. Cost summary +## 10. Cost summary After all steps complete (or after an abort), run: @@ -109,11 +110,10 @@ After all steps complete (or after an abort), run: python3 ${CLAUDE_PLUGIN_ROOT}/scripts/cost_report.py --session <session_id> ``` -Print the output so the user can see actual cost vs. all-Opus baseline and the savings achieved by tier routing. +Print the output so the user sees actual cost vs. all-opus baseline (tiering breakdown) and the metered, cache-accurate total captured by the status line. -## 9. Constraints +## 11. Constraints -- Never skip the guardrail. Every step must pass `guardrail.py` before the next step begins. +- Never skip the guardrail — every step must pass it (run by the executor, or inline) before the next begins. - Never modify the plan file during execution. -- Never delegate a `needs-strong` step to a weaker model, even if it keeps failing — escalate the conversation to the user instead. - Keep the shared-context prefix identical across all steps within a route group so the cache prefix is maximally stable. diff --git a/commands/nexum-plan.md b/commands/nexum-plan.md index d777bb7..dfda9b3 100644 --- a/commands/nexum-plan.md +++ b/commands/nexum-plan.md @@ -26,7 +26,7 @@ Assign every step exactly one route. Use the cheapest tier that is sufficient: - **standard** — dispatch to Sonnet. This is the default for most implementation work: multi-file changes, logic that requires some reasoning, anything not clearly mechanical and not requiring deep architectural thought. -- **needs-strong** — stay on Opus (main model). Reserve for: architecture decisions, cross-cutting concerns that span many files, ambiguous requirements needing interpretation, complex debugging across layers. +- **needs-strong** — route to the Opus tier (`nexum-impl-opus`, or implemented inline when the session is already on Opus). Reserve for: architecture decisions, cross-cutting concerns that span many files, ambiguous requirements needing interpretation, complex debugging across layers. When in doubt, prefer **standard** over **mechanical** — a false mechanical that fails wastes more time than a conservative standard. diff --git a/hooks/hooks.json b/hooks/hooks.json index 4da9607..590a266 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -3,11 +3,6 @@ "PostToolUse": [ { "hooks": [ - { - "command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/truncate.py", - "timeout": 10, - "type": "command" - }, { "command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/dedup.py", "timeout": 10, diff --git a/scripts/audit.py b/scripts/audit.py index 652cd5e..7447d0d 100644 --- a/scripts/audit.py +++ b/scripts/audit.py @@ -357,7 +357,7 @@ def _write_ignore(ignore_path: str, new_patterns: List[str]) -> Tuple[List[str], if in_nexum: # The block ends at the next blank line or a new comment section # that is NOT a nexum-added pattern or sub-comment. - if stripped.startswith("#") and stripped != _NEXUM_MARKER and not stripped.startswith("# nexum"): + if stripped.startswith("#") and not stripped.startswith("# nexum"): nexum_block_end = i in_nexum = False elif stripped == "": diff --git a/scripts/context_watch.py b/scripts/context_watch.py index 450ee53..5b10df9 100644 --- a/scripts/context_watch.py +++ b/scripts/context_watch.py @@ -105,8 +105,15 @@ # --------------------------------------------------------------------------- def _derive_task_type(words: set) -> str | None: - """Return the canonical task type found in *words*, or None.""" - for word in words: + """Return the canonical task type found in *words*, or None. + + Words are scanned in sorted order so the result is deterministic across + process runs. Iterating a set directly would follow hash order, which is + randomized per-process (PYTHONHASHSEED); for a prompt mentioning more than + one task type that made the derived type — and therefore the intent-guard + block/allow decision — flip between otherwise-identical runs. + """ + for word in sorted(words): # Try exact match first, then prefix match for stemmed forms if word in _TASK_TYPE_MAP: return _TASK_TYPE_MAP[word] @@ -315,11 +322,7 @@ def _handle(data: dict) -> dict: store.set_session_task(session_id, _pack_sig(new_keywords, new_task_type)) else: # Guard disabled: just update the task signature - raw_task = store.get_session_task(session_id) - if raw_task is None: - store.set_session_task(session_id, _pack_sig(new_keywords, new_task_type)) - else: - store.set_session_task(session_id, _pack_sig(new_keywords, new_task_type)) + store.set_session_task(session_id, _pack_sig(new_keywords, new_task_type)) # Allow — possibly with a compaction systemMessage if system_message: diff --git a/scripts/cost_report.py b/scripts/cost_report.py index 722c760..2b87179 100644 --- a/scripts/cost_report.py +++ b/scripts/cost_report.py @@ -15,11 +15,17 @@ output cost = output_tok / 1_000_000 * price_out cache_read = cache_read_tok / 1_000_000 * price_in * 0.1 -v1 data source: store usage rows only. -If CLAUDE_CODE_ENABLE_TELEMETRY is set, usage data may also be available -via OTel metrics (nexum.input_tokens, nexum.output_tokens, nexum.cache_read_tokens) -— but in v1 we do not build a collector; set that env var and instrument -store.add_usage() at call sites to record data from the OTel stream. +Two data sources: + 1. usage rows (store.add_usage) — per-call tiering breakdown, actual vs + all-opus baseline. Populated by the implement workflow when token data is + available. + 2. session_cost snapshot (store.upsert_session_cost) — Claude Code's own + metered, cache-accurate total, captured by the nexum statusLine every + render. On API-key billing this matches the invoice; it reflects prompt- + cache writes/reads that a token-count reconstruction cannot see. + +A full OTel collector is still out of scope; the statusLine snapshot is the +reliable, stdlib-only way to observe real API spend. """ import argparse @@ -145,6 +151,40 @@ def build_report(rows: list) -> str: return "\n".join(lines) +def build_metered_section(cost_rows: list) -> str: + """Render Claude Code's own metered, cache-accurate cost snapshot. + + These rows come from the statusLine capture (store.upsert_session_cost) and + reflect the authoritative bill Claude Code computed — including prompt-cache + economics — rather than a reconstruction from token counts. On API-key + billing this is the number that matches the invoice. + """ + if not cost_rows: + return ( + "[nexum] No metered cost captured yet. The session-cost snapshot is " + "recorded by the nexum statusLine — install it with /nexum-statusline " + "and run at least one turn." + ) + + total = sum(float(r.get("cost_usd") or 0.0) for r in cost_rows) + lines = [] + lines.append("Metered cost (Claude Code authoritative, cache-accurate):") + lines.append(f" {'Session':<24} {'Model':<16} {'Input':>12} {'Output':>12} " + f"{'Cache-R':>12} {'Cost $':>10}") + lines.append(" " + "-" * 90) + for r in sorted(cost_rows, key=lambda x: x.get("updated_ts") or 0): + sid = (r.get("session_id") or "?")[:24] + model = (r.get("model") or "?")[:16] + lines.append( + f" {sid:<24} {model:<16} {r.get('input_tok', 0):>12,} " + f"{r.get('output_tok', 0):>12,} {r.get('cache_read_tok', 0):>12,} " + f"${float(r.get('cost_usd') or 0.0):>9.4f}" + ) + lines.append(" " + "-" * 90) + lines.append(f" {'TOTAL':<24} {'':<16} {'':>12} {'':>12} {'':>12} ${total:>9.4f}") + return "\n".join(lines) + + # --------------------------------------------------------------------------- # CLI entry point # --------------------------------------------------------------------------- @@ -164,6 +204,9 @@ def main() -> None: rows = store.usage_rows(session_id=args.session) print(build_report(rows)) + print() + cost_rows = store.session_cost_rows(session_id=args.session) + print(build_metered_section(cost_rows)) if __name__ == "__main__": diff --git a/scripts/dedup.py b/scripts/dedup.py index 3a5eae7..b1ba41c 100644 --- a/scripts/dedup.py +++ b/scripts/dedup.py @@ -125,9 +125,20 @@ def main() -> None: f"(hash {h[:8]}) — omitted to save context." ) try: - saved = store.estimate_tokens(output) - store.estimate_tokens(pointer) + # The model only ever saw the *shrunk* first occurrence (its + # token count was recorded then), so the context actually avoided + # is shrunk_tokens - pointer_tokens, not original - pointer. + prior_tok = existing.get("token_count") + if not isinstance(prior_tok, int) or prior_tok <= 0: + prior_tok = store.estimate_tokens(output) + saved = prior_tok - store.estimate_tokens(pointer) if saved > 0: - store.record_saving(session_id, "dedup", saved) + # A repeated read would bill at the cache-read rate, not full + # price — weight the dollar-equivalent saving accordingly so + # the reported savings reflect actual API spend. + weight = float(cfg.get("dedup_cache_weight", 0.1)) + effective = max(0, round(saved * weight)) + store.record_saving(session_id, "dedup", saved, effective) except Exception: pass response = { diff --git a/scripts/scan_guard.py b/scripts/scan_guard.py index 47eb533..a3d35aa 100644 --- a/scripts/scan_guard.py +++ b/scripts/scan_guard.py @@ -69,17 +69,21 @@ def _allow() -> None: def _under_deny(path: str, deny_paths: list) -> bool: """Return True if *path* starts with any deny entry (by path component).""" - # Normalise: strip leading ./ and / - p = path.lstrip("./").lstrip("/") + # Normalise: strip a leading "./" prefix, then any leading slashes. + # NOTE: use slicing, not lstrip("./") — lstrip strips a *character set* and + # would mangle dot-leading paths (".git" -> "git", so ".git/x" misses the + # ".git" deny entry). + p = path + if p.startswith("./"): + p = p[2:] + p = p.lstrip("/") for entry in deny_paths: entry = entry.strip("/") - # Match if path starts with the deny entry followed by / or equals it + if not entry: + continue + # Match if path equals the deny entry or starts with it as a component. if p == entry or p.startswith(entry + "/") or p.startswith(entry + os.sep): return True - # Also match if the raw path (after stripping leading /) starts with entry - raw = path.lstrip("/") - if raw == entry or raw.startswith(entry + "/") or raw.startswith(entry + os.sep): - return True return False @@ -137,19 +141,7 @@ def _grep_path_args(cmd: str) -> list: # Consume flags and their option-arguments; collect non-flag tokens. non_flags = [] i = 0 - # flags that consume a following argument value - FLAGS_WITH_VALUE = { - "-e", "--regexp", "-f", "--file", - "-m", "--max-count", - "-A", "--after-context", - "-B", "--before-context", - "-C", "--context", - "--color", "--colour", - "--include", "--exclude", - "--exclude-dir", - "-l", "--files-with-matches", # these don't consume a value - } - # actually only these genuinely consume a next token: + # flags that genuinely consume a following argument value FLAGS_CONSUMING_NEXT = {"-e", "--regexp", "-f", "--file", "-m", "--max-count", "-A", "--after-context", diff --git a/scripts/statusline.py b/scripts/statusline.py index 39c28d8..93826eb 100644 --- a/scripts/statusline.py +++ b/scripts/statusline.py @@ -98,6 +98,7 @@ def main() -> None: saved = store.session_savings(session_id) warn_pct = int(store.get_config().get("statusline_compaction_warn_pct", 80)) warn_tokens = int(store.get_config().get("statusline_compaction_warn_tokens", 80000)) + _capture_session_cost(store, session_id, data) except Exception: saved = 0 warn_pct = 80 @@ -106,5 +107,32 @@ def main() -> None: print(render(data, saved, warn_pct, warn_tokens)) +def _capture_session_cost(store, session_id: str, data: dict) -> None: + """Snapshot Claude Code's own metered cost/usage for this session. + + `cost.total_cost_usd` is the authoritative bill (cache-accurate); the + context_window token counts are cumulative. Cache token fields are read + opportunistically — captured if Claude Code exposes them, ignored if not. + Fail-open: a missing field or store error must never affect the status line. + """ + try: + cost = data.get("cost") or {} + cw = data.get("context_window") or {} + model = (data.get("model") or {}).get("display_name") or "?" + store.upsert_session_cost( + session_id=session_id, + model=model, + cost_usd=cost.get("total_cost_usd") or 0.0, + input_tok=cw.get("total_input_tokens") or 0, + output_tok=cw.get("total_output_tokens") or 0, + cache_read_tok=cw.get("cache_read_input_tokens") + or cw.get("total_cache_read_tokens") or 0, + cache_creation_tok=cw.get("cache_creation_input_tokens") + or cw.get("total_cache_creation_tokens") or 0, + ) + except Exception: + pass + + if __name__ == "__main__": main() diff --git a/scripts/store.py b/scripts/store.py index 0bb99f4..b99c5d3 100644 --- a/scripts/store.py +++ b/scripts/store.py @@ -8,7 +8,8 @@ - Utility helpers: sha256, estimate_tokens - Dedup / memo helpers: seen_output, record_output, memo_get, memo_put - Session KV (flags + task): get_flag, set_flag, get_session_task, set_session_task -- Metrics: add_usage, usage_rows +- Metrics: add_usage, usage_rows, record_saving, session_savings, + upsert_session_cost, session_cost_rows CLI: python3 store.py init — create the database and schema @@ -54,6 +55,18 @@ "intent_similarity_threshold": 0.25, "statusline_compaction_warn_pct": 80, "statusline_compaction_warn_tokens": 80000, + # Dollar-weight applied to dedup (pointer-collapse) savings. A repeated tool + # output would, under Claude Code's automatic prompt caching, bill at the + # cache-read rate (~0.1x input) rather than full price — so collapsing it + # saves ~0.1x of its tokens in dollar terms, not 1x. Truncation of fresh + # (never-cached) output is weighted 1.0. Tunable for non-cached setups. + "dedup_cache_weight": 0.1, + # /nexum-implement dispatch granularity: "group" sends a whole route-tier + # of steps to ONE executor dispatch (warm context, one cached prefix); + # "step" sends one dispatch per step (more isolation, more cold starts). + "dispatch_granularity": "group", + # Same-tier retries before escalating a failing step one tier up. + "max_same_tier_retries": 1, } # --------------------------------------------------------------------------- @@ -98,14 +111,34 @@ """, """ CREATE TABLE IF NOT EXISTS savings( - session_id TEXT, - source TEXT, - saved_tok INTEGER, - ts REAL + session_id TEXT, + source TEXT, + saved_tok INTEGER, + effective_tok INTEGER, + ts REAL + ) + """, + """ + CREATE TABLE IF NOT EXISTS session_cost( + session_id TEXT PRIMARY KEY, + model TEXT, + cost_usd REAL, + input_tok INTEGER, + output_tok INTEGER, + cache_read_tok INTEGER, + cache_creation_tok INTEGER, + updated_ts REAL ) """, ] +# Column migrations for databases created by an earlier schema version. +# Each entry: (table, column, column_def). ALTER is wrapped so a column that +# already exists (or any other error) never breaks db(). +_MIGRATIONS = [ + ("savings", "effective_tok", "INTEGER"), +] + # --------------------------------------------------------------------------- # nexum_data_dir @@ -138,10 +171,16 @@ def nexum_data_dir() -> str: # --------------------------------------------------------------------------- def _apply_schema(conn: sqlite3.Connection) -> None: - """Create tables if they don't exist.""" + """Create tables if they don't exist, then apply additive column migrations.""" with conn: for ddl in _DDL: conn.execute(ddl) + for table, column, coldef in _MIGRATIONS: + try: + conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {coldef}") + except sqlite3.OperationalError: + # Column already present — expected on an already-migrated db. + pass def _open_db(db_path: str) -> sqlite3.Connection: @@ -149,11 +188,32 @@ def _open_db(db_path: str) -> sqlite3.Connection: conn = sqlite3.connect(db_path, timeout=5, check_same_thread=False) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") - conn.execute("PRAGMA foreign_keys=ON") _apply_schema(conn) return conn +# A process-wide shared-cache in-memory database used as the corruption +# fallback. A module-level "keeper" connection stays open for the life of the +# process so the shared cache survives even though every caller closes its own +# handle — making the fallback a single SHARED store rather than a fresh +# (stateless) DB per db() call. +_MEMORY_KEEPER: Optional[sqlite3.Connection] = None +_MEMORY_URI = "file:nexum-fallback?mode=memory&cache=shared" + + +def _memory_db() -> sqlite3.Connection: + """Return a fresh handle to the process-wide shared in-memory database.""" + global _MEMORY_KEEPER + if _MEMORY_KEEPER is None: + keeper = sqlite3.connect(_MEMORY_URI, uri=True, check_same_thread=False) + keeper.row_factory = sqlite3.Row + _apply_schema(keeper) + _MEMORY_KEEPER = keeper + conn = sqlite3.connect(_MEMORY_URI, uri=True, check_same_thread=False) + conn.row_factory = sqlite3.Row + return conn + + def db() -> sqlite3.Connection: """Open nexum.db (WAL mode). Retry once on OperationalError. @@ -173,12 +233,11 @@ def db() -> sqlite3.Connection: except Exception: break - # Fall back to in-memory so callers never raise + # Fall back to a SHARED in-memory DB so callers never raise AND still see + # each other's writes within the process (a private :memory: per call would + # silently turn dedup / session-KV into no-ops). try: - conn = sqlite3.connect(":memory:", check_same_thread=False) - conn.row_factory = sqlite3.Row - _apply_schema(conn) - return conn + return _memory_db() except Exception: # Absolute last resort — return a bare in-memory connection return sqlite3.connect(":memory:", check_same_thread=False) @@ -379,15 +438,29 @@ def usage_rows(session_id: Optional[str] = None) -> List[Dict[str, Any]]: return [] -def record_saving(session_id: str, source: str, saved_tok: int) -> None: - """Append a savings row (tokens saved by a context-reduction source).""" +def record_saving( + session_id: str, + source: str, + saved_tok: int, + effective_tok: Optional[int] = None, +) -> None: + """Append a savings row. + + ``saved_tok`` is the raw token count removed from the tool output. + ``effective_tok`` is the dollar-equivalent saving after accounting for + prompt-cache economics (a deduped re-read would have billed at the + cache-read rate, not full price). When omitted it defaults to ``saved_tok`` + (full weight) so direct callers and full-price truncations are unchanged. + """ + if effective_tok is None: + effective_tok = saved_tok try: conn = db() with conn: conn.execute( - "INSERT INTO savings(session_id, source, saved_tok, ts) " - "VALUES (?, ?, ?, ?)", - (session_id, source, saved_tok, time.time()), + "INSERT INTO savings(session_id, source, saved_tok, effective_tok, ts) " + "VALUES (?, ?, ?, ?, ?)", + (session_id, source, saved_tok, effective_tok, time.time()), ) conn.close() except Exception: @@ -395,11 +468,16 @@ def record_saving(session_id: str, source: str, saved_tok: int) -> None: def session_savings(session_id: str) -> int: - """Return the total tokens saved for this session.""" + """Return the cache-adjusted (dollar-equivalent) tokens saved this session. + + Sums ``effective_tok``, falling back to ``saved_tok`` for legacy rows + written before the cache-weight column existed. + """ try: conn = db() row = conn.execute( - "SELECT COALESCE(SUM(saved_tok),0) FROM savings WHERE session_id=?", + "SELECT COALESCE(SUM(COALESCE(effective_tok, saved_tok)),0) " + "FROM savings WHERE session_id=?", (session_id,), ).fetchone() conn.close() @@ -410,6 +488,70 @@ def session_savings(session_id: str) -> int: return 0 +# --------------------------------------------------------------------------- +# Session cost snapshot — Claude Code's own metered, cache-accurate totals. +# +# Claude Code's statusLine hook receives a per-session JSON that already carries +# `cost.total_cost_usd` (the authoritative metered bill, which internally +# reflects cache writes/reads) plus cumulative token counts. Those values are +# CUMULATIVE, so we UPSERT a single row per session rather than appending — +# this is the one reliable place a stdlib hook can observe real API spend +# without standing up an OTel collector. +# --------------------------------------------------------------------------- + +def upsert_session_cost( + session_id: str, + model: str, + cost_usd: float, + input_tok: int = 0, + output_tok: int = 0, + cache_read_tok: int = 0, + cache_creation_tok: int = 0, +) -> None: + """Insert or replace the latest cumulative cost snapshot for a session.""" + try: + conn = db() + with conn: + conn.execute( + "INSERT OR REPLACE INTO session_cost(" + "session_id, model, cost_usd, input_tok, output_tok, " + "cache_read_tok, cache_creation_tok, updated_ts) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + session_id, model, float(cost_usd or 0.0), + int(input_tok or 0), int(output_tok or 0), + int(cache_read_tok or 0), int(cache_creation_tok or 0), + time.time(), + ), + ) + conn.close() + except Exception: + pass + + +def session_cost_rows(session_id: Optional[str] = None) -> List[Dict[str, Any]]: + """Return session_cost snapshot rows (optionally filtered by session_id).""" + cols = ( + "session_id, model, cost_usd, input_tok, output_tok, " + "cache_read_tok, cache_creation_tok, updated_ts" + ) + try: + conn = db() + if session_id is not None: + rows = conn.execute( + f"SELECT {cols} FROM session_cost WHERE session_id=? ORDER BY updated_ts", + (session_id,), + ).fetchall() + else: + rows = conn.execute( + f"SELECT {cols} FROM session_cost ORDER BY updated_ts" + ).fetchall() + conn.close() + return [dict(r) for r in rows] + except Exception: + return [] + + # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- diff --git a/scripts/truncate.py b/scripts/truncate.py index 2f4d2e9..0302bc2 100644 --- a/scripts/truncate.py +++ b/scripts/truncate.py @@ -93,11 +93,9 @@ def shrink(text: str, cfg: dict) -> tuple[str, bool]: # If no newlines (binary/single line) and huge, hard-cut by chars if len(lines) <= 1 and len(text) > 10000: # Hard-cut: first 5000 + last 5000 chars - if len(text) > 10000: - first = text[:5000] - last = text[-5000:] - return f"{first}\n... [nexum] omitted middle ...\n{last}", True - return (text, False) + first = text[:5000] + last = text[-5000:] + return f"{first}\n... [nexum] omitted middle ...\n{last}", True # Check threshold if len(lines) < min_lines_to_act: diff --git a/tests/test_determinism.py b/tests/test_determinism.py new file mode 100644 index 0000000..983f811 --- /dev/null +++ b/tests/test_determinism.py @@ -0,0 +1,109 @@ +""" +test_determinism.py — guards the prompt-cache prefix invariant. + +On API-key Claude Code, the conversation prefix is auto-cached. nexum rewrites +tool output at PostToolUse; that rewrite must be BYTE-DETERMINISTIC, or the +modified output differs between the turn it is produced and later turns, +invalidating the cache prefix from that point on — which costs far more than +any truncation saves. These tests assert that truncate.shrink and the dedup +hook emit identical bytes for identical input across repeated invocations. +""" + +import json +import os +import subprocess +import sys +import tempfile +import unittest + +_SCRIPTS_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts" +) +if _SCRIPTS_DIR not in sys.path: + sys.path.insert(0, _SCRIPTS_DIR) + +import truncate # noqa: E402 +import store # noqa: E402 + +# Large enough to trip shrink (> truncate_min_lines_to_act default 240) and to +# include an error line that the keep-regex must retain deterministically. +_BIG_LINES = [f"line {i}: payload content {i * 7}" for i in range(400)] +_BIG_LINES[123] = "ERROR: something failed at step 123" +_BIG_TEXT = "\n".join(_BIG_LINES) + + +def _run_dedup(payload, data_dir): + env = os.environ.copy() + env["CLAUDE_PLUGIN_DATA"] = data_dir + env["PYTHONPATH"] = _SCRIPTS_DIR + os.pathsep + env.get("PYTHONPATH", "") + result = subprocess.run( + [sys.executable, os.path.join(_SCRIPTS_DIR, "dedup.py")], + input=json.dumps(payload).encode(), + capture_output=True, + env=env, + timeout=15, + ) + return result.stdout.decode() + + +class TestShrinkDeterminism(unittest.TestCase): + def test_shrink_is_byte_stable(self): + cfg = store.get_config() + outs = [truncate.shrink(_BIG_TEXT, cfg)[0] for _ in range(5)] + self.assertTrue(all(o == outs[0] for o in outs)) + + def test_shrink_acted(self): + cfg = store.get_config() + _, acted = truncate.shrink(_BIG_TEXT, cfg) + self.assertTrue(acted, "expected shrink to act on a 400-line input") + + def test_shrink_retains_error_line(self): + cfg = store.get_config() + out, _ = truncate.shrink(_BIG_TEXT, cfg) + self.assertIn("ERROR: something failed at step 123", out) + + +class TestTaskTypeDeterminism(unittest.TestCase): + """_derive_task_type must not depend on PYTHONHASHSEED. + + A prompt mentioning more than one task type previously resolved via set + (hash) iteration order, so the intent-guard decision flipped between runs. + """ + + def _task_type(self, prompt, seed): + env = os.environ.copy() + env["PYTHONHASHSEED"] = str(seed) + env["PYTHONPATH"] = _SCRIPTS_DIR + os.pathsep + env.get("PYTHONPATH", "") + code = ( + "import context_watch as cw;" + f"print(cw._signature({prompt!r})[1])" + ) + result = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, env=env, timeout=15, + ) + return result.stdout.decode().strip() + + def test_mixed_prompt_stable_across_seeds(self): + prompt = "please debug and integrate the billing module" + results = {self._task_type(prompt, seed) for seed in range(6)} + self.assertEqual(len(results), 1, + f"task type varied across hash seeds: {results}") + + +class TestDedupDeterminism(unittest.TestCase): + def test_fresh_output_emits_identical_bytes(self): + # Two independent sessions so neither call hits the pointer-collapse + # path; both take the shrink-and-record branch. The emitted + # updatedToolOutput must be byte-identical. + d1 = tempfile.mkdtemp() + d2 = tempfile.mkdtemp() + p1 = {"session_id": "a", "tool_name": "Read", "tool_response": _BIG_TEXT} + p2 = {"session_id": "b", "tool_name": "Read", "tool_response": _BIG_TEXT} + out1 = _run_dedup(p1, d1) + out2 = _run_dedup(p2, d2) + self.assertEqual(out1, out2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_metering.py b/tests/test_metering.py new file mode 100644 index 0000000..bcd07f0 --- /dev/null +++ b/tests/test_metering.py @@ -0,0 +1,61 @@ +""" +test_metering.py — cache-aware savings + session-cost capture in store.py. + +Covers: +- record_saving stores both raw and effective tokens; session_savings sums the + effective (cache-adjusted) value. +- a legacy-style row (effective omitted) falls back to raw under session_savings. +- upsert_session_cost snapshots cumulative cost (UPSERT, not append). +- session_cost_rows reads back the snapshot and is session-isolated. +""" + +import importlib +import os +import sys +import tempfile +import unittest + +_SCRIPTS_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts" +) +if _SCRIPTS_DIR not in sys.path: + sys.path.insert(0, _SCRIPTS_DIR) + + +class TestMetering(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.mkdtemp() + os.environ["CLAUDE_PLUGIN_DATA"] = self._tmp + import store as _store + importlib.reload(_store) + import store + self.store = store + + def test_effective_savings_summed(self): + # dedup-style row: raw 1000, effective 100 (cache-weighted) + self.store.record_saving("s1", "dedup", 1000, 100) + # truncate-style row: full weight (effective defaults to raw) + self.store.record_saving("s1", "truncate", 50) + self.assertEqual(self.store.session_savings("s1"), 150) + + def test_effective_defaults_to_raw(self): + self.store.record_saving("s1", "dedup", 80) + self.assertEqual(self.store.session_savings("s1"), 80) + + def test_session_cost_upsert_is_not_append(self): + self.store.upsert_session_cost("s1", "Sonnet", 0.10, 1000, 200) + self.store.upsert_session_cost("s1", "Sonnet", 0.25, 3000, 500, + cache_read_tok=9000) + rows = self.store.session_cost_rows("s1") + self.assertEqual(len(rows), 1) + self.assertAlmostEqual(rows[0]["cost_usd"], 0.25) + self.assertEqual(rows[0]["cache_read_tok"], 9000) + + def test_session_cost_isolated(self): + self.store.upsert_session_cost("s1", "Opus", 1.0) + self.assertEqual(self.store.session_cost_rows("other"), []) + self.assertEqual(len(self.store.session_cost_rows()), 1) + + +if __name__ == "__main__": + unittest.main() From cf6e2a59b4445dfde2c837ce51c5f4a76e627213 Mon Sep 17 00:00:00 2001 From: Rahul Tyagi <rahul.1992.tyagi@gmail.com> Date: Thu, 18 Jun 2026 17:39:24 +0530 Subject: [PATCH 2/4] Add session handoff feature, rename commands to nx-*, harden context levers - Handoff: auto-skeleton writer (scripts/handoff.py) + /nx-save and /nx-load commands; context_watch auto-writes a resume handoff past the threshold. - Rename commands nexum-* -> nx-* (audit/build/plan/status) and add nx-save/nx-load. - scan_guard: shlex-based tokenizer so quoted args don't evade the grep guard; PreToolUse Read-guard injects limit/offset for large files. - dedup: gate truncate/dedup savings behind a per-session self-test, since PostToolUse updatedToolOutput is ignored for built-in tools on current CC. - statusline: capture real metered cost/context size. - README/SPEC: honest description of which context levers work today. - Scratch notes: HANDOFF-hook-investigation.md, nexum-review.md. --- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 10 +- HANDOFF-hook-investigation.md | 195 +++++++++ README.md | 24 +- SPEC.md | 101 ++++- agents/nexum-impl-haiku.md | 7 +- agents/nexum-impl-opus.md | 7 +- agents/nexum-impl-sonnet.md | 7 +- commands/{nexum-audit.md => nx-audit.md} | 0 commands/{nexum-implement.md => nx-build.md} | 56 ++- commands/nx-load.md | 32 ++ commands/{nexum-plan.md => nx-plan.md} | 4 +- commands/nx-save.md | 60 +++ .../{nexum-statusline.md => nx-status.md} | 0 nexum-review.md | 55 +++ scripts/context_watch.py | 100 ++++- scripts/cost_report.py | 2 +- scripts/dedup.py | 121 +++++- scripts/guardrail.py | 73 ++-- scripts/handoff.py | 181 +++++++++ scripts/scan_guard.py | 75 +++- scripts/statusline.py | 130 +++++- scripts/store.py | 370 +++++++++++++++++- scripts/truncate.py | 7 + tests/test_context_watch.py | 134 +++++++ tests/test_dedup.py | 84 +++- tests/test_guardrail.py | 74 ++++ tests/test_handoff.py | 115 ++++++ tests/test_meta_imports.py | 4 + tests/test_scan_guard.py | 90 +++++ tests/test_statusline.py | 73 ++++ tests/test_store.py | 265 +++++++++++++ tests/test_truncate.py | 23 ++ 33 files changed, 2372 insertions(+), 109 deletions(-) create mode 100644 HANDOFF-hook-investigation.md rename commands/{nexum-audit.md => nx-audit.md} (100%) rename commands/{nexum-implement.md => nx-build.md} (51%) create mode 100644 commands/nx-load.md rename commands/{nexum-plan.md => nx-plan.md} (86%) create mode 100644 commands/nx-save.md rename commands/{nexum-statusline.md => nx-status.md} (100%) create mode 100644 nexum-review.md create mode 100644 scripts/handoff.py create mode 100644 tests/test_handoff.py diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 99e21a1..d29d3e6 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -5,5 +5,5 @@ }, "description": "Context-token and model-cost optimization for Claude Code.", "name": "nexum", - "version": "0.2.1" + "version": "0.3.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 8066548..92432aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,12 @@ All notable changes to nexum are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.3.0] - 2026-06-18 ### Added +- **Orchestrator step-ledger resume.** `/nx-build` persists each step's verdict to a new `step_ledger` table (keyed by session + plan hash) and, on re-run for the same plan, skips `done` steps and patch-retries `failed` ones from their saved diff — so a session that dies mid-plan resumes instead of redoing completed work. A resumed failed step continues the escalation ladder from its persisted `attempts`/`tier_used`. New `store.py` CLI: `plan-hash`, `step-set/get/list/clear`. Config `orchestrator_resume_enabled` (default true). +- **Code-enforced dispatch batch cap.** `store.partition_steps` + CLI `plan-batches` split a route tier into deterministic, order-preserving sub-batches of at most `max_steps_per_dispatch` (default 6), so one dispatch can't overflow an executor's context. Executors return verdict-only on PASS (+ the diff on FAIL) to keep the orchestrator's shared context small. +- **Auto-handoff write + `/nx-load` resume.** Past `handoff_threshold_tokens` (default 100k), `context_watch` auto-writes a deterministic handoff skeleton (git state + task + tokens, via `handoff.py`) to `handoff/latest.md` every prompt — guaranteed even if the session dies. The trigger uses Claude Code's REAL context size (the statusline persists `real_context_tokens` from `context_window`), not a prompt-text estimate. A fresh session resumes on demand with `/nx-load` (resume is explicit, not automatic). Config `handoff_threshold_tokens`, `handoff_auto_write_enabled`. + - **Metered cost capture for API-key Claude Code.** The status line now snapshots Claude Code's own `cost.total_cost_usd` and cumulative token counts into a new `session_cost` table (`store.upsert_session_cost`). `cost_report.py` prints this authoritative, cache-accurate total alongside the per-tier breakdown — on API-key billing it matches the invoice, capturing prompt-cache writes/reads that a token-count reconstruction cannot see. - **Cache-aware savings.** Dedup pointer-collapses are now weighted by `dedup_cache_weight` (default 0.1), because a repeated tool read would bill at the cache-read rate (~0.1×), not full price. `record_saving` records raw + effective tokens; `session_savings` (and the status-line "saved" figure) report the dollar-equivalent effective number. Truncation of fresh output stays at full weight. - **`tests/test_determinism.py`** — asserts `truncate.shrink` and the dedup hook emit byte-identical output across repeated calls, protecting the auto-cached conversation prefix from invalidation. @@ -14,7 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - New config keys: `dispatch_granularity` (`group` | `step`, default `group`), `max_same_tier_retries` (default 1), `dedup_cache_weight` (default 0.1). ### Changed -- **`/nexum-implement` request-cost overhaul.** Steps are now batched by tier into one warm executor dispatch (`dispatch_granularity: group`) instead of one cold-start dispatch per step; executors run `guardrail.py` themselves and return its verdict (no separate orchestrator round-trip); the reviewer is gated to escalation/`needs-strong`/many-file steps; the retry ladder is 1 same-tier retry (patching the failed diff, not reimplementing) then escalate; and a step whose tier matches the current session model is implemented inline rather than spawned. Orchestration no longer assumes Opus. +- **Compact command names:** `/nexum-plan`→`/nx-plan`, `/nexum-implement`→`/nx-build`, `/nexum-handoff`→`/nx-save`, `/nexum-audit`→`/nx-audit`, `/nexum-statusline`→`/nx-status`; new `/nx-load`. +- **Staged context nudges:** `/nx-save` suggested at 100k, `/compact` at 120k (was a single 120k nudge). +- **`/nx-build` request-cost overhaul.** Steps are batched by tier into one warm executor dispatch (`dispatch_granularity: group`) instead of one cold-start dispatch per step; executors run `guardrail.py` themselves and return its verdict (no separate orchestrator round-trip); the reviewer is gated to escalation/`needs-strong`/many-file steps; the retry ladder is 1 same-tier retry (patching the failed diff, not reimplementing) then escalate; and a step whose tier matches the current session model is implemented inline rather than spawned. Orchestration no longer assumes Opus. ## [0.2.1] - 2026-06-15 ### Added diff --git a/HANDOFF-hook-investigation.md b/HANDOFF-hook-investigation.md new file mode 100644 index 0000000..09b21b1 --- /dev/null +++ b/HANDOFF-hook-investigation.md @@ -0,0 +1,195 @@ +# Handoff: nexum live-hook + status-line investigation + +> **RESOLVED 2026-06-17 — see "## RESOLUTION" at the bottom.** The shrink hook is inert +> because **Claude Code 2.1.178 does not honor the PostToolUse `updatedToolOutput` field**. +> Also: the central premise below ("live plugin = the cache") is **wrong** — live hooks run +> from the **worktree** (directory-type marketplace). The sections below are the original +> (partly-mistaken) notes; read the RESOLUTION first. + +_Resume point for a clean session. Context: we set out to reduce context bloat when +`/nexum-implement` dispatches to subagents, then discovered the live plugin's core +shrink hook may be inert._ + +## Environment facts (load-bearing) +- **Live plugin = the installed cache, NOT the working tree.** Active install: + `~/.claude/plugins/cache/nexum/nexum/0.2.1/` (commit `9ceb54c`). Cached versions + present: 0.1.1, 0.2.0, 0.2.1. Working tree `/Users/rahultyagi/work/nexum` is on + branch `review-fixes` (commit `84b85f4`) and its changes do NOT affect the live + session until the plugin is reinstalled/updated. +- `claude --version` = **2.1.178**. +- statusLine in `~/.claude/settings.json` runs: + `python3 "$(ls -dt ~/.claude/plugins/cache/nexum/nexum/*/scripts/statusline.py | head -1)"` + → resolves to the 0.2.1 copy. + +## Confirmed +1. **PreToolUse `scan_guard` fires in BOTH main agent and subagents.** Unscoped + `find .` is blocked with the `[nexum]` message in a spawned subagent and in the + main session. +2. **PostToolUse shrink (truncate/dedup) does NOT visibly act in the live session.** + `seq 1 600` (600 lines > 240 truncate threshold, > 30 dedup gate) returned in full, + no `[nexum] omitted N lines` marker — in main agent AND subagent. +3. **The scripts themselves are correct.** Feeding `dedup.py` a realistic Bash payload + (`tool_response` as string and as `{stdout}`) directly produces a valid + `{"hookSpecificOutput":{"hookEventName":"PostToolUse","updatedToolOutput":"…"}}` + with the marker. Logic is fine. +4. **`updatedToolOutput` is a real, supported PostToolUse field**, nested inside + `hookSpecificOutput` exactly as the script emits (confirmed via + https://code.claude.com/docs/en/hooks). No min-version stated in docs. +5. **The live DB has no fresh activity.** `~/.claude/plugins/.../0.2.1/.nexum-data/nexum.db` + `outputs` rows are all ~55h old; `savings` table empty. My `seq 1 600` added NO row + → the PostToolUse hook most likely **did not fire (or errored before record_output)** + in this session, even though PreToolUse did. + +## Top open question +**Does the PostToolUse hook fire at all in the live session, or fire-but-`updatedToolOutput`-ignored?** +Decisive test for the clean session: +- Add an unmistakable side-effect to a COPY of the live hook (e.g. append a line to + `/tmp/nexum-posttool-probe.log` at the very top of `dedup.py`/`truncate.py` main()), + point a throwaway PostToolUse hook at it (or temporarily edit the cache copy), then run + `seq 1 600` and check: (a) does the log line appear? → fires. (b) is output shrunk? → honored. +- Also verify the hook's **data dir**: it writes to `$CLAUDE_PLUGIN_DATA` or + `$CLAUDE_PLUGIN_ROOT/.nexum-data`. Confirm which dir the LIVE hook actually uses (env + may differ from our shell, where both were empty) — fresh rows may be landing elsewhere. +- If it fires but isn't honored: build a 5-line minimal PostToolUse hook that + unconditionally replaces output, register it alone, and test whether 2.1.178 honors + `updatedToolOutput` for Bash/Read at all. + +## Status line "garbage" (user-reported, UNREPRODUCED) +- The resolved 0.2.1 `statusline.py` and the full `/bin/sh -c "$(ls -dt …)"` wrapper both + print clean output locally (`nexum Opus 4.8 · ▓▓░░░░░░░░ 25% · 16.7k tok · $0.42`) + and fail-open to `nexum` on bad input. Could not reproduce the garbage. +- **Most likely cause:** the `▓` (U+2593) / `░` (U+2591) block-bar glyphs rendering as + mojibake in the user's terminal/font. **Need the user to paste/describe one render** + (boxes? traceback? escape codes? raw JSON?) — the fix differs by cause. +- Likely fix if encoding: swap the bar glyphs for ASCII (e.g. `#`/`-`) in `statusline.py`. + Fix in repo, then reinstall — or edit the cache copy for immediate relief. + +## Secondary bug found +- **scan_guard tokenizer doesn't handle quoted args.** `grep -r "def " .` was NOT blocked + because `_tokens` splits on whitespace, so the quoted pattern's space injects a bogus + path token and `_is_unscoped_grep` sees a non-`.` "path". Real-world quoted patterns with + spaces evade the guard. (`tests/test_scan_guard.py` only uses space-free patterns.) + +## Note on the original goal +The brainstormed bloat improvements (minimal subagent returns, bound batch size, +shrink-based fail logs, orchestrator-as-subagent) are **moot until the shrink hook is +confirmed working live** — fix the foundation first. + +## Already done this session (committed, branch `review-fixes`, `84b85f4`) +Review-finding fixes + task-handling overhaul + metering. See commit body. Untracked scratch: +`nexum-review.md` (the original review), this file. + +--- + +## RESOLUTION (2026-06-17, clean session) + +### Headline +**Claude Code 2.1.178 (the latest published version — `npm view @anthropic-ai/claude-code +version` == 2.1.178) does NOT honor the PostToolUse `updatedToolOutput` field.** The hook +fires and emits perfectly-formed JSON; CC just doesn't apply it. nexum's entire +truncate/dedup context-savings feature is therefore **inert on current Claude Code**, +independent of script correctness. + +### How it was proven (decisive, reproducible) +Instrumented the *worktree* `dedup.py` (see below for why worktree) to log its emit decision, +plus a throwaway probe in the separate `campy` PostToolUse hook: + +1. **nexum emits a real shrink, ignored.** A 600-line unique output produced: + `[dedup-WT] EMIT updatedToolOutput in_len=10691 out_len=3165 acted=True` + — yet the model received the full 10691-char output, no `[nexum] omitted` marker. +2. **A minimal foreign hook is also ignored.** Made `campy/.../post-tool-use.sh` emit + `{"updatedToolOutput":"__MARKER__"}` (tested BOTH nested-in-`hookSpecificOutput` and + top-level). Output never replaced. +3. **Single-emitter, ruling out multi-hook clobber.** Triggered a `Write` (nexum's matcher + `Read|Bash|Grep|Glob` does NOT match Write, so only campy's `*` hook ran). Still ignored. + → not a hook-ordering/merge artifact; the field is simply unsupported in 2.1.178. + +Docs (`code.claude.com/docs/en/hooks`) DO list `updatedToolOutput` as the PostToolUse +output-replacement field — so this is a docs-vs-behavior gap (likely an unshipped/broken +feature), not a usage error on our side. + +### The handoff's two mistakes (corrected) +- **Live hooks run from the WORKTREE, not the cache.** nexum's marketplace `source` is + `directory: /Users/rahultyagi/work/nexum` (see `~/.claude/plugins/known_marketplaces.json`), + so `CLAUDE_PLUGIN_ROOT` resolves to the worktree. Probes added to the worktree scripts fired; + probes in the 0.2.1 cache copy never did. All prior cache-focused evidence (cache + `.nexum-data` DB staleness, instrumenting the cache) examined the wrong files. Fresh DB rows + land in the **worktree's** `.nexum-data`. +- **The PostToolUse hook DOES fire** every matching call (`dedup.py` fired reliably). The + handoff's "didn't fire" conclusion came from checking the cache copy + cache DB. It fires; + it just can't mutate output. + +### Other facts nailed down +- Worktree `hooks/hooks.json` registers ONLY `dedup.py` for PostToolUse (not `truncate.py`); + dedup re-applies `truncate.shrink()` internally. So "truncate didn't fire" is by design. +- Real PostToolUse payload for **Bash**: `tool_response` = dict with keys + `[stdout, stderr, interrupted, isImage, noOutputExpected]`. `extract_output` handles this + (reads `stdout`). Top-level keys: `session_id, transcript_path, cwd, permission_mode, effort, + hook_event_name, tool_name, tool_input, tool_response, tool_use_id, duration_ms`. +- **Read-tool bug:** for `Read`, `tool_response` = `{type, file}` (content nested under `file`), + so `extract_output` returns None → nexum can never shrink Read output, even if the field + worked. Fix `extract_output` to dig into `file` when/if the feature becomes usable. +- PreToolUse control (scan_guard's permission decision) is a SEPARATE mechanism and is the only + currently-working savings lever; only PostToolUse output-replacement is broken. (Note: a bare + `find .` was NOT blocked this session — scan_guard's match logic may need a recheck, but that's + orthogonal to the shrink finding.) + +### Implication / next move (the real decision) +The shrink-via-PostToolUse approach cannot work on current CC. Options, in order of sanity: +1. **Verify upstream**: is `updatedToolOutput` actually implemented in 2.1.178? File an issue / + watch the changelog. If it's coming, gate nexum's claims behind a version check + fail loud. +2. **Pivot the savings lever** to something that works today: PreToolUse input shaping (e.g. + auto-appending output limiters), or scan_guard-style blocking of known-noisy commands. +3. Until then, **stop advertising truncate/dedup savings** — they don't materialize live. + +The original subagent-bloat improvements remain moot until a working savings mechanism exists. + +### Cleanup done +All probes removed and reverted: worktree `scripts/{dedup,truncate}.py` (`git checkout`), +cache 0.2.1 copies (manual revert, parse-checked), `campy/.../post-tool-use.sh` (restored from +backup). Scratch files (`/tmp/nexum-posttool-probe.log`, test flag, backup) deleted. `git status` +clean for both repos' touched files. + +--- + +## UPSTREAM CORROBORATION (2026-06-17) — it's a known, unfixed CC bug + +Searched `anthropics/claude-code` issues. My finding is independently confirmed by multiple +reporters using the *exact same* additionalContext-vs-updatedToolOutput discriminator: + +- **#67442** (closed dup, v2.1.173): "`updatedToolOutput` silently ignored for built-in tools + (Bash, WebFetch)". Ruled out wrong-field, not-firing (additionalContext + permissionDecision + work), and multi-hook — same as us. +- **#65403** (closed dup): "still not honored for Bash — secrets leak". States plainly: + **"it has never worked for the Bash tool."** +- **#54196** (v2.1.121): same, auto-closed for inactivity (NOT_PLANNED). +- **#32105** (closed COMPLETED 2026-04-24): the feature request that is *literally nexum's use + case* ("context budget recovery", Bash output compression via PostToolUse). Marked shipped in + v2.1.121 per changelog — but the bug reports above prove it does **not** actually work for + built-in tools. Also documents the key distinction: **`updatedMCPToolOutput` works but ONLY for + MCP tools**; `updatedToolOutput` for built-in tools is the broken path. +- Issues just bounce through an auto-duplicate chain (#65122→#64326→…) with no maintainer ETA. + A third-party external executor (`ironrun`) exists as a workaround for the redaction case. + +**Net:** `updatedToolOutput` for built-in Bash/Read/WebFetch has been broken across every version +from 2.1.121 → 2.1.178 (current latest), for months, with no fix in sight. nexum's PostToolUse +shrink cannot work on any shippable CC today. This is NOT a near-term version-gate-and-wait. + +### Working levers that DO exist (corroborated by #32105's own alternatives table) +- **PreToolUse `updatedInput`** — WORKS. For **Read**, inject `limit`/`offset` when a file is + large → genuinely caps Read output. (The one clean, supported win.) +- **PreToolUse command-shaping for Bash** — rewrite/limit known-verbose commands. Real but + whack-a-mole, and blind to output size. **Do NOT** auto-return `permissionDecision:"allow"` to + force rewrites (RTK anti-pattern — bypasses the permission/safety system; see #32105). +- **scan_guard PreToolUse blocking** — already works (permission decisions are honored). +- **`additionalContext`** — works, but ADDS tokens; useless for shrinking. +- **MCP tool outputs** — `updatedMCPToolOutput`/`updatedToolOutput` works for MCP, not built-ins. + +### Recommended nexum changes +1. **Honesty fix (do first):** stop reporting truncate/dedup "savings" for Bash/Read — they don't + materialize. Either remove the claim or gate it behind a runtime self-test (emit a probe + `updatedToolOutput` once per session; only count savings if it actually took effect). +2. **Pivot the real lever to PreToolUse `updatedInput` for Read** (large-file limit/offset). +3. **Keep scan_guard** as the blocking lever; optionally add conservative Bash command-shaping. +4. **Auto-reactivation:** ship the session self-test so the PostToolUse shrink turns itself back + on automatically if/when CC fixes built-in `updatedToolOutput`. Track #65403/#32105. diff --git a/README.md b/README.md index be66053..d9e9ab9 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,9 @@ To try it from a local checkout instead: ## Commands -- **`/nexum-plan`** — Analyze the task and produce a multi-step plan with explicit contracts and scope boundaries. -- **`/nexum-implement`** — Execute the plan, routing each step to Haiku, Sonnet, or Opus based on complexity, running acceptance checks, and reporting per-step results. -- **`/nexum-audit`** — Scan the repo for context risks (unignored large/binary files, missing ignore rules) and optionally apply recommendations. +- **`/nx-plan`** — Analyze the task and produce a multi-step plan with explicit contracts and scope boundaries. +- **`/nx-build`** — Execute the plan, routing each step to Haiku, Sonnet, or Opus based on complexity, running acceptance checks, and reporting per-step results. +- **`/nx-audit`** — Scan the repo for context risks (unignored large/binary files, missing ignore rules) and optionally apply recommendations. ## Status line @@ -38,7 +38,7 @@ nexum ships `scripts/statusline.py`, a Claude Code `statusLine` command that ren nexum <model> · <bar> <pct>% · <tokens> tok · $<cost> · saved <n> ``` -A plugin cannot register the main `statusLine` itself (a plugin's `settings.json` only supports `agent` and `subagentStatusLine`), so you add it to your own settings. Run `/nexum-statusline` to merge it automatically, or add it manually: +A plugin cannot register the main `statusLine` itself (a plugin's `settings.json` only supports `agent` and `subagentStatusLine`), so you add it to your own settings. Run `/nx-status` to merge it automatically, or add it manually: ```json { @@ -66,3 +66,19 @@ Both thresholds are configurable via `config.json` in the nexum data directory. - **Stdlib only** — all Python dependencies are from the standard library (3.9+). No pip installs. - **Fail-open** — hooks never crash the Claude Code session; errors emit `{}` and exit 0. - **State** — persistent session state (dedup memo, usage metrics, task history) lives in SQLite at `${CLAUDE_PLUGIN_ROOT}/.nexum-data/nexum.db`. + +### Context levers: what works today vs. what is pending + +**Working levers (PreToolUse `updatedInput` is honored):** + +- **Read-guard** — when a file exceeds `read_guard_min_bytes` (default 262144 bytes) and has no explicit `limit` already set, nexum injects a line limit (default `read_guard_inject_lines` = 2000) via `updatedInput`. This is the reliable context-saving path for large file reads. Configure via `config.json`: + ```json + { "read_guard_enabled": true, "read_guard_min_bytes": 262144, "read_guard_inject_lines": 2000 } + ``` +- **Scan-guard** — unscoped recursive greps, broad globs, and reads into deny paths are blocked via PreToolUse `permissionDecision: deny`. This prevents context-blowing scans from reaching the model at all. + +**Pending / self-test-gated (PostToolUse `updatedToolOutput` is currently ignored):** + +PostToolUse `updatedToolOutput` is silently ignored for built-in tools on current Claude Code (see anthropics/claude-code [#65403](https://github.com/anthropics/claude-code/issues/65403) and [#32105](https://github.com/anthropics/claude-code/issues/32105)). As a result, the output truncation (`truncate.py`) and dedup pointer-collapse (`dedup.py`) hooks emit replacements that the harness does not apply. + +nexum performs a per-session self-test to detect whether the harness honors `updatedToolOutput`. Savings are only counted in the status line and cost report after the self-test confirms the field is being applied — so the `saved` counter stays at zero until upstream fixes the issue (at which point nexum auto-reactivates without any config change). diff --git a/SPEC.md b/SPEC.md index 11e2853..4beeb07 100644 --- a/SPEC.md +++ b/SPEC.md @@ -34,6 +34,27 @@ subagents: every file has an exact contract, edge cases, and acceptance criteria - **Tone of user-facing messages:** one short factual sentence, no emoji, prefixed `[nexum] `. +### Harness facts: working levers vs. pending + +**PostToolUse `updatedToolOutput` is silently ignored for built-in tools on current +Claude Code** (anthropics/claude-code #65403, #32105). Consequently, the output +truncation (`truncate.py`) and dedup pointer-collapse (`dedup.py`) hooks emit +replacements that the harness does not apply. nexum performs a per-session +self-test to detect this: savings from truncation and dedup are only recorded and +surfaced in the status line after the self-test confirms the harness is actually +applying `updatedToolOutput`. If the upstream issue is fixed, the self-test will +pass and savings will be counted automatically (no config change required). + +**PreToolUse `updatedInput` and `permissionDecision` ARE honored.** The levers that +reliably reduce context today are: + +- **Read-guard** (`_read_limit_input` in `scan_guard.py`) — injects a line `limit` + via `updatedInput` for files above `read_guard_min_bytes` (default 262144 bytes) + that do not already carry an explicit limit. Configurable via `config.json`: + `read_guard_enabled`, `read_guard_min_bytes`, `read_guard_inject_lines`. +- **Scan-guard blocking** — unscoped recursive greps, broad globs, and reads into + deny paths are blocked via `permissionDecision: deny` before the tool executes. + --- ## 1. Plugin packaging @@ -42,7 +63,7 @@ subagents: every file has an exact contract, edge cases, and acceptance criteria nexum/ .claude-plugin/plugin.json hooks/hooks.json - commands/ nexum-plan.md nexum-implement.md nexum-audit.md + commands/ nx-plan.md nx-build.md nx-audit.md agents/ nexum-impl-haiku.md nexum-impl-sonnet.md nexum-reviewer.md scripts/ store.py truncate.py dedup.py scan_guard.py context_watch.py guardrail.py cost_report.py audit.py @@ -144,6 +165,8 @@ CREATE TABLE usage( session_id TEXT, model TEXT, input_tok INTEGER, "truncate_min_lines_to_act": 240, "keep_error_regex": "(?i)(error|exception|traceback|failed|fatal|warning)", "compaction_threshold_tokens": 120000, + "handoff_threshold_tokens": 100000, + "handoff_auto_write_enabled": true, "scan_guard_enabled": true, "scan_deny_paths": ["node_modules", ".git", "dist", "build", "target", "vendor", ".next", "coverage", ".venv", "__pycache__"], @@ -153,7 +176,9 @@ CREATE TABLE usage( session_id TEXT, model TEXT, input_tok INTEGER, "statusline_compaction_warn_tokens": 80000, "dedup_cache_weight": 0.1, "dispatch_granularity": "group", - "max_same_tier_retries": 1 + "max_same_tier_retries": 1, + "max_steps_per_dispatch": 6, + "orchestrator_resume_enabled": true } ``` @@ -211,7 +236,7 @@ falls back correctly. ## 4. Pillar 2 — cost-driven planner → executor workflow -### 4.1 `commands/nexum-plan.md` · TIER: standard +### 4.1 `commands/nx-plan.md` · TIER: standard Frontmatter `model: opus` (or `inherit`). Instructs the model to produce a plan file at `<data_dir>/plan/<session>.md` whose steps follow the **Step Schema** below. The command body is the prompt; no script needed beyond writing the file via @@ -244,14 +269,24 @@ file: frontmatter (`name`, `description`, `model`) + a body stating the implement-only-listed-steps / stay-in-scope / run-guardrail-and-return-JSON contract. -### 4.3 `commands/nexum-implement.md` · TIER: standard +### 4.3 `commands/nx-build.md` · TIER: standard Body orchestrates (cost levers in order of impact): - **Group by route** (`mechanical`→`standard`→`needs-strong`) to keep each model's cache warm. - **Batch by tier** (`dispatch_granularity: group`, default): send a whole route group to ONE executor dispatch — shared spec/files read once, one cached prefix — instead of one cold-start dispatch per step. `step` granularity is - the opt-in per-step mode. + the opt-in per-step mode. The batch cap is **code-enforced**, not eyeballed: + the orchestrator calls `store.py plan-batches --indices <...>` (uses + `partition_steps`), which returns deterministic, order-preserving sub-batches + of at most `max_steps_per_dispatch` (default 6; `0` disables) so one dispatch + can't overflow the executor's context or widen a failure's blast radius, while + still reusing the warm prefix. +- **Minimal executor returns:** executors return only the per-step verbatim + guardrail JSON (+ one-line summary + files) on PASS — no diffs/file dumps/ + narration, since the orchestrator's context is shared across the batch. On + FAIL they additionally return the step's unified diff, which the orchestrator + persists for patch-retry. - **Skip the spawn when the step's tier == the current session model** — implement inline rather than spawning, since a subagent only earns its keep by running a *different* model without trashing the main cache. @@ -263,6 +298,15 @@ Body orchestrates (cost levers in order of impact): - On FAIL: retry same tier up to `max_same_tier_retries` (default 1) **handing the failed diff back to patch** (not reimplement), then escalate haiku→sonnet→opus once; never demote a `needs-strong` step. +- **Resume across restarts** (`orchestrator_resume_enabled`, default true): each + step's verdict is persisted to the `step_ledger` table (keyed by session + + `plan-hash`) as soon as it's known. A re-run for the same plan **skips** `done` + steps and **patch-retries** `failed` ones from their saved diff/verdict — so a + session that died mid-plan resumes instead of redoing completed work. A + resumed `failed` step continues the escalation ladder from its persisted + `attempts`/`tier_used` rather than restarting it. Editing the plan changes its + hash, discarding stale state. CLI: `store.py plan-hash`, `step-set`, + `step-get`, `step-list`, `step-clear`, `plan-batches`. Prompt-driven (uses the Task/subagent mechanism), referencing the agents in 4.2. ### 4.4 `scripts/guardrail.py` · TIER: standard @@ -325,12 +369,20 @@ Prompt-driven (uses the Task/subagent mechanism), referencing the agents in 4.2. ### 5.2 `scripts/context_watch.py` (UserPromptSubmit) · TIER: standard Two responsibilities in one hook: -- **(a) Compaction prompt:** maintain a running token estimate per session in - `session_kv` (add this turn's prompt estimate + accumulated tool tokens recorded - by dedup). When it crosses `compaction_threshold_tokens` AND not already prompted - this window (flag), emit a `systemMessage`: - `"[nexum] Context is large (~Xk tokens). Consider /compact to reduce cost."` - (systemMessage only — do NOT block.) +- **(a) Context nudges + auto-handoff:** track context size per session. Prefer + Claude Code's REAL measured size — the `real_context_tokens` flag the statusline + hook persists from `context_window` (input+output tokens) — and fall back to a + running per-prompt estimate in `session_kv` when the statusline hasn't run yet + (the estimate omits tool output and undercounts badly). Use `max(estimate, real)`. + Two staged `systemMessage` nudges, each emitted at most once per window (flag): + at `handoff_threshold_tokens` (default 100k) suggest `/nx-save`; at the higher + `compaction_threshold_tokens` (default 120k) suggest `/compact`. The compaction + warning takes precedence when both cross at once. (systemMessage only — do NOT + block.) Additionally, while above `handoff_threshold_tokens` and + `handoff_auto_write_enabled`, call `handoff.write_skeleton` to (re)write a + deterministic handoff skeleton to `handoff/<session>.md` + `handoff/latest.md` + every prompt, so the most recent git state is captured for `/nx-load` to resume + even if this session dies. Fail-open. - **(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). @@ -350,7 +402,7 @@ Two responsibilities in one hook: fix→feature divergence blocked; `continue` bypasses then adopts new task; threshold crossing emits systemMessage exactly once per window. -### 5.3 `scripts/audit.py` + `commands/nexum-audit.md` · TIER: standard +### 5.3 `scripts/audit.py` + `commands/nx-audit.md` · TIER: standard - CLI `python3 audit.py [--root <dir>] [--write]`. Scans `--root` (default cwd): - **Ignore mechanism:** detect which Claude Code ignore the repo uses. v1 targets, in priority: a `.claudeignore` file if present; else `.gitignore`; report which. @@ -363,7 +415,7 @@ Two responsibilities in one hook: - Output: human report to stdout. With `--write`: append a `# nexum` block of suggested patterns to the chosen ignore file (idempotent — skip patterns already present; never duplicate; never delete existing lines). -- `commands/nexum-audit.md`: frontmatter `model: haiku` (cheap); body runs +- `commands/nx-audit.md`: frontmatter `model: haiku` (cheap); body runs `python3 ${CLAUDE_PLUGIN_ROOT}/scripts/audit.py` and summarizes, offering `--write`. - **EDGE CASES:** empty repo → "clean" report; existing `# nexum` block → update in place, no dupes; symlinks → don't follow into deny dirs; permission errors → skip @@ -371,6 +423,29 @@ Two responsibilities in one hook: - **ACCEPTANCE:** repo with unignored `node_modules` → flagged; `--write` adds it once and is a no-op on second run; missing ignore file → recommends creating one. +### 5.4 Auto-handoff write + manual resume: `scripts/handoff.py` + `commands/nx-load.md` · TIER: standard +The handoff carries work across a session boundary. The WRITE is automatic (hook, +no manual step); the RESUME is an explicit user command (`/nx-load`) — auto- +injecting a prior session's context into every new session was judged too risky +(noise / unintended resumes), so pickup is opt-in. +- **`handoff.py`** — deterministic skeleton writer (no model/conversation). API + `build_skeleton(session_id, cwd, token_total)` and + `write_skeleton(session_id, data_dir, cwd, token_total)`; CLI + `python3 handoff.py write --session <id> [--cwd <dir>] [--tokens <n>]`. Captures + git branch/status/diff-stat/recent-commits, the stored task signature + (humanized), tokens, timestamp → `handoff/<session>.md` + `handoff/latest.md`. + Called by `context_watch.py` (§5.2) every prompt above `handoff_threshold_tokens` + (gated by `handoff_auto_write_enabled`). Fail-open: a non-git cwd or any error + degrades to a partial skeleton, never raises. +- **`commands/nx-load.md`** — user-invoked resume. Reads `handoff/latest.md`, + sanity-checks freshness/branch, re-establishes real git state (doesn't trust the + notes blindly), summarizes, and continues. Leaves `latest.md` in place. The rich + `/nx-save` overwrites the skeleton at the same paths, so a manual save wins. +- **EDGE CASES:** no handoff → "nothing to resume"; skeleton vs rich handoff noted; + git state contradicting the handoff is surfaced, not acted on. +- **ACCEPTANCE:** crossing the handoff threshold writes `latest.md`; `/nx-load` with + no handoff reports nothing to resume; with one, it loads + verifies before acting. + --- ## 6. Build order & dispatch plan (who builds what) diff --git a/agents/nexum-impl-haiku.md b/agents/nexum-impl-haiku.md index fd3dbb9..8799fc9 100644 --- a/agents/nexum-impl-haiku.md +++ b/agents/nexum-impl-haiku.md @@ -17,4 +17,9 @@ python3 ${CLAUDE_PLUGIN_ROOT}/scripts/guardrail.py \ --changed <comma-separated files you actually touched for this step> ``` -Return, **per step**: the step index, a one-line summary of changes, the files touched, and the **verbatim guardrail JSON** (`{"pass": ..., "acceptance_rc": ..., "scope_violations": [...], "log": "..."}`). Do not paraphrase the guardrail output — the orchestrator parses it directly. If a step fails its guardrail, report the failure and continue to the next independent step (the orchestrator decides on retry/escalation). +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** (`{"pass": ..., "acceptance_rc": ..., "scope_violations": [...], "log": "..."}`). Do NOT paste diffs, file contents, or step-by-step narration — the orchestrator only needs the verdict to proceed. +- **On FAIL:** include the same fields **plus the unified diff of exactly what you changed for that step** (`git diff -- <files>`). The orchestrator persists this diff so a patch-retry can build on it instead of reimplementing from spec. Then continue to the next independent step (the orchestrator decides on retry/escalation). + +Never paraphrase the guardrail JSON — the orchestrator parses it directly. diff --git a/agents/nexum-impl-opus.md b/agents/nexum-impl-opus.md index f76ac4c..54c39e0 100644 --- a/agents/nexum-impl-opus.md +++ b/agents/nexum-impl-opus.md @@ -17,4 +17,9 @@ python3 ${CLAUDE_PLUGIN_ROOT}/scripts/guardrail.py \ --changed <comma-separated files you actually touched> ``` -Return, for each step: a brief summary of changes made, the list of files touched, and the **verbatim guardrail JSON** (`{"pass": ..., "acceptance_rc": ..., "scope_violations": [...], "log": "..."}`). Do not paraphrase the guardrail output — the orchestrator parses it directly. +Return contract — **keep it minimal; the orchestrator's context is shared, so every extra line you return is multiplied:** + +- **On PASS:** return ONLY a one-line summary per step, the files touched, and the **verbatim guardrail JSON** (`{"pass": ..., "acceptance_rc": ..., "scope_violations": [...], "log": "..."}`). Do NOT paste diffs, file contents, or design narration — the orchestrator only needs the verdict to proceed. (For `needs-strong` debugging steps, a 1–2 line note on the root cause is fine; keep it terse.) +- **On FAIL:** include the same fields **plus the unified diff of exactly what you changed for that step** (`git diff -- <files>`), so a patch-retry can build on it instead of reimplementing from spec. + +Never paraphrase the guardrail JSON — the orchestrator parses it directly. diff --git a/agents/nexum-impl-sonnet.md b/agents/nexum-impl-sonnet.md index f56081d..baf73be 100644 --- a/agents/nexum-impl-sonnet.md +++ b/agents/nexum-impl-sonnet.md @@ -17,4 +17,9 @@ python3 ${CLAUDE_PLUGIN_ROOT}/scripts/guardrail.py \ --changed <comma-separated files you actually touched for this step> ``` -Return, **per step**: the step index, a one-line summary of changes, the files touched, and the **verbatim guardrail JSON** (`{"pass": ..., "acceptance_rc": ..., "scope_violations": [...], "log": "..."}`). Do not paraphrase the guardrail output — the orchestrator parses it directly. If a step fails its guardrail, report the failure and continue to the next independent step (the orchestrator decides on retry/escalation). +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** (`{"pass": ..., "acceptance_rc": ..., "scope_violations": [...], "log": "..."}`). Do NOT paste diffs, file contents, or step-by-step narration — the orchestrator only needs the verdict to proceed. +- **On FAIL:** include the same fields **plus the unified diff of exactly what you changed for that step** (`git diff -- <files>`). The orchestrator persists this diff so a patch-retry can build on it instead of reimplementing from spec. Then continue to the next independent step (the orchestrator decides on retry/escalation). + +Never paraphrase the guardrail JSON — the orchestrator parses it directly. diff --git a/commands/nexum-audit.md b/commands/nx-audit.md similarity index 100% rename from commands/nexum-audit.md rename to commands/nx-audit.md diff --git a/commands/nexum-implement.md b/commands/nx-build.md similarity index 51% rename from commands/nexum-implement.md rename to commands/nx-build.md index 284ee4b..7e73be9 100644 --- a/commands/nexum-implement.md +++ b/commands/nx-build.md @@ -2,7 +2,7 @@ description: "Execute a nexum plan file by dispatching steps to the cheapest capable model tier, verifying acceptance, and escalating on failure." --- -You are the nexum implementer. You read a plan file produced by `/nexum-plan`, execute its steps by delegating to the cheapest capable model tier, and handle retries and escalation. You orchestrate; you do not write step code yourself (except inline, see §4). +You are the nexum implementer. You read a plan file produced by `/nx-plan`, execute its steps by delegating to the cheapest capable model tier, and handle retries and escalation. You orchestrate; you do not write step code yourself (except inline, see §4). **Orchestration is mechanical** — parsing the plan, dispatching, reading verdicts, and branching do **not** require Opus. Do not assume this command runs on Opus; only `needs-strong` *step content* needs Opus, and that is delegated to a subagent (§4). Keeping the driver cheap is a standing saving on every run. @@ -12,9 +12,26 @@ Resolve the data directory (same logic as `store.py`): `$CLAUDE_PLUGIN_DATA`, el Read the plan file in full. Parse out every step: its index, `route`, `files`, `objective`, `contract`, `scope`, and `acceptance`. -If the plan file does not exist, stop and tell the user: `[nexum] No plan found for this session. Run /nexum-plan first.` +If the plan file does not exist, stop and tell the user: `[nexum] No plan found for this session. Run /nx-plan first.` -Read the effective config once: `python3 ${CLAUDE_PLUGIN_ROOT}/scripts/store.py config`. The keys that drive this command are `dispatch_granularity` (`group` | `step`), `max_same_tier_retries` (default 1), and the model tiers. +Read the effective config once: `python3 ${CLAUDE_PLUGIN_ROOT}/scripts/store.py config`. The keys that drive this command are `dispatch_granularity` (`group` | `step`), `max_same_tier_retries` (default 1), `orchestrator_resume_enabled` (default true), and the model tiers. + +## 1a. Resume from the step ledger (skip work already done) + +The orchestrator persists each step's outcome to a durable **step ledger** so a session that died mid-plan — context/plan limit, crash, or an interrupted background dispatch — resumes instead of redoing completed steps. This is the main anti-wastage lever across restarts. + +If `orchestrator_resume_enabled` is true (default): + +1. Compute the **plan hash** (the ledger key that ties saved state to *this* plan's content; editing the plan changes the hash and discards stale state automatically): + `python3 ${CLAUDE_PLUGIN_ROOT}/scripts/store.py plan-hash --file <plan_file>` +2. Load any prior state: + `python3 ${CLAUDE_PLUGIN_ROOT}/scripts/store.py step-list --session <session_id> --plan-hash <plan_hash>` +3. For each step, treat the ledger as the source of truth for what's already finished: + - **`done`** → **skip it.** Do not re-dispatch, do not re-run acceptance. Print `[nexum] Step <N> already done (resumed) — skipping.` + - **`failed`** → resume mid-ladder, do **not** restart the retry/escalation ladder from scratch. Read the row's `attempts` and `tier_used` and continue from there: re-dispatch as a patch-retry (§7) seeded with the saved `last_diff` and `verdict`, at the tier the ladder had already reached (`tier_used`), counting the prior `attempts` against `max_same_tier_retries` before escalating. This avoids re-spending escalations a previous session already paid for. + - **`pending` or absent** → execute normally. + +If every step is already `done`, report that the plan is complete and skip to the cost summary (§10). Hold the `plan_hash` for the rest of the run — you record every verdict against it (§6a). ## 2. Group steps by route @@ -26,13 +43,23 @@ Partition steps into three ordered groups and execute them in this order: Grouping keeps each model's prompt prefix stable, maximising cache hits. +**Dependencies override tier order.** A correct plan routes steps so dependencies never run before their prerequisites (see the planner's dependency-vs-tier rule), but verify it yourself and adapt: never dispatch a step before a step it depends on, even if that means running a cheaper tier *after* a costlier one. Concretely — a test step that exercises code written in another step must run after that step; and a final full-suite / verification step always runs **last**, regardless of its tier. When tier order and dependency order conflict, dependency order wins. + ## 3. Dispatch granularity — batch by tier (default) vs per-step **This is the main cost lever.** Read `dispatch_granularity` from config: -- **`group` (default):** Send the *entire* route group to **one** executor dispatch. The executor reads the shared spec and source files once and reuses them across every step in the group — one warm context, one cached prefix, instead of N cold starts that each re-derive the same context. The executor returns a per-step result list. +- **`group` (default):** Send the route group to **one** executor dispatch. The executor reads the shared spec and source files once and reuses them across every step in the group — one warm context, one cached prefix, instead of N cold starts that each re-derive the same context. The executor returns a per-step result list. - **`step`:** Send one dispatch per step. More isolation (a mid-batch failure can't affect siblings), but pays a cold start and re-reads context for every step. Use only when steps in a tier are large or risky enough that isolation outweighs the re-derivation cost. +**Cap the batch size — compute the split, don't eyeball it.** Under `group`, a single oversized dispatch can overflow the executor's own context (forcing a mid-batch compaction that re-derives everything and wipes the token savings) and widens the blast radius if one step fails. Do not judge "too big" by eye — ask the helper for the deterministic, order-preserving partition of the tier's step indices (in execution order): + +``` +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/store.py plan-batches --indices "<comma-separated step indices for this tier, in order>" +``` + +It returns a JSON array of sub-batches (e.g. `[[0,1,2,3,4,5],[6,7]]`), each at most `max_steps_per_dispatch` (config, default 6; `0` disables the cap). Dispatch the sub-batches **sequentially**; each reuses the same shared-context prefix, so the cache stays warm while each dispatch is bounded. Order is preserved, so a dependent step never runs before its prerequisite. + ## 4. Skip the spawn when the tier already matches the session model A subagent exists to run a step on a **different** model than the main session without invalidating the main conversation's prompt cache. If a step's tier is the **same** model you (the orchestrator) are already running, spawning buys nothing — it only adds a cold start. In that case, implement the step(s) **inline** in this conversation instead of dispatching. @@ -58,7 +85,9 @@ Global constraints (apply to every step): - <language/runtime constraints from the plan, e.g. Python 3.9+ stdlib only> - Fail-open where the plan requires it; emit deterministic JSON where required. - Do not touch files outside each step's declared scope. -- After each step, run guardrail.py for that step and return its verbatim JSON. +- After each step, run guardrail.py for that step and return its verbatim JSON. Invoke it as: + `python3 ${CLAUDE_PLUGIN_ROOT}/scripts/guardrail.py --acceptance "<step acceptance>" --changed "<files you actually modified>" --deny-path "<each path from the step's 'scope: do NOT touch' list>"` + Pass the step's scope exclusions straight through as `--deny-path` (repeatable) — do not invert them into an allow-list. Paths may be absolute or repo-relative; the guardrail normalises both. [STEP-SPECIFIC CONTENT — all steps in this group, each copied verbatim] ### Step <N>: <title> @@ -75,11 +104,26 @@ Copy the step fields verbatim from the plan — do not summarise or compress the Executors run `guardrail.py` themselves as their final action per step and return the **verbatim JSON** (`{"pass": bool, "acceptance_rc": int, "scope_violations": [...], "log": "..."}`). Do **not** re-run the guardrail from the orchestrator for passing steps — that is a redundant round-trip. `guardrail.py` is deterministic, so the returned JSON is trustworthy. +**If a dispatch ran in the background** (you received only a completion *notification*, not the executor's final message), you do not have its guardrail JSON. Retrieve the executor's result before proceeding — message the agent for its verbatim per-step JSON — or, if that is unavailable, re-run each step's `acceptance` yourself once from the repo root and treat that as the verdict. Never mark a backgrounded step done on the notification alone. + For each returned step verdict: - **`pass` is true:** proceed. (Spot-check by re-running the guardrail yourself only if a verdict looks implausible — e.g. claims pass on a step it also reports it could not complete.) - **`pass` is false:** follow the retry/escalation ladder in §7. +## 6a. Record every verdict to the ledger (so a restart can resume) + +Immediately after reading each step's verdict — **before dispatching the next step** — persist it. This is what makes resume work: if the session dies on the next step, this one is already banked. Record as soon as you know the outcome, not in a batch at the end. + +- **On pass:** + `python3 ${CLAUDE_PLUGIN_ROOT}/scripts/store.py step-set --session <session_id> --plan-hash <plan_hash> --index <N> --status done --title "<title>" --route <route> --tier <inline|haiku|sonnet|opus>` +- **On fail (before retrying):** record `--status failed`, and persist the attempt's diff and guardrail verdict so a post-restart retry can patch instead of reimplementing. Write the diff and verdict JSON to temp files and pass them (avoids shell-quoting large/multiline content): + `python3 ${CLAUDE_PLUGIN_ROOT}/scripts/store.py step-set --session <session_id> --plan-hash <plan_hash> --index <N> --status failed --title "<title>" --route <route> --tier <tier> --attempts <n> --diff-file <path> --verdict-file <path>` + +When a previously-failed step later passes, overwrite it with `--status done` as above. + +If `orchestrator_resume_enabled` is false, skip all ledger writes. + ## 7. Retry and escalation ladder On a FAIL: @@ -90,6 +134,8 @@ On a FAIL: 3. **If the escalated attempt also fails** (or a `needs-strong` step on Opus keeps failing): stop that step, report the full guardrail output to the user, and ask whether to skip or abort. Never silently continue past a failing step. Never delegate a `needs-strong` step to a weaker model. +**Update the ledger on every rung.** Each time you re-dispatch a failing step, first write the latest failed attempt to the ledger via §6a with the *cumulative* `--attempts` count and the `--tier` you are now at. That is what lets a resumed session (§1a) continue this ladder from the right rung instead of re-spending retries. When the step finally passes, record it `done`. + ## 8. Gate the reviewer The guardrail (acceptance + scope) is the routine review, so a step that passes its guardrail does **not** get a separate reviewer pass. Invoke `nexum-reviewer` only for: steps that failed and were escalated, `needs-strong` steps, or steps that touched many files. This avoids doubling requests on the common path. diff --git a/commands/nx-load.md b/commands/nx-load.md new file mode 100644 index 0000000..81f9799 --- /dev/null +++ b/commands/nx-load.md @@ -0,0 +1,32 @@ +--- +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 is (usually) in a fresh session and wants to pick up where a previous session left off. Load the most recent handoff and continue the work — explicitly, because the user asked, not automatically. + +## 1. Locate the handoff + +Resolve the data directory the same way `store.py` does: `$CLAUDE_PLUGIN_DATA`, else `${CLAUDE_PLUGIN_ROOT}/.nexum-data`, else `./.nexum-data`. + +Read `<data_dir>/handoff/latest.md`. +- If it does not exist, check `<data_dir>/handoff/` for any `*.md` and offer the most recently modified one. +- If there is no handoff at all, tell the user: `[nexum] No handoff found. Nothing to resume.` and stop. + +## 2. Sanity-check freshness and origin + +- Print the handoff's **Written** timestamp and **Branch**. If the handoff is old (e.g. more than a day) or names a branch that isn't the current one (`git rev-parse --abbrev-ref HEAD`), say so plainly — the user may not want to resume it. Ask before proceeding if there's a mismatch. +- A handoff may be a **rich** one (from `/nx-save`) or an **auto-skeleton** (git state only, written by the hook past the context threshold). If it's the skeleton, say so: it has no decisions/next-steps narrative, so you will reconstruct intent from the git diff. + +## 3. Re-establish state (don't trust the handoff blindly) + +Run, from the repo root: +- `git rev-parse --abbrev-ref HEAD`, `git status --short`, `git diff --stat` — confirm the working tree matches what the handoff describes. +- If the handoff references a nexum plan or step ledger, the work may be resumable via `/nx-build` (which itself resumes completed steps). Mention that if relevant. + +If the actual git state contradicts the handoff (e.g. it says files are uncommitted but the tree is clean), surface the discrepancy rather than acting on stale notes. + +## 4. Summarize and continue + +Print a short summary: the goal, what was done/verified, and the next concrete step from the handoff (or, for a skeleton, the next step you infer from the diff). Then proceed with that next step — or, if the goal is ambiguous from a skeleton, ask the user to confirm the goal before doing work. + +Do not delete or move `latest.md`; leave it in place so the user can re-run this if needed. diff --git a/commands/nexum-plan.md b/commands/nx-plan.md similarity index 86% rename from commands/nexum-plan.md rename to commands/nx-plan.md index dfda9b3..a08d90b 100644 --- a/commands/nexum-plan.md +++ b/commands/nx-plan.md @@ -30,6 +30,8 @@ Assign every step exactly one route. Use the cheapest tier that is sufficient: When in doubt, prefer **standard** over **mechanical** — a false mechanical that fails wastes more time than a conservative standard. +**Dependency-vs-tier rule.** The implementer executes tiers in the order mechanical → standard → needs-strong, so a step must never be routed to a tier that runs *before* a step it depends on. If step B consumes step A's output, B's tier must be the same as or costlier than A's. In particular: a test step that exercises code written in a `standard` step is itself `standard` (not `mechanical`); and a final full-suite / verification step takes the highest tier of any step it validates (or, if you keep it `mechanical`, state explicitly in its `objective` that it runs last). Ordering steps so prerequisites come first is not enough — the tier assignment must also respect the dependency, or the cheaper tier will run first and fail. + ## 3. Step schema Every step MUST include all six fields, in this exact format: @@ -80,7 +82,7 @@ Write the plan file as a Markdown document with this structure: ... ``` -After writing the file, print its path to the user and give a one-line summary of each step (step number, route, title) so the user can review the plan before running `/nexum-implement`. +After writing the file, print its path to the user and give a one-line summary of each step (step number, route, title) so the user can review the plan before running `/nx-build`. ## 6. Constraints diff --git a/commands/nx-save.md b/commands/nx-save.md new file mode 100644 index 0000000..2f9a441 --- /dev/null +++ b/commands/nx-save.md @@ -0,0 +1,60 @@ +--- +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. Use this when the context window is filling up or the 5-hour plan limit is nearly exhausted and continuing in a fresh session (or after the reset) is cheaper/cleaner. + +## 1. Resolve the handoff path + +Resolve the data directory the same way `store.py` does: `$CLAUDE_PLUGIN_DATA`, else `${CLAUDE_PLUGIN_ROOT}/.nexum-data`, else `./.nexum-data`. Session id comes from `$CLAUDE_SESSION_ID` (or `_nosession`). + +Create `<data_dir>/handoff/` if needed. Write the handoff to BOTH: +- `<data_dir>/handoff/<session_id>.md` (the durable per-session copy), and +- `<data_dir>/handoff/latest.md` (a stable path a fresh session can find without knowing the old id). + +Note: the `context_watch` hook may have already auto-written a deterministic *skeleton* to these paths once context crossed `handoff_threshold_tokens` (git state only, no narrative). This command produces the **rich** handoff and overwrites that skeleton — which is the intended result. A fresh session picks up whichever `latest.md` is present when the user runs `/nx-load`, so your richer version wins. + +## 2. Gather real state (do not guess) + +Before writing, collect concrete facts: +- `git rev-parse --abbrev-ref HEAD` (current branch) and `git status --short` and `git diff --stat` (uncommitted work). Note untracked scratch files. +- The session task, if recorded: read it via `python3 -c "import sys; sys.path.insert(0,'${CLAUDE_PLUGIN_ROOT}/scripts'); import store; print(store.get_session_task('<session_id>') or '')"`. +- The actual work done this session from the conversation: decisions made, what was verified (and how), and what is still open. + +## 3. Write the handoff (self-contained, concise) + +The file MUST let a cold session resume without re-deriving context. Use this structure: + +```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: what the overall task is trying to achieve> + +## State now +- <what is DONE and verified — name the verification, e.g. "tests pass: python3 -m pytest -q (221 passed)"> +- Uncommitted changes: <git diff --stat summary>; untracked: <files> +- <key decisions / findings that are expensive to rediscover> + +## Next steps (in order) +1. <the very next concrete action, with the file path / command> +2. ... + +## How to resume +- Read: <the 1-3 files most worth opening first, with paths> +- Run: <the command(s) that re-establish where things stand, e.g. `git status`, the test suite> +- Watch out for: <gotchas, e.g. live plugin runs from the worktree, not the cache> +``` + +Rules: +- Name every file path explicitly; a fresh session cannot see this conversation. +- Convert relative times to absolute. Quote exact commands. +- Be concise — capture signal, not a transcript. Do NOT paste large diffs; summarise and point to `git diff`. +- Do not invent state you did not verify; if something is uncertain, say so. + +## 4. Report + +Print the absolute path of the written handoff and a one-line summary. Tell the user they can start a fresh session and run `/nx-load` is NOT required — instead instruct: open the new session and say "read <data_dir>/handoff/latest.md and continue", or `cat` that file. If a context limit (not the plan limit) triggered this, also suggest `/compact` as the lighter alternative when a full reset isn't needed. diff --git a/commands/nexum-statusline.md b/commands/nx-status.md similarity index 100% rename from commands/nexum-statusline.md rename to commands/nx-status.md diff --git a/nexum-review.md b/nexum-review.md new file mode 100644 index 0000000..916a85e --- /dev/null +++ b/nexum-review.md @@ -0,0 +1,55 @@ +# Nexum Codebase Review + +## What It Does + +**Nexum** is a Claude Code plugin that reduces context token usage and model costs via three pillars: + +1. **Context-savings hooks** — `truncate.py` shrinks large tool outputs (head+tail+error-lines), `dedup.py` collapses repeated identical outputs into one-line pointers +2. **Cost-driven planner & executor** — `nexum-plan`/`nexum-implement` commands route work to Haiku/Sonnet/Opus subagents based on complexity tier, with guardrails and retry/escalation +3. **Lifecycle & hygiene guards** — `context_watch.py` detects mid-session task switches (e.g. fix→feature) and blocks them; `scan_guard.py` denies unscoped recursive searches; `audit.py` finds unignored noisy dirs + +--- + +## Logic Flaws + +### Behavioral Issues + +1. **`truncate.py:96-100` — dead code branch** + The inner `if len(text) > 10000` is guaranteed true (outer check on line 94 already passed it); the `else: return (text, False)` branch is unreachable. + +2. **`truncate.py:83` vs SPEC — error-line cap mismatch** + SPEC says "cap extra at 40 lines" but the config default is `truncate_max_lines: 200` and the code uses that value. SPEC and implementation disagree by 5x. + +3. **`context_watch.py:229` — blocked prompts inflate token total** + `_accumulate_tokens()` runs before any guard check, so every blocked prompt counts toward the compaction warning threshold. A user repeatedly hitting the intent guard can trigger a false compaction warning. + +4. **`context_watch.py:114-116` — fragile task-type prefix matching** + `_derive_task_type` returns the first match in dict-iteration order, making results dependent on insertion ordering. A word that matches both "integrat" (feature) and "debug" (fix) arbitrarily picks whichever dict entry comes first. + +5. **`audit.py:123-141` — `_is_matched` ignores `!` negation patterns** + Gitignore supports `!important.log` to un-ignore a file, but the audit treats it as ignored, producing false negatives. + +### Redundant / Dead Code + +6. **`scan_guard.py:79-82` — duplicate path check** + The second `raw` block (`path.lstrip("/")`) is entirely redundant with the first `p` block. Every case it could catch is already caught by the first normalization. + +7. **`audit.py:360` — redundant condition** + `stripped != _NEXUM_MARKER` is subsumed by `not stripped.startswith("# nexum")`. + +### Design Concerns + +8. **`store.py:196-223` — in-memory fallback is a mirage** + If the SQLite file corrupts, each call to `db()` creates a *fresh* in-memory DB with no shared state. All helpers that call `db()+close()` (like `seen_output` then `record_output`) see different databases. The fallback silently turns all hooks into no-ops. + +9. **`store.py:191` — `PRAGMA foreign_keys=ON` on a schema with zero foreign keys** + Harmless but misleading. + +10. **`cost_report.py:75` — "all-opus baseline" inflates savings** + The report compares actual cost to what everything *would* cost at Opus rates. Since the alternative to routing isn't "all Opus", this savings figure is synthetic. A comparison against "all Sonnet" would be more honest. + +11. **`scan_guard.py:190-201` — `_is_unscoped_grep` false-positives on `grep -r --include='*.py'`** + A grep with `--include`/`--exclude-dir` but no explicit path is treated as unscoped, even though it's semantically scoped by file type. + +12. **`dedup.py:127-135` — savings recorded even when dedup was a no-op on the model** + If the prior output was already truncated down to ~200 lines and the repeat is collapsed to a pointer, the "saved" count compares original(1000 lines) - pointer(1 line) = 999, but the model only ever saw ~200 lines from the first occurrence. The savings figure overstates what was *actually* avoided in context. diff --git a/scripts/context_watch.py b/scripts/context_watch.py index 5b10df9..3ca23fe 100644 --- a/scripts/context_watch.py +++ b/scripts/context_watch.py @@ -2,9 +2,11 @@ context_watch.py — Nexum UserPromptSubmit hook. Two responsibilities: - (a) Compaction prompt: warn the user when the running token estimate for the - session crosses compaction_threshold_tokens (emits a systemMessage once - per window). + (a) Context nudges: warn the user as the running token estimate grows. + At handoff_threshold_tokens, suggest /nx-save (capture a resume + point while context is still clean); at the higher + compaction_threshold_tokens, suggest /compact. Each fires at most once + per session window; the compaction warning takes precedence. (b) Intent-change guard: detect when the user has shifted tasks mid-session (e.g. fix→feature) and block with a helpful message, allowing bypass via the word "continue". @@ -31,6 +33,7 @@ sys.path.insert(0, _SCRIPTS_DIR) import store # noqa: E402 — must be after sys.path tweak +import handoff # noqa: E402 — must be after sys.path tweak # --------------------------------------------------------------------------- @@ -213,6 +216,32 @@ def _block(reason: str) -> dict: return {"decision": "block", "reason": reason} +# Markers that identify a prompt as system-injected / automated rather than a +# genuine user-typed task. The intent-guard must never fire on these (a +# background-agent task-notification, slash-command stdout, or a system reminder +# is not "a new task"), nor adopt them as the session task signature. +_AUTOMATED_PROMPT_MARKERS = ( + "<task-notification>", + "<task-id>", + "<tool-use-id>", + "<command-name>", + "<command-message>", + "<local-command-stdout>", + "<local-command-caveat>", + "<system-reminder>", + "<bash-stdout>", + "<bash-stderr>", +) + + +def _is_automated_prompt(prompt: str) -> bool: + """Return True if *prompt* is a system-injected/automated message (not a + user-typed task), so the intent-guard should be skipped for it.""" + if not prompt: + return False + return any(marker in prompt for marker in _AUTOMATED_PROMPT_MARKERS) + + # --------------------------------------------------------------------------- # Main logic # --------------------------------------------------------------------------- @@ -233,19 +262,74 @@ def _handle(data: dict) -> dict: # ------------------------------------------------------------------ # # (a) Token accumulation + compaction prompt # ------------------------------------------------------------------ # - token_total = _accumulate_tokens(session_id, prompt_stripped) + estimate_total = _accumulate_tokens(session_id, prompt_stripped) + # Prefer Claude Code's REAL measured context size (persisted by the + # statusline hook) over our crude per-prompt estimate, which omits tool + # output and undercounts badly. Use whichever is larger so the threshold + # never fires later than the estimate would have. Falls back to the + # estimate when the statusline hasn't run yet (no real value stored). + token_total = estimate_total + try: + real_raw = _kv_get(session_id, "real_context_tokens") + if real_raw is not None: + token_total = max(estimate_total, int(real_raw)) + except (TypeError, ValueError): + pass compaction_threshold = int(cfg.get("compaction_threshold_tokens", 120000)) + handoff_threshold = int(cfg.get("handoff_threshold_tokens", 100000)) system_message: str | None = None + k_tokens = round(token_total / 1000) + # ------------------------------------------------------------------ # + # Auto-handoff: once context is large enough that a fresh session may be + # needed, write (and keep refreshing) a deterministic handoff skeleton so a + # fresh session can resume it via /nx-load. This is independent of the + # message branch and of the compaction threshold — we refresh latest.md on + # every prompt above the handoff threshold so a resume reflects the most + # recent git state. Deterministic + fail-open: a failure here never blocks + # the prompt. + # ------------------------------------------------------------------ # + handoff_written = False + if ( + handoff_threshold > 0 + and token_total >= handoff_threshold + and cfg.get("handoff_auto_write_enabled", True) + ): + try: + handoff_written = handoff.write_skeleton( + session_id=session_id, + cwd=data.get("cwd"), + token_total=token_total, + ) is not None + except Exception: + handoff_written = False + + # Two staged nudges, each emitted at most once per session window. The + # compaction warning (higher threshold) takes precedence: once context is + # that large, /compact is the more immediate lever. Below it, point the user + # at the handoff that was just auto-written (or suggest writing one). if token_total >= compaction_threshold: - already_warned = _kv_get(session_id, "compaction_warned") - if not already_warned: - k_tokens = round(token_total / 1000) + if not _kv_get(session_id, "compaction_warned"): system_message = ( f"[nexum] Context is large (~{k_tokens}k tokens). " "Consider /compact to reduce cost." ) _kv_set(session_id, "compaction_warned", "1") + elif handoff_threshold > 0 and token_total >= handoff_threshold: + if not _kv_get(session_id, "handoff_warned"): + if handoff_written: + system_message = ( + f"[nexum] Context passed ~{handoff_threshold // 1000}k tokens " + f"(~{k_tokens}k now). Wrote a handoff skeleton — start a fresh " + "session and run /nx-load to resume. Run /nx-save first for a richer one." + ) + else: + system_message = ( + f"[nexum] Context has grown past ~{handoff_threshold // 1000}k tokens " + f"(~{k_tokens}k now). Consider /nx-save to capture a resume " + "point so you can continue cleanly in a fresh session." + ) + _kv_set(session_id, "handoff_warned", "1") # ------------------------------------------------------------------ # # (b) Intent-change guard @@ -270,7 +354,7 @@ def _handle(data: dict) -> dict: return _allow(systemMessage=system_message) return _allow() - if intent_guard_enabled: + if intent_guard_enabled and not _is_automated_prompt(prompt_stripped): raw_task = store.get_session_task(session_id) if raw_task is None: diff --git a/scripts/cost_report.py b/scripts/cost_report.py index 2b87179..2004e89 100644 --- a/scripts/cost_report.py +++ b/scripts/cost_report.py @@ -162,7 +162,7 @@ def build_metered_section(cost_rows: list) -> str: if not cost_rows: return ( "[nexum] No metered cost captured yet. The session-cost snapshot is " - "recorded by the nexum statusLine — install it with /nexum-statusline " + "recorded by the nexum statusLine — install it with /nx-status " "and run at least one turn." ) diff --git a/scripts/dedup.py b/scripts/dedup.py index b1ba41c..f7523d1 100644 --- a/scripts/dedup.py +++ b/scripts/dedup.py @@ -40,6 +40,103 @@ _MIN_LINES = 30 _MIN_CHARS = 2000 +# Self-test: PostToolUse `updatedToolOutput` is silently ignored for built-in +# tools (Bash/Read/Grep/Glob) on current Claude Code (anthropics/claude-code +# #65403, #67442, #32105). When it is ignored, the shrunk/pointer output we emit +# never reaches the model, so recording a "saving" for it would be fiction. We +# therefore gate savings on a per-session, transcript-verified self-test: +# uto_works flag: "yes" → replacements take effect, record real savings. +# "no" → ignored, record nothing (honest). +# unset → undetermined; emit a probe and confirm via transcript. +# This also auto-reactivates savings if a future Claude Code honors the field. +_UTO_FLAG = "uto_works" +_UTO_PROBE = "uto_probe" +# Minimum char gap between original and emitted output before a shrink is usable +# as a self-test probe (so honored-vs-ignored is unambiguous by length). +_PROBE_MIN_GAP = 200 + + +def _verify_pending_probe(session_id: str, transcript_path: str) -> None: + """Resolve a pending updatedToolOutput self-test probe against the transcript. + + If the model's recorded tool result matches the length we emitted → the + replacement was honored (flag "yes", and back-record the probe's saving). + If it matches the original length → ignored (flag "no"). Undetermined + (transcript not yet flushed) → leave pending and retry on a later call. + """ + if store.get_flag(session_id, _UTO_FLAG): + return # already determined + probe_raw = store.get_flag(session_id, _UTO_PROBE) + if not probe_raw: + return + try: + probe = json.loads(probe_raw) + except Exception: + store.set_flag(session_id, _UTO_PROBE, "") + return + actual = store.transcript_tool_result_len(transcript_path, probe.get("tool_use_id", "")) + if actual is None: + return # not yet written; try again next invocation + emitted = int(probe.get("emitted_len", 0)) + original = int(probe.get("original_len", 0)) + if abs(actual - emitted) <= abs(actual - original): + # Replacement landed — the field is honored this session. + store.set_flag(session_id, _UTO_FLAG, "yes") + store.record_saving( + session_id, + probe.get("source", "truncate"), + int(probe.get("saved_tok", 0)), + probe.get("effective_tok"), + ) + else: + store.set_flag(session_id, _UTO_FLAG, "no") + store.set_flag(session_id, _UTO_PROBE, "") + + +def _maybe_record_saving( + session_id: str, + source: str, + saved: int, + effective, + tool_use_id: str, + emitted_len: int, + original_len: int, +) -> None: + """Record a saving only if the session self-test confirms replacements work. + + Before the test resolves, stash one probe describing this emission so a later + call can verify it via the transcript. Never over-counts: an unverified or + failed self-test records nothing. + """ + if saved <= 0: + return + verdict = store.get_flag(session_id, _UTO_FLAG) + if verdict == "yes": + store.record_saving(session_id, source, saved, effective) + return + if verdict == "no": + return # replacement ignored by the harness — no real saving + # Undetermined: arm a single probe (needs an unambiguous length gap + an id). + if ( + tool_use_id + and (original_len - emitted_len) >= _PROBE_MIN_GAP + and not store.get_flag(session_id, _UTO_PROBE) + ): + store.set_flag( + session_id, + _UTO_PROBE, + json.dumps( + { + "tool_use_id": tool_use_id, + "emitted_len": emitted_len, + "original_len": original_len, + "saved_tok": saved, + "effective_tok": effective, + "source": source, + } + ), + ) + def _is_large(text: str) -> bool: """Return True if the output is large enough to be worth deduplicating.""" @@ -98,6 +195,16 @@ def main() -> None: # ---------------------------------------------------------------- session_id = data.get("session_id") or "_nosession" tool_name = data.get("tool_name") or "unknown" + tool_use_id = data.get("tool_use_id") or "" + transcript_path = data.get("transcript_path") or "" + + # Resolve any outstanding self-test probe from a prior call: did the + # output we replaced last time actually reach the model? (See the + # _UTO_* helpers above.) Cheap no-op once the verdict is known. + try: + _verify_pending_probe(session_id, transcript_path) + except Exception: + pass # ---------------------------------------------------------------- # 5. Load config (needed for shrink; fail-open if unavailable) @@ -138,7 +245,12 @@ def main() -> None: # the reported savings reflect actual API spend. weight = float(cfg.get("dedup_cache_weight", 0.1)) effective = max(0, round(saved * weight)) - store.record_saving(session_id, "dedup", saved, effective) + # Gated on the self-test: only counts if the pointer actually + # replaces the repeated output in the model's context. + _maybe_record_saving( + session_id, "dedup", saved, effective, + tool_use_id, len(pointer), len(output), + ) except Exception: pass response = { @@ -159,7 +271,12 @@ def main() -> None: try: saved = store.estimate_tokens(output) - store.estimate_tokens(shrunk) if saved > 0: - store.record_saving(session_id, "truncate", saved) + # Gated on the self-test: only counts if the shrunk output + # actually replaces the original in the model's context. + _maybe_record_saving( + session_id, "truncate", saved, None, + tool_use_id, len(shrunk), len(output), + ) except Exception: pass diff --git a/scripts/guardrail.py b/scripts/guardrail.py index 5eb6aef..0cc2550 100644 --- a/scripts/guardrail.py +++ b/scripts/guardrail.py @@ -6,8 +6,11 @@ python3 guardrail.py --acceptance "<cmd>" --scope-root <dir> [--scope-root <dir2>] \ --changed <f1,f2,...> -Runs the acceptance command and checks that all changed files are under at least -one allowed scope root. Outputs a single JSON object to stdout. +Runs the acceptance command and checks that the changed files respect scope. A +changed file is a violation if it is outside every allowed --scope-root, OR if it +is under any --deny-path. --deny-path mirrors a plan step's "scope: do NOT touch +X" language directly, so the orchestrator can pass the exclusions verbatim +instead of inverting them into an allow-list. Outputs a single JSON object. Output shape: { @@ -20,7 +23,8 @@ Edge cases (§4.4): - No --acceptance given → pass=true, acceptance_rc=0, note in log. - Acceptance times out → pass=false, acceptance_rc=124. -- No --scope-root given → skip scope check (scope_violations=[]). +- No --scope-root given → skip the allow-list check. +- No --deny-path given → skip the deny check. - No --changed given → no files to check (scope_violations=[]). """ @@ -35,31 +39,23 @@ _LOG_TAIL_CHARS = 4096 -def _resolve_scope_roots(raw_roots: list[str]) -> list[str]: +def _flatten_csv(raw_values: list[str]) -> list[str]: """ - Accept --scope-root values that may themselves be comma-separated lists, + Accept repeatable CLI values that may themselves be comma-separated lists, expand them, and return a flat list of stripped, non-empty strings. """ - roots: list[str] = [] - for entry in raw_roots: + out: list[str] = [] + for entry in raw_values: for part in entry.split(","): part = part.strip() if part: - roots.append(part) - return roots + out.append(part) + return out -def _resolve_changed_files(raw_changed: list[str]) -> list[str]: - """ - Accept --changed values that may be comma-separated; return a flat list. - """ - files: list[str] = [] - for entry in raw_changed: - for part in entry.split(","): - part = part.strip() - if part: - files.append(part) - return files +# Backwards-compatible aliases (kept so existing imports/callers keep working). +_resolve_scope_roots = _flatten_csv +_resolve_changed_files = _flatten_csv def _is_under_root(file_path: str, root: str) -> bool: @@ -69,9 +65,14 @@ def _is_under_root(file_path: str, root: str) -> bool: Comparison is done on normalised paths so that 'src/a.py' is considered under 'src' even when the caller omits a trailing slash. """ - # Normalise both sides so we can do a clean prefix check. - norm_file = os.path.normpath(file_path) - norm_root = os.path.normpath(root) + # Normalise both sides to ABSOLUTE paths before comparing. The orchestrator + # commonly passes a relative --changed (e.g. "tests/x.py") alongside an + # absolute --scope-root (or vice versa); os.path.commonpath raises ValueError + # when mixing absolute and relative paths, which previously surfaced as a + # spurious scope violation. abspath() resolves both against the cwd so the + # prefix check is consistent regardless of how the caller spelled the paths. + norm_file = os.path.abspath(file_path) + norm_root = os.path.abspath(root) # A file is under a root when the root is a path-component prefix of the # file path — i.e. the file's path starts with "<root>/". @@ -138,6 +139,17 @@ def main() -> None: "Also accepts a comma-separated list of roots in a single value." ), ) + parser.add_argument( + "--deny-path", + dest="deny_paths", + metavar="DIR", + action="append", + default=[], + help=( + "Path a changed file must NOT be under (repeatable; also accepts a " + "comma-separated list). Mirrors a plan step's 'do NOT touch X' scope." + ), + ) parser.add_argument( "--changed", metavar="FILES", @@ -168,14 +180,21 @@ def main() -> None: # ------------------------------------------------------------------ # 2. Scope check # ------------------------------------------------------------------ - scope_roots = _resolve_scope_roots(args.scope_roots) - changed_files = _resolve_changed_files(args.changed) + scope_roots = _flatten_csv(args.scope_roots) + deny_paths = _flatten_csv(args.deny_paths) + changed_files = _flatten_csv(args.changed) scope_violations: list[str] = [] - if scope_roots and changed_files: + if changed_files: for f in changed_files: - if not any(_is_under_root(f, root) for root in scope_roots): + # Violation if outside every allow-list root... + outside_allow = scope_roots and not any( + _is_under_root(f, root) for root in scope_roots + ) + # ...or under any explicitly denied path. + under_deny = any(_is_under_root(f, deny) for deny in deny_paths) + if outside_allow or under_deny: scope_violations.append(f) # ------------------------------------------------------------------ diff --git a/scripts/handoff.py b/scripts/handoff.py new file mode 100644 index 0000000..890ef55 --- /dev/null +++ b/scripts/handoff.py @@ -0,0 +1,181 @@ +""" +handoff.py — write a deterministic session-handoff skeleton (no model needed). + +Used by context_watch.py to auto-write a handoff when the running context +estimate crosses the handoff threshold, and available as a CLI. The skeleton +captures only what can be gathered WITHOUT conversation context: git +branch/status/diff-stat/recent commits, the stored session task signature, the +running token estimate, and a timestamp. Because the hook (not the model) writes +it, a handoff is guaranteed to exist even if the session then dies — but it has +no decisions/next-steps narrative. A fresh session loads it on demand via the +/nx-load command; the user can run /nx-save for a richer version. + +CLI: + python3 handoff.py write --session <id> [--cwd <dir>] [--tokens <n>] + +Fail-open: errors degrade to an empty/partial skeleton rather than raising, so a +hook calling this never breaks the turn. +""" + +from __future__ import annotations + +import argparse +import datetime +import json +import os +import subprocess +import sys + +_SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__)) +if _SCRIPTS_DIR not in sys.path: + sys.path.insert(0, _SCRIPTS_DIR) + +import store # noqa: E402 — after sys.path tweak + + +def _git(cwd: str, *args: str) -> str: + """Run a git command in *cwd*, returning stripped stdout ('' on any error).""" + try: + out = subprocess.run( + ["git", *args], + cwd=cwd or None, + capture_output=True, + text=True, + timeout=5, + ) + if out.returncode != 0: + return "" + return out.stdout.strip() + except Exception: + return "" + + +def _humanize_task(raw: str | None) -> str: + """Render the stored task signature into a readable line. + + context_watch stores the task as a packed JSON signature (keyword list with + an optional ``__type__:`` marker), not free text. Best-effort unpack it; + fall back to the raw string or a placeholder. + """ + if not raw: + return "(none recorded)" + try: + parts = json.loads(raw) + if isinstance(parts, list): + task_type = None + keywords = [] + for p in parts: + if isinstance(p, str) and p.startswith("__type__:"): + task_type = p[len("__type__:"):] + else: + keywords.append(p) + bits = [] + if task_type: + bits.append(f"type: {task_type}") + if keywords: + bits.append("keywords: " + ", ".join(sorted(keywords))) + return "; ".join(bits) if bits else "(none recorded)" + except Exception: + pass + return str(raw) + + +def _indent_block(text: str, placeholder: str) -> str: + """Return *text* as an indented code block, or a placeholder if empty.""" + text = (text or "").strip() + if not text: + return f" {placeholder}" + return "\n".join(" " + line for line in text.splitlines()) + + +def build_skeleton(session_id: str, cwd: str, token_total: int = 0) -> str: + """Build the handoff skeleton markdown from git + stored state.""" + cwd = cwd or os.getcwd() + branch = _git(cwd, "rev-parse", "--abbrev-ref", "HEAD") or "(unknown)" + status = _git(cwd, "status", "--short") + diffstat = _git(cwd, "diff", "--stat") + commits = _git(cwd, "log", "--oneline", "-5") + task = _humanize_task(store.get_session_task(session_id)) + now = datetime.datetime.now().astimezone().isoformat(timespec="seconds") + k_tokens = round((token_total or 0) / 1000) + + return f"""# Handoff (auto-skeleton): {branch} + +**Session:** {session_id} **Branch:** {branch} **Written:** {now} +**Why now:** context crossed the auto-handoff threshold (~{k_tokens}k tokens). +**Note:** Auto-generated by the nexum hook with NO conversation context — it has +git state but no decisions/next-steps narrative. For a richer handoff, run +`/nx-save` in the originating session before it ends. + +## Task (recorded signature) +{task} + +## Git state (at write time) +- Branch: `{branch}` +- Uncommitted changes (`git status --short`): +{_indent_block(status, "(working tree clean)")} +- Diff stat (`git diff --stat`): +{_indent_block(diffstat, "(no unstaged changes)")} +- Recent commits (`git log --oneline -5`): +{_indent_block(commits, "(no commits)")} + +## How to resume +- Run `git status` and `git diff` to see in-progress work. +- Open the files listed in the diff stat above first. +- This is a skeleton: reconstruct intent from the diff + task signature, or ask + the user for the goal if it is unclear. +""" + + +def write_skeleton( + session_id: str, + data_dir: str | None = None, + cwd: str | None = None, + token_total: int = 0, +) -> str | None: + """Write the skeleton to handoff/<session>.md and handoff/latest.md. + + Returns the per-session file path, or None on failure (fail-open). + """ + try: + data_dir = data_dir or store.nexum_data_dir() + handoff_dir = os.path.join(data_dir, "handoff") + os.makedirs(handoff_dir, exist_ok=True) + content = build_skeleton(session_id, cwd or os.getcwd(), token_total) + per_session = os.path.join(handoff_dir, f"{session_id}.md") + latest = os.path.join(handoff_dir, "latest.md") + for path in (per_session, latest): + with open(path, "w", encoding="utf-8") as fh: + fh.write(content) + return per_session + except Exception: + return None + + +def _cmd_write(args) -> None: + path = write_skeleton( + session_id=args.session, + cwd=args.cwd, + token_total=args.tokens or 0, + ) + print(json.dumps({"ok": path is not None, "path": path})) + + +def main() -> None: + parser = argparse.ArgumentParser(prog="handoff.py", description="Nexum handoff skeleton writer.") + sub = parser.add_subparsers(dest="command") + p_write = sub.add_parser("write", help="Write the handoff skeleton for a session.") + p_write.add_argument("--session", required=True) + p_write.add_argument("--cwd", default=None) + p_write.add_argument("--tokens", type=int, default=0) + + args = parser.parse_args() + if args.command == "write": + _cmd_write(args) + else: + parser.print_help() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/scan_guard.py b/scripts/scan_guard.py index a3d35aa..24559a5 100644 --- a/scripts/scan_guard.py +++ b/scripts/scan_guard.py @@ -13,6 +13,7 @@ import json import os import re +import shlex import sys # --------------------------------------------------------------------------- @@ -57,6 +58,18 @@ def _update_input(new_command: str) -> None: sys.exit(0) +def _update_tool_input(new_input: dict) -> None: + """Emit a PreToolUse updatedInput (full replacement tool_input) and exit 0.""" + out = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "updatedInput": new_input, + } + } + print(json.dumps(out, sort_keys=True)) + sys.exit(0) + + def _allow() -> None: """Emit {} (allow, no modification).""" print("{}") @@ -91,10 +104,19 @@ def _under_deny(path: str, deny_paths: list) -> bool: # Bash command analysis # --------------------------------------------------------------------------- -# Tokenise a shell command into argv-style tokens (simple, no full shell parse) +# Tokenise a shell command into argv-style tokens (shell-aware via shlex) def _tokens(command: str) -> list: - """Split a command string into whitespace-separated tokens, stripping quotes.""" - return re.split(r'\s+', command.strip()) + """Split a command string into shell tokens, respecting quoted arguments. + + Uses shlex.split so that quoted arguments containing spaces are treated as + single tokens (e.g. ``grep -r "def foo" .`` → ['grep','-r','def foo','.']). + Falls back to simple whitespace-split on ValueError (malformed quotes) so + the function never raises (fail-open). + """ + try: + return shlex.split(command) + except ValueError: + return re.split(r'\s+', command.strip()) def _is_grep_like(cmd: str) -> bool: @@ -305,6 +327,48 @@ def _read_is_denied(tool_input: dict, deny_paths: list) -> bool: return _under_deny(fp, deny_paths) +# Extensions Read handles specially (images / PDFs / notebooks rendered as +# cells) or that are binary — a line `limit`/`offset` is meaningless or harmful +# for these, so the read-guard skips them. +_READ_GUARD_SKIP_EXTS = { + ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg", ".ico", + ".pdf", ".ipynb", + ".zip", ".gz", ".tar", ".tgz", ".bz2", ".xz", ".7z", + ".so", ".dylib", ".dll", ".o", ".a", ".bin", ".exe", ".wasm", + ".mp3", ".mp4", ".mov", ".avi", ".wav", ".woff", ".woff2", ".ttf", +} + + +def _read_limit_input(tool_input: dict, cfg: dict): + """Return an updatedInput dict injecting a line `limit` for a too-large text + file Read, or None to leave the Read untouched. + + Honors an explicit limit the caller already set, skips binary/rendered + file types, and only acts on files above ``read_guard_min_bytes``. Uses + PreToolUse ``updatedInput`` — the working lever for Read (PostToolUse + output shrink is ignored for built-in tools on current Claude Code). + """ + if not cfg.get("read_guard_enabled", True): + return None + fp = tool_input.get("file_path", "") or "" + if not fp: + return None + # Respect an explicit limit/offset the caller chose. + if tool_input.get("limit") is not None or tool_input.get("offset") is not None: + return None + if os.path.splitext(fp)[1].lower() in _READ_GUARD_SKIP_EXTS: + return None + try: + size = os.path.getsize(fp) + except OSError: + return None + if size <= int(cfg.get("read_guard_min_bytes", 262144)): + return None + new_input = dict(tool_input) + new_input["limit"] = int(cfg.get("read_guard_inject_lines", 2000)) + return new_input + + # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- @@ -377,6 +441,11 @@ def main() -> None: if _read_is_denied(tool_input, deny_paths): fp = tool_input.get("file_path", "") _deny(f"file path is inside a high-noise directory ({fp})") + # Cap oversized text-file reads via updatedInput (the working lever + # for Read; PostToolUse output shrink is ignored for built-in tools). + narrowed = _read_limit_input(tool_input, cfg) + if narrowed is not None: + _update_tool_input(narrowed) _allow() else: diff --git a/scripts/statusline.py b/scripts/statusline.py index 93826eb..9c4ebe4 100644 --- a/scripts/statusline.py +++ b/scripts/statusline.py @@ -12,6 +12,7 @@ import json import os import sys +import time # --------------------------------------------------------------------------- @@ -34,43 +35,121 @@ def format_tokens(n: int) -> str: return f"{n / 1000:.1f}k" -def render(data: dict, saved_tokens: int, warn_pct: int = 0, warn_tokens: int = 0) -> str: +def _bar(pct: float) -> str: + """A 10-char progress bar filled to *pct* percent (▓ filled, ░ empty).""" + filled = max(0, min(10, round(pct / 10))) + return "▓" * filled + "░" * (10 - filled) + + +def format_reset(resets_at, now: float) -> str: + """Format a rate-limit reset time as a compact 'time until reset' string. + + e.g. 2h05m, 45m, 1d03h, or 'now' if already past. Empty string if the + timestamp is missing/unparseable. + """ + try: + delta = int(resets_at) - int(now) + except (TypeError, ValueError): + return "" + if delta <= 0: + return "now" + mins = delta // 60 + hours, mins = divmod(mins, 60) + if hours >= 24: + days, hours = divmod(hours, 24) + return f"{days}d{hours:02d}h" + if hours: + return f"{hours}h{mins:02d}m" + return f"{mins}m" + + +def render( + data: dict, + saved_tokens: int, + warn_pct: int = 0, + warn_tokens: int = 0, + plan_warn_pct: int = 0, + now: float | None = None, +) -> str: """Return ONE compact status line (no trailing newline). + Leads with the subscription plan's **5-hour session window** — how much of + the rate limit is left and when it resets (`rate_limits.five_hour`) — since + that is the limit that actually gates a working session. The 7-day window is + shown compactly when present. When `rate_limits` is absent (e.g. API-key + users), falls back to context-window usage. + Parameters ---------- data: Parsed session JSON from Claude Code's statusLine hook. saved_tokens: Tokens saved by nexum this session (0 → omit the segment). - warn_pct: Context-usage percentage threshold above which a compaction - warning is appended. 0 means disabled (no warning). - warn_tokens: Absolute context token threshold above which a compaction - warning is appended. 0 means disabled (no warning). + warn_pct: Context-usage percentage threshold above which a context + warning is appended. 0 means disabled. + warn_tokens: Absolute context token threshold above which a context + warning is appended. 0 means disabled. + now: Current epoch seconds (for reset countdowns); defaults to + time.time(). Injectable for deterministic tests. """ + if now is None: + now = time.time() + model = (data.get("model") or {}).get("display_name") or "?" + cost = float((data.get("cost") or {}).get("total_cost_usd") or 0.0) cw = data.get("context_window") or {} pct = int(cw.get("used_percentage") or 0) ctx_tok = (cw.get("total_input_tokens") or 0) + (cw.get("total_output_tokens") or 0) - cost = float((data.get("cost") or {}).get("total_cost_usd") or 0.0) - - # Progress bar: 10 chars wide - filled = max(0, min(10, round(pct / 10))) - bar = "▓" * filled + "░" * (10 - filled) - - parts = [ - f"nexum {model}", - f"{bar} {pct}%", - f"{format_tokens(ctx_tok)} tok", - f"${cost:.2f}", - ] + parts = [f"nexum {model}"] + + rate_limits = data.get("rate_limits") or {} + five = rate_limits.get("five_hour") or {} + seven = rate_limits.get("seven_day") or {} + five_used = five.get("used_percentage") + + if five_used is not None: + # Subscription plan mode: usage LEFT for the current 5-hour window. + # The bar fills with REMAINING budget (full bar = plenty left). + left = max(0, min(100, int(round(100 - float(five_used))))) + seg = f"{_bar(left)} {left}% left" + reset = format_reset(five.get("resets_at"), now) + if reset: + seg += f" · ↻{reset}" + parts.append(seg) + + seven_used = seven.get("used_percentage") + if seven_used is not None: + left7 = max(0, min(100, int(round(100 - float(seven_used))))) + parts.append(f"wk {left7}%") + + # Context-window usage, shown compactly alongside the plan window. + parts.append(f"ctx {pct}% · {format_tokens(ctx_tok)}") + else: + # Fallback: no plan rate-limit data → context-window usage is primary. + parts.append(f"{_bar(pct)} {pct}%") + parts.append(f"{format_tokens(ctx_tok)} tok") + + parts.append(f"${cost:.2f}") if saved_tokens and saved_tokens > 0: parts.append(f"saved {format_tokens(saved_tokens)}") - should_warn = (warn_pct and warn_pct > 0 and pct >= warn_pct) or (warn_tokens and warn_tokens > 0 and ctx_tok >= warn_tokens) + # Context near limit → suggest /compact or a handoff to a fresh session. + should_warn = (warn_pct and warn_pct > 0 and pct >= warn_pct) or ( + warn_tokens and warn_tokens > 0 and ctx_tok >= warn_tokens + ) if should_warn: - parts.append("⚠ /compact") + parts.append("⚠ /compact · /nx-save") + + # 5-hour plan window nearly exhausted → suggest a handoff so work can resume + # in a fresh session after the window resets. + if ( + five_used is not None + and plan_warn_pct + and plan_warn_pct > 0 + and float(five_used) >= plan_warn_pct + ): + parts.append("⚠ plan low · /nx-save") return " · ".join(parts) @@ -130,6 +209,19 @@ def _capture_session_cost(store, session_id: str, data: dict) -> None: cache_creation_tok=cw.get("cache_creation_input_tokens") or cw.get("total_cache_creation_tokens") or 0, ) + # Persist the REAL context size so context_watch can trigger its + # handoff/compaction thresholds off Claude Code's own measurement + # rather than its crude per-prompt token estimate (which omits tool + # output and so massively undercounts). This is the same value the + # status line shows as `ctx … tok`. + real_ctx_tok = int(cw.get("total_input_tokens") or 0) + int( + cw.get("total_output_tokens") or 0 + ) + if real_ctx_tok > 0: + store.set_flag(session_id, "real_context_tokens", str(real_ctx_tok)) + real_pct = int(cw.get("used_percentage") or 0) + if real_pct > 0: + store.set_flag(session_id, "real_context_pct", str(real_pct)) except Exception: pass diff --git a/scripts/store.py b/scripts/store.py index b99c5d3..9e8ed02 100644 --- a/scripts/store.py +++ b/scripts/store.py @@ -46,6 +46,20 @@ "truncate_min_lines_to_act": 240, "keep_error_regex": "(?i)(error|exception|traceback|failed|fatal|warning)", "compaction_threshold_tokens": 120000, + # When the running session token estimate crosses this, context_watch + # nudges the user (once per window) to run /nx-save and capture a + # resume point before the window fills. Fires below the compaction + # threshold so a handoff can be written while context is still clean. + # 0 disables the handoff nudge. + "handoff_threshold_tokens": 100000, + # Auto-handoff: when context crosses handoff_threshold_tokens, context_watch + # writes a deterministic handoff skeleton (git state + task + tokens) to + # handoff/<session>.md and handoff/latest.md — no model involvement, so it + # is guaranteed even if the session then dies. Resume is NOT automatic: a + # fresh session picks it up only when the user runs /nx-load (auto-injecting + # prior context into every new session was judged too risky). 0 also via the + # handoff_threshold disables the write. + "handoff_auto_write_enabled": True, "scan_guard_enabled": True, "scan_deny_paths": [ "node_modules", ".git", "dist", "build", "target", "vendor", @@ -53,20 +67,47 @@ ], "intent_guard_enabled": True, "intent_similarity_threshold": 0.25, + # Read-guard: cap Read of very large text files via PreToolUse updatedInput + # (inject a line `limit`). PostToolUse output shrink cannot help here — + # `updatedToolOutput` is ignored for built-in tools on current Claude Code — + # but PreToolUse `updatedInput` IS honored for Read, so this is the working + # lever for Read context savings. Only acts on files above the byte + # threshold that don't already carry an explicit limit; the model can always + # re-read further with an offset. + "read_guard_enabled": True, + "read_guard_min_bytes": 262144, # 256 KB — only intervene on big files + "read_guard_inject_lines": 2000, # injected line limit (Read's own default cap) "statusline_compaction_warn_pct": 80, "statusline_compaction_warn_tokens": 80000, + # When the 5-hour subscription plan window is this % used or more, the status + # line suggests writing a handoff (/nx-save) so work can resume in a + # fresh session after the window resets. 0 disables the plan warning. + "statusline_plan_warn_pct": 90, # Dollar-weight applied to dedup (pointer-collapse) savings. A repeated tool # output would, under Claude Code's automatic prompt caching, bill at the # cache-read rate (~0.1x input) rather than full price — so collapsing it # saves ~0.1x of its tokens in dollar terms, not 1x. Truncation of fresh # (never-cached) output is weighted 1.0. Tunable for non-cached setups. "dedup_cache_weight": 0.1, - # /nexum-implement dispatch granularity: "group" sends a whole route-tier + # /nx-build dispatch granularity: "group" sends a whole route-tier # of steps to ONE executor dispatch (warm context, one cached prefix); # "step" sends one dispatch per step (more isolation, more cold starts). "dispatch_granularity": "group", # Same-tier retries before escalating a failing step one tier up. "max_same_tier_retries": 1, + # Upper bound on steps sent in a single executor dispatch under "group" + # granularity. A whole route-tier is still grouped for cache warmth, but a + # tier with more than this many steps is split into sub-batches so one + # dispatch can't overflow the executor's context (which would force a + # mid-batch compaction and re-derivation) or widen the blast radius of a + # single failure. 0 disables the cap (send the entire tier at once). + "max_steps_per_dispatch": 6, + # Resume: /nx-build persists each step's verdict to the step_ledger + # table and, on a re-run for the same plan, skips already-`done` steps and + # patch-retries `failed` ones from their saved diff — so a session that died + # mid-plan resumes instead of redoing completed work. Set False to always + # execute every step from scratch. + "orchestrator_resume_enabled": True, } # --------------------------------------------------------------------------- @@ -130,6 +171,29 @@ updated_ts REAL ) """, + # Step ledger — durable per-step execution state for /nx-build, so a + # session that dies mid-plan resumes instead of redoing completed steps. + # Keyed by (session_id, plan_hash, step_index): plan_hash ties a row to a + # specific plan content, so editing the plan naturally invalidates stale + # state (a new hash → no matching rows → clean start). status is one of + # pending | done | failed. last_diff/verdict persist the failed attempt so a + # retry (even across a restart) can patch rather than reimplement. + """ + CREATE TABLE IF NOT EXISTS step_ledger( + session_id TEXT, + plan_hash TEXT, + step_index INTEGER, + title TEXT, + route TEXT, + status TEXT, + tier_used TEXT, + last_diff TEXT, + verdict TEXT, + attempts INTEGER, + updated_ts REAL, + PRIMARY KEY(session_id, plan_hash, step_index) + ) + """, ] # Column migrations for databases created by an earlier schema version. @@ -275,6 +339,54 @@ def estimate_tokens(text: str) -> int: return max(1, len(text) // 4) +def transcript_tool_result_len(transcript_path: str, tool_use_id: str) -> Optional[int]: + """Return the character length of the tool_result the MODEL actually received + for *tool_use_id*, by reading the session transcript JSONL. + + This is the oracle for the PostToolUse ``updatedToolOutput`` self-test: the + transcript records the post-hook tool result the model saw, so comparing its + length against what a hook emitted reveals whether the replacement took + effect (it is silently ignored for built-in tools on current Claude Code — + see anthropics/claude-code #65403/#32105). Returns None if not found or on + any error (caller treats None as "undetermined, try later"). + """ + if not transcript_path or not tool_use_id: + return None + try: + found: Optional[int] = None + with open(transcript_path, "r", encoding="utf-8", errors="replace") as fh: + for line in fh: + if '"tool_result"' not in line or tool_use_id not in line: + continue + try: + d = json.loads(line) + except Exception: + continue + msg = d.get("message") or {} + content = msg.get("content") + if not isinstance(content, list): + continue + for block in content: + if ( + isinstance(block, dict) + and block.get("type") == "tool_result" + and block.get("tool_use_id") == tool_use_id + ): + c = block.get("content") + if isinstance(c, list): + text = "".join( + x.get("text", "") for x in c if isinstance(x, dict) + ) + elif isinstance(c, str): + text = c + else: + text = str(c) + found = len(text) # keep the last (most recent) match + return found + except Exception: + return None + + # --------------------------------------------------------------------------- # Dedup / memo helpers # --------------------------------------------------------------------------- @@ -552,6 +664,143 @@ def session_cost_rows(session_id: Optional[str] = None) -> List[Dict[str, Any]]: return [] +# --------------------------------------------------------------------------- +# Step ledger — durable execution state for /nx-build resume +# --------------------------------------------------------------------------- + +_STEP_COLS = ( + "session_id, plan_hash, step_index, title, route, status, " + "tier_used, last_diff, verdict, attempts, updated_ts" +) + + +def record_step( + session_id: str, + plan_hash: str, + step_index: int, + status: str, + title: Optional[str] = None, + route: Optional[str] = None, + tier_used: Optional[str] = None, + last_diff: Optional[str] = None, + verdict: Optional[str] = None, + attempts: Optional[int] = None, +) -> None: + """Upsert a step's execution state. + + Only the fields passed (non-None) overwrite existing values; omitted fields + preserve whatever the prior row held. ``status`` is required and always + written. ``attempts`` defaults to preserving the prior count; pass an + explicit value to set it. This lets the orchestrator mark a step ``done`` + with a one-liner while still being able to persist a failed attempt's diff + and guardrail verdict for a later (possibly post-restart) patch-retry. + """ + try: + conn = db() + with conn: + prior = conn.execute( + f"SELECT {_STEP_COLS} FROM step_ledger " + "WHERE session_id=? AND plan_hash=? AND step_index=?", + (session_id, plan_hash, step_index), + ).fetchone() + prior = dict(prior) if prior is not None else {} + + def pick(name, value): + return value if value is not None else prior.get(name) + + row = { + "title": pick("title", title), + "route": pick("route", route), + "tier_used": pick("tier_used", tier_used), + "last_diff": pick("last_diff", last_diff), + "verdict": pick("verdict", verdict), + "attempts": attempts if attempts is not None else (prior.get("attempts") or 0), + } + conn.execute( + "INSERT OR REPLACE INTO step_ledger(" + "session_id, plan_hash, step_index, title, route, status, " + "tier_used, last_diff, verdict, attempts, updated_ts) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + session_id, plan_hash, int(step_index), + row["title"], row["route"], status, + row["tier_used"], row["last_diff"], row["verdict"], + int(row["attempts"]), time.time(), + ), + ) + conn.close() + except Exception: + pass + + +def get_step(session_id: str, plan_hash: str, step_index: int) -> Optional[Dict[str, Any]]: + """Return the step_ledger row dict for one step, or None.""" + try: + conn = db() + row = conn.execute( + f"SELECT {_STEP_COLS} FROM step_ledger " + "WHERE session_id=? AND plan_hash=? AND step_index=?", + (session_id, plan_hash, int(step_index)), + ).fetchone() + conn.close() + return dict(row) if row is not None else None + except Exception: + return None + + +def step_ledger_rows(session_id: str, plan_hash: str) -> List[Dict[str, Any]]: + """Return all step rows for a (session, plan), ordered by step_index.""" + try: + conn = db() + rows = conn.execute( + f"SELECT {_STEP_COLS} FROM step_ledger " + "WHERE session_id=? AND plan_hash=? ORDER BY step_index", + (session_id, plan_hash), + ).fetchall() + conn.close() + return [dict(r) for r in rows] + except Exception: + return [] + + +def clear_step_ledger(session_id: str, plan_hash: Optional[str] = None) -> None: + """Delete step rows for a session (optionally scoped to one plan_hash).""" + try: + conn = db() + with conn: + if plan_hash is None: + conn.execute("DELETE FROM step_ledger WHERE session_id=?", (session_id,)) + else: + conn.execute( + "DELETE FROM step_ledger WHERE session_id=? AND plan_hash=?", + (session_id, plan_hash), + ) + conn.close() + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Dispatch batching — deterministic sub-batch partition for /nx-build +# --------------------------------------------------------------------------- + +def partition_steps(items: List[Any], max_per: int) -> List[List[Any]]: + """Split an ordered list of step indices into ordered sub-batches of at + most *max_per* items each, preserving order. + + This is the code-enforced version of the orchestrator's batch cap: rather + than the prompt judging "more than N" by eye, it calls this so splitting is + deterministic. Order preservation is what keeps it dependency-safe — the + planner already orders steps so a prerequisite precedes its dependent, and + chunking in order means a dependent never lands in a sub-batch that runs + *before* its prerequisite's. ``max_per <= 0`` means no cap (one batch). + """ + items = list(items) + if max_per is None or max_per <= 0 or len(items) <= max_per: + return [items] if items else [] + return [items[i:i + max_per] for i in range(0, len(items), max_per)] + + # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- @@ -571,6 +820,76 @@ def _cmd_config() -> None: print(json.dumps(cfg, sort_keys=True, indent=2)) +def _read_optional_file(path: Optional[str]) -> Optional[str]: + """Read a file's text, or None. '-' means stdin. Missing → None (fail-open).""" + if not path: + return None + try: + if path == "-": + return sys.stdin.read() + with open(path, "r", encoding="utf-8", errors="replace") as fh: + return fh.read() + except Exception: + return None + + +def _cmd_plan_hash(args) -> None: + """Print the content hash of a plan file — the orchestrator's ledger key.""" + text = _read_optional_file(args.file) or "" + print(sha256(text)) + + +def _cmd_step_set(args) -> None: + """Upsert one step's ledger state. Big fields load from files (or stdin '-').""" + record_step( + session_id=args.session, + plan_hash=args.plan_hash, + step_index=args.index, + status=args.status, + title=args.title, + route=args.route, + tier_used=args.tier, + last_diff=_read_optional_file(args.diff_file), + verdict=_read_optional_file(args.verdict_file), + attempts=args.attempts, + ) + print(json.dumps({"ok": True, "step_index": args.index, "status": args.status})) + + +def _cmd_step_get(args) -> None: + """Print one step row as JSON (null if absent).""" + print(json.dumps(get_step(args.session, args.plan_hash, args.index))) + + +def _cmd_step_list(args) -> None: + """Print all step rows for a (session, plan) as a JSON array.""" + print(json.dumps(step_ledger_rows(args.session, args.plan_hash))) + + +def _cmd_step_clear(args) -> None: + """Delete step rows for a session (optionally one plan_hash).""" + clear_step_ledger(args.session, args.plan_hash) + print(json.dumps({"ok": True})) + + +def _cmd_plan_batches(args) -> None: + """Print the ordered sub-batches for a comma-separated list of step indices. + + --max defaults to config max_steps_per_dispatch when omitted. + """ + raw = [tok.strip() for tok in (args.indices or "").split(",") if tok.strip()] + indices: List[Any] = [] + for tok in raw: + try: + indices.append(int(tok)) + except ValueError: + indices.append(tok) + max_per = args.max if args.max is not None else int( + get_config().get("max_steps_per_dispatch", 6) + ) + print(json.dumps(partition_steps(indices, max_per))) + + def main() -> None: parser = argparse.ArgumentParser( prog="store.py", @@ -580,12 +899,61 @@ def main() -> None: sub.add_parser("init", help="Create the nexum database and schema.") sub.add_parser("config", help="Print effective config as JSON.") + p_hash = sub.add_parser("plan-hash", help="Print the content hash of a plan file.") + p_hash.add_argument("--file", required=True, help="Plan file path ('-' for stdin).") + + p_set = sub.add_parser("step-set", help="Upsert a step's ledger state.") + p_set.add_argument("--session", required=True) + p_set.add_argument("--plan-hash", required=True) + p_set.add_argument("--index", type=int, required=True) + p_set.add_argument("--status", required=True, choices=["pending", "done", "failed"]) + p_set.add_argument("--title") + p_set.add_argument("--route") + p_set.add_argument("--tier") + p_set.add_argument("--diff-file", help="Path to the attempt's diff ('-' for stdin).") + p_set.add_argument("--verdict-file", help="Path to the guardrail verdict JSON ('-' for stdin).") + p_set.add_argument("--attempts", type=int) + + p_get = sub.add_parser("step-get", help="Print one step row as JSON.") + p_get.add_argument("--session", required=True) + p_get.add_argument("--plan-hash", required=True) + p_get.add_argument("--index", type=int, required=True) + + p_list = sub.add_parser("step-list", help="Print all step rows for a (session, plan).") + p_list.add_argument("--session", required=True) + p_list.add_argument("--plan-hash", required=True) + + p_clear = sub.add_parser("step-clear", help="Delete step rows for a session.") + p_clear.add_argument("--session", required=True) + p_clear.add_argument("--plan-hash", default=None) + + p_batches = sub.add_parser( + "plan-batches", + help="Partition step indices into capped, order-preserving sub-batches.", + ) + p_batches.add_argument("--indices", required=True, + help="Comma-separated step indices in execution order.") + p_batches.add_argument("--max", type=int, default=None, + help="Cap per batch; defaults to max_steps_per_dispatch.") + args = parser.parse_args() if args.command == "init": _cmd_init() elif args.command == "config": _cmd_config() + elif args.command == "plan-hash": + _cmd_plan_hash(args) + elif args.command == "step-set": + _cmd_step_set(args) + elif args.command == "step-get": + _cmd_step_get(args) + elif args.command == "step-list": + _cmd_step_list(args) + elif args.command == "step-clear": + _cmd_step_clear(args) + elif args.command == "plan-batches": + _cmd_plan_batches(args) else: parser.print_help() sys.exit(1) diff --git a/scripts/truncate.py b/scripts/truncate.py index 0302bc2..9845d59 100644 --- a/scripts/truncate.py +++ b/scripts/truncate.py @@ -48,6 +48,13 @@ def extract_output(data: dict) -> str | None: if val and isinstance(val, str): return val + # Case 3: Read tool shape — {"type":"text","file":{"filePath","content",...}} + file_obj = tool_response.get("file") + if isinstance(file_obj, dict): + val = file_obj.get("content") + if val and isinstance(val, str): + return val + return None except Exception: return None diff --git a/tests/test_context_watch.py b/tests/test_context_watch.py index 1ebed2a..4354f39 100644 --- a/tests/test_context_watch.py +++ b/tests/test_context_watch.py @@ -151,6 +151,51 @@ def test_block_message_format(self): self.assertIn("continue", reason.lower()) +class TestContextWatchAutomatedPromptSkipsGuard(unittest.TestCase): + """System-injected prompts (task-notifications, command stdout) must never be + blocked by the intent-guard, even when they look like a task-type change.""" + + def setUp(self): + self._tmp = tempfile.mkdtemp() + + def test_task_notification_not_blocked(self): + sid = "sess_auto" + # Establish a fix context (bare prompt) so a feature prompt would block. + _run_context_watch( + {"session_id": sid, "prompt": "fix the crash bug in the payment module"}, + self._tmp, + ) + # A background-agent completion arrives as a task-notification whose text + # would otherwise read as a divergent "feature" task. + auto = { + "session_id": sid, + "prompt": ( + "<task-notification>\n<task-id>abc123</task-id>\n" + "add new billing dashboard feature implement\n</task-notification>" + ), + } + out, rc = _run_context_watch(auto, self._tmp) + self.assertEqual(rc, 0) + self.assertTrue( + _is_allowed(out), + f"Automated task-notification must not be blocked, got: {out}", + ) + + def test_bare_version_still_blocks(self): + """Control: the same divergent text WITHOUT automation markers still blocks, + proving the skip is specific to automated prompts.""" + sid = "sess_auto_control" + _run_context_watch( + {"session_id": sid, "prompt": "fix the crash bug in the payment module"}, + self._tmp, + ) + out, _ = _run_context_watch( + {"session_id": sid, "prompt": "add new billing dashboard feature implement"}, + self._tmp, + ) + self.assertTrue(_is_blocked(out), f"Bare divergent prompt should block, got: {out}") + + class TestContextWatchContinueBypass(unittest.TestCase): """'continue' reply bypasses the block.""" @@ -237,6 +282,95 @@ def test_system_message_emitted_only_once(self): "systemMessage emitted twice — should be once per window") +class TestContextWatchHandoffNudge(unittest.TestCase): + """Crossing the handoff threshold (but not compaction) suggests /nx-save.""" + + def setUp(self): + self._tmp = tempfile.mkdtemp() + + def _set_thresholds(self, handoff, compaction): + cfg_path = os.path.join(self._tmp, "config.json") + with open(cfg_path, "w") as f: + json.dump({ + "handoff_threshold_tokens": handoff, + "compaction_threshold_tokens": compaction, + }, f) + + def test_handoff_nudge_emitted_below_compaction(self): + """When tokens cross the handoff threshold (still under compaction), + the nudge suggests /nx-save, not /compact.""" + # Low handoff threshold, high compaction threshold so only handoff fires. + self._set_thresholds(handoff=10, compaction=100000) + payload = {"session_id": "sess_handoff", + "prompt": "fix the bug in the authentication module now"} + out, rc = _run_context_watch(payload, self._tmp) + self.assertEqual(rc, 0) + self.assertIn("systemMessage", out, + f"Expected handoff nudge, got: {out}") + msg = out["systemMessage"].lower() + self.assertIn("[nexum]", out["systemMessage"]) + self.assertIn("nx-save", msg) + self.assertNotIn("/compact", msg) + + def test_handoff_nudge_emitted_only_once(self): + """The handoff nudge fires at most once per window.""" + self._set_thresholds(handoff=10, compaction=100000) + sid = "sess_handoff_once" + out1, _ = _run_context_watch( + {"session_id": sid, "prompt": "fix the authentication bug module"}, self._tmp) + out2, rc = _run_context_watch( + {"session_id": sid, "prompt": "fix the login error too please"}, self._tmp) + self.assertEqual(rc, 0) + if "systemMessage" in out1: + self.assertNotIn("systemMessage", out2, + "handoff nudge emitted twice — should be once per window") + + def test_compaction_takes_precedence(self): + """When tokens cross both thresholds at once, the compaction warning wins.""" + self._set_thresholds(handoff=5, compaction=10) + payload = {"session_id": "sess_both", + "prompt": "fix the bug in the authentication module now please"} + out, rc = _run_context_watch(payload, self._tmp) + self.assertEqual(rc, 0) + self.assertIn("systemMessage", out) + self.assertIn("compact", out["systemMessage"].lower()) + + def test_handoff_nudge_disabled_when_zero(self): + """handoff_threshold_tokens=0 disables the nudge.""" + self._set_thresholds(handoff=0, compaction=100000) + payload = {"session_id": "sess_disabled", + "prompt": "fix the bug in the authentication module now"} + out, rc = _run_context_watch(payload, self._tmp) + self.assertEqual(rc, 0) + self.assertNotIn("systemMessage", out) + + def test_auto_writes_handoff_skeleton_on_crossing(self): + """Crossing the handoff threshold auto-writes the handoff skeleton.""" + self._set_thresholds(handoff=5, compaction=999999) + payload = {"session_id": "sess_autowrite", + "prompt": "implement the new billing feature now please", + "cwd": os.getcwd()} + out, rc = _run_context_watch(payload, self._tmp) + self.assertEqual(rc, 0) + latest = os.path.join(self._tmp, "handoff", "latest.md") + self.assertTrue(os.path.isfile(latest), "skeleton latest.md not written") + self.assertIn("skeleton", out.get("systemMessage", "").lower()) + + def test_auto_write_disabled(self): + """handoff_auto_write_enabled=false suppresses the skeleton write.""" + cfg_path = os.path.join(self._tmp, "config.json") + with open(cfg_path, "w") as f: + json.dump({"handoff_threshold_tokens": 5, + "compaction_threshold_tokens": 999999, + "handoff_auto_write_enabled": False}, f) + payload = {"session_id": "sess_nowrite", + "prompt": "implement the new billing feature now please", + "cwd": os.getcwd()} + out, rc = _run_context_watch(payload, self._tmp) + self.assertEqual(rc, 0) + self.assertFalse(os.path.isfile(os.path.join(self._tmp, "handoff", "latest.md"))) + + class TestContextWatchFailOpen(unittest.TestCase): """Malformed input → fail-open ({} exit 0).""" diff --git a/tests/test_dedup.py b/tests/test_dedup.py index 3b68408..9975703 100644 --- a/tests/test_dedup.py +++ b/tests/test_dedup.py @@ -261,12 +261,40 @@ class TestDedupSavingsRecorded(unittest.TestCase): def setUp(self): self._tmp = tempfile.mkdtemp() - def test_savings_recorded_after_two_calls(self): - """First call (new-content branch) and second call (pointer branch) each record savings.""" + def _session_savings(self, session_id): + """Read back session_savings under this test's CLAUDE_PLUGIN_DATA.""" + import os as _os import store as _store + old_env = _os.environ.get("CLAUDE_PLUGIN_DATA") + _os.environ["CLAUDE_PLUGIN_DATA"] = self._tmp + try: + return _store.session_savings(session_id) + finally: + if old_env is None: + _os.environ.pop("CLAUDE_PLUGIN_DATA", None) + else: + _os.environ["CLAUDE_PLUGIN_DATA"] = old_env + + def _set_flag(self, session_id, key, value): + """Seed a session_kv flag under this test's CLAUDE_PLUGIN_DATA.""" + import os as _os + import store as _store + old_env = _os.environ.get("CLAUDE_PLUGIN_DATA") + _os.environ["CLAUDE_PLUGIN_DATA"] = self._tmp + try: + _store.set_flag(session_id, key, value) + finally: + if old_env is None: + _os.environ.pop("CLAUDE_PLUGIN_DATA", None) + else: + _os.environ["CLAUDE_PLUGIN_DATA"] = old_env + + def test_savings_recorded_when_self_test_passes(self): + """With the self-test confirmed ("uto_works"=="yes"), both action paths record savings.""" + session_id = "sess_savings_yes" + # Simulate a harness that honors updatedToolOutput (verified self-test). + self._set_flag(session_id, "uto_works", "yes") - session_id = "sess_savings_test" - # Use a payload large enough to guarantee dedup acts large_payload = "\n".join([f"savings line {i}: " + "x" * 60 for i in range(60)]) payload = { "session_id": session_id, @@ -274,29 +302,47 @@ def test_savings_recorded_after_two_calls(self): "tool_response": large_payload, } - # First call: new-content branch (truncate savings recorded if shrunk < output) - out1, rc1 = _run_dedup(payload, self._tmp) + # First call: new-content branch (truncate saving). + _, rc1 = _run_dedup(payload, self._tmp) self.assertEqual(rc1, 0) - # Second call: pointer branch (dedup savings recorded) + # Second call: pointer branch (dedup saving). out2, rc2 = _run_dedup(payload, self._tmp) self.assertEqual(rc2, 0) self.assertIn("hookSpecificOutput", out2) self.assertIn("[nexum] identical", out2["hookSpecificOutput"]["updatedToolOutput"]) - # Now read back savings using the same CLAUDE_PLUGIN_DATA - import os as _os - old_env = _os.environ.get("CLAUDE_PLUGIN_DATA") - _os.environ["CLAUDE_PLUGIN_DATA"] = self._tmp - try: - total = _store.session_savings(session_id) - finally: - if old_env is None: - _os.environ.pop("CLAUDE_PLUGIN_DATA", None) - else: - _os.environ["CLAUDE_PLUGIN_DATA"] = old_env + self.assertGreater( + self._session_savings(session_id), 0, + "savings should be recorded once the self-test confirms replacements work", + ) + + def test_savings_gated_until_self_test_verified(self): + """Honesty fix: with the self-test unverified, no savings are recorded even though + the shrink/pointer output is still emitted (it may be silently ignored by the harness).""" + session_id = "sess_savings_unknown" + large_payload = "\n".join([f"savings line {i}: " + "x" * 60 for i in range(60)]) + payload = { + "session_id": session_id, + "tool_name": "Read", + "tool_response": large_payload, + # tool_use_id present so a probe could arm, but no transcript exists + # to confirm it → verdict stays unknown → nothing counted. + "tool_use_id": "toolu_probe_xyz", + "transcript_path": "/nonexistent/transcript.jsonl", + } - self.assertGreater(total, 0, "session_savings should be > 0 after pointer collapse") + out1, rc1 = _run_dedup(payload, self._tmp) + self.assertEqual(rc1, 0) + out2, rc2 = _run_dedup(payload, self._tmp) + self.assertEqual(rc2, 0) + # The replacement is still emitted (so it works if the harness ever honors it)… + self.assertIn("[nexum] identical", out2["hookSpecificOutput"]["updatedToolOutput"]) + # …but no savings are claimed while unverified. + self.assertEqual( + self._session_savings(session_id), 0, + "unverified self-test must not record fictional savings", + ) if __name__ == "__main__": diff --git a/tests/test_guardrail.py b/tests/test_guardrail.py index d207aed..b68556c 100644 --- a/tests/test_guardrail.py +++ b/tests/test_guardrail.py @@ -119,6 +119,80 @@ def test_multiple_scope_roots(self): self.assertTrue(out["pass"]) self.assertEqual(out["scope_violations"], []) + def test_absolute_root_relative_changed_not_flagged(self): + """Regression: an absolute --scope-root with a relative --changed (a common + orchestrator invocation) must NOT spuriously flag the file. Previously + os.path.commonpath raised ValueError on mixed abs/rel paths → false violation.""" + abs_tests = os.path.abspath("tests") + out, rc = _run_guardrail( + "--acceptance", "true", + "--scope-root", abs_tests, + "--changed", "tests/test_guardrail.py", + data_dir=self._tmp, + ) + self.assertEqual(rc, 0) + self.assertEqual(out["scope_violations"], []) + self.assertTrue(out["pass"]) + + def test_absolute_root_relative_out_of_scope_still_flagged(self): + """Control: with the same absolute root, a relative file truly outside it + is still flagged (the fix must not over-allow).""" + abs_tests = os.path.abspath("tests") + out, rc = _run_guardrail( + "--acceptance", "true", + "--scope-root", abs_tests, + "--changed", "scripts/store.py", + data_dir=self._tmp, + ) + self.assertEqual(rc, 0) + self.assertIn("scripts/store.py", out["scope_violations"]) + self.assertFalse(out["pass"]) + + +class TestGuardrailDenyPath(unittest.TestCase): + """--deny-path flags changed files under a denied path (plan 'do NOT touch X').""" + + def setUp(self): + self._tmp = tempfile.mkdtemp() + + def test_file_under_deny_path_flagged(self): + out, rc = _run_guardrail( + "--acceptance", "true", + "--deny-path", "scripts/", + "--changed", "tests/test_x.py,scripts/store.py", + data_dir=self._tmp, + ) + self.assertEqual(rc, 0) + self.assertFalse(out["pass"]) + self.assertIn("scripts/store.py", out["scope_violations"]) + self.assertNotIn("tests/test_x.py", out["scope_violations"]) + + def test_deny_path_only_allows_others(self): + """With only a deny-path (no allow-list), files outside it pass.""" + out, rc = _run_guardrail( + "--acceptance", "true", + "--deny-path", "scripts/store.py", + "--changed", "tests/test_x.py", + data_dir=self._tmp, + ) + self.assertEqual(rc, 0) + self.assertTrue(out["pass"]) + self.assertEqual(out["scope_violations"], []) + + def test_deny_path_combined_with_scope_root(self): + """A file inside the allow-list but also under a deny-path is still flagged.""" + out, rc = _run_guardrail( + "--acceptance", "true", + "--scope-root", "scripts/", + "--deny-path", "scripts/store.py", + "--changed", "scripts/store.py,scripts/dedup.py", + data_dir=self._tmp, + ) + self.assertEqual(rc, 0) + self.assertFalse(out["pass"]) + self.assertIn("scripts/store.py", out["scope_violations"]) + self.assertNotIn("scripts/dedup.py", out["scope_violations"]) + class TestGuardrailNoAcceptanceCmd(unittest.TestCase): """No --acceptance → pass=true with a note in log.""" diff --git a/tests/test_handoff.py b/tests/test_handoff.py new file mode 100644 index 0000000..7d73734 --- /dev/null +++ b/tests/test_handoff.py @@ -0,0 +1,115 @@ +""" +test_handoff.py — stdlib unittest tests for scripts/handoff.py + +Covers the deterministic handoff-skeleton writer: +- build_skeleton renders git state + task + tokens +- write_skeleton writes both per-session and latest.md +- task signature is humanized +- fail-open on a non-git / bad cwd +""" + +import json +import os +import subprocess +import sys +import tempfile +import unittest + +_SCRIPTS_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts" +) +if _SCRIPTS_DIR not in sys.path: + sys.path.insert(0, _SCRIPTS_DIR) + + +class TestHumanizeTask(unittest.TestCase): + def test_unpacks_signature(self): + import handoff + sig = json.dumps(["__type__:feature", "billing", "invoice"]) + out = handoff._humanize_task(sig) + self.assertIn("type: feature", out) + self.assertIn("billing", out) + self.assertIn("invoice", out) + + def test_none_returns_placeholder(self): + import handoff + self.assertEqual(handoff._humanize_task(None), "(none recorded)") + + def test_non_json_returns_raw(self): + import handoff + self.assertEqual(handoff._humanize_task("fix the login bug"), "fix the login bug") + + +class TestBuildSkeleton(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.mkdtemp() + os.environ["CLAUDE_PLUGIN_DATA"] = self._tmp + + def tearDown(self): + os.environ.pop("CLAUDE_PLUGIN_DATA", None) + + def test_contains_core_sections(self): + import handoff + md = handoff.build_skeleton("sess1", os.getcwd(), token_total=105000) + self.assertIn("# Handoff (auto-skeleton)", md) + self.assertIn("**Session:** sess1", md) + self.assertIn("105k tokens", md) + self.assertIn("## Git state", md) + self.assertIn("## How to resume", md) + + def test_non_git_cwd_does_not_raise(self): + import handoff + md = handoff.build_skeleton("sess1", self._tmp, token_total=0) + # branch resolves to the unknown placeholder, no exception + self.assertIn("(unknown)", md) + + +class TestWriteSkeleton(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.mkdtemp() + os.environ["CLAUDE_PLUGIN_DATA"] = self._tmp + + def tearDown(self): + os.environ.pop("CLAUDE_PLUGIN_DATA", None) + + def test_writes_both_files(self): + import handoff + path = handoff.write_skeleton("sessW", cwd=os.getcwd(), token_total=101000) + self.assertIsNotNone(path) + per_session = os.path.join(self._tmp, "handoff", "sessW.md") + latest = os.path.join(self._tmp, "handoff", "latest.md") + self.assertTrue(os.path.isfile(per_session)) + self.assertTrue(os.path.isfile(latest)) + with open(latest) as f: + self.assertEqual(f.read(), open(per_session).read()) + + def test_latest_records_session_id(self): + import handoff + handoff.write_skeleton("sessW2", cwd=os.getcwd()) + with open(os.path.join(self._tmp, "handoff", "latest.md")) as f: + self.assertIn("**Session:** sessW2", f.read()) + + +class TestHandoffCLI(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.mkdtemp() + self._handoff = os.path.join(_SCRIPTS_DIR, "handoff.py") + + def _run(self, *args): + env = os.environ.copy() + env["CLAUDE_PLUGIN_DATA"] = self._tmp + env["PYTHONPATH"] = _SCRIPTS_DIR + os.pathsep + env.get("PYTHONPATH", "") + r = subprocess.run([sys.executable, self._handoff, *args], + capture_output=True, env=env, timeout=15) + return r.stdout.decode(), r.returncode + + def test_write_cli(self): + out, rc = self._run("write", "--session", "sCLI", "--cwd", os.getcwd(), "--tokens", "100001") + self.assertEqual(rc, 0) + res = json.loads(out) + self.assertTrue(res["ok"]) + self.assertTrue(os.path.isfile(os.path.join(self._tmp, "handoff", "latest.md"))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_meta_imports.py b/tests/test_meta_imports.py index fe2ec1e..904cac4 100644 --- a/tests/test_meta_imports.py +++ b/tests/test_meta_imports.py @@ -34,6 +34,7 @@ "fnmatch", "argparse", "dataclasses", + "datetime", "typing", # Standard library extras that are legitimately used (transitively stdlib) @@ -57,6 +58,7 @@ "guardrail", "cost_report", "audit", + "handoff", # Python built-ins / __future__ (never third-party) "__future__", @@ -74,6 +76,7 @@ "logging", "math", "operator", + "shlex", "shutil", "signal", "stat", @@ -182,6 +185,7 @@ def test_expected_scripts_exist(self): "store.py", "truncate.py", "dedup.py", "scan_guard.py", "context_watch.py", "guardrail.py", "cost_report.py", "audit.py", + "handoff.py", ] for script in required: path = os.path.join(_SCRIPTS_DIR, script) diff --git a/tests/test_scan_guard.py b/tests/test_scan_guard.py index 24c8863..ce89206 100644 --- a/tests/test_scan_guard.py +++ b/tests/test_scan_guard.py @@ -361,5 +361,95 @@ def test_valid_json_unknown_tool_allowed(self): self.assertTrue(_is_allow(out)) +class TestScanGuardQuotedGrep(unittest.TestCase): + """Quoted grep patterns with spaces must still be caught by the guard (Step 3 fix).""" + + def setUp(self): + self._tmp = tempfile.mkdtemp() + + def test_quoted_pattern_with_space_denied(self): + """grep -r "def foo" . — quoted pattern with space → deny (unscoped).""" + payload = { + "tool_name": "Bash", + "tool_input": {"command": 'grep -r "def foo" .'}, + } + out, rc = _run_scan_guard(payload, self._tmp) + self.assertEqual(rc, 0) + self.assertTrue( + _is_deny(out), + f'Expected deny for grep -r "def foo" ., got: {out}', + ) + + +class TestScanGuardReadGuard(unittest.TestCase): + """Read-guard: large files get a limit injected via updatedInput; small files pass through.""" + + def setUp(self): + self._tmp = tempfile.mkdtemp() + + def _make_large_file(self): + """Create a temporary file larger than 262144 bytes and return its path.""" + f = tempfile.NamedTemporaryFile(delete=False, suffix=".py") + # Write slightly over 262144 bytes + f.write(b"x" * 270000) + f.flush() + f.close() + return f.name + + def _make_small_file(self): + """Create a temporary file smaller than 262144 bytes and return its path.""" + f = tempfile.NamedTemporaryFile(delete=False, suffix=".py") + f.write(b"hello world\n") + f.flush() + f.close() + return f.name + + def test_large_file_gets_limit_injected(self): + """A file above read_guard_min_bytes gets updatedInput with limit=2000 and + file_path preserved.""" + large_path = self._make_large_file() + try: + payload = { + "tool_name": "Read", + "tool_input": {"file_path": large_path}, + } + out, rc = _run_scan_guard(payload, self._tmp) + self.assertEqual(rc, 0) + self.assertIn("hookSpecificOutput", out, f"Expected updatedInput, got: {out}") + updated = out["hookSpecificOutput"].get("updatedInput", {}) + self.assertEqual(updated.get("limit"), 2000) + self.assertEqual(updated.get("file_path"), large_path) + finally: + os.unlink(large_path) + + def test_small_file_passes_through(self): + """A file below read_guard_min_bytes emits {} (no updatedInput).""" + small_path = self._make_small_file() + try: + payload = { + "tool_name": "Read", + "tool_input": {"file_path": small_path}, + } + out, rc = _run_scan_guard(payload, self._tmp) + self.assertEqual(rc, 0) + self.assertEqual(out, {}, f"Expected {{}} for small file, got: {out}") + finally: + os.unlink(small_path) + + def test_explicit_limit_already_set_no_injection(self): + """A large file with an explicit limit=50 in tool_input → no updatedInput emitted.""" + large_path = self._make_large_file() + try: + payload = { + "tool_name": "Read", + "tool_input": {"file_path": large_path, "limit": 50}, + } + out, rc = _run_scan_guard(payload, self._tmp) + self.assertEqual(rc, 0) + self.assertEqual(out, {}, f"Expected {{}} when limit already set, got: {out}") + finally: + os.unlink(large_path) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_statusline.py b/tests/test_statusline.py index 2bc3ac3..c461245 100644 --- a/tests/test_statusline.py +++ b/tests/test_statusline.py @@ -171,6 +171,79 @@ def test_render_token_threshold_disabled_with_zero(self): self.assertNotIn("⚠", result) +class TestFormatReset(unittest.TestCase): + """Unit tests for format_reset (rate-limit reset countdown).""" + + def test_hours_and_minutes(self): + # 7500s before reset → 2h05m + self.assertEqual(statusline.format_reset(10000, 2500), "2h05m") + + def test_minutes_only(self): + self.assertEqual(statusline.format_reset(10000, 9700), "5m") + + def test_exactly_now(self): + self.assertEqual(statusline.format_reset(10000, 10000), "now") + + def test_past_is_now(self): + self.assertEqual(statusline.format_reset(10000, 20000), "now") + + def test_days(self): + # 90000s = 25h → 1d01h + self.assertEqual(statusline.format_reset(90000, 0), "1d01h") + + def test_missing_returns_empty(self): + self.assertEqual(statusline.format_reset(None, 0), "") + + +class TestRenderPlanMode(unittest.TestCase): + """render() leads with subscription plan usage-left + reset when rate_limits present.""" + + def _plan_data(self): + return { + "model": {"display_name": "Opus"}, + "rate_limits": { + "five_hour": {"used_percentage": 42, "resets_at": 10000}, + "seven_day": {"used_percentage": 4, "resets_at": 20000}, + }, + "context_window": { + "used_percentage": 30, + "total_input_tokens": 300000, + "total_output_tokens": 500, + }, + "cost": {"total_cost_usd": 40.0}, + } + + def test_shows_plan_usage_left_and_reset(self): + # now = 2500 → 7500s until 5h reset → 2h05m; 100-42 = 58% left + out = statusline.render(self._plan_data(), 0, now=2500) + self.assertIn("58% left", out) + self.assertIn("↻2h05m", out) + + def test_shows_weekly_left(self): + out = statusline.render(self._plan_data(), 0, now=2500) + self.assertIn("wk 96%", out) # 100 - 4 + + def test_keeps_context_and_cost(self): + """Context AND dollar usage must still appear in plan mode.""" + out = statusline.render(self._plan_data(), 0, now=2500) + self.assertIn("ctx 30%", out) + self.assertIn("300.5k", out) # 300000 + 500 input/output + self.assertIn("$40.00", out) + + def test_fallback_when_no_rate_limits(self): + """No rate_limits → context-usage primary (legacy format preserved).""" + out = statusline.render( + {"model": {"display_name": "Opus"}, + "context_window": {"used_percentage": 25, "total_input_tokens": 15000, + "total_output_tokens": 1700}, + "cost": {"total_cost_usd": 0.42}}, + 0, + ) + self.assertIn("25%", out) + self.assertIn("16.7k tok", out) + self.assertNotIn("left", out) + + class TestSubprocessEndToEnd(unittest.TestCase): """End-to-end subprocess tests for statusline.py.""" diff --git a/tests/test_store.py b/tests/test_store.py index f3b92f1..fbbd6f4 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -157,6 +157,10 @@ def test_defaults_present(self): "scan_deny_paths", "intent_guard_enabled", "intent_similarity_threshold", + "handoff_threshold_tokens", + "max_same_tier_retries", + "max_steps_per_dispatch", + "orchestrator_resume_enabled", ] for key in required: self.assertIn(key, cfg, f"Default config key {key!r} missing") @@ -386,5 +390,266 @@ def test_concurrent_writes(self): self.assertEqual(p2.exitcode, 0, "Process 2 crashed during concurrent write") +class TestTranscriptToolResultLen(unittest.TestCase): + """transcript_tool_result_len reads the correct length from a JSONL transcript.""" + + def setUp(self): + self._tmp = tempfile.mkdtemp() + os.environ["CLAUDE_PLUGIN_DATA"] = self._tmp + + def tearDown(self): + os.environ.pop("CLAUDE_PLUGIN_DATA", None) + + def _write_transcript(self, path, tool_use_id, content): + """Write a minimal transcript JSONL file with one tool_result entry.""" + line = json.dumps({ + "type": "user", + "message": { + "content": [ + { + "type": "tool_result", + "tool_use_id": tool_use_id, + "content": content, + } + ] + }, + }) + with open(path, "w") as f: + f.write(line + "\n") + + def test_known_tool_use_id_returns_length(self): + """Returns the character length of the content for a known tool_use_id.""" + import store + tf = os.path.join(self._tmp, "transcript.jsonl") + self._write_transcript(tf, "toolu_1", "hello world") + result = store.transcript_tool_result_len(tf, "toolu_1") + self.assertEqual(result, 11) + + def test_unknown_tool_use_id_returns_none(self): + """Returns None when the tool_use_id is not in the transcript.""" + import store + tf = os.path.join(self._tmp, "transcript.jsonl") + self._write_transcript(tf, "toolu_1", "hello world") + result = store.transcript_tool_result_len(tf, "toolu_unknown") + self.assertIsNone(result) + + def test_nonexistent_path_returns_none(self): + """Returns None for a path that does not exist.""" + import store + result = store.transcript_tool_result_len( + "/nonexistent/path/transcript.jsonl", "toolu_1" + ) + self.assertIsNone(result) + + +class TestStepLedger(unittest.TestCase): + """Step ledger: durable per-step state for /nx-build resume.""" + + def setUp(self): + self._tmp = tempfile.mkdtemp() + os.environ["CLAUDE_PLUGIN_DATA"] = self._tmp + + def tearDown(self): + os.environ.pop("CLAUDE_PLUGIN_DATA", None) + + def test_record_and_get_roundtrip(self): + import store + store.record_step("s1", "h1", 0, "done", title="t", route="mechanical", tier_used="haiku") + row = store.get_step("s1", "h1", 0) + self.assertIsNotNone(row) + self.assertEqual(row["status"], "done") + self.assertEqual(row["title"], "t") + self.assertEqual(row["route"], "mechanical") + self.assertEqual(row["tier_used"], "haiku") + self.assertEqual(row["attempts"], 0) + + def test_get_absent_returns_none(self): + import store + self.assertIsNone(store.get_step("s1", "h1", 99)) + + def test_partial_update_preserves_other_fields(self): + """Re-recording with only status set must keep title/route/tier/diff.""" + import store + store.record_step("s1", "h1", 1, "failed", title="wire", route="standard", + tier_used="sonnet", attempts=1) + # Update only the diff; everything else preserved. + store.record_step("s1", "h1", 1, "failed", last_diff="diff --git a/x b/x") + row = store.get_step("s1", "h1", 1) + self.assertEqual(row["title"], "wire") + self.assertEqual(row["route"], "standard") + self.assertEqual(row["tier_used"], "sonnet") + self.assertEqual(row["attempts"], 1) + self.assertEqual(row["last_diff"], "diff --git a/x b/x") + + def test_status_transition_failed_to_done(self): + import store + store.record_step("s1", "h1", 2, "failed", title="x", route="standard") + store.record_step("s1", "h1", 2, "done", tier_used="sonnet") + row = store.get_step("s1", "h1", 2) + self.assertEqual(row["status"], "done") + self.assertEqual(row["title"], "x") # preserved + + def test_plan_hash_isolates_state(self): + """A different plan_hash sees no rows — editing the plan discards stale state.""" + import store + store.record_step("s1", "h1", 0, "done", title="a") + self.assertEqual(len(store.step_ledger_rows("s1", "h1")), 1) + self.assertEqual(store.step_ledger_rows("s1", "h2"), []) + + def test_list_ordered_by_index(self): + import store + store.record_step("s1", "h1", 2, "pending") + store.record_step("s1", "h1", 0, "done") + store.record_step("s1", "h1", 1, "failed") + rows = store.step_ledger_rows("s1", "h1") + self.assertEqual([r["step_index"] for r in rows], [0, 1, 2]) + + def test_clear_scoped_to_plan(self): + import store + store.record_step("s1", "h1", 0, "done") + store.record_step("s1", "h2", 0, "done") + store.clear_step_ledger("s1", "h1") + self.assertEqual(store.step_ledger_rows("s1", "h1"), []) + self.assertEqual(len(store.step_ledger_rows("s1", "h2")), 1) + + def test_clear_all_for_session(self): + import store + store.record_step("s1", "h1", 0, "done") + store.record_step("s1", "h2", 0, "done") + store.clear_step_ledger("s1") + self.assertEqual(store.step_ledger_rows("s1", "h1"), []) + self.assertEqual(store.step_ledger_rows("s1", "h2"), []) + + +class TestPartitionSteps(unittest.TestCase): + """Deterministic, order-preserving sub-batch partition (dispatch cap).""" + + def setUp(self): + self._tmp = tempfile.mkdtemp() + os.environ["CLAUDE_PLUGIN_DATA"] = self._tmp + + def tearDown(self): + os.environ.pop("CLAUDE_PLUGIN_DATA", None) + + def test_chunks_at_cap(self): + import store + self.assertEqual( + store.partition_steps([0, 1, 2, 3, 4, 5, 6, 7], 6), + [[0, 1, 2, 3, 4, 5], [6, 7]], + ) + + def test_fits_in_one_batch(self): + import store + self.assertEqual(store.partition_steps([0, 1], 6), [[0, 1]]) + + def test_zero_or_negative_means_no_cap(self): + import store + self.assertEqual(store.partition_steps([0, 1, 2, 3], 0), [[0, 1, 2, 3]]) + self.assertEqual(store.partition_steps([0, 1, 2, 3], -1), [[0, 1, 2, 3]]) + + def test_empty_input(self): + import store + self.assertEqual(store.partition_steps([], 6), []) + + def test_order_preserved(self): + import store + flat = [x for batch in store.partition_steps(list(range(10)), 3) for x in batch] + self.assertEqual(flat, list(range(10))) + + +class TestPlanBatchesCLI(unittest.TestCase): + """The plan-batches CLI the orchestrator calls to size dispatches.""" + + def setUp(self): + self._tmp = tempfile.mkdtemp() + self._store = os.path.join(_SCRIPTS_DIR, "store.py") + + def _run(self, *args): + env = os.environ.copy() + env["CLAUDE_PLUGIN_DATA"] = self._tmp + env["PYTHONPATH"] = _SCRIPTS_DIR + os.pathsep + env.get("PYTHONPATH", "") + import subprocess + r = subprocess.run([sys.executable, self._store, *args], + capture_output=True, env=env, timeout=15) + return r.stdout.decode(), r.returncode + + def test_explicit_max(self): + out, rc = self._run("plan-batches", "--indices", "0,1,2,3,4,5,6,7", "--max", "3") + self.assertEqual(rc, 0) + self.assertEqual(json.loads(out), [[0, 1, 2], [3, 4, 5], [6, 7]]) + + def test_default_max_from_config(self): + with open(os.path.join(self._tmp, "config.json"), "w") as f: + json.dump({"max_steps_per_dispatch": 2}, f) + out, _ = self._run("plan-batches", "--indices", "0,1,2,3,4") + self.assertEqual(json.loads(out), [[0, 1], [2, 3], [4]]) + + def test_empty_indices(self): + out, rc = self._run("plan-batches", "--indices", "") + self.assertEqual(rc, 0) + self.assertEqual(json.loads(out), []) + + +class TestStepLedgerCLI(unittest.TestCase): + """The store.py CLI subcommands the orchestrator drives via bash.""" + + def setUp(self): + self._tmp = tempfile.mkdtemp() + self._store = os.path.join(_SCRIPTS_DIR, "store.py") + + def _run(self, *args, stdin=None): + env = os.environ.copy() + env["CLAUDE_PLUGIN_DATA"] = self._tmp + env["PYTHONPATH"] = _SCRIPTS_DIR + os.pathsep + env.get("PYTHONPATH", "") + import subprocess + r = subprocess.run([sys.executable, self._store, *args], + input=(stdin.encode() if stdin else None), + capture_output=True, env=env, timeout=15) + return r.stdout.decode(), r.returncode + + def test_plan_hash_deterministic(self): + p = os.path.join(self._tmp, "plan.md") + with open(p, "w") as f: + f.write("step 1\nstep 2\n") + out1, rc = self._run("plan-hash", "--file", p) + self.assertEqual(rc, 0) + out2, _ = self._run("plan-hash", "--file", p) + self.assertEqual(out1, out2) + self.assertEqual(len(out1.strip()), 64) # sha256 hex + + def test_set_list_get_cycle(self): + out, rc = self._run("step-set", "--session", "s1", "--plan-hash", "h1", + "--index", "0", "--status", "done", "--title", "x", + "--route", "mechanical", "--tier", "haiku") + self.assertEqual(rc, 0) + self.assertTrue(json.loads(out)["ok"]) + out, _ = self._run("step-list", "--session", "s1", "--plan-hash", "h1") + rows = json.loads(out) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["status"], "done") + out, _ = self._run("step-get", "--session", "s1", "--plan-hash", "h1", "--index", "0") + self.assertEqual(json.loads(out)["title"], "x") + + def test_get_absent_prints_null(self): + out, rc = self._run("step-get", "--session", "s1", "--plan-hash", "h1", "--index", "0") + self.assertEqual(rc, 0) + self.assertIsNone(json.loads(out)) + + def test_diff_via_stdin(self): + self._run("step-set", "--session", "s1", "--plan-hash", "h1", "--index", "1", + "--status", "failed", "--title", "wire") + self._run("step-set", "--session", "s1", "--plan-hash", "h1", "--index", "1", + "--status", "failed", "--diff-file", "-", stdin="diff line\nsecond\n") + out, _ = self._run("step-get", "--session", "s1", "--plan-hash", "h1", "--index", "1") + row = json.loads(out) + self.assertEqual(row["last_diff"], "diff line\nsecond\n") + self.assertEqual(row["title"], "wire") # preserved across diff-only update + + def test_invalid_status_rejected(self): + _, rc = self._run("step-set", "--session", "s1", "--plan-hash", "h1", + "--index", "0", "--status", "bogus") + self.assertNotEqual(rc, 0) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_truncate.py b/tests/test_truncate.py index f71ce00..c1eb867 100644 --- a/tests/test_truncate.py +++ b/tests/test_truncate.py @@ -235,6 +235,29 @@ def test_none_tool_response_returns_none(self): data = {"tool_response": None} self.assertIsNone(truncate.extract_output(data)) + def test_read_tool_nested_file_content(self): + """Read tool nests its output under tool_response['file']['content'].""" + import truncate + data = { + "tool_name": "Read", + "tool_response": { + "type": "text", + "file": { + "filePath": "/x", + "content": "alpha\nbeta\ngamma", + "numLines": 3, + "startLine": 1, + "totalLines": 3, + }, + }, + } + self.assertEqual(truncate.extract_output(data), "alpha\nbeta\ngamma") + + def test_read_tool_empty_file_content_returns_none(self): + import truncate + data = {"tool_response": {"type": "text", "file": {"content": ""}}} + self.assertIsNone(truncate.extract_output(data)) + class TestTruncateHookViaSubprocess(unittest.TestCase): """Drive truncate.py as a subprocess: stdin JSON → stdout JSON, exit 0.""" From b4a97615e2b639a27d10b5a42d83f8b1f56ad1bb Mon Sep 17 00:00:00 2001 From: Rahul Tyagi <rahul.1992.tyagi@gmail.com> Date: Thu, 18 Jun 2026 19:45:20 +0530 Subject: [PATCH 3/4] Fix 100k auto-handoff: drive thresholds off real transcript context size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auto-handoff never fired in practice. context_watch drove the threshold off max(prompt-text estimate, real_context_tokens flag): the estimate only counts the typed prompt (never reaches 100k), and the flag is written by the statusLine hook — which runs the cache copy lacking that write and can resolve a different data dir. So token_total stayed tiny and no handoff was written. - context_watch: read the REAL context size directly from the session transcript (input + cache_creation + cache_read of the last usage block) via new store.context_tokens_from_transcript; fall back to the estimate/flag only when the transcript is unavailable. Removes the statusline/data-dir/flag chain. - Re-arm the handoff/compaction nudges when context drops back below the threshold (e.g. after /clear), instead of warning once per session forever. - Handoff message now explicitly says to run /clear (or a fresh session) then /nx-load. - handoff.write_skeleton + /nx-save + /nx-load resolve a project-scoped data dir (store.project_data_dir: $CLAUDE_PLUGIN_DATA, else git-root/.nexum-data, else cwd/.nexum-data) so writer and reader always agree per-project. - Tests for the transcript reader, project_data_dir, transcript-driven handoff, and re-arm. Full suite: 269 passed. --- commands/nx-load.md | 2 +- commands/nx-save.md | 2 +- scripts/context_watch.py | 52 +++++++++----- scripts/handoff.py | 2 +- scripts/store.py | 73 ++++++++++++++++++++ tests/test_context_watch.py | 74 +++++++++++++++++++- tests/test_store.py | 134 ++++++++++++++++++++++++++++++++++++ 7 files changed, 318 insertions(+), 21 deletions(-) diff --git a/commands/nx-load.md b/commands/nx-load.md index 81f9799..d4b1974 100644 --- a/commands/nx-load.md +++ b/commands/nx-load.md @@ -6,7 +6,7 @@ You are the nexum handoff loader. The user is (usually) in a fresh session and w ## 1. Locate the handoff -Resolve the data directory the same way `store.py` does: `$CLAUDE_PLUGIN_DATA`, else `${CLAUDE_PLUGIN_ROOT}/.nexum-data`, else `./.nexum-data`. +Resolve the data directory: `$CLAUDE_PLUGIN_DATA` if set, else `<git toplevel of the current working directory>/.nexum-data` (via `git rev-parse --show-toplevel`), else `./.nexum-data`. Read `<data_dir>/handoff/latest.md`. - If it does not exist, check `<data_dir>/handoff/` for any `*.md` and offer the most recently modified one. diff --git a/commands/nx-save.md b/commands/nx-save.md index 2f9a441..a6c2365 100644 --- a/commands/nx-save.md +++ b/commands/nx-save.md @@ -6,7 +6,7 @@ You are the nexum handoff writer. Capture everything a NEW session (which shares ## 1. Resolve the handoff path -Resolve the data directory the same way `store.py` does: `$CLAUDE_PLUGIN_DATA`, else `${CLAUDE_PLUGIN_ROOT}/.nexum-data`, else `./.nexum-data`. Session id comes from `$CLAUDE_SESSION_ID` (or `_nosession`). +Resolve the data directory: `$CLAUDE_PLUGIN_DATA` if set, else `<git toplevel of the current working directory>/.nexum-data` (via `git rev-parse --show-toplevel`), else `./.nexum-data`. Session id comes from `$CLAUDE_SESSION_ID` (or `_nosession`). Create `<data_dir>/handoff/` if needed. Write the handoff to BOTH: - `<data_dir>/handoff/<session_id>.md` (the durable per-session copy), and diff --git a/scripts/context_watch.py b/scripts/context_watch.py index 3ca23fe..b012a5c 100644 --- a/scripts/context_watch.py +++ b/scripts/context_watch.py @@ -263,23 +263,41 @@ def _handle(data: dict) -> dict: # (a) Token accumulation + compaction prompt # ------------------------------------------------------------------ # estimate_total = _accumulate_tokens(session_id, prompt_stripped) - # Prefer Claude Code's REAL measured context size (persisted by the - # statusline hook) over our crude per-prompt estimate, which omits tool - # output and undercounts badly. Use whichever is larger so the threshold - # never fires later than the estimate would have. Falls back to the - # estimate when the statusline hasn't run yet (no real value stored). - token_total = estimate_total + # Primary signal: the REAL context size read straight from the session + # transcript (input + cache_creation + cache_read of the last usage block). + # This needs no statusline hook and no cross-hook flag, so it works even + # when the status line isn't installed. Fall back to Claude Code's + # statusline-persisted value, then to our crude per-prompt estimate (which + # omits tool output and undercounts badly), only when the transcript is + # unavailable. + token_total: int | None = None try: - real_raw = _kv_get(session_id, "real_context_tokens") - if real_raw is not None: - token_total = max(estimate_total, int(real_raw)) - except (TypeError, ValueError): - pass + token_total = store.context_tokens_from_transcript(data.get("transcript_path")) + except Exception: + token_total = None + if token_total is None: + token_total = estimate_total + try: + real_raw = _kv_get(session_id, "real_context_tokens") + if real_raw is not None: + token_total = max(estimate_total, int(real_raw)) + except (TypeError, ValueError): + pass compaction_threshold = int(cfg.get("compaction_threshold_tokens", 120000)) handoff_threshold = int(cfg.get("handoff_threshold_tokens", 100000)) system_message: str | None = None k_tokens = round(token_total / 1000) + # Re-arm the once-per-session nudges when context has dropped back below the + # handoff threshold (e.g. after a /clear or /compact). Without this, the + # warnings fire at most once for the life of the session id and stay silent + # even if context climbs past the threshold a second time. + if token_total < handoff_threshold: + if _kv_get(session_id, "handoff_warned"): + _kv_set(session_id, "handoff_warned", "") + if _kv_get(session_id, "compaction_warned"): + _kv_set(session_id, "compaction_warned", "") + # ------------------------------------------------------------------ # # Auto-handoff: once context is large enough that a fresh session may be # needed, write (and keep refreshing) a deterministic handoff skeleton so a @@ -319,15 +337,15 @@ def _handle(data: dict) -> dict: if not _kv_get(session_id, "handoff_warned"): if handoff_written: system_message = ( - f"[nexum] Context passed ~{handoff_threshold // 1000}k tokens " - f"(~{k_tokens}k now). Wrote a handoff skeleton — start a fresh " - "session and run /nx-load to resume. Run /nx-save first for a richer one." + f"[nexum] Context ~{k_tokens}k tokens. Wrote a handoff — run " + "/clear (or open a fresh session) then /nx-load to continue " + "cleanly. Run /nx-save first for a richer one." ) else: system_message = ( - f"[nexum] Context has grown past ~{handoff_threshold // 1000}k tokens " - f"(~{k_tokens}k now). Consider /nx-save to capture a resume " - "point so you can continue cleanly in a fresh session." + f"[nexum] Context ~{k_tokens}k tokens. Consider /nx-save to " + "capture a resume point, then /clear (or a fresh session) and " + "/nx-load to continue cleanly." ) _kv_set(session_id, "handoff_warned", "1") diff --git a/scripts/handoff.py b/scripts/handoff.py index 890ef55..1b8bfa2 100644 --- a/scripts/handoff.py +++ b/scripts/handoff.py @@ -138,7 +138,7 @@ def write_skeleton( Returns the per-session file path, or None on failure (fail-open). """ try: - data_dir = data_dir or store.nexum_data_dir() + data_dir = data_dir or store.project_data_dir(cwd) handoff_dir = os.path.join(data_dir, "handoff") os.makedirs(handoff_dir, exist_ok=True) content = build_skeleton(session_id, cwd or os.getcwd(), token_total) diff --git a/scripts/store.py b/scripts/store.py index 9e8ed02..0c620ec 100644 --- a/scripts/store.py +++ b/scripts/store.py @@ -208,6 +208,44 @@ # nexum_data_dir # --------------------------------------------------------------------------- +def project_data_dir(cwd: Optional[str] = None) -> str: + """Resolve and create a project-local nexum data directory. + + Priority (kept in lockstep with the /nx-save and /nx-load commands so the + handoff writer and reader always agree): + 1. $CLAUDE_PLUGIN_DATA (explicit override) — returned as-is. + 2. ``<git toplevel of cwd>/.nexum-data`` (project-scoped). + 3. ``<cwd or os.getcwd()>/.nexum-data`` (fallback when not in a git repo). + + Fail-open: if the git call raises or times out, falls back to cwd/os.getcwd() + without raising. + """ + env_data = os.environ.get("CLAUDE_PLUGIN_DATA", "").strip() + if env_data: + os.makedirs(env_data, exist_ok=True) + return env_data + + base: Optional[str] = None + effective_cwd = cwd or os.getcwd() + try: + import subprocess as _subprocess + result = _subprocess.run( + ["git", "-C", effective_cwd, "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0 and result.stdout.strip(): + base = result.stdout.strip() + except Exception: + pass + if not base: + base = effective_cwd + path = Path(base) / ".nexum-data" + os.makedirs(str(path), exist_ok=True) + return str(path) + + def nexum_data_dir() -> str: """Resolve and create the nexum data directory. @@ -339,6 +377,41 @@ def estimate_tokens(text: str) -> int: return max(1, len(text) // 4) +def context_tokens_from_transcript(transcript_path: str) -> Optional[int]: + """Return the real current context size from a Claude Code session transcript JSONL. + + Reads the file line by line and tracks the LAST line whose parsed object has a + ``message.usage`` dict. Returns the sum of input_tokens + cache_creation_input_tokens + + cache_read_input_tokens from that last usage block. + + Returns None if *transcript_path* is falsy, the file is missing/unreadable, no line + has ``message.usage``, or any exception occurs (fail-open). + """ + if not transcript_path: + return None + try: + last_usage: Optional[dict] = None + with open(transcript_path, "r", encoding="utf-8", errors="replace") as fh: + for line in fh: + try: + d = json.loads(line) + except Exception: + continue + msg = d.get("message") or {} + usage = msg.get("usage") + if isinstance(usage, dict): + last_usage = usage + if last_usage is None: + return None + return ( + int(last_usage.get("input_tokens") or 0) + + int(last_usage.get("cache_creation_input_tokens") or 0) + + int(last_usage.get("cache_read_input_tokens") or 0) + ) + except Exception: + return None + + def transcript_tool_result_len(transcript_path: str, tool_use_id: str) -> Optional[int]: """Return the character length of the tool_result the MODEL actually received for *tool_use_id*, by reading the session transcript JSONL. diff --git a/tests/test_context_watch.py b/tests/test_context_watch.py index 4354f39..f829478 100644 --- a/tests/test_context_watch.py +++ b/tests/test_context_watch.py @@ -354,7 +354,9 @@ def test_auto_writes_handoff_skeleton_on_crossing(self): self.assertEqual(rc, 0) latest = os.path.join(self._tmp, "handoff", "latest.md") self.assertTrue(os.path.isfile(latest), "skeleton latest.md not written") - self.assertIn("skeleton", out.get("systemMessage", "").lower()) + msg = out.get("systemMessage", "").lower() + self.assertIn("handoff", msg) + self.assertIn("nx-load", msg) def test_auto_write_disabled(self): """handoff_auto_write_enabled=false suppresses the skeleton write.""" @@ -422,5 +424,75 @@ def test_valid_json_no_prompt_key(self): self.assertTrue(_is_allowed(out)) +class TestContextWatchTranscriptHandoff(unittest.TestCase): + """The handoff/compaction thresholds are driven by the REAL context size read + from the session transcript, and the nudges re-arm after context drops.""" + + def setUp(self): + self._tmp = tempfile.mkdtemp() + + def _transcript(self, input_tok, cache_creation_tok, cache_read_tok): + """Write a transcript JSONL whose LAST usage block has the given fields + (a smaller earlier usage block is present to prove last-wins). Returns + the file path. The real context size = sum of the three fields.""" + path = os.path.join(self._tmp, "transcript.jsonl") + with open(path, "w", encoding="utf-8") as fh: + fh.write(json.dumps({"type": "assistant", "message": {"usage": { + "input_tokens": 1, "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1}}}) + "\n") + fh.write(json.dumps({"type": "user", "message": {"content": "hi"}}) + "\n") + fh.write(json.dumps({"type": "assistant", "message": {"usage": { + "input_tokens": input_tok, + "cache_creation_input_tokens": cache_creation_tok, + "cache_read_input_tokens": cache_read_tok}}}) + "\n") + return path + + def test_transcript_above_handoff_writes_handoff_and_clear_message(self): + """Context between the default handoff (100k) and compaction (120k) + thresholds writes a handoff and nudges /clear + /nx-load.""" + tp = self._transcript(2, 2000, 108000) # 110002 -> handoff, not compaction + payload = {"session_id": "sess_tx_high", "prompt": "do the work", + "cwd": self._tmp, "transcript_path": tp} + out, rc = _run_context_watch(payload, self._tmp) + self.assertEqual(rc, 0) + self.assertIn("systemMessage", out, f"expected a nudge, got: {out}") + msg = out["systemMessage"].lower() + self.assertIn("/clear", msg) + self.assertIn("/nx-load", msg) + latest = os.path.join(self._tmp, "handoff", "latest.md") + self.assertTrue(os.path.isfile(latest), "handoff latest.md not written") + + def test_transcript_below_threshold_writes_nothing(self): + """A small transcript context produces no handoff and no nudge.""" + tp = self._transcript(2, 10, 400) # 412 + payload = {"session_id": "sess_tx_low", "prompt": "do the work", + "cwd": self._tmp, "transcript_path": tp} + out, rc = _run_context_watch(payload, self._tmp) + self.assertEqual(rc, 0) + self.assertNotIn("systemMessage", out) + self.assertFalse(os.path.isfile(os.path.join(self._tmp, "handoff", "latest.md"))) + + def test_nudge_rearms_after_context_drops(self): + """handoff_warned is set when context is high, then cleared (re-armed) + once the transcript reports context back below the threshold.""" + sid = "sess_tx_rearm" + big = self._transcript(2, 2000, 108000) # 110002 > handoff + out1, _ = _run_context_watch( + {"session_id": sid, "prompt": "do the work", + "cwd": self._tmp, "transcript_path": big}, self._tmp) + self.assertIn("systemMessage", out1) + os.environ["CLAUDE_PLUGIN_DATA"] = self._tmp + try: + import store + self.assertTrue(store.get_flag(sid, "handoff_warned")) + small = self._transcript(2, 10, 400) # 412 < handoff + _run_context_watch( + {"session_id": sid, "prompt": "more work", + "cwd": self._tmp, "transcript_path": small}, self._tmp) + self.assertFalse(store.get_flag(sid, "handoff_warned")) + finally: + os.environ.pop("CLAUDE_PLUGIN_DATA", None) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_store.py b/tests/test_store.py index fbbd6f4..10f7f54 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -651,5 +651,139 @@ def test_invalid_status_rejected(self): self.assertNotEqual(rc, 0) +class TestContextTokensFromTranscript(unittest.TestCase): + """context_tokens_from_transcript reads the last usage block and sums token fields.""" + + def setUp(self): + self._tmp = tempfile.mkdtemp() + os.environ["CLAUDE_PLUGIN_DATA"] = self._tmp + + def tearDown(self): + os.environ.pop("CLAUDE_PLUGIN_DATA", None) + + def _make_transcript(self, path, lines): + """Write a JSONL transcript from a list of dicts.""" + with open(path, "w", encoding="utf-8") as fh: + for obj in lines: + fh.write(json.dumps(obj) + "\n") + + def test_last_usage_wins(self): + """Returns the sum from the LAST message.usage block, ignoring earlier ones.""" + import store + tf = os.path.join(self._tmp, "t1.jsonl") + self._make_transcript(tf, [ + # first usage block — should be ignored + {"message": {"usage": { + "input_tokens": 100, + "cache_creation_input_tokens": 200, + "cache_read_input_tokens": 300, + }}}, + # non-usage line + {"message": {"content": "hello"}}, + # last usage block — should be used + {"message": {"usage": { + "input_tokens": 10, + "cache_creation_input_tokens": 20, + "cache_read_input_tokens": 30, + }}}, + ]) + result = store.context_tokens_from_transcript(tf) + self.assertEqual(result, 60) # 10 + 20 + 30 + + def test_empty_string_returns_none(self): + """Empty path returns None (fail-open).""" + import store + self.assertIsNone(store.context_tokens_from_transcript("")) + + def test_nonexistent_path_returns_none(self): + """Non-existent file returns None (fail-open).""" + import store + self.assertIsNone(store.context_tokens_from_transcript( + os.path.join(self._tmp, "no_such_file.jsonl") + )) + + def test_no_usage_blocks_returns_none(self): + """File with no message.usage lines returns None.""" + import store + tf = os.path.join(self._tmp, "no_usage.jsonl") + self._make_transcript(tf, [ + {"message": {"content": "hello"}}, + {"type": "user", "text": "something"}, + ]) + self.assertIsNone(store.context_tokens_from_transcript(tf)) + + def test_malformed_lines_skipped(self): + """Malformed JSON lines are skipped; valid usage lines still work.""" + import store + tf = os.path.join(self._tmp, "malformed.jsonl") + with open(tf, "w", encoding="utf-8") as fh: + fh.write("NOT JSON\n") + fh.write(json.dumps({"message": {"usage": { + "input_tokens": 5, + "cache_creation_input_tokens": 3, + "cache_read_input_tokens": 2, + }}}) + "\n") + result = store.context_tokens_from_transcript(tf) + self.assertEqual(result, 10) # 5 + 3 + 2 + + def test_missing_token_fields_default_to_zero(self): + """Missing token sub-fields default to 0 rather than raising.""" + import store + tf = os.path.join(self._tmp, "partial.jsonl") + self._make_transcript(tf, [ + {"message": {"usage": {"input_tokens": 42}}}, + ]) + result = store.context_tokens_from_transcript(tf) + self.assertEqual(result, 42) + + +class TestProjectDataDir(unittest.TestCase): + """project_data_dir returns a created .nexum-data dir, honoring the + CLAUDE_PLUGIN_DATA override (so writer and /nx-load reader agree).""" + + def setUp(self): + self._tmp = tempfile.mkdtemp() + # Exercise the project-scoped fallback path: ensure no env override leaks + # in from another test class. + self._old_env = os.environ.pop("CLAUDE_PLUGIN_DATA", None) + + def tearDown(self): + if self._old_env is None: + os.environ.pop("CLAUDE_PLUGIN_DATA", None) + else: + os.environ["CLAUDE_PLUGIN_DATA"] = self._old_env + + def test_returns_nexum_data_path(self): + """A non-git cwd falls back to <cwd>/.nexum-data.""" + import store + d = store.project_data_dir(self._tmp) + self.assertTrue(d.endswith(".nexum-data"), f"Expected .nexum-data suffix, got {d!r}") + + def test_directory_created(self): + """The returned directory exists on disk.""" + import store + d = store.project_data_dir(self._tmp) + self.assertTrue(os.path.isdir(d), f"Directory not created: {d!r}") + + def test_none_cwd_uses_os_getcwd(self): + """Passing None falls back to os.getcwd() without raising.""" + import store + d = store.project_data_dir(None) + self.assertTrue(d.endswith(".nexum-data")) + self.assertTrue(os.path.isdir(d)) + + def test_env_override_honored(self): + """CLAUDE_PLUGIN_DATA takes priority and is returned as-is.""" + import store + override = os.path.join(self._tmp, "explicit-data") + os.environ["CLAUDE_PLUGIN_DATA"] = override + try: + d = store.project_data_dir(self._tmp) + self.assertEqual(d, override) + self.assertTrue(os.path.isdir(d)) + finally: + os.environ.pop("CLAUDE_PLUGIN_DATA", None) + + if __name__ == "__main__": unittest.main() From fb1f65e17099ab68c52ab26965dcfaf0d6811879 Mon Sep 17 00:00:00 2001 From: Rahul Tyagi <rahul.1992.tyagi@gmail.com> Date: Thu, 18 Jun 2026 20:38:59 +0530 Subject: [PATCH 4/4] Add pre-emptive dedup, plan cost preview, and resume nudge Three optimizations folded into 0.3.0: - predup.py (PreToolUse): deny an identical repeated Read/Grep/Glob (and optional read-only Bash) call already seen this session, with an mtime guard for Read. A PreToolUse deny is honored (unlike the inert PostToolUse shrink), so the avoided re-injection is recorded ungated and the status-line "saved" figure finally moves. Backed by a new input-keyed tool_calls table in store.py, populated by dedup.py on first occurrence. - plan_preview.py: /nx-build prints a projected per-tier cost vs all-opus baseline before dispatching, so routing savings are visible up front. - resume_nudge.py (SessionStart): one-line "run /nx-load" hint when a fresh handoff matches the current branch; nothing auto-loads. Wire both new hooks in hooks.json, document in README/CHANGELOG, and sync marketplace.json to 0.3.0 (was 0.2.1, failing check_version). Full suite green. --- .claude-plugin/marketplace.json | 2 +- CHANGELOG.md | 3 + README.md | 53 ++++++ commands/nx-build.md | 12 ++ hooks/hooks.json | 21 +++ scripts/dedup.py | 22 +++ scripts/plan_preview.py | 196 +++++++++++++++++++ scripts/predup.py | 172 +++++++++++++++++ scripts/resume_nudge.py | 149 +++++++++++++++ scripts/store.py | 91 +++++++++ tests/test_dedup.py | 51 +++++ tests/test_plan_preview.py | 171 +++++++++++++++++ tests/test_predup.py | 322 ++++++++++++++++++++++++++++++++ tests/test_resume_nudge.py | 237 +++++++++++++++++++++++ 14 files changed, 1501 insertions(+), 1 deletion(-) create mode 100644 scripts/plan_preview.py create mode 100644 scripts/predup.py create mode 100644 scripts/resume_nudge.py create mode 100644 tests/test_plan_preview.py create mode 100644 tests/test_predup.py create mode 100644 tests/test_resume_nudge.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 008040e..4e82bf4 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "nexum", "source": "./", "description": "Context-token and model-cost optimization for Claude Code.", - "version": "0.2.1", + "version": "0.3.0", "author": { "name": "Rahul Tyagi", "email": "rahul.1992.tyagi@gmail.com" diff --git a/CHANGELOG.md b/CHANGELOG.md index 92432aa..53a9906 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.3.0] - 2026-06-18 ### Added +- **Pre-emptive dedup hook** (`scripts/predup.py`, PreToolUse). Denies an identical repeated `Read`, `Grep`, or `Glob` call that already ran in the same session, with an mtime guard for `Read` to allow re-reads of changed files. Unlike the PostToolUse dedup (currently inert), a PreToolUse `deny` is actually honored by Claude Code, so the avoided re-injection is a real saving — the `saved` figure in the status line now moves on deduped calls. Config: `predup_enabled` (default true), `predup_decision` (`deny` | `ask`, default `deny`), `predup_bash_readonly` (extend coverage to read-only Bash; default false). +- **`/nx-build` plan cost preview** (`scripts/plan_preview.py`). Before dispatching any steps, `/nx-build` prints a projected cost table — steps per tier, estimated input/output tokens, actual cost, and all-opus baseline — so the user sees the projected savings before execution begins. Numbers are a per-step token heuristic; authoritative post-run totals still come from the §10 cost report. Config: `plan_preview_enabled` (default true). +- **Session resume nudge** (`scripts/resume_nudge.py`, SessionStart hook). On each new session start, if a recent handoff for the current branch exists, a one-line hint is surfaced: "Resume available — run /nx-load to continue." Nothing is loaded automatically. Skipped for resumed or compacted sessions and for handoffs older than `resume_nudge_max_age_hours`. Config: `resume_nudge_enabled` (default true), `resume_nudge_max_age_hours` (default 24). - **Orchestrator step-ledger resume.** `/nx-build` persists each step's verdict to a new `step_ledger` table (keyed by session + plan hash) and, on re-run for the same plan, skips `done` steps and patch-retries `failed` ones from their saved diff — so a session that dies mid-plan resumes instead of redoing completed work. A resumed failed step continues the escalation ladder from its persisted `attempts`/`tier_used`. New `store.py` CLI: `plan-hash`, `step-set/get/list/clear`. Config `orchestrator_resume_enabled` (default true). - **Code-enforced dispatch batch cap.** `store.partition_steps` + CLI `plan-batches` split a route tier into deterministic, order-preserving sub-batches of at most `max_steps_per_dispatch` (default 6), so one dispatch can't overflow an executor's context. Executors return verdict-only on PASS (+ the diff on FAIL) to keep the orchestrator's shared context small. - **Auto-handoff write + `/nx-load` resume.** Past `handoff_threshold_tokens` (default 100k), `context_watch` auto-writes a deterministic handoff skeleton (git state + task + tokens, via `handoff.py`) to `handoff/latest.md` every prompt — guaranteed even if the session dies. The trigger uses Claude Code's REAL context size (the statusline persists `real_context_tokens` from `context_window`), not a prompt-text estimate. A fresh session resumes on demand with `/nx-load` (resume is explicit, not automatic). Config `handoff_threshold_tokens`, `handoff_auto_write_enabled`. diff --git a/README.md b/README.md index d9e9ab9..7302189 100644 --- a/README.md +++ b/README.md @@ -82,3 +82,56 @@ Both thresholds are configurable via `config.json` in the nexum data directory. PostToolUse `updatedToolOutput` is silently ignored for built-in tools on current Claude Code (see anthropics/claude-code [#65403](https://github.com/anthropics/claude-code/issues/65403) and [#32105](https://github.com/anthropics/claude-code/issues/32105)). As a result, the output truncation (`truncate.py`) and dedup pointer-collapse (`dedup.py`) hooks emit replacements that the harness does not apply. nexum performs a per-session self-test to detect whether the harness honors `updatedToolOutput`. Savings are only counted in the status line and cost report after the self-test confirms the field is being applied — so the `saved` counter stays at zero until upstream fixes the issue (at which point nexum auto-reactivates without any config change). + +- **Pre-emptive dedup** (`scripts/predup.py`) — the working, context-saving complement to the inert PostToolUse dedup. It runs as a PreToolUse hook and denies an identical repeated `Read`, `Grep`, or `Glob` call (and optionally read-only `Bash`) that was already executed in the same session. For `Read` calls an mtime guard is applied first: if the file has changed since the first call, the repeat is allowed through. Because a PreToolUse `deny` is actually honored by Claude Code, the avoided re-injection is a **real** saving — it records an ungated saving so the `saved` figure in the status line moves. Configure via `config.json`: + ```json + { + "predup_enabled": true, + "predup_decision": "deny", + "predup_bash_readonly": false + } + ``` + Set `predup_decision` to `"ask"` to prompt instead of silently denying. Set `predup_bash_readonly` to `true` to also cover read-only Bash commands (`cat`, `grep`, `ls`, `git log/diff/show/status/branch`, etc.). + +### /nx-build cost preview + +Before dispatching any steps, `/nx-build` prints a projected cost breakdown when `plan_preview_enabled` is true (the default). It runs `scripts/plan_preview.py` against the plan file and shows the estimated cost per tier (Haiku / Sonnet / Opus) and the projected savings vs an all-opus run: + +``` +[nexum] Plan cost preview (estimate) + Steps: 9 | Per-step heuristic: 8,000 in / 2,000 out tokens + Note: token counts are a per-step heuristic, not measured usage. + + Tier Steps Input tok Output tok Actual $ Baseline $ + -------------------------------------------------------------------- + haiku 3 24,000 6,000 $0.0027 $0.0900 + sonnet 5 40,000 10,000 $0.0600 $0.1500 + opus 1 8,000 2,000 $0.0540 $0.0540 + -------------------------------------------------------------------- + TOTAL 9 72,000 18,000 $0.1167 $0.2940 + +Projected: $0.1167 vs all-opus $0.2940 — saves $0.1773 (60.3%) +``` + +The numbers are a per-step token heuristic (an estimate, not measured). The authoritative post-run totals — capturing prompt-cache writes/reads and actual token counts — come from the §10 cost report at the end of the run. Configure via `config.json`: + +```json +{ "plan_preview_enabled": true } +``` + +### Session resume nudge + +`scripts/resume_nudge.py` runs as a `SessionStart` hook. When a recent handoff for the current branch exists in the nexum data directory, it surfaces a one-line hint in the session context: + +``` +[nexum] Resume available: a handoff for branch 'my-branch' was written 2026-06-18T10:00:00+00:00 — run /nx-load to continue. (Not loaded automatically.) +``` + +The nudge is skipped for continued (`resume`) or compacted sessions, and it checks that the handoff was written within `resume_nudge_max_age_hours` (default 24). Nothing is loaded automatically — the user must run `/nx-load` explicitly. Configure via `config.json`: + +```json +{ + "resume_nudge_enabled": true, + "resume_nudge_max_age_hours": 24 +} +``` diff --git a/commands/nx-build.md b/commands/nx-build.md index 7e73be9..41d0774 100644 --- a/commands/nx-build.md +++ b/commands/nx-build.md @@ -33,6 +33,18 @@ If `orchestrator_resume_enabled` is true (default): If every step is already `done`, report that the plan is complete and skip to the cost summary (§10). Hold the `plan_hash` for the rest of the run — you record every verdict against it (§6a). +## 1b. Cost preview (show projected savings up front) + +If `plan_preview_enabled` is true (the default), run: + +``` +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/plan_preview.py --plan <plan_file> +``` + +and print its output verbatim **before dispatching any steps**, so the user sees the projected cost per tier and the savings vs an all-opus run at the start of execution. + +The preview is a per-step token heuristic (an estimate, not measured usage); the authoritative post-run numbers — capturing cache writes/reads and actual token counts — still come from the §10 cost report after all steps complete. + ## 2. Group steps by route Partition steps into three ordered groups and execute them in this order: diff --git a/hooks/hooks.json b/hooks/hooks.json index 590a266..c217e38 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -22,6 +22,27 @@ } ], "matcher": "Bash|Grep|Glob|Read" + }, + { + "hooks": [ + { + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/predup.py", + "timeout": 10, + "type": "command" + } + ], + "matcher": "Read|Bash|Grep|Glob" + } + ], + "SessionStart": [ + { + "hooks": [ + { + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/resume_nudge.py", + "timeout": 10, + "type": "command" + } + ] } ], "UserPromptSubmit": [ diff --git a/scripts/dedup.py b/scripts/dedup.py index f7523d1..51779b9 100644 --- a/scripts/dedup.py +++ b/scripts/dedup.py @@ -285,6 +285,28 @@ def main() -> None: store.record_output(session_id, tool_name, h, summary, token_count) + # Additively record the INPUT signature so PreToolUse predup can + # recognise a later identical call. Wrapped in its own try/except so + # it can never change existing behaviour. + try: + tool_input = data.get("tool_input") or {} + input_sig = store.tool_call_sig(tool_name, tool_input) + if tool_name == "Read": + fp = tool_input.get("file_path") + try: + mtime = os.path.getmtime(fp) if (fp and os.path.exists(fp)) else None + except Exception: + mtime = None + else: + fp = None + mtime = None + # Record the ORIGINAL (pre-shrink) token estimate — that is what a + # repeat would re-inject, since PostToolUse shrink is inert on + # current Claude Code. + store.record_tool_call(session_id, input_sig, tool_name, store.estimate_tokens(output), fp, mtime) + except Exception: + pass + response = { "hookSpecificOutput": { "hookEventName": "PostToolUse", diff --git a/scripts/plan_preview.py b/scripts/plan_preview.py new file mode 100644 index 0000000..0173f19 --- /dev/null +++ b/scripts/plan_preview.py @@ -0,0 +1,196 @@ +""" +plan_preview.py — Nexum plan cost preview CLI. + +Parses a nexum plan file, maps each step's route to a model tier, and prints +a projected cost estimate per tier and total against an all-opus baseline. +This lets /nx-build show savings before dispatching any work. + +Usage: + python3 plan_preview.py --plan <path> [--session <id>] + +Stdlib only. Fail-open: errors in main() degrade to a message and exit 0. +""" + +import argparse +import os +import re +import sys +from typing import List + +# --------------------------------------------------------------------------- +# sys.path: ensure scripts/ dir is importable as "import store" +# --------------------------------------------------------------------------- +_SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__)) +if _SCRIPTS_DIR not in sys.path: + sys.path.insert(0, _SCRIPTS_DIR) + +import store # noqa: E402 + + +# --------------------------------------------------------------------------- +# Route → tier mapping +# --------------------------------------------------------------------------- +ROUTE_TIER = { + "mechanical": "haiku", + "standard": "sonnet", + "needs-strong": "opus", +} + + +# --------------------------------------------------------------------------- +# Plan parser +# --------------------------------------------------------------------------- + +def parse_plan_steps(text: str) -> List[dict]: + """Parse a nexum plan markdown and return steps with index, title, and route. + + Scans for ``### Step <N>: <title>`` headers and reads the first + ``- route: <value>`` line that follows. Returns a list of + ``{"index": int, "title": str, "route": str}`` in file order. + Only real ``### Step`` headers are parsed; route-rubric examples + inside ``|``-quoted table cells are ignored. + """ + steps = [] + lines = text.splitlines() + i = 0 + while i < len(lines): + line = lines[i] + # Match ### Step N: title + m = re.match(r'^###\s+Step\s+(\d+):\s+(.+)', line) + if m: + index = int(m.group(1)) + title = m.group(2).strip() + # Scan forward for the route line (first occurrence within the block) + route = "standard" + for j in range(i + 1, min(i + 30, len(lines))): + next_line = lines[j] + # Stop at the next ### header (new step) + if re.match(r'^###\s+Step\s+\d+:', next_line): + break + # Only match a plain list item (not inside a table cell) + rm = re.match(r'^- route:\s+(\S+)', next_line) + if rm: + raw_route = rm.group(1).rstrip(',;|') + # Map to known routes; default to "standard" + if raw_route in ROUTE_TIER: + route = raw_route + break + steps.append({"index": index, "title": title, "route": route}) + i += 1 + return steps + + +# --------------------------------------------------------------------------- +# Preview builder +# --------------------------------------------------------------------------- + +def build_preview(steps: list, cfg: dict) -> str: + """Build the plan cost preview string. + + For each step: cost = in_tok/1e6 * price_in + out_tok/1e6 * price_out. + Aggregate per tier; compute baseline at opus rates. + Returns a human-readable string starting with "[nexum] Plan cost preview". + """ + if not steps: + return "[nexum] No steps found in plan." + + in_tok = int(cfg.get("plan_preview_input_tok_per_step", 8000)) + out_tok = int(cfg.get("plan_preview_output_tok_per_step", 2000)) + + opus_in, opus_out = store.PRICING["opus"] + + # Per-tier aggregation + # tier -> {count, in_tok_total, out_tok_total, actual_cost, baseline_cost} + tiers: dict = {} + + for step in steps: + route = step.get("route", "standard") + tier = ROUTE_TIER.get(route, "sonnet") + price_in, price_out = store.PRICING.get(tier, store.PRICING["sonnet"]) + + step_actual = in_tok / 1e6 * price_in + out_tok / 1e6 * price_out + step_baseline = in_tok / 1e6 * opus_in + out_tok / 1e6 * opus_out + + if tier not in tiers: + tiers[tier] = { + "count": 0, + "in_tok": 0, + "out_tok": 0, + "actual": 0.0, + "baseline": 0.0, + } + tiers[tier]["count"] += 1 + tiers[tier]["in_tok"] += in_tok + tiers[tier]["out_tok"] += out_tok + tiers[tier]["actual"] += step_actual + tiers[tier]["baseline"] += step_baseline + + total_actual = sum(t["actual"] for t in tiers.values()) + total_baseline = sum(t["baseline"] for t in tiers.values()) + saved = total_baseline - total_actual + pct = (saved / total_baseline * 100) if total_baseline > 0 else 0.0 + + lines = [] + lines.append("[nexum] Plan cost preview (estimate)") + lines.append(f" Steps: {len(steps)} | Per-step heuristic: {in_tok:,} in / {out_tok:,} out tokens") + lines.append(f" Note: token counts are a per-step heuristic, not measured usage.") + lines.append("") + lines.append( + f" {'Tier':<12} {'Steps':>6} {'Input tok':>12} {'Output tok':>12} " + f"{'Actual $':>10} {'Baseline $':>12}" + ) + lines.append(" " + "-" * 68) + for tier in ("haiku", "sonnet", "opus"): + if tier not in tiers: + continue + t = tiers[tier] + lines.append( + f" {tier:<12} {t['count']:>6} {t['in_tok']:>12,} {t['out_tok']:>12,} " + f"${t['actual']:>9.4f} ${t['baseline']:>11.4f}" + ) + lines.append(" " + "-" * 68) + lines.append( + f" {'TOTAL':<12} {len(steps):>6} " + f"{sum(t['in_tok'] for t in tiers.values()):>12,} " + f"{sum(t['out_tok'] for t in tiers.values()):>12,} " + f"${total_actual:>9.4f} ${total_baseline:>11.4f}" + ) + lines.append("") + lines.append( + f"Projected: ${total_actual:.4f} vs all-opus ${total_baseline:.4f} " + f"— saves ${saved:.4f} ({pct:.1f}%)" + ) + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# CLI entry point +# --------------------------------------------------------------------------- + +def main() -> None: + """CLI: parse a plan file and print the cost preview.""" + try: + parser = argparse.ArgumentParser( + prog="plan_preview.py", + description="Nexum plan cost preview — projected cost vs all-opus baseline.", + ) + parser.add_argument("--plan", required=True, help="Path to the nexum plan file.") + parser.add_argument("--session", default=None, help="Session ID (unused, accepted for parity).") + args = parser.parse_args() + + try: + with open(args.plan, "r", encoding="utf-8", errors="replace") as fh: + text = fh.read() + except Exception: + print(f"[nexum] No plan file at {args.plan}.") + return + + cfg = store.get_config() + print(build_preview(parse_plan_steps(text), cfg)) + except Exception: + # Fail-open + pass + + +if __name__ == "__main__": + main() diff --git a/scripts/predup.py b/scripts/predup.py new file mode 100644 index 0000000..da7510f --- /dev/null +++ b/scripts/predup.py @@ -0,0 +1,172 @@ +""" +predup.py — Nexum PreToolUse pre-emptive dedup hook. + +Denies (or asks on) a tool call whose normalised input was already executed +earlier this session. Recording a saving BEFORE the repeat runs is ungated — +a PreToolUse deny is actually honored by Claude Code, so the avoided +re-injection is a real saving, not a fictional PostToolUse one. + +Hook contract: + stdin → single JSON object (Claude Code PreToolUse payload) + stdout → single JSON object (deny shape or {}) + exit 0 always (fail-open) +""" + +import json +import os +import shlex +import sys + +# --------------------------------------------------------------------------- +# sys.path: ensure scripts/ dir is importable as "import store" +# --------------------------------------------------------------------------- +_SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__)) +if _SCRIPTS_DIR not in sys.path: + sys.path.insert(0, _SCRIPTS_DIR) + +import store # noqa: E402 + + +# --------------------------------------------------------------------------- +# Read-only Bash command allowlist for predup_bash_readonly mode. +# For "git" a second-token check is also required (see main()). +# --------------------------------------------------------------------------- +_BASH_READONLY = {"cat", "head", "tail", "ls", "wc", "grep", "rg", "egrep", "fgrep", "find", "git"} +_GIT_READONLY_SUBCMDS = {"log", "diff", "show", "status", "branch"} + + +def _bash_is_readonly(command: str) -> bool: + """Return True if the Bash command is on the read-only allowlist.""" + try: + tokens = shlex.split(command) + except ValueError: + tokens = command.split() + if not tokens: + return False + first = tokens[0] + if first not in _BASH_READONLY: + return False + if first == "git": + second = tokens[1] if len(tokens) > 1 else "" + return second in _GIT_READONLY_SUBCMDS + return True + + +def main() -> None: + """PreToolUse hook entry point.""" + try: + # ---------------------------------------------------------------- + # 1. Parse stdin JSON + # ---------------------------------------------------------------- + try: + raw = sys.stdin.read() + data = json.loads(raw) + except Exception: + print("{}") + return + + if not isinstance(data, dict): + print("{}") + return + + # ---------------------------------------------------------------- + # 2. Check config gate + # ---------------------------------------------------------------- + cfg = store.get_config() + if not cfg.get("predup_enabled", True): + print("{}") + return + + # ---------------------------------------------------------------- + # 3. Extract fields + # ---------------------------------------------------------------- + session_id = data.get("session_id") or "_nosession" + tool_name = data.get("tool_name") or "" + tool_input = data.get("tool_input") or {} + + # ---------------------------------------------------------------- + # 4. Eligibility check + # ---------------------------------------------------------------- + if tool_name in {"Read", "Grep", "Glob"}: + eligible = True + elif tool_name == "Bash": + if cfg.get("predup_bash_readonly", False): + command = tool_input.get("command", "") or "" + eligible = _bash_is_readonly(command) + else: + eligible = False + else: + eligible = False + + if not eligible: + print("{}") + return + + # ---------------------------------------------------------------- + # 5. Compute signature and check for a prior call + # ---------------------------------------------------------------- + sig = store.tool_call_sig(tool_name, tool_input) + prior = store.seen_tool_call(session_id, sig) + + if prior is None: + # First occurrence — nothing to dedup + print("{}") + return + + # ---------------------------------------------------------------- + # 6. Read-state guard (only for Read tool) + # ---------------------------------------------------------------- + if tool_name == "Read": + fp = tool_input.get("file_path") + if fp and prior.get("mtime") is not None: + try: + cur = os.path.getmtime(fp) + if cur != prior["mtime"]: + print("{}") + return + except OSError: + # File gone or inaccessible → let the call through + print("{}") + return + + # ---------------------------------------------------------------- + # 7. Compute and record saving (ungated — deny IS honored) + # ---------------------------------------------------------------- + raw_tok = int(prior.get("token_count") or 0) + if raw_tok > 0: + try: + weight = float(cfg.get("dedup_cache_weight", 0.1)) + effective = max(0, round(raw_tok * weight)) + store.record_saving(session_id, "predup", raw_tok, effective) + except Exception: + pass + + # ---------------------------------------------------------------- + # 8. Build and emit the deny/ask decision + # ---------------------------------------------------------------- + decision = cfg.get("predup_decision", "deny") + if decision not in {"deny", "ask"}: + decision = "deny" + + reason = ( + f"[nexum] identical {tool_name} call already ran earlier this session " + f"(~{raw_tok} tok already in context) — reuse the earlier result " + "instead of re-running." + ) + + out = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": decision, + "permissionDecisionReason": reason, + } + } + print(json.dumps(out, sort_keys=True)) + + except Exception: + # Fail-open: never crash the Claude Code session + print("{}") + + +if __name__ == "__main__": + main() diff --git a/scripts/resume_nudge.py b/scripts/resume_nudge.py new file mode 100644 index 0000000..6f412ca --- /dev/null +++ b/scripts/resume_nudge.py @@ -0,0 +1,149 @@ +""" +resume_nudge.py — Nexum SessionStart resume hint hook. + +When a recent handoff for the current branch exists, surfaces a one-line +"resume available — run /nx-load" hint without auto-loading anything. + +Hook contract: + stdin → single JSON object (Claude Code SessionStart payload) + stdout → single JSON object (hint or {}) + exit 0 always (fail-open) +""" + +import datetime +import json +import os +import re +import subprocess +import sys + +# --------------------------------------------------------------------------- +# sys.path: ensure scripts/ dir is importable as "import store" +# --------------------------------------------------------------------------- +_SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__)) +if _SCRIPTS_DIR not in sys.path: + sys.path.insert(0, _SCRIPTS_DIR) + +import store # noqa: E402 + + +def main() -> None: + """SessionStart hook entry point.""" + try: + # ---------------------------------------------------------------- + # 1. Parse stdin JSON + # ---------------------------------------------------------------- + try: + raw = sys.stdin.read() + data = json.loads(raw) + except Exception: + print("{}") + return + + if not isinstance(data, dict): + print("{}") + return + + # ---------------------------------------------------------------- + # 2. Check config gate + # ---------------------------------------------------------------- + cfg = store.get_config() + if not cfg.get("resume_nudge_enabled", True): + print("{}") + return + + # ---------------------------------------------------------------- + # 3. Skip continued sessions (already resumed or compacted) + # ---------------------------------------------------------------- + source = data.get("source") + if source in {"resume", "compact"}: + print("{}") + return + + # ---------------------------------------------------------------- + # 4. Locate the latest handoff file + # ---------------------------------------------------------------- + cwd = data.get("cwd") or os.getcwd() + data_dir = store.project_data_dir(cwd) + latest = os.path.join(data_dir, "handoff", "latest.md") + if not os.path.isfile(latest): + print("{}") + return + + # ---------------------------------------------------------------- + # 5. Parse header from the handoff file + # ---------------------------------------------------------------- + try: + with open(latest, "r", encoding="utf-8", errors="replace") as fh: + content = fh.read() + except Exception: + print("{}") + return + + branch_m = re.search(r'\*\*Branch:\*\*\s*`?([^\s`*]+)', content) + written_m = re.search(r'\*\*Written:\*\*\s*([0-9T:+\-]+)', content) + + if not branch_m or not written_m: + print("{}") + return + + branch = branch_m.group(1).strip() + written = written_m.group(1).strip() + + # ---------------------------------------------------------------- + # 6. Freshness check + # ---------------------------------------------------------------- + try: + written_dt = datetime.datetime.fromisoformat(written) + now_dt = datetime.datetime.now().astimezone() + age = now_dt - written_dt + max_age_hours = float(cfg.get("resume_nudge_max_age_hours", 24)) + if age.total_seconds() > max_age_hours * 3600: + print("{}") + return + except Exception: + print("{}") + return + + # ---------------------------------------------------------------- + # 7. Branch match + # ---------------------------------------------------------------- + try: + result = subprocess.run( + ["git", "-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, + text=True, + timeout=5, + ) + current_branch = result.stdout.strip() + except Exception: + current_branch = "" + + if current_branch and current_branch != branch: + print("{}") + return + + # ---------------------------------------------------------------- + # 8. Emit the hint + # ---------------------------------------------------------------- + hint = ( + f"[nexum] Resume available: a handoff for branch '{branch}' was written " + f"{written} — run /nx-load to continue. (Not loaded automatically.)" + ) + + out = { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": hint + " Do not load it unless the user asks.", + }, + "systemMessage": hint, + } + print(json.dumps(out, sort_keys=True)) + + except Exception: + # Fail-open: never crash the Claude Code session + print("{}") + + +if __name__ == "__main__": + main() diff --git a/scripts/store.py b/scripts/store.py index 0c620ec..5f7e09b 100644 --- a/scripts/store.py +++ b/scripts/store.py @@ -108,6 +108,27 @@ # mid-plan resumes instead of redoing completed work. Set False to always # execute every step from scratch. "orchestrator_resume_enabled": True, + # PreToolUse pre-emptive dedup: deny (or ask on) a tool call whose + # normalised input was already executed earlier this session, avoiding a + # redundant re-injection and recording a real saving. + "predup_enabled": True, + # When predup fires, either "deny" the repeat outright or "ask" the user. + "predup_decision": "deny", + # When True, also predup a conservative allowlist of read-only Bash commands + # (cat, head, tail, ls, wc, grep family, find, git log/diff/show/status/branch). + "predup_bash_readonly": False, + # SessionStart resume nudge: when a recent handoff for the current branch + # exists, surface a one-line hint without auto-loading anything. + "resume_nudge_enabled": True, + # Maximum age (hours) of a handoff before the nudge is suppressed. + "resume_nudge_max_age_hours": 24, + # Plan cost preview: parse a nexum plan file and print projected cost per + # tier vs an all-opus baseline so /nx-build can show savings up front. + "plan_preview_enabled": True, + # Per-step token heuristic for the plan cost preview (input side). + "plan_preview_input_tok_per_step": 8000, + # Per-step token heuristic for the plan cost preview (output side). + "plan_preview_output_tok_per_step": 2000, } # --------------------------------------------------------------------------- @@ -171,6 +192,22 @@ updated_ts REAL ) """, + # Input-keyed tool-call store so PreToolUse predup can recognise repeat calls. + # Keyed by (session_id, input_sig): input_sig is the SHA-256 of tool_name + + # NUL + json.dumps(tool_input, sort_keys=True) so two identical invocations + # produce the same key regardless of dict-insertion order. + """ + CREATE TABLE IF NOT EXISTS tool_calls( + session_id TEXT, + input_sig TEXT, + tool_name TEXT, + token_count INTEGER, + file_path TEXT, + mtime REAL, + ts REAL, + PRIMARY KEY(session_id, input_sig) + ) + """, # Step ledger — durable per-step execution state for /nx-build, so a # session that dies mid-plan resumes instead of redoing completed steps. # Keyed by (session_id, plan_hash, step_index): plan_hash ties a row to a @@ -460,6 +497,60 @@ def transcript_tool_result_len(transcript_path: str, tool_use_id: str) -> Option return None +# --------------------------------------------------------------------------- +# Input-keyed tool-call helpers (used by PreToolUse predup) +# --------------------------------------------------------------------------- + +def tool_call_sig(tool_name: str, tool_input: dict) -> str: + """Return the SHA-256 of tool_name + NUL + json.dumps(tool_input, sort_keys=True). + + Deterministic for identical inputs regardless of dict insertion order. + Uses the existing sha256() helper. + """ + serialised = tool_name + "\x00" + json.dumps(tool_input, sort_keys=True, default=str) + return sha256(serialised) + + +def record_tool_call( + session_id: str, + input_sig: str, + tool_name: str, + token_count: int, + file_path=None, + mtime=None, +) -> None: + """INSERT OR REPLACE a row into tool_calls with ts=time.time(). Fail-open.""" + try: + conn = db() + with conn: + conn.execute( + "INSERT OR REPLACE INTO tool_calls" + "(session_id, input_sig, tool_name, token_count, file_path, mtime, ts) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (session_id, input_sig, tool_name, token_count, file_path, mtime, time.time()), + ) + conn.close() + except Exception: + pass + + +def seen_tool_call(session_id: str, input_sig: str) -> Optional[Dict[str, Any]]: + """Return the tool_calls row dict for (session_id, input_sig), or None.""" + try: + conn = db() + row = conn.execute( + "SELECT session_id, input_sig, tool_name, token_count, file_path, mtime, ts " + "FROM tool_calls WHERE session_id=? AND input_sig=?", + (session_id, input_sig), + ).fetchone() + conn.close() + if row is None: + return None + return dict(row) + except Exception: + return None + + # --------------------------------------------------------------------------- # Dedup / memo helpers # --------------------------------------------------------------------------- diff --git a/tests/test_dedup.py b/tests/test_dedup.py index 9975703..8b88f08 100644 --- a/tests/test_dedup.py +++ b/tests/test_dedup.py @@ -345,5 +345,56 @@ def test_savings_gated_until_self_test_verified(self): ) +class TestDedupInputSigRecorded(unittest.TestCase): + """After dedup processes a large Read output, store.seen_tool_call returns a row.""" + + def setUp(self): + self._tmp = tempfile.mkdtemp() + + def _seen_tool_call(self, session_id, tool_name, tool_input): + """Look up a tool_call row under this test's CLAUDE_PLUGIN_DATA.""" + import os as _os + import store as _store + old_env = _os.environ.get("CLAUDE_PLUGIN_DATA") + _os.environ["CLAUDE_PLUGIN_DATA"] = self._tmp + try: + sig = _store.tool_call_sig(tool_name, tool_input) + return _store.seen_tool_call(session_id, sig) + finally: + if old_env is None: + _os.environ.pop("CLAUDE_PLUGIN_DATA", None) + else: + _os.environ["CLAUDE_PLUGIN_DATA"] = old_env + + def test_input_sig_recorded_after_first_occurrence(self): + """A large Read PostToolUse payload records a tool_calls row with token_count > 0.""" + # Create a real temp file so os.path.getmtime can succeed + f = tempfile.NamedTemporaryFile(delete=False, suffix=".py", dir=self._tmp) + f.write(_LARGE_TEXT.encode()) + f.close() + real_path = f.name + + session_id = "sess_input_sig" + tool_input = {"file_path": real_path} + + payload = { + "session_id": session_id, + "tool_name": "Read", + "tool_input": tool_input, + "tool_response": _LARGE_TEXT, + } + out, rc = _run_dedup(payload, self._tmp) + self.assertEqual(rc, 0) + # Must be a first-occurrence (not pointer) response + self.assertIn("hookSpecificOutput", out) + + row = self._seen_tool_call(session_id, "Read", tool_input) + self.assertIsNotNone(row, "Expected a tool_call row after dedup first occurrence") + self.assertGreater( + row["token_count"], 0, + f"Expected token_count > 0 in tool_call row, got: {row}" + ) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_plan_preview.py b/tests/test_plan_preview.py new file mode 100644 index 0000000..be3123a --- /dev/null +++ b/tests/test_plan_preview.py @@ -0,0 +1,171 @@ +""" +test_plan_preview.py — stdlib unittest tests for scripts/plan_preview.py + +Covers: + (a) parse_plan_steps on 3-step sample returns correct routes + (b) build_preview output contains "Plan cost preview" and "Projected:" with non-zero savings + (c) main on missing path prints "No plan file" and exits 0 +""" + +import json +import os +import subprocess +import sys +import tempfile +import unittest + +_SCRIPTS_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts" +) +if _SCRIPTS_DIR not in sys.path: + sys.path.insert(0, _SCRIPTS_DIR) + +import plan_preview +import store + + +_SAMPLE_PLAN = """ +# Sample Plan + +### Step 1: mechanical task +- route: mechanical +- scope: scripts/foo.py +- acceptance: python3 -c "print('ok')" + +### Step 2: standard task +- route: standard +- scope: scripts/bar.py +- acceptance: python3 -c "print('ok')" + +### Step 3: needs-strong task +- route: needs-strong +- scope: scripts/baz.py +- acceptance: python3 -c "print('ok')" +""" + + +class TestParsePlanSteps(unittest.TestCase): + """parse_plan_steps correctly extracts index, title, and route.""" + + def test_three_steps_returned(self): + steps = plan_preview.parse_plan_steps(_SAMPLE_PLAN) + self.assertEqual(len(steps), 3, f"Expected 3 steps, got: {steps}") + + def test_step_indices(self): + steps = plan_preview.parse_plan_steps(_SAMPLE_PLAN) + self.assertEqual(steps[0]["index"], 1) + self.assertEqual(steps[1]["index"], 2) + self.assertEqual(steps[2]["index"], 3) + + def test_step_routes(self): + steps = plan_preview.parse_plan_steps(_SAMPLE_PLAN) + self.assertEqual(steps[0]["route"], "mechanical") + self.assertEqual(steps[1]["route"], "standard") + self.assertEqual(steps[2]["route"], "needs-strong") + + def test_step_titles(self): + steps = plan_preview.parse_plan_steps(_SAMPLE_PLAN) + self.assertIn("mechanical task", steps[0]["title"]) + self.assertIn("standard task", steps[1]["title"]) + self.assertIn("needs-strong task", steps[2]["title"]) + + def test_empty_plan_returns_empty(self): + steps = plan_preview.parse_plan_steps("") + self.assertEqual(steps, []) + + def test_no_steps_plan(self): + steps = plan_preview.parse_plan_steps("# Just a header\n\nSome text.\n") + self.assertEqual(steps, []) + + +class TestBuildPreview(unittest.TestCase): + """build_preview returns a correctly formatted estimate string.""" + + def setUp(self): + self._cfg = store.get_config() + + def test_contains_plan_cost_preview(self): + steps = plan_preview.parse_plan_steps(_SAMPLE_PLAN) + output = plan_preview.build_preview(steps, self._cfg) + self.assertIn("Plan cost preview", output) + + def test_contains_projected_line(self): + steps = plan_preview.parse_plan_steps(_SAMPLE_PLAN) + output = plan_preview.build_preview(steps, self._cfg) + self.assertIn("Projected:", output) + + def test_savings_nonzero(self): + """Haiku/sonnet are cheaper than opus so saves must be > 0.""" + steps = plan_preview.parse_plan_steps(_SAMPLE_PLAN) + output = plan_preview.build_preview(steps, self._cfg) + # Extract the saves amount from "saves $X.XXXX" + import re + m = re.search(r'saves \$([0-9.]+)', output) + self.assertIsNotNone(m, f"Could not find 'saves $...' in output:\n{output}") + saved = float(m.group(1)) + self.assertGreater(saved, 0.0, f"Expected non-zero savings, got: {saved}") + + def test_empty_steps_returns_no_steps_message(self): + output = plan_preview.build_preview([], self._cfg) + self.assertIn("No steps found", output) + + def test_all_opus_steps_zero_savings(self): + """All needs-strong (opus) steps → 0 savings vs all-opus baseline.""" + steps = [ + {"index": 1, "title": "a", "route": "needs-strong"}, + {"index": 2, "title": "b", "route": "needs-strong"}, + ] + output = plan_preview.build_preview(steps, self._cfg) + import re + m = re.search(r'saves \$([0-9.]+)', output) + self.assertIsNotNone(m) + saved = float(m.group(1)) + self.assertAlmostEqual(saved, 0.0, places=6) + + +class TestMainMissingPlan(unittest.TestCase): + """main prints 'No plan file' and exits 0 when plan path doesn't exist.""" + + def setUp(self): + self._tmp = tempfile.mkdtemp() + + def test_missing_plan_exit_0(self): + missing_path = os.path.join(self._tmp, "nonexistent_plan.md") + env = os.environ.copy() + env["CLAUDE_PLUGIN_DATA"] = self._tmp + env["PYTHONPATH"] = _SCRIPTS_DIR + os.pathsep + env.get("PYTHONPATH", "") + result = subprocess.run( + [sys.executable, os.path.join(_SCRIPTS_DIR, "plan_preview.py"), + "--plan", missing_path], + capture_output=True, + env=env, + timeout=15, + ) + self.assertEqual(result.returncode, 0) + output = result.stdout.decode() + self.assertIn("No plan file", output, f"Expected 'No plan file' in output: {output!r}") + + def test_real_plan_exit_0(self): + """A valid plan file runs successfully.""" + plan_path = os.path.join(self._tmp, "test_plan.md") + with open(plan_path, "w") as fh: + fh.write(_SAMPLE_PLAN) + + env = os.environ.copy() + env["CLAUDE_PLUGIN_DATA"] = self._tmp + env["PYTHONPATH"] = _SCRIPTS_DIR + os.pathsep + env.get("PYTHONPATH", "") + result = subprocess.run( + [sys.executable, os.path.join(_SCRIPTS_DIR, "plan_preview.py"), + "--plan", plan_path], + capture_output=True, + env=env, + timeout=15, + ) + self.assertEqual(result.returncode, 0) + output = result.stdout.decode() + self.assertIn("Plan cost preview", output) + self.assertIn("Projected:", output) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_predup.py b/tests/test_predup.py new file mode 100644 index 0000000..79501e5 --- /dev/null +++ b/tests/test_predup.py @@ -0,0 +1,322 @@ +""" +test_predup.py — stdlib unittest tests for scripts/predup.py + +Covers: + (a) no prior call → allow {} + (b) seeded prior + identical Read input → deny with savings recorded + (c) predup_enabled=false → allow {} + (d) Read with changed mtime vs seeded prior → allow {} + (e) malformed stdin → allow {} exit 0 + (f) Bash with predup_bash_readonly default False → allow {} +""" + +import json +import os +import subprocess +import sys +import tempfile +import unittest + +_SCRIPTS_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts" +) +if _SCRIPTS_DIR not in sys.path: + sys.path.insert(0, _SCRIPTS_DIR) + + +def _run_predup(payload, data_dir, extra_config=None): + """Run predup.py subprocess and return (parsed_output, exit_code).""" + env = os.environ.copy() + env["CLAUDE_PLUGIN_DATA"] = data_dir + env["PYTHONPATH"] = _SCRIPTS_DIR + os.pathsep + env.get("PYTHONPATH", "") + + if extra_config: + cfg_path = os.path.join(data_dir, "config.json") + with open(cfg_path, "w") as fh: + json.dump(extra_config, fh) + + result = subprocess.run( + [sys.executable, os.path.join(_SCRIPTS_DIR, "predup.py")], + input=json.dumps(payload).encode(), + capture_output=True, + env=env, + timeout=15, + ) + return json.loads(result.stdout.decode()), result.returncode + + +def _run_predup_raw(raw_bytes, data_dir): + """Run predup.py with raw bytes on stdin.""" + env = os.environ.copy() + env["CLAUDE_PLUGIN_DATA"] = data_dir + env["PYTHONPATH"] = _SCRIPTS_DIR + os.pathsep + env.get("PYTHONPATH", "") + result = subprocess.run( + [sys.executable, os.path.join(_SCRIPTS_DIR, "predup.py")], + input=raw_bytes, + capture_output=True, + env=env, + timeout=15, + ) + return result.stdout.decode().strip(), result.returncode + + +def _seed_tool_call(data_dir, session_id, tool_name, tool_input, token_count=500, mtime=None): + """Seed a tool_call row in the store for the given data_dir.""" + old_val = os.environ.get("CLAUDE_PLUGIN_DATA") + os.environ["CLAUDE_PLUGIN_DATA"] = data_dir + try: + import importlib + import store as _store + importlib.reload(_store) + sig = _store.tool_call_sig(tool_name, tool_input) + _store.record_tool_call(session_id, sig, tool_name, token_count, mtime=mtime) + return sig + finally: + if old_val is None: + os.environ.pop("CLAUDE_PLUGIN_DATA", None) + else: + os.environ["CLAUDE_PLUGIN_DATA"] = old_val + + +def _get_session_savings(data_dir, session_id): + """Read session_savings from the store for the given data_dir.""" + old_val = os.environ.get("CLAUDE_PLUGIN_DATA") + os.environ["CLAUDE_PLUGIN_DATA"] = data_dir + try: + import importlib + import store as _store + importlib.reload(_store) + return _store.session_savings(session_id) + finally: + if old_val is None: + os.environ.pop("CLAUDE_PLUGIN_DATA", None) + else: + os.environ["CLAUDE_PLUGIN_DATA"] = old_val + + +class TestPredupNoPrior(unittest.TestCase): + """No prior tool call → predup emits {} (allow).""" + + def setUp(self): + self._tmp = tempfile.mkdtemp() + + def test_no_prior_allows(self): + payload = { + "session_id": "s_new", + "tool_name": "Read", + "tool_input": {"file_path": "/some/file.py"}, + } + out, rc = _run_predup(payload, self._tmp) + self.assertEqual(rc, 0) + self.assertEqual(out, {}, f"Expected {{}} for no-prior call, got: {out}") + + +class TestPredupDenyOnRepeat(unittest.TestCase): + """Seeded prior + identical Read input → deny with savings recorded.""" + + def setUp(self): + self._tmp = tempfile.mkdtemp() + + def test_repeat_read_is_denied(self): + session_id = "s_repeat" + tool_input = {"file_path": "/tmp/some_file.py"} + + # Seed the prior call in the SAME db + _seed_tool_call(self._tmp, session_id, "Read", tool_input, token_count=400) + + # Now run the hook — it should deny + payload = { + "session_id": session_id, + "tool_name": "Read", + "tool_input": tool_input, + } + out, rc = _run_predup(payload, self._tmp) + self.assertEqual(rc, 0) + self.assertIn("hookSpecificOutput", out, f"Expected deny output, got: {out}") + decision = out["hookSpecificOutput"].get("permissionDecision") + self.assertEqual(decision, "deny", f"Expected deny, got: {decision}") + + def test_repeat_savings_recorded(self): + session_id = "s_savings" + tool_input = {"file_path": "/tmp/savings_file.py"} + + # Seed prior + _seed_tool_call(self._tmp, session_id, "Read", tool_input, token_count=600) + + payload = { + "session_id": session_id, + "tool_name": "Read", + "tool_input": tool_input, + } + _run_predup(payload, self._tmp) + + savings = _get_session_savings(self._tmp, session_id) + self.assertGreater(savings, 0, "Expected savings > 0 after predup deny") + + def test_repeat_grep_is_denied(self): + session_id = "s_grep" + tool_input = {"pattern": "TODO", "path": "src/"} + + _seed_tool_call(self._tmp, session_id, "Grep", tool_input, token_count=300) + + payload = { + "session_id": session_id, + "tool_name": "Grep", + "tool_input": tool_input, + } + out, rc = _run_predup(payload, self._tmp) + self.assertEqual(rc, 0) + decision = out.get("hookSpecificOutput", {}).get("permissionDecision") + self.assertEqual(decision, "deny") + + +class TestPredupDisabled(unittest.TestCase): + """predup_enabled=false → always allow {}.""" + + def setUp(self): + self._tmp = tempfile.mkdtemp() + + def test_disabled_allows_repeat(self): + session_id = "s_disabled" + tool_input = {"file_path": "/tmp/disabled_test.py"} + + # Seed prior + _seed_tool_call(self._tmp, session_id, "Read", tool_input, token_count=500) + + payload = { + "session_id": session_id, + "tool_name": "Read", + "tool_input": tool_input, + } + out, rc = _run_predup(payload, self._tmp, extra_config={"predup_enabled": False}) + self.assertEqual(rc, 0) + self.assertEqual(out, {}, f"Expected {{}} when disabled, got: {out}") + + +class TestPredupMtimeGuard(unittest.TestCase): + """Read with changed mtime → allow {} (file changed since cached).""" + + def setUp(self): + self._tmp = tempfile.mkdtemp() + + def test_changed_mtime_allows(self): + session_id = "s_mtime" + + # Create a real temp file so we can get its actual mtime + f = tempfile.NamedTemporaryFile(delete=False, suffix=".py") + f.write(b"content\n") + f.close() + fp = f.name + + tool_input = {"file_path": fp} + + try: + # Seed with a stale mtime (different from current) + stale_mtime = os.path.getmtime(fp) - 100.0 + _seed_tool_call(self._tmp, session_id, "Read", tool_input, + token_count=500, mtime=stale_mtime) + + payload = { + "session_id": session_id, + "tool_name": "Read", + "tool_input": tool_input, + } + out, rc = _run_predup(payload, self._tmp) + self.assertEqual(rc, 0) + self.assertEqual(out, {}, f"Expected {{}} when mtime changed, got: {out}") + finally: + os.unlink(fp) + + def test_same_mtime_denies(self): + session_id = "s_mtime_same" + + f = tempfile.NamedTemporaryFile(delete=False, suffix=".py") + f.write(b"content\n") + f.close() + fp = f.name + + tool_input = {"file_path": fp} + + try: + # Seed with the EXACT current mtime + cur_mtime = os.path.getmtime(fp) + _seed_tool_call(self._tmp, session_id, "Read", tool_input, + token_count=500, mtime=cur_mtime) + + payload = { + "session_id": session_id, + "tool_name": "Read", + "tool_input": tool_input, + } + out, rc = _run_predup(payload, self._tmp) + self.assertEqual(rc, 0) + decision = out.get("hookSpecificOutput", {}).get("permissionDecision") + self.assertEqual(decision, "deny", f"Expected deny for same mtime, got: {out}") + finally: + os.unlink(fp) + + +class TestPredupMalformedStdin(unittest.TestCase): + """Malformed stdin → {} exit 0.""" + + def setUp(self): + self._tmp = tempfile.mkdtemp() + + def test_malformed_json(self): + out_str, rc = _run_predup_raw(b"NOT JSON {{{", self._tmp) + self.assertEqual(rc, 0) + self.assertEqual(out_str, "{}") + + def test_empty_stdin(self): + out_str, rc = _run_predup_raw(b"", self._tmp) + self.assertEqual(rc, 0) + self.assertEqual(out_str, "{}") + + def test_non_dict_json(self): + out_str, rc = _run_predup_raw(b"[1, 2, 3]", self._tmp) + self.assertEqual(rc, 0) + self.assertEqual(out_str, "{}") + + +class TestPredupBashDefault(unittest.TestCase): + """Bash with predup_bash_readonly=False (default) → always allow {}.""" + + def setUp(self): + self._tmp = tempfile.mkdtemp() + + def test_bash_not_eligible_by_default(self): + session_id = "s_bash" + tool_input = {"command": "cat /tmp/foo.py"} + + # Seed a prior Bash call + _seed_tool_call(self._tmp, session_id, "Bash", tool_input, token_count=300) + + payload = { + "session_id": session_id, + "tool_name": "Bash", + "tool_input": tool_input, + } + out, rc = _run_predup(payload, self._tmp) + self.assertEqual(rc, 0) + self.assertEqual(out, {}, f"Expected {{}} for Bash (readonly=False), got: {out}") + + def test_bash_readonly_enabled_cat_denies(self): + """With predup_bash_readonly=True, cat is eligible and repeat is denied.""" + session_id = "s_bash_ro" + tool_input = {"command": "cat /tmp/foo.py"} + + _seed_tool_call(self._tmp, session_id, "Bash", tool_input, token_count=200) + + payload = { + "session_id": session_id, + "tool_name": "Bash", + "tool_input": tool_input, + } + out, rc = _run_predup(payload, self._tmp, extra_config={"predup_bash_readonly": True}) + self.assertEqual(rc, 0) + decision = out.get("hookSpecificOutput", {}).get("permissionDecision") + self.assertEqual(decision, "deny", f"Expected deny for cat with readonly=True, got: {out}") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_resume_nudge.py b/tests/test_resume_nudge.py new file mode 100644 index 0000000..254f503 --- /dev/null +++ b/tests/test_resume_nudge.py @@ -0,0 +1,237 @@ +""" +test_resume_nudge.py — stdlib unittest tests for scripts/resume_nudge.py + +Covers: + (a) fresh ts + matching branch → output contains "/nx-load" + (b) timestamp older than 24h → {} + (c) handoff branch != current branch → {} + (d) resume_nudge_enabled=false → {} + (e) source="compact" → {} + (f) malformed stdin → {} exit 0 +""" + +import datetime +import json +import os +import subprocess +import sys +import tempfile +import unittest + +_SCRIPTS_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts" +) +if _SCRIPTS_DIR not in sys.path: + sys.path.insert(0, _SCRIPTS_DIR) + +_RESUME_NUDGE = os.path.join(_SCRIPTS_DIR, "resume_nudge.py") + + +def _init_git_repo(path: str) -> str: + """Init a git repo in path and return the current branch name.""" + subprocess.run(["git", "init", path], capture_output=True) + subprocess.run(["git", "-C", path, "config", "user.email", "test@test.com"], capture_output=True) + subprocess.run(["git", "-C", path, "config", "user.name", "Test"], capture_output=True) + # Create an initial commit so HEAD is valid + dummy = os.path.join(path, "README.md") + with open(dummy, "w") as fh: + fh.write("test\n") + subprocess.run(["git", "-C", path, "add", "README.md"], capture_output=True) + subprocess.run(["git", "-C", path, "commit", "-m", "init"], capture_output=True) + result = subprocess.run( + ["git", "-C", path, "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, text=True, + ) + return result.stdout.strip() + + +def _write_handoff(data_dir: str, branch: str, ts: str) -> None: + """Write a handoff/latest.md with the given branch and ISO timestamp.""" + handoff_dir = os.path.join(data_dir, "handoff") + os.makedirs(handoff_dir, exist_ok=True) + content = ( + f"# Handoff (auto-skeleton): {branch}\n\n" + f"**Session:** testsession **Branch:** {branch} **Written:** {ts}\n" + f"**Why now:** context crossed the auto-handoff threshold.\n" + ) + with open(os.path.join(handoff_dir, "latest.md"), "w") as fh: + fh.write(content) + + +def _run_nudge(payload: dict, data_dir: str, cwd: str, extra_config: dict = None): + """Run resume_nudge.py and return (parsed_output, exit_code).""" + env = os.environ.copy() + env["CLAUDE_PLUGIN_DATA"] = data_dir + env["PYTHONPATH"] = _SCRIPTS_DIR + os.pathsep + env.get("PYTHONPATH", "") + + if extra_config: + cfg_path = os.path.join(data_dir, "config.json") + with open(cfg_path, "w") as fh: + json.dump(extra_config, fh) + + result = subprocess.run( + [sys.executable, _RESUME_NUDGE], + input=json.dumps(payload).encode(), + capture_output=True, + env=env, + cwd=cwd, + timeout=15, + ) + return json.loads(result.stdout.decode()), result.returncode + + +def _run_nudge_raw(raw_bytes: bytes, data_dir: str, cwd: str): + """Run resume_nudge.py with raw bytes.""" + env = os.environ.copy() + env["CLAUDE_PLUGIN_DATA"] = data_dir + env["PYTHONPATH"] = _SCRIPTS_DIR + os.pathsep + env.get("PYTHONPATH", "") + result = subprocess.run( + [sys.executable, _RESUME_NUDGE], + input=raw_bytes, + capture_output=True, + env=env, + cwd=cwd, + timeout=15, + ) + return result.stdout.decode().strip(), result.returncode + + +def _now_iso() -> str: + return datetime.datetime.now().astimezone().isoformat(timespec="seconds") + + +def _old_iso(hours: float = 25) -> str: + dt = datetime.datetime.now().astimezone() - datetime.timedelta(hours=hours) + return dt.isoformat(timespec="seconds") + + +class TestResumeNudgeFreshMatchingBranch(unittest.TestCase): + """Fresh timestamp + matching branch → output contains /nx-load.""" + + def setUp(self): + self._tmp = tempfile.mkdtemp() + self._git_dir = tempfile.mkdtemp() + self._branch = _init_git_repo(self._git_dir) + + def test_fresh_matching_branch_nudges(self): + ts = _now_iso() + _write_handoff(self._tmp, self._branch, ts) + + payload = {"cwd": self._git_dir} + out, rc = _run_nudge(payload, self._tmp, self._git_dir) + self.assertEqual(rc, 0) + self.assertNotEqual(out, {}, f"Expected hint, got: {out}") + # Check for /nx-load in system message or hook output + text = json.dumps(out) + self.assertIn("/nx-load", text, f"Expected /nx-load in output: {out}") + + +class TestResumeNudgeOldTimestamp(unittest.TestCase): + """Timestamp older than 24h → {}.""" + + def setUp(self): + self._tmp = tempfile.mkdtemp() + self._git_dir = tempfile.mkdtemp() + self._branch = _init_git_repo(self._git_dir) + + def test_old_timestamp_suppressed(self): + ts = _old_iso(hours=25) + _write_handoff(self._tmp, self._branch, ts) + + payload = {"cwd": self._git_dir} + out, rc = _run_nudge(payload, self._tmp, self._git_dir) + self.assertEqual(rc, 0) + self.assertEqual(out, {}, f"Expected {{}} for old timestamp, got: {out}") + + +class TestResumeNudgeBranchMismatch(unittest.TestCase): + """Handoff branch != current branch → {}.""" + + def setUp(self): + self._tmp = tempfile.mkdtemp() + self._git_dir = tempfile.mkdtemp() + _init_git_repo(self._git_dir) + + def test_branch_mismatch_suppressed(self): + ts = _now_iso() + # Write handoff for a different branch + _write_handoff(self._tmp, "some-other-branch-xyz", ts) + + payload = {"cwd": self._git_dir} + out, rc = _run_nudge(payload, self._tmp, self._git_dir) + self.assertEqual(rc, 0) + self.assertEqual(out, {}, f"Expected {{}} for branch mismatch, got: {out}") + + +class TestResumeNudgeDisabled(unittest.TestCase): + """resume_nudge_enabled=false → {}.""" + + def setUp(self): + self._tmp = tempfile.mkdtemp() + self._git_dir = tempfile.mkdtemp() + self._branch = _init_git_repo(self._git_dir) + + def test_disabled_suppresses(self): + ts = _now_iso() + _write_handoff(self._tmp, self._branch, ts) + + payload = {"cwd": self._git_dir} + out, rc = _run_nudge(payload, self._tmp, self._git_dir, + extra_config={"resume_nudge_enabled": False}) + self.assertEqual(rc, 0) + self.assertEqual(out, {}, f"Expected {{}} when disabled, got: {out}") + + +class TestResumeNudgeSourceCompact(unittest.TestCase): + """source='compact' → {}.""" + + def setUp(self): + self._tmp = tempfile.mkdtemp() + self._git_dir = tempfile.mkdtemp() + self._branch = _init_git_repo(self._git_dir) + + def test_compact_source_suppressed(self): + ts = _now_iso() + _write_handoff(self._tmp, self._branch, ts) + + payload = {"cwd": self._git_dir, "source": "compact"} + out, rc = _run_nudge(payload, self._tmp, self._git_dir) + self.assertEqual(rc, 0) + self.assertEqual(out, {}, f"Expected {{}} for source=compact, got: {out}") + + def test_resume_source_suppressed(self): + ts = _now_iso() + _write_handoff(self._tmp, self._branch, ts) + + payload = {"cwd": self._git_dir, "source": "resume"} + out, rc = _run_nudge(payload, self._tmp, self._git_dir) + self.assertEqual(rc, 0) + self.assertEqual(out, {}, f"Expected {{}} for source=resume, got: {out}") + + +class TestResumeNudgeMalformedStdin(unittest.TestCase): + """Malformed stdin → {} exit 0.""" + + def setUp(self): + self._tmp = tempfile.mkdtemp() + self._git_dir = tempfile.mkdtemp() + _init_git_repo(self._git_dir) + + def test_malformed_json(self): + out_str, rc = _run_nudge_raw(b"NOT JSON {{{", self._tmp, self._git_dir) + self.assertEqual(rc, 0) + self.assertEqual(out_str, "{}") + + def test_empty_stdin(self): + out_str, rc = _run_nudge_raw(b"", self._tmp, self._git_dir) + self.assertEqual(rc, 0) + self.assertEqual(out_str, "{}") + + def test_non_dict_json(self): + out_str, rc = _run_nudge_raw(b"[1, 2, 3]", self._tmp, self._git_dir) + self.assertEqual(rc, 0) + self.assertEqual(out_str, "{}") + + +if __name__ == "__main__": + unittest.main()