CLChangelogv0.10.0

Every release,
in its own words.

Release notes for @n-dx/core, generated from the changesets that shipped each version. Currently on v0.10.0, released Oct 26, 2026.

0.10.0

Minor npm ↗
  • 0.10.0 is the v2 migration release. ndx migrate moves a v1 PRD (prd_tree/) to the v2 product and change layers, ndx init starts new projects on v2, and rex commands on a v1 PRD print one deprecation line naming ndx migrate; the release after this one refuses v1 PRDs. Every @n-dx/* package is versioned together, so they all move to 0.10.0. #749
  • ndx config skips __proto__, constructor and prototype keys while merging .n-dx.json and .n-dx.local.json. A literal "__proto__" key in either file used to set the merged object's prototype, so every reader of the project config saw fields the file never wrote as settings. #846
  • ndx config <pkg>.<key> <value> refuses a key with a __proto__, constructor or prototype segment. ndx config llm.__proto__.polluted x followed configs.llm["__proto__"] onto Object.prototype and assigned there, in the CLI's own process; the write was never persisted, but the command now exits non-zero naming the segment and leaves every config file untouched. hench's setConfigValue applies the same guard behind its CONFIG_FIELDS allowlist. #846
  • The Analyze dashboard and the Overview page gain a Delivery & infrastructure panel. It shows the SDLC readiness score from readiness.json, with each practice's score (tests, CI, CD, rollback, migrations, feature flags, quality gates, observability, agent safety), and the resources in infrastructure.json counted by kind. Opened, it lists each practice's evidence and gaps and every resource with the file that declares it. When an analysis has not written either file, it says to run ndx analyze. ndx export now copies both files, so a static dashboard shows the panel too. #838
  • One host and port shape rule for the local LLM endpoint. @n-dx/llm-client exports isPlausibleLlmHost, isValidPort and describeLocalEndpointProblem, and the local inference provider refuses a saved llm.local.host or llm.local.port that fails them before building its URL — a hand-edited .n-dx.json used to reach fetch unchecked. The dashboard imports the rule instead of carrying a copy, and reports a saved port written as a string as invalid rather than silently probing 1234. ndx config llm.local.host (and llm.local.verifier.host) refuses a host carrying a path, credentials, a second port or whitespace, and accepts localhost, 127.1, [::1] and a LAN name — 127.1 was previously coerced to a number and refused. A parity test holds core's inline copy equal to llm-client's. #846
  • ndx migrate --apply [--plan=<path>] [--dry-run] (rex migrate-apply --plan=<path.json>) applies a reviewed ndx migrate --plan plan, reading ./migration-plan.json by default, where ndx migrate --plan writes. It writes product/ and changes/ and removes prd_tree/ and tree-meta.json under one hold of the PRD lock, so the two layouts never sit side by side. Every v1 id is checked by count and id before anything is written and again on the tree read back. Changes the rules placed land on their capability; held changes, and items made after the plan, land in the Inbox, with any model pick recorded as a suggestion only. Release epics become no node, and a blockedBy ref to one is repointed at the changes it split into. A blockedBy ref to a capability, constraint or area names that node's open changes (a change held for the Inbox counts as open on the product node its v1 parent became, for this rewrite only), a product node's own blockedBy moves onto its open changes, and a ref with no open change to name is dropped as met; the cut change lists every rewrite and drop. The first change, "Product map and change layer", records the cut. A held change completed at or before the cut cannot be placed, so rex health reports its change-placed-at-close as a warning, not an error. So is v1 work completed between plan and apply, which the cut change lists; a held change completed in v2 after apply is still an error. Apply refuses a plan whose model pass stopped, and refuses while .rex/prd.md, .rex/prd.json or a branch-scoped PRD file sits beside the tree. On a v2 tree it reports so and exits zero. A prd_tree/ left beside the v2 tree is removed only when it is what an interrupted apply left (it matches the plan's source or, with no plan, every id it holds is in the v2 tree); any other is refused and kept, naming the ids to re-capture, and --dry-run says which. #749
  • ndx migrate [dir], with neither --plan nor --apply, runs every migration step the project still needs, in order: the layout move into .ndx/, the prd.md / prd.json conversion to the folder tree, then rex migrate-plan (to --out, default ./migration-plan.json) and rex migrate-apply. A step already done is skipped, so a failed run resumes where it stopped; --dry-run rehearses each step that can. #749
  • ndx migrate --plan shows the preflight banner and a run summary that lists the --out plan as written and says model calls are not counted. llm.routes for prd.migrate.judge (or *) turns the Jev review off. #749
  • ndx install-sample on a v2 project adds the sample as a change with two tasks through rex add, instead of writing a prd_tree/ beside product/; ndx destroy-sample removes it. A v1 project is unchanged. #749
  • rex migrate-plan, rex migrate-apply --dry-run and rex migrate-apply refuse a v1 tree with an item file the parser cannot read, such as unclosed front matter left by a hand edit or a merge conflict. The refusal names each file. Before this, the store's load dropped the parser's warning, so the plan and the dry run left the item out without saying so, and apply then removed prd_tree/ along with that item's file. The v1 parser now marks a warning skipped when it drops an item, as the v2 reader already did. #749
  • The Windows spawn guard now refuses what cmd.exe would actually expand. #790 refused a %NAME% token only when NAME was in the environment, but cmd.exe also expands variables it computes itself (%CD%, %RANDOM%, %CMDCMDLINE%, the undocumented %__APPDIR__% and %__CD__%, …) and reads the name before a :~ or :a=b modifier, so %CMDCMDLINE:~-1% slipped through and expanded to a bare " that ended the argument. A token carrying a line break is refused too, since cmd.exe silently drops everything after it. Every cmd.exe invocation passes /v:off, and sourcevision's execFileSyncCli is llm-client's, so there is one recipe. Those sourcevision spawns (rex tree, ps) now appear in claude_commands.log like every other spawn through these helpers. #846
  • Windows: no more console windows from n-dx's child processes. exec() and spawnCli in llm-client gain a windowsHide option that defaults to true, and every other production spawn in the monorepo passes it; a new root policy test (windows-hide-policy) fails any spawn that does not. The vendor-CLI log claude_commands.log is now gitignored by ndx init and discounted by hench's uncommitted-work gate, so a fresh project's first agent run no longer refuses completion over it. The Overview page's isometric hero shows its Options button again. #839
Full release notes

ndx config <pkg>.<key> <value> refuses a key with a __proto__, constructor or prototype segment. ndx config llm.__proto__.polluted x followed configs.llm["__proto__"] onto Object.prototype and assigned there, in the CLI's own process; the write was never persisted, but the command now exits non-zero naming the segment and leaves every config file untouched. hench's setConfigValue applies the same guard behind its CONFIG_FIELDS allowlist.

The ndx work command that ndx work --resolve prints is now correct on Windows for a path that ends in a backslash or contains a quote: backslashes before a " or the closing quote are doubled, as the argv rules every Node program decodes with require, so C:\My Repo\ prints as "C:\My Repo\\" rather than "C:\My Repo\".

Four regular expressions that were quadratic on hostile input scan linearly: the icacls ACE parser behind owner-only config files (the same result on every icacls line; it now rejects a line with a ( inside a flag group or a :( inside an identity, which icacls never prints), the trailing-separator trim in the MCP shim's workspace key, and the project-id slug in ndx start and its twin in the hub's new-project page (the same result on every input). ndx ci's data-layer check now escapes every regex metacharacter in a layout directory name rather than only the dot.

ndx migrate --apply [--plan=<path>] [--dry-run] (rex migrate-apply --plan=<path.json>) applies a reviewed ndx migrate --plan plan, reading ./migration-plan.json by default, where ndx migrate --plan writes. It writes product/ and changes/ and removes prd_tree/ and tree-meta.json under one hold of the PRD lock, so the two layouts never sit side by side. Every v1 id is checked by count and id before anything is written and again on the tree read back. Changes the rules placed land on their capability; held changes, and items made after the plan, land in the Inbox, with any model pick recorded as a suggestion only. Release epics become no node, and a blockedBy ref to one is repointed at the changes it split into. A blockedBy ref to a capability, constraint or area names that node's open changes (a change held for the Inbox counts as open on the product node its v1 parent became, for this rewrite only), a product node's own blockedBy moves onto its open changes, and a ref with no open change to name is dropped as met; the cut change lists every rewrite and drop. The first change, "Product map and change layer", records the cut. A held change completed at or before the cut cannot be placed, so rex health reports its change-placed-at-close as a warning, not an error. So is v1 work completed between plan and apply, which the cut change lists; a held change completed in v2 after apply is still an error. Apply refuses a plan whose model pass stopped, and refuses while .rex/prd.md, .rex/prd.json or a branch-scoped PRD file sits beside the tree. On a v2 tree it reports so and exits zero. A prd_tree/ left beside the v2 tree is removed only when it is what an interrupted apply left (it matches the plan's source or, with no plan, every id it holds is in the v2 tree); any other is refused and kept, naming the ids to re-capture, and --dry-run says which.

--dry-run runs apply's checks, including the staged write and read-back, and prints per v1 kind how many items become each v2 type, how many are held for the Inbox and how many metadata values are dropped (droppedMeta, droppedLog), and each blockedBy rewrite and drop with its old and new refs; it writes nothing in the project.

Soft freeze: rex migrate-apply is a new rex command and --apply, --plan=<path> and --dry-run are new ndx migrate flags; the --plan and --out behaviour of ndx migrate --plan and rex migrate-plan is unchanged.

The v2 writer refuses to turn a folder into a leaf while its state.yaml holds top-level keys other than schema and items, naming the folder and the keys, instead of dropping them. rex migrate-to-folder-tree renames a converted prd.json to prd.json.migrated.

ndx migrate [dir], with neither --plan nor --apply, runs every migration step the project still needs, in order: the layout move into .ndx/, the prd.md / prd.json conversion to the folder tree, then rex migrate-plan (to --out, default ./migration-plan.json) and rex migrate-apply. A step already done is skipped, so a failed run resumes where it stopped; --dry-run rehearses each step that can.

rex commands on a v1 PRD (prd_tree/ and no product/) keep working and print one deprecation line on stderr naming ndx migrate; the next release refuses v1 PRDs. The line is dropped under --quiet, and NDX_LEGACY_NOTICE=off silences it. ndx ci, status, plan, add, recommend, refresh, work, analyze, self-heal, start, web and pair-programming print it once per command, not once per rex process they spawn.

Agents are never told to migrate and cannot: every hench run that ndx work, ndx self-heal and ndx pair-programming spawn gets NDX_AGENT_RUN=1 and NDX_LEGACY_NOTICE=off, and ndx migrate (bare, --plan and --apply) refuses while NDX_AGENT_RUN is set: an operator runs the migration from their own shell.

Soft freeze: bare ndx migrate used to exit 1 asking for --plan; it now runs the chain. --plan and --apply are unchanged. NDX_LEGACY_NOTICE (off) and NDX_AGENT_RUN are new environment variables read by rex and core.

ndx migrate --plan shows the preflight banner and a run summary that lists the --out plan as written and says model calls are not counted. llm.routes for prd.migrate.judge (or *) turns the Jev review off.

Adds a config key under the soft freeze: llm.routes accepts prd.migrate.judge (the migration's Jev review), and a route to a tier turns that review off. ndx migrate --plan prints the preflight banner and run summary. No flag or MCP tool changes shape.

On Windows, the guard that keeps rex export, rex migrate-plan --out/--summary and rex tree-diff from writing inside the rex directory now sees through 8.3 short-name aliases such as RUNNER~1. It resolved paths with fs.realpathSync, which leaves short names unexpanded, so a project or output path written in short form slipped past the check. It now uses realpathSync.native; macOS and Linux behave as before.

Fixes a CLI guard under the soft freeze (Windows only): the same commands refuse a path they already meant to refuse. No flag or MCP tool changes shape.

ndx install-sample on a v2 project adds the sample as a change with two tasks through rex add, instead of writing a prd_tree/ beside product/; ndx destroy-sample removes it. A v1 project is unchanged.

Changes what a CLI command writes under the soft freeze: on a v2 project ndx install-sample and ndx destroy-sample write the change layer ({rex}/changes/), not prd_tree/. No flag changes shape.

rex remove <id> now works on a v2 PRD, removing a change, task or subtask inside the PRD lock. ndx destroy-sample on a v2 project uses it instead of deleting change folders itself, and fails naming the lock holder when another writer holds the lock.

Widens a CLI command under the soft freeze: rex remove accepts v2 change, task and subtask ids (it refused v2 trees before). No flag or MCP tool changes shape.

The first /api/rex/prd read after ndx start no longer answers 404 on a folder tree: the boot PRD cache is written before the server listens, so a request cannot beat it.

Changes a v2 rule under the soft freeze: change-placed-at-close is a warning, not an error, for a change the migration held for placement that completed in v1, at or before the plan's cut or between the plan and apply (the cut change lists the latter). A held change that completes in v2 after apply is still an error. A blockedBy ref to a product node whose pending work is held in the Inbox now names the held changes instead of being dropped as met.

The apply-side fixes on this layer (blockedBy rewrites onto open changes, the guarded prd_tree/ leftover removal, the dry run listing each rewrite) are described in the ndx migrate --apply changeset, migrate-apply.md.

rex migrate-plan, rex migrate-apply --dry-run and rex migrate-apply refuse a v1 tree with an item file the parser cannot read, such as unclosed front matter left by a hand edit or a merge conflict. The refusal names each file. Before this, the store's load dropped the parser's warning, so the plan and the dry run left the item out without saying so, and apply then removed prd_tree/ along with that item's file. The v1 parser now marks a warning skipped when it drops an item, as the v2 reader already did.

rex migrate-apply and its --dry-run print the v2 rule findings on the migrated tree, counted by rule (errors and warnings), as rex health then reports them. They are reported, not refused. A test now pins apply's refusal when the tree reads back without a v1 id.

rex migrate-apply keeps a dissolved release epic's description and acceptance criteria under its line in the cut change's body, and moves its own blockedBy onto its open children, so a release's work still waits for the release it depended on.

migrate --apply: when removing prd_tree/ or tree-meta.json fails after the v2 tree is switched in (a handle held on Windows), apply now says the v2 tree is in place, names the leftovers and the underlying error, and gives the manual step to clear them, instead of throwing the raw error.

migrate --apply: every leftover prd_tree/ message now names the leftover and the same manual step (check the v2 tree holds everything in it, then delete it by hand). A failed removal after the switch no longer promises a re-run finishes the job, and a failed removal on an already-v2 apply says the v2 tree is in place instead of throwing the raw error.

A migration plan's metAt now follows a capability's spec when a later pass redrafts it, instead of keeping the rules-stage draft's hash.

ndx migrate (the bare chain) now shows the preflight banner, and its --dry-run runs no model unless --with-models is given

rex migrate-plan, rex migrate-apply and ndx migrate-layout refuse inside an agent run (NDX_AGENT_RUN set), as ndx migrate already does.

rex migrate-to-folder-tree keeps the PRD title

The command wrote the tree but not .rex/tree-meta.json, so a migrated project read back as "PRD". It now writes the sidecar with the source document's title.

rex migrate-slugs on a v2 tree now says slugs are frozen there and applies to v1 trees only, instead of pointing at the v1 rex migrate-to-folder-tree command.

Placing an Inbox change (rex change place) carries the non-tree files in its folder into changes/<slug>/ with it, and removes the empty Inbox folder; a name clash refuses before anything is written.

The v2 writer keeps a moved folder node's extra top-level state.yaml keys (keys a newer rex wrote) in its new folder. A node that moves and loses its last child in the same write is refused, naming its old folder and the keys, instead of dropping them.

Writes on a v2 tree now stamp lastModified and lastModifiedBy on every node they change, create or move, as v1 writes do. Before, update_task_status, edit_item, add_item and move_item left a node's old stamp in place and gave a new change none, so remote sync and the dashboard read v2 writes as stale. A write that changes nothing stamps nothing. The stamp lands in the node's state.yaml row, so a write now also rewrites that file.

The rex MCP get_capabilities tool now reports "schemaVersion": "rex/v2" on a v2 tree. It answered "rex/v1" beside "adapter": "v2-change-layer", so a client that reads schemaVersion took the tree for v1. A v1 tree still reports rex/v1. The change layer store declares its version through the new optional schemaVersion on StoreCapabilities.

get_product now answers with a summary by default: each area with its counts of capabilities and constraints by status, defective nodes and open changes. Pass area (an id or display id) for that area's capabilities and constraints, or full: true for the whole map, the previous default. On a large v2 tree the whole map exceeded what an MCP client accepts and was refused. The /ndx-capture, /ndx-plan, /ndx-reshape and /ndx-adversarial-review skills read the summary, then the areas they need.

The preflight banner and the dashboard's command effects name changes/ and product/ for the PRD on a v2 project instead of prd_tree/, which a v2 project does not have.

The dashboard server turns off rex's v1-layout deprecation notice for the commands it spawns, so a failing rex spawn on a v1 project shows its real error and not the notice first.

rex next labels v2 work by type ([change], [task], [subtask]), never [epic]. On a v2 tree, level in MCP and JSON payloads is the v1 projection of type and nesting: it stays for compatibility in this release and is deprecated. type is authoritative; the release that refuses v1 PRDs removes level from every v2 payload.

Changes under the soft freeze: - ndx migrate --dry-run (the bare chain) runs no model unless the new --with-models flag is given. - rex migrate-plan, rex migrate-apply and ndx migrate-layout refuse inside an agent run. - rex migrate-plan, migrate-apply and its dry run refuse a v1 tree with an item file the parser skipped. - MCP get_capabilities reports schemaVersion: rex/v2 on a v2 tree. - MCP get_product answers with a per-area summary by default; area and full: true reach the detail, and full: true is the previous default. - v2 writes stamp lastModified / lastModifiedBy. - level is deprecated on v2 payloads (kept in this release); rex next text labels v2 work by type. - The preflight banner and command effects name changes/ and product/ on a v2 project.

0.9.0

Minor npm ↗
  • Release 0.8.0 · Find your way — a minor release. #569
  • AGENTS.md now tells Codex and other assistants that a task can carry saved run settings. #537
  • Keep AGENTS.md inside the budget Codex reads it under, and guard it. #606
  • Fix rex backfill-commit-attribution reading only part of a real repository's history. #605
  • Initialize a blank folder — including its git repository — from the dashboard. #486
  • ndx ci no longer fails user projects that import child_process. The architecture-policy step enforces n-dx's own spawn allowlist, so it now runs only on the n-dx monorepo and is reported as skipped elsewhere. #587
  • ndx ci's architecture-policy step no longer fails on every run of this repository. #548
  • A completed task's commit now contains every file the run changed. #517
  • With auth on, the hub now presents the per-user token on every call it makes to a project server. Its queued-run replay and POST /api/hench/execute/check pre-check used to get 401 (a queued run was dropped as "could not start", the pre-check failed open), and the in-flight count behind the machine-wide session cap read 0, so the cap never held. ndx refresh --live-server's reload request sends the token too. #586
  • ndx init now names the directories it actually created. The static summary printed .sourcevision/, .rex/ and .hench/ unconditionally while establishInitLayout had already put every new project on the .ndx/ layout, so a fresh init reported three directories that were not there — the created/reused verdict was right, only the labels were hard-coded. Both recaps (Ink and static) now read their labels from the resolved layout through one shared helper, and ndx init --help no longer claims a fixed folder. #543
  • The N-DX-Item commit trailer now carries the PRD item id rather than a dashboard permalink. The permalink was built from web.publicUrl, defaulting to http://localhost:3117, so every autonomous commit wrote the author's host into permanent history and resolved to nothing on any other machine; web.publicUrl no longer affects the trailer. Readers accept both forms — itemIdFromTrailer unwraps a permalink of any host to the same id — so commits written before this change keep attributing. #605
  • Carry N-DX-Item on every commit that is for one PRD item #605
  • Add the placement decision for a change on the product layer (core/placement-policy.ts). rex.placement.models (text, jev, both) chooses which tiers run beside the rules, and rex.placement.autoAccept (none, agree, confident) decides when a placement is accepted without a person; a change without one gets needsPlacement. Jev runs under its own prd.place.judge task class, which is not a default judgment route, so exporting TYPESAFE_API_KEY alone never turns it on. confident needs a Jev pick at confidence 0.8 or higher that is on the rules shortlist. Jev can abstain with a none-of-these option, which is never auto-accepted, and a Jev confidence that is not finite or is outside 0–1 is ignored with a warning. Without Jev, both falls back to the text model and jev to rules only, each with a warning. Not wired into the store, CLI or MCP yet. #579
  • Route core, and the rest of hench and web, through the layout resolver #529
  • New rex MCP tools for a v2 PRD: get_product (areas, capabilities and constraints with computed status and health), get_capability (one node with its parent chain, related changes, binding constraints and co-changes), place_change (the rules' placement shortlist without target; with target, records a touches or a modified amendment, with the amendment's proposed text and criteria delta, and clears needsPlacement; a criteria delta naming an id the capability lacks, or adding one it has, is refused at placement, as apply would refuse it) and apply_change (a steward applies a change's amendments whatever rex.applyOn says). Each refuses a v1 tree. On a v2 tree get_prd_status reports change counts, the Inbox count, product status per area and change counts per release. The assistant-assets manifest lists the four tools. #610
  • Assistant guidance (generated CLAUDE.md) now states that stdio rex/sourcevision MCP servers follow the client's MCP roots in worktree sessions, that unservable roots refuse writes, and how to verify the write target with get_capabilities (#499). #520
  • ndx mcp <server> bridging to the hub now follows the client's MCP roots (#499). When the client advertises roots, the shim asks roots/list after initialized. If the root names another worktree of the same repository, the shim re-opens the hub session for that worktree and closes the first one, so tool calls write the tree the editor is open on rather than the checkout the command started in. The client sees one session throughout. notifications/roots/list_changed re-targets the same way. Clients without roots, roots naming the current worktree, and roots in another repository keep the cwd-derived workspace; the last case logs one stderr line. #520
  • --no-review and --no-skip-test-gate turn a saved setting off for one run. #571
  • Every package's guidance now lives in its AGENTS.md, with the CLAUDE.md beside it reduced to the @AGENTS.md import. Zone policies and seam registries for core, rex, hench and web were readable only by Claude Code before this; Codex and any other assistant that reads nested AGENTS.md files now get them too. tests/e2e/instruction-alignment.test.js fails a package CLAUDE.md with no AGENTS.md beside it, or one carrying content of its own. #530
  • Rank placement candidates for a change from rules (package and path mentions, title word overlap, file evidence against realized-by) and an optional text model on the new prd.place task class; the model counts as agreeing only when it picks the rules' top candidate. #579
  • Remove the dashboard's Notion and integration surfaces #531
  • Remove the Notion, Jira, Asana and GitHub Projects store adapters, rex sync, rex adapter and the sync_with_remote MCP tool. #531
  • Remove the documentation for the tracker adapters, ndx sync, rex sync, rex adapter and the sync_with_remote MCP tool, and drop .rex/adapters.json from the recommended .gitignore template. #531
  • ndx work --resolve=<value> now resolves instead of starting a real run. Any value except false (which means no flag) resolves, in both core and hench. #503
  • The shared project guidance (and the generated CLAUDE.md) now documents the run argument of the rex MCP tools add_item and edit_item. #537
  • Cut the test time of an ndx work run (#539). #546
  • Share the architecture governance sections with every assistant. The zone fragility governance, gateway modules, spawn-versus-gateway and concurrency contract sections — including the PRD write invariant — lived only in claude-addendum.md, so AGENTS.md never carried them and Codex ran without the gateway rules. They now live in project-guidance.md; the addendum keeps only the pointers to Claude's per-directory CLAUDE.md files. A heading in the addendum that is not on the Claude-only allowlist now fails tests/e2e/instruction-alignment.test.js. #530
  • The commit step in /ndx-work, /ndx-capture, /ndx-plan, /ndx-reshape, /ndx-config and /ndx-adversarial-review now stages only its own changes and works in linked worktrees. Each skill notes which paths are already dirty when it starts and stages and commits, by explicit path, only the paths that were not, so the user's in-progress work — staged or not — is no longer swept into the commit. Status is read with core.quotepath=false, so a file with a non-ASCII name is staged rather than aborting the step. Before its first write, each skill also checks whether it is about to change a path that was already dirty (for PRD writes, any dirty path under .rex/prd_tree/) and asks the user to commit or stash it first, so its commit holds the whole change. The commit message is written to .ndx-commit-msg.txt at the project root instead of .git/NDX_COMMIT_MSG, which failed with "not a directory" in a linked worktree, where .git is a file. #619
  • ndx start --here restarts this directory's own token-protected server when its PID file is gone, instead of starting a second dashboard beside it. The server is identified by its owner and command line; no probe sends the token. #535
  • ndx start no longer SIGKILLs a token-protected n-dx server on the requested port; it moves to the next free port, as it already did for the hub and peer dashboards. ndx start --background now waits until the server is listening (up to 30s, or until the server exits) instead of giving up after 5s and printing a URL that refused connections. #535
  • New rex merge-state git merge driver for the v2 trees' per-folder state.yaml: rows merge by item id, so children added or completed on parallel branches merge cleanly. A metAt both sides changed is recomputed from the node's spec hash, and a status both sides changed reads completed when the merged row records a completion; anything else that cannot be decided leaves conflict markers on that field. ndx init pins <rex>/product/**/state.yaml and <rex>/changes/**/state.yaml to merge=rex-state and registers the driver beside rex-prd. #589
  • Surface SDLC readiness through the CLI, ndx, MCP and the dashboard status. #592
  • Turn rex backfill-commit-attribution into a read-only trailer coverage report. #618
  • v2 trees on a Windows CRLF checkout: the reader loads a CRLF file into the same model as its LF copy (bodies included), and the v2 writer and state.yaml writer leave a file alone when it differs only by CRLF. ndx init now pins <rexDir>/**/*.yaml (the v2 state.yaml files) to LF. #565
  • Child processes no longer open console windows on Windows. The shared exec wrapper defaults windowsHide to true, and the core CLI spawn shim, the vitest launcher and the e2e helpers set it, so a test suite or an agent run no longer flashes a console per spawn across the screen and steals keyboard focus. #602
  • ndx work --task=<id> --resolve [flags] <dir> (hench run --resolve) prints one JSON object describing the run those flags would start, without starting it: the task, the workspace, every setting (vendor, model, provider, permission mode, review and review model, test gate, budgets, fresh, allow-dirty, reset-deferred) with the config key that supplied it, the per-run options with their flags, types and allowed values, the equivalent ndx work command, and each reason the run would refuse — task-not-found, not-actionable, claimed-elsewhere, tree-not-conformant, vendor-unset, vendor-cli-missing, provider-unsupported, model-vendor-mismatch, dirty-tree. It exits 0 when it reports refusals, and takes no claim, writes no PRD or git state and starts no vendor CLI. Resolution uses the run's own flag parsing and model chain, so the reported model is the one the run uses. resolveTaskModel now also returns the source key that supplied its model. #503
Full release notes

Release 0.8.0 · Find your way — a minor release.

The accumulated changesets are individually patches, by the repo's standing rule that a change defaults to patch unless a release says otherwise. This one says otherwise: 0.8.0 adds capability rather than only fixing behaviour, so the release is a minor and the fixed group moves together.

What it adds:

  • The hub serves every registered repository from one per-user process on port 3117, with a project chooser and a reverse proxy at /p/<id>/, so several projects no longer contend for a port.
  • Worktree-aware navigation — a /w/<key>/ slot in the URL, workspace-tagged WebSocket frames and a breadcrumb switcher, so a branch worktree's PRD, runs and analysis are addressable and writes stay scoped to the worktree the URL names.
  • The Workspaces board, with each worktree's PRD delta against the anchor and the ability to start a run in another worktree by name.
  • ndx mcp <server>, a shim that forwards a stdio MCP client to the hub with the workspace header, so a desktop session in a worktree reaches that worktree's servers.
  • Hub admission — a global session cap and memory headroom check that queues a run instead of answering 503.

Changesets takes the highest bump across everything pending, so this is the only entry that needs to say minor.

AGENTS.md now tells Codex and other assistants that a task can carry saved run settings.

The Rex guidance that ndx init writes into your project lists a new bullet: add_item and edit_item accept a run block (a portable model tier plus optional per-vendor model pins, provider, review, permission mode, test gate, turn and token budgets, notes for the agent). An object replaces the whole block and null removes it. Re-run ndx init to refresh an existing AGENTS.md.

Keep AGENTS.md inside the budget Codex reads it under, and guard it.

Codex reads the root AGENTS.md and a package's own together, up to project_doc_max_bytes, and silently drops whatever lies past that — no error, no log. renderAgentsMd puts the Codex-operational sections last (Workflow, Skills, MCP Servers, Troubleshooting), so an overrun takes exactly the half Codex cannot work without. The root had grown to 30,611 bytes against a ~32,768-byte limit, leaving 2,157 for any nested file: rex, core and llm-client were already over and hench had 127 bytes to spare.

What moved. Reference prose went to docs/; the rules stayed in-context. The web package's internal zone diagram is now a pointer to packages/web/AGENTS.md, where it already lived. The gateway table keeps the file addresses and links the export inventory to docs/architecture/gateways.md. The concurrency contract keeps its safety table, the PRD invariant and three one-paragraph rules, and links the full reasoning — including the bundle and narrative carve-outs — to a new docs/architecture/prd-write-concurrency.md. The root is now 26,628 bytes; rex, core, llm-client and hench fit again.

The guard. tests/e2e/agents-md-size-budget.test.js fails a pull request when the rendered root exceeds the limit minus a 2 KiB margin, or leaves a package file no room, naming the limit and saying that Codex drops the rest. The Codex docs confirm the setting and the truncation but publish no default number, so 32 KiB is treated as this project's budget rather than quoted as a documented constant.

Not yet fixed: packages/web/AGENTS.md (21,941 bytes) still overruns the chain by 15,329 and needs its own trim; the guard reports it as a warning and will assert on it once that lands.

Fix rex backfill-commit-attribution reading only part of a real repository's history.

The command buffered the whole git log body in memory through a bare execFile, whose 1 MiB default it outgrew — on this repository's own 3.5 MiB history it reported "could not read git history" and did nothing, a silent no-op. It now runs through core's git helper, the same path change-commits.ts already uses with a 256 MiB ceiling for exactly this reason, so its node:child_process entry drops out of the allowlist.

Two parsing gaps surfaced once it could read the log at all:

  • Only the first trailer per commit was read. A commit that touches several items carries one N-DX-Status trailer per item; this repository's 53 such commits carry 122 trailers between them, so better than half were being dropped. Every trailer is now read.
  • Only the Unicode arrow was matched. Both → and -> occur in history; the -> form was skipped entirely.

The body is still scanned rather than handed to git's own %(trailers:…) parser, which reads only a message's final paragraph: most of this history puts a blank line between the N-DX-Status lines and the closing Co-Authored-By, so git classifies them as prose and recognises 3 of the 53 commits. A comment on the parser records that.

Initialize a blank folder — including its git repository — from the dashboard.

ndx start in an empty folder already served the setup page with an Initialize project button, but the init it ran could never create a git repository: the preflight prompt that offers one needs a TTY, and the wizard spawns ndx init with piped stdio. A folder set up that way stayed outside version control, with auto-commit, pair programming, and the hench run loop silently disabled.

ndx init now takes --git / --no-git, which answer that prompt ahead of time — the only way a run without a TTY can create a repository. --git also gets the chore: n-dx init baseline commit the interactive path makes, so the working tree is clean straight out of init.

The setup wizard asks the question in the browser instead. A new GET /api/commands/init/preflight reports whether the folder is already a repository and whether git is on PATH; the question appears only when there is something to decide, is disabled with an explanation when git is missing, and travels to POST /api/commands/init as git: boolean. The init status endpoint reports gitRequested / gitInitialized, confirmed from disk rather than from the exit code — ndx init treats a failed git init as a warning and still exits 0.

The wizard also addresses the project through its hub prefix now. Plain ndx start registers with the per-user hub, which serves each project at /p/<id>/; the page's root-relative fetch("/api/...") calls reached the hub instead, which answers 409 once a second project is registered.

Initializing from the dashboard also moves the running server onto the layout init wrote. A server started in an empty folder resolves its paths before anything exists, so it holds the legacy roots (.rex, .sourcevision, .hench) while ndx init gives a new project the .ndx/ container — the dashboard went on serving the setup page, and every data route read an empty project, until the server was restarted by hand.

ndx ci's architecture-policy step no longer fails on every run of this repository.

ci.js and tests/e2e/architecture-policy.test.js each kept their own list of files allowed to import node:child_process. Nobody kept ci.js's list up to date, so it reported 26 violations that the test allowed. Both now read packages/core/child-process-allowlist.json, where every entry carries a reason. Agent worktrees and local harness scratch space are excluded from the scan. A parity test fails if the two scanners ever disagree.

A completed task's commit now contains every file the run changed.

Reported from the dashboard's run-task button: files the agent had changed were missing from the commit, and the task was recorded completed anyway. Two independent causes, both of which committed before the task was verified complete.

The commit only ever held what the agent staged. The prompt asks the agent to git add -- <path...> naming each path and never to stage the whole tree, so any file it forgot was simply absent from the commit. A new stageRunWork stages the run's own work itself, and does it *before* the uncommitted-work gate inspects the tree — order being the point. The gate's job is to refuse a completion claim while finished work sits uncommitted, not to punish an incomplete git add; staging first means it sees the run's work as staged and the commit carries all of it, while anything that is not the run's work stays dirty and is still refused.

Four exclusions keep that from meaning git add -A: anything already dirty when the run started (the operator's work in progress, captured by the new captureBaselineDirty), hench's own runtime artifacts including the .hench-commit-msg.txt sentinel, and the PRD paths, which performCommitPromptIfNeeded stages itself *after* writing the completion so the status transition and the code land together. With no baseline captured nothing is staged at all — an unknown baseline cannot tell the run's work from the operator's, and guessing is how someone's work-in-progress ends up inside a task commit.

The mid-run auto-commit timer is off by default (hench.commitMsgTimeoutMs now defaults to 0, was 300000). Armed, it fired five minutes after the agent wrote its commit message — during the rest of the session and the whole review pass — and committed whatever happened to be staged at that instant: before the test gate, before the uncommitted-work gate, before the completion was written, and without staging the PRD paths or the review repairs. It then set didAutoCommit(), which short-circuits the real commit path, so the completion write never reached a commit either and the next run's pre-run gate inherited it. The case it covered — a run that dies after the agent staged its work — is handled without committing anything unverified: the uncommitted-work gate refuses to record the task done, and the next run's pre-run commit gate offers the leftovers as a checkpoint. A positive value restores the timer.

That key was also described in three places as "how long the commit-message generation call may run", which it never was. ndx config, hench config and the dashboard's config form now say what it does.

No change was needed for per-iteration gating: runIterations, runLoop and runEpicByEpic already call shouldStopForUncommittedWork between tasks, and between-task-uncommitted-guard.test.ts already pins all three.

Carry N-DX-Item on every commit that is for one PRD item

The trailer is how rex's realized-by edge finds the commits that realize an item — computeChangeCommits reads it and no other part of the message. Only hench's work commit emitted it, so a task whose code the operator committed by hand, or one driven through /ndx-work rather than ndx work, left nothing in history for the edge to find.

Three paths now emit it: hench's chore(prd): record commit names the completed task, the /ndx-work skill gained a commit step whose trailer block names the task it implemented, and /ndx-capture names the item it created. The rule is stated once in packages/core/commit-trailers.js, whose buildTrailerBlock and buildCommitMessage take an optional item id: emit N-DX-Item when the commit is for exactly one item. A commit spanning several emits none, because naming one of the N would attribute the whole commit to it — that leaves hench's --reset-deferred commit, the pre-run gate commit, and the ndx-plan / ndx-reshape / ndx-adversarial-review skills unchanged.

The repository also gains .github/pull_request_template.md, ending in a trailer block: GitHub copies a PR description into the squash-merge commit, so that is where the trailer has to be for it to reach main.

Route core, and the rest of hench and web, through the layout resolver

Every remaining literal .rex/, .hench/, .sourcevision/ and .n-dx* path in the orchestration tier now asks resolveLayout where the project keeps its state, as do the hench and web files the 0.8.0 sweep left behind. On a project that has run ndx migrate-layout, these all used to read or write a path nothing is there — silently, because a missing file is indistinguishable from an empty project. Fixed as part of that:

- ndx start stop and ndx start status could not find a running dashboard on the new layout, and left it running. - The staleness notice told a migrated project that all three of its tool directories were missing and that it should re-run ndx init. - ndx ci, ndx export and ndx refresh looked for analysis output, the PRD tree and run records under the legacy names; the cross-vendor reviewer found no codebase context, no PRD excerpt and no configured test command. - The guard baseline every hench run is clamped to blocked .rex/** and .hench/** only, so on the new layout the agent was free to write n-dx's own state, PRD tree included. - The "Strict Safety" workflow template had drifted from that baseline in both copies, dropping half its credential patterns — choosing it left a project *less* protected than the default. Both copies now derive from the baseline. - Hench classified PRD writes by file extension on the new layout, so run summaries reported bookkeeping as documentation changes. - The note printed after a completion commit named a gitignore path the operator does not have, so following it left the tree dirty and the next run still refused to start.

New in @n-dx/llm-client: layoutStateNames(), for the classifiers that are handed a path and must recognise n-dx state under either layout rather than resolve one.

--no-review and --no-skip-test-gate turn a saved setting off for one run.

Once a task can save review: true or skipTestGate: true, a one-off run needs a way to overrule it. Both new flags resolve as cli-flag, so they outrank the task's saved block and hench.* alike, and — being flags — they apply to every task in a --loop or --iterations run. --no-review carries the saved reviewModel and reviewOptional with it: there is no reviewer left for them to configure.

A flag and its negation together is an error rather than a guess (--review with --no-review, --skip-test-gate with --no-skip-test-gate), as are --review-model or --review-optional alongside --no-review.

Behaviour change for dashboard clients. The run-option tables now carry a negatedFlag for the two booleans a task can save, so runOptionArgs turns an explicit review: false / skipTestGate: false into --no-review / --no-skip-test-gate instead of emitting nothing. That is what lets the dashboard say "off for this run even though the task saved it on". An absent key still emits nothing, which is how a request leaves the decision to the task and the config, and booleans nothing can save (fresh, allowDirty, reviewOptional) are unchanged — their false still says nothing.

Remove the dashboard's Notion and integration surfaces

The tracker adapters went in the previous change; these are the dashboard surfaces that existed to configure them. With no adapter behind them they were a settings page for a feature that could not do anything.

Deleted: routes-notion.ts and its /api/notion/* endpoints, the notion-config and integration-config views, the Notion schema wizard and both stylesheets. The rex.notionSync and rex.integrations feature toggles go with them — from the registry in routes-features.ts, from the route gate table, from the static export's prerendered features.json, and from ndx config --help. The Project page is now two sections, analyze-and-plan settings and feature flags, with no conditional sections at all.

routes-notion.ts held the last dynamic @n-dx/rex/dist/* import in web, so that escape hatch and its entry in the architecture policy's documented-dynamic- imports registry are both gone. Note that this leaves rex's src/store/adapter-config.ts — extracted in the previous change specifically so this route could keep reading .rex/adapters.json — without a consumer. It is still exported from public.ts and still tested; removing it is a rex decision rather than a dashboard one, so it is left for the follow-up that records the tracker removal.

Two things were removed beyond the literal surface, both because deleting the routes made them dead rather than merely unused:

- RouteFeatureGate.prefix. Both subtree gates were Notion's and /api/integrations'; every remaining gate lists its paths exactly. An unexercised matching branch in the function that decides whether a disabled feature's endpoint is reachable is the kind of thing that rots, so the field and its branch go and exact becomes required. - The .cmd-sync-* and .intg-* rules in commands.css and deployed.css, orphaned when the sync buttons and the integration list went.

The /notion-config and /integrations redirect aliases are deliberately kept. They are URL compatibility for 0.8.0 bookmarks and they point at /project, which still exists — removing them would turn a working redirect into a 404 and buy nothing.

Unrelated robustness fix in tests/e2e/prompt-census.test.js: it enumerated files with git ls-files, which reads the index, then read each one from disk. A file deleted in the working tree but not yet staged made it throw ENOENT and report as a crash rather than as the clean result it was. It now skips paths that no longer exist.

Remove the Notion, Jira, Asana and GitHub Projects store adapters, rex sync, rex adapter and the sync_with_remote MCP tool.

No project used them. They were written against the whole-document store model that the folder tree replaced, so every one of them had been carrying a conversion layer between a PRD tree and a flat list of remote records — four copies of a translation nobody was running. A work-tracker bridge is planned as its own package, built against the storage model that actually exists; keeping four unexercised adapters alive until then buys nothing and has to be migrated with every schema change.

Gone from rex: notion-*, jira-*, asana-* and github-projects-* under src/store/, the integration-schema system and its four tracker schemas, the AdapterRegistry, SyncEngine, the sync and adapter CLI commands with their help entries, and the sync_with_remote MCP tool. Gone from core: the ndx sync command, its help and its command-effects entry.

Two dashboard surfaces went with them, because they could not outlive what they called: routes-integrations.ts, whose every handler began by importing the deleted integration-schema modules, and POST /api/commands/sync, which spawned the deleted CLI command. routes-notion.ts survived this step — it reads and writes .rex/adapters.json through the credential helpers below — and is removed by the dashboard change that follows. The Notion wizard, the feature toggles and the remaining viewer views are a separate change.

What stayed, and why:

- file-adapter.ts and folder-tree-store.ts — the local stores. Untouched. - src/core/sync.ts — not the sync engine despite the name. It is the item bookkeeping module (stampModified, isModifiedSinceSync, ITEM_BOOKKEEPING_FIELDS), and the folder-tree store, the bundle exporter and rex analyze all depend on it. - Credential redaction and environment resolution, moved at this step into src/store/adapter-config.ts as plain functions rather than registry methods, so that routes-notion.ts kept working. They did not survive the release: deleting that route left them without a caller, and a later change in this same release removes the module. See the entry for that change. - WorkItemLink in the schema. Items may still record a link to an external system; nothing in rex writes one now. Removing the field is a schema change, not an adapter removal.

createStore keeps its adapter-name parameter and now throws for anything other than "file". Callers across rex and hench pass the name explicitly, and a parameter that is silently ignored is worse than one that is checked — a caller asking for "notion" should hear that it is gone rather than quietly receive the local store.

The redaction rule changed shape. It used to read each adapter's configSchema for an explicit sensitive: true; those schemas went with the adapters, so the key name is now the only signal and the rule had to widen to match it. A key whose name ends in token, secret, password, passphrase, apikey or credential is redacted — which newly covers apiToken, previously caught only by Jira's schema flag. The match is anchored at the end of the key rather than done as a substring, so projectKey is still stored in the clear: redacting it would write a __redacted marker over a value that was never a secret and then fail to resolve it from an environment variable nobody set.

Remove the documentation for the tracker adapters, ndx sync, rex sync, rex adapter and the sync_with_remote MCP tool, and drop .rex/adapters.json from the recommended .gitignore template.

The code went in the two changes before this one. Documentation that outlives the feature it describes is worse than no documentation: a reader who finds ndx config rex.adapter notion . in a guide has no way to tell it is a fossil until they run it, and a command reference listing a command that no longer exists makes the whole reference untrustworthy. A work-tracker bridge ships later as its own package; when it does it gets its own documentation, written against the storage model that will actually exist rather than inherited from the adapters it replaces.

Gone from the guides: the "Using ndx sync for team-backed PRDs" section of Keeping Your PRD Alive along with its two Notion team workflows, conflict-resolution rules and the external-adapter branch of the maintenance checklist; the ndx sync row in the command reference; the sync_with_remote row in the MCP tool tables of the MCP guide, the rex package page and rex's own README; the ndx sync row and the sync_with_remote entry in @n-dx/core's README, which is the package's npm page; and the rex sync / rex adapter examples from the rex CLI listing. Rex's README also now lists claim_task and release_task, so its stated tool count matches the eighteen tools the server registers.

A new check in tests/e2e/command-docs-parity.test.js runs the existing parity rule in the other direction: every ndx <command> row in the repository README, the command guide and @n-dx/core's README must name a command the registry knows. Before this, a removed command could stay documented indefinitely.

change-management.md was kept rather than deleted. The task that scheduled this work called for the whole file. Four of its five sections — drift detection, the four-cycle maintenance loop, archive management and the recovery playbook — have nothing to do with trackers and nothing else documents them; the sync material was about 15% of the page. Deleting all of it to remove that 15% would have cost four sections of live guidance and broken the sidebar entry plus two inbound links for no gain. The tracker content was excised instead, which satisfies the same requirement.

Three references to files that no longer exist were corrected while they were in reach: rex/src/core/notion-map.ts in the level-system reference, routes-integrations.ts and routes-notion.ts in the zone inventory, and the Notion and integrations sections of the Project view in the accessibility route table. The /notion-config and /integrations redirect aliases are still documented in the viewer architecture page, because those aliases still exist — they point at /project for 0.8.0 URL compatibility.

.rex/adapters.json is gone from the recommended ignore template (packages/core/assistant-assets/ndx.gitignore) and from the three documented copies of it. Nothing produces the file any more. ndx init does not apply that template — users copy it by hand — so an existing project's .gitignore is untouched and keeps ignoring any adapters.json left over from an old setup. The repository's own .gitignore drops the entry in the same change, because tests/unit/ndx-gitignore-template.test.js requires the template and this repository to list identical .rex/ entries.

The dated audit tables in docs/cli-ui-gap.md keep their ndx sync row, with a note that the command was since removed. That table records what shipped on a given date; rewriting it would falsify the history it exists to preserve. The current-state inventories in the same file did drop the removed rows.

Cut the test time of an ndx work run (#539).

  • Gate-only retry. When a task's last run failed only at the test gate, with its work committed and its completion held, ndx work --task=<id> skips the agent, re-runs the gate and applies the held completion on green. A review that passed on the same commit is inherited. A second gate failure goes back to the agent. The read-only refusal also stands down when earlier attempts already committed the task's files, instead of re-spawning the agent cold.
  • Flake absorption. With hench.testGate.rerunCommand, an unattended run re-runs only the failed suites once; a pass counts and is recorded as testGate.flakyRerun.
  • Scoped gate. hench.testGate.command replaces the gate command and takes {base}, the run's start commit. scripts/run-all-tests.mjs gains suite labels, affected <base> and --list. Run records gain testGate.base, suites, scopeFallback, firstAttempt, rerun and gateOnlyRetry. Both keys are opt-in and appear in ndx config help and the dashboard config fields.
  • Scoped checks. The agent brief and the in-hench reviewer run scoped checks only, since the gate follows and CI runs everything.
  • Interactive gate prompt. A failed gate on an interactive run offers rerun/abort/skip again; it used to abort silently.

Surface SDLC readiness through the CLI, ndx, MCP and the dashboard status.

analyze now writes readiness.json beside sdlc-profile.json — the weighted score computed from the detected profile. Four surfaces read it:

- sourcevision readiness [--json] prints the scorecard, with the evidence behind each dimension and the gap that would raise it. - ndx readiness delegates to it, forwarding --json. - get_readiness on the SourceVision MCP server returns the same artifact. - GET /api/status carries sv.readiness ({ overall, analyzedAt }), null rather than absent on an analysis that produced no readiness artifact.

The CLI and MCP surfaces recompute from sdlc-profile.json rather than serving readiness.json, so a weight change shows up without a re-analysis; the persisted file is for readers that only want the headline. The scorecard is heuristic — it detects whether a practice exists and is wired up, not whether it is good — and is labelled as such on every surface that publishes it.

Turn rex backfill-commit-attribution into a read-only trailer coverage report.

Schema v2 stores no commits on an item — a commit's SHA is not its identity, so a change's commits are computed from its N-DX-Item trailers on demand. That left the backfill writing state nothing reads. What survives is the question it was really answering: how much of history can be attributed at all, which is worth knowing before the trailer format is frozen.

The command now reports, for every commit reachable from the default branch, whether its message carries an N-DX-Item or N-DX-Status trailer — grouped by author and by month, with totals. --json (or --format=json) prints the same report machine-readably, and --ref=<branch> reads another branch.

It writes nothing: not under the rex directory, not the trailer cache change-commits.ts keeps, and it no longer loads the PRD at all.

Two counts are reported rather than one, because they disagree and the gap matters. covered scans the whole message; attributed asks git's own trailer parser, which is what attribution actually reads — and git reads trailers only from a message's final paragraph. On this repository 67 of the 79 covered commits write the trailer outside it, so a single number would either call history covered that nothing can attribute, or hide that the trailer was written at all. Merge commits are counted separately for the same reason: they carry no trailer by construction.

A git failure — an unknown ref, a shallow CI checkout — now fails the command with the cause named. The predecessor caught it and returned normally, which reported an unreadable log as success.

0.8.0

Minor npm ↗
  • 0.8.0 — Find your way #459
  • Add Claude Opus 5.5, Sonnet 5.5 and Fable 5.1, and move the Claude defaults onto them #505
  • Claude API requests now send llm.effort as output_config.effort, and Claude Opus 5.5 defaults to high effort. llm.effort was parsed but never sent. With no matching rule, claude-opus-5-5 gets high so the move from Opus 5 keeps its reasoning depth (Opus 5.5's API default is medium), and other models are unchanged. Effort is never sent to a model that rejects it (Haiku 4.5, Sonnet 4.5 and older) or when the value is not low, medium, high, xhigh or max; both cases print a warning. Claude Code CLI runs are unchanged. #505
  • ndx config claude.<field> now writes llm.claude.<field> (printing a one-line deprecation note on stderr naming the key actually written), and every CLI read path — the single-key get, the whole-section get, --json, and --test-connection — resolves llm.claude.<field> over the legacy claude.<field> per field, via resolveClaudeConfig (@n-dx/llm-client). #469
  • Every ndx command now declares its effects — what it reads and writes, which phases call a model and roughly how often, what network it touches, and how long it takes. ndx help --effects --format=json prints the declarations, GET /api/commands/manifest attaches each one to its command unchanged (with the project's layoutPaths for expanding path tokens like {rex}/prd_tree/), and docs/cli-ui-gap.md gains a generated Command effects table (node scripts/build-cli-ui-gap.mjs). #508
  • ndx start now creates the per-user dashboard token at <ndx home>/auth.token, passes it to the hub, single-project and preview servers it starts, sends it on its own hub probes, and prints URLs and MCP registration commands that carry it. Loopback is shared by every account on a machine; the token is what tells this user's browser and CLI apart from another account's process. --no-auth, or web.auth: false in the project's config file, turns it off; a home directory that cannot be written degrades to a warning rather than refusing to start. Authentication is a property of the hub, not of the project that happened to start it: ndx start refuses to register with a hub whose mode does not match, naming ndx hub stop, rather than silently joining an unauthenticated one. --open opens the same tokenised URL it prints. #489
  • The per-user directory the hub keeps its registry, pid and config in is now ~/.ndx/, resolved by resolveNdxHome alongside the project-layout resolver rather than spelled out at each site. #439
  • Add hench.models.<vendor>, a per-vendor agent-only model override that ndx work actually honours, and deprecate the dead hench.model scalar. #449
  • Version the batch session chain and key it by the identity it was opened under. #434
  • Add ndx migrate-layout, which moves an already-initialized project from the legacy layout (.rex/, .hench/, .sourcevision/ and five loose .n-dx* files at the project root) onto the .ndx/ container. #445
  • Resolve the legacy top-level claude.* keys against llm.claude.* per field, and stop writing the legacy copies. #469
  • Add rex tree-diff (ndx tree-diff) to compare two PRD trees. #442
  • Show what ndx analyze, ndx plan and ndx recommend are about to do, and what they did #440
  • Make the ndx start --preview document an editor. The mock-up now renders from index.layout.json beside it and writes changes back over POST /__preview/layout: drag sections, tabs and panels to reorder them, drop one onto the middle of a group to nest it inside (so sections can be grouped into named dropdowns), rename anything — a rename renders as "New name (previously Old name)" — and add, cut or restore items. Moves, additions and removals are marked in place and collected into a change list for reviewers. The layout file is the artifact: hand-edit it instead of dragging if you prefer, and review it as a diff. #425
  • Report an unknown run cost as unknown, and scope the summary to the invocation #440
  • ndx init ends with a review of what the checkout shipped as execution config and what else came with it (PRD items, analysis, run records), and asks whether to trust it; unattended inits never trust automatically. New ndx trust [status|accept|revoke] [dir] command, delegating to hench trust. #491
  • Add rex log / ndx log as a CLI route for append_log. #433
  • List every hench setting on the Workflow page and in hench config #447
  • Add ndx start --preview: serves a hand-editable UI layout document (packages/web/src/preview/index.html) on port 3118 with live reload, for reshuffling dashboard sections before touching components. It runs no analysis, exposes no MCP endpoints and writes nothing under .rex/ or .sourcevision/, keeps its own .n-dx-preview.pid/.port files, and relocates rather than killing a port occupant — so it is safe to run alongside a real ndx start. --file=<path.html> serves a different document. #425
  • Add a folder-layout resolver and a paths module per package. #436
  • ndx init now starts new projects on the .ndx/ layout #445
  • Dashboard: work settings, CLI timeouts and templates are now one Workflow settings page. #482
Full release notes

0.8.0 — Find your way

A single .ndx/ directory for project state, with ndx migrate-layout to move an existing project onto it. A reorganised dashboard: views are stages, Analysis opens on the codebase map, settings are three pages (Robot Wrangler, Workflow, Project) on a shared save frame, and every moved path redirects. A Live tab for watching every run across a repository's worktrees. A per-user token on the hub and dashboard, and repository trust gating what a checkout's execution config may widen. New Claude model defaults, per-vendor agent models, per-field resolution of the legacy claude.* keys, and effects declared for every command and shown in a preflight banner.

Every other changeset in this release is a patch, which is the repo default and correct for each change on its own. This one makes the aggregate a minor, as the 0.8.0 epic requires.

Add Claude Opus 5.5, Sonnet 5.5 and Fable 5.1, and move the Claude defaults onto them

The defaults have changed. The standard tier (and the default when no model is configured) is now claude-sonnet-5-5. The heavy tier and the review model are now claude-opus-5-5. The opus alias now resolves to claude-opus-5-5 and fable to claude-fable-5-1. ndx init offers Sonnet 5.5 (recommended), Opus 5.5, Fable 5.1 and Haiku 4.5.

All three new models have a 1M context window and list pricing, so budget preflight, ndx usage and the dashboard spend views price them as known. That includes dated -YYYYMMDD snapshots. Previously they fell back to estimated rates and showed known: false.

claude-sonnet-5, claude-opus-5 and claude-fable-5 are still priced and resolvable, and ndx init accepts them without an unknown-model warning.

The dashboard config footer now shows every version part: sonnet 5.5, fable 5.1, haiku 4.5. Before, it showed sonnet 5 and haiku 4.

Pinning back. Older Claude Code releases can reject the new model ids on CLI-provider runs. If yours does, upgrade Claude Code, or pin the previous model: ndx config llm.claude.model claude-sonnet-5 .. For the heavy tier and review, also set llm.tiers.claude.heavy and llm.claude.reviewModel to claude-opus-5.

ndx config claude.<field> now writes llm.claude.<field> (printing a one-line deprecation note on stderr naming the key actually written), and every CLI read path — the single-key get, the whole-section get, --json, and --test-connection — resolves llm.claude.<field> over the legacy claude.<field> per field, via resolveClaudeConfig (@n-dx/llm-client).

Before this, ndx config claude.model answered Key "claude.model" not found. right after setting it, ndx config claude and ndx config --json showed only the legacy section, and a project configured entirely under llm.claude.* could look unconfigured to these read paths. Existing claude.* values on disk are left in place — only where new writes land has changed.

The hand-written resolveClaudeSettings duplicate in packages/core/config.js is gone; it now delegates to resolveClaudeConfig, loaded the same way config.js already loads other @n-dx/llm-client helpers (await import(...) — config.js may not statically import packages).

Every ndx command now declares its effects — what it reads and writes, which phases call a model and roughly how often, what network it touches, and how long it takes. ndx help --effects --format=json prints the declarations, GET /api/commands/manifest attaches each one to its command unchanged (with the project's layoutPaths for expanding path tokens like {rex}/prd_tree/), and docs/cli-ui-gap.md gains a generated Command effects table (node scripts/build-cli-ui-gap.mjs).

The preflight banner no longer promises "no model calls" for ndx analyze --no-llm or ndx plan --fast, neither of which turns the model off; each command now names the flags that actually do. The banner and run summary also name the right paths on a .ndx/-layout project.

The per-user directory the hub keeps its registry, pid and config in is now ~/.ndx/, resolved by resolveNdxHome alongside the project-layout resolver rather than spelled out at each site.

Nothing moves on an existing machine: the lookup takes $NDX_HOME, then $N_DX_HOME, then ~/.ndx if it exists, then ~/.n-dx if it exists, and only a machine with neither starts on ~/.ndx. A 0.7.x install keeps using ~/.n-dx until it is migrated. An empty override is treated as unset.

ndx start now passes the resolved directory to a hub it spawns as $NDX_HOME, and the hub reports a bad config key by naming the resolved file rather than a tilde path that may not be the one in force.

Add hench.models.<vendor>, a per-vendor agent-only model override that ndx work actually honours, and deprecate the dead hench.model scalar.

hench.model has never been read. ndx work resolved its model from --model, then llm-client's task-model resolution for agent.execute (llm.routes, llm.tiers.<vendor>.<tier>, llm.model / llm.<vendor>.model, then the vendor default) — the config field's "sonnet" default never reached the agent loop, and on a non-Codex vendor it was meaningless anyway.

hench.models is an optional map keyed by vendor (claude, codex, google, local) of non-empty model strings. Only the entry for the *active* vendor is consulted, so a config can carry a pinned model for every vendor it switches between. It sits between --model and all llm.* model configuration, which is what makes it agent-only: analyze, plan and the dashboard's Ask panel keep resolving from llm.* alone, so pinning hench.models.claude changes the executor without changing what anything else runs. --model still wins, and an override incompatible with the active vendor fails with the same actionable error a bad llm.model gets.

The vendor/model header reports it as hench-override. Resolution moved out of cmdRun into cli/commands/agent-model.ts so the chain is testable on its own.

Existing hench.model values stay ignored, so no project changes behaviour on upgrade. The new key goes through the same post-merge override validation as every other field: an invalid hench.models in .n-dx.json warns, falls back, and does not stop the run.

Version the batch session chain and key it by the identity it was opened under.

Under hench.sessionStrategy=batch, a task resumes the previous task's session. The chain recorded only vendor, model, a task count and the last task title, so resuming was permitted whenever those four matched — across worktrees, branches, source states and permission sets. Two worktrees of one repository running loops in parallel share all four, which made a cross-worktree resume the ordinary case rather than an edge one.

The entry is now versioned (BATCH_CHAIN_VERSION) and carries the worktree root, ref, sourcevision fingerprint, execution-policy hash, vendor, model, creation time and last-use time. Any mismatch is a named miss — worktree-changed, ref-changed, sourcevision-changed, policy-changed, vendor-changed, model-changed, expired, idle, unversioned, version-changed, malformed, alongside the existing no-chain, cap-reached and disabled — and identity is checked before freshness and the cap, so a chain belonging to another checkout says so instead of reporting that it filled up.

The policy hash matters more than it looks: codex exec resume accepts no sandbox or approval flags, so a resumed thread keeps the policy that created it. Before this change, tightening hench.guard between tasks left the next task running under the looser policy with no signal.

Two bounds rather than one TTL, because they catch different drift: total age (hench.batchMaxAgeHours, default 8) retires a loop whose repository has moved on beneath it, and idle time (hench.batchMaxIdleHours, default 1) retires one that stopped while someone worked in the same tree by hand. A single bound would make the last-use stamp unreachable.

The ref is keyed on the branch, not HEAD. A task that completes commits, so keying on HEAD would retire the chain after every success — batching disabled by way of it working.

Chains written by earlier versions read back as unversioned and are declined rather than reinterpreted or treated as an error; the next task simply opens a new one.

Add ndx migrate-layout, which moves an already-initialized project from the legacy layout (.rex/, .hench/, .sourcevision/ and five loose .n-dx* files at the project root) onto the .ndx/ container.

Migrating is optional — every command reads either shape, and ndx init deliberately leaves an initialized project on the layout it has. This is how you move when you choose to.

The result is renames plus two dotfiles. Each path moves on disk and both ends are staged, so git log --follow still reaches the history; .gitignore and .gitattributes are rewritten so their patterns name the new paths, because a .rex/** eol pin matches nothing once the PRD lives in .ndx/rex/. The move is then verified — every path arrived, and rex validate gives the same answer it gave before — and a failed check restores the project to the legacy layout without committing anything. Running it on a project already on .ndx/ is a no-op.

--dry-run prints the moves and changes nothing; --no-commit stages the migration and leaves the commit to you.

Two details are load-bearing and worth stating. The move is a rename plus git add -A on both paths rather than git mv: git stores no rename records — --follow and the R status are diff-time similarity detection — so the commit is byte-identical, and git mv <dir> aborts the whole directory over any single tracked entry missing from the working tree, which is an ordinary mid-work state rather than a corrupt repository. That trade is only safe with the second detail: the command refuses to commit when anything under the migrated paths is uncommitted, so an in-progress PRD edit cannot be swept into a commit advertised as nothing but renames. --no-commit lifts that refusal.

Nothing in the command names a directory. The move list and the pattern rewrites are both derived by resolving the same project root under both layout modes and pairing the fields, so a path added to the resolver joins the migration rather than being silently left behind at the root.

Resolve the legacy top-level claude.* keys against llm.claude.* per field, and stop writing the legacy copies.

Behaviour change. A project carrying both an llm.claude block and legacy claude.* keys now honours each legacy field the new block leaves unset. The fallback used to be block-level (llmClaude ?? legacyClaude), so a modern block holding a single field discarded every legacy field beside it — a project that set claude.api_key once and later pinned llm.claude.model silently lost the key, and claude.lightModel silently reverted to the tier default. Nothing said so, because both states are valid config.

resolveClaudeConfig in @n-dx/llm-client is the one implementation of that rule, exported alongside a sources map saying where each resolved field came from. loadLLMConfig, GET /api/llm/config and GET /api/ndx-config all use it, so the CLI, the dashboard and the footer can no longer disagree about which value is live.

Two further consequences of reading the resolved view:

- The dashboard footer's auth check (GET /api/ndx-config) now counts a credential set under llm.claude.api_key / llm.claude.cli_path. It read only the legacy block before, and reported authMethod: "none" for a perfectly configured project — it happened to work solely because packages/core/config.js mirrored modern writes back into the legacy keys. - That mirror is gone. ndx config llm.claude.<field> now writes only llm.claude.<field>. Existing claude.* values are deliberately left where they are: they are still read until 1.0.0, so rewriting or removing them would change what a project resolves without being asked to. The local-file migration still clears a legacy *secret* from the shared file, because a key mirrored there by an older version must not stay committed.

Readers that leaned on that mirror move with it. ndx config --test-connection resolves both locations per field, so it tests a credential stored under llm.claude.cli_path / llm.claude.api_key instead of reporting "No Claude configuration set" for a fully configured project. ndx auth already resolved both and is unchanged. loadClaudeConfig in @n-dx/llm-client, which supplies the API key, CLI path and endpoint to ndx work on the API provider and to rex's LLM commands, resolves both locations the same way, so a key set with ndx config llm.claude.api_key reaches them.

Not yet moved: ndx config claude.<field> as a *read*, the whole-section ndx config claude, and the claude block in ndx config --json still answer from the legacy key alone, so they no longer surface a value set under llm.claude.*. Read it under its own name (ndx config llm.claude.<field>) until that migration lands.

Writes go to the modern keys only. PUT /api/llm/config refuses claude.model and claude.lightModel with a 400 naming the llm.claude.* replacement, rather than the generic unknown-path error those keys would otherwise get.

GET /api/ndx-config also stops falling back to hench.model for the displayed model. ndx work has never read that key, so the footer could name a model nothing would run.

One sharp edge worth stating: a legacy claude.model that is incompatible with the active vendor was previously masked whenever any llm.claude block existed, and is now resolved — so ndx work reports it with the same actionable error a bad llm.claude.model already gets, instead of silently running the default.

Add rex tree-diff (ndx tree-diff) to compare two PRD trees.

The diff is by item id over the flattened trees, into added, changed, completed, moved and removed, each entry carrying the item's ancestor chain so a bare id does not have to be looked up to be understood. Because it keys on the id, a reparented item is reported once as moved — with both its old and new chains — rather than twice as an unrelated removal and addition.

With no flags it compares this checkout's working tree against the default branch, which answers "what has this branch done to the PRD". --from=<ref> --to=<ref> compares two commits, --against=<dir> compares two checkouts on disk (a worktree against its anchor), and --json prints the machine-readable form. Identical trees produce an empty diff. A ref from before the PRD tree existed reports present: false rather than reading as a tree-sized list of additions.

The command is read-only: it takes no PRD lock and writes nothing, so it can be run while another command is writing the tree. Reading the tree at a ref materialises it into a temp directory through a single git checkout with GIT_INDEX_FILE pointed at a throwaway index, so the caller's index and working tree are untouched.

The dashboard's Workspaces PRD delta now computes through the same diffTrees engine rather than its own copy of the id-indexing and field-comparison loop — its published payload is unchanged, but the CLI and the dashboard can no longer disagree about the same pair of trees.

Show what ndx analyze, ndx plan and ndx recommend are about to do, and what they did

Run interactively, these three now print a preflight banner before they start: what the command reads, what it writes (and which writes need --accept), which phases call a model and why, and roughly how long it takes. It pauses briefly so you can Ctrl-C, then prints a closing summary of the files written, the LLM calls and tokens, the cost, and the command to run next.

ndx recommend is declared as making no model calls at all, because it groups SourceVision findings deterministically — knowing which commands are free is the point of the banner as much as knowing which are not.

The banner is skipped under --yes, --quiet and --format=json, in CI, and whenever stdout is not a terminal — so autonomous ndx work runs and piped invocations are unaffected. Both the banner and the summary go to stderr, so --format=json stdout is byte-identical either way. NDX_PREFLIGHT=always forces the banner when piping; NDX_PREFLIGHT_PAUSE_MS sets the pause.

Supporting changes:

- rex analyze and rex recommend now run under the shared monotonic progress reporter from @n-dx/llm-client, and rex's spinner registers with it like sourcevision's already did — so a rate-limit retry raised inside an LLM call pauses the spinner and prints its own line instead of corrupting it. - Cost is recorded by the tool that spends it, never recomputed by a reader: sv analyze writes lastAnalysis.llm.costUsd to the manifest (additive, optional), priced per task class at the model that answered; rex analyze adds costUsd to its analyze_token_usage log entry. The orchestrator cannot import the price table, and a second copy of it would drift — so where no cost was recorded the summary says "not recorded" rather than guessing.

Report an unknown run cost as unknown, and scope the summary to the invocation

priceRunLedger discarded resolveModelPricing(...).known and priced every bucket, so a local or unlisted model was charged at the fallback rates — which are claude-sonnet-5's, a real catalogue entry, so the guess read as a measurement. recordLLMCall also folds a missing tokenUsage in as 0, so a provider that reported no usage priced to exactly 0. The manifest's costUsd is a single number with no room to say "partly", so one unknowable class now leaves the field off the run entirely; formatCost renders that as "not recorded".

collectRunSummary read manifest.lastAnalysis unconditionally, but that is the last analysis the *project* ran, not the last one this command ran — and most commands never run SourceVision. An interactive ndx recommend . after an ndx analyze . therefore reported the analysis's calls, tokens and dollars as its own, and plan --file read the same stale figures. It is now scoped by the run's timestamp, the way the rex half already was.

analyze also spawns sv narrate detached and returns before that child records a token, so the summary now says when a narrator this run queued is still going, rather than presenting an incomplete total as final.

Add rex log / ndx log as a CLI route for append_log.

append_log was reachable only as a rex MCP tool. Every ndx work run in a recent measured batch reported the same gap: the workflow's log step asks the agent to call it, but the rex MCP server was not connected to any of those sessions, and rex owns execution-log.jsonl under the write-access protocol, so hand-writing the file is not a substitute. Each run put the detail in its commit message instead.

rex log <event> [--item=<id>] [--detail="..."] and the append_log MCP tool now build their entry through the same appendExecutionLogEntry and persist it via the same PRDStore#appendLog, so the two routes cannot diverge on shape, truncation (2,000 characters), or rotation (execution-log.1.jsonl past 1 MB). ndx log in packages/core spawns rex log — no rex import, same as every other delegated command.

rex's default workflow and the ndx-work skill now name ndx log as the route when no rex MCP server is connected — the ordinary case for a claude/codex CLI-provider run.

List every hench setting on the Workflow page and in hench config

ndx config, hench config and the dashboard's Workflow page each kept their own hand-written list of hench settings, and the three had drifted. hench config was missing sixteen documented keys — promptCacheTtl, the whole prune and test-gate groups, the git-safety pair, session reuse — and the Workflow page was missing those plus the guard keys the CLI already had. One of them, guard.memoryMonitor.spawnThreshold, is named in the message hench prints when it throttles a spawn, so the suggested hench config command answered "Unknown config key".

All three surfaces now offer every key hench's schema defines, grouped into Session Reuse, Context Prune, Test Gate and Git Safety alongside the existing categories. tests/e2e/hench-config-gate-contract.test.js compares the lists and pins each recorded default against hench's own, so they cannot drift apart again.

Also fixed:

- The dashboard can now edit prune.*. Its write gate was per-field and could not see that hench refuses a config whose prune.retainPairs reaches its prune.triggerPairs; a new sibling-constraint check runs on the finished config, after group completion, on every write path. - The gate understands min/max bounds, so it no longer accepts a memory threshold above 100 that hench would then refuse. - language: "swift" was rejected by hench's own config schema even though hench init writes it for a Swift project. - The Workflow page appends any category it does not recognise instead of dropping it, and hench config --interactive no longer offers "1-5" when there are nine categories.

Add a folder-layout resolver and a paths module per package.

n-dx keeps its state in three dot-directories and five loose .n-dx* files, named directly at roughly 380 source files. resolveLayout in @n-dx/llm-client makes that one decision in one place: it reads a .ndx/ container first and falls back to the legacy layout silently, so existing projects keep working untouched. Each package gains a paths module (resolveRexPaths, resolveSourcevisionPaths, resolveHenchPaths, resolveWebPaths) as the single home for its own folder names, and the orchestration tier gets a hand-written twin in packages/core/layout.js — it may not import from any package tier — pinned to the canonical implementation by a contract test.

No call sites are rewired yet, so behaviour is unchanged.

ndx init now starts new projects on the .ndx/ layout

A project with no n-dx state gets a single .ndx/ container holding rex/, hench/, sourcevision/ and config.json, instead of three dot-directories and a .n-dx.json scattered across the root. .mcp.json stays at the repository root, because the vendor CLIs read it there.

A project that already has n-dx state keeps the layout it has. Re-running init is how people pick up new assistant surfaces and repaired config, and it must not turn into a migration nobody asked for — moving an existing project is ndx migrate-layout's job, where it can snapshot first and git mv so history follows.

The mechanism is that init creates the container before it spawns the sub-CLIs, so each one resolves its own paths and they cannot disagree. Alongside it, the paths that ndx init writes and that every later command reads now come from the resolver rather than from literals: the project and package config files, the requireInit check, the .gitignore and .gitattributes blocks, the git baseline commit, and hench's own state directory across its CLI.

relativeToRoot(layout, path) is new in @n-dx/llm-client (and its orchestration-tier twin), for the several places that need a resolved path as .gitignore spells it — root-relative, forward slashes.

Dashboard: work settings, CLI timeouts and templates are now one Workflow settings page.

The page (/workflow, in the settings list where the work settings were) renders on the shared settings frame: Save writes each changed part to its own endpoint, and a part whose write fails stays unsaved with its error shown. Templates still apply immediately; Apply and Save as template are disabled while the page has unsaved changes, and applying a template refreshes the work settings. Provider and model are no longer edited here — a link points to Robot Wrangler, which owns them. Templates are no longer a section of the Work stage.

/hench-config, /cli-timeouts and /hench-templates redirect to /workflow. The ndx config help for llm.local.timeoutMs names the new page.

0.7.1

Patch npm ↗
  • Check the sourcevision primer's fingerprint before trusting it, and seed hench orientation with it. #390
  • ndx claim accepts its directory argument from outside the project #405
  • A refused completion no longer hands the task back to other worktrees, and claims can now be inspected and freed from the CLI. #392
  • Add hench.promptCache to disable Anthropic cache_control breakpoints #406
  • Add hench.promptCacheTtl to opt in to Anthropic's 1-hour cache TTL #406
  • Make the context prune's retention and transcript limits configurable #406
  • ndx start --open no longer flashes a visible cmd.exe console window on Windows: the browser-opener spawn now passes windowsHide, matching the hub and background-server spawns. #379
  • A fragmented zone partition is no longer frozen. Before a previous partition is reused or used as the Louvain seed, sourcevision checks its health: one where at least 40% of zones hold two files or fewer is rebuilt from scratch, and in the borderline band Jev decides. The rebuild happens once per input fingerprint and needs no sv reset. Background narration no longer loses work: a new analyze stops a still-running narrator and queues what it had not finished. ndx status and the dashboard overview report pending or failed narration, and ndx plan waits for narration before rex analyze. #410
  • Price token usage at each model's own rates instead of Claude Sonnet's. #390
  • Stamp the primer with a content fingerprint, so an analysis that changed nothing stops invalidating it. #412
  • ndx start (and ndx web) now print how to stop the server as the last line of the success block: Stop: ndx start stop . (or 'ndx hub stop' for every project) (ndx web stop . after ndx web). Previously the server ran through the hub daemon and returned immediately with no indication that Ctrl-C does nothing — the stop commands existed but were documented only under --help, which is read before the command, not after. #403
  • Release workflow creates git tags and GitHub releases again. Moved to changesets/action@v2, which reads the CHANGESETS_OUTPUT NDJSON that Changesets CLI v3 writes instead of scraping stdout for New tag: lines (which CLI v3 no longer prints — so 0.5.0 through 0.7.0 reached npm untagged while every run stayed green). A publish run now fails if the version it just published is on npm but its tags are missing from origin or its GitHub releases were not created, and scripts/backfill-release-tags.mjs recreates missing tags and releases from the CHANGELOGs. #381
  • Make refusal recovery commands safe to paste into cmd.exe and PowerShell, not just POSIX shells. A path outside the shell-inert charset no longer rides inline with POSIX single-quoting (literal characters to cmd.exe — & still split the command, spaces still separated, and %VAR% expansion cannot be escaped interactively): the paths are written to .hench/recovery/pathspec*.txt and the suggested commands use git … --pathspec-from-file, so the copyable line contains no arbitrary text for any shell to expand or split. The POSIX-quoted form survives only as a fallback when the pathspec file cannot be written, now carrying an explicit "POSIX shells only" caveat. .hench/recovery/ is declared a hench runtime artifact (gitignored by hench init, discounted by the gates) and added to the ndx init ignore template. Covered by execution tests that run the emitted commands through real cmd.exe, PowerShell, and sh against hostile filenames (spaces, &, %, $, backticks, embedded single quotes). #405
  • Keep a worktree run's MCP PRD writes in its own worktree. #416
Full release notes

Check the sourcevision primer's fingerprint before trusting it, and seed hench orientation with it.

ndx work preferred .sourcevision/PRIMER.md over CONTEXT.md whenever the file existed, with no freshness check — so a primer left behind by an earlier analysis was served to every task as current. It is now used only when the fingerprint it was stamped with matches the current manifest.json; a stale, unstamped, or empty primer falls back to CONTEXT.md.

hench's orientation session now starts from that same primer instead of rediscovering the repo layout with an LLM, and asks the session to confirm and correct it rather than explore from zero. A primer that fails the fingerprint check is ignored, so orientation never inherits a stale description.

hench's warm-parent session cache now keys on the same fingerprint as the primer check. Before, hench joined the manifest fields with a NUL byte where sourcevision used a space, so its sourcevisionFingerprint and sourcevision's primerFingerprint could never agree; tests/integration/primer-fingerprint-contract.test.js now holds the implementations in agreement. Existing .hench/session-cache.json entries re-orient after upgrading, and once more after the first analysis that stamps the primer's content fingerprint.

ndx claim accepts its directory argument from outside the project

ndx claim list <dir> and ndx claim release <id> <dir> checked for an initialized project in the *current* directory before forwarding to rex, so running them from outside the project — exactly what the completion-refusal hint suggests — failed the init check even though rex resolves the directory argument itself. The check now looks at the same directory rex will act on, mirroring rex's own subcommand-aware slicing (list [dir] and release --all [dir] take it second, release <taskId> [dir] third). A bare ndx claim list in an uninitialized directory still refuses as before.

A refused completion no longer hands the task back to other worktrees, and claims can now be inspected and freed from the CLI.

When the uncommitted-work gate refuses to mark a task complete, the run's cross-worktree claim is held instead of released: the work is real and it is in that worktree, so freeing the task invited a second worktree to redo it. A held claim records why it is held, survives its holder's exit (an ordinary claim dies with its pid), and does not expire — it ends only when someone deals with the work: ndx claim release <id>, release --all from the worktree that left the work, release --force, or a fresh claim from that worktree (a re-run there clears the hold). Another worktree passes the task over; hench run and the dashboard's Execute, asked for it explicitly, say the task is held, that the hold does not expire, and how to clear it, rather than "is being worked on". Ordinary claims keep their existing lease-and-pid liveness exactly as before.

New ndx claim (rex claim): list shows every live claim with task title, worktree, holder liveness, state and expiry (a held claim reads expires: never — held until released); release <taskId> frees one — from whichever worktree the operator is standing in when the claim is held or its holder is dead — refusing while the holder is alive unless --force; release --all frees this worktree's held and dead-holder claims, keeping any a live run is still working unless --force. --format=json throughout.

Add hench.promptCache to disable Anthropic cache_control breakpoints

The API loop's new cache_control breakpoints are sent on every request, so a claude.api_endpoint pointing at a gateway or proxy that rejects the field (some OpenAI-to-Anthropic shims, some enterprise gateways, Bedrock's legacy InvokeModel path for models without caching) would fail every run's first turn with a 400 without a way to turn the markers off.

hench.promptCache (boolean, default true) is the escape hatch. When set to false, the API loop sends the request as it looked before prompt caching was added: system as a plain string, tools and messages untouched, zero cache_control markers anywhere. ndx config hench.promptCache false persists it to .hench/config.json.

Add hench.promptCacheTtl to opt in to Anthropic's 1-hour cache TTL

Both cache_control breakpoints in the Anthropic API loop used the 5-minute TTL unconditionally. Anthropic measures the TTL from the start of the request that wrote or read the entry, so a turn whose tool call runs long (a slow test gate, for example) leaves the next request outside that 5-minute window: the conversation prefix is re-written at the 1.25x-input rate instead of read at 0.1x.

hench.promptCacheTtl ("5m" | "1h", default "5m") lets both breakpoints move to the 1-hour TTL. It is opt-in: a 1-hour write costs 2x input rather than 1.25x, so it only pays off when the gap between turns regularly falls between 5 and 60 minutes. Cost estimates price every write at 1.25x regardless of TTL — see the caveat above MODEL_COSTS in llm-client's config.ts — so enabling "1h" under-reports estimated spend by that difference. ndx config hench.promptCacheTtl 1h persists it to .hench/config.json.

Make the context prune's retention and transcript limits configurable

The summarizing prune in the API loops keeps the most recent turn-pairs verbatim and shows its summarizer a capped excerpt of each dropped message. Together those bound how much of a run the agent can still read and how much of it can reach the summary, so both are tunable rather than hard-coded.

ConversationPruner takes the limits as constructor options, and all three API loops resolve them from the new hench.prune config group: prune.triggerPairs (default 20), prune.retainPairs (default 10) and prune.transcriptMessageChars (default 2,000). At the pair defaults a prune fires roughly once every ten turns, which keeps the cached prefix stable between prunes. Validation refuses a retention at or above the trigger, and either below 2, naming both keys.

The 2,000-character per-message excerpt matches the size at which hench truncates a tool result for the run record, so a result the run recorded in full reaches the summarizer in full. The overall transcript is capped at 40,000 characters; since roughly half of a dropped span's messages are tool results at the cap, a typical span lands well under it and is not cut from the end.

The pruner also clamps its own limits rather than trusting the schema, because loadConfig merges .n-dx.json's hench section after validation — so a hench.prune override in that file reaches the agent loop unchecked. An out-of-range value falls back to the default, and a retention at or above the trigger falls back to one pair below it and says so in the run log, instead of ending the run.

The group is CLI- and file-only for now (ndx config hench.prune.retainPairs 15). Exposing it in the dashboard first needs a config-aware write gate: retainPairs is only valid relative to triggerPairs, and the dashboard's gate validates one field at a time.

Price token usage at each model's own rates instead of Claude Sonnet's.

Reported costs rise on upgrade — typically by about half, and Opus-heavy projects by up to about two-thirds. Dashboard and CLI cost figures go up because runs are now priced at each model's own rates instead of a flat Sonnet rate; no tokens were added and nothing runs more expensively. On this repo's baseline batch the same runs moved from $161.08 (flat Sonnet) to $247.53 (per model), a 54% rise — the old figure under-reported by about 35%. Budget alerts or dashboards keyed to the old under-reported figures will see a one-time jump.

estimateCost took a ModelPricing parameter that every caller left at a single hardcoded Sonnet default (3/15 per MTok, cache write 3.75, cache read 0.30). Opus (5/25) usage was therefore quoted at three-fifths of its real cost — on this repo's own run history, which is not all Opus, $124 quoted against a real $186. The (based on Sonnet pricing) label made that honest rather than silently wrong, but it left the figures unusable for the before/after comparisons the cost work depends on.

- @n-dx/llm-client — MODEL_COSTS gains cache-write and cache-read rates, so the existing catalog now covers all four billed token kinds for every model in TIER_MODELS across claude, codex and google. New model-pricing module exports resolveModelPricing (exact id → Claude alias → Codex legacy remap → lower-case retry → labelled fallback) and priceTokens. Two known under-reporting caveats are documented on the table: a 1-hour cache write bills at 2x input rather than 1.25x, and long-context surcharges apply above 200K input on some models. Neither is recoverable from aggregate token counts. - @n-dx/rex — token aggregation carries a per-model split (byModel) drawn from hench per-turn records, which already recorded vendor and model, so a run that switched models mid-flight is priced per segment rather than at its run-level model. estimateCost prices each bucket at its own rates and reports a per-model breakdown; tokens with no recorded model, and any remainder between the buckets and the totals, form a separate unattributed line at the fallback rate. An unrecognised model id degrades to that same labelled fallback rather than throwing or pricing at zero. ndx usage now prints the per-model split in place of the blanket Sonnet caveat, and emits it in --format=json. - @n-dx/web — the dashboard's duplicate pricing literal is gone; it resolves the same fallback rates from the shared table. Its aggregation now carries its own per-model split and prices it through rex's arithmetic, so dashboard and CLI figures agree for the same runs (see the dashboard-per-model-pricing changeset in this release).

Stamp the primer with a content fingerprint, so an analysis that changed nothing stops invalidating it.

.sourcevision/PRIMER.md is the distilled startup context ndx work feeds every task. It was stamped with a hash of analyzedAt and gitSha — but every sourcevision analyze rewrites analyzedAt, so the stamp went stale on *every* run. A run that makes no LLM call (no vendor reachable, a CI runner, ndx ci, a provider outage) cannot re-distil and cannot re-stamp, so from that point on both readers rejected a primer that was still perfectly accurate: ndx work silently fell back to the full CONTEXT.md and orientation re-explored from scratch. Two cheaper losses came with it — an LLM-enabled analyse never hit the primer cache, so it bought a context.distill call on every run, and hench's warm-parent session cache, keyed on the same value, was thrown away by every re-analysis of an unchanged tree.

sourcevision analyze now computes analysisFingerprint from gitSha and the CONTEXT.md it just wrote — the primer's actual input, with no timestamp in it — and publishes it in manifest.json. Two analyses that found the same thing produce the same value; a changed tree produces a different one and the primer is correctly rejected until re-distilled.

Consumers now *read* that field rather than recomputing the hash, so the current path has one producer and no copies to hold in agreement. A manifest written before the field falls back to the old analyzedAt + gitSha hash, so an existing manifest and the primer beside it keep matching until the next analysis re-stamps both — that fallback is still implemented in all three tiers, and the cross-tier contract test still holds those three copies in agreement.

Keep a worktree run's MCP PRD writes in its own worktree.

A run in a linked worktree could have its update_task_status write the task file in a different checkout. claude mcp add --scope local — the pre-0.7 ndx init path — records the project directory absolutely, and Claude Code applies a repository's local-scope entry to sessions started in that repository's *other* worktrees, shadowing the tracked .mcp.json that resolves . per worktree. The per-workspace PRD lock cannot help: the write never reaches the right workspace to contend for its lock.

Three changes close it:

- A Claude run now writes its own MCP config to .hench/mcp/<runId>.json and spawns with --mcp-config <file> --strict-mcp-config, so the session's rex and sourcevision servers name the run's project directory absolutely and nothing inherited can shadow them. Both servers are spawned through the core CLI that launched the run, which keeps the agent's rex the same build as hench's own. That CLI is taken from NDX_CLI_PATH only after confirming it resolves back to the running hench; otherwise the run keeps today's behaviour and says why. The Codex adapter is unchanged. - ndx init no longer registers an absolute project directory under --mcp-scope=local; it records ., as the tracked .mcp.json already did. It removes the pinned entries it can reach and reports the ones it cannot, with the claude mcp remove --scope local command. - The ndx work pre-flight warns when a local-scope registration pins another checkout of this repository. It is silent for Claude runs that pinned their own servers, and applies to Codex runs, to Claude runs that fell back (a standalone hench run, or an NDX_CLI_PATH that does not lead back to this hench), and to interactive sessions.

Note that --strict-mcp-config drops the operator's user-scope and other MCP servers from every Claude run that pins its servers, attended or not — the spawned session sees only rex and sourcevision.

Known gap: the agent's shell rex / ndx / n-dx commands still resolve from PATH, which can reach a different install than the one running hench. Pinning those behind per-run shims is not part of this change.

0.7.0

Minor npm ↗
  • New ndx mcp <server> [dir]: one MCP command that works whether or not the hub is running. When a hub is up and the repository is registered, it bridges stdio JSON-RPC frames to /p/<id>/mcp/<server> with X-Ndx-Workspace naming the worktree the editor was opened in, so one server per repository serves every editor, every worktree and the dashboard, and a tool call lands in the tree the editor is actually on. Otherwise it serves MCP in this process exactly as ndx rex mcp . always has — which is also what happens for an unregistered repository, outside a repository, or when the hub stops answering. The hub's port and project id come from the marker ndx start leaves in the directory, falling back to ~/.n-dx/config.json and a registry lookup, so a hub started on a non-default port is still found. Diagnostics go to stderr only; stdout is the protocol. #369
  • ndx start now registers the repository with the per-user hub by default and serves it at http://localhost:3117/p/<id>/; --here (or web.mode: "here") gets the single-project server that owns the port. ndx start stop unregisters the worktree it runs in through the new DELETE /api/hub/projects/:id/worktrees/:path — the project keeps being served while another worktree is registered, and the last one unregisters the project and stops its server. A hub left with no projects exits, unless hub.keepAlive is set in ~/.n-dx/config.json. ndx start status reports the hub (pid, port, uptime), the project (id, state, server pid and port, repository, registered worktrees) and the URL. New ndx hub status and ndx hub stop address the hub itself from any directory. #369
  • ndx start --hub now writes .n-dx-web.port (the hub's port) and .n-dx-web.pid ({ pid, port, startedAt, via: "hub", projectId }) in the started directory, so ndx refresh --live-server keeps working unchanged: its reload signal reaches the hub, which forwards it to the project's own server. The reload body now carries dir; with several projects registered the hub matches it against each project's repository root and worktrees (deepest match wins), answering 409 without a directory and 404 for an unregistered one. ndx start stop, ndx start status and refresh's pre-flight recognise the hub marker and never stop the hub through it; ndx start --here over a marker takes the files over instead of killing the hub. #369
  • ndx start --hub (or web.mode: "hub" in .n-dx.json) registers the repository with the per-user hub instead of starting a single-project server: it resolves the repository through git worktree list (main checkout as repoRoot, the started directory's checkout as the worktree), derives a stable project id from .rex/config.json's project (hash-suffixed only on a clash with a different repository), starts the hub detached if ~/.n-dx/hub.pid / GET /api/hub/health do not answer, registers via POST /api/hub/projects, and prints the /p/<id>/ URL and MCP endpoints (--open opens the browser). --here forces today's single-project server, which remains the default until stop/status learn hub mode. $N_DX_HOME overrides ~/.n-dx; ~/.n-dx/config.json hub.port overrides 3117. #369
  • Write API keys to .n-dx.local.json, never to the shared .n-dx.json. #369
  • Fix eight defects found reviewing this branch: the hub's API now checks the browser origin before registering a project (registration spawns a process); the hub restates the forwarded Origin so dashboard mutations and WebSocket upgrades work through the proxy in hub mode; the anchor's frames go out untagged so the single-worktree dashboard updates live again; X-Ndx-Workspace outranks the /w/<key>/ slot, so the Workspaces board acts on the worktree it names; ndx start --here relocates rather than SIGKILLing the hub; the rex MCP path writes task claims (claim_task, release_task, and on in_progress) instead of only reading them; workflow-template config overlays go through the same allowlist as every other config writer; and a malformed percent-escape in a URL no longer ends either server. #369
  • Prove the child-lifecycle kill paths against real OS processes. #369
  • ndx ci now fails its config-secrets step when git is tracking .n-dx.local.json. That file holds every API key ndx config writes, and nothing protects it but the .gitignore entry — so trimming that line, negating it, or one git add -f put every vendor key on the remote while the step reported "no API keys in .n-dx.json" and passed. A tracked local file now fails on the fact of being tracked, whatever it holds, and the detail says to git rm --cached it, restore the ignore line and rotate any key it held. The step also no longer prints a tick above a failing run. #369
  • ndx export no longer adds a git-tracked out-dir to .gitignore, and says what it decided either way. Exporting into a directory you commit on purpose — --out-dir=docs/site feeding your own Pages pipeline — used to append docs/site/ to .gitignore with no output at all: the tracked files stayed tracked, but everything written there afterwards became invisible to git status and nothing said why. A tracked directory is now left alone with a notice, and an entry that is added is announced. isGitTracked moved to gitignore.js, beside the writer whose decision it governs. #369
  • ndx export resolves the rex CLI from the installed package instead of spawning a bare rex from PATH. Without a globally linked binary the export failed at "pre-rendering PRD data" with spawn rex ENOENT before publishing any run record — in every clean install and in CI. #369
  • Ship gitignore.js in the published @n-dx/core tarball. #369
  • ndx work prints one identity line before handing over to the agent: ndx <version> · <cliPath> · <projectDir> · <branch>. A run is the most expensive thing the CLI starts and the hardest to attribute afterwards — the run record says what happened but not which install produced it. The branch is read from the project directory, so it names the checkout the work lands on rather than the one n-dx runs from, and it is dropped outside a working tree. --dry-run prints it too; --format=json, --quiet and -q do not, since their output is parsed rather than read. #369
  • Make the explicit dashboard-port relocation test deterministic under concurrent CI port allocation. #370
  • ndx export now publishes run records through an allowlist, not a denylist. #369
  • ndx export no longer publishes agent transcripts by default, and --deploy=github confirms before pushing. #369
  • Send the Gemini API key in the x-goog-api-key header, not the URL. #369
  • Make the local (LM Studio) per-request timeout configurable via llm.local.timeoutMs #358
  • Add ndx which [dir] — report which copy of n-dx is actually running. #369
  • Make the detached process-tree regression fixture use the active Node executable, report shell launch errors, and clean up Windows fixture processes reliably. #370
  • Make the detached process-tree test wait for server startup before diagnosing its shell-launched child. #370
  • Surface the real grandchild launch failure in the detached process-tree test: the fixture's shell now carries its background job's exit status (wait with no operand always exits 0) and persists shell stderr, so a Node that the Windows shell cannot start is reported as the executable error rather than a readiness timeout. Fixture paths are absolute and /-separated, and the temp directory is removed even when a fixture process survives teardown. #369
  • Prove cleanup-gated late child termination with real OS processes, including a detached POSIX process group and its grandchild. #370
  • Reap child processes spawned after the cleanup gate has already run. #370
  • A review pass that could not run no longer reports a completed, reviewed task #370
  • Stop an autonomous run that repeats the same tool call, and report its real counters while it runs (GH #362) #370
  • Tree-kill descendants of children registered after cleanup has begun on Windows. #370
  • Skill runs measure their token usage from a mark written by code, not a timestamp typed by the model. New hench usage mark --task=<id> snapshots the Claude Code session transcript's cumulative usage and position when a task starts (hench usage marks lists pending ones); hench record --task=<id> then claims exactly the difference between that snapshot and the transcript at record time, per token class, printing both positions and a fresh-work subtotal beside the cache-read total. --startedAt is now the run's start time only (defaulting to the mark's) and never selects usage; --since remains an explicit window; --mark=<id> consumes a mark taken under another name. Without a mark the record falls back to the session watermark and warns, naming the mark command. Every shipped skill that records a run now marks at its first step instead of noting the time. #369
Full release notes

Write API keys to .n-dx.local.json, never to the shared .n-dx.json.

ndx config claude.api_key (and llm.claude/codex/google.api_key) wrote the key into .n-dx.json — the file that carries zone pins, the vendor and the dashboard port, that the gitignore template calls safe to commit, and that ndx init never gitignores. Only *.cli_path was routed to the gitignored .n-dx.local.json; the help text itself said "stored in .n-dx.json — add to .gitignore". One git add -A put the key on the remote. The 0600 chmod on the file defends against other local users, not against git.

*.api_key now joins *.cli_path in the local-only set. Every reader already merged the local layer over the shared one (core config.js, @n-dx/llm-client loadClaudeConfig/loadLLMConfig), so resolution is unchanged; the dashboard's /api/ndx-config auth-method detection was the one reader that looked at .n-dx.json alone and now merges too, so the footer keeps its ✓.

For projects configured before this: every ndx config run warns on stderr when .n-dx.json holds an api_key, naming the key and the fix. Re-setting the key is the migration — it lands in the local file and the shared copy (and the legacy claude.* mirror of an llm.claude.* key) is removed. ndx ci gains a config-secrets step that fails when a git-tracked .n-dx.json contains an api_key and warns when an untracked one does.

Found by the 2026-09-11 adversarial security review (finding A).

Prove the child-lifecycle kill paths against real OS processes.

Every kill path in packages/core/child-lifecycle.js was covered exclusively by tests/unit/child-lifecycle.test.js, which drives them through a fake ChildProcess and injected signallers and asserts *which strategy ran*. Replace any of those implementations with one that records the right signal and delivers nothing and the suite still passes — so the module whose one job is not leaking processes had no test proving it does not leak one. Two earlier items chose that scope deliberately; the accumulated result was a foundation nothing verified.

tests/integration/terminate-tree-liveness.test.js now watches real pids stop answering kill(pid, 0): terminateTree kills a detached child *and* its grandchild through the process group; the direct-child fallback kills a child leading no group; fixtures that install an empty SIGTERM handler prove the escalation delivers a real SIGKILL rather than merely reaching its second phase; and terminateTreeByPid kills a process, and a whole tree, addressed only by pid with no ChildProcess handle held anywhere in the test process — a short-lived launcher spawns the tree detached and exits, so it is reparented to init exactly like the background server whose pid ndx start stop reads out of a file. Each case was verified red against a no-op mutant of its own delivery site.

The Windows taskkill /T /F path keeps its injected-argv assertions, and the module header now says why: libuv puts a node-spawns-node tree in a global job object that reaps it regardless, so a liveness assertion there would pass without proving a kill reached anyone. That header also records which test proves which path, so the next reviewer reads the coverage map instead of re-deriving it.

No production behaviour changes. The real-process fixtures move to tests/helpers/real-process-tree.js, shared with the late-arrival suite that had its own copies.

Ship gitignore.js in the published @n-dx/core tarball.

cli.js and export.js import ./gitignore.js (the shared ensureGitignoreEntry helper), but the file was missing from package.json's files array, so an installed @n-dx/core would fail to load those modules. Added it to files; published-imports-resolved now passes.

ndx export now publishes run records through an allowlist, not a denylist.

sanitizeRunForExport deleted five named fields (toolCalls, events, error, diagnostics.promptSections, testGate.error) and published everything else verbatim. Everything else included the raw test-runner output (testGate.packages[].failureOutput), post-run test output and error (structuredSummary.postRunTests.output / .error), the literal command lines the agent ran (structuredSummary.commandsExecuted[].command, testsRun[].command), dependency-audit and cleanup error text, and diagnostics.notes[]. The runs.json index was worse: it copied the whole structuredSummary plus error. A failing test that printed an env dump, or an agent curl -H "Authorization: Bearer …", ended up in the static site while the deploy manifest said transcripts were excluded — and --deploy=github --yes force-pushed it.

- sanitizeRunForExport copies only fields named in an explicit allowlist, so a free-text field added to RunRecord later is excluded by default rather than published by default. It keeps what the deployed viewer renders: identity, timestamps, status, turns, summary, model/vendor/weight/ndxVersion, token usage and per-turn totals, structuredSummary.counts and fileChangesWithStatus, diagnostic labels, the test-gate verdict, review counts. - A new summarizeRunForExport builds the runs.json entries, mirroring the live server's toRunSummary. It drops error (published only under --include-transcripts) and stamps transcriptOmitted, and it now carries vendor, tokenDiagnosticStatus and invocationContext, which the runs list renders and the export previously omitted. - cliPath (an absolute path on the operator's machine), actor (git name and email) and host are no longer published. - The deploy manifest and ndx export --help describe what is actually withheld, including that the record is allowlisted. - tests/unit/export-sanitize.test.js replaces every string leaf of a full run-record fixture with a sentinel naming its own path and asserts the exact set that survives, so a new leak fails without anyone remembering to deny it. tests/e2e/cli-export.test.js asserts on the bytes actually written to api/hench/runs/<id>.json and api/hench/runs.json.

Found by the 2026-09-15 adversarial review of the September security fix branch.

ndx export no longer publishes agent transcripts by default, and --deploy=github confirms before pushing.

Every .hench/runs/*.json was copied verbatim into the static site — toolCalls[].input/output, events, error bodies — and --deploy=github force-pushed the result to origin/n-dx-dashboard with no prompt. A run that had cat'd a .env or printed process.env put that on the remote; the project's own discovery notes had already called this a blocker. The default out-dir ./ndx-export sat inside the repo and was not gitignored either.

- Exported run records are passed through sanitizeRunForExport, which drops toolCalls, events, error, diagnostics.promptSections and testGate.error, keeps summaries and token usage, and stamps transcriptOmitted: true so the static Task Audit view can say why the transcript is absent. --include-transcripts opts back in. - --deploy=github builds a manifest (remote, branch, run count, PRD item count, transcript inclusion) and asks before doing any work. Without a TTY it stops with exit 1 unless --yes is passed — nothing is written or pushed. - ndx init and ndx export both add ndx-export/ to .gitignore; the shipped template carries it too. ensureGitignoreEntry moved to packages/core/gitignore.js so both callers share it. - The dashboard's POST /api/commands/export forwards --yes only when the body carries confirmDeploy: true (set by the viewer's confirmation step) and refuses a deploy request without it; includeTranscripts: true maps to --include-transcripts. The confirmation dialog now says what is published.

Found by the 2026-09-11 adversarial security review (finding B).

Send the Gemini API key in the x-goog-api-key header, not the URL.

Every Gemini request built the key into the URL query string (…/models/<model>:generateContent?key=<API_KEY>, and …/models?key=… for auth validation), where proxies, corporate egress logs, and any future URL-logging path record it. The n-dx config llm.google.api_key validation preflight (packages/core/config.js) did the same.

All Gemini fetches — completions, streaming, tool calls, validateAuth, and the config preflight — now pass the key as the x-goog-api-key request header and build a key-free URL (streaming keeps ?alt=sse). Same endpoints, same auth, no behaviour change. A test asserts no request URL contains key= and the header carries the key.

Found by the 2026-09-11 adversarial security review (finding G).

Make the local (LM Studio) per-request timeout configurable via llm.local.timeoutMs

Local completions were bounded by a hardcoded 5-minute abort in three places — the local API provider, hench's local tool loop, and the second-model verifier (60 s) — so a slow local model failed with NDX_CLI_TIMEOUT no matter what the CLI-timeout settings said. cli.timeoutMs / the "CLI Timeouts" page bound a whole command, not an individual HTTP request, so setting them to unlimited had no effect on this path.

All three now read llm.local.timeoutMs (default 300000, 0 = no timeout), settable via ndx config llm.local.timeoutMs <ms> or the LLM Provider settings page. The timeout error message now names the key to change.

Add ndx which [dir] — report which copy of n-dx is actually running.

The package version is the same string in every checkout and every install, so ndx --version cannot distinguish a globally installed ndx from the worktree you are standing in. ndx which prints five fields instead: the version, the absolute path of the cli.js executing, the install kind (npm registry install, pnpm global link, or workspace checkout), the branch and short SHA of the install checkout when it is a git working tree, and the resolved project directory. --json emits the same record as one object, and ndx --version --verbose prints the identical report.

Install kind distinguishes a globally linked checkout from one invoked directly by comparing process.argv[1] against the realpathed entry point — Node leaves the former as the symlink, so a global bin shim makes the two disagree. The command always exits 0: a missing git, or an install that is not a working tree, reports no git identity rather than failing.

which is registered in ndx --help, has its own ndx which --help page, and appears in the dashboard's All Commands view under Setup — ungated, since identifying the CLI is most useful on a project that is not initialized yet.

Reap child processes spawned after the cleanup gate has already run.

createChildProcessTracker's cleanup() terminated the children it knew about at the moment it was called, then the process exited. But terminating the in-flight child is precisely what unblocks whatever was awaiting it — so the caller promptly spawned the next one, and that child was adopted into a set nobody would drain again.

ndx ci leaked one orphan per Ctrl-C this way: the gate SIGKILLed sourcevision analyze, runCapture resolved, runCI advanced to sourcevision validate, and the parent exited leaving that child reparented to PID 1. A sweep of one dev machine found 31 of them across seven worktrees, holding 431 MB, the oldest alive for over 11 hours — enough to push hench's memory monitor toward throttling later autonomous runs.

register() now refuses to adopt a child once cleanup has started and SIGKILLs it instead — synchronously, and without the SIGTERM grace period, because the grace period is exactly the window in which the parent exits first. This was a production defect, not only a test-side leak; the test fix is the backstop.

A review pass that could not run no longer reports a completed, reviewed task

ndx work --review is a gate, but a reviewer whose spawn failed (a stale vendor CLI, a --review-model the installed binary rejects) left a run that reported completed, committed, and said nothing — indistinguishable from a reviewer that read the diff and found nothing.

A reviewer that never started now refuses the completion: the run fails naming the missing review, the task returns to pending (not deferred), the validated work is left in the tree rather than rolled back, and the run does not count toward stuck-task detection, because the usual cause is a config line rather than a defect in the task. --review-optional downgrades the refusal to a warning.

A reviewer that *did* run and only lost its report still warns, as before. Both the end-of-run summary and hench show now carry a review line, so a run that was never reviewed says so where the terminal output does not survive.

Also fixes run.review being silently dropped whenever a run record was read back from disk: the run-record schema did not declare the field, and zod strips what it does not declare. hench show could not report whether a run was reviewed, and the stuck-task exemption above could not see its own marker.

Stop an autonomous run that repeats the same tool call, and report its real counters while it runs (GH #362)

A run could spend eighty minutes and sixteen million cache-read tokens repeating one cycle — relaunch the test suite, sleep, block on a background task whose process had died, re-read the same unchanged diff — and nothing caught it. lastActivityAt advances on every tool call and polling is a tool call, so the heartbeat monitor saw maximal activity, while the run record still said 0 turns and 0 tokens, so the dashboard drew it as idle.

Both halves are fixed:

  • Livelock detection. Identical tool calls — same name, same arguments, and the same result where the provider exposes one — counted inside a sliding window, with any file-mutating call clearing the count. Six repeats with nothing written in between stops the run and names the repeated call. A fix loop that edits between two identical test runs is unaffected. Tunable via hench.livelockThreshold (0 disables).
  • Truthful heartbeats. The CLI loop's in-flight turn and token counters are now folded onto the run record on every heartbeat, so a running task is no longer reported as 0/0.

0.6.0

Minor npm ↗
  • .codex/config.toml now uses cwd-relative stdio commands ({"command":"n-dx","args":["rex","mcp","."]}) instead of embedding this checkout's absolute dist/cli/index.js paths and project directory — matching the .mcp.json fix for Claude Code. Codex launches stdio servers with cwd at the project root, so both servers now start correctly from any worktree or teammate clone, and the registration survives the install moving or a dev-link toggle. The command name respects a configured cli.name in .n-dx.json. #361
  • ndx init no longer registers Claude MCP servers via claude mcp add --scope local by default — it relies solely on the tracked, cwd-relative .mcp.json written alongside it. Pass --mcp-scope=local to restore the local-scope registration for setups that cannot rely on .mcp.json being picked up. #361
  • ndx init now writes a tracked .mcp.json at the project root with cwd-relative stdio commands for rex and sourcevision (e.g. {"command":"n-dx","args":["rex","mcp","."]}), alongside the existing local-scope claude mcp add registration. #361
  • Attribute SourceVision Ask token spend in the LLM Utilization view #351
  • fix(core): clamp findRelocationPort's near window to port 65535 #360
  • Make the full-suite gate's timeout configurable via hench.fullTestTimeoutMs. #356
  • Rewrote the MCP registration docs (README, the shared project-guidance.md, and docs/guide/mcp.md) to describe the tracked .mcp.json stdio flow as the default: the one-time Claude Code approval prompt for project servers, the npx -y @n-dx/core rex mcp . alternative when ndx isn't on PATH, and --mcp-scope=local for the legacy per-machine registration. HTTP transport (http://localhost:3117/mcp/rex) is now documented plainly as safe for a single project only — it points at whichever project currently holds the port — until the multi-project hub (0.7.0) lands. #361
  • Stop rex export from writing an artifact that becomes the PRD backend. #357
  • fix(core): kill only the process listening on a busy port, never a client #359
  • Add a narrative PRD rendering: ndx prd export --format=narrative. #357
  • Add a portable PRD bundle: ndx prd export / ndx prd import. #357
  • Stop prompt-census --write stamping a commit the measurement did not come from. #356
  • Build rex's and sourcevision's LLM prompts through PromptEnvelope so their cost is attributable per section rather than as one opaque total. #356
  • Pin packages/core/assistant-assets/**/*.md to LF, so a prompt's measured size does not depend on which OS checked the repo out. #356
  • Add scripts/prompt-census.mjs and the checked-in prompt token baseline at docs/analysis/prompt-token-baseline.md. The census measures every LLM prompt surface across rex, sourcevision, hench, and core using llm-client's existing budgetPreflight() estimator, separates fixed prompt text from per-run context, and emits a before/after comparison against the recorded baseline. #356
  • fix(core): relocate near the requested port, not into 3117–3200 #360
  • Remove instructions that rex prompts already gave elsewhere in the same prompt, and resolve a task-size contradiction between them. #356
  • Scope a PRD bundle to one item: rex export --item=<id-or-slug> --out=<path.json> (also ndx prd export --item=…). #357
  • Give every workflow skill an explicit stopping condition, and stop exempting the repo-local skills from the portability guards. #356
  • Fix the skill-authoring reference telling authors to write the one commit step CI rejects, and pin the shipped-vs-source difference instead of arguing about it. #356
  • Add POST /api/sourcevision/ask, answering a question from the existing analysis #351
  • State the enrichment output contract once instead of four times, and fix the copy that had already drifted. #356
  • ndx start asks who is on the port before killing it, and steps aside for another project's dashboard. #359
  • Fix ndx start's port-occupant self/peer check so a symlinked project path (or, on Windows, a different drive-letter/casing spelling) is recognised as the same directory instead of a peer. #359
  • Stop the pre-run git gate counting the warm-parent session cache as operator work. #351
Full release notes

ndx init no longer registers Claude MCP servers via claude mcp add --scope local by default — it relies solely on the tracked, cwd-relative .mcp.json written alongside it. Pass --mcp-scope=local to restore the local-scope registration for setups that cannot rely on .mcp.json being picked up.

Init also no longer calls claude mcp remove --scope user — user scope is global, and stripping it removed registrations that had nothing to do with the project being initialised. Stale local-scope entries from a prior run are still cleaned up, but only when the existing entry's own recorded arguments actually target the project being initialised.

The init recap now reports where MCP registration landed — tracked .mcp.json, local scope, or skipped (no claude CLI found).

ndx init now writes a tracked .mcp.json at the project root with cwd-relative stdio commands for rex and sourcevision (e.g. {"command":"n-dx","args":["rex","mcp","."]}), alongside the existing local-scope claude mcp add registration.

Local scope is stored in ~/.claude.json keyed by absolute project path, so worktrees and teammate clones got no MCP registration at all, and it broke whenever the install moved or a dev link toggled. .mcp.json is committed to the repo instead: every worktree and clone gets the same two entries, resolved relative to whatever directory Claude Code launches the stdio server from.

Re-running ndx init merges into an existing .mcp.json rather than overwriting it — unrelated servers already present are left untouched. The command name respects a configured cli.name in .n-dx.json for projects that embed n-dx under another binary name.

Attribute SourceVision Ask token spend in the LLM Utilization view

Every Ask call spent real tokens from a surface with no accounting path. Hench runs land in .hench/runs/ and roll up per PRD item; rex and sourcevision report through their own artifacts. The dashboard's own spend reported nowhere, so the one view whose job is to report the bill was blind to its own.

Each call is now appended to .n-dx-web-usage.jsonl with vendor, model, input, output, cache-creation and cache-read tokens, plus how the call ended. The utilization aggregation reads it as a fourth package bucket, web, rendered as "Dashboard" — its own colour, donut slice, filter option and command row, so it stays separable from hench run spend everywhere the view breaks down by package. Asks are not task-scoped, so the spend is a dashboard bucket rather than being attributed to whichever PRD item happened to be selected.

Failed calls are recorded too, with the call counted and whatever the provider reported. A provider that finishes after the ask timed out appends its counts as a second, call-free record, so late tokens are neither lost nor double-counted as a second call. A call that never reached a provider (no analysis, unconstructible client) is deliberately not recorded — the ledger counts calls, not intentions.

Cache tokens are now reported in this view rather than hidden, consistent with the hench/rex decision. The server had always counted and priced them (estimateCost charges cache writes at 1.25x input and reads at 0.1x), but the viewer's local copy of the wire shape omitted the fields and totalled only input + output — so "Total Tokens" disagreed with the "Est. Cost" beside it, and on a cache-heavy run most of the bill had no visible line. Cache write/read now appear as headline figures, as columns in the vendor-model and command tables, and as their own cost lines.

The aggregation cache also fingerprints the ledger, so an answer's cost appears without waiting for an unrelated source to change.

fix(core): clamp findRelocationPort's near window to port 65535

findRelocationPort scanned requestedPort + 1 through requestedPort + 83 without clamping to the maximum TCP port. For a requested port near the top of the range (e.g. --port=65535 or --port=65500), the scan reached 65536 and beyond; net.createConnection throws ERR_SOCKET_BAD_PORT synchronously for a port outside 0–65535, which crashed ndx start's relocation branch instead of relocating or falling back to 3117–3200.

The near window's upper bound is now clamped to 65535. isPortInUse is also defensive: an out-of-range port is treated as "in use" rather than probed, so no future caller can reintroduce the same crash.

Make the full-suite gate's timeout configurable via hench.fullTestTimeoutMs.

It was hardcoded (5 minutes originally, raised to a measured 15 in the same release this ships in). However generous the constant, a suite an operator cannot re-budget will eventually exceed it — and overrunning aborts a task whose work was already done and committed. Set the key in .hench/config.json or .n-dx.json (the latter wins, as with every other hench key); 0 disables the limit. ndx config documents it under a new "Hench test-gate settings" section, alongside hench.fullTestCommand.

A timeout is now attributable like any other gate failure. It used to return zero packages, so the caller counted zero failures and printed 0/0 package(s) failed for a run it was about to abort; the result now carries a workspace entry naming the command, how long it was given, and the key that moves it.

Stop rex export from writing an artifact that becomes the PRD backend.

The in-tree guard rejected only paths inside .rex/prd_tree/, which is one directory short of the backends the store still falls back to: FileStore.loadDocument prefers .rex/prd.md, then .rex/prd.json, whenever the folder tree is absent.

Two reachable outcomes, both of which the guard's own docblock claimed to prevent. rex export --out=.rex/prd.json passed: a bundle envelope carries schema, title and items, so it satisfies document validation and silently *becomes* the PRD on a checkout without the tree. And rex export --format=narrative --out=.rex/prd.md planted prose at the preferred legacy path, where the markdown parser then throws and blocks every rex command on that checkout.

The guard now refuses anywhere inside .rex/, which is correct rather than merely wider: nothing is ever legitimately exported into the PRD storage directory. The bundle and narrative carve-outs in the project guidance are reworded to match what the code actually enforces.

fix(core): kill only the process listening on a busy port, never a client

killPortOccupant selected its victim with lsof -ti tcp:<port> and took the first pid. That query lists every process holding a socket on the port — CLIENTS included, not just the LISTEN socket — and lsof prints pids in ascending order, so any older local process with a live or CLOSE_WAIT connection to the dashboard ranked above the listener and was SIGKILLed in its place. Observed while writing tests/e2e/cli-start-two-projects.test.js: a vitest worker still holding a CLOSE_WAIT socket 50 ms after its last /api/status request was killed instead of the server, which reported as Worker exited unexpectedly with no assertion, no attribution, and four orphaned dashboards left behind. Outside tests the candidate victim is a browser tab, a curl, or another CLI connected to the dashboard.

The POSIX query is now lsof -t -sTCP:LISTEN -i tcp:<port>, so a client socket is not a candidate. The win32 netstat branch already matched on LISTENING and is unchanged apart from collecting every match rather than the first.

Whom-to-kill is now a separate, pure decision (selectKillTarget) with three refusals, each a case where killing would be a guess: nobody listening, this process among the listeners (the self-preservation guard, which every in-process test of the peer path depends on), and several listening pids — SO_REUSEPORT or a pre-fork server. That last case now fails loudly, naming the pids, rather than killing one at random; ndx start reports the port as uncleared and the operator still has --port=N.

This is the whom-to-kill half of the peer-dashboard work. The /api/status probe decides *whether* to kill, and it cannot protect a client, because a client is not what /api/status describes.

Add a narrative PRD rendering: ndx prd export --format=narrative.

The PRD can now be written out as prose Markdown for a stakeholder rather than as a transport bundle. Epics become sections with a stated goal and a rationale, features become described capabilities, and acceptance criteria become readable sentences under a "How we'll know it's done" heading. Dependencies read as sequencing prose — "This follows on from …" — instead of blockedBy id lists. The default format is unchanged: rex export --out=… still writes the JSON bundle.

No internal vocabulary reaches the page. Item ids, folder slugs, and the raw status and priority values are all omitted by construction: every status and priority is mapped to a phrase chosen to be disjoint from the enum literal it replaces (in_progress reads as "Under way now", high as "Should-have"), so a single regex sweep for the literals is a real proof rather than a spot check. A uuid pasted into a description is resolved to the title it names — or dropped, along with the parentheses it leaves empty, when the title is already in the sentence or resolves to nothing.

--item=<id-or-slug> narrows the document to one subtree, so a single initiative can be handed over without the rest of the PRD. It resolves an item id, an exact title, a folder path, or a directory name copied out of .rex/prd_tree/ — including the -{id6} suffix, which is often the only part of a directory name that still matches after a slug-rule change. An ambiguous reference lists the candidates instead of guessing, and a valueless --item is an error rather than a silent whole-PRD render.

Finished and deleted work is excluded by default. --include-completed restores finished items for a retrospective-style document; deleted items stay out regardless. A finished container that still holds unfinished children is kept as a section — heading and goal only, with no state or criteria — so its children do not lose the context they sit in.

Narrative output is deliberately one-way and is documented as such in the command help, the READMEs, and the PRD-invariant carve-out. The JSON bundle remains the only round-trip surface; nothing parses a narrative document back.

Add a portable PRD bundle: ndx prd export / ndx prd import.

A PRD can now be carried between machines as a single JSON file, without sharing the repo or configuring a remote adapter. rex export --out=<path> serializes the whole loaded document; rex import-bundle --in=<path> rebuilds .rex/prd_tree/ from it. The orchestrator exposes both as an ndx prd subcommand group; ndx export, the static-dashboard exporter, is unchanged.

Fidelity is the point — a round trip preserves item ids, hierarchy, level, status, priority, description, acceptance criteria, tags, blockedBy edges, source, and attribution metadata. Verified against a 1392-item PRD with no field, parent, or membership differences.

The bundle carries the PRD SCHEMA_VERSION. Import gates on it more strictly than the store's read path does: a bundle from a newer rex (newer envelope version, newer schema minor, or a different major) is refused before anything is written, rather than imported partially. isCompatibleSchema keeps its forward-compatible behaviour for ordinary loads.

Import has a defined collision policy instead of last-writer-wins. --merge (the default) is additive: local items keep their content and placement, new bundle items are grafted onto the matching parent, an id that already exists anywhere in the tree is reported rather than duplicated, and ids whose content differs are listed with the local copy kept. --replace discards the local tree and needs confirmation, or --yes when not on a terminal. The tree write runs inside store.withTransaction, so it holds the PRD lock across the whole read-modify-write and cannot lose a concurrent writer's items.

The bundle is a transport artifact, not a PRD backend: writing one inside .rex/prd_tree/ is refused in code, and the PRD invariant — the folder tree is the sole writable PRD surface — is documented with an explicit carve-out.

The rex-side importer is named import-bundle because rex import is a long-standing alias for rex analyze.

Stop prompt-census --write stamping a commit the measurement did not come from.

The census measures the working tree but stamped .git/HEAD, so a recording made from a dirty tree attributed its numbers to a commit that did not contain them. That is how the checked-in baseline came to name 3dda8b5b while containing JSON_OBJECT_ONLY, a constant introduced by 0b57e2eb — one of its own descendants. --compare then reported "(no surface changed)" across a range that had demonstrably changed a prompt, and the wrong SHA was published to the docs site.

Note what would not have caught it: the token counts were right. Only the attribution was wrong, so no numbers-based check could have noticed.

- --write now refuses a dirty tree, naming the reason. --allow-dirty records anyway and marks the stamp <sha>-dirty plus dirty: true, so an override can never be mistaken for a clean recording. The markdown header says so too. - Recordings carry a contentHash over the measured surfaces and skill bodies — provenance that needs no git and cannot disagree with its own contents. - tests/e2e/prompt-census.test.js fails when the repo no longer matches that hash, so a stale baseline cannot merge. Deliberately a content check rather than commit equality: recording dirties the baseline files, so committing them puts HEAD one ahead of the stamp and a SHA assertion would fail after every legitimate re-record. - The dirty check is whole-tree, so an unrelated edit blocks recording. It can report dirty when the measurement would have been faithful, but never clean when it would not — and a false clean is the bug being fixed.

scripts/prompt-census.mjs joins the "Development scripts" entries in the ALLOWED set of tests/e2e/architecture-policy.test.js, since answering "does the tree match HEAD?" from .git/ alone would mean reimplementing git's index and object store, and the mtime shortcut reports clean after a touch.

The baseline is re-recorded from a clean tree in the following commit.

Build rex's and sourcevision's LLM prompts through PromptEnvelope so their cost is attributable per section rather than as one opaque total.

Every prompt in rex/src/analyze/ and sourcevision/src/analyzers/ is now declared as a list of named sections against a shared per-package vocabulary, with the paired *Prompt function reduced to an assembly call. Prompt text is unchanged apart from removed doubled blank lines, where an absent conditional block used to leave its own padding behind — pinned by prompt-text-identity snapshot suites in both packages.

The section-measurement helpers (promptSectionCosts, dominantPromptSections, formatPromptSectionCosts, extractPromptSectionDiagnostics) moved down to @n-dx/llm-client so rex and sourcevision, which sit below hench and cannot import from it, share one implementation instead of a copy; hench keeps only its CLI rendering and reaches the rest through its existing gateway.

The prompt census now follows module-local helper calls when extracting static prompt text. Factoring duplicated text into a helper previously dropped it from the count entirely, so a refactor could silently shrink the baseline. Correcting this raised the recorded totals ~5% with no prompt growing — the earlier figures were an undercount — and the baseline records the revision so the jump is not misread as a regression. The baseline also now reports a per-section breakdown for each envelope-built package.

Pin packages/core/assistant-assets/**/*.md to LF, so a prompt's measured size does not depend on which OS checked the repo out.

.gitattributes already pins .claude/skills/**/*.md — the copies ndx init generates — but not the sources those copies are written from. On a Windows checkout with core.autocrlf=true the sources came out CRLF, one extra byte per line, which inflated every shipped skill body: ndx-adversarial-review measured 20,678 chars against the 20,512 recorded in docs/analysis/prompt-token-baseline.md.

That failed tests/e2e/prompt-census.test.js with a 140-token drift no commit had caused, and the three skills living only in .claude/skills/ measured exactly right — the pin was the difference. Because CI runs on LF it never saw it, so the guard was red on Windows and green in CI on the same commit.

Re-recording the baseline would have been the wrong fix: it would stamp CRLF-inflated figures into the doc and flip the guard on the next platform switch. The tree is renormalized to LF instead, which leaves the index unchanged and stops a Windows publish shipping CRLF skill sources.

fix(core): relocate near the requested port, not into 3117–3200

runWeb's peer-relocation branch called findFreePortInRange(port), whose defaults scan 3117–3200 regardless of what port actually was — the requested port was used only as the value to skip. An operator who set web.port or passed --port to steer around an environment that blocks 3117 (corporate proxy, another service) had that instruction silently overridden: their second project relocated straight back into 3117–3200, typically landing on 3117 itself.

findRelocationPort now scans upward from the requested port first (port + 1 through port + 83, the same width as the default range) and falls back to 3117–3200 only when that neighbourhood is full. For the default port (3117) the near window already covers 3118–3200 exactly, so behaviour there is unchanged.

tests/e2e/cli-start-two-projects.test.js and tests/unit/web-port-occupant.test.js are updated to assert the new contract; the latter adds direct coverage of findRelocationPort's window selection for a port outside the default range, the default port itself, and the fallback path.

Remove instructions that rex prompts already gave elsewhere in the same prompt, and resolve a task-size contradiction between them.

TASK_QUALITY_RULES sized a task at "one focused session (1-4 hours)" while PRD_SCHEMA asked for loe in engineer-weeks, CONSOLIDATION_INSTRUCTION asked for 0.5–4 engineer-week tasks, and decomposition splits anything over taskThresholdWeeks: 2. Nine builders carried the hours figure; eight of those also carried a weeks figure. buildAssessmentEnvelope — the prompt that decides whether to split or merge a proposal — graded week-scale work against the same hour-scale bar, so it recommended break_down on correctly-sized tasks. Sizing is now stated in engineer-weeks everywhere.

Deleted as duplicated within the prompt that contained them: the markdown-fence prohibition (already in OUTPUT_INSTRUCTION), the "tasks need a description and criteria" and "no vague titles" rules (already in TASK_QUALITY_RULES, and restated twice more in consolidation-guard and decompose), the existing-PRD duplicate rule (kept in ANTI_PATTERNS, which reaches every prompt that had both), and AUTO_PLACEMENT_INSTRUCTION's restatement of what existingId does. No instruction was removed from a prompt that did not still state it.

Measured with scripts/prompt-census.mjs: rex drops from 16,121 to 15,217 per-call tokens and 7,473 to 7,195 unique, a monorepo total of -904 / -278. A new prompt-non-redundancy.test.ts suite pins each rule against reintroduction, and both prompt suites now share one fixture list so a new prompt cannot be covered by one and missed by the other.

Also adds .hench/session-cache.json to the ndx init ignore template, matching this repo's own .gitignore.

Scope a PRD bundle to one item: rex export --item=<id-or-slug> --out=<path.json> (also ndx prd export --item=…).

A single epic or feature can now be carried between machines without exporting the whole PRD. The scope is a closure rather than a filter, because a filtered subtree is not importable:

- the requested item arrives with every descendant beneath it, so the fragment is a working subtree rather than a childless stub; - it arrives with the transitive blockedBy closure, so a task blocked by an item in a different epic brings that item along. Keeping the edge without the target would import a dangling dependency; dropping the edge would lose sequencing information; - it arrives with the ancestor containers of everything selected, so import reconstructs the subtree at its original depth instead of re-parenting it to the root. That applies to items the closure itself pulled in, so a blocker from another epic brings its own chain of containers.

Blockers are carried without their own descendants — a blocker is needed as a dependency target, not as a body of work, and expanding it downward would make a scoped export unbounded in practice.

The export summary counts the requested subtree and the closure's contribution separately ("2 requested items … closure pulled in 1 blocking item and 3 ancestor containers"), because a closure can reach well past what was asked for and a scoped export that quietly grows to half the PRD should say so. --format=json reports the same breakdown under a scope key.

Every blockedBy id in a scoped bundle resolves to an item in the same bundle. An edge whose target is missing from the source PRD — already broken before the export — is dropped rather than carried, and reported with the item that held it.

--item resolves through the same resolver the narrative rendering uses, so a uuid and a folder slug name the same item and one flag keeps one meaning. An unknown or ambiguous reference fails before anything is written, so a mistyped slug never leaves a whole-PRD bundle named after the item it meant to scope to.

Give every workflow skill an explicit stopping condition, and stop exempting the repo-local skills from the portability guards.

A skill without a stated ending does not fail loudly — it overruns. The agent finishes what was asked, still holds context and tools, and continues into whatever looks adjacent: fixing the defect it was only asked to report, committing files it was only asked to inspect. Twelve of the thirteen skills ended on their last action with nothing marking it as the last; ndx-adversarial-review was the only one that said where it stopped. Each skill now closes with a ## Done when section naming both the terminating action and what is deliberately out of scope, enforced by skill-termination.test.js.

skill-portability.test.js derived its skill list from the assistant-assets manifest, so iso-map, triage and dev-link — which exist only in .claude/skills/ — were checked by none of its guards. The suite passed anyway, so the gap was invisible. Both suites now enumerate through tests/helpers/all-skills.js, which covers manifest and repo-local skills alike and derives the local set by subtraction, so a new skill of either kind is covered as soon as it appears. The three were already portable; the guard simply did not know they existed.

The prompt census now measures skill bodies (--json, --compare, and a table in the recorded baseline). A skill body enters context whole on invocation, so its size is a per-invocation bill in the same way a builder's fixed text is a per-call one — and it was the one category of prompt text edited by hand most often with no number attached. Recorded at 16,990 tokens across 13 skills, of which ndx-adversarial-review is 5,167. Skill totals are reported separately from per-call totals, never summed: a skill run and an analyze call are different events.

Fix the skill-authoring reference telling authors to write the one commit step CI rejects, and pin the shipped-vs-source difference instead of arguing about it.

SKILLS.md documented the required commit step as git commit -m "$(cat <<'EOF' … EOF)". skill-portability.test.js forbids exactly that construction in skill bodies — heredocs and $(...) are POSIX-only, and Git Bash is not part of Windows, so the step fails on a stock PowerShell at the *last* action of a skill, after all the real work is done. Following the documentation produced a skill that failed CI, and nothing could notice because the guard only read skill bodies, never the reference they are written from. The reference now teaches the file-based form the skills already use (git commit -F), and the guard covers it.

.claude/skills/<name>/SKILL.md is generated as YAML frontmatter followed by the canonical body verbatim, so its line count runs about six higher than assistant-assets/skills/<name>.md. That gap has been read as drift; it is not. It is now asserted rather than assumed: the generated body must be byte-identical to the canonical source, and the frontmatter may carry only name, description, and argument-hint.

That check closes a real gap. assistant-body-drift.test.js compares the committed artifact against what the generator produces today, so the two always agree — including when the generator is what is wrong. A renderer that dropped or reordered a section would have kept every suite green while shipping a skill that no longer matched the file its authors edit.

Add POST /api/sourcevision/ask, answering a question from the existing analysis

The SourceVision Ask panel's server half. The request is { prompt, seed? } validated by a zod schema; the response is { answer, vendor, model, tokens, contextSources }.

Bundle, not a tool-use loop. Context is pre-assembled from the .sourcevision/ artifacts already on disk — manifest, inventory, imports, zones, findings, derived next steps, component count, and a CONTEXT.md excerpt — and sent in a single non-agentic call. A loop that queried lookups on demand would answer a wider range of questions, but at an unbounded number of round trips per question and with no way to test what the model actually saw. A unit test now asserts the assembled facts reach the completion request, which is the property the whole endpoint rests on. Every section is capped and reports what it cut, so the bundle does not grow with the repository until the vendor rejects it as an opaque 400.

Analysis is the only ground truth. The endpoint reads no source, and refuses with no_analysis rather than letting the model answer from its priors when nothing has been analysed. All sourcevision access — including the five artifact schema types the reads are parsed against — goes through server/domain-gateway.ts; the gateway's export cap moved 15 → 16 with that reason recorded.

Named failures, and it cannot hang. Vendor and model come from the project's own config via loadLLMConfig + resolveTaskModel (new sourcevision.ask class, standard tier, reroutable through llm.routes), and the pair that served the call is reported back so the panel never has to guess which model produced an answer. The call races a budget — sourcevision.ask.timeoutMs, default 120s, also passed down so a CLI-mode child bounds itself — and every failure returns a named kind (timeout, rate_limit, auth, network, no_analysis, invalid_request, llm_error) with the vendor's retry delay when it supplied one, instead of a generic 500. A provider that already threw a typed ClaudeClientError is trusted over re-classifying its message, so a 429 the provider knew about is never downgraded to unknown.

The task-class registry contract test now scans web as well as the three domain packages: web declares classes now, and an unregistered one there resolves silently to the standard tier exactly as it would anywhere else.

State the enrichment output contract once instead of four times, and fix the copy that had already drifted.

Four builders across enrich-batch.ts and enrich-per-zone.ts each spelled out the response format for themselves — the severity enumeration, the "respond with only JSON" instruction, and the later-pass "add only new insights" rule. Written out four times, they had diverged: buildSingleZoneLaterPassEnvelope asked for "Respond with ONLY a JSON object:" where the other three asked for "...(no markdown, no explanation)", so one enrichment path never forbade markdown at all.

That drift was survivable rather than harmless — tryParseJSON strips fences before parsing, so a fenced response was recovered — which is precisely why nothing caught it. It still spent output tokens on a path that had asked for none, on a prompt that runs once per zone.

The contract now lives in prompt-envelope.ts as JSON_OBJECT_ONLY, ONLY_NEW_INSIGHTS, and findingsContract(withCategory). The category clause is the one intended difference between batch and per-zone prompts — per-zone is the minimal fallback path and classifyFinding derives a category from the finding text when the model omits one — so that difference is asserted rather than left to be rediscovered as drift.

sourcevision drops from 2,332 to 2,194 unique fixed tokens. Per-call is essentially unchanged: the emitted text is byte-identical for every batch prompt (the prompt-text-identity snapshots pass untouched), and the one deliberate change adds the missing no-markdown clause to the per-zone later pass.

Also records why buildReviewerPrompt lives in packages/core/. The orchestration-tier rule is spawn-only, and it constrains *imports*, not string composition: pair-programming.js imports nothing but Node built-ins and its core siblings, then hands the prompt to a spawned CLI. Moving it into a package would force core to import it, which is the thing the rule actually forbids.

ndx start asks who is on the port before killing it, and steps aside for another project's dashboard.

The PID file runWeb consults lives inside the directory it was given, so a second project or worktree had no entry there. A busy 3117 therefore read as a stranger squatting on the port and killPortOccupant SIGKILLed it — including when the occupant was another project's working dashboard. The server's own fallback allocator (findAvailablePort, 3117–3200) never got a chance to run, because the orchestrator killed first.

A busy port is now probed with a 1.5 s GET /api/status before anything is killed:

- an n-dx dashboard reporting a different projectDir is left alone, and this invocation moves to the next free port in 3117–3200; - an n-dx dashboard reporting this directory is restarted as before — that is ndx start's documented idempotency, reached here when the PID file is missing; - anything else, including a dashboard too old to report projectDir, keeps today's behaviour exactly. An inconclusive probe never widens the kill.

GET /api/status gained a top-level projectDir so the probe has something to attribute the server by. Purely additive.

Fix ndx start's port-occupant self/peer check so a symlinked project path (or, on Windows, a different drive-letter/casing spelling) is recognised as the same directory instead of a peer.

classifyPortOccupant compared resolve()d paths, which normalises separators and .. segments but does not resolve symlinks and does not case-fold. A directory reached through a symlink (e.g. ndx start ~/work where ~/work links to the directory already serving 3117) therefore compared unequal to itself, was classified as a peer, and runWeb relocated to a second port instead of restarting — leaving two dashboards live against the same .rex/prd_tree/. Foreground ndx start never writes a PID file, so this comparison is the only thing guarding idempotent restart in that case.

Both sides are now canonicalised with realpathSync.native (falling back to resolve() when the path no longer exists, so a deleted directory degrades to the old lexical comparison instead of throwing).

Stop the pre-run git gate counting the warm-parent session cache as operator work.

.hench/session-cache.json is rewritten on every orientation, but it was absent from HENCH_RUNTIME_GITIGNORE_ENTRIES and from both ignore lists, so git add -A in the pre-run commit gate swept it into commits — and a later run then saw its own write as one uncommitted file and refused to start. It is now ignored, discounted by the gate, and written by hench init. The ignore template test additionally pins every declared runtime artifact to both ignore files, so the constant can no longer drift away from them.

0.5.2

Patch npm ↗
  • Add a distilled repo primer and prefer it over CONTEXT.md as agent startup context. #346
  • Add .hench/reviews/ to the ndx init ignore template, and retire its stale .rex/prd.json.lock entry. #346
  • The isometric map can now show the two things an import graph structurally cannot: injection seams and runtime infrastructure. #350
  • Iso map: one implementation, several gaps closed. #350
  • New opt-in sourcevision iso command renders .sourcevision/iso-map.html: a standalone, dependency-free isometric map of the codebase. Each architectural zone becomes an extruded 3D block — footprint from file count, height from line count, colour from its dominant archetype — with import relationships drawn as connectors, click-to-inspect detail panels (metrics, insights, findings, key files, cross-zone edges), pan/zoom, and a legend that filters by kind. Never generated by analyze. #350
  • Register MCP servers against the project being initialised, not the caller's working directory. #346
  • Expose the task-routing config surface in ndx config and the dashboard. #346
  • Add the warm-parent session foundation: --fork-session support, the orientation session cache, and the session-strategy config keys. #346
  • Bound the spawns one task can make, and retry by resuming the failed session rather than cold-restarting it. #346
  • List the known task classes in ndx config --help, and tell users when a route class looks mistyped. #346
  • Fork task spawns from a warm orientation session instead of cold-starting every task. #346
Full release notes

Add a distilled repo primer and prefer it over CONTEXT.md as agent startup context.

ndx work pipes CONTEXT.md plus a PRD excerpt into every task spawn. Even capped, that document is written for breadth — zone metrics, findings, route tables, import summaries — while a task starting work needs where things live, how to build and test, and what the conventions are. It needs that on every task and every retry, which is what makes distilling it worth one LLM call.

sourcevision analyze now writes .sourcevision/PRIMER.md, and the orchestrator reads it in preference to CONTEXT.md, falling back silently when absent. The distiller lives in sourcevision rather than beside the pipe it feeds: orchestration is only allowed to spawn CLIs, and sourcevision already owns artifact generation, content-hash caching, and the one callClaude choke point with task-class routing. A side benefit is that the primer is now an artifact any consumer can read.

Everything about it fails soft. The primer is cached against the analysis fingerprint, so it is regenerated only when the repository is re-analysed. Generation is skipped entirely unless the analysis already made successful LLM calls — the vendor and auth-mode getters both fall back to defaults when nothing is configured, so consulting them would have this attempt a spawn in every environment without a model, including CI. Output shorter than 200 or longer than 12,000 characters is rejected rather than truncated, because a primer cut mid-sentence would be inherited by every task in the loop while a missing one simply falls back to CONTEXT.md.

Add .hench/reviews/ to the ndx init ignore template, and retire its stale .rex/prd.json.lock entry.

The adversarial-review pass writes .hench/reviews/<run-id>.json, and hench's pre-run gate commits with git add -A, so a project that pasted the template into its .gitignore would commit machine-local review reports on the next run. The template also still named .rex/prd.json.lock, which FileStore stopped writing once both stores moved to prdLockPath() — now .rex/*.lock, which covers the folder-tree lock that is actually created.

Nothing tested the template, which is how both entries drifted. It is now pinned against this repo's own .gitignore, so a runtime artifact added on one side and missed on the other fails a test instead of shipping.

The isometric map can now show the two things an import graph structurally cannot: injection seams and runtime infrastructure.

Injection seams. A callback or event seam runs the opposite way at runtime from the import that static analysis sees, so the map used to draw an arrow that was backwards for the behaviour people care about. Seams declared under sourcevision.isoMap.injectionSeams in .n-dx.json are now drawn in the runtime control-flow direction, in a distinct colour, with a panel listing the injected callbacks and stating plainly that the relationship was declared rather than inferred. from/to accept a zone id, a file path or a directory prefix.

Runtime infrastructure. Queues, buckets, caches and databases have no import signature at all. Terraform resource blocks are now scanned and classified, and anything IaC does not cover can be declared under sourcevision.isoMap.infrastructure. Both render as a trailing column, attributed to the zones whose source names them — weaker evidence than an import, and the panel says so. Resource types with no architectural meaning (IAM roles, security groups) are skipped, and names too short or too generic to match on are refused.

A declaration that cannot be drawn — both ends inside one zone, or naming a file no zone owns — is reported in the page footer rather than silently dropped.

Iso map: one implementation, several gaps closed.

The standalone skill script is now generated from packages/sourcevision/src/export/ by scripts/build-iso-skill.mjs rather than hand-maintained, so the map has a single source of truth; tests/e2e/iso-skill-drift.test.js fails if the committed bundle goes stale, and also executes it. This removed a real divergence where the two copies disagreed on zone colours because one counted archetypes before mapping them to kinds and the other after — kinds are now resolved per file and counted once, which answers "what does this zone do" rather than "what is its most common file type".

Also: the project scanner moved into the package (iso-scan.ts) and gained tsconfig paths, workspace-package and Go go.mod resolution, so a monorepo's own packages stop looking third-party; call-graph data, when present, adds per-edge runtime call counts with a Weight: imports/calls toggle and surfaces call-only edges as injected seams; output is reproducible (timestamps default to the HEAD commit time); key files link to source via the git remote; multi-layer edges route through corridors between rows instead of cutting through blocks; and the page gained a light theme, reduced-motion support, kind glyphs alongside colour, a skip link, and a tab order of one stop per zone instead of one per connector.

New: ndx iso, sourcevision iso --source=scan, and GET /api/iso-map in the web dashboard.

Register MCP servers against the project being initialised, not the caller's working directory.

registerMcpServers shelled out to claude mcp remove / claude mcp add without a cwd, so the child inherited whatever directory the shell was in. Local scope — the default for claude mcp add — is stored per-directory, so cd ~/repoA && ndx init ~/repoB stripped repoA's rex and sourcevision registrations and replaced them with entries pointing at repoB. Both the remove loop and the add now run with cwd set to the resolved project directory, and the add states --scope local explicitly rather than relying on the default.

This surfaced as the E2E suite destroying developers' own registrations: an init test's temp directory ended up registered in ~/.claude.json under the repo the suite was launched from, leaving two MCP servers pointed at a deleted path. Recovery for an already-clobbered config is documented in TESTING.md under "Family 5".

Expose the task-routing config surface in ndx config and the dashboard.

ndx config now validates llm.tiers.<vendor>.<tier>, llm.routes.<class>, llm.effort.<class>, and llm.escalation.*, and documents them in --help along with the fact that top-level llm.model is a standard-tier shorthand rather than a global override. The dashboard's LLM config route accepts the same keys, plus llm.model itself — writable via the CLI but previously absent from the route's allowlist, so the field with the highest precedence over the model actually used was invisible in the UI.

Validation is deliberately asymmetric. Values are checked strictly, and so is the shape of a tier path: an unrecognized vendor or tier there is a typo, never a feature. Route and effort *class names* stay open, because glob keys (prd.*, *) are the documented routing design and this layer cannot see the task-class registry without importing across a tier boundary.

Both surfaces also had to learn that task classes contain dots. Since config paths use dots as separators, llm.routes.agent.execute would otherwise write a nested {agent: {execute}} object — which the flat-map extractor in loadLLMConfig silently ignores, making the setting appear to work while changing nothing. Those two sections now treat everything after the section name as one literal key, on both the read and write paths.

Add the warm-parent session foundation: --fork-session support, the orientation session cache, and the session-strategy config keys.

The Claude CLI adapter gains forkSession, emitting --fork-session after --resume so a spawn can inherit a parent transcript under a new session id without mutating the parent. Forking without a session to fork from is suppressed rather than passed through — it would claim a fork that never happened.

A new agent/lifecycle/session-cache.ts owns which orientation session exists and whether it is still safe to fork. Finding a parent is permissive (absent, unreadable, or corrupt cache files are all simply a miss, costing one orientation spawn); *using* one is strict, because a stale hit would have every task in a loop inherit an orientation describing a repo that has since changed. A parent is rejected, with a named reason, when the sourcevision analysis fingerprint changes, when it ages past hench.parentMaxAgeHours, when the vendor or model differs from the one it was built under, or when --fresh is requested.

New config: hench.sessionStrategy (fork | batch | cold), hench.tasksPerSession (default 4), and hench.parentMaxAgeHours (default 24), documented in ndx config --help. Strategy resolution degrades rather than errors: forking needs a CLI that resumes by session id, so other vendors and provider=api resolve to cold.

No spawn behavior changes yet — the orientation pass and fork wiring that consume this land next.

Bound the spawns one task can make, and retry by resuming the failed session rather than cold-restarting it.

Breaking-ish behaviour change: plan-mode re-spawns now consume the retry budget. Previously they were a separate per-attempt allowance, so four retries against up to three plan re-spawns each could reach twelve cold spawns for a single task — every one re-paying the harness prompt, the project instructions, and the repo re-exploration. Making them additive means a task that spends its budget entering plan mode gets correspondingly fewer failure retries than it did before.

A hard ceiling sits on top of the retry budget, defaulting to 8 and configurable via hench.maxSpawnsPerTask. The two layers are not redundant: some re-spawn paths deliberately *avoid* charging the retry budget, because nothing was learned about the task — a plan-mode interception, the stale-parent fork fallback. The ceiling counts every spawn regardless of why it happened, so no future re-spawn path can reintroduce unbounded multiplication by simply not asking. It is checked before spawning, so it refuses to spend rather than reporting that the spending already happened, and hitting it fails the task with the full breakdown.

Transient failures on the Claude CLI now retry by resuming the failed session — a plain resume, not a fork, since branching off the failure would leave the retry without the transcript it exists to continue. The cold-restart retry notice is suppressed on those retries: a resumed session *was* the previous attempt, so telling it that files from a prior attempt still exist and to check the current state before redoing work restates what it just did, and grows the prompt on every retry. Vendors with no resume on this path keep cold retries and keep the notice.

Runs now record spawnCount and spawnBreakdown, so ndx usage can report retry overhead — a task that took four spawns to succeed reads differently from one that took one, and six plan re-spawns call for a different fix than six failure retries.

List the known task classes in ndx config --help, and tell users when a route class looks mistyped.

llm.routes.<class> and llm.effort.<class> accept any class name on purpose: glob keys (prd.*, *) are a routing feature, and a class added to the registry in a newer release has to keep working against an older CLI. The cost of that openness was silence — ndx config llm.routes.agent.exceute heavy was accepted, written, and matched nothing, so the user saw success and got the old model.

Setting an unrecognized class now prints a note saying so, naming the closest known class when one is within an edit distance of three. Three catches the realistic typos — a transposition, a dropped or doubled character, a wrong suffix — without pointing zone.enrich-scan at code.classify; beyond it the note still appears and only the suggestion is withheld. It remains advisory in every case: the value is written, the exit status is unchanged, and globs and known classes stay silent.

ndx config --help now lists all nineteen classes grouped by package with the tier each routes to by default, so the set is discoverable without reading source.

The class list is duplicated from @n-dx/llm-client's DEFAULT_ROUTES rather than imported, because orchestration-tier scripts must not import from packages — the same reason LLM_VENDOR is declared locally in that file. A new integration test fails when the copy drifts, checking not just the keys and tiers but that no known class is ever reported as unknown: that is the failure drift produces, and it hands the user advice that is exactly backwards.

Fork task spawns from a warm orientation session instead of cold-starting every task.

A cold task spawn spends its first turns rediscovering the repo — layout, build and test commands, conventions — and pays that again on every retry and every task in a --loop. Hench now runs that once, in a read-only orientation session, and spawns each task as a fork of it (--resume <parent> --fork-session), so tasks arrive already oriented and every fork presents the same prefix.

The orientation prompt is deliberately task-free: mention one task in it and every fork gets a different prefix, and the first task's framing leaks into the rest of the loop. Orientation is also read-only three times over — stated in the system prompt, restated in the task prompt, and spawned in plan mode — because that transcript is inherited by everything downstream.

cliLoop runs once per task, so the cache, not loop plumbing, is what makes orientation happen once per loop; it also persists across separate ndx work invocations within the TTL. ndx work --fresh discards it, applied once at the start of a run rather than per task, so a loop re-orients exactly once.

Two failure modes are handled deliberately. Orientation never fails a run: a spawn that errors, throws, or reports no session id simply yields no parent and tasks spawn cold. And because a cached parent is validated against its own metadata rather than the vendor's session store, a parent the CLI has since forgotten would otherwise fail every task in the loop — so the first failed fork drops the cache, disables forking for the rest of the run, and re-spawns cold without consuming retry budget.

Runs record parentSessionId when they forked, making the saving auditable. Forking requires a CLI that resumes by session id, so other vendors and provider=api continue to spawn cold.

0.5.1

Patch npm ↗
  • Assisted skill runs now record what they cost, instead of zero. #332
  • ndx config <key> no longer lets a same-named directory shadow the key at the pre-dispatch layer. #343
  • Stop a directory from shadowing a config key in ndx config #332
  • Correct stale zone-governance documentation and stop leaking n-dx internals into generated instruction files #339
  • Add Gemini support to the dashboard LLM Provider view, and complete the documentation cleanup #339
  • The skill bodies' POSIX timestamp example works on BSD date now. #343
  • Document what the assisted-run token capture added, and the flake family it exposed. #332
  • Attribute every commit n-dx creates. #332
  • ndx init now registers the rex-prd merge driver. #343
  • Add the /ndx-adversarial-review skill: review by attack rather than by inspection. #332
  • Add ndx work --review: an adversarial review pass that runs after a task's changes validate and before the commit prompt, so must-fix repairs ship in the same commit as the work they repair. #342
  • Stop resolving a tool subcommand as the project directory. #332
  • Skill commit steps no longer prescribe a POSIX-only heredoc. #343
  • File PRD items against add_item's real fields in every skill that creates them. #332
  • Scope every skill's run record to the skill's own work. #332
  • ndx start stop no longer warns that a server "did not exit" when it did. #332
  • Update LLM model catalogs to current vendor releases #339
  • Add --verbose/--debug live progress across ndx init and sourcevision analyze, and replace scattered vendor string literals with shared LLM_VENDOR constants. #331
  • Review follow-ups on session usage recording and the adversarial-review skill. #332
Full release notes

Assisted skill runs now record what they cost, instead of zero.

hench record writes the run entry for work driven through a skill rather than a spawned agent. Those entries carried empty token usage, on the stated grounds that "Claude Code does not expose its own token consumption to the running skill". That holds for the tool surface and not for the filesystem: Claude Code writes a JSONL transcript per session in which every assistant message carries the API's usage object, and it exports CLAUDE_CODE_SESSION_ID to the tools it runs. So the numbers were readable all along, and ndx usage plus the dashboard's per-item rollup were under-reporting every skill-driven task by its entire cost.

Usage is now read from that transcript by default. Two things make the attribution honest rather than merely non-zero:

  • Only the delta. One session routinely completes several tasks — the session this was built in completed four — so a per-record session total would count the same tokens once per task. A watermark per session lives in .hench/usage-cursors/, and each record claims only what accumulated since the last one. It survives transcript compaction by falling back from message uuid to a count, and says when it did.
  • Only after the work started. The watermark cannot help the FIRST record in a session, which would otherwise claim everything spent before the task began. Measured against a live session: 549 messages and 127M cache-read tokens for one task. --startedAt now doubles as the earliest spend a record may claim, and /ndx-work captures it when it marks the task in progress.

Precedence is explicit --input-tokens/--output-tokens/--cache-*-tokens flags, then the transcript, then zeros. A missing or unreadable transcript never fails the record — an unrecorded run is worse than one missing its tokens — and the command reports which happened. --no-tokens opts out; --session and --transcript override discovery.

The assisted flag keeps its meaning as provenance (skill vs agent) rather than "no usage", and turns is now the transcript's message count, which is a real API-call count.

Skills that mutate state — /ndx-work, /ndx-capture, /ndx-plan, /ndx-reshape, /ndx-config — record their runs as a documented step. Planning-style skills record against skill:<name>, which get_token_usage reports in its existing orphans bucket: work that produced many items should not be charged to one of them.

Also fixes a test-isolation hazard this created. CLAUDE_CODE_SESSION_ID is exported to pnpm test as well, so the suite began reading the ambient live transcript and asserting against numbers that change between runs — green in CI, unreproducible locally. tests/setup-session-env.js clears it in every worker, the same shape as the existing setup-color-env.js and for the same reason.

ndx config <key> no longer lets a same-named directory shadow the key at the pre-dispatch layer.

The config handler already resolved the ambiguity correctly — a known config key beats a directory, because a key is an exact match against a closed set while a directory name is arbitrary — but the pre-dispatch resolver (resolveExistingDir, which decides where to read .n-dx.json and check initialization) still used disk existence alone. In a project containing a hench/ subdirectory, ndx config hench read config from ./hench (silently dropping command timeouts and experimental flags, since the missing .n-dx.json falls back to {}) and printed "Project setup incomplete" for a fully initialized project.

resolveExistingDir now accepts a skip predicate and, for the config command, applies the handler's own exported isConfigKey tiebreaker — so both layers agree. ./hench and ../hench remain unambiguous ways to name the directory, and a trailing directory argument in ndx config <key> <value> <dir> still resolves.

Stop a directory from shadowing a config key in ndx config

n-dx config [dir] and n-dx config <key> occupy the same positional slot, and the tie was broken by asking the filesystem: if the argument named something that existed, it became the directory. In a project that happened to contain a subdirectory named after a config section, that silently discarded the key — ndx config hench in a project with a hench/ directory read the config of ./hench, found none, and reported a fully-initialized project as uninitialized.

A known key now wins, because a key is an exact match against a closed set (PROJECT_SECTIONS, the package names, and language) while a directory name is arbitrary. ./hench and ../hench still unambiguously mean the directory: their first dot sits at index 0, so the root segment is empty and never matches a section. A positional that is not a known key is resolved as a directory exactly as before, so ndx config ./some/project is unaffected.

Correct stale zone-governance documentation and stop leaking n-dx internals into generated instruction files

The zone fragility tables named six zones that no longer exist under those IDs (web-shared, crash, viewer-ui-hub, prd-fix-command, chunked-review, hench-agent) with metrics that no longer match any analysis. Because CLAUDE.md is generated from assistant-assets/, those n-dx-specific zone names and numbers also shipped into every ndx init target.

The generated surfaces now carry only the threshold rule plus instructions to read live values; n-dx's own measured inventory moved to ZONES.md, which is repo-internal and not templated downstream.

Add Gemini support to the dashboard LLM Provider view, and complete the documentation cleanup

The dashboard offered claude / codex / local only, so a project configured with llm.vendor google could not see or edit its model settings there and llm.google.* was absent from the config API response. Gemini is now a first-class vendor in that view.

Also completes the outstanding documentation findings: removes the removed prd.md + prd.json dual-write architecture from the rex README (including an unreplaced ![img_here](img_here) placeholder that shipped to npm), corrects the Node floor to match engines: >=22, completes the command references, and deletes or archives superseded docs.

The skill bodies' POSIX timestamp example works on BSD date now.

Every recording skill's first step named date -Is as the POSIX example — GNU-only. On macOS (BSD date), the platform this project is primarily developed on, it exits 1 with invalid argument 's' for -I. All 18 skill-body copies now prescribe date -Iseconds, valid on both GNU and BSD date, and tests/e2e/skill-run-recording.test.js rejects the bare form so it cannot return (with the lookahead that keeps date -Iseconds itself legal).

Document what the assisted-run token capture added, and the flake family it exposed.

.hench/usage-cursors/ now appears in the key-files table (project-guidance.md, so both CLAUDE.md and AGENTS.md carry it) and in the gitignore guide's copy-paste block and per-path table — it is machine- and session-local, and committing one collides between machines and puts a session id in history.

The skills guide gains a shared section on what every state-mutating skill does at the end. The per-skill step lists are summaries that omit the commit step, so adding a lone "record the run" step to each would have implied they were exhaustive; the shared note covers both, including why planning-style skills record against skill:<name> and land in get_token_usage's orphans bucket.

TESTING.md gains Family 4 — Ambient environment leaking into the suite, which is the pattern behind three separate defects rather than one: FORCE_COLOR (24 failures across 8 files), whether sh resolves on PATH (21 failures across 5 files plus 5 vacuous passes), and CLAUDE_CODE_SESSION_ID (the suite reading a live, growing transcript). CI set none of them, so all three were green in CI and red only for humans. The rules record the setupFile mechanism, why a genuine environment dependency gets guarded rather than removed, and that an ambient dependency which breaks assertions usually also satisfies some for the wrong reason — so an audit should look for the vacuous passes too. The section intro said "Two failure families" while three were documented below it.

Attribute every commit n-dx creates.

Three commit sources omitted the Co-Authored-By: En Dash's n-dx <n-dx@endash.us> trailer: /ndx-adversarial-review's commit step, the dashboard deploy commit in export.js, and the chore: n-dx init baseline commit in git-preflight.js. The trailer is what routes a commit to the n-dx identity — merge-history.ts parses it for the dashboard's merge graph and GitHub reads it for the contribution graph — so those commits were invisible to both, silently.

The two source-level commits now build their message with buildCommitMessage() from the new packages/core/commit-trailers.js, tagged export/dashboard and init/baseline. The skill gained the same HEREDOC commit step the other file-modifying skills use.

The root cause was documentation: SKILLS.md rule 2 showed a bare git commit -m "<skill>: <desc>" with no trailers, so a skill written against the documented rule came out wrong. Rule 2 now shows the trailer-bearing HEREDOC as the canonical template, and the N-DX* namespace is documented — N-DX: for what produced the commit, N-DX-Item: for which item, N-DX-Status: for what changed. They are three keys with distinct meanings, not variants to unify.

Skills now declare "commits": true in the manifest, and tests/e2e/skill-commit-isolation.test.js classifies on that flag instead of a hardcoded array. Deriving the classification from body content would have made the read-only assertion tautological; reading declared intent means a skill that commits without declaring it fails loudly. A further assertion pins core's trailer string byte-identical to hench's, since the orchestration tier cannot import from packages and the string is necessarily duplicated.

ndx init now registers the rex-prd merge driver.

Alongside the existing .gitattributes EOL pins, init appends .rex/prd_tree/** merge=rex-prd (same idempotent pattern-keyed mechanism — a user's own line for the pattern wins) and, inside a git repository, registers merge.rex-prd.name and merge.rex-prd.driver (rex merge-driver %O %A %B) in git config. An already-set driver — including a user-customized command — is left untouched, re-running init changes nothing, and outside a git repo the registration is silently skipped: init never fails over it. Together with the rex merge-driver command this makes PRD tree merges three-way and frontmatter-aware out of the box.

Add the /ndx-adversarial-review skill: review by attack rather than by inspection.

Invoked bare it attacks the working or branch diff; given a task ID it attacks the claim that the task is done, criterion by criterion; given a name or topic it finds the matching PRD item and confirms it with the user before starting. Since rex exposes no search tool, that resolution enumerates .rex/prd_tree/ — whose directory names are the item slugs — rather than guessing IDs, the same technique the duplicate check uses.

The skill runs two passes. Pass 1 works a fixed rubric — unimagined inputs, failure paths, concurrency, platform, contract drift, test quality, the acceptance criteria themselves — and drops any finding it cannot give a concrete trigger or cannot defend against its own refutation attempt. Pass 2 then asks whether each survivor is worth acting on at all: reachable by a real caller, already covered upstream, worth what the fix costs, and in scope for this change. A real defect that lands on "not worth fixing" is reported as such rather than inflated into work.

Ground truth includes the project's own checks, discovered rather than assumed — .rex/workflow.md, the manifest scripts, or the CI config name them, and only read-only ones are run. A red result is a finding whose repro is already written; a green one bounds the review without ending it.

Nothing is written until the user rules on the findings. Approved findings are then checked against what the PRD already tracks — matched on the defect rather than the wording — so a repeated review does not bury the original item under near-duplicates: an already-tracked finding is either extended via edit_item, when the review has something new to say, or skipped with the existing item's ID reported. Genuinely new findings become items carrying the failure scenario, candidate solutions, and failing acceptance criteria.

The skill never edits source or applies a fix. Fixing an approved item is a separate /ndx-work run.

The run it records is scoped to itself: Step 1 notes an ISO-8601 timestamp before reading anything and Step 7 passes it as --startedAt, so a review invoked partway through a long session claims only what the review spent rather than everything that came before it.

Captured items are filed against add_item's real parameters rather than described in prose, so acceptance criteria reach the acceptanceCriteria array where verify_criteria and the dashboard can read them, and severity maps one-to-one onto priority instead of being buried in the description.

Claim mode calls verify_criteria with runTests: false, so resolving the target reads the criteria-to-test mapping without spawning the project's configured test command — a command that step has not discovered or vetted. The checks discovered in Step 2 are stated to be the only ones permitted to execute tests.

Diff mode resolves the default branch with git symbolic-ref --short refs/remotes/origin/HEAD rather than assuming main, and asks which branch to compare against when origin/HEAD is unset — so the skill's primary entry mode works in a repo on master or develop instead of failing with "fatal: ambiguous argument".

Add ndx work --review: an adversarial review pass that runs after a task's changes validate and before the commit prompt, so must-fix repairs ship in the same commit as the work they repair.

--review changes meaning; the old gate is now --approve-diff. The flag previously showed the diff and prompted for approval. That gate is unchanged apart from its name — pass --approve-diff to get it. The two are independent and compose: the review pass runs first, so a human answering the diff prompt sees the repaired tree rather than the one the implementer left behind. Runs that pass --review print a line saying where the old behavior went.

The reviewer resumes the work session. On the Claude CLI the pass re-enters the session that just did the work (--resume <session-id>, captured from the session_id that --output-format stream-json stamps on every line) and runs it on a stronger model. That inherits what the diff cannot show: which approaches were tried and abandoned, which files were read and found irrelevant, what the implementer believed it was doing. Vendors whose CLI has no resume equivalent — and any run where the session id never arrived — fall back to a fresh reviewer seeded with the task, its acceptance criteria, and the change's scope.

Resuming invites anchoring, so the reviewer's system prompt is built against it: prior reasoning in the conversation is named as evidence under test rather than a position to defend, and every finding must carry inputs-to-wrong-result concrete enough to be refuted. Findings that cannot be triggered are dropped rather than softened.

Review gets its own model tier, REVIEW_MODELS. Review is read-heavy and judgment-dense but short — one diff, one pass — so its token volume is a fraction of the run it audits and a stronger model costs little in absolute terms. Claude defaults to claude-opus-5 ($5/$25 per MTok): Opus-tier reasoning at the same input price as Opus 4.8, where claude-fable-5 would cost twice as much for a single pass. Codex and Google resolve to their existing top tier; local uses whatever is loaded.

Resolution is --review-model → llm.<vendor>.reviewModel → llm.reviewModel → the vendor default. llm.model and llm.<vendor>.model are deliberately excluded: inheriting the execution model would mean a project that pins a cheap executor silently gets a cheap reviewer, which defeats the reason the tier exists. --review-model without --review is an error rather than a no-op.

Findings are triaged, not just listed. Autonomous runs apply the verdict policy directly — must-fix is repaired in-session with the test that would have caught it, should-fix and out-of-scope are captured as rex items after checking .rex/prd_tree/ for an existing item describing the same defect, and not-worth-fixing is reported with its reason. Interactive runs still stop and ask before writing anything to the PRD. The reviewer is barred from committing, from changing task status, and from any command that rewrites analysis or PRD state concurrently with the run.

A broken review never fails a valid task. By the time the pass runs, the task's own completion validation has already passed, so a reviewer that dies, writes nothing, or writes something unparseable tells us nothing about the work — the failure is reported and the run continues. The distinction is preserved on the run record (run.review) rather than left in terminal scrollback, because a review that silently did not happen must not read as one that found nothing. Report transport is a JSON file under .hench/reviews/<run-id>.json, keyed by run so a re-review keeps both, and cleared before each pass so a stale report can never be read as the current one. Unknown enum values in a report are coerced toward alarm — an unrecognized severity becomes critical, an unrecognized action becomes failed — so a garbled field demands attention instead of reading as clean.

The pass requires the CLI provider and errors out on provider=api rather than accepting the flag and doing nothing. Its token usage is charged to the run it reviewed, so ndx usage reflects what --review actually costs.

Stop resolving a tool subcommand as the project directory.

Before dispatch, main() infers a directory to read .n-dx.json from and to check whether the project is initialized. It used the same last-positional rule the command handlers use — but a tool-delegation call still carries its subcommand in those args, so ndx hench record --task=X --status=completed resolved the project directory to ./record, and ndx rex status --format=json to ./status.

Two things followed, neither of them loud. checkProjectStaleness looked for .rex, .hench and .sourcevision under a path that does not exist and printed "Project setup incomplete — run ndx init to initialize" in a fully initialized project. And loadProjectConfig read .n-dx.json from that same path and fell back to {}, so commandTimeouts and BETA experimental flags silently stopped applying to any ndx rex|hench|sourcevision|sv <subcommand> call without a trailing directory. The delegated tool itself was unaffected — it resolves its own directory — which is why the symptom was a false warning and dropped config rather than a failed command.

That call site now uses resolveExistingDir, which accepts a positional only when it names a real directory. This is safe there specifically because the result is never an operation target: a path that does not exist has no config to read, so falling back to the cwd loses nothing. The 25 handler call sites keep the existing rule, so a directory the user intends to create — ndx init newdir — still resolves as before. The stricter rule also fixes the same misfire for a non-path positional, such as ndx rex add "some description".

Skill commit steps no longer prescribe a POSIX-only heredoc.

Five skill bodies (ndx-adversarial-review, ndx-capture, ndx-config, ndx-plan, ndx-reshape) built their commit message with git commit -m "$(cat <<'EOF' … )" — a construct that does not exist in PowerShell or cmd.exe, and Git Bash is not part of Windows (it arrives with Git for Windows, whose usr/bin is not on PATH outside Git Bash itself). The failure landed at the skill's LAST step, after all real work was written; worse, an assistant improvising around the parse error could drop the Co-Authored-By trailer, which fails silently — the commit lands but vanishes from the dashboard's merge graph.

The bodies now instruct the assistant to write the message with its file-writing tool to a scratch file and run git commit -F <file> — no shell quoting anywhere, so the trailer bytes survive in every shell. Repeated -m flags are explicitly named as unsafe (git's blank-line joining splits the trailer block). tests/e2e/skill-portability.test.js now rejects cat << and $(cat in any skill body, so a sixth copy cannot creep in.

File PRD items against add_item's real fields in every skill that creates them.

/ndx-plan, /ndx-capture, and /ndx-reshape described item content in prose — "create it with appropriate descriptions, acceptance criteria, and parent placement" — without naming the parameters those map to. Two things went wrong as a result. Acceptance criteria landed in description prose while the acceptanceCriteria array stayed empty, and that array is what verify_criteria and the dashboard's requirements view read, so the criteria could never be mapped to tests or checked by a later review: the item looked complete while being quietly unverifiable. And level, which is required with no default, was guessed per run, so items of the same kind landed at different levels.

Each skill now names the fields it actually needs rather than carrying a copy of the same table. /ndx-plan — which files in bulk and had no coverage at all — gained the full mapping including priority and source. /ndx-capture already handled level, parent, and priority through its own steps, so it gained the acceptanceCriteria and source guidance it lacked. /ndx-reshape creates containers rather than work items, so it gained explicit level and parentId for those, and the criteria rule for the rarer case where a container has a testable outcome of its own.

A new test, tests/e2e/skill-item-fields.test.js, derives its skill list from the manifest, so a future skill that creates items is covered the moment it is added.

Scope every skill's run record to the skill's own work.

/ndx-capture, /ndx-plan, /ndx-reshape, and /ndx-config all ended with ndx hench record but never captured a start time, so none could pass --startedAt. Without it the first record in a session has no watermark to work back from: readUsageDelta opens its window at the top of the transcript and the record claims every token the session spent before the skill was invoked. A /ndx-capture run measured while fixing this claimed 21,343,032 tokens across 171 messages — an entire session's unrelated work charged to one captured item.

Each of the four now notes the current time in ISO-8601 before it starts and passes it as --startedAt. /ndx-work already did, but prescribed date -Is, which does not exist in PowerShell; it and the four new instructions name both date -Is and Get-Date -Format o as examples of whatever the shell provides.

A new test, tests/e2e/skill-run-recording.test.js, derives its skill list from the manifest rather than hardcoding it, so a future skill that records runs is covered the moment it is added: it must pass --startedAt, say where the value comes from, and not prescribe a POSIX-only command to get it.

ndx start stop no longer warns that a server "did not exit" when it did.

terminateTreeByPid returns whether a pid stopped answering signal 0, which is not the same question as whether the intended process exited: kill(pid, 0) succeeds for a zombie — exited but not yet reaped — and for a PID that has since been recycled. SIGKILL is unblockable, so a still-signallable pid after one is weak evidence of survival and strong evidence of nothing.

The two stop paths disagreed about that. cli.js discarded the result and explained why; web.js branched on it and logged Server (PID N) did not exit within Nms of SIGKILL. — directly above the Stopped ... line it printed anyway. Stopping a server that exited cleanly could produce both lines, and the warning was the wrong one.

web.js now discards the result too. The rationale lives in one place — the contract on terminateTreeByPid — with both call sites pointing at it instead of carrying their own copy, and the @returns tag corrected: it claimed "whether the pid is gone", contradicting the prose four lines above it.

Where a stop path genuinely needs to report failure, the signal-0 probe belongs *before* the kill, which is where cli.js already separates EPERM ("exists, not ours to signal" — a real failure) from ESRCH ("already gone" — success). That behaviour is unchanged.

Guarded by a source assertion rather than a behavioural test: misreporting needs an unreaped zombie in the window between exit and reap, which cannot be staged deterministically, whereas the invariant that produced it — no caller consults the result — checks exactly.

Update LLM model catalogs to current vendor releases

Refreshes the Claude, Codex, and Gemini model catalogs and fixes several incorrect context-window and pricing entries. Two of the previous defaults pointed at models that are no longer usable.

Claude - claude-opus-4-8 → claude-opus-5 in the init catalog, the opus shorthand alias, and the heavy tier (was claude-opus-4-7). - Added a fable shorthand alias for claude-fable-5. - Corrected context windows: claude-sonnet-4-6 and claude-opus-4-7 are 1M models, not 200K. - Corrected pricing: claude-haiku-4-5 is $1.00/$5.00 (was $0.80/$4.00) and claude-opus-4-7 is $5.00/$25.00 (was $15.00/$75.00). - Default remains claude-sonnet-5.

Codex — GPT-5.6 replaces the GPT-5.4/5.5 line - Default is now gpt-5.6-terra (was gpt-5.5), with gpt-5.6-sol as a new heavy tier (codex previously had no tier above standard) and gpt-5.6-luna as light (was gpt-5.4-mini). - gpt-5.4 and gpt-5.4-mini retire from ChatGPT-authenticated Codex sessions on 2026-08-31; gpt-5.3-codex and gpt-5.2 are already unavailable there. All four are now legacy aliases that normalize to OpenAI's stated replacements, so existing .n-dx.json files keep working after upgrade. - gpt-5.5 is still supported and remains a selectable catalog entry. - openai-api-provider default was gpt-4o; now gpt-5.6-terra.

Google - gemini-2.0-flash has been shut down by Google and was the configured light tier — replaced with gemini-3.5-flash-lite. standard moves from gemini-2.5-flash to gemini-3.7-flash. - heavy intentionally stays on gemini-2.5-pro, the newest *stable* Pro model. gemini-3.1-pro-preview is newer but is a preview release whose ID may be renamed or withdrawn; it remains selectable via llm.google.model. - Corrected gemini-2.5-flash pricing to $0.30/$2.50 (was $0.15/$0.60).

Also refreshes the dashboard's model suggestions, which still listed retired IDs (claude-haiku-3-5, claude-3-7-sonnet-20250219, o3, o4-mini), and updates model examples in ndx config --help, ndx init --help, and the configuration guide.

Add --verbose/--debug live progress across ndx init and sourcevision analyze, and replace scattered vendor string literals with shared LLM_VENDOR constants.

Live progress instrumentation. ndx init gave no visibility into a slow sourcevision analyze run — --debug reached the child process but its output was fully captured and discarded on success, so a slow run was indistinguishable from a hung one. ndx init's spinner now forwards the child's own progress live (throttled so a high-volume --debug firehose can't stall the pipe via backpressure), and the Components phase (component parsing, route detection, server-route detection) gets per-operation timestamped tracing plus automatic gap detection that flags any silence past 250ms by naming the last known checkpoint. A worker-thread-backed live stopwatch prints an incrementing "current operation runtime" for any operation still in flight — verified to keep ticking even during a fully synchronous, non-yielding block, which a same-thread timer cannot do. hench's shell tool gets equivalent live-tail output for long-running commands.

Fixed a real infinite loop this instrumentation surfaced. inferPrefix (server-route prefix inference) could spin forever on any two ordinary routes that share no deeper common path (e.g. /users/:id and /orders) — confirmed live via a CPU sample showing 100% of time in String.prototype.lastIndexOf. Also tightens isLikelyRouteFile so a client-side api/ directory (axios/fetch-style callers, not Express-style route definitions) is no longer scanned for server routes at all, and adds a length guard against any future misextracted route "path" that's actually an unrelated string literal.

Vendor literal consolidation. Replaces hardcoded "claude"/"codex"/"google"/"local" string comparisons throughout core, hench, rex, sourcevision, and web with the canonical LLM_VENDOR/DEFAULT_LLM_VENDOR/LLM_VENDORS/isLLMVendor helpers exported from provider-interface.ts and re-exported through each package's llm-client gateway, so the supported-vendor set has one source of truth instead of being duplicated ad hoc at each call site.

Fixed ndx config <key> incorrectly reporting an initialized project as stale. The pre-dispatch directory resolver used for the staleness check and command-timeout config load treated a config key like llm as a target directory when no explicit directory argument was given, so ndx config llm looked for .sourcevision/.rex/.hench under a nonexistent llm/ subdirectory and reported a fully-initialized project as uninitialized.

Review follow-ups on session usage recording and the adversarial-review skill.

  • The usage watermark can no longer rewind. When the newest scanned transcript entry carried no uuid, the cursor kept the previous lastUuid while consumed advanced past it — and lastUuid wins on the next read, so everything between the two was claimed twice. The uuid watermark is now dropped when the tail has none, so the count governs and nothing is re-claimed. Latent rather than live (real transcripts stamp a uuid on every usage-bearing entry), but uuid is typed optional and the input is untrusted JSON parsed line-by-line, and the failure mode was silent inflation of exactly the number this module exists to make trustworthy.
  • CLAUDE_CONFIG_DIR is honoured when locating the transcript. resolveTranscriptPath accepted a configDir option but nothing outside its own test passed one, so a user who relocated their Claude config tree silently recorded zero tokens. The environment variable is now consulted between the explicit option and the ~/.claude default.
  • Transcript discovery probes with stat instead of a full read. The existence probe read the whole file and threw the bytes away; the caller then read it again — twice the I/O on transcripts that reach tens of MB.
  • ndx-adversarial-review stages only what it wrote. Its commit step inherited the house git add -A, but this skill's diff mode takes the dirty working tree as its review subject, so unscoped staging swept the user's in-progress work into a commit attributed to the review. It now runs git add .rex/prd_tree/ and scopes its porcelain check the same way; the other committing skills, which never take a dirty tree as input, keep git add -A.

0.5.0

Minor npm ↗
  • Surface concise re-authentication guidance when a provider rejects credentials, and stop dumping raw JSON error payloads. #317
  • Detect the project's CLI command name from the package.json bin field and record it as cli.name in .n-dx.json during init; manual overrides via ndx config cli.name are preserved. #328
  • Redact secrets in the logged commandLine, and cover every secret pattern in the twin tests. #329
  • Close out Codex workflow parity (#122) and fix the skill-tracking asymmetry (#284). #309
  • Make ndx export work on Windows. #329
  • Route ndx init's Claude CLI invocations through the Windows-safe spawn helper. #329
  • Stop printing the child-lifecycle process-group warning on every Windows ndx invocation. #329
  • Register MCP servers using the discovered claude CLI path instead of a bare claude literal. registerMcpServers computed claudeCmd = discovery.path but the claude mcp remove / claude mcp add commands still shelled out to the literal string claude, requiring it on PATH. When discoverClaudeCli resolved claude at a well-known location that is not on PATH — notably Windows %APPDATA%\npm\claude.cmd / claude.exe, but also nvm and Homebrew installs — ndx init silently failed to register the rex and sourcevision MCP servers even though discovery had succeeded. Both commands now invoke the quoted discovered path, so MCP registration works on installs where claude is not on PATH. #298
  • Route ndx start's port-occupant kill through the Windows-safe spawn helper. #329
  • Fix a POSIX process-group leak where surviving grandchildren were never force-killed. #329
  • Terminate the whole process tree on Windows, behind one cross-OS contract. #329
  • Actually restrict API-key file permissions on Windows, instead of only claiming to. #329
  • Make a command timeout actually stop the command, descendants included. #329
  • Complete the .gitattributes LF-pin coverage (follow-up to #283/#285). Three n-dx-written surfaces were writing LF but had no eol pin, so Windows checkouts (core.autocrlf=true) showed line-ending-only churn on every tool write: #309
  • ndx init now writes (or merges into) the target project's .gitattributes, pinning every n-dx-written tracked file (.rex/, .hench/, .sourcevision/, .n-dx.json, AGENTS.md, CLAUDE.md, .agents/) to text eol=lf. This stops Windows checkouts (core.autocrlf=true) from showing spurious line-ending-only modifications after every tool write. Existing .gitattributes content is preserved and user rules for overlapping patterns win; re-running ndx init is idempotent. #285
  • Make the hench pre-run commit gate size-aware with configurable thresholds. #317
  • Clear the losing timer in every bounded termination wait, so a CLI exits when its work is done. #329
  • Add a dashboard "Refresh Data" trigger: new ndx refresh --live-server mode skips the pre-refresh server termination and refuses UI-rebuild plans, and the web dashboard gains POST /api/commands/refresh (+ status poll) with a Refresh Data panel in the Commands view. #328
  • Add ndx auth — on-demand credential verification for the active LLM vendor. #317
  • Dashboard command triggers (refresh, ci, auth, self-heal, export) now resolve the ndx CLI on analyzed projects that aren't the n-dx monorepo: cli.js advertises its own path to child processes via N_DX_CLI_PATH, and the server's resolver tries the project-local bin, that env path, and @n-dx/core/cli.js from its module graph before the monorepo dogfood fallback. #328
  • Stop ndx pair-programming orphaning processes when a reviewer CLI or test command times out. #329
  • Add a BETA option to make the POSIX timeout kill definitive: freeze the process tree, prove it is frozen, then kill it. Off by default. #329
  • Reconcile Codex model identifiers across the config surface. Removed the dead gpt-5.4mini legacy alias from LEGACY_CODEX_MODEL_ALIASES (its target gpt-5.4-mini is already a direct catalog model and the non-hyphen key was never a shipped ID). The remaining legacy brand IDs (gpt-5-codex, gpt-5.1-codex-max, gpt-5.1-codex-mini) now match the orchestration-tier list in init-llm.js, with cross-reference comments pinning the two tiers together. Updated the hench vendor-compatibility error hint from the outdated gpt-4o, o1 to current Codex models (gpt-5.5, gpt-5.4-mini). #298
  • Refresh the Claude model catalog shown in ndx init and align the runtime default. Adds Claude Fable 5 (claude-fable-5) and Claude Sonnet 5 (claude-sonnet-5) to the selector, and promotes Sonnet 5 to the recommended default (replacing the previous-generation Sonnet 4.6 as the pre-selected model and as DEFAULT_CLAUDE_MODEL / NEWEST_MODELS.claude). Sonnet 5's 1M context window and pricing are registered for budget preflight. claude-sonnet-4-6 remains a valid, accepted model id (kept in the context/cost maps and added to the init legacy-alias list) so existing configs and --claude-model=claude-sonnet-4-6 keep working without warnings. Codex and Gemini catalogs are unchanged. #279
  • Add sample app installation feature with dashboard tutorial and optimize CLI resolution path #324
  • Self-heal and n-dx workflow visibility in the dashboard. The dashboard can now run and observe the full n-dx flow: self-heal with live iteration/phase progress and a stop control, full sourcevision analysis with async progress, rex fix/reshape/CI actions with dry-run previews, a Commands reference with inline run triggers, and views for the previously UI-less requirements, adaptive-optimization, and activity-log APIs. Command references throughout the dashboard and hench prompts resolve from the project's detected CLI name. #328
  • ndx start stop now terminates the background server's children, not just the recorded PID. #329
  • Local-loop tasks reset to pending on infra failures (retryable instead of deferred), --reset-deferred documented in hench help, and single-item PATCH via the web API restores startedAt/completedAt timestamping and status validation. #330
  • The "Update available" notice now suggests an upgrade command that actually works. #323
  • Record every vendor CLI invocation to an append-only claude_commands.log. #329
  • Security and modernization pass over all dependencies. Resolves all 45 pnpm audit findings (2 critical, 16 high) via updated direct dependencies and refreshed pnpm overrides (hono, @hono/node-server, fast-uri, ip-address, js-yaml, nanoid, postcss, qs, vite, ws, body-parser). Modernizes major tooling: TypeScript 6.0, vitest 4.1.10, ink 7, ora 9, jsdom 30, esbuild 0.28, @modelcontextprotocol/sdk 1.30, @anthropic-ai/sdk 0.117, changesets 3. Raises the supported Node.js floor from 18 to 22 (Node 18 and 20 are both end-of-life; CI already runs Node 22). #334
  • Stop quoting bare command names in the Windows cmd.exe verbatim command line so PATHEXT resolution still applies. buildWindowsCliCommandLine quoted every token including the binary, and a quoted command name makes cmd.exe look for an exact filename match on PATH instead of trying .CMD/.EXE/… in turn. When a PATH directory holds an extensionless file beside its shim — exactly what pnpm/npm global installs produce (pnpm + pnpm.CMD, claude + claude.CMD) — cmd found the extensionless POSIX script, failed CreateProcess, and exited 1 with The system cannot find the path specified., making the CLI look absent on Windows. Arguments are still quoted unconditionally and binary paths containing spaces or metacharacters keep their quotes, so the GH #68 spaced-path handling is unchanged. Non-Windows platforms are unaffected — they use a plain spawn and never build a cmd.exe command line. #323
  • Harden CLI spawning on Windows so launching .cmd shims (claude, codex, rex) no longer fails. Node can't spawn a .cmd directly (post-CVE-2024-27980), and the previous shell: process.platform === "win32" workaround triggered the [DEP0190] deprecation and broke on paths containing spaces. #299
Full release notes

Surface concise re-authentication guidance when a provider rejects credentials, and stop dumping raw JSON error payloads.

A new canonical helper in @n-dx/llm-client (authFailureGuidance / authFailureMessage) is the single source of truth for auth-failure wording: it names the provider, states the cause (Invalid or expired credentials), and gives the exact fix — claude logout && claude login, codex logout && codex login, or ndx config llm.google.api_key <KEY>. Every entry point now reads identically:

  • ndx init / ndx config llm.vendor — the core preflight (packages/core/config.js) replaces the verbose Details: <raw JSON> dump with the concise, ANSI-colored guidance (red headline, yellow remediation). The NDX error code (e.g. NDX_CLAUDE_PREFLIGHT_AUTH_REQUIRED) is demoted to a dim secondary line instead of the headline, and JSON payloads are never printed. A missing Google key gets a distinct "No API key configured" message.
  • ndx work — the runtime LLM providers already throw AuthFailureError; its message is now the canonical, JSON-free line.
  • ndx plan / ndx analyze — rex/sourcevision route auth errors through the shared classifier and (for rex) render AuthFailureError with the shared remediation.

Redact secrets in the logged commandLine, and cover every secret pattern in the twin tests.

cli-log redacted args but wrote commandLine through verbatim. On Windows that is the same data twice: buildWindowsCliCommandLine(binary, args) embeds the argv into the command line, so a key that redactArgs correctly replaced with <redacted> in the args field reappeared in full in commandLine on the same log line of claude_commands.log.

redactArgs cannot be reused for this, and reaching for it makes things worse rather than better. It iterates its argument, so handing it a string walks the individual characters — every pattern is anchored (^sk-ant-…), no single character matches, and the field is emitted as an array of letters with the secret fully intact:

"commandLine":["c","l","a","u","d","e"," ","-","-","a","p","i","-","k","e","y"," ","s","k","-","a","n","t","-","S","E","C","R","E","T"]

So both twins gain a redactCommandLine(line) that tokenises on whitespace, preserves the original spacing, and applies the same SECRET_FLAGS / SECRET_PATTERNS tables through the surrounding quotes that buildWindowsCliCommandLine adds. Returns a string, as the field's own type always claimed.

Two of the four SECRET_PATTERNS were never exercised. The parity block drove five fixed records, and between them they only ever hit gh[pousr]_; the per-twin behaviour block added sk-ant-. Nothing anywhere passed an AIza… (Google AI Studio) or a non-Anthropic sk-… token, in either twin. Either pattern could have been dropped from either copy with the whole suite green — and a dropped pattern means the key it matches is written to disk in plaintext. Both now have cases, plus redactCommandLine coverage per twin and a secret-bearing commandLine in the parity records.

Verified by deleting the AIza pattern from the core twin alone: 4 assertions fail across both the behaviour and parity blocks, where previously that deletion was invisible.

Also corrects both twins' TWIN docblock, which pointed at tests/unit/cli-log-parity.test.js. No such file exists — the guard is the cli-log twin parity block inside tests/unit/cli-log.test.js. A pointer whose only job is telling the next person where the tripwire is should not name a file that was never there.

Close out Codex workflow parity (#122) and fix the skill-tracking asymmetry (#284).

  • Body-drift regression test — a new e2e test regenerates the assistant artifacts from the canonical source (assistant-assets/) and asserts the committed CLAUDE.md, AGENTS.md, and every vendor SKILL.md match the generator. This closes the last acceptance gap of #122 (tests now fail on body drift, not just inventory drift). It immediately caught a real drift: the committed CLAUDE.md carried a ## Changeset Versioning section that was never in the canonical project-guidance.md, so AGENTS.md silently lacked it — that section is now in the shared source and both instruction files carry it.
  • #284 — commit both: the generated Claude ndx-* skills were gitignored while the Codex skills were committed, so cloned checkouts lacked the /ndx-* skills for Claude until re-init. .claude/skills/ is removed from .gitignore, the generated skills are committed (and LF-pinned in .gitattributes, matching .agents/skills/), and ndx init now warns via checkSkillTracking() when an enabled assistant's skill directory is gitignored.
  • Docs sweep: the web package README and the troubleshooting guide no longer describe MCP setup as Claude-only.

Make ndx export work on Windows.

ndx export --deploy=github could not succeed on Windows. Two independent blockers, both now fixed and both verified end-to-end.

1. The dynamic import aborted the command immediately. export.js did await import(resolvePackagePath(...)) with a bare absolute path, and Node's ESM loader rejects that on Windows:

ERR_UNSUPPORTED_ESM_URL_SCHEME … Received protocol 'c:'

The command died before doing any work — before generating the dashboard, let alone deploying. Now wrapped in pathToFileURL(...).href, the same fix tests/e2e/published-package-loadability.test.js already applies for this exact error. This was not in the original bug report; it surfaced only when the flow was actually run on Windows.

2. POSIX-only shell commands in the deploy path.

  • rm -rf "<path>" per worktree entry. rm is not a cmd.exe command, and although Git for Windows ships usr/bin/rm.exe it is not normally on PATH. With no try/catch this threw and aborted the deploy. Replaced with rmSync(path, { recursive: true, force: true }) — no shell at all, and force: true carries the -f intent.
  • git rm -rf . 2>/dev/null || true. Both 2>/dev/null and || true are POSIX sh constructs. The tolerate-failure intent moved into a JS try/catch, which is also clearer about *why* it is tolerated: a fresh orphan branch legitimately has nothing to remove.

All 16 remaining execSync command strings converted to argv via execFileSyncCli from win-spawn.js. The interpolated ones hand-quoted paths (tmpWorktree, dir) that break on Windows when a project path ends in a backslash — the trailing backslash escapes its own closing quote — or contains &/^. rex also needs the .cmd shim handling that helper provides.

Worth stating precisely: the unquoted ${branch} interpolations were not an injection vector, because branch is the hardcoded constant "n-dx-dashboard". The genuine risk was the project-derived paths.

export.js is no longer exempt from the shell-string architecture guard; that exemption was retired rather than left standing.

Verified against a scratch repository with a local bare origin, from a project directory named e & p (v2) — a space, an &, and parentheses. Both deploy routes pass: the orphan-branch creation path on first run, and the existing-branch worktree add path on the second. 28 files pushed, no .ndx-deploy-tmp left behind. No real deploy target was ever contacted.

Route ndx init's Claude CLI invocations through the Windows-safe spawn helper.

packages/core/claude-integration.js built six execSync command strings by hand — the MCP remove/add registration pair and four --version discovery probes. Every argument involved is a filesystem path (the claude binary, the resolved MCP entrypoint, the project directory), and the surrounding quoting was a bare ":

execSync(`"${claudeCmd}" mcp add ${name} -- node "${bin}" ${descriptor.mcpCommand} "${absDir}"`)

A project directory ending in a backslash — C:\Users\Tom&Jerry\my proj (v2)\ — produces ..."C:\Users\Tom&Jerry\my proj (v2)\", where the trailing backslash escapes its own closing quote. Argument parsing corrupts from that point on, and the command can still exit 0, so ndx init reports "registered" having stored a truncated command. All six now use execFileSyncCli from win-spawn.js, which applies the quoteWindowsToken/ArgvQuote rules and logs each invocation itself.

Verified against that exact path: the argv form emits "...(v2)\\" with the backslash doubled, keeps every & inside a quoted token, and round-trips 9 argv entries to 9 command-line tokens.

The unquoted interpolations in the old strings (${scope}, ${name}, ${descriptor.mcpCommand}) were manifest-derived constants rather than user input, so this was a correctness bug on unusual-but-legal paths, not a user-input injection vector.

The guard that should have caught it is now scan-based. architecture-policy.test.js walked a hardcoded 12-file DEP0190_SCOPE, so it only ratcheted over files someone remembered to enumerate — which is exactly how claude-integration.js and export.js kept hand-built command lines through an entire Windows-hardening epic. It now scans the whole production tree for:

  • imports of exec/execSync from child_process (the string-command APIs — execFile/execFileSync/spawn take argv and are fine)
  • shell: process.platform
  • shell: true with non-empty args

Exemptions moved into a SHELL_STRING_EXEMPT map where each entry states its reason, with the previously-undocumented ci.js and pr-check.js pnpm cases now recorded explicitly and export.js naming the task that will retire it. Demonstrated red-then-green against a newly added unhardened file, with no edit to the guard required to catch it.

claude-integration.js was also dropped from the child_process import allowlists in architecture-policy.test.js and ci.js — it no longer imports any child_process API, so the permission was removed rather than left permitted-but-unused.

Stop printing the child-lifecycle process-group warning on every Windows ndx invocation.

packages/core/cli.js builds its child-process tracker with processGroups: true at module load, and createChildProcessTracker emitted the fallback notice at construction time. Because PLATFORM_SUPPORTS_PROCESS_GROUPS is always false on win32, every command — including ndx --version, ndx --help, and ndx status, none of which spawn a child — prefixed its output with:

[child-lifecycle] process group cleanup is not supported on this platform; falling back to direct child kill

The message read as a degradation, but direct child kill is the intended Windows path — cli.js already omits detached: true on win32 by design, so there was nothing for users to act on.

The notice is now opt-in behind NDX_DEBUG_LIFECYCLE (or the global NDX_DEBUG), matching the existing NDX_DEBUG_LLM / NDX_DEBUG convention in @n-dx/llm-client. Set either to 1, true, or yes to restore it when diagnosing child-cleanup behavior. Termination behavior is unchanged on all platforms; only the logging is gated. The stripKnownRuntimeNoise filter in scripts/cli-smoke-parity.mjs is retained so smoke output stays comparable against older installs and debug-enabled runs.

Route ndx start's port-occupant kill through the Windows-safe spawn helper.

killPortOccupant() in packages/core/web.js arrived with the local-LLM-provider merge and built four command strings by hand:

execSync(`netstat -ano`, …)
execSync(`taskkill /F /PID ${pid}`, { stdio: "ignore" })
execSync(`lsof -ti tcp:${port}`, …)
execSync(`kill -9 ${pid}`, { stdio: "ignore" })

Unlike the earlier claude-integration.js and export.js conversions, there is no live defect here: port is parseInt(flags.port, 10) or a typeof number-validated config value, and both pid values are parseInt results, so no interpolation can carry &, ^, (, ), !, or a trailing backslash into a shell command line. This is the blanket exec/execSync policy being applied to new code rather than a bug being fixed — the point of a scan-based guard is that new files do not get to opt out on the argument that their particular interpolations happen to be safe today.

netstat, taskkill, and lsof now go through execFileSyncCli from win-spawn.js with argv. The POSIX kill drops its subprocess entirely in favour of process.kill(pid, "SIGKILL") — spawning /bin/kill to deliver a signal to a pid already in hand added a dependency on the binary being present for nothing. Failure behaviour is unchanged: execFileSyncCli throws on a non-zero exit exactly as execSync did (lsof -ti exits 1 when nothing is listening), and the whole body is already wrapped in a try/catch that returns false.

Caught by architecture-policy.test.js's scan-based shell-string guard as a semantic merge conflict — the two sides never touched the same lines, so the merge produced a tree that typechecked and passed every package test suite while violating a policy one of the branches had just added. Verified red-then-green against the guard (packages/core/web.js — imports \execSync\ from child_process → 56/56 passing), with the full root suite green at 97 files.

Fix a POSIX process-group leak where surviving grandchildren were never force-killed.

terminateProcessGroup sent SIGTERM to the child's process group, then decided whether to escalate to SIGKILL by checking the direct child:

await Promise.race([waitForChildExit(child), delay(forceKillTimeoutMs)]);
if (!isChildRunning(child)) return;            // gates on the child
try { killGroup(-child.pid, "SIGKILL"); }      // escalation for the group

The leader commonly installs a SIGTERM handler and exits promptly while a grandchild ignores the signal — routine for long-running CLIs, including the claude/codex processes ndx spawns. The child's exit resolved the wait, the early return fired, and SIGKILL never reached the group, stranding every surviving member. That is the precise leak process groups exist to prevent.

Escalation now depends on the group: groupHasMembers() probes with signal 0 (a kernel existence check that delivers nothing) and waitForGroupExit() polls it on a bounded deadline instead of awaiting the child's exit event, so all members get the grace period and SIGKILL lands whenever anyone is left.

This is POSIX signal semantics, so it affected macOS as well as Linux.

PID-reuse safety is documented at the probe: a pgid stays allocated while its group has members, and a pgid is its leader's PID, so that PID cannot be recycled while anyone remains in the group — probing immediately before signalling cannot target an unrelated process. If the group drains in between, the signal fails ESRCH and is swallowed.

The Windows taskkill /T /F path is unchanged.

Terminate the whole process tree on Windows, behind one cross-OS contract.

child-lifecycle.js previously exported PLATFORM_SUPPORTS_PROCESS_GROUPS and picked its termination strategy from it, so Windows silently got direct child kill only — any grandchild spawned by a tracked child was orphaned. Since ndx spawns CLIs that themselves spawn processes (claude/codex), that leak was real rather than theoretical.

A single terminateTree(child, options) now owns the decision: POSIX signals the process group (process.kill(-pgid), SIGTERM then SIGKILL); Windows runs taskkill /PID <pid> /T /F through win-spawn.js. Both fall back to killing the direct child if the tree-wide attempt fails or leaves it running. PLATFORM_SUPPORTS_PROCESS_GROUPS is no longer exported, and the processGroups tracker option is renamed treeKill — it named a POSIX mechanism that does not exist on Windows, where tree-killing nonetheless works.

cli.js no longer branches on process.platform for termination: the detached: true decision moved into an exported treeKillSpawnOptions(), so the platform difference lives in the termination layer that owns it.

Also removes the construction-time stderr notice entirely. Gating it behind NDX_DEBUG_LIFECYCLE (previous release) stopped it appearing on every command, but its text — "falling back to direct child kill" — is now simply false on Windows. Strategy reporting moved to terminateTree, where it names the strategy at the moment one actually runs.

Documented Windows limitations, rather than papered over:

  • No graceful phase. taskkill /T without /F posts WM_CLOSE, which only a process pumping a window-message loop acts on — Node children do not — and process.kill(pid, "SIGTERM") is TerminateProcess anyway. A graceful pass would burn the grace period for nothing, so Windows goes straight to /F.
  • Job Objects not used. They are the architecturally correct primitive (kill-on-job-close is exactly analogous to a process group) but need a native addon, which would put a compiled dependency in a pure-JS orchestration package.
  • Shutdown-time dependency. taskkill is spawned during cleanup; if the ndx process is itself force-killed, no handler runs and the tree survives unless the host contained it.

The platform, spawnCliImpl, and killGroup seams are injectable so both OS strategies are testable on any host — CI runs the suite on Linux only, so without them the Windows branch would ship unexercised.

Actually restrict API-key file permissions on Windows, instead of only claiming to.

config.js called chmod(path, 0o600) after writing .n-dx.json whenever it held a provider API key, and ndx config --help stated "File permissions set to 0600 (owner-only) for security" unconditionally. On Windows both were false. Measured on Windows 11:

after chmod(path, 0o600):
  mode reads back as 0666        (not 0600)
  icacls:  SYSTEM:(I)(F)  BUILTIN\Administrators:(I)(F)  <user>:(I)(F)

Every entry is (I) — inherited. fs.chmod cannot express a POSIX mode on Windows; it maps only the read-only attribute and never touches the DACL. So the API key stayed readable by SYSTEM and every administrator while the help text promised owner-only, and the two tests that would have caught it were it.skipIf(win32).

A new file-permissions.js module now attempts and then verifies the restriction, reporting what it actually achieved:

  • POSIX — chmod to 0600, then confirm via stat that the mode landed (FAT/exFAT mounts and some network shares silently drop mode changes).
  • Windows — icacls <path> /inheritance:r /grant:r <DOMAIN\user>:F through win-spawn.js, then read the DACL back and require no inherited (I) entries and no principal other than the current user. The exit code is not trusted: icacls reports "Successfully processed 1 files" in cases where the resulting ACL is not what was requested.

When verification fails, the user is warned at the point of writing — naming the file, the cause, and the safer alternative (ANTHROPIC_API_KEY, or Credential Manager on Windows). A false assurance about an API key is worse than a stated limitation. The help text now describes what the running platform actually does.

Verified end-to-end: a real ndx config claude.api_key on Windows now produces a file whose ACL is exactly <user>:(F).

Also resolves the related cli_path executable check. access(value, X_OK) succeeds for a plain JSON file on Windows — Node documents X_OK as having no effect there, so it degrades to F_OK and the check could never reject anything, while advising "Run: chmod +x". It is now explicitly skipped on Windows via executableBitIsMeaningful() with the reasoning recorded. Requiring a PATHEXT extension instead was rejected: it would refuse the extensionless POSIX scripts that pnpm/npm global installs place beside their .CMD shims, and a validation that rejects valid input is worse than none — spawn-time diagnostics already cover the rest.

All four Windows skips in tests/e2e/cli-config.test.js are gone (143 passing, 0 skipped): the permission assertions now check mode on POSIX and the DACL on Windows, and the executable-bit case asserts the documented Windows behaviour rather than being silently skipped.

Make a command timeout actually stop the command, descendants included.

exec delegated its timeout to Node's execFile, which signals only the process it spawned. Anything that process had itself started survived — kept running, kept holding file handles, kept writing to the workspace — while the caller had already been told the command stopped. Measured on Windows with a 400ms timeout: the reported result was Command timed out after 400ms, yet the surviving process went on to write four more times, and a temp directory it held could not be removed for 52 seconds.

That report is what an autonomous agent acts on. It reads files and runs the next command believing the previous one finished, so a build or codemod still writing underneath it can corrupt the state being read.

exec now owns the timeout timer and terminates the whole process tree when it fires: a process-group signal on POSIX (SIGTERM, escalating to SIGKILL, waiting on the *group* rather than the direct child), and taskkill /T /F on Windows. exitCode: null still signals a timeout, and an externally-killed child still reports the same way it always did. Opt out with treeKill: false when a child must stay in the caller's own process group.

Not a Windows-only fix, though Windows is where it was caught: the orphan survived on POSIX too, just invisibly, because unlinking open files is permitted there so no EBUSY drew attention to it. On Windows, libuv's global job object masks the problem for node-spawned node, but not for the cases that matter — sh, cmd, make, and pnpm/npm shims all leave their children behind.

The primitive is exported as terminateProcessTree / treeKillSpawnOptions. It is a deliberate twin of terminateTree in packages/core/child-lifecycle.js, since the orchestration tier must not import from packages; a parity test fails if the two diverge.

Complete the .gitattributes LF-pin coverage (follow-up to #283/#285). Three n-dx-written surfaces were writing LF but had no eol pin, so Windows checkouts (core.autocrlf=true) showed line-ending-only churn on every tool write:

  • .claude/skills/**/*.md — generated Claude skills (now committed per #284)
  • .codex/config.toml — generated Codex MCP config
  • .sourcevision/**/*.txt — sourcevision text output (e.g. llms.txt)

All three are added to both GITATTRIBUTES_EOL_RULES (the list ndx init injects into a project's .gitattributes) and n-dx's own .gitattributes, keeping the two in sync per the stated invariant.

The root cause of the pins shipping incomplete was that these two sources drifted apart — one updated, the other not — and no test caught it. To close that class of bug for good:

  • The rules are extracted into a single importable source of truth (packages/core/gitattributes-pins.js), imported by cli.js.
  • A sync-guard test (prd-line-endings.test.js) asserts the injector's pattern set equals n-dx's own .gitattributes eol=lf pattern set — any future divergence fails CI, not just the three patterns fixed today. cli-init.test.js also asserts the new patterns are injected.

Make the hench pre-run commit gate size-aware with configurable thresholds.

The gate now measures change magnitude (dirty file count plus lines changed vs HEAD via git diff --numstat, shared helper measureChangeMagnitude) instead of reacting only to a non-empty dirty list. Two new persisted settings under hench.git.* (.hench/config.json, editable via ndx config):

  • hench.git.checkpointThreshold (default: 200, 0 disables) — at/above this many changed lines, the interactive prompt warns about the change size and defaults to committing a checkpoint instead of proceeding. Below the threshold, behavior is unchanged.
  • hench.git.requireCleanTree (default: false) — refuse to start against a dirty tree: the interactive prompt drops the "proceed" option and non-interactive runs (--yes, piped) abort.

Autonomous runs (--auto/--loop/--epic-by-epic) keep today's behavior — abort on any dirty tree unless --allow-dirty — but the refusal now reports the measured magnitude. --allow-dirty takes precedence over both config settings for a single run (flag > config > defaults). Documented in hench run --help and ndx config --help.

Clear the losing timer in every bounded termination wait, so a CLI exits when its work is done.

Promise.race([waitForChildExit(child), delay(forceKillTimeoutMs)]) reads as "wait, but not forever". It also leaks: when the child wins the race, the delay timer is still armed, and an armed timer holds the event loop open. Nothing was waiting on it — the process simply could not exit until it fired.

Measured against sh -c "sleep 30" with a 300 ms command timeout, before and after, no other change:

| | exec() resolves | process exits | dead time | |---|---|---|---| | before | 432 ms | 5436 ms | ~5000 ms | | after | 445 ms | 446 ms | ~1 ms |

The 5 s is DEFAULT_FORCE_KILL_TIMEOUT_MS. Any CLI that finished immediately after a command timeout sat idle for the full kill grace period before returning to the shell.

Nine sites across the two twins, all of them replaced with a raceWithTimeout helper that clears its own timer in a finally:

  • packages/llm-client/src/process-tree.ts — four waitForChildExit races, the taskkill completion race, and captureStdout's bare setTimeout(finish, timeoutMs). That last one is the worst of the set: it is reached on every POSIX non-freeze kill via posixDescendants → readProcessTable, where ps returns in milliseconds but the timer is armed for the whole grace period.
  • packages/core/child-lifecycle.js — the childTarget wait adapter and both Windows tree-kill races. Same defect, and it had to be fixed twice because the orchestration tier cannot import @n-dx/llm-client (spawn-only rule).

The polling delay() calls are deliberately untouched. Those are awaited directly rather than raced, so their timer always fires and never outlives its await — replacing them would add a clearTimeout that can never run.

No behavioural change to the kill sequence itself: the same signals go out in the same order with the same bounds, and every existing termination test passes unmodified. What changes is only how long the process lingers afterwards.

Add ndx auth — on-demand credential verification for the active LLM vendor.

The command re-runs the same provider auth preflight used by ndx init / ndx config llm.vendor and exits 0 when credentials are valid (printing the active vendor, resolved model, and "credentials valid") or 1 on failure (printing the canonical, JSON-free auth-failure guidance). It works without an initialized project — the default vendor (claude) is checked when no config exists.

Every vendor's auth-failure remediation (and the flattened authFailureMessage used by runtime errors) now ends with the canonical verification step Verify credentials: ndx auth, exported from @n-dx/llm-client as VERIFY_CREDENTIALS_STEP, so users always know how to confirm a fix.

Stop ndx pair-programming orphaning processes when a reviewer CLI or test command times out.

All three timeout paths in pair-programming.js signalled only the direct child with a bare child.kill("SIGTERM"). That was four separate defects: no tree kill, so descendants survived; no process group to signal on POSIX; no escalation, so a child that ignores SIGTERM outlived its own timeout indefinitely; and resolve() on the line after kill(), so the caller saw timedOut: true while the tree was still running and still holding the workspace and any port it had bound.

runShellTestCommand was the worst of the three and broken deterministically rather than by race: it spawns with shell: true, so the child being signalled *is* the shell and never the test command beneath it. A timed-out npm test kept building with its output pipe already abandoned.

All three now terminate through child-lifecycle.js's terminateTree — process group on POSIX, taskkill /T on Windows, escalating to SIGKILL — and await it before resolving, so timedOut cannot be observed while the tree is alive. Each spawn passes treeKillSpawnOptions() so the POSIX group-signal path has a group to signal.

Detaching for that group had a catch worth naming: a detached child leaves this process's foreground group, so Ctrl-C would no longer have reached it — trading a timeout orphan for an interrupt orphan. cli.js now registers these children with the tracker whose SIGINT/SIGTERM/SIGHUP handlers already terminate tracked trees, via a new registerChild injection seam.

Covered by real-process tests (tests/e2e/pair-programming-timeout-tree-kill.test.js) that assert the grandchild is dead *without polling* — the promise settling early is precisely the defect — and that a SIGTERM-ignoring command still dies.

Add a BETA option to make the POSIX timeout kill definitive: freeze the process tree, prove it is frozen, then kill it. Off by default.

It ships behind a flag because the sweep it replaces has far more mileage: the freeze path's unit coverage injects its seams, and its behaviour against real POSIX processes is not yet proven in CI. Enable per-project with ndx config experimental.posixFreezeTreeKill true, or for a single run with NDX_POSIX_FREEZE_KILL=1. ndx config --help documents it as BETA and NOT RIGOROUSLY TESTED so nobody turns it on unaware.

The previous approach enumerated descendants and signalled them, which is inference. Its hole is reparenting: a descendant whose parent dies is adopted by init, so the pid→ppid link the enumeration depends on dissolves at exactly the moment the killing starts. The old code collected descendants *before* signalling to work around that; freezing first removes it, because reparenting only happens when a parent exits and nothing exits until enumeration is finished.

On timeout, exec now SIGSTOPs the tree, closes over its descendants to a fixpoint — a pass that discovers nothing, rather than a fixed number of rounds — verifies every member reads as stopped in the process table, and only then SIGKILLs, leaves before parents. It terminates because SIGSTOP cannot be caught, blocked, or ignored and a stopped process cannot fork, so new arrivals can only come from processes that were still running at the previous read, and that set shrinks monotonically. When the child *is* a process-group leader the fast path skips enumeration entirely: group membership is inherited rather than listed, so SIGSTOP then SIGKILL on the group are atomic over the whole tree.

SIGKILL, never SIGTERM: a stopped process does not act on SIGTERM — the signal queues until SIGCONT — so a "graceful" attempt against a frozen tree is a silent no-op. Freezing and graceful termination are therefore mutually exclusive, and this policy is opt-in via freeze on terminateProcessTree, used only for timeouts and runaways. Graceful shutdown keeps its SIGTERM grace period unchanged, and a test pins that the two policies stay distinct.

Windows is unchanged. It has no pure-JS pause — libuv maps the signals it supports onto TerminateProcess, and the real equivalents all need native code — so taskkill /T remains a tree walk. Its failure mode is the mirror image of POSIX's and is now documented where taskkill is invoked: Windows never reparents, so a link survives its parent's death and can dangle onto a recycled pid.

Known limit, recorded in the code: a deliberate double-fork daemon escapes parentage by design and no enumeration finds it. That is a policy question about whether agent-run commands may daemonize, not a detection one.

ndx start stop now terminates the background server's children, not just the recorded PID.

The stop path signalled only the PID from .n-dx-web.pid. On Windows that is doubly insufficient: SIGTERM is TerminateProcess, so the server never ran its cleanup handlers, and nothing walked the tree — so any rex analyze or hench run the server had spawned survived, potentially holding the port or the workspace. The server is also started detached: true, which places it outside libuv's job object, so nothing else would have reaped those children either.

Stop now routes through a shared terminateTreeByPid in child-lifecycle.js: taskkill /T on Windows, a process-group signal on POSIX. Grace periods are unchanged — ndx start stop keeps its 2s default and the N_DX_STOP_GRACE_MS override, deliberately shorter than the 5s used for shutdown, so consolidating the mechanism does not change stop latency.

There were three copies of the SIGTERM → grace → SIGKILL escalation (child-lifecycle.js, web.js, cli.js); there is now one, written against injected signal/liveness/wait capabilities so a live ChildProcess, a bare PID, and a POSIX process group all share the same sequence instead of each drifting. A PID is weaker evidence than a handle — kill(pid, 0) cannot distinguish a live process from a zombie or a recycled PID — so pid-file staleness handling stays with the callers that own the file rather than being assumed away.

The "Update available" notice now suggests an upgrade command that actually works.

Previously it always printed npm i -g @n-dx/core, regardless of how the copy was installed, which failed two ways:

  • Wrong package manager. A pnpm-global user following npm i -g ends up with a second global install under the npm prefix. Both ship an ndx shim, and whichever resolves first on PATH wins — so ndx --version can keep reporting the old version even though the upgrade "succeeded". update-check.js now infers the installing manager from its own path on disk (pnpm's .pnpm virtual store, yarn's data directory, else npm) and prints the matching pnpm add -g / yarn global add / npm i -g form.
  • Missing @latest. pnpm records a caret range in its global manifest, and for 0.x versions ^0.3.1 means >=0.3.1 <0.4.0. A bare pnpm add -g @n-dx/core or pnpm update -g re-resolves inside that range and can never cross a minor boundary, leaving users stranded on an old line indefinitely. The suggested command now always pins @n-dx/core@latest.

Adds a docs/guide/troubleshooting.md entry for the ERR_MODULE_NOT_FOUND … assistant-assets/index.js crash that 0.3.x installs hit, since that failure occurs while Node links the module graph — before any ndx code can run and surface an update notice. Documents the upgrade-pinning rule in the README install section.

Record every vendor CLI invocation to an append-only claude_commands.log.

Each claude / codex spawn now appends one JSON line capturing the timestamp, vendor, binary, argv, cwd, platform, the spawning helper, and — on Windows — the fully-built verbatim command line. The log accumulates across sessions, giving a project a consistent history of what was actually run.

Wired at the spawn chokepoints rather than per call site, so a single edit per tier covers everything downstream:

  • packages/llm-client/src/exec.ts spawnCli — covers cli-provider.ts (claude), codex-cli-provider.ts (codex), and hench's cli-loop.ts
  • packages/core/win-spawn.js spawnCli + execFileSyncCli — covers pair-programming.js reviewer runs and config.js preflight/--version probes
  • packages/core/claude-integration.js — the ndx init MCP registration claude mcp add/remove calls, which use raw execSync and bypass the helpers

Behaviour:

  • On by default; opt out with NDX_CLI_LOG=0 (also false / no).
  • Path: <cwd>/claude_commands.log, overridable via NDX_CLI_LOG_PATH. Gitignored, along with its rotated .1 generation.
  • Secrets redacted before the write — values following --api-key/--token/--password (and the --flag=value form), plus standalone sk-ant-*, sk-*, gh[pousr]_*, and AIza* tokens become <redacted>. The log is a plain file that outlives the process, so redaction happens at write time rather than read time.
  • One atomic single-line append per invocation, so concurrent ndx processes interleave cleanly by line instead of tearing.
  • Never throws — an unwritable cwd, permission error, or full disk cannot turn a logging failure into a spawn failure.
  • Rotates at 1 MB to claude_commands.log.1, mirroring .rex/execution-log.jsonl.

The implementation is duplicated as packages/llm-client/src/cli-log.ts and packages/core/cli-log.js because the orchestration tier must not import @n-dx/llm-client (spawn-only rule) — the same constraint that already forces the quoteWindowsToken twin. tests/unit/cli-log.test.js runs the full behavioural suite against both copies and asserts they emit byte-identical lines for a shared record table; both twins are imported from source so the parity check cannot fail on a stale dist/.

Also adds cli-log.js to @n-dx/core's files allowlist — without it the published package would import a file it does not ship.

Harden CLI spawning on Windows so launching .cmd shims (claude, codex, rex) no longer fails. Node can't spawn a .cmd directly (post-CVE-2024-27980), and the previous shell: process.platform === "win32" workaround triggered the [DEP0190] deprecation and broke on paths containing spaces.

  • New spawnCli helper (@n-dx/llm-client) routes CLI binaries through cmd.exe /d /s /c with windowsVerbatimArguments and never uses shell:true. Argument quoting follows the Microsoft ArgvQuote / cross-spawn rules (unconditional quoting, backslash-run doubling before quotes, embedded-quote doubling) so paths with spaces and tokens with cmd.exe metacharacters (& | < > ^ ( )) are handled. The orchestration tier (@n-dx/core) carries an equivalent win-spawn.js twin (it cannot import @n-dx/llm-client), kept in lockstep by a cross-package parity test.
  • All CLI-binary spawn sites are routed through the helper: the claude and codex providers, the hench agent loop and its adapters, the ndx config CLI-path validator, ndx pair-programming's reviewer, and sourcevision's rex invocations.
  • Prompts are delivered via stdin for the codex hench adapter and the pair-programming reviewer (previously passed as an argv token), preventing multi-line prompt truncation and command injection through cmd.exe.
  • diagnoseCliInvocation produces an actionable message when a CLI binary is missing or not invokable — distinguishing a not-found binary, a configured absolute path that doesn't exist, and a binary present on PATH but failing to run — and works from the close/non-zero-exit path on Windows (where a missing .cmd never raises ENOENT). Detection is anchored to the spawned binary so a legitimate run's own error output isn't misclassified.
  • A regression guard test fails CI if any CLI spawn site reintroduces the shell:true + args (DEP0190) pattern.

No behavior change on macOS or Linux.

0.4.6

Patch npm ↗
  • Fix ndx plan --no-llm not suppressing LLM calls in sourcevision zone enrichment. The flag was filtered out before being passed to sourcevision analyze; now maps to --fast (skip AI enrichment) so the full pipeline respects the flag. #268
  • Fix ndx ci on Windows: pnpm is a .cmd shim and requires shell: true to resolve without ENOENT. Add shell: process.platform === "win32" to the docs-build spawn in ci.js. #267
  • Record /ndx-work task execution in hench run history (#271). The /ndx-work skill drove tasks through Claude Code without spawning hench, so the work left no .hench/runs/ entry and was invisible to run history and ndx usage. A new hench record command writes a lightweight run record (task id, title, status, summary, timestamps, model) marked assisted, and the skill now calls it as a final step. Because Claude Code does not expose its own token consumption to a running skill, assisted records carry empty token usage and an assisted flag so analytics can distinguish them from genuine hench runs rather than reading them as anomalies; the skill also surfaces this caveat to the user. #243
  • Added Google integration #239

0.4.5

Patch npm ↗
  • reduce code size, improve skills for claude #222
  • init changes to readmes, and startup messages #236

0.4.4

Patch npm ↗
  • Fix: include self-heal-confirm.js in the published @n-dx/core tarball. The file exists in source and is imported by cli.js (line 50), but was missing from package.json's files array, so 0.4.3 published without it and ndx crashed at startup with ERR_MODULE_NOT_FOUND: Cannot find module … self-heal-confirm.js. #233
Full release notes

Fix: include self-heal-confirm.js in the published @n-dx/core tarball. The file exists in source and is imported by cli.js (line 50), but was missing from package.json's files array, so 0.4.3 published without it and ndx crashed at startup with ERR_MODULE_NOT_FOUND: Cannot find module … self-heal-confirm.js.

Because the changeset config groups all six @n-dx/* packages as fixed, this patch bumps the whole set to 0.4.4 — the other five packages republish unchanged but at the new version.

0.4.3

Patch npm ↗
  • Republish via npm Trusted Publishing. 0.4.2 was bumped in source but never made it to the registry because the original NPM_TOKEN-based publish in the Release run for #227 returned E404. Workflow now uses OIDC; this changeset moves all six packages to 0.4.3 so they get published with provenance attestation. #229

0.4.1

Patch npm ↗
  • Adding auto-changing llm models for long runs, self-heal improvements and bug fixes. #201

0.4.0

Minor npm ↗
  • Address security findings, fix package publishing regression, and refresh documentation. #198
Full release notes

Address security findings, fix package publishing regression, and refresh documentation.

Security — clears 27 of 30 Dependabot advisories:

  • @modelcontextprotocol/sdk ^1.25.3 → ^1.29.0 (rex, sourcevision, web) — fixes cross-client data leak via shared transport reuse (GHSA-345p-7cg4-v4c7) plus transitive hono, @hono/node-server, path-to-regexp, ajv, and qs advisories.
  • @anthropic-ai/sdk ^0.85.0 → ^0.94.0 (hench, llm-client) — fixes insecure default file permissions in the local-filesystem memory tool (GHSA-p7fg-763f-g4gf).
  • vitest ^4.0.18 → ^4.1.5 (root) — fixes transitive vite and picomatch advisories.
  • Adds range-scoped pnpm.overrides for picomatch, postcss, hono, @hono/node-server, ajv, path-to-regexp, qs, and vite to pin patched versions in transitive trees the resolver would otherwise leave on older cached versions.

Audit drops from 11 high / 21 moderate / 2 low to 1 high / 2 moderate. The remaining advisories (rollup, esbuild, vite reached via vitepress) are dev-server-only docs-build vulns deferred to a follow-up.

Packaging regression guard — moves assistant-assets/ under packages/core/ so it ships inside the published @n-dx/core tarball, and adds two e2e tests to prevent recurrence:

  • tests/e2e/published-assets-bundled.test.js — asserts pnpm pack includes the assistant-assets payload.
  • tests/e2e/published-package-loadability.test.js — installs each packed tarball into a clean fixture and verifies CLIs load.

Docs — README, getting-started, and quickstart updates with screenshots in documentation/ to walk through ndx init, analyze, plan, work, status, start, ci, and self-heal.

0.3.4

Patch npm ↗
  • added more documentation changes #197

0.3.3

Patch npm ↗
  • Fix ndx crashing on launch with ERR_MODULE_NOT_FOUND: ./pair-programming.js. The file is now included in the published @n-dx/core package's files array; previously cli.js imported a file that was excluded from the tarball. #194
Full release notes

Fix ndx crashing on launch with ERR_MODULE_NOT_FOUND: ./pair-programming.js. The file is now included in the published @n-dx/core package's files array; previously cli.js imported a file that was excluded from the tarball.

Docs: add an Existing project onboarding guide for adopting ndx into a repo with real history, expand the Quickstart with screenshots of ndx init / analyze / plan / status / work, and add a @n-dx/core package README so the npm landing page is no longer empty.

0.3.2

Patch npm ↗
  • new PRD structure and smaller fixes #186

0.3.1

Patch npm ↗
  • Fix ndx binary crashing on npm install due to missing files in the published tarball #172
Full release notes

Fix ndx binary crashing on npm install due to missing files in the published tarball

  • packages/core/package.json files array was missing assistant-integration.js and codex-integration.js
  • cli.js statically imports assistant-integration.js, which in turn statically imports codex-integration.js, so the resolution failure happened at module load before any error handling could run
  • Verified via npm pack --dry-run: tarball now ships 25 files, and the transitive static-import graph from cli.js resolves cleanly

0.3.0

Minor npm ↗
  • Vendor-neutral assistant integration layer for ndx init #158
  • more documentation additions and sourcevision token optimizations #167
  • Fix cli.timeouts.<command> being silently ignored when stored as a string #164
  • Added more documentation, small fixes and increased base timeout #165
  • Added more codex fixes, added full codex integration and other smaller fixes #168
Full release notes

Vendor-neutral assistant integration layer for ndx init

  • Add assistant-integration orchestration that provisions Claude and Codex surfaces independently of the active LLM vendor
  • Add init-llm module with interactive provider/model selection via enquirer (flag > config > prompt precedence)
  • Add vendor-specific model flags (--claude-model, --codex-model) that persist independently
  • Fix MCP server re-registration: remove before re-add so ndx init is idempotent
  • Surface MCP registration error details in init summary instead of silent failures
  • Integrate child-lifecycle process tracking and signal handlers from main
  • Add machine-local config support (.n-dx.local.json) for CLI paths and other per-machine settings

Fix cli.timeouts.<command> being silently ignored when stored as a string

  • ndx config cli.timeouts.work <ms> now stores the value as a number (numeric-shaped strings and "true"/"false" are auto-coerced when setting a brand-new key)
  • resolveCommandTimeout accepts numeric strings defensively, so existing configs that were written as strings by earlier versions start working without a re-set
  • ndx init runs a new config-repair pass that rewrites known-numeric paths (cli.timeoutMs, cli.timeouts.*, web.port) as proper numbers and reports what was repaired

0.2.3

Patch npm ↗
  • model and quality of experience improvements #155

0.2.2

Patch npm ↗
  • Immersive animated init experience with Ink TUI framework #153
  • This change optimizes some code, adds timeouts and big fixes for major use cases. No new functionality is added. #138
Full release notes

Immersive animated init experience with Ink TUI framework

  • Walking T-Rex mascot with shaded pixel art (half-block fg/bg color technique)
  • Ink-based animated UI with React components (htm/react for JSX without build step)
  • Braille spinners for each init phase, smooth animation via child process offloading
  • Sourcevision fast analysis (--fast) runs during init for immediate codebase data
  • Graceful degradation: static fallback for non-TTY, --quiet mode, NO_COLOR support
  • Actionable next-steps menu with CLI commands and skill suggestions
  • New dependencies: ink, react, htm

0.2.0

Minor npm ↗
  • Extract @n-dx/core into packages/core/ as a proper workspace package. Fixes workspace:\* dependency leak that prevented npm installation. #120

Other releases

Versions with no user-facing notes — dependency-only bumps, and releases published before the project adopted changesets. Details live in the commit history.