Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"name": "nexum",
"source": "./",
"description": "Context-token and model-cost optimization for Claude Code.",
"version": "0.3.0",
"version": "0.4.0",
"author": {
"name": "Rahul Tyagi",
"email": "rahul.1992.tyagi@gmail.com"
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
},
"description": "Context-token and model-cost optimization for Claude Code.",
"name": "nexum",
"version": "0.3.0"
"version": "0.4.0"
}
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ 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).

## [0.4.0] - 2026-06-18
### Added
- **Estimated per-tier usage recording in `/nx-build`** so the cost report breakdown is populated with token estimates per execution tier, giving visibility into where the session's cost is incurred.
- **Per-repo routing calibration** that biases `/nx-plan` from historical step outcomes, routing subsequent steps based on learned success rates and execution patterns rather than static tier assignments.
- **Throttled SessionStart audit nudge** (`scripts/audit_nudge.py`, SessionStart hook) that checks ignore-config rot on session start without spamming the user, surfacing a one-line reminder when the ignore config has actionable findings (missing ignore file, unignored noise dirs, or large/binary files). Throttled to once per repo per `audit_nudge_throttle_hours`. Config: `audit_nudge_enabled` (default true), `audit_nudge_throttle_hours` (default 24).

### Changed
- **`max_steps_per_dispatch` default lowered 6 → 4.** A 6-step grouped Sonnet dispatch stalled the stream watchdog mid-batch (600s no-progress) and lost the whole batch; smaller batches bound both the per-dispatch context and a single failure's blast radius.
- **Size-aware dispatch batching.** `/nx-build` now partitions a route tier with `plan_preview.py --plan … --indices …`, which estimates each step's context load (per-step base + declared-file bytes ÷ 4) and bounds each sub-batch by **both** `max_dispatch_context_tokens` (default 50000) and the `max_steps_per_dispatch` count cap — so a few large-file steps split off while a single over-budget step still dispatches alone (steps are never split). New `store.partition_steps_by_size`; config `max_dispatch_context_tokens`, `dispatch_step_base_tokens`. The count-only `store.py plan-batches` helper remains for when no plan file is available.

### Fixed
- **predup freshness guard.** A recorded `tool_calls` row only proves an output was injected once, not that it is still in the live context — subagents share the parent's DB, and compaction/resume evicts output while the row persists, so predup could deny a legitimate read whose content was no longer (or never) in context. predup now lets a call through when its prior row is older than `predup_max_age_seconds` (default 900; 0 restores the prior ever-recorded behaviour).

## [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).
Expand Down
33 changes: 30 additions & 3 deletions commands/nx-build.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,15 @@ Grouping keeps each model's prompt prefix stable, maximising cache hits.
- **`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):
**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, or — as observed — stalling the stream watchdog and losing the whole batch) 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>"
python3 ${CLAUDE_PLUGIN_ROOT}/scripts/plan_preview.py --plan <plan_file> --indices "<comma-separated step indices for this tier, in order>" --root <repo root>
```

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.
This is **size-aware**: it estimates each step's context load (a per-step base + the byte size of its declared `files` ÷ 4) and packs the tier into sub-batches bounded by **both** `max_dispatch_context_tokens` (config, default 50000) and the `max_steps_per_dispatch` count cap (config, default 4). So a tier of a few large-file steps splits more aggressively than a tier of small ones, while a single step over the token budget still dispatches alone (a step is never split). It returns a JSON array of sub-batches (e.g. `[[1,2,3,4],[5,7]]`). 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.

(The older count-only helper `store.py plan-batches --indices "…"` still exists and caps by `max_steps_per_dispatch` alone — use it only if a plan file isn't available to size against.)

## 4. Skip the spawn when the tier already matches the session model

Expand Down Expand Up @@ -136,6 +138,31 @@ When a previously-failed step later passes, overwrite it with `--status done` as

If `orchestrator_resume_enabled` is false, skip all ledger writes.

**Immediately after recording the ledger for each step**, also record estimated usage and (when enabled) calibration counters:

**Usage recording (always; read heuristics from `store.py config`):**

```
python3 ${CLAUDE_PLUGIN_ROOT}/scripts/store.py record-usage \
--session <session_id> \
--model <model id for the tier actually used: inline → current session model; dispatched → the tier's model> \
--input-tok <plan_preview_input_tok_per_step × number of steps in this dispatch> \
--output-tok <plan_preview_output_tok_per_step × number of steps in this dispatch>
```

Read `plan_preview_input_tok_per_step` and `plan_preview_output_tok_per_step` from `store.py config`. These are per-step heuristic estimates; multiply by the number of steps in the dispatch to get the total. An escalated step counts against the higher tier (the one that actually ran), not the original dispatched tier.

**Calibration recording (when `route_calib_enabled` from `store.py config` is true):**

```
python3 ${CLAUDE_PLUGIN_ROOT}/scripts/store.py calib-record \
--repo <repo key: git toplevel basename of the repo root> \
--route <step route> \
--dispatched 1
```

Append `--passed-first-try 1` when the step passed on the first attempt with no escalation. Append `--escalated 1` when the step required escalation to a higher tier. Usage recording is always performed; calibration is skipped only when `route_calib_enabled` is false.

## 7. Retry and escalation ladder

On a FAIL:
Expand Down
8 changes: 8 additions & 0 deletions commands/nx-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ 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.

**Calibration nudge (per-repo history).** Before finalising routes, consult this repo's historical outcomes when `route_calib_enabled` (from `python3 ${CLAUDE_PLUGIN_ROOT}/scripts/store.py config`) is true. Run:

```
python3 ${CLAUDE_PLUGIN_ROOT}/scripts/store.py calib-list --repo <git toplevel basename of cwd>
```

For any route whose `dispatched >= route_calib_min_samples` (config, default 5) **and** `passed_first_try / dispatched < route_calib_min_success_ratio` (config, default 0.6), nudge the steps you would have assigned to that route **up one tier** (mechanical→standard, standard→needs-strong) and note the calibration reason in that step's `objective` (e.g. "routed up from mechanical: this repo's mechanical steps pass first-try only 40% of the time"). This is **advisory and one-directional**: never downgrade a route, and never move a `needs-strong` step. When calibration is disabled, the repo has no rows, or a route's sample count is below `route_calib_min_samples`, route by the static rubric above unchanged.

**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
Expand Down
9 changes: 9 additions & 0 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@
"type": "command"
}
]
},
{
"hooks": [
{
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/audit_nudge.py",
"timeout": 10,
"type": "command"
}
]
}
],
"UserPromptSubmit": [
Expand Down
140 changes: 140 additions & 0 deletions scripts/audit_nudge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""
audit_nudge.py — Nexum SessionStart ignore-config nudge hook.

When the project's ignore config has actionable findings (missing ignore file,
unignored noise dirs, or large/binary files), surfaces a one-line
"/nx-audit" hint — throttled to once per repo per audit_nudge_throttle_hours.

Hook contract:
stdin → single JSON object (Claude Code SessionStart payload)
stdout → single JSON object (hint or {})
exit 0 always (fail-open)
"""

import json
import os
import sys
import time

# ---------------------------------------------------------------------------
# sys.path: ensure scripts/ dir is importable as "import store" / "import audit"
# ---------------------------------------------------------------------------
_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
import audit # 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("audit_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. Throttle: once per repo per audit_nudge_throttle_hours
# ----------------------------------------------------------------
cwd = data.get("cwd") or os.getcwd()
repo_key = store.current_repo(cwd)
throttle_hours = float(cfg.get("audit_nudge_throttle_hours", 24))

last_str = store.get_flag("_audit_nudge", repo_key)
now = time.time()
if last_str is not None:
try:
last_ts = float(last_str)
if now - last_ts < throttle_hours * 3600:
print("{}")
return
except ValueError:
pass

# ----------------------------------------------------------------
# 5. Run audit
# ----------------------------------------------------------------
try:
findings = audit.run_audit(cwd)
except Exception:
print("{}")
return

# ----------------------------------------------------------------
# 6. Check for actionable findings
# ----------------------------------------------------------------
missing = findings.get("missing_ignore", False)
noise_dirs = findings.get("unignored_noise_dirs") or []
large_bin = findings.get("large_or_binary") or []

if not missing and not noise_dirs and not large_bin:
print("{}")
return

# ----------------------------------------------------------------
# 7. Build hint
# ----------------------------------------------------------------
count = sum([bool(missing), bool(noise_dirs), bool(large_bin)])

if noise_dirs:
example = noise_dirs[0] + "/"
elif missing:
example = "no ignore file"
else:
# large_or_binary entries are tuples of (rel_path, size, is_binary)
first = large_bin[0]
example = first[0] if isinstance(first, (tuple, list)) else str(first)

hint = (
f"[nexum] Ignore-config has {count} issue(s) "
f"(e.g. {example}); run /nx-audit."
)

out = {
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": hint,
},
"systemMessage": hint,
}

# ----------------------------------------------------------------
# 8. Record throttle timestamp and emit
# ----------------------------------------------------------------
store.set_flag("_audit_nudge", repo_key, str(now))
print(json.dumps(out, sort_keys=True))

except Exception:
# Fail-open: never crash the Claude Code session
print("{}")


if __name__ == "__main__":
main()
11 changes: 8 additions & 3 deletions scripts/cost_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,13 +122,16 @@ def build_report(rows: list) -> str:
saved = total_baseline - total_actual

lines = []
lines.append("[nexum] Cost report")
lines.append("=" * 48)
lines.append(
"[nexum] Cost report "
"(per-tier breakdown is ESTIMATED — heuristic attribution per dispatch)"
)
lines.append("=" * 72)
lines.append(f" Actual cost: ${total_actual:>10.4f}")
lines.append(f" All-opus baseline: ${total_baseline:>10.4f}")
lines.append(f" Saved vs opus: ${saved:>10.4f}")
lines.append("")
lines.append("Per-model breakdown:")
lines.append("Per-model breakdown (ESTIMATED — heuristic attribution recorded by /nx-build):")
lines.append(f" {'Model':<20} {'Input tok':>12} {'Output tok':>12} "
f"{'Cache-R tok':>12} {'Actual $':>10} {'Baseline $':>10}")
lines.append(" " + "-" * 80)
Expand All @@ -147,6 +150,8 @@ def build_report(rows: list) -> str:
else:
lines.append("[nexum] Note: token yield needs shipped-token tagging "
"(no shipped-token field in v1 usage rows).")
lines.append("[nexum] The authoritative, cache-accurate total is the metered "
"section below.")

return "\n".join(lines)

Expand Down
Loading
Loading