diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 921c9d17a..c6155f7a7 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,10 +11,10 @@ "source": "git-subdir", "url": "https://github.com/Hellblazer/nexus.git", "path": "conexus", - "ref": "v7.36.0" + "ref": "v7.36.1" }, "description": "Self-hosted three-tier knowledge management with 13 specialized agents, plan-centric retrieval via nx_answer, semantic search, and RDR decision tracking for Claude Code.", - "version": "7.36.0" + "version": "7.36.1" }, { "name": "sn", @@ -22,10 +22,10 @@ "source": "git-subdir", "url": "https://github.com/Hellblazer/nexus.git", "path": "sn", - "ref": "v7.36.0" + "ref": "v7.36.1" }, "description": "Injects Serena and Context7 MCP tool usage guidance into subagents via SubagentStart hook.", - "version": "7.36.0" + "version": "7.36.1" } ] } diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 2070c87e3..a0cf4674e 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -218,7 +218,7 @@ CI enforces parity. Missing any one of these fails the marketplace-version-match Optional but recommended: also bump `plugins[].source.sha` to the 40-char SHA of the release commit, for protection against tag force-push. Add post-commit (Step 8a, see below). -**Engine-service pin (conditional 8th target — nexus-3rq00).** The Python/Java boundary rides one more hand-edited constant that sits OUTSIDE the seven-manifest parity gate: `PINNED_SERVICE_TAG` in `src/nexus/daemon/binary_install.py`, the `engine-service-vX.Y.Z` release this build auto-installs. It is DERIVED from `REQUIRED_ENGINE_VERSION`, so it is never hand-edited: moving the engine identity moves the pin by construction. Two invariants the `TestEnginePinParity` test enforces: (1) `PINNED_SERVICE_TAG`'s numeric version must be `>= REQUIRED_ENGINE_VERSION` (`src/nexus/engine_version.py`) — never ship a client that auto-installs an engine it then refuses as too old; (2) at the 6.0 release boundary the pin must be non-None (it is intentionally `None` pre-6.0). A release that bumps pyproject to 6.x without setting a real pin trips CI. **The pin's VALUE is asserted in exactly one test** — `tests/test_engine_version.py::TestRequiredEngineVersion::test_pinned_floor_is_current` hardcodes the tuple with a per-bump comment trail — so every `REQUIRED_ENGINE_VERSION` bump edits that assertion in the same commit (add the bump's reason to its comment). Missed on 7.23.0: the unit leg of the battery red'd on it after the paired engine gate was already green. +**Engine-service pin (conditional 8th target — nexus-3rq00).** The Python/Java boundary rides one more hand-edited constant that sits OUTSIDE the seven-manifest parity gate: `PINNED_SERVICE_TAG` in `src/nexus/daemon/binary_install.py`, the `engine-service-vX.Y.Z` release this build auto-installs. It is DERIVED from `REQUIRED_ENGINE_VERSION`, so it is never hand-edited: moving the engine identity moves the pin by construction. Two invariants the `TestEnginePinParity` test enforces: (1) `PINNED_SERVICE_TAG`'s numeric version must be `>= REQUIRED_ENGINE_VERSION` (`src/nexus/engine_version.py`) — never ship a client that auto-installs an engine it then refuses as too old; (2) at the 6.0 release boundary the pin must be non-None (it is intentionally `None` pre-6.0). A release that bumps pyproject to 6.x without setting a real pin trips CI. **The pin's VALUE is asserted in exactly one test** — `tests/test_engine_version.py::TestRequiredEngineVersion::test_pinned_floor_is_current` — and since nexus-9gggv (2026-09-08) it is DERIVED, not typed: the floor must equal the first `engine-service-vX.Y.Z` the newest released `CHANGELOG.md` section names. So a `REQUIRED_ENGINE_VERSION` bump needs no test edit; it needs the release's CHANGELOG section to name its engine pairing as the FIRST `engine-service-v` mention in that section (a `was engine-service-vX` aside goes after the pairing, never before it; every released section to date satisfies this, 358 checked), and a bump without that line, or that line without the bump, is the red. Add the bump's reason to the test's comment trail for provenance. Before this derivation the tuple was hand-edited per release and missed on 7.23.0: the unit leg of the battery red'd on it after the paired engine gate was already green. Semver: MAJOR for breaking, MINOR for new features, PATCH for bug fixes. @@ -463,7 +463,7 @@ Both must report `vX.Y.Z` / `X.Y.Z`. **Do not declare done before this check pas git checkout develop && git pull git merge origin/main --no-edit # trivially clean right after a release: # the release branch just CONTAINED develop -git push origin develop +scripts/git-push-develop.sh HEAD # the merge commit vouches for what it merged in (nexus-9wxu6) ``` Why mandatory (2026-07-23 incident): from 6.12.0 through 6.17.0 no release diff --git a/AGENTS.md b/AGENTS.md index 1cc9cc207..71eb37745 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,6 +95,7 @@ Pagination over a large collection: `limit ≤ 300` per call, `offset += 300` in - **`develop` release boundary LIFTED 2026-06-29** — release-blocker bead `nexus-luxe6` closed; conexus 6.0.0 (the migration-capable release) published from develop, and `develop` is releasable again. **RDR-155 P4b (the FINAL Chroma deletion) SHIPPED 2026-07-25** — the dependency is dropped (absent from `uv.lock`), `guided_upgrade_cmd.py`/`migrate_cmd.py` are deleted outright, and `nx guided-upgrade` no longer exists. Pre-PG installs redirect through a two-hop path: pin to the last migration-capable release, `conexus==6.18.1`, where `nx guided-upgrade` still runs (Chroma → PG17+pgvector, copy-not-move), then upgrade normally from there. Frozen Chroma directories left on disk are relics: nothing reads them and there is no path back to that era (Hal, 2026-08-29). Authoritative record: T2 `nexus/release-boundary-since-p4a` (updated). - **Integration branch is `develop`.** Open PRs against `develop`, not `main`. `main` carries the plugin marketplace surface; the develop split protects it from in-flight churn. Releases promote `develop` to `main` via a PR-gated release branch (nexus-mkj6u) — there are NO direct-to-`main` commits at all (`docs/contributing.md` § Release Process). The plugin no longer ships a review-coverage push gate (deleted 2026-08-22, Sam's decision: self-attested, one true positive against denying correct pushes, and `develop` is already PR-gated to `main` with required checks so unreviewed code ships to nobody) — push-to-main protection and the `git add` wildcard redirect remain Hal's user-level hook (`~/.claude/hooks/nexus-git-policy.py`), personal workflow policy rather than a plugin behavior other conexus users inherit. - **Never `git add -A` or `git add .`.** Stage by explicit path so untracked drafts don't sneak in. +- **Push `develop` only through `scripts/git-push-develop.sh ...`** (nexus-9wxu6): it refuses unless `origin/develop..develop` equals the commits you name, so a peer's unpushed commits in the shared checkout never ride your push (four times on 2026-09-07). `NX_PUSH_SOURCE=HEAD` from a detached worktree pushes your commits without touching the local branch. Never `git commit --amend` in the primary checkout; the user-level hook denies it when HEAD is not this session's commit. See `docs/contributing.md` § Git Workflow. - **Never include AI attribution in commits.** No "Generated with Claude", no `Co-Authored-By: Claude`. Bead references and `Closes #N` only. - **Never delete RDR files.** Closing an RDR is a frontmatter `status: closed` flip — the file stays. See [`docs/rdr/AGENTS.md`](docs/rdr/AGENTS.md). - **Closed vocabularies (RDR status, and future ones) are CHECKED TABLES, not prose — see [`docs/rdr/AGENTS.md`](docs/rdr/AGENTS.md) § RDR lifecycle for the full story.** `src/nexus/tables/` (packaged, checked at load time); `docs/tables/` for repo-only tables (the release-choreography table both release gates resolve). @@ -206,7 +207,7 @@ make nearly every cut a docs cut. The Java **engine-service** binary is a separate release artifact with its own cadence. Conflating it with the PyPI/marketplace release is how the cloud engine silently drifts behind develop (2026-06-26: 22 `service/` commits / 4 days un-deployed, un-cloud-tested). -Build or test the engine through `scripts/mvnw-leased.sh` (never a bare `./mvnw`/`mvn`) — one builder at a time; a concurrent `./mvnw` invocation against the same `service/target` corrupts jOOQ codegen mid-build (nexus-c00dw, see `scripts/lib/build-lease.sh`). The lease lives in the git common dir, so every worktree shares it, and a live holder is waited for rather than refused (`NX_BUILD_LEASE_WAIT`, nexus-g6xpa): one engine build or suite per box. `scripts/build-gate-jar.sh` caches the stamped jar on the exact `service/` content, so a fresh worktree with an unchanged tree gets a copy instead of a nine-minute rebuild. +Build or test the engine through `scripts/mvnw-leased.sh` (never a bare `./mvnw`/`mvn`) — one builder at a time; a concurrent `./mvnw` invocation against the same `service/target` corrupts jOOQ codegen mid-build (nexus-c00dw, see `scripts/lib/build-lease.sh`). The lease lives in the git common dir, so every worktree shares it, and a live holder is waited for rather than refused (`NX_BUILD_LEASE_WAIT`, nexus-g6xpa): one engine build or suite per box. `scripts/build-gate-jar.sh` caches the stamped jar on the exact `service/` content, so a fresh worktree with an unchanged tree gets a copy instead of a nine-minute rebuild. The Python suite reads the same lease at session start (nexus-pv93h): while a build holds it, `pytest` refuses the whole run with one line and exit 75 naming the holder, and `NX_BUILD_LEASE_WAIT=` makes it wait instead; `NX_TEST_T2_SUBSTRATE=none` runs are never gated. - **Artifact + trigger:** an `engine-service-vX.Y.Z` git tag fires `engine-service-release.yml`, which builds + cosign-signs the 3 native binaries (linux-amd64, linux-arm64, mac-arm64 — mac-arm64 unsmoked, no Docker on GH macOS, nexus-4xf5m; mac-amd64/Intel is not a supported target). It publishes **nothing to PyPI** and is **NOT gated by the luxe6 / RDR-155-P4a develop release boundary** (the workflow header says so explicitly). **The release is a DRAFT until every asset is attached** (nexus-cl14i, after v0.1.95 published PG bundles with no binary): a final `promote-release` job flips it only when both matrices succeeded and all 21 assets are present, so a tag is consumable roughly 40 to 65 minutes after push, never partially; a failed leg, mac-arm64 included, leaves a draft that `gh run rerun --failed` completes and promotes. So the engine can be refreshed in the cloud at any time, independent of the unreleasable-develop state. - **Version is tag-stamped — there is NO manifest to bump.** `release.properties` `release_version` is blank in source and stamped at native-build time from the tag (the Maven `pom.xml` stays `1.0-SNAPSHOT`, the dev coordinate). The cut is NOT just suite-green-then-tag: the `engine-release` skill (Authority: this section) enforces a full pre-tag battery — full engine suite green on the tagged commit, `tests/e2e/migration-rehearsal/run.sh --shakeout` (must end `CANDIDATE SHAKEOUT PASSED`) — then human pushes `engine-service-vX.Y.Z`, followed by a post-publish `--acquire` gate against the published bytes. `scripts/check_client_release_precondition.py --engine-tag engine-service-vX.Y.Z` gates the **DEPLOY, never the tag cut** (Hal directive 2026-08-02 — its pre-tag wiring forced conexus 7.1.0 to ship pinned to a pre-fence engine, its own flagship feature inert on fresh local installs; a red exit means the deploy waits for the client tag carrying the listed commits, per the paired-release choreography below). **A tag gates DELIVERY, not work**: engine changes are fully testable end-to-end on develop (`scripts/mvnw-leased.sh test` + the Python suite's engine substrate + LSG against a `build-gate-jar.sh` dev jar) — "cannot deploy yet" is never "cannot do/test/tag it" (error recurred 3x: nexus-0ehwe thread 2026-07-31 twice, the 7.1.0/v0.1.62 inversion 2026-08-02). Use the `engine-release` skill as the executable checklist, not this summary. @@ -297,7 +298,7 @@ clone (see `docs/contributing.md` § Step 0b KNOWN LIMITATION, nexus-2zmfw). ``` git checkout develop && git pull git merge origin/main --no-edit # trivially clean right after a release - git push origin develop + scripts/git-push-develop.sh HEAD # the merge commit vouches for what it merged in (nexus-9wxu6) ``` Skipping this is how develop drifts behind the release-only commits and the next release branch conflicts (2026-07-23 incident; `docs/contributing.md` step 11b). 9. **Reinstall locally.** `scripts/reinstall-tool.sh && nx --version` — `pyproject.toml` is bumped, but the shim does not point at a wheel: it resolves `/current` at spawn time and execs the generation that pointer names, so until the reinstall flips `current` every new spawn lands in the old generation (and existing holders keep running from theirs afterwards). diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ee362b11..3322ff8be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,48 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [7.36.1] - 2026-09-08 + +Client-only patch. Engine unchanged at engine-service-v0.1.108 +(`REQUIRED_ENGINE_VERSION` 0.1.108); the wire ledger's Unshipped section is +empty. Plugin pin advances to v7.36.1, which makes every entry that waited +in `conexus/PENDING_RELEASE.md` since 7.36.0 live (see `conexus/CHANGELOG.md`). + +### Fixed + +- Service error bodies are redacted where they enter `VectorServiceError` + (new `nexus.redact` module), so `nx search`, `nx doc` and the doctor log + line no longer echo a rejected credential (nexus-8ooxn, nexus-hcy4w). +- The upgrade-finish stamp (`last_seen_version`) is written only by a + managed install; a dev checkout on a release branch no longer makes the + next installed-tool invocation report an upgrade that never happened + (nexus-i24r4). +- Session end reaps stale `t1_mint_.lock` litter under + `~/.config/nexus` (older than a day, no live lease) as its own + failure-isolated step, instead of only under `nx doctor --fix` + (nexus-fjwk7). +- Aspects: the cost banner names the measured model, usage is logged before + the envelope check, and an extraction refusal is reported as such + (follow-up to 7.36.0's aspects work). +- `nx dt index` page-coverage follow-ups after review (nexus-i0cwh). + +### Developer workflow + +- `scripts/git-push-develop.sh ...` is the way develop is pushed from a + shared checkout: it fetches, resolves the tip once, and pushes only when + `origin/develop..tip` equals the commits vouched on the command line; a + vouched merge commit covers what it merges in, so the release and + plugin-cut back-merges use it too (nexus-9wxu6). +- The unit suite decides once at session start while a Maven run holds the + shared build lease: it refuses with exit 75 naming the holder, or waits + when `NX_BUILD_LEASE_WAIT=` is set; `NX_TEST_T2_SUBSTRATE=none` + runs are never gated. The migration-rehearsal `--shakeout` native build + and the gc-ab harness wait on the lease like every other producer + (nexus-pv93h). +- Release pins that moved by hand every cut are derived: the engine-floor + test reads the pairing from the newest released CHANGELOG section, and + the stale-dim-table count pins tally code lines only (nexus-9gggv). + ## [7.36.0] - 2026-09-07 Pairs with engine-service-v0.1.108 (additive). diff --git a/conexus/.claude-plugin/plugin.json b/conexus/.claude-plugin/plugin.json index 273fd70ee..a0ce7a452 100644 --- a/conexus/.claude-plugin/plugin.json +++ b/conexus/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "conexus", - "version": "7.36.0", + "version": "7.36.1", "description": "Self-hosted three-tier knowledge management with plan-centric retrieval (nx_answer), specialized agents, semantic search, and RDR decision tracking for Claude Code.", "author": { "name": "Hal Hildebrand", diff --git a/conexus/CHANGELOG.md b/conexus/CHANGELOG.md index 65a821ad6..6aa252739 100644 --- a/conexus/CHANGELOG.md +++ b/conexus/CHANGELOG.md @@ -4,6 +4,28 @@ All notable changes to the conexus plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [7.36.1] - 2026-09-08 + +Plugin version aligned with conexus 7.36.1. Everything below waited in +`PENDING_RELEASE.md` since 7.36.0 and is live at this pin. + +- `pre_close_verification_hook.sh`: the close command is tokenized + quote-aware before it is split on operators, so a `;` or `do` inside a + quoted `--reason` no longer harvests prose ids (a session name, a peer's + bead) as close targets; with an unbalanced quote the flag value is + blanked before the raw-scan fallback (nexus-fv65m). +- `rdr_hook.py` (SessionStart): collection existence is asked of the T3 + client, not substring-matched against `nx collection list`; a resolution + failure is logged instead of swallowed; the T3 call has a 4 s deadline + inside the hook's 10 s cap; the summary counts every document the + indexer walks beside the RDR count (nexus-owna8). The text names + `nx index repo ` alone; `nx catalog setup` is retired. +- Every example that said `collection="knowledge"` now names a subject + (`collection=""`) across 27 skills, agents, commands and the + reference, with the subject rule and a Common Mistakes row in + `using-nx-skills` (nexus-fjc8v): the placeholder had minted + `knowledge__knowledge____v1` with 1464 live chunks. + ## [7.36.0] - 2026-09-07 - `rdr-gate` skill and command: the outcome is computed by diff --git a/conexus/PENDING_RELEASE.md b/conexus/PENDING_RELEASE.md index 622817ebf..26465cbd5 100644 --- a/conexus/PENDING_RELEASE.md +++ b/conexus/PENDING_RELEASE.md @@ -29,6 +29,6 @@ mechanize, it matters enough to ship. --- -## Awaiting the next release or plugin cut (pinned: v7.36.0) +## Awaiting the next release or plugin cut (pinned: v7.36.1) (none) diff --git a/conexus/README.md b/conexus/README.md index ce0368660..410855b44 100644 --- a/conexus/README.md +++ b/conexus/README.md @@ -217,7 +217,7 @@ MCP tool directly and skip the agent spawn entirely. | Stub agent | Replacement | Call shape | |------------|-------------|------------| -| knowledge-tidier | nx_tidy | `mcp__plugin_conexus_nexus__nx_tidy(topic=..., collection="knowledge")` | +| knowledge-tidier | nx_tidy | `mcp__plugin_conexus_nexus__nx_tidy(topic=..., collection="")` | | plan-auditor | nx_plan_audit | `mcp__plugin_conexus_nexus__nx_plan_audit(plan_json=..., context="")` | | plan-enricher | nx_enrich_beads | `mcp__plugin_conexus_nexus__nx_enrich_beads(bead_description=..., context="")` | diff --git a/conexus/agents/_shared/CONTEXT_PROTOCOL.md b/conexus/agents/_shared/CONTEXT_PROTOCOL.md index 233c2da6d..cc7ae6590 100644 --- a/conexus/agents/_shared/CONTEXT_PROTOCOL.md +++ b/conexus/agents/_shared/CONTEXT_PROTOCOL.md @@ -383,13 +383,13 @@ All agents should: ### Storage Tools ``` # Store a document -mcp__plugin_conexus_nexus__store_put( content="content", collection="knowledge", title="research-topic-date", tags="category", agent="" +mcp__plugin_conexus_nexus__store_put( content="content", collection="", title="research-topic-date", tags="category", agent="" # Search stored knowledge mcp__plugin_conexus_nexus__search( query="query", corpus="knowledge", limit=5 # List stored documents -mcp__plugin_conexus_nexus__store_list( collection="knowledge" +mcp__plugin_conexus_nexus__store_list( collection="" ``` ### Metadata diff --git a/conexus/agents/architect-planner.md b/conexus/agents/architect-planner.md index 35441267c..c2b5d4dd8 100644 --- a/conexus/agents/architect-planner.md +++ b/conexus/agents/architect-planner.md @@ -71,7 +71,7 @@ The only valid skip is structural inapplicability (a tier physically cannot have **Findings not stored are findings lost.** Before returning your result, persist what downstream consumers would benefit from. Pick the tier(s) that match the audience: - **Sibling agents downstream THIS session** (T1, narrowest scope, cheapest write) → `mcp__plugin_conexus_nexus__scratch(action="put", content=..., tags="")`. The next sibling the caller dispatches finds your work via `scratch search` and skips re-derivation. -- **Permanent cross-project knowledge** (T3, future sessions everywhere) → `mcp__plugin_conexus_nexus__store_put(content=..., collection="knowledge", title=..., tags=..., agent="architect-planner")`. The `agent` kwarg mirrors `memory_put`'s attribution (nexus-4ftd7) — an unmarked write collapses onto the shared `"mcp"` fallback and defeats `_flag_contradictions`'s agent-diversity precondition. AUTO-LINKS via T1 scratch tag `link-context` — seed first via `catalog_search` → `scratch put` if you want catalog links auto-created. +- **Permanent cross-project knowledge** (T3, future sessions everywhere) → `mcp__plugin_conexus_nexus__store_put(content=..., collection="", title=..., tags=..., agent="architect-planner")`. The `agent` kwarg mirrors `memory_put`'s attribution (nexus-4ftd7) — an unmarked write collapses onto the shared `"mcp"` fallback and defeats `_flag_contradictions`'s agent-diversity precondition. AUTO-LINKS via T1 scratch tag `link-context` — seed first via `catalog_search` → `scratch put` if you want catalog links auto-created. - **Project-scoped decisions / findings** (T2, future sessions this project) → `mcp__plugin_conexus_nexus__memory_put(content=..., project="", title=..., agent="architect-planner", ttl=30)`. The `agent` kwarg attributes this write to the architect-planner role so `nx tier-status` slices by agent (nexus-9clx). **Don't dismiss insights as "low-signal noise" because the surrounding work was structural.** If you noticed a bug, a race, a perf gap, an architectural observation, or a non-obvious cross-module connection while doing your primary task, that IS a finding worth persisting — for sibling agents this session (T1), or future sessions in this project (T2) or any project (T3). Bug-discoveries-in-passing are exactly the class of finding downstream work benefits from. @@ -166,7 +166,7 @@ Set `needsMoreThoughts: true` to continue, use `branchFromThought`/`branchId` to - Always conclude planning phase by including a `## Next Step: nx_plan_audit` block in your output for the caller to call `mcp__plugin_conexus_nexus__nx_plan_audit` **Documentation Requirements:** -- Store architectural decisions and rationale: mcp__plugin_conexus_nexus__store_put(content="...", collection="knowledge", title="decision-architect-{component}", tags="architecture" +- Store architectural decisions and rationale: mcp__plugin_conexus_nexus__store_put(content="...", collection="", title="decision-architect-{component}", tags="architecture" - Maintain execution progress and learnings: mcp__plugin_conexus_nexus__memory_put(content="content", project="{project}", title="plan-{component}.md" - Create correlation maps between related concepts and components - Document alternative paths and decision criteria @@ -211,7 +211,7 @@ Use to propose architectures using proven technologies. 2. Use 5 Nexus queries above to understand landscape 3. Design architecture informed by discovered patterns 4. Reference discovered patterns in design document -5. Store design decisions in Nexus: mcp__plugin_conexus_nexus__store_put(content="...", collection="knowledge", title="decision-architect-{topic}", tags="architecture" +5. Store design decisions in Nexus: mcp__plugin_conexus_nexus__store_put(content="...", collection="", title="decision-architect-{topic}", tags="architecture" ## Persistence (before returning) @@ -225,7 +225,7 @@ You MUST persist your architectural decisions BEFORE returning — **unless the ``` mcp__plugin_conexus_nexus__store_put( content="# Architecture: {topic}\n\n{decisions}", - collection="knowledge", + collection="", title="architecture-{topic}-{date}", tags="architecture,architect-planner,{domain}" ) @@ -252,10 +252,10 @@ Your final output MUST include a clearly labeled next-step recommendation for th This agent follows the [Shared Context Protocol](./_shared/CONTEXT_PROTOCOL.md). ### Agent-Specific PRODUCE -- **Architectural Decisions**: mcp__plugin_conexus_nexus__store_put(content="...", collection="knowledge", title="decision-architect-{component}", tags="architecture" +- **Architectural Decisions**: mcp__plugin_conexus_nexus__store_put(content="...", collection="", title="decision-architect-{component}", tags="architecture" - **Execution Plans**: mcp__plugin_conexus_nexus__memory_put(content="content", project="{project}", title="plan-{component}.md" - **Dependency Maps**: Include in bead design field -- **Risk Assessments**: mcp__plugin_conexus_nexus__store_put(content="...", collection="knowledge", title="risk-architect-{topic}", tags="risk" +- **Risk Assessments**: mcp__plugin_conexus_nexus__store_put(content="...", collection="", title="risk-architect-{topic}", tags="risk" - **Catalog Links** (if catalog tools available): After storing decisions or risk assessments: 1. If relay context references an RDR (check T1 scratch for `rdr-planning-context`): `mcp__plugin_conexus_nexus-catalog__link(from_tumbler="{decision-title}", to_tumbler="{rdr-title}", link_type="relates", created_by="architect-planner")` 2. If a research synthesis informed the decision: `mcp__plugin_conexus_nexus-catalog__link(from_tumbler="{decision-title}", to_tumbler="{research-title}", link_type="cites", created_by="architect-planner")` diff --git a/conexus/agents/code-review-expert.md b/conexus/agents/code-review-expert.md index 261778ee1..5d0cf056c 100644 --- a/conexus/agents/code-review-expert.md +++ b/conexus/agents/code-review-expert.md @@ -69,7 +69,7 @@ The only valid skip is structural inapplicability (a tier physically cannot have **Findings not stored are findings lost.** Before returning your result, persist what downstream consumers would benefit from. Pick the tier(s) that match the audience: - **Sibling agents downstream THIS session** (T1, narrowest scope, cheapest write) → `mcp__plugin_conexus_nexus__scratch(action="put", content=..., tags="")`. The next sibling the caller dispatches finds your work via `scratch search` and skips re-derivation. -- **Permanent cross-project knowledge** (T3, future sessions everywhere) → `mcp__plugin_conexus_nexus__store_put(content=..., collection="knowledge", title=..., tags=..., agent="code-review-expert")`. The `agent` kwarg mirrors `memory_put`'s attribution (nexus-4ftd7) — an unmarked write collapses onto the shared `"mcp"` fallback and defeats `_flag_contradictions`'s agent-diversity precondition. AUTO-LINKS via T1 scratch tag `link-context`, seed first via `catalog_search` → `scratch put` if you want catalog links auto-created. +- **Permanent cross-project knowledge** (T3, future sessions everywhere) → `mcp__plugin_conexus_nexus__store_put(content=..., collection="", title=..., tags=..., agent="code-review-expert")`. The `agent` kwarg mirrors `memory_put`'s attribution (nexus-4ftd7) — an unmarked write collapses onto the shared `"mcp"` fallback and defeats `_flag_contradictions`'s agent-diversity precondition. AUTO-LINKS via T1 scratch tag `link-context`, seed first via `catalog_search` → `scratch put` if you want catalog links auto-created. - **Project-scoped decisions / findings** (T2, future sessions this project) → `mcp__plugin_conexus_nexus__memory_put(content=..., project="", title=..., agent="code-review-expert", ttl=30)`. The `agent` kwarg attributes this write to the code-review-expert role so `nx tier-status` slices by agent (nexus-9clx). **Don't dismiss insights as "low-signal noise" because the surrounding work was structural.** If you noticed a bug, a race, a perf gap, an architectural observation, or a non-obvious cross-module connection while doing your primary task, that IS a finding worth persisting, for sibling agents this session (T1), or future sessions in this project (T2) or any project (T3). Bug-discoveries-in-passing are exactly the class of finding downstream work benefits from. @@ -319,7 +319,7 @@ This agent follows the [Shared Context Protocol](./_shared/CONTEXT_PROTOCOL.md). - **Significant Issues**: Create beads for critical findings - **Pattern Violations Found**: When a review identifies a violation of established patterns (naming, error handling, structural conventions), store it to T3: - mcp__plugin_conexus_nexus__store_put(content="# Review: Pattern Violation\n## Pattern\n{pattern name}\n## Violation\n{what was found}\n## File\n{path}\n## Recommendation\n{fix}", collection="knowledge", title="review-pattern-{pattern-name}-{date}", tags="review,pattern,violation" + mcp__plugin_conexus_nexus__store_put(content="# Review: Pattern Violation\n## Pattern\n{pattern name}\n## Violation\n{what was found}\n## File\n{path}\n## Recommendation\n{fix}", collection="", title="review-pattern-{pattern-name}-{date}", tags="review,pattern,violation" Store when: a pattern is violated across multiple locations in the reviewed code; a violation suggests the pattern itself may need documentation; the violation is non-obvious (not a typo). Do not store: single-instance style nits, formatting errors, trivial cases. diff --git a/conexus/agents/codebase-deep-analyzer.md b/conexus/agents/codebase-deep-analyzer.md index ccbcc670c..d2e2636b7 100644 --- a/conexus/agents/codebase-deep-analyzer.md +++ b/conexus/agents/codebase-deep-analyzer.md @@ -71,7 +71,7 @@ The only valid skip is structural inapplicability (a tier physically cannot have **Findings not stored are findings lost.** Before returning your result, persist what downstream consumers would benefit from. Pick the tier(s) that match the audience: - **Sibling agents downstream THIS session** (T1, narrowest scope, cheapest write) → `mcp__plugin_conexus_nexus__scratch(action="put", content=..., tags="")`. The next sibling the caller dispatches finds your work via `scratch search` and skips re-derivation. -- **Permanent cross-project knowledge** (T3, future sessions everywhere) → `mcp__plugin_conexus_nexus__store_put(content=..., collection="knowledge", title=..., tags=..., agent="codebase-deep-analyzer")`. The `agent` kwarg mirrors `memory_put`'s attribution (nexus-4ftd7) — an unmarked write collapses onto the shared `"mcp"` fallback and defeats `_flag_contradictions`'s agent-diversity precondition. AUTO-LINKS via T1 scratch tag `link-context` — seed first via `catalog_search` → `scratch put` if you want catalog links auto-created. +- **Permanent cross-project knowledge** (T3, future sessions everywhere) → `mcp__plugin_conexus_nexus__store_put(content=..., collection="", title=..., tags=..., agent="codebase-deep-analyzer")`. The `agent` kwarg mirrors `memory_put`'s attribution (nexus-4ftd7) — an unmarked write collapses onto the shared `"mcp"` fallback and defeats `_flag_contradictions`'s agent-diversity precondition. AUTO-LINKS via T1 scratch tag `link-context` — seed first via `catalog_search` → `scratch put` if you want catalog links auto-created. - **Project-scoped decisions / findings** (T2, future sessions this project) → `mcp__plugin_conexus_nexus__memory_put(content=..., project="", title=..., agent="codebase-deep-analyzer", ttl=30)`. The `agent` kwarg attributes this write to the codebase-deep-analyzer role so `nx tier-status` slices by agent (nexus-9clx). **Don't dismiss insights as "low-signal noise" because the surrounding work was structural.** If you noticed a bug, a race, a perf gap, an architectural observation, or a non-obvious cross-module connection while doing your primary task, that IS a finding worth persisting — for sibling agents this session (T1), or future sessions in this project (T2) or any project (T3). Bug-discoveries-in-passing are exactly the class of finding downstream work benefits from. @@ -156,7 +156,7 @@ Thought 8: Synthesize findings into a coherent architectural picture Set `needsMoreThoughts: true` to continue, use `branchFromThought`/`branchId` to explore separate concerns in parallel. 4. **Nexus Knowledge Management**: Use store_put and search tools as documentation repository and coordination hub: - - Store findings: mcp__plugin_conexus_nexus__store_put(content="content", collection="knowledge", title="ID", tags="category" + - Store findings: mcp__plugin_conexus_nexus__store_put(content="content", collection="", title="ID", tags="category" - Query findings: mcp__plugin_conexus_nexus__search(query="query", corpus="knowledge", limit=5 - Document relationships between components - Track analysis progress and coverage gaps @@ -223,7 +223,7 @@ You MUST persist your analysis findings BEFORE returning — **unless the dispat ``` mcp__plugin_conexus_nexus__store_put( content="# Codebase Analysis: {topic}\n\n{findings}", - collection="knowledge", + collection="", title="analysis-codebase-{topic}-{date}", tags="analysis,codebase-deep-analyzer,{domain}" ) @@ -250,10 +250,10 @@ When your analysis reveals work that needs to be planned (e.g., refactoring, new This agent follows the [Shared Context Protocol](./_shared/CONTEXT_PROTOCOL.md). ### Agent-Specific PRODUCE -- **Architecture Maps**: mcp__plugin_conexus_nexus__store_put(content="...", collection="knowledge", title="architecture-{scope}-{date}", tags="architecture" +- **Architecture Maps**: mcp__plugin_conexus_nexus__store_put(content="...", collection="", title="architecture-{scope}-{date}", tags="architecture" - **Dependency Analysis**: Include in response - **Technical Debt**: Create chore beads for significant debt -- **Pattern Catalog**: mcp__plugin_conexus_nexus__store_put(content="...", collection="knowledge", title="pattern-codebase-{name}", tags="pattern" +- **Pattern Catalog**: mcp__plugin_conexus_nexus__store_put(content="...", collection="", title="pattern-codebase-{name}", tags="pattern" - **Catalog Links** (if catalog tools available): After storing architecture maps or pattern catalogs: 1. `mcp__plugin_conexus_nexus-catalog__search(query="{scope} architecture", content_type="knowledge")` — find related prior analyses 2. For related architecture maps on interconnected modules: `mcp__plugin_conexus_nexus-catalog__link(from_tumbler="{this-map-title}", to_tumbler="{related-map-title}", link_type="relates", created_by="codebase-deep-analyzer")` diff --git a/conexus/agents/debugger.md b/conexus/agents/debugger.md index 20d6815b6..517aa23b7 100644 --- a/conexus/agents/debugger.md +++ b/conexus/agents/debugger.md @@ -71,7 +71,7 @@ The only valid skip is structural inapplicability (a tier physically cannot have **Findings not stored are findings lost.** Before returning your result, persist what downstream consumers would benefit from. Pick the tier(s) that match the audience: - **Sibling agents downstream THIS session** (T1, narrowest scope, cheapest write) → `mcp__plugin_conexus_nexus__scratch(action="put", content=..., tags="")`. The next sibling the caller dispatches finds your work via `scratch search` and skips re-derivation. -- **Permanent cross-project knowledge** (T3, future sessions everywhere) → `mcp__plugin_conexus_nexus__store_put(content=..., collection="knowledge", title=..., tags=..., agent="debugger")`. The `agent` kwarg mirrors `memory_put`'s attribution (nexus-4ftd7) — an unmarked write collapses onto the shared `"mcp"` fallback and defeats `_flag_contradictions`'s agent-diversity precondition. AUTO-LINKS via T1 scratch tag `link-context`, seed first via `catalog_search` → `scratch put` if you want catalog links auto-created. +- **Permanent cross-project knowledge** (T3, future sessions everywhere) → `mcp__plugin_conexus_nexus__store_put(content=..., collection="", title=..., tags=..., agent="debugger")`. The `agent` kwarg mirrors `memory_put`'s attribution (nexus-4ftd7) — an unmarked write collapses onto the shared `"mcp"` fallback and defeats `_flag_contradictions`'s agent-diversity precondition. AUTO-LINKS via T1 scratch tag `link-context`, seed first via `catalog_search` → `scratch put` if you want catalog links auto-created. - **Project-scoped decisions / findings** (T2, future sessions this project) → `mcp__plugin_conexus_nexus__memory_put(content=..., project="", title=..., agent="debugger", ttl=30)`. The `agent` kwarg attributes this write to the debugger role so `nx tier-status` slices by agent (nexus-9clx). **Don't dismiss insights as "low-signal noise" because the surrounding work was structural.** If you noticed a bug, a race, a perf gap, an architectural observation, or a non-obvious cross-module connection while doing your primary task, that IS a finding worth persisting, for sibling agents this session (T1), or future sessions in this project (T2) or any project (T3). Bug-discoveries-in-passing are exactly the class of finding downstream work benefits from. @@ -172,7 +172,7 @@ Set `needsMoreThoughts: true` to continue, use `branchFromThought`/`branchId` to - **Memory Analysis**: Use memory_put/memory_get tools as persistent scratch pad for organizing findings **Documentation Strategy:** -- Store all hypotheses, test results, and discoveries in Nexus knowledge store: mcp__plugin_conexus_nexus__store_put(content="...", collection="knowledge", title="debug-finding-{issue}", tags="debug" +- Store all hypotheses, test results, and discoveries in Nexus knowledge store: mcp__plugin_conexus_nexus__store_put(content="...", collection="", title="debug-finding-{issue}", tags="debug" - Maintain a debugging journal: mcp__plugin_conexus_nexus__memory_put(content="content", project="{project}", title="debug-journal.md" - Create knowledge graphs linking symptoms to potential causes - Document patterns and anti-patterns discovered during investigation @@ -217,7 +217,7 @@ You MUST persist your debugging findings BEFORE returning, **unless the dispatch ``` mcp__plugin_conexus_nexus__store_put( content="# Debug: {issue}\n\n{findings}", - collection="knowledge", + collection="", title="debug-{issue}-{date}", tags="debug,debugger,{domain}" ) @@ -245,11 +245,11 @@ This agent follows the [Shared Context Protocol](./_shared/CONTEXT_PROTOCOL.md). ### Agent-Specific PRODUCE - **Root Cause Analysis**: After confirming root cause, store with structured sections: - mcp__plugin_conexus_nexus__store_put(content="# Debug: {symptom}\n## Root Cause\n{finding}\n## Evidence\n{key evidence}\n## Fix\n{fix applied}", collection="knowledge", title="debug-finding-{component}-{symptom}", tags="debug,rootcause" + mcp__plugin_conexus_nexus__store_put(content="# Debug: {symptom}\n## Root Cause\n{finding}\n## Evidence\n{key evidence}\n## Fix\n{fix applied}", collection="", title="debug-finding-{component}-{symptom}", tags="debug,rootcause" The structured sections make retrieved findings immediately actionable without further parsing. - **Hypothesis Trail**: Document in bead notes - **Fix Recommendations**: Include in output as "Recommended Next Step" for caller to dispatch developer -- **Prevention Patterns**: mcp__plugin_conexus_nexus__store_put(content="...", collection="knowledge", title="pattern-prevention-{topic}", tags="pattern,prevention" +- **Prevention Patterns**: mcp__plugin_conexus_nexus__store_put(content="...", collection="", title="pattern-prevention-{topic}", tags="pattern,prevention" - **Catalog Links** (if catalog tools available): After storing a root cause analysis or prevention pattern, search for related prior findings and create links: 1. `mcp__plugin_conexus_nexus-catalog__search(query="{component} debug finding")`, find prior findings on same component 2. For each match: `mcp__plugin_conexus_nexus-catalog__link(from_tumbler="{this-finding-title}", to_tumbler="{prior-finding-title}", link_type="relates", created_by="debugger")` diff --git a/conexus/agents/deep-analyst.md b/conexus/agents/deep-analyst.md index 7d3f33bb8..640144549 100644 --- a/conexus/agents/deep-analyst.md +++ b/conexus/agents/deep-analyst.md @@ -71,7 +71,7 @@ The only valid skip is structural inapplicability (a tier physically cannot have **Findings not stored are findings lost.** Before returning your result, persist what downstream consumers would benefit from. Pick the tier(s) that match the audience: - **Sibling agents downstream THIS session** (T1, narrowest scope, cheapest write) → `mcp__plugin_conexus_nexus__scratch(action="put", content=..., tags="")`. The next sibling the caller dispatches finds your work via `scratch search` and skips re-derivation. -- **Permanent cross-project knowledge** (T3, future sessions everywhere) → `mcp__plugin_conexus_nexus__store_put(content=..., collection="knowledge", title=..., tags=..., agent="deep-analyst")`. The `agent` kwarg mirrors `memory_put`'s attribution (nexus-4ftd7) — an unmarked write collapses onto the shared `"mcp"` fallback and defeats `_flag_contradictions`'s agent-diversity precondition. AUTO-LINKS via T1 scratch tag `link-context`, seed first via `catalog_search` → `scratch put` if you want catalog links auto-created. +- **Permanent cross-project knowledge** (T3, future sessions everywhere) → `mcp__plugin_conexus_nexus__store_put(content=..., collection="", title=..., tags=..., agent="deep-analyst")`. The `agent` kwarg mirrors `memory_put`'s attribution (nexus-4ftd7) — an unmarked write collapses onto the shared `"mcp"` fallback and defeats `_flag_contradictions`'s agent-diversity precondition. AUTO-LINKS via T1 scratch tag `link-context`, seed first via `catalog_search` → `scratch put` if you want catalog links auto-created. - **Project-scoped decisions / findings** (T2, future sessions this project) → `mcp__plugin_conexus_nexus__memory_put(content=..., project="", title=..., agent="deep-analyst", ttl=30)`. The `agent` kwarg attributes this write to the deep-analyst role so `nx tier-status` slices by agent (nexus-9clx). **Don't dismiss insights as "low-signal noise" because the surrounding work was structural.** If you noticed a bug, a race, a perf gap, an architectural observation, or a non-obvious cross-module connection while doing your primary task, that IS a finding worth persisting, for sibling agents this session (T1), or future sessions in this project (T2) or any project (T3). Bug-discoveries-in-passing are exactly the class of finding downstream work benefits from. @@ -201,7 +201,7 @@ You MUST persist your analysis findings BEFORE returning, **unless the dispatchi ``` mcp__plugin_conexus_nexus__store_put( content="# Analysis: {topic}\n\n{findings}", - collection="knowledge", + collection="", title="analysis-deep-{topic}-{date}", tags="analysis,deep-analyst,{domain}" ) @@ -229,7 +229,7 @@ This agent follows the [Shared Context Protocol](./_shared/CONTEXT_PROTOCOL.md). ### Agent-Specific PRODUCE - **Significant Analysis Findings**: Store confirmed analytical conclusions to T3: - mcp__plugin_conexus_nexus__store_put(content="# Analysis: {component}/{question}\n## Finding\n{conclusion}\n## Evidence\n{key evidence}", collection="knowledge", title="analysis-deep-{component}-{date}", tags="analysis,deep-analyst" + mcp__plugin_conexus_nexus__store_put(content="# Analysis: {component}/{question}\n## Finding\n{conclusion}\n## Evidence\n{key evidence}", collection="", title="analysis-deep-{component}-{date}", tags="analysis,deep-analyst" Only store findings you are confident in, not working hypotheses. Storing a hypothesis that turns out to be wrong creates noise in future retrievals. - **Hypothesis Results**: Document with confidence levels diff --git a/conexus/agents/deep-research-synthesizer.md b/conexus/agents/deep-research-synthesizer.md index 13d09a008..137492425 100644 --- a/conexus/agents/deep-research-synthesizer.md +++ b/conexus/agents/deep-research-synthesizer.md @@ -72,7 +72,7 @@ The only valid skip is structural inapplicability (a tier physically cannot have **Findings not stored are findings lost.** Before returning your result, persist what downstream consumers would benefit from. Pick the tier(s) that match the audience: - **Sibling agents downstream THIS session** (T1, narrowest scope, cheapest write) → `mcp__plugin_conexus_nexus__scratch(action="put", content=..., tags="")`. The next sibling the caller dispatches finds your work via `scratch search` and skips re-derivation. -- **Permanent cross-project knowledge** (T3, future sessions everywhere) → `mcp__plugin_conexus_nexus__store_put(content=..., collection="knowledge", title=..., tags=..., agent="deep-research-synthesizer")`. The `agent` kwarg mirrors `memory_put`'s attribution (nexus-4ftd7) — an unmarked write collapses onto the shared `"mcp"` fallback and defeats `_flag_contradictions`'s agent-diversity precondition. AUTO-LINKS via T1 scratch tag `link-context` — seed first via `catalog_search` → `scratch put` if you want catalog links auto-created. +- **Permanent cross-project knowledge** (T3, future sessions everywhere) → `mcp__plugin_conexus_nexus__store_put(content=..., collection="", title=..., tags=..., agent="deep-research-synthesizer")`. The `agent` kwarg mirrors `memory_put`'s attribution (nexus-4ftd7) — an unmarked write collapses onto the shared `"mcp"` fallback and defeats `_flag_contradictions`'s agent-diversity precondition. AUTO-LINKS via T1 scratch tag `link-context` — seed first via `catalog_search` → `scratch put` if you want catalog links auto-created. - **Project-scoped decisions / findings** (T2, future sessions this project) → `mcp__plugin_conexus_nexus__memory_put(content=..., project="", title=..., agent="deep-research-synthesizer", ttl=30)`. The `agent` kwarg attributes this write to the deep-research-synthesizer role so `nx tier-status` slices by agent (nexus-9clx). **Don't dismiss insights as "low-signal noise" because the surrounding work was structural.** If you noticed a bug, a race, a perf gap, an architectural observation, or a non-obvious cross-module connection while doing your primary task, that IS a finding worth persisting — for sibling agents this session (T1), or future sessions in this project (T2) or any project (T3). Bug-discoveries-in-passing are exactly the class of finding downstream work benefits from. @@ -131,7 +131,7 @@ You have access to and will actively leverage: - mcp__plugin_conexus_nexus__query(question="topic", where="bib_year>=2023" -- filter by year, citations, tags - mcp__plugin_conexus_nexus__search(query="query", corpus="knowledge", limit=5 -- chunk-level semantic search - mcp__plugin_conexus_nexus__store_list(collection="knowledge__art-1-1__voyage-context-3__v1", docs=true -- enumerate all documents (RDR-103: collections are `______v`) - - mcp__plugin_conexus_nexus__store_put(content="content", collection="knowledge", title="title", tags="tags" -- store findings (the bare prefix is auto-promoted to a conformant 4-segment name) + - mcp__plugin_conexus_nexus__store_put(content="content", collection="", title="title", tags="tags" -- store findings (the bare prefix is auto-promoted to a conformant 4-segment name) - **nx code index**: Semantic code search across indexed repositories - mcp__plugin_conexus_nexus__search(query="query", corpus="code", limit=20 -- hybrid semantic + ripgrep - mcp__plugin_conexus_nexus__search(query="query", corpus="code____voyage-code-3__v1", limit=20 -- repo-specific @@ -190,7 +190,7 @@ You MUST persist your research findings to the conexus knowledge store BEFORE re ``` mcp__plugin_conexus_nexus__store_put( content="# Research: {topic}\n\n{findings}", - collection="knowledge", + collection="", title="research-{agent}-{topic}-{date}", tags="research,{domain}" ) @@ -219,7 +219,7 @@ Your final output MUST include a clearly labeled next-step recommendation. ``` ## Next Step: nx_tidy -**Call**: nx_tidy(topic="", collection="knowledge") +**Call**: nx_tidy(topic="", collection="") **Deliverable**: Consolidated T3 knowledge documents ``` @@ -229,7 +229,7 @@ Your final output MUST include a clearly labeled next-step recommendation. This agent follows the [Shared Context Protocol](./_shared/CONTEXT_PROTOCOL.md). ### Agent-Specific PRODUCE -- **Research Synthesis (default — T3)**: mcp__plugin_conexus_nexus__store_put(content="# Research: {topic}\n{content}", collection="knowledge", title="research-{topic}-{date}", tags="research,{domain}") — use when the dispatching relay does NOT specify an alternative target +- **Research Synthesis (default — T3)**: mcp__plugin_conexus_nexus__store_put(content="# Research: {topic}\n{content}", collection="", title="research-{topic}-{date}", tags="research,{domain}") — use when the dispatching relay does NOT specify an alternative target - **Research Synthesis (relay-overridden — T2)**: mcp__plugin_conexus_nexus__memory_put(content="...", project="{relay-specified}", title="{relay-specified}", ttl={relay-specified, default 30}) — use when the dispatching relay specifies a T2 target (e.g. `rdr_process/audit--` for rdr-audit classifier dispatches) - **Source Citations**: Include in document content - **Knowledge Gaps**: Create research beads for follow-up @@ -309,7 +309,7 @@ You will systematically: ### Phase 4: Knowledge Integration with Version Control You will automatically: 1. Store all significant findings in T3 store with appropriate categorization, tags, and version numbers: - mcp__plugin_conexus_nexus__store_put(content="# Research: {topic}\n\n{content}", collection="knowledge", title="research-{topic}-{date}", tags="research,{domain}" + mcp__plugin_conexus_nexus__store_put(content="# Research: {topic}\n\n{content}", collection="", title="research-{topic}-{date}", tags="research,{domain}" 2. Create new documents in nx store when discovering substantial new topic areas 3. Update existing documents with new insights while preserving version history 4. **Create catalog citation links** (if catalog tools available): For each stored research document, create `cites` links to its primary sources: @@ -344,7 +344,7 @@ Present findings including: - **deep-analyst**: Requests for additional information during analysis ### I Hand Off To (via Recommended Next Step): -- **nx_tidy** (MCP tool): After major research for consolidation — `nx_tidy(topic=..., collection="knowledge")` +- **nx_tidy** (MCP tool): After major research for consolidation — `nx_tidy(topic=..., collection="")` - **architect-planner**: Research findings for architecture decisions - **nx_plan_audit** (MCP tool): Research that informs plan validation diff --git a/conexus/agents/developer.md b/conexus/agents/developer.md index 1245d7d08..1a5e925bf 100644 --- a/conexus/agents/developer.md +++ b/conexus/agents/developer.md @@ -71,7 +71,7 @@ The only valid skip is structural inapplicability (a tier physically cannot have **Findings not stored are findings lost.** Before returning your result, persist what downstream consumers would benefit from. Pick the tier(s) that match the audience: - **Sibling agents downstream THIS session** (T1, narrowest scope, cheapest write) → `mcp__plugin_conexus_nexus__scratch(action="put", content=..., tags="")`. The next sibling the caller dispatches finds your work via `scratch search` and skips re-derivation. -- **Permanent cross-project knowledge** (T3, future sessions everywhere) → `mcp__plugin_conexus_nexus__store_put(content=..., collection="knowledge", title=..., tags=..., agent="developer")`. The `agent` kwarg mirrors `memory_put`'s attribution (nexus-4ftd7) — an unmarked write collapses onto the shared `"mcp"` fallback and defeats `_flag_contradictions`'s agent-diversity precondition. AUTO-LINKS via T1 scratch tag `link-context`, seed first via `catalog_search` → `scratch put` if you want catalog links auto-created. +- **Permanent cross-project knowledge** (T3, future sessions everywhere) → `mcp__plugin_conexus_nexus__store_put(content=..., collection="", title=..., tags=..., agent="developer")`. The `agent` kwarg mirrors `memory_put`'s attribution (nexus-4ftd7) — an unmarked write collapses onto the shared `"mcp"` fallback and defeats `_flag_contradictions`'s agent-diversity precondition. AUTO-LINKS via T1 scratch tag `link-context`, seed first via `catalog_search` → `scratch put` if you want catalog links auto-created. - **Project-scoped decisions / findings** (T2, future sessions this project) → `mcp__plugin_conexus_nexus__memory_put(content=..., project="", title=..., agent="developer", ttl=30)`. The `agent` kwarg attributes this write to the developer role so `nx tier-status` slices by agent (nexus-9clx). **Don't dismiss insights as "low-signal noise" because the surrounding work was structural.** If you noticed a bug, a race, a perf gap, an architectural observation, or a non-obvious cross-module connection while doing your primary task, that IS a finding worth persisting, for sibling agents this session (T1), or future sessions in this project (T2) or any project (T3). Bug-discoveries-in-passing are exactly the class of finding downstream work benefits from. @@ -201,7 +201,7 @@ This agent follows the [Shared Context Protocol](./_shared/CONTEXT_PROTOCOL.md). - **Implementation Notes**: Store in Nexus memory if multi-session: mcp__plugin_conexus_nexus__memory_put(content="content", project="{project}", title="impl-notes.md" - **Implementation Discoveries**: Store non-obvious findings that future implementers would need to know and could not easily rediscover: - mcp__plugin_conexus_nexus__store_put(content="...", collection="knowledge", title="insight-developer-{topic}", tags="insight" + mcp__plugin_conexus_nexus__store_put(content="...", collection="", title="insight-developer-{topic}", tags="insight" Store when: module initialization order has a non-obvious constraint; an API behaves differently than its documentation suggests; a pattern that appears reusable is actually tied to a specific context. @@ -238,7 +238,7 @@ Integration with test-first: 1. Use search tool to understand existing patterns 2. Write tests based on discovered conventions 3. Implement following established patterns -4. Store findings in Nexus for team knowledge: mcp__plugin_conexus_nexus__store_put(content="...", collection="knowledge", title="insight-developer-{topic}", tags="insight" +4. Store findings in Nexus for team knowledge: mcp__plugin_conexus_nexus__store_put(content="...", collection="", title="insight-developer-{topic}", tags="insight" ## Problem-Solving Approach @@ -246,7 +246,7 @@ For every problem, complex or not: 1. Break down the problem using `mcp__plugin_conexus_sequential-thinking__sequentialthinking` 2. Form hypotheses about the issue or solution 3. Test hypotheses systematically -4. Document findings in Nexus if they are architecturally significant: mcp__plugin_conexus_nexus__store_put(content="...", collection="knowledge", title="insight-developer-{topic}", tags="insight" +4. Document findings in Nexus if they are architecturally significant: mcp__plugin_conexus_nexus__store_put(content="...", collection="", title="insight-developer-{topic}", tags="insight" 5. Adapt the plan based on learnings while maintaining forward momentum **Pattern for a Code Change** (run this chain BEFORE the edit, not after it): @@ -375,6 +375,6 @@ The caller then: dispatches both reviewers, gates on both returning clean (Criti You stick to the plan and move forward, but you understand that plans evolve. When requirements change, adapt systematically rather than thrashing. Use your expertise to make sound architectural decisions quickly. Trust your judgment on when to write custom code versus using a library. -Apply `mcp__plugin_conexus_sequential-thinking__sequentialthinking` BEFORE every design choice and every fix — not only when you encounter obstacles; waiting for an obstacle is how it goes unused. Store important architectural knowledge in Nexus for future reference: mcp__plugin_conexus_nexus__store_put(content="...", collection="knowledge", title="insight-developer-{topic}", tags="insight". Keep the build system healthy and the codebase clean. +Apply `mcp__plugin_conexus_sequential-thinking__sequentialthinking` BEFORE every design choice and every fix — not only when you encounter obstacles; waiting for an obstacle is how it goes unused. Store important architectural knowledge in Nexus for future reference: mcp__plugin_conexus_nexus__store_put(content="...", collection="", title="insight-developer-{topic}", tags="insight". Keep the build system healthy and the codebase clean. You are the agent that takes a plan and executes it to completion with excellence, pragmatism, and unwavering focus on delivering working, tested, maintainable code. diff --git a/conexus/agents/strategic-planner.md b/conexus/agents/strategic-planner.md index 49f984434..d44528c0f 100644 --- a/conexus/agents/strategic-planner.md +++ b/conexus/agents/strategic-planner.md @@ -70,7 +70,7 @@ The only valid skip is structural inapplicability (a tier physically cannot have **Findings not stored are findings lost.** Before returning your result, persist what downstream consumers would benefit from. Pick the tier(s) that match the audience: - **Sibling agents downstream THIS session** (T1, narrowest scope, cheapest write) → `mcp__plugin_conexus_nexus__scratch(action="put", content=..., tags="")`. The next sibling the caller dispatches finds your work via `scratch search` and skips re-derivation. -- **Permanent cross-project knowledge** (T3, future sessions everywhere) → `mcp__plugin_conexus_nexus__store_put(content=..., collection="knowledge", title=..., tags=..., agent="strategic-planner")`. The `agent` kwarg mirrors `memory_put`'s attribution (nexus-4ftd7) — an unmarked write collapses onto the shared `"mcp"` fallback and defeats `_flag_contradictions`'s agent-diversity precondition. AUTO-LINKS via T1 scratch tag `link-context`, seed first via `catalog_search` → `scratch put` if you want catalog links auto-created. +- **Permanent cross-project knowledge** (T3, future sessions everywhere) → `mcp__plugin_conexus_nexus__store_put(content=..., collection="", title=..., tags=..., agent="strategic-planner")`. The `agent` kwarg mirrors `memory_put`'s attribution (nexus-4ftd7) — an unmarked write collapses onto the shared `"mcp"` fallback and defeats `_flag_contradictions`'s agent-diversity precondition. AUTO-LINKS via T1 scratch tag `link-context`, seed first via `catalog_search` → `scratch put` if you want catalog links auto-created. - **Project-scoped decisions / findings** (T2, future sessions this project) → `mcp__plugin_conexus_nexus__memory_put(content=..., project="", title=..., agent="strategic-planner", ttl=30)`. The `agent` kwarg attributes this write to the strategic-planner role so `nx tier-status` slices by agent (nexus-9clx). **Don't dismiss insights as "low-signal noise" because the surrounding work was structural.** If you noticed a bug, a race, a perf gap, an architectural observation, or a non-obvious cross-module connection while doing your primary task, that IS a finding worth persisting, for sibling agents this session (T1), or future sessions in this project (T2) or any project (T3). Bug-discoveries-in-passing are exactly the class of finding downstream work benefits from. @@ -308,7 +308,7 @@ You MUST persist key architectural decisions BEFORE returning, **unless the disp ``` mcp__plugin_conexus_nexus__store_put( content="# Decision: {topic}\n\n{rationale}", - collection="knowledge", + collection="", title="decision-planner-{topic}-{date}", tags="decision,planning,{domain}" ) diff --git a/conexus/agents/substantive-critic.md b/conexus/agents/substantive-critic.md index 9189ca054..3f4449372 100644 --- a/conexus/agents/substantive-critic.md +++ b/conexus/agents/substantive-critic.md @@ -70,7 +70,7 @@ The only valid skip is structural inapplicability (a tier physically cannot have **Findings not stored are findings lost.** Before returning your result, persist what downstream consumers would benefit from. Pick the tier(s) that match the audience: - **Sibling agents downstream THIS session** (T1, narrowest scope, cheapest write) → `mcp__plugin_conexus_nexus__scratch(action="put", content=..., tags="")`. The next sibling the caller dispatches finds your work via `scratch search` and skips re-derivation. -- **Permanent cross-project knowledge** (T3, future sessions everywhere) → `mcp__plugin_conexus_nexus__store_put(content=..., collection="knowledge", title=..., tags=..., agent="substantive-critic")`. The `agent` kwarg mirrors `memory_put`'s attribution (nexus-4ftd7) — an unmarked write collapses onto the shared `"mcp"` fallback and defeats `_flag_contradictions`'s agent-diversity precondition. AUTO-LINKS via T1 scratch tag `link-context`, seed first via `catalog_search` → `scratch put` if you want catalog links auto-created. +- **Permanent cross-project knowledge** (T3, future sessions everywhere) → `mcp__plugin_conexus_nexus__store_put(content=..., collection="", title=..., tags=..., agent="substantive-critic")`. The `agent` kwarg mirrors `memory_put`'s attribution (nexus-4ftd7) — an unmarked write collapses onto the shared `"mcp"` fallback and defeats `_flag_contradictions`'s agent-diversity precondition. AUTO-LINKS via T1 scratch tag `link-context`, seed first via `catalog_search` → `scratch put` if you want catalog links auto-created. - **Project-scoped decisions / findings** (T2, future sessions this project) → `mcp__plugin_conexus_nexus__memory_put(content=..., project="", title=..., agent="substantive-critic", ttl=30)`. The `agent` kwarg attributes this write to the substantive-critic role so `nx tier-status` slices by agent (nexus-9clx). **Don't dismiss insights as "low-signal noise" because the surrounding work was structural.** If you noticed a bug, a race, a perf gap, an architectural observation, or a non-obvious cross-module connection while doing your primary task, that IS a finding worth persisting, for sibling agents this session (T1), or future sessions in this project (T2) or any project (T3). Bug-discoveries-in-passing are exactly the class of finding downstream work benefits from. @@ -212,7 +212,7 @@ This agent follows the [Shared Context Protocol](./_shared/CONTEXT_PROTOCOL.md). ### Agent-Specific PRODUCE - **Critique Reports**: Include in response - **Critical Issues**: Create beads for must-fix items -- **Pattern Analysis**: Store recurring issues: mcp__plugin_conexus_nexus__store_put(content="", collection="knowledge", title="critique-pattern-{topic}", tags="critique,pattern" +- **Pattern Analysis**: Store recurring issues: mcp__plugin_conexus_nexus__store_put(content="", collection="", title="critique-pattern-{topic}", tags="critique,pattern" - **Improvement Recommendations**: Include in output for caller to act on - **Critique Notes**: Use T1 scratch to track issues found during critique: mcp__plugin_conexus_nexus__scratch(action="put", content="Issue [{severity}]: {description} in {location}", tags="critique,{severity}" diff --git a/conexus/agents/test-validator.md b/conexus/agents/test-validator.md index b3ba481c6..e1d567acb 100644 --- a/conexus/agents/test-validator.md +++ b/conexus/agents/test-validator.md @@ -70,7 +70,7 @@ The only valid skip is structural inapplicability (a tier physically cannot have **Findings not stored are findings lost.** Before returning your result, persist what downstream consumers would benefit from. Pick the tier(s) that match the audience: - **Sibling agents downstream THIS session** (T1, narrowest scope, cheapest write) → `mcp__plugin_conexus_nexus__scratch(action="put", content=..., tags="")`. The next sibling the caller dispatches finds your work via `scratch search` and skips re-derivation. -- **Permanent cross-project knowledge** (T3, future sessions everywhere) → `mcp__plugin_conexus_nexus__store_put(content=..., collection="knowledge", title=..., tags=..., agent="test-validator")`. The `agent` kwarg mirrors `memory_put`'s attribution (nexus-4ftd7) — an unmarked write collapses onto the shared `"mcp"` fallback and defeats `_flag_contradictions`'s agent-diversity precondition. AUTO-LINKS via T1 scratch tag `link-context` — seed first via `catalog_search` → `scratch put` if you want catalog links auto-created. +- **Permanent cross-project knowledge** (T3, future sessions everywhere) → `mcp__plugin_conexus_nexus__store_put(content=..., collection="", title=..., tags=..., agent="test-validator")`. The `agent` kwarg mirrors `memory_put`'s attribution (nexus-4ftd7) — an unmarked write collapses onto the shared `"mcp"` fallback and defeats `_flag_contradictions`'s agent-diversity precondition. AUTO-LINKS via T1 scratch tag `link-context` — seed first via `catalog_search` → `scratch put` if you want catalog links auto-created. - **Project-scoped decisions / findings** (T2, future sessions this project) → `mcp__plugin_conexus_nexus__memory_put(content=..., project="", title=..., agent="test-validator", ttl=30)`. The `agent` kwarg attributes this write to the test-validator role so `nx tier-status` slices by agent (nexus-9clx). **Don't dismiss insights as "low-signal noise" because the surrounding work was structural.** If you noticed a bug, a race, a perf gap, an architectural observation, or a non-obvious cross-module connection while doing your primary task, that IS a finding worth persisting — for sibling agents this session (T1), or future sessions in this project (T2) or any project (T3). Bug-discoveries-in-passing are exactly the class of finding downstream work benefits from. @@ -295,7 +295,7 @@ This agent follows the [Shared Context Protocol](./_shared/CONTEXT_PROTOCOL.md). - **Coverage Gaps**: Create task beads for missing tests - **Quality Metrics**: Store in T2 memory: mcp__plugin_conexus_nexus__memory_put(content="metrics", project="{project}", title="test-metrics.md" - **Recurring Patterns**: Store test quality patterns in T3 for reuse across sessions: - mcp__plugin_conexus_nexus__store_put(content="# Test pattern: {pattern-name}\n{description}", collection="knowledge", title="pattern-test-{pattern-name}", tags="testing,pattern" + mcp__plugin_conexus_nexus__store_put(content="# Test pattern: {pattern-name}\n{description}", collection="", title="pattern-test-{pattern-name}", tags="testing,pattern" - **Regression Risks**: Document in relay notes - **Test Result Snapshots**: Use T1 scratch to capture test run state during analysis: Capture test run result: diff --git a/conexus/commands/knowledge-tidy.md b/conexus/commands/knowledge-tidy.md index 07d48c5c5..eca1a3141 100644 --- a/conexus/commands/knowledge-tidy.md +++ b/conexus/commands/knowledge-tidy.md @@ -24,7 +24,7 @@ Invoke the **knowledge-tidying** skill (calls `mcp__plugin_conexus_nexus__nx_tid ``` mcp__plugin_conexus_nexus__nx_tidy( topic="", - collection="knowledge" + collection="" ) ``` @@ -32,7 +32,7 @@ Then store the organized knowledge: ``` mcp__plugin_conexus_nexus__store_put( content="", - collection="knowledge", + collection="", title="", tags="" ) diff --git a/conexus/hooks/scripts/pre_close_verification_hook.sh b/conexus/hooks/scripts/pre_close_verification_hook.sh index ef8df59f3..391f3ab87 100755 --- a/conexus/hooks/scripts/pre_close_verification_hook.sh +++ b/conexus/hooks/scripts/pre_close_verification_hook.sh @@ -355,7 +355,7 @@ import json, re, shlex, sys cmd = sys.stdin.read() VALUE_FLAGS = {'--reason', '--description', '--notes', '-m'} BEAD_RE = re.compile(r'\bnexus-[a-z0-9]+\b', re.IGNORECASE) -segments = re.split(r'(?:&&|\|\||;|\s\|\s|\bthen\b|\bdo\b)', cmd) +OPERATORS = {';', '&&', '||', '|', 'then', 'do'} seen, ids = set(), [] def _scan_text(text): @@ -365,13 +365,41 @@ def _scan_text(text): seen.add(tok) ids.append(tok) -for seg in segments: - try: - tokens = shlex.split(seg, posix=True) - except ValueError: - # Malformed quoting for this segment -- fall back to a raw scan - # rather than losing the segment's ids entirely. - _scan_text(seg) +# nexus-fv65m: tokenize the WHOLE command quote-aware first, then split on +# operator TOKENS. The old order split the raw string on ';' / '|' / 'do' +# / 'then' BEFORE tokenizing, so a semicolon or the word 'do' inside a +# quoted --reason broke the quotes, shlex failed on both halves, and the +# raw-scan fallback harvested every id-shaped word of the prose (a session +# name, a peer's bead) as a close target. +try: + all_tokens = shlex.split(cmd, posix=True) + segments = [[]] + for tok in all_tokens: + if tok in OPERATORS: + segments.append([]) + else: + segments[-1].append(tok) + tokenized = [(seg, None) for seg in segments if seg] +except ValueError: + # Malformed quoting for the whole command: segment the raw string and + # let each segment fall back to a raw scan where shlex still fails. + tokenized = [] + for raw_seg in re.split(r'(?:&&|\|\||;|\s\|\s|\bthen\b|\bdo\b)', cmd): + try: + tokenized.append((shlex.split(raw_seg, posix=True), None)) + except ValueError: + tokenized.append((None, raw_seg)) + +# Malformed quoting means the flag value could not be isolated by shlex; +# blank it textually (a value opened by an unbalanced quote runs to the +# end of the segment) so the raw scan never reads --reason prose as targets. +FLAG_VALUE_RE = re.compile( + r'(--reason|--description|--notes|-m)(=|\s+)(\x22[^\x22]*\x22?|\x27[^\x27]*\x27?|\S+)' +) + +for tokens, raw in tokenized: + if tokens is None: + _scan_text(FLAG_VALUE_RE.sub(r'\1\2 ', raw)) continue i = 0 while i < len(tokens): diff --git a/conexus/hooks/scripts/rdr_hook.py b/conexus/hooks/scripts/rdr_hook.py index 88e941156..6c1b02618 100755 --- a/conexus/hooks/scripts/rdr_hook.py +++ b/conexus/hooks/scripts/rdr_hook.py @@ -34,6 +34,8 @@ import re import subprocess +from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import TimeoutError as FutureTimeout from collections import Counter from pathlib import Path @@ -87,25 +89,68 @@ def _resolve_rdr_collection(repo_root: Path) -> str | None: return cat.collection_for_repo(repo_root, "rdr").render() except LookupError: pass # owner not registered yet, fall through - except Exception: - pass + except Exception as exc: # noqa: BLE001 — the SessionStart hook must never fail; the reason is logged, not swallowed (nexus-owna8) + _log_resolution_error("catalog", exc) try: from nexus.indexer import _repo_collection_or_legacy # noqa: PLC0415 return _repo_collection_or_legacy(repo_root, "rdr") - except Exception: + except Exception as exc: # noqa: BLE001 — same contract as above + _log_resolution_error("path-derived", exc) return None +def _log_resolution_error(source: str, exc: BaseException) -> None: + """nexus-owna8: a blind except here forced every session onto the + path-derived fallback, whose owner id can differ from the catalog's, and + the hook then reported a fully indexed tree as NOT indexed. The failure + is logged so the next false verdict names its cause.""" + try: + import structlog # noqa: PLC0415 + + structlog.get_logger(__name__).warning( + "rdr_hook_collection_resolution_failed", + source=source, error_type=type(exc).__name__, error=str(exc), + ) + except Exception: # noqa: BLE001 — even the log is best-effort in a hook + pass + + +# hooks.json caps this hook at 10s and the T3 client's own request timeout +# is 30s, so a slow-but-reachable store would have the harness kill the hook +# before the listing fallback ever ran (review of a71c93e92). Both halves +# get a budget that fits inside the cap. +_T3_DEADLINE_S = 4.0 +_LISTING_TIMEOUT_S = 4 + + def _collection_exists(target: str) -> bool: + """Whether *target* exists in T3, asked of the store itself + (nexus-owna8: the previous substring match over ``nx collection list`` + output missed a listed collection when the resolved name and the + listed name were rendered differently). The T3 call runs under + ``_T3_DEADLINE_S``; past it, or on any error, the listing is the fallback.""" + try: + from nexus.db import make_t3 # noqa: PLC0415 + + pool = ThreadPoolExecutor(max_workers=1) + try: + future = pool.submit(lambda: bool(make_t3().collection_exists(target))) + return future.result(timeout=_T3_DEADLINE_S) + finally: + pool.shutdown(wait=False) + except FutureTimeout: + _log_resolution_error("t3-exists", TimeoutError(f"no answer within {_T3_DEADLINE_S}s")) + except Exception as exc: # noqa: BLE001 — the hook must never fail; fall back to the listing + _log_resolution_error("t3-exists", exc) try: result = subprocess.run( ["nx", "collection", "list"], - capture_output=True, text=True, timeout=10, + capture_output=True, text=True, timeout=_LISTING_TIMEOUT_S, ) if result.returncode == 0: return target in result.stdout - except Exception: + except Exception: # noqa: BLE001 — best-effort fallback pass return False @@ -247,14 +292,23 @@ def _rdr_dir(root: Path) -> Path: def _rdr_files(rdr_dir: Path) -> list[Path]: """The RDR documents directly under *rdr_dir* -- non-recursive, so - ``docs/rdr/post-mortem/`` (a separate document set) is never counted, - with the index/template/agents files excluded by name.""" + ``docs/rdr/post-mortem/`` (a separate document set) never carries an + RDR status, with the index/template/agents files excluded by name.""" return [ p for p in rdr_dir.glob("*.md") if p.name.lower() not in _EXCLUDE_FILES and _extract_rdr_id(p) is not None ] +def _indexed_document_count(rdr_dir: Path) -> int: + """Every markdown file the repo indexer registers under *rdr_dir*: the + same recursive walk as ``nx index repo`` (joint/ and post-mortem/ + included, README and AGENTS included). nexus-owna8: the hook reported + the RDR count against a collection holding this count, and the two + numbers (215 vs 298) read as a partial index.""" + return sum(1 for p in rdr_dir.rglob("*.md") if p.is_file() and not p.is_symlink()) + + def main() -> None: root = _repo_root() if root is None: @@ -274,11 +328,12 @@ def main() -> None: statuses = _load_all_t2_statuses(repo_name) counts = _rdr_status_counts(repo_name, statuses) + documents = _indexed_document_count(rdr_dir) if counts: breakdown = ", ".join(f"{n} {s}" for s, n in counts.most_common()) - status_info = f"{len(rdr_files)} documents ({breakdown})" + status_info = f"{documents} documents ({len(rdr_files)} RDRs: {breakdown})" else: - status_info = f"{len(rdr_files)} document(s)" + status_info = f"{documents} documents ({len(rdr_files)} RDRs)" if indexed: print(f"RDR: {status_info}, indexed in {rdr_collection}") diff --git a/conexus/skills/architecture/SKILL.md b/conexus/skills/architecture/SKILL.md index 7eb1a6bf9..0c4d01901 100644 --- a/conexus/skills/architecture/SKILL.md +++ b/conexus/skills/architecture/SKILL.md @@ -108,9 +108,9 @@ The architect-planner uses `nx search --corpus code --hybrid` for discovery (30- ## Agent-Specific PRODUCE -- **Architecture Designs**: Store in T3 via store_put tool: content="# Architecture: {component}\n{design}", collection="knowledge", title="architecture-{project}-{component}", tags="architecture,design" +- **Architecture Designs**: Store in T3 via store_put tool: content="# Architecture: {component}\n{design}", collection="", title="architecture-{project}-{component}", tags="architecture,design" - **Execution Plans**: Store in T2 memory via memory_put tool: content="plan", project="{project}", title="plan-{component}.md", ttl="30d" -- **Design Decisions**: Store in T3 via store_put tool: content="# Decision: {topic}\n{rationale}", collection="knowledge", title="decision-architect-{topic}", tags="decision,architecture" +- **Design Decisions**: Store in T3 via store_put tool: content="# Decision: {topic}\n{rationale}", collection="", title="decision-architect-{topic}", tags="decision,architecture" - **Beads**: Epic → Phase → Task hierarchy with `/beads:dep add` for dependencies - **Design Notes**: Use T1 scratch for working notes during architecture analysis: - scratch tool: action="put", content="Design consideration: {note}", tags="architecture,design" diff --git a/conexus/skills/catalog/SKILL.md b/conexus/skills/catalog/SKILL.md index 74810c14b..d1e9fd678 100644 --- a/conexus/skills/catalog/SKILL.md +++ b/conexus/skills/catalog/SKILL.md @@ -105,7 +105,7 @@ mcp__plugin_conexus_nexus__scratch( ) # 3. Now store_put — auto-linker fires automatically -mcp__plugin_conexus_nexus__store_put(content="...", collection="knowledge", title="...", agent="") +mcp__plugin_conexus_nexus__store_put(content="...", collection="", title="...", agent="") # → auto-linker reads link-context and creates: new_doc → 1.1.440 (relates) ``` diff --git a/conexus/skills/code-review/SKILL.md b/conexus/skills/code-review/SKILL.md index f6a2e9fc9..aeab42325 100644 --- a/conexus/skills/code-review/SKILL.md +++ b/conexus/skills/code-review/SKILL.md @@ -162,7 +162,7 @@ The code-review-expert agent uses hypothesis-driven review: - **Session Scratch (T1)**: scratch tool: action="put", content="", tags="review" — working review notes during session; flagged items auto-promote to T2 at session end - **nx memory**: memory_put tool: content="...", project="{project}", title="review-findings.md" — persistent review findings across sessions -- **nx store** (optional): store_put tool: content="...", collection="knowledge", title="pattern-code-{topic}", tags="pattern,code-review" — recurring violation patterns worth long-term storage +- **nx store** (optional): store_put tool: content="...", collection="", title="pattern-code-{topic}", tags="pattern,code-review" — recurring violation patterns worth long-term storage - **Beads**: creates bug beads (`/beads:create "..." -t bug`) for critical findings that require follow-up work ## Success Criteria diff --git a/conexus/skills/codebase-analysis/SKILL.md b/conexus/skills/codebase-analysis/SKILL.md index 8f9f31f19..4a8360c5a 100644 --- a/conexus/skills/codebase-analysis/SKILL.md +++ b/conexus/skills/codebase-analysis/SKILL.md @@ -51,7 +51,7 @@ The codebase-deep-analyzer uses `mcp__plugin_conexus_sequential-thinking__sequen 2. Gather evidence from code structure, naming, dependencies 3. Validate/refute hypothesis against actual code 4. Map module/package structure and document patterns -5. Persist findings to nx store: mcp__plugin_conexus_nexus__store_put(content="...", collection="knowledge", title="architecture-{project}-{component}", tags="architecture" +5. Persist findings to nx store: mcp__plugin_conexus_nexus__store_put(content="...", collection="", title="architecture-{project}-{component}", tags="architecture" ## Success Criteria @@ -66,7 +66,7 @@ The codebase-deep-analyzer uses `mcp__plugin_conexus_sequential-thinking__sequen Outputs generated by the codebase-deep-analyzer agent: -- **T3 knowledge**: Architecture findings via store_put tool: content="# Architecture: {project}/{component}\n{findings}", collection="knowledge", title="architecture-{project}-{component}", tags="architecture,analysis" +- **T3 knowledge**: Architecture findings via store_put tool: content="# Architecture: {project}/{component}\n{findings}", collection="", title="architecture-{project}-{component}", tags="architecture,analysis" - **T2 memory**: Subtask findings promoted via scratch_manage tool: action="promote", entry_id="", project="{project}", title="analysis-{component}.md" - **T1 scratch**: Per-subtask findings via scratch tool: action="put", content="Subtask {N} findings: {summary}", tags="analysis,subtask-{N}" (flagged for T2 at phase end) diff --git a/conexus/skills/debugging/SKILL.md b/conexus/skills/debugging/SKILL.md index 96d940627..98bda4ada 100644 --- a/conexus/skills/debugging/SKILL.md +++ b/conexus/skills/debugging/SKILL.md @@ -102,7 +102,7 @@ The debugger uses `mcp__plugin_conexus_sequential-thinking__sequentialthinking`: Outputs generated by the debugger agent: -- **T3 knowledge**: Root cause analysis via store_put tool: content="# Debug: {issue}\nRoot cause: {cause}", collection="knowledge", title="debug-finding-{issue}", tags="debug" and prevention patterns via store_put tool: content="# Prevention: {topic}\n{pattern}", collection="knowledge", title="pattern-prevention-{topic}", tags="pattern,prevention" +- **T3 knowledge**: Root cause analysis via store_put tool: content="# Debug: {issue}\nRoot cause: {cause}", collection="", title="debug-finding-{issue}", tags="debug" and prevention patterns via store_put tool: content="# Prevention: {topic}\n{pattern}", collection="", title="pattern-prevention-{topic}", tags="pattern,prevention" - **T2 memory**: Debugging journal via memory_put tool: content="content", project="{project}", title="debug-journal.md"; hypothesis chain via scratch_manage tool: action="promote", entry_id="", project="{project}", title="debug-hypothesis-chain.md" - **T1 scratch**: Hypothesis chain via scratch tool: action="put", content="Hypothesis {N}: {description}\nEvidence: {evidence}\nStatus: testing", tags="debug,hypothesis-{N}" (promoted to T2 when root cause found) diff --git a/conexus/skills/deep-analysis/SKILL.md b/conexus/skills/deep-analysis/SKILL.md index 93670d739..8d13d41b6 100644 --- a/conexus/skills/deep-analysis/SKILL.md +++ b/conexus/skills/deep-analysis/SKILL.md @@ -97,7 +97,7 @@ The deep-analyst uses `mcp__plugin_conexus_sequential-thinking__sequentialthinki ## Agent-Specific PRODUCE -- **Analysis Findings**: Store in T3 via store_put tool: content="# Analysis: {topic}\n{findings}", collection="knowledge", title="analysis-{topic}-{date}", tags="analysis" +- **Analysis Findings**: Store in T3 via store_put tool: content="# Analysis: {topic}\n{findings}", collection="", title="analysis-{topic}-{date}", tags="analysis" - **Hypothesis Results**: Document with confidence levels in T3 - **Recommendations**: Include in output as "Recommended Next Step" for caller to dispatch strategic-planner - **Analysis Chain**: Use T1 scratch to track hypothesis progression during investigation: diff --git a/conexus/skills/nexus/SKILL.md b/conexus/skills/nexus/SKILL.md index 8efde2c12..12969712a 100644 --- a/conexus/skills/nexus/SKILL.md +++ b/conexus/skills/nexus/SKILL.md @@ -59,8 +59,8 @@ mcp__plugin_conexus_nexus__memory_get(project="{repo}", title="file.md" # mcp__plugin_conexus_nexus__memory_search(query="query", project="{repo}" # Knowledge (T3) -mcp__plugin_conexus_nexus__store_put(content="content", collection="knowledge", title="title", tags="tag", agent="" -mcp__plugin_conexus_nexus__store_list(collection="knowledge" +mcp__plugin_conexus_nexus__store_put(content="content", collection="", title="title", tags="tag", agent="" +mcp__plugin_conexus_nexus__store_list(collection="" # Scratch (T1) mcp__plugin_conexus_nexus__scratch(action="put", content="working note" diff --git a/conexus/skills/nexus/reference.md b/conexus/skills/nexus/reference.md index 7453f4fb7..b269d9507 100644 --- a/conexus/skills/nexus/reference.md +++ b/conexus/skills/nexus/reference.md @@ -234,8 +234,8 @@ Store content in the T3 permanent knowledge store. | `session` | str | `""` | Optional explicit session_id override | ``` -mcp__plugin_conexus_nexus__store_put(content="finding text", collection="knowledge", title="research-topic", tags="arch", agent="" -mcp__plugin_conexus_nexus__store_put(content="notes", collection="knowledge", title="sprint-notes", ttl="30d", agent="" +mcp__plugin_conexus_nexus__store_put(content="finding text", collection="", title="research-topic", tags="arch", agent="" +mcp__plugin_conexus_nexus__store_put(content="notes", collection="", title="sprint-notes", ttl="30d", agent="" ``` **TTL formats**: `30d` (30 days), `4w` (4 weeks), `permanent` or `never` (no expiry). @@ -265,7 +265,7 @@ List entries in a T3 knowledge collection. | `docs` | bool | `false` | Show unique documents instead of individual chunks. Deduplicates by content_hash, shows title, chunk count, page count, extraction method | ``` -mcp__plugin_conexus_nexus__store_list(collection="knowledge" # auto-promoted to conformant +mcp__plugin_conexus_nexus__store_list(collection="" # auto-promoted to conformant mcp__plugin_conexus_nexus__store_list(collection="knowledge__art-1-1__voyage-context-3__v1", docs=true # document-level view (RDR-103: 4-segment) mcp__plugin_conexus_nexus__store_list(collection="knowledge__notes-1-1__voyage-context-3__v1", limit=50, offset=100 ``` diff --git a/conexus/skills/orchestration/SKILL.md b/conexus/skills/orchestration/SKILL.md index 26ef3de60..e7fb2b32d 100644 --- a/conexus/skills/orchestration/SKILL.md +++ b/conexus/skills/orchestration/SKILL.md @@ -148,7 +148,7 @@ dispatch below. - Design gate first: when a plan marks an item DESIGN DECISION FIRST, the orchestrator locks the design-of-record in T1 before any code dispatch; deviations from a plan's recommendation need the human's nod, named fallbacks do not. - Review at every seam: each arc gets the stacked dual review independently; cross-arc integration points get named in reviewer briefs. - Commit order: arcs commit pathspec-limited in dependency order (lib contract before doc text referencing it), one arc per commit. -- service/ builds: one builder at a time. Never dispatch a bare `./mvnw`/`mvn` invocation while another agent might also be building `service/`; both write `service/target` and a collision corrupts jOOQ codegen mid-build (nexus-c00dw). Any ad hoc Maven call goes through `scripts/mvnw-leased.sh ` (it takes the `scripts/lib/build-lease.sh` lease, cds into `service/`, and passes args through); `scripts/build-gate-jar.sh` takes the same lease internally for the stamped-jar path. The lease is shared by every worktree of the repo (git common dir, nexus-g6xpa), and a live holder is waited for (bounded by `NX_BUILD_LEASE_WAIT`, default 3600s; 0 refuses immediately with rc 75 naming the holder) — one engine build or suite per box, never bypassed. `build-gate-jar.sh` serves a cached jar when service/ content is unchanged (`NX_GATE_JAR_CACHE=off` disables). Worktree agents run only the tests scoped to their change; the full engine suite runs once, in the primary, at cherry-pick time. A queued build can outlive the Bash tool's 600s ceiling, so a build or suite an agent dispatches goes through `run_in_background` (the suite alone already exceeds that ceiling); the waiting line names the holder's pid, label and command. Coverage (every in-repo caller, not just these two) is enforced by `scripts/lib/bare_mvnw_lint_test.sh`. +- service/ builds: one builder at a time. Never dispatch a bare `./mvnw`/`mvn` invocation while another agent might also be building `service/`; both write `service/target` and a collision corrupts jOOQ codegen mid-build (nexus-c00dw). Any ad hoc Maven call goes through `scripts/mvnw-leased.sh ` (it takes the `scripts/lib/build-lease.sh` lease, cds into `service/`, and passes args through); a substrate-backed `pytest` on the box refuses to start with exit 75 while that lease is held (set `NX_BUILD_LEASE_WAIT=` to wait, nexus-pv93h); `scripts/build-gate-jar.sh` takes the same lease internally for the stamped-jar path. The lease is shared by every worktree of the repo (git common dir, nexus-g6xpa), and a live holder is waited for (bounded by `NX_BUILD_LEASE_WAIT`, default 3600s; 0 refuses immediately with rc 75 naming the holder) — one engine build or suite per box, never bypassed. `build-gate-jar.sh` serves a cached jar when service/ content is unchanged (`NX_GATE_JAR_CACHE=off` disables). Worktree agents run only the tests scoped to their change; the full engine suite runs once, in the primary, at cherry-pick time. A queued build can outlive the Bash tool's 600s ceiling, so a build or suite an agent dispatches goes through `run_in_background` (the suite alone already exceeds that ceiling); the waiting line names the holder's pid, label and command. Coverage (every in-repo caller, not just these two) is enforced by `scripts/lib/bare_mvnw_lint_test.sh`. ## Quick Routing diff --git a/conexus/skills/rdr-gate/SKILL.md b/conexus/skills/rdr-gate/SKILL.md index 24e90eacb..69a542e7c 100644 --- a/conexus/skills/rdr-gate/SKILL.md +++ b/conexus/skills/rdr-gate/SKILL.md @@ -216,7 +216,7 @@ by hand. The rules it applies (`review-rounds.toml`, contract `rdr-gate`): ### On Pass -1. Store the critique in T2 FIRST: mcp__plugin_conexus_nexus__memory_put(content="{critique}", project="{repo}_rdr", title="{id}-gate-critique-{date}", ttl="permanent", tags="rdr,gate,critique"). Same-day re-gates append a letter (`{date}b`, `{date}c`). T2 is where the preamble reads; a T3 copy (collection="knowledge", title="gate-rdr-NNN-{date}") is optional and never the only copy. +1. Store the critique in T2 FIRST: mcp__plugin_conexus_nexus__memory_put(content="{critique}", project="{repo}_rdr", title="{id}-gate-critique-{date}", ttl="permanent", tags="rdr,gate,critique"). Same-day re-gates append a letter (`{date}b`, `{date}c`). T2 is where the preamble reads; a T3 copy (collection="", title="gate-rdr-NNN-{date}") is optional and never the only copy. 2. Write gate result to T2: mcp__plugin_conexus_nexus__memory_put(content="outcome: PASSED\ndate: YYYY-MM-DD\ncritical_count: 0\nsignificant_count: N\nobservation_count: N\nship_blockers: 0\nsummary: One-sentence summary\ncritique: {repo}_rdr/{id}-gate-critique-{date}\ncommit: >\nfix_check: <{repo}_rdr/{id}-fix-check-, sha equal to commit:, or 'none (no change since )'; mandatory on every re-gate>\nresiduals: \nprior: [] ( ), ", project="{repo}_rdr", title="{id}-gate-latest", ttl="permanent", tags="rdr,gate"). `critique:`, `commit:` and `prior:` are what the re-gate block reads; `fix_check:` must equal `commit:`. 3. Append gate findings to the RDR's Revision History section 4. Print: `> Run '/conexus:rdr-accept ' to accept this RDR.` @@ -226,7 +226,7 @@ Status remains **Draft** until the author explicitly accepts via `/conexus:rdr-a ### On Fail 1. Store the critique in T2 first (same title scheme as On Pass), then write the gate result (same format, `outcome: "BLOCKED"`, with `critique:` and `commit:`). The next `nx rdr preamble rdr-gate` run turns these two fields into the Layer 0 block. -2. T3 copy optional (collection="knowledge", title="gate-rdr-NNN-{date}", tags="rdr,gate,critique,blocked") +2. T3 copy optional (collection="", title="gate-rdr-NNN-{date}", tags="rdr,gate,critique,blocked") 3. Display the critique with specific sections to address 4. Status remains Draft diff --git a/conexus/skills/rdr-research/SKILL.md b/conexus/skills/rdr-research/SKILL.md index 9669e01f0..4a5b8bd5e 100644 --- a/conexus/skills/rdr-research/SKILL.md +++ b/conexus/skills/rdr-research/SKILL.md @@ -165,7 +165,7 @@ For additional optional fields, see [RELAY_TEMPLATE.md](../../agents/_shared/REL Outputs generated by dispatched agents (deep-research-synthesizer, codebase-deep-analyzer): -- **T3 knowledge**: Research findings via store_put tool: content="# RDR NNN Research: {topic}\n{findings}", collection="knowledge", title="research-rdr-NNN-{topic}", tags="rdr,research" +- **T3 knowledge**: Research findings via store_put tool: content="# RDR NNN Research: {topic}\n{findings}", collection="", title="research-rdr-NNN-{topic}", tags="rdr,research" - **T2 memory**: Finding records via memory_put tool: content="...", project="{repo}_rdr", title="NNN-research-{seq}", ttl="permanent", tags="rdr,research,{classification}" - **T1 scratch**: Working notes during investigation via scratch tool: action="put", content="RDR NNN research: {hypothesis}", tags="rdr,research" (promoted to T2 on completion) diff --git a/conexus/skills/research-synthesis/SKILL.md b/conexus/skills/research-synthesis/SKILL.md index fd8512aa8..1f565e8ad 100644 --- a/conexus/skills/research-synthesis/SKILL.md +++ b/conexus/skills/research-synthesis/SKILL.md @@ -96,7 +96,7 @@ The agent uses `mcp__plugin_conexus_sequential-thinking__sequentialthinking`: ## Agent-Specific PRODUCE -- **Research Synthesis**: Store in T3 via store_put tool: content="# Research: {topic}\n{content}", collection="knowledge", title="research-{topic}-{date}", tags="research,{domain}" +- **Research Synthesis**: Store in T3 via store_put tool: content="# Research: {topic}\n{content}", collection="", title="research-{topic}-{date}", tags="research,{domain}" - **Source Citations**: Include in document content (not separate) - **Knowledge Gaps**: Create research beads for follow-up - **Cross-Reference Maps**: Document relationships in T3 document content diff --git a/conexus/skills/strategic-planning/SKILL.md b/conexus/skills/strategic-planning/SKILL.md index 915f657ae..2098345f9 100644 --- a/conexus/skills/strategic-planning/SKILL.md +++ b/conexus/skills/strategic-planning/SKILL.md @@ -85,7 +85,7 @@ The agent uses `mcp__plugin_conexus_sequential-thinking__sequentialthinking`: Outputs generated by the strategic-planner agent: -- **T3 knowledge**: Validated architectural decisions via store_put tool: content="# Decision: {topic}\n{rationale}", collection="knowledge", title="decision-planner-{topic}", tags="decision,planning" +- **T3 knowledge**: Validated architectural decisions via store_put tool: content="# Decision: {topic}\n{rationale}", collection="", title="decision-planner-{topic}", tags="decision,planning" - **T2 memory**: Continuation state via memory_put tool: content="content", project="{project}", title="continuation-state.md", ttl="30d" - **T1 scratch**: Analysis notes via scratch tool: action="put", content="Planning note: {consideration}", tags="planning,analysis" (flagged for T2 at plan completion) - **Beads**: Epic and task beads with context links, success criteria, and file paths diff --git a/conexus/skills/substantive-critique/SKILL.md b/conexus/skills/substantive-critique/SKILL.md index cd2c1e3e1..6724fb30f 100644 --- a/conexus/skills/substantive-critique/SKILL.md +++ b/conexus/skills/substantive-critique/SKILL.md @@ -93,7 +93,7 @@ The agent uses `mcp__plugin_conexus_sequential-thinking__sequentialthinking`: Outputs generated by the substantive-critic agent: -- **T3 knowledge**: Critique findings via store_put tool: content="# Critique: {artifact}\n{findings}", collection="knowledge", title="critique-{component}-{date}", tags="critique,review" +- **T3 knowledge**: Critique findings via store_put tool: content="# Critique: {artifact}\n{findings}", collection="", title="critique-{component}-{date}", tags="critique,review" - **T2 memory**: Session findings via memory_put tool: content="content", project="{project}", title="critique-{date}.md", ttl="30d" - **T1 scratch**: Critique notes during analysis via scratch tool: action="put", content="Critique note: {finding}", tags="critique,review" (promoted to T2 on completion) diff --git a/conexus/skills/using-nx-skills/SKILL.md b/conexus/skills/using-nx-skills/SKILL.md index cad353eb6..9c8e10391 100644 --- a/conexus/skills/using-nx-skills/SKILL.md +++ b/conexus/skills/using-nx-skills/SKILL.md @@ -95,6 +95,7 @@ Write path: T1 (immediate, shared with siblings) → `--persist` flag to T2 (sur | `nx_answer` for a file:line, single-fact, or already-in-T2 question | `search` / `query` (seconds; mean about 8s, tail to about 45s) or Serena. `nx_answer`'s p50 is 80s | | Researching from scratch without checking T3 | `nx search` first (seconds); prior sessions may have already answered | | Returning findings without storing them | `store_put` (T3) or `memory_put` (T2) before returning | +| `store_put(collection="knowledge")` or any placeholder (`default`, `test`, a session, a source app) | `collection=""`: a durable subject area a reader would browse (`distributed-systems`); reuse an existing one (`nx collection list`) before minting; rules in docs/collections.md | | Test fails → try a different fix | `/conexus:debug` | | Implement undesigned work without brainstorming-gate | `brainstorming-gate` first (unless a design of record exists) | | Plan exists, start implementing | `/conexus:plan-audit` first | diff --git a/conexus/skills/writing-nx-skills/SKILL.md b/conexus/skills/writing-nx-skills/SKILL.md index 5c7c4dc1a..bb98317ce 100644 --- a/conexus/skills/writing-nx-skills/SKILL.md +++ b/conexus/skills/writing-nx-skills/SKILL.md @@ -80,7 +80,7 @@ Do NOT inline the relay template. One source of truth: `agents/_shared/RELAY_TEM Skills that produce outputs must document which tiers they use: - **T1 scratch**: scratch tool: action="put", content="...", tags="..." — session-scoped ephemeral notes - **T2 memory**: memory_put tool: content="...", project="{repo}", title="file.md" — cross-session state -- **T3 knowledge**: store_put tool: content="...", collection="knowledge", title="..." — permanent validated findings +- **T3 knowledge**: store_put tool: content="...", collection="", title="..." — permanent validated findings ### Registry Integration diff --git a/docs/contributing.md b/docs/contributing.md index ddebc805c..66b0ff4de 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -177,6 +177,7 @@ Do not bump these without testing the full chunking pipeline. - Branch naming: `feature/-` - **Integration branch is `develop`.** Open PRs against `develop`, not `main`. `main` carries the plugin marketplace surface; the develop split protects it from in-flight churn. Releases promote `develop` to `main` via merge (or merge-then-tag). - `main` is fully PR-gated, release version-bumps included (nexus-mkj6u replaced the prior direct-to-main carve-out). See Release Process below. +- **Pushing `develop` from the shared checkout goes through `scripts/git-push-develop.sh ...`** (nexus-9wxu6). Every session commits as the same git user, so the outbound range cannot be attributed by author; the script fetches, lists `origin/develop..develop`, and pushes only when that range equals the commits you vouch for on the command line. An unvouched commit, a vouched commit that is not in the range, or a diverged branch refuses with a `PUSH_REFUSED_*` line and pushes nothing. From a detached worktree based on `origin/develop`, `NX_PUSH_SOURCE=HEAD` pushes your worktree's commits and leaves the local `develop` (a peer's in-flight tree) alone. `git commit --amend` in the primary checkout is refused by the user-level policy hook when HEAD is not a commit this session made (`tests/fixtures/hal_git_policy_hook.py` rule 3); amend in a worktree you own, or make a new commit. The same hook (rule 4) denies a bare `git push` to `develop` in any repo whose toplevel carries the script, so the script is the only unblocked path; `# routing-allow: ` is the audited escape. - Use `bd` (beads, **≥ 1.0.0**: `brew install beads` or `brew upgrade beads`) for task tracking. Earlier 0.x versions reject the comma-separated `--status` flag the close-skill preamble uses; the bead advisory will silently report no open beads on stale installs. - **Code review**: Plans include review tasks after implementation phases. Use `/conexus:review-code` or dispatch `code-review-expert` at the designated plan steps. @@ -527,7 +528,7 @@ Every step below is **required**. Missing any one of them has caused problems in git checkout develop && git pull git merge origin/main --no-edit # trivially clean right after a release: # the release branch just CONTAINED develop - git push origin develop + scripts/git-push-develop.sh HEAD # the merge commit vouches for what it merged in ``` Earned by the 2026-07-23 incident: from 6.12.0 through 6.17.0 no release was ever merged back, so develop's seven manifests froze at 6.11.0 — all @@ -607,7 +608,7 @@ gh pr create --base main --head plugin-release/X.Y.Z-n --title "plugin release: gh pr merge --merge git tag -a plugin-vX.Y.Z-n -m "plugin-vX.Y.Z-n" git push origin plugin-vX.Y.Z-n # fires the verify-only plugin-release.yml -scripts/plugin_cut_back_merge.sh . && git push origin develop +scripts/plugin_cut_back_merge.sh . && scripts/git-push-develop.sh HEAD ``` The back-merge is NEVER a bare `git merge origin/main`: the cut commit diff --git a/docs/wire-contract-pending.md b/docs/wire-contract-pending.md index a47c47b2d..312757d48 100644 --- a/docs/wire-contract-pending.md +++ b/docs/wire-contract-pending.md @@ -59,10 +59,11 @@ carries no method signature for a contract change to reconcile against) is a ## Unshipped -- `c78be96ad8b68bb55a3be0069403c4e632578261` -- bead nexus-zewg3 -- engine tag `engine-service-v0.1.108` -- [additive] `GET /v1/catalog/manifest/chashes` gains an OPT-IN, additive `tombstone_protected_count` field -- computed ONLY when the request carries `with_tombstone_protected=1`/`true` -- reporting how many of the returned chashes are held alive ONLY by a pending tombstone (never a live document), tenant-wide, per `CatalogRepository.tombstoneProtectedChunkCount`. `nx t3 gc`'s report line reads this instead of re-deriving it client-side. Engine half (this commit): `CatalogHandler.handleManifestChashes`, `CatalogRepository.tombstoneProtectedChunkCount`. Client half: `392778bdffdce7f428852d7b94347ca479224401` (`HttpCatalogClient.chashes_for_collection_with_tombstone_protected`, `nx t3 gc`'s report line), landed as a SEPARATE commit -- the mechanized per-commit detector (`scripts/check_wire_contract_pairing.py`) cannot see this pairing, since neither commit alone touches both `service/` and the client wire surface in the same commit; this entry records the risk by hand (code review T2 `nexus/critique-nexus-zewg3-engine-side` Significant 2). No existing request or response FIELD changed shape, only added to, and only when explicitly requested. Direction safety, both directions: OLD client + NEW engine -- `chashes_for_collection` (the indexer's hot per-collection call, `nexus.indexer._prune_deleted_files`, invoked on every `nx index repo` run) never passes `with_tombstone_protected`, so the field is never computed or returned to a caller that doesn't ask for it; an old client that doesn't know the key simply never requests or reads it, no behavior change, no added query cost. NEW client + OLD engine -- an engine older than this commit ignores the unrecognized `with_tombstone_protected` query param (same envelope as before) and never emits the key, so `chashes_for_collection_with_tombstone_protected`'s `"tombstone_protected_count" in result` check is False and the second return value is `None`, never a fabricated `0`; `nx t3 gc` reports "Protected by pending tombstones: unavailable on this engine" (`test_gc_reports_tombstone_protected_as_unavailable_on_an_older_engine`). Ack condition: the client release whose `REQUIRED_ENGINE_VERSION` bumps to the engine tag carrying this commit. +(none) ## Shipped +- `c78be96ad8b68bb55a3be0069403c4e632578261` -- bead nexus-zewg3 -- shipped in `v7.36.0` -- engine half engine-service-v0.1.108 (tagged on fa21c24ed; deployed 2026-09-08T01:13Z; STEP-6 PASSED gate-report-20260908T011402Z-v011.json) -- [additive] `GET /v1/catalog/manifest/chashes` gains an OPT-IN, additive `tombstone_protected_count` field -- computed ONLY when the request carries `with_tombstone_protected=1`/`true` -- reporting how many of the returned chashes are held alive ONLY by a pending tombstone (never a live document), tenant-wide, per `CatalogRepository.tombstoneProtectedChunkCount`. `nx t3 gc`'s report line reads this instead of re-deriving it client-side. Engine half (this commit): `CatalogHandler.handleManifestChashes`, `CatalogRepository.tombstoneProtectedChunkCount`. Client half: `392778bdffdce7f428852d7b94347ca479224401` (`HttpCatalogClient.chashes_for_collection_with_tombstone_protected`, `nx t3 gc`'s report line), landed as a SEPARATE commit -- the mechanized per-commit detector (`scripts/check_wire_contract_pairing.py`) cannot see this pairing, since neither commit alone touches both `service/` and the client wire surface in the same commit; this entry records the risk by hand (code review T2 `nexus/critique-nexus-zewg3-engine-side` Significant 2). No existing request or response FIELD changed shape, only added to, and only when explicitly requested. Direction safety, both directions: OLD client + NEW engine -- `chashes_for_collection` (the indexer's hot per-collection call, `nexus.indexer._prune_deleted_files`, invoked on every `nx index repo` run) never passes `with_tombstone_protected`, so the field is never computed or returned to a caller that doesn't ask for it; an old client that doesn't know the key simply never requests or reads it, no behavior change, no added query cost. NEW client + OLD engine -- an engine older than this commit ignores the unrecognized `with_tombstone_protected` query param (same envelope as before) and never emits the key, so `chashes_for_collection_with_tombstone_protected`'s `"tombstone_protected_count" in result` check is False and the second return value is `None`, never a fabricated `0`; `nx t3 gc` reports "Protected by pending tombstones: unavailable on this engine" (`test_gc_reports_tombstone_protected_as_unavailable_on_an_older_engine`). Client half shipped in conexus 7.36.0 (REQUIRED_ENGINE_VERSION (0,1,108)). - `6e94e5b544ee416fbd76f57b5e458bd703d39fdb` -- bead nexus-8hdg9 -- shipped in `v7.33.0` -- engine half engine-service-v0.1.105 (tagged on 01639ae88; deployed 2026-09-06T19:54Z after conexus's PITR-fork walk rehearsal; STEP-6 PASSED gate-report-20260906T195544Z-v011.json, recorded in deployed-engine-version 20:05Z) -- [additive] phases 3/4 observability: `GET /v1/status`'s two activity shapes GROW by one field, `deadline_aborts_total` (a monotonic per-embedder count of embed calls aborted at a cooperative request-deadline check point -- `Bge768Embedder.embedSubBatched` between ONNX sub-batches, `CceEmbedder.embedParallel` before each collected future -- raising `RequestDeadlineExceededException`, the 503 + `Retry-After` the `e146905867a6` entry below declared). Present in every `embedder_activity` entry and in `local_embed_activity` when that is non-null (`StatusHandler.appendSnapshot`, from `EmbedActivitySnapshot.deadlineAbortsTotal`). This is the A/B gate's instrument: a healthy indexing run must read 0. The same commit family makes the phase-2/5 deadline REACHABLE (the check points now exist) and adds the `NX_EMBED_DEADLINE_MAX_MS` hard ceiling (default 900000 ms) that clamps both the `X-Nexus-Request-Deadline-Ms` header budget and the `NX_EMBED_DEADLINE_MS` default; neither is a wire-shape change (the header and the 503 were already declared below), only the status field is. Direction safety, both directions: OLD CLIENT + NEW ENGINE -- the new field is present in the body but `http_engine_status.py` reads only the keys it knows (`active`, `chunks_done_total`, `last_activity_age_ms`, ...) and ignores unknown ones, so nothing on an old client changes behavior; the newly reachable 503 lands on the client's existing `_GATEWAY_RETRY_CODES` ladder exactly as the `e146905867a6` entry describes. NEW CLIENT + OLD ENGINE -- no client code in this commit family reads `deadline_aborts_total` yet (the A/B gate reads it from the container's engine directly, not through the client), so a new client against an old engine that lacks the field sees nothing different; an old engine also never raises the 503, so the client path is byte-identical to today. No existing request or response FIELD changed shape, only added to. Ack condition: the client release whose `REQUIRED_ENGINE_VERSION` bumps to the engine tag carrying this commit. - `41d4aab7e` -- bead nexus-8hdg9 -- shipped in `v7.33.0` -- engine half engine-service-v0.1.105 (tagged on 01639ae88; deployed 2026-09-06T19:54Z after conexus's PITR-fork walk rehearsal; STEP-6 PASSED gate-report-20260906T195544Z-v011.json, recorded in deployed-engine-version 20:05Z) -- [additive] phase 5: the client declares its own embed budget on `POST /v1/vectors/upsert-chunks` via a NEW advisory request header, `X-Nexus-Request-Deadline-Ms` (client half: `http_vector_client._request_once` stamps `_UPSERT_CHUNKS_DEADLINE_MS` = socket timeout minus a 60s margin = 540000, route-keyed on the `/upsert-chunks` suffix only, never on the search family; the margin is pinned by `tests/test_embed_deadline_default_ordering.py`), and the engine uses it IN PLACE OF the `NX_EMBED_DEADLINE_MS` default when minting `RequestContext.deadlineNanos()` (engine half: `RequestDeadline.resolveBudgetMs` called from `AuthFilter.doFilter` -- a present, positive, numeric header wins outright, larger than the env default included, since the design's reason for the header is that a server-guessed deadline kills healthy slow requests the client would still wait for; the env default is the fallback only; a malformed or non-positive header is IGNORED, never a 400, because the header is advisory). Still UNREACHABLE as a 503 in practice: no embed-loop check point reads the deadline yet (phases 3/4, each gated on its own timed A/B). Direction safety, both directions: OLD CLIENT + NEW ENGINE -- the header is absent, `resolveBudgetMs(null, envDefault)` returns the env default, the deadline minted is byte-identical to phase 2's, no behavior change (proven by `AuthFilterTest.absentHeaderFallsBackToEnvDefault`); NEW CLIENT + OLD ENGINE -- `X-Nexus-Request-Deadline-Ms` is an unknown request header, which `com.sun.net.httpserver` ignores, so the request is handled exactly as today, no behavior change. No existing request or response FIELD changed shape, only a request header added. Ack condition: the client release whose `REQUIRED_ENGINE_VERSION` bumps to the engine tag carrying this commit. diff --git a/mcpb/manifest.json b/mcpb/manifest.json index 8439cb661..b4128c1fa 100644 --- a/mcpb/manifest.json +++ b/mcpb/manifest.json @@ -3,7 +3,7 @@ "manifest_version": "0.4", "name": "conexus", "display_name": "Conexus", - "version": "7.36.0", + "version": "7.36.1", "description": "Three-tier semantic memory + knowledge management for Claude Desktop chat. Persistent code/docs/RDR indexing, plan-centric retrieval via nx_answer, and a Postgres-backed storage substrate that interoperates with the Claude Code plugin and the nx CLI on the same host.", "long_description": "Conexus is the Claude Desktop Extension form of Nexus. Same MCP tools and storage tiers as the Claude Code plugin and the nx CLI, packaged as a .mcpb bundle that uv resolves on first install. Persistent state is served by the storage service (Postgres), which `nx init` provisions \u2014 there is no separate daemon to install or talk to. State is shared with any other consumer on the same host (Claude Code plugin, nx CLI, Cowork via SDK bridge).", "author": { diff --git a/mcpb/pyproject.toml b/mcpb/pyproject.toml index b32f609b5..6242682ab 100644 --- a/mcpb/pyproject.toml +++ b/mcpb/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "conexus-mcpb" -version = "7.36.0" +version = "7.36.1" description = "Conexus packaged as Claude Desktop .mcpb (Desktop Extension)" requires-python = ">=3.12" dependencies = [ @@ -10,7 +10,7 @@ dependencies = [ # while collections are indexed at 768/1024-dim, so every T3 search hits a # dimension mismatch and returns zero results. The .mcpb cannot run the # interactive `nx init` embedder choice, so it must pin [local] explicitly. - "conexus[local]>=7.36.0", + "conexus[local]>=7.36.1", ] [tool.uv] diff --git a/pyproject.toml b/pyproject.toml index f37b3ab29..32cdb02d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ build-backend = "hatchling.build" [project] name = "conexus" -version = "7.36.0" +version = "7.36.1" description = "Self-hosted semantic search and knowledge management for LLM-driven development" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.12,<3.14" diff --git a/scripts/cut_plugin_release.py b/scripts/cut_plugin_release.py index 6879d124c..2656e585e 100644 --- a/scripts/cut_plugin_release.py +++ b/scripts/cut_plugin_release.py @@ -553,7 +553,7 @@ def perform_cut( # while develop still carries them, so the back-merge conflicts on # conexus/PENDING_RELEASE.md by construction; the script resolves that # one conflict (main wins) and runs develop's drift contract as the net. - print(" scripts/plugin_cut_back_merge.sh . && git push origin develop") + print(" scripts/plugin_cut_back_merge.sh . && scripts/git-push-develop.sh HEAD") return {"n": n, "tag": tag, "branch": branch, "moved_plugins": moved} diff --git a/scripts/git-push-develop.sh b/scripts/git-push-develop.sh new file mode 100755 index 000000000..8125c5b29 --- /dev/null +++ b/scripts/git-push-develop.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# Push the local integration branch only when every commit in the outbound +# range is one the caller vouches for (nexus-9wxu6). +# +# Why: several sessions share one primary checkout and commit as the same +# git user, so the author field cannot tell a peer's unpushed commit from +# the caller's own. On 2026-09-07 four pushes carried a peer's commits +# because the origin/develop..develop read was printed beside the push or +# skipped on a retry. This script makes the read and the push one step, +# with the vouch list as the only thing the caller supplies: the outbound +# range must equal the vouched set exactly, or nothing is pushed. +# +# Usage: git-push-develop.sh [ ...] +# Each argument is a commit the caller made (any revision that resolves +# to a commit; short SHAs are fine). With no arguments the script only +# reports; a non-empty range is refused, never pushed. +# +# A vouched MERGE commit also vouches for everything it merges in (the +# commits reachable from its second and later parents that are not yet on +# the remote branch). That is the release back-merge and the plugin-cut +# back-merge: the caller made the merge, so main's release-only commits +# ride it. Commits under the merge on its FIRST-parent line (a peer's +# unpushed work on the local branch) are never covered by it. +# +# Environment: +# NX_PUSH_REMOTE remote name (default origin) +# NX_PUSH_BRANCH branch name (default develop) +# NX_PUSH_SOURCE the local revision to push (default refs/heads/$branch). +# Set it to HEAD from a detached worktree based on +# $remote/$branch when the local branch is a peer's +# in-flight tree: the range is then $remote/$branch..HEAD +# and the local branch is left alone. +# +# Exit codes (each failure prints a PUSH_REFUSED_* line first): +# 0 PUSH_OK n= tip=, or PUSH_NOOP when nothing is outbound +# and nothing was vouched +# 2 PUSH_REFUSED_FOREIGN the range holds commits nobody vouched for +# 3 PUSH_REFUSED_STALE_VOUCH a vouched commit is not in the range (already +# pushed, rebased away, or on another branch) +# 4 PUSH_REFUSED_DIVERGED the remote branch is not an ancestor of the +# local one; rebase or merge first +# 5 PUSH_REFUSED_BAD_SHA an argument does not resolve to a commit +# 6 PUSH_REFUSED_NO_BRANCH the local or remote-tracking branch is missing + +set -euo pipefail + +remote="${NX_PUSH_REMOTE:-origin}" +branch="${NX_PUSH_BRANCH:-develop}" +source="${NX_PUSH_SOURCE:-refs/heads/$branch}" + +git rev-parse --show-toplevel >/dev/null + +git fetch -q "$remote" + +# Resolve the tip ONCE. Everything below (ancestry, range, push) uses this +# sha, never the moving ref, so a peer commit landing on the branch between +# the range read and the push cannot ride it. +if ! tip="$(git rev-parse -q --verify "$source^{commit}")"; then + echo "PUSH_REFUSED_NO_BRANCH source $source does not resolve to a commit" + exit 6 +fi +if ! git rev-parse -q --verify "refs/remotes/$remote/$branch^{commit}" >/dev/null; then + echo "PUSH_REFUSED_NO_BRANCH $remote/$branch does not exist after fetch" + exit 6 +fi + +if ! git merge-base --is-ancestor "refs/remotes/$remote/$branch" "$tip"; then + echo "PUSH_REFUSED_DIVERGED $remote/$branch is not an ancestor of $source; rebase or merge first" + exit 4 +fi + +range=() +while IFS= read -r sha; do + [[ -n "$sha" ]] && range+=("$sha") +done < <(git rev-list "refs/remotes/$remote/$branch..$tip") + +vouched=() +for arg in "$@"; do + if ! full="$(git rev-parse -q --verify "$arg^{commit}")"; then + echo "PUSH_REFUSED_BAD_SHA $arg does not resolve to a commit" + exit 5 + fi + vouched+=("$full") + # A merge's non-first parents: what it merges in is covered by vouching it. + while IFS= read -r parent; do + [[ -z "$parent" ]] && continue + while IFS= read -r merged; do + [[ -n "$merged" ]] && vouched+=("$merged") + done < <(git rev-list "$parent" "^refs/remotes/$remote/$branch") + done < <(git rev-list --parents -n 1 "$full" | cut -d' ' -f3- | tr ' ' '\n') +done + +if [[ ${#range[@]} -eq 0 && ${#vouched[@]} -eq 0 ]]; then + echo "PUSH_NOOP $source is already at $remote/$branch" + exit 0 +fi + +contains() { + local needle="$1"; shift + local x + for x in "$@"; do [[ "$x" == "$needle" ]] && return 0; done + return 1 +} + +foreign=() +for sha in "${range[@]+"${range[@]}"}"; do + contains "$sha" "${vouched[@]+"${vouched[@]}"}" || foreign+=("$sha") +done +stale=() +for sha in "${vouched[@]+"${vouched[@]}"}"; do + contains "$sha" "${range[@]+"${range[@]}"}" || stale+=("$sha") +done + +if [[ ${#foreign[@]} -gt 0 ]]; then + echo "PUSH_REFUSED_FOREIGN ${#foreign[@]} of ${#range[@]} outbound commit(s) on $source are not vouched:" + for sha in "${foreign[@]}"; do + git log -1 --format=' %h %an %s' "$sha" + done + echo "Vouch only for commits this session made. A peer's commit is theirs to push; do not add its sha to unblock yourself." + exit 2 +fi + +if [[ ${#stale[@]} -gt 0 ]]; then + echo "PUSH_REFUSED_STALE_VOUCH ${#stale[@]} vouched commit(s) are not in $remote/$branch..$source:" + for sha in "${stale[@]}"; do + git log -1 --format=' %h %s' "$sha" + done + echo "Re-read the range: the commit was already pushed, rebased away, or is on another branch." + exit 3 +fi + +git push -q "$remote" "$tip:refs/heads/$branch" +echo "PUSH_OK n=${#range[@]} tip=$tip" diff --git a/sn/.claude-plugin/plugin.json b/sn/.claude-plugin/plugin.json index 0ff7d91c9..57763e836 100644 --- a/sn/.claude-plugin/plugin.json +++ b/sn/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "sn", - "version": "7.36.0", + "version": "7.36.1", "description": "Injects Serena and Context7 MCP tool usage guidance into subagents via SubagentStart hook.", "author": { "name": "Hal Hildebrand", diff --git a/src/nexus/_session_end_launcher.py b/src/nexus/_session_end_launcher.py index 8bbd2d4e9..a76de3eab 100644 --- a/src/nexus/_session_end_launcher.py +++ b/src/nexus/_session_end_launcher.py @@ -88,6 +88,29 @@ def _run_session_end_synchronously() -> None: # Fully detached; nothing upstream can observe us. Swallow. pass _write_capability_census() + _sweep_local_garbage() + + +def _sweep_local_garbage() -> None: + """Reap the config directory's litter at session close (nexus-fjwk7): + ``t1_mint_.lock`` files older than a day with no live lease, + rotated logs, operator dumps. The same sweep ``nx doctor --fix`` runs, + so a box no one doctors stops accumulating hundreds of zero-byte + locks. Failure-isolated like the census above.""" + try: + from nexus.config import nexus_config_dir # noqa: PLC0415 — deliberate function-scoped import (defer heavy/optional dep, avoid circular import) + from nexus.garbage import sweep_local_garbage # noqa: PLC0415 — deliberate function-scoped import (defer heavy/optional dep, avoid circular import) + + sweep_local_garbage(nexus_config_dir()) + except Exception as exc: # noqa: BLE001 — boundary catch; session close must never break on a sweep + try: + import structlog # noqa: PLC0415 — deliberate function-scoped import (defer heavy/optional dep, avoid circular import) + + structlog.get_logger(__name__).debug( + "session_end_garbage_sweep_failed", error=str(exc), + ) + except Exception: # noqa: BLE001 — even the debug log is best-effort + pass def _write_capability_census() -> None: diff --git a/src/nexus/db/http_vector_client.py b/src/nexus/db/http_vector_client.py index d2905a2f6..0f7b10185 100644 --- a/src/nexus/db/http_vector_client.py +++ b/src/nexus/db/http_vector_client.py @@ -38,6 +38,7 @@ import structlog +from nexus.redact import redact_credentials from nexus.logging_setup import emit_import_time_warning _log = structlog.get_logger(__name__) @@ -1337,7 +1338,9 @@ def _post(path: str, body: dict, *, tenant: str = "default", timeout: int = 120) err = json.loads(body_bytes) except Exception: # noqa: BLE001 — error-body decode is best-effort; fall back to raw bytes err = {"error": body_bytes.decode(errors="replace")} - msg = f"POST {path} → HTTP {e.code}: {err.get('error', err)}" + # nexus-8ooxn: a 401/403 body can echo the rejected credential; + # redact where it enters the message so every renderer sees it gone. + msg = f"POST {path} → HTTP {e.code}: {redact_credentials(str(err.get('error', err)))}" # RDR-195 (nexus-kmtlp.11): a STRUCTURED error body — the engine's # 422 for Voyage TOO_MANY_TOKENS_IN_BATCH carries detail/sub_requests/ # batch_size/model — must reach the caller intact. Keeping only the @@ -1346,7 +1349,7 @@ def _post(path: str, body: dict, *, tenant: str = "default", timeout: int = 120) # purpose: any error body with a ``detail`` field gets the same # treatment; plain ``{"error": ...}`` bodies render exactly as before. if isinstance(err, dict) and err.get("detail"): - msg += f" — {err['detail']}" + msg += f" — {redact_credentials(str(err['detail']))}" extras = [ f"{k}={err[k]}" for k in ("sub_requests", "batch_size", "model") @@ -1395,7 +1398,7 @@ def _get(path: str, *, tenant: str = "default") -> Any: err = json.loads(body_bytes) except Exception: # noqa: BLE001 — error-body decode is best-effort; fall back to raw bytes err = {"error": body_bytes.decode(errors="replace")} - msg = f"GET {path} → HTTP {e.code}: {err.get('error', err)}" + msg = f"GET {path} → HTTP {e.code}: {redact_credentials(str(err.get('error', err)))}" edge_server = _edge_server(e.headers) # nexus-1jtob — see _post if edge_server: remedy: str | None = _edge_refusal_remedy(edge_server, e.code) diff --git a/src/nexus/health.py b/src/nexus/health.py index 11e62754c..1caf87083 100644 --- a/src/nexus/health.py +++ b/src/nexus/health.py @@ -17,12 +17,16 @@ import structlog from nexus.config import default_db_path +from nexus.redact import redact_credentials # noqa: F401 — re-exported; callers import it from here if TYPE_CHECKING: from nexus.catalog.catalog_protocol import CatalogReader _log = structlog.get_logger(__name__) +#: Credential-bearing phrases a service error body may echo back. The value +#: after the key word is replaced, whatever its shape, so a rotated token or +#: a raw api_key never reaches a log line (nexus-8ooxn / nexus-hcy4w). _CHECK = "✓" _WARN = "✗" # RDR-129 B4 (nexus-uq8a4): a third, soft state — the check could not complete @@ -1093,8 +1097,12 @@ def _check_vector_service() -> HealthResult: # green "✓ Managed/remote service — release_version 0.1.55", and in # cloud mode there is no local service to start in the first place. code = getattr(exc, "code", None) + # nexus-8ooxn / nexus-hcy4w: the service echoes the rejected credential + # in its 401 body; the log line must not carry it either (under xdist + # the worker's console renderer lands in the CliRunner output). + err_text = redact_credentials(str(exc)) if code in (401, 403): - _log.debug("vector_service_auth_failed", status=code, error=str(exc)) + _log.debug("vector_service_auth_failed", status=code, error=err_text) return HealthResult( label="Vector service (/v1/vectors)", ok=False, @@ -1113,7 +1121,7 @@ def _check_vector_service() -> HealthResult: if code is not None: # The service answered, just not successfully. Surface the status # instead of laundering it into a reachability claim. - _log.debug("vector_service_http_error", status=code, error=str(exc)) + _log.debug("vector_service_http_error", status=code, error=err_text) return HealthResult( label="Vector service (/v1/vectors)", ok=False, @@ -1124,7 +1132,7 @@ def _check_vector_service() -> HealthResult: ], fatal=True, ) - _log.debug("vector_service_not_reachable", error=str(exc)) + _log.debug("vector_service_not_reachable", error=err_text) # nexus-4m6i0.7: the service can crash-loop before answering any # request (a Liquibase VALIDATE failure on boot, GH #1390) — surface # the root cause from the local service log when one is available, diff --git a/src/nexus/redact.py b/src/nexus/redact.py new file mode 100644 index 000000000..6d6647106 --- /dev/null +++ b/src/nexus/redact.py @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Credential redaction for text that came back from a service and is about +to be logged, printed, or carried in an exception message (nexus-8ooxn, +nexus-hcy4w). The engine's 401/403 bodies can echo the rejected credential; +redaction happens where the body enters a message, so every path that +renders the message (structlog, ``nx search``, ``nx doc``, the doctor) +sees the redacted form. +""" +from __future__ import annotations + +import re + +_CREDENTIAL_RE = re.compile( + r"(?i)\b(api[_-]?key|token|bearer|password|secret|authorization)\b(\s*[:=]\s*|\s+)(\S+)" +) +_ALWAYS_VALUE_KEYS = frozenset({"bearer", "authorization"}) +_MIN_VALUE_LEN = 6 + + +def _looks_like_a_value(key: str, sep: str, value: str) -> bool: + if sep.strip() in (":", "=") or key.lower() in _ALWAYS_VALUE_KEYS: + return True + # A bare lowercase word after the key word is prose ("token endpoint", + # "secret sauce"); anything else long enough to be a credential is one. + if value.isalpha() and value.islower(): + return False + return len(value) >= _MIN_VALUE_LEN + + +def redact_credentials(text: str) -> str: + """*text* with the value after any credential key word replaced by + ``[redacted]``; prose that merely uses the word is left alone.""" + + def _sub(m: re.Match[str]) -> str: + key, sep, value = m.group(1), m.group(2), m.group(3) + if not _looks_like_a_value(key, sep, value): + return m.group(0) + return f"{key}{sep}[redacted]" + + return _CREDENTIAL_RE.sub(_sub, text) diff --git a/src/nexus/upgrade_finish.py b/src/nexus/upgrade_finish.py index f95e469aa..afaf06342 100644 --- a/src/nexus/upgrade_finish.py +++ b/src/nexus/upgrade_finish.py @@ -2559,6 +2559,13 @@ def check_version_transition( seen = "" if seen == version: return None + # nexus-i24r4: a dev-checkout run (uv run nx from a release branch + # checked out in the shared tree) used to reach the stamp write below + # and record ITS version, so the next managed-install invocation read + # 7.34.0 -> 7.33.0 as an upgrade that never happened. Only a managed + # install owns the stamp; the tool-install check ran after the write. + if not running_from_tool_install(): + return None if preview is None: preview = invocation_is_preview() if not preview: @@ -2601,11 +2608,6 @@ def check_version_transition( # transition, so the real finish pass would never run automatically. if not seen: return None # first-ever run: nothing stale to finish - if not running_from_tool_install(): - # A dev checkout's venv mtime says nothing about the production - # processes on this box — measuring (let alone killing) them from - # here is the cross-venv confusion class. Report-only via doctor. - return None try: report = detect_stale_processes() actions = restart_stale(report, dry_run=preview) diff --git a/tests/conftest.py b/tests/conftest.py index 01759f0ac..827a160b1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -173,6 +173,44 @@ def _scan_fixture_cache_files() -> set[Path]: _fixture_cache_baseline: set[Path] = set() +def _gate_on_build_lease() -> None: + """Refuse the whole session ONCE while a service build holds the lease, + or wait for it when asked (nexus-pv93h). + + The lease is shared by every worktree of this repo (nexus-g6xpa), so + while ANY Maven run on the box holds it, ``_boot()`` refuses per test: + a full run beside a scoped engine run reported 1216 setup errors + (2026-09-07), all one fact. Deciding at session start, on the xdist + controller (workers spawn after this returns), turns that into one + line and exit 75 — or, with ``NX_BUILD_LEASE_WAIT=``, into a + wait that starts the suite when the holder is gone. ``_boot()``'s own + check runs once per worker process at its first substrate boot, so a + build that starts after this gate and before a worker boots is still + refused there; one that starts after every worker has booted is not + seen by either (the shell lease's own residual, nexus-06fu4). + + ``NX_TEST_T2_SUBSTRATE=none`` runs need no engine and are never gated. + """ + if os.environ.get("NX_TEST_T2_SUBSTRATE") == "none": + return + try: + from tests.db._service_fixture import build_lease_wait_seconds, wait_for_build_lease + reason = wait_for_build_lease(build_lease_wait_seconds()) + except Exception as exc: # noqa: BLE001 — the gate must never break collection on its own bug + import sys as _sys + + _sys.stderr.write(f"build-lease gate skipped on its own error (nexus-pv93h): {exc!r}\n") + return + if reason is None: + return + pytest.exit( + "engine substrate: refusing to start — " + reason + + " Set NX_BUILD_LEASE_WAIT= to wait for it instead, or " + "NX_TEST_T2_SUBSTRATE=none for a run that needs no engine (nexus-pv93h).", + returncode=75, + ) + + def _warn_if_service_jar_is_stale() -> None: """Say ONCE, at session start, that the service jar is stale (nexus-zryqm). @@ -253,6 +291,8 @@ def pytest_sessionstart(session): """ global _fixture_cache_baseline, _real_config_dir_baseline, _is_controller_or_serial _is_controller_or_serial = not _is_xdist_worker(session) + if _is_controller_or_serial: + _gate_on_build_lease() # nexus-pfuns: FENCE $HOME before any test runs. The gates were fenced # first; this suite was not, and it runs with the operator's real home. diff --git a/tests/db/_service_fixture.py b/tests/db/_service_fixture.py index 6ad2ccd0f..a3b4795e8 100644 --- a/tests/db/_service_fixture.py +++ b/tests/db/_service_fixture.py @@ -40,6 +40,7 @@ import signal import socket import subprocess +import sys import tempfile import time import urllib.error @@ -380,10 +381,31 @@ def _build_lease_root() -> Path: return _REPO_ROOT / "service" / ".build-lease" -_BUILD_LEASE_ROOT = _build_lease_root() +def _pid_alive(pid: int) -> bool: + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True # alive, owned by another user — still a live build + return True + + +def _group_alive(pgid: int) -> bool: + """Any member of process group *pgid* alive? Mirrors the shell lib's + ``kill -0 -- -PGID``: EPERM counts as alive (the failure direction is + always "still held").""" + try: + os.killpg(pgid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True -def build_in_progress_reason(lease_root: Path = _BUILD_LEASE_ROOT) -> str | None: + +def build_in_progress_reason(lease_root: Path | None = None) -> str | None: """Return a reason if a service BUILD IS RUNNING, else ``None``. nexus-06fu4. Writers of the shaded jar take a lease @@ -415,18 +437,26 @@ def build_in_progress_reason(lease_root: Path = _BUILD_LEASE_ROOT) -> str | None Never raises: a malformed or half-written lease returns ``None`` rather than blocking the suite on the lease reader's own bug. """ + # Resolved per call, not at import: NX_BUILD_LEASE_ROOT set by a test + # (or a box) after this module loaded must still win (nexus-pv93h — + # the freshness tests read the box's live lease and failed whenever any + # Maven run held it). try: - lease = lease_root / "service" + lease = (lease_root or _build_lease_root()) / "service" if not lease.is_dir(): return None raw_pid = (lease / "pid").read_text().strip() pid = int(raw_pid) - try: - os.kill(pid, 0) - except (ProcessLookupError, ValueError): + # The lease lib records the real build's process GROUP (`pgid`, + # build_lease_track_pid) once the ./mvnw child exists; it outlives + # a wrapper killed directly, so it wins over the wrapper's pid + # exactly as in _build_lease_group_alive (nexus-pv93h). + pgid_file = lease / "pgid" + if pgid_file.exists(): + if not _group_alive(int(pgid_file.read_text().strip())): + return None + elif not _pid_alive(pid): return None # holder is gone; the lease lib will reclaim it - except PermissionError: - pass # alive, owned by another user — still a live build holder = (lease / "label").read_text().strip() or "unknown" command = (lease / "command").read_text().strip() or "unknown command" since = (lease / "ts").read_text().strip() or "unknown time" @@ -440,6 +470,60 @@ def build_in_progress_reason(lease_root: Path = _BUILD_LEASE_ROOT) -> str | None ) +#: Seconds between lease polls while waiting (matches build_lease_acquire_wait). +_BUILD_LEASE_POLL_S = 5 + + +def build_lease_wait_seconds() -> int: + """``NX_BUILD_LEASE_WAIT`` as an int, 0 when unset or unparsable. + + The same variable bounds every shell producer's wait + (``build_lease_acquire_wait``); here it is OPT-IN, because a developer + who types ``pytest`` should be told the box is building, not left to + wonder why nothing has started for an hour. Unset means refuse at + once with the holder named. + """ + raw = os.environ.get("NX_BUILD_LEASE_WAIT", "").strip() + return int(raw) if raw.isdigit() else 0 + + +def wait_for_build_lease( + max_seconds: int, + *, + reason_fn=None, + sleep=time.sleep, + announce=None, +) -> str | None: + """Wait up to *max_seconds* for the service build lease to clear. + + nexus-pv93h. Returns ``None`` once no live build holds the lease, or + the last in-progress reason if it is still held when the bound runs + out (``max_seconds`` 0 is a single look). Polls every + ``_BUILD_LEASE_POLL_S`` and keys on the HOLDER's liveness through + ``build_in_progress_reason`` — the lease directory in the git common + dir never disappears on its own, so a waiter that watched the path + would wait forever behind a dead holder. Announces once at the first + refusal and once a minute after, the same cadence as the shell lib. + """ + reason_fn = reason_fn or build_in_progress_reason + announce = announce or (lambda msg: sys.stderr.write(msg + "\n")) + waited = 0 + announced = False + while True: + reason = reason_fn() + if reason is None: + if announced: + announce(f"build lease: clear after {waited}s, starting.") + return None + if waited >= max_seconds: + return reason + if not announced or waited % 60 == 0: + announce(f"build lease: held — waiting ({waited}s of {max_seconds}s). {reason}") + announced = True + sleep(_BUILD_LEASE_POLL_S) + waited += _BUILD_LEASE_POLL_S + + def jar_freshness_skip_reason(jar: Path = _SERVICE_JAR) -> str | None: """Return a skip reason if the shaded service jar is missing, INCOMPLETE or STALE, else ``None`` (jar is current and safe to launch). diff --git a/tests/db/test_service_jar_freshness.py b/tests/db/test_service_jar_freshness.py index 50c472853..3378fc6e0 100644 --- a/tests/db/test_service_jar_freshness.py +++ b/tests/db/test_service_jar_freshness.py @@ -11,6 +11,19 @@ import pytest + +@pytest.fixture(autouse=True) +def _no_live_build_lease(tmp_path, monkeypatch): + """Isolate every test here from the BOX's build lease (nexus-pv93h). + + ``jar_freshness_skip_reason`` consults the lease before it looks at the + jar, and the lease is shared by every worktree of the repo, so while any + Maven run held it these tests reported "BUILD IS IN PROGRESS" instead of + the jar states they exist to pin. Tests that want a lease pass an + explicit root. + """ + monkeypatch.setenv("NX_BUILD_LEASE_ROOT", str(tmp_path / "no-lease")) + from tests.db._service_fixture import ( _SERVICE_JAR, build_in_progress_reason, @@ -374,3 +387,91 @@ def test_a_malformed_lease_never_raises(self, tmp_path): def test_a_missing_lease_root_never_raises(self, tmp_path): assert build_in_progress_reason(tmp_path / "nope") is None + + +class TestLeaseHolderLiveness: + """The lease directory in the git common dir never disappears on its own + (nexus-pv93h): a reader keys on the HOLDER, never on the path. The lease + lib records the real build's process group as ``pgid`` once the ./mvnw + child exists; that group outlives a wrapper killed directly.""" + + @staticmethod + def _lease(tmp_path, pid: int, pgid: int | None = None): + d = tmp_path / "root" / "service" + d.mkdir(parents=True) + (d / "pid").write_text(f"{pid}\n") + (d / "ts").write_text("2026-09-07T20:53:27Z\n") + (d / "label").write_text("someone\n") + (d / "command").write_text("scripts/mvnw-leased.sh ./mvnw test\n") + if pgid is not None: + (d / "pgid").write_text(f"{pgid}\n") + return tmp_path / "root" + + def test_a_dead_wrapper_with_a_live_build_group_is_still_a_build(self, tmp_path): + import os + + # 2^22 is above pid_max on darwin and linux: the wrapper is "dead"; + # this test process's own group is alive. + root = self._lease(tmp_path, 4194304, pgid=os.getpgid(0)) + assert build_in_progress_reason(root) is not None + + def test_a_dead_build_group_clears_even_with_a_live_wrapper_pid(self, tmp_path): + import os + + root = self._lease(tmp_path, os.getpid(), pgid=4194304) + assert build_in_progress_reason(root) is None + + +class TestWaitForBuildLease: + """``wait_for_build_lease`` (nexus-pv93h): the pytest-side counterpart of + ``build_lease_acquire_wait`` -- polls the holder, bounded, announcing at + the shell lib's cadence, and never sleeps past a cleared lease.""" + + def _run(self, reasons: list, max_seconds: int): + from tests.db._service_fixture import wait_for_build_lease + + seq = iter(reasons) + sleeps: list[int] = [] + said: list[str] = [] + result = wait_for_build_lease( + max_seconds, reason_fn=lambda: next(seq), sleep=sleeps.append, announce=said.append, + ) + return result, sleeps, said + + def test_clear_lease_returns_at_once_without_sleeping_or_announcing(self): + result, sleeps, said = self._run([None], 3600) + assert result is None and sleeps == [] and said == [] + + def test_zero_bound_is_a_single_look_that_returns_the_reason(self): + result, sleeps, said = self._run(["held by 1"], 0) + assert result == "held by 1" and sleeps == [] and said == [] + + def test_waits_until_the_holder_is_gone_and_says_so(self): + result, sleeps, said = self._run(["held", "held", None], 3600) + assert result is None + assert sleeps == [5, 5] + assert said[0].startswith("build lease: held — waiting (0s of 3600s). held") + assert said[-1] == "build lease: clear after 10s, starting." + assert len(said) == 2, "one announcement at the first refusal, none per poll" + + def test_gives_up_at_the_bound_with_the_last_reason(self): + result, sleeps, said = self._run(["a", "b", "c", "d"], 10) + assert result == "c" + assert sleeps == [5, 5] + + def test_announces_once_a_minute_while_held(self): + result, sleeps, said = self._run(["held"] * 30, 120) + assert result == "held" + assert len(said) == 2 + assert said[0].startswith("build lease: held — waiting (0s of 120s)") + assert said[1].startswith("build lease: held — waiting (60s of 120s)") + + def test_env_bound_is_opt_in(self, monkeypatch): + from tests.db._service_fixture import build_lease_wait_seconds + + monkeypatch.delenv("NX_BUILD_LEASE_WAIT", raising=False) + assert build_lease_wait_seconds() == 0 + monkeypatch.setenv("NX_BUILD_LEASE_WAIT", "900") + assert build_lease_wait_seconds() == 900 + monkeypatch.setenv("NX_BUILD_LEASE_WAIT", "soon") + assert build_lease_wait_seconds() == 0 diff --git a/tests/e2e/gc-ab/run-ab.sh b/tests/e2e/gc-ab/run-ab.sh index a47c4f5c4..aeccf55b8 100755 --- a/tests/e2e/gc-ab/run-ab.sh +++ b/tests/e2e/gc-ab/run-ab.sh @@ -71,7 +71,7 @@ build_variant() { # gc-name # bind mount of this host checkout, so service/target is the same # single-writer resource the host-side lease guards — acquire it here on # the host, around the docker invocation, not inside the container. - build_lease_acquire service docker-native-build "--gc=${gc}" + build_lease_acquire_wait service "${NX_BUILD_LEASE_WAIT:-3600}" docker-native-build "--gc=${gc}" docker run --rm --entrypoint bash \ --add-host=host.docker.internal:host-gateway \ -v "$PWD":/src -w /src/service \ diff --git a/tests/e2e/migration-rehearsal/run.sh b/tests/e2e/migration-rehearsal/run.sh index ceddcdd01..e4fb5195c 100755 --- a/tests/e2e/migration-rehearsal/run.sh +++ b/tests/e2e/migration-rehearsal/run.sh @@ -661,8 +661,11 @@ elif [ "$DO_BUILD" = 1 ]; then # nexus-c00dw: ./mvnw runs INSIDE the container, but /src is a bind # mount of this host checkout, so service/target is the same # single-writer resource the host-side lease guards — acquire on the - # host, around the docker invocation. - build_lease_acquire service docker-native-build migration-rehearsal + # host, around the docker invocation. A live holder is WAITED for, + # bounded by NX_BUILD_LEASE_WAIT like every other producer (nexus-pv93h: + # --shakeout used to exit 75 the instant a cached gate-jar copy held + # the lease); rc 75 names the holder only once the bound is exhausted. + build_lease_acquire_wait service "${NX_BUILD_LEASE_WAIT:-3600}" docker-native-build migration-rehearsal docker run --rm --entrypoint bash \ --add-host=host.docker.internal:host-gateway \ -v "$PWD":/src -w /src/service \ diff --git a/tests/fixtures/bead_ids.txt b/tests/fixtures/bead_ids.txt index 365da427c..6a14d67cd 100644 --- a/tests/fixtures/bead_ids.txt +++ b/tests/fixtures/bead_ids.txt @@ -631,6 +631,7 @@ nexus-3xg21 nexus-3xg21.1 nexus-3xi4x nexus-3y87g +nexus-3ygp3 nexus-3z1a0 nexus-3z1a0.1 nexus-3z6ax @@ -1108,6 +1109,7 @@ nexus-6e6u1 nexus-6ee5i nexus-6ew6o nexus-6f9p +nexus-6fvwo nexus-6h0e nexus-6ha8a nexus-6hoa6 @@ -1615,6 +1617,7 @@ nexus-9fu7 nexus-9g27y nexus-9gaj7 nexus-9gaj7.1 +nexus-9gggv nexus-9gvru nexus-9h14 nexus-9h1s @@ -1637,6 +1640,7 @@ nexus-9l2lg nexus-9l9he nexus-9lzx nexus-9m7tk +nexus-9meyc nexus-9mzkd nexus-9n04m nexus-9n04m.1 @@ -1713,6 +1717,8 @@ nexus-9vg5g.1 nexus-9vkil nexus-9w282 nexus-9wqqt +nexus-9wxu6 +nexus-9wxu6.1 nexus-9wz72 nexus-9x9k nexus-9xado @@ -2610,6 +2616,7 @@ nexus-f55fu nexus-f5lch nexus-f5lch.1 nexus-f5qvq +nexus-f5wwx nexus-f61mz nexus-f6j31 nexus-f6j31.1 @@ -2670,6 +2677,7 @@ nexus-fiyv9 nexus-fjc8v nexus-fju9d nexus-fju9d.1 +nexus-fjwk7 nexus-fjwxh nexus-fjwxh.1 nexus-fkhe2 @@ -2745,6 +2753,7 @@ nexus-ft7eg nexus-ftpk3 nexus-ftpm nexus-fuzd +nexus-fv65m nexus-fvww nexus-fvxh1 nexus-fvxh1.1 @@ -4071,6 +4080,7 @@ nexus-ng2sy.1 nexus-ngcpo nexus-ngj9 nexus-ngpx0 +nexus-ngpx0.1 nexus-nh30k nexus-nhfqa nexus-nhqll @@ -4328,6 +4338,7 @@ nexus-ob4vc.1 nexus-obp2 nexus-obp2.1 nexus-oc98c +nexus-oc98c.1 nexus-ocf52 nexus-ocsym nexus-ocu9 @@ -4567,6 +4578,7 @@ nexus-pu2z8 nexus-pu4c nexus-pucte nexus-pucte.1 +nexus-pv93h nexus-pvw nexus-pw0jk nexus-pw0jk.1 @@ -4587,6 +4599,7 @@ nexus-pz7du nexus-pzbu nexus-pzdol nexus-pzeqp +nexus-q1xvk nexus-q2ign nexus-q2jsa nexus-q2t5t @@ -6160,6 +6173,7 @@ nexus-zedf7 nexus-zedf7.1 nexus-zekpl nexus-zewg3 +nexus-zewg3.1 nexus-zf7ga nexus-zfnkb nexus-zfutt diff --git a/tests/fixtures/hal_git_policy_hook.py b/tests/fixtures/hal_git_policy_hook.py index ae3509c54..5f13c686e 100644 --- a/tests/fixtures/hal_git_policy_hook.py +++ b/tests/fixtures/hal_git_policy_hook.py @@ -17,7 +17,8 @@ -------------------------------------------------------------------------- -Hal's personal git-policy PreToolUse hook: wildcard-add + push-to-main. +Hal's personal git-policy PreToolUse hook: wildcard-add + push-to-main + +foreign-tip amend. SCOPE DECISION (Hal, 2026-08-18, nexus-2mb2j): this hook is deliberately UNSCOPED -- it fires in EVERY repo, with no nexus-repo detection. The @@ -112,7 +113,37 @@ actually sanctioned. -------------------------------------------------------------------------- -Escape hatch (both rules): append ``# routing-allow: `` (>=8 +RULE 3: deny ``git commit --amend`` in the PRIMARY checkout when the tip +commit is not this session's own -- nexus-9wxu6, 2026-09-07. + +THE INCIDENT (2026-09-07, five sessions in one checkout). A session ran +``git commit --amend`` in the shared primary to fold a follow-up into +"its" last commit; by then HEAD was a peer's commit, and the amend +rewrote it (restored by SHA). Worktrees are private, so the rule only +fires when the cwd's git dir IS the common dir (a linked worktree has a +``.git/worktrees/`` git dir and is never blocked). + +"This session's own" is read from a record the companion PostToolUse +hook (``hal_record_session_commits_hook.py``) appends to after every +``git commit``: one file per Claude Code ``session_id`` under +``~/.config/nexus/session_commits/`` (override: ``NX_SESSION_COMMITS_DIR``), +one HEAD sha per line. An amend is allowed when HEAD is in the current +session's file; anything else (no session id, no file, HEAD not listed) +is denied. Denied means the tip is not provably yours; restore-by-SHA is +the only recovery once the rewrite has happened, so the rule fails closed. + +-------------------------------------------------------------------------- +RULE 4: deny a bare ``git push`` whose effective target is ``develop`` in a +repo that ships ``scripts/git-push-develop.sh`` -- nexus-9wxu6. + +The vouched push script is the control; a rule that lives only in a memory +file is the failure class rule 2 already names. Scoped by the script's +presence at the repo toplevel so repos without it are untouched. The +script's own inner ``git push`` is invisible to this hook (the tool +command is the script), so the script is the only unblocked path. + +-------------------------------------------------------------------------- +Escape hatch (all rules): append ``# routing-allow: `` (>=8 characters) to the command. Every escape is logged to the routing log (see ``_log_path`` below) so over-use stays visible. """ @@ -495,6 +526,219 @@ def _push_to_main_message(target_hint: str) -> str: ) +# --------------------------------------------------------------------------- +# Rule 3: `git commit --amend` in the primary checkout on a foreign tip +# (nexus-9wxu6). +# --------------------------------------------------------------------------- + +_DEFAULT_SESSION_COMMITS_DIR = pathlib.Path.home() / ".config" / "nexus" / "session_commits" + + +def _session_commits_dir() -> pathlib.Path: + override = os.environ.get("NX_SESSION_COMMITS_DIR") + return pathlib.Path(override) if override else _DEFAULT_SESSION_COMMITS_DIR + + +def _git_verb_segments(command: str, verb: str) -> list[tuple[list[str], str | None]]: + """Every ``git [-C dir] ...`` segment in *command*: the tokens + from the verb onward, plus the ``-C`` directory if one was given.""" + out: list[tuple[list[str], str | None]] = [] + for segment in re.split(_SEGMENT_SPLIT_RE, command): + try: + candidates = [shlex.split(segment, posix=True)] + except ValueError: + candidates = _degraded_token_variants(segment) # nexus-2e874 + for tokens in candidates: + i = 0 + while i < len(tokens) and _ENV_ASSIGN_RE.match(tokens[i]): + i += 1 + tokens = tokens[i:] + if len(tokens) < 2 or tokens[0] != "git": + continue + c_dir: str | None = None + j = 1 + while j < len(tokens) and tokens[j].startswith("-"): + if tokens[j] in {"-C", "-c"}: + if tokens[j] == "-C" and j + 1 < len(tokens): + c_dir = tokens[j + 1] + j += 2 + else: + j += 1 + if j < len(tokens) and tokens[j] == verb: + out.append((tokens[j:], c_dir)) + break + return out + + +def _amend_segments(command: str) -> list[tuple[list[str], str | None]]: + return [ + (tokens, c_dir) + for tokens, c_dir in _git_verb_segments(command, "commit") + if "--amend" in [t.rstrip(")") for t in _strip_shell_redirections(tokens)] + ] + + +def _effective_cwd(payload_cwd: str, c_dir: str | None) -> str: + if c_dir is None: + return payload_cwd + return c_dir if os.path.isabs(c_dir) else os.path.join(payload_cwd, c_dir) + + +def _is_primary_checkout(cwd: str) -> bool: + """True iff *cwd* is inside the primary checkout of a repo (its git dir + is the common dir). A linked worktree, or a non-repo, returns False.""" + try: + r = subprocess.run( + ["git", "rev-parse", "--git-dir", "--git-common-dir"], + cwd=cwd, capture_output=True, text=True, timeout=5, + ) + except Exception: + return False + if r.returncode != 0: + return False + lines = r.stdout.strip().splitlines() + if len(lines) != 2: + return False + git_dir = os.path.realpath(os.path.join(cwd, lines[0])) + common = os.path.realpath(os.path.join(cwd, lines[1])) + return git_dir == common + + +def _head_sha(cwd: str) -> str | None: + try: + r = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=cwd, capture_output=True, text=True, timeout=5, + ) + except Exception: + return None + if r.returncode != 0: + return None + return r.stdout.strip() or None + + +def _session_owns(session_id: str, sha: str) -> bool: + if not session_id or not sha: + return False + path = _session_commits_dir() / session_id + try: + return sha in path.read_text(encoding="utf-8").split() + except OSError: + return False + + +def _amend_on_foreign_tip(command: str, payload: dict[str, Any]) -> str | None: + """The offending HEAD sha when *command* amends a foreign tip in the + primary checkout, else None.""" + segments = _amend_segments(command) + if not segments: + return None + payload_cwd = str(payload.get("cwd") or "") or os.getcwd() + session_id = str(payload.get("session_id") or "") + for _tokens, c_dir in segments: + cwd = _effective_cwd(payload_cwd, c_dir) + if not _is_primary_checkout(cwd): + continue + head = _head_sha(cwd) + if head is None: + continue # unborn branch: nothing to rewrite + if not _session_owns(session_id, head): + return head + return None + + +def _amend_message(head: str) -> str: + return ( + f"git commit --amend in the shared primary checkout is blocked: HEAD " + f"{head[:12]} is not recorded as this session's own commit " + f"(nexus-9wxu6, 2026-09-07: an amend here rewrote a peer's commit).\n" + f"Make a new commit instead, or amend from a worktree you own. If HEAD " + f"really is yours (committed before this guard was installed), append " + f"`# routing-allow: ` (>=8 chars); the escape is logged." + ) + + +# --------------------------------------------------------------------------- +# Rule 4: a bare `git push` to the integration branch in a repo that ships +# the vouched push script (nexus-9wxu6). +# --------------------------------------------------------------------------- + +_VOUCHED_PUSH_SCRIPT = os.path.join("scripts", "git-push-develop.sh") +_INTEGRATION: frozenset[str] = frozenset({"develop"}) + + +def _toplevel(cwd: str) -> str | None: + try: + r = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + cwd=cwd, capture_output=True, text=True, timeout=5, + ) + except Exception: + return None + return r.stdout.strip() or None if r.returncode == 0 else None + + +def _push_target(tokens: list[str], cwd: str) -> str | None: + """The branch a ``git push`` segment would update, or None for a tag + push / undeterminable. Same resolution as rule 2: explicit refspec, + else the upstream, else the current branch name.""" + positional: list[str] = [] + skip_next = False + for tok in _strip_shell_redirections(tokens[1:]): + if skip_next: + skip_next = False + continue + if tok in _VALUED_PUSH_FLAGS: + skip_next = True + continue + if tok.startswith("-"): + continue + positional.append(tok) + refspecs = positional[1:] if len(positional) > 1 else [] + if "--follow-tags" not in tokens and "--tags" in tokens and not refspecs: + return None + if refspecs: + for spec in refspecs: + if spec.startswith("refs/tags/") or re.fullmatch(r"v\d+\.\d+\.\d+", spec): + continue + dst = spec.split(":")[-1].lstrip("+") + return dst.rsplit("/", 1)[-1] + return None + return _upstream_branch(cwd) or _current_branch(cwd) + + +def _bare_push_to_integration(command: str, cwd: str) -> str | None: + """The integration branch a bare ``git push`` in *command* would update + when the repo at *cwd* ships the vouched push script, else None. The + script's own inner push is never seen here: the tool command is the + script, not ``git push``.""" + segments = _push_tokens(command) + if not segments: + return None + top = _toplevel(cwd) + if top is None or not os.path.exists(os.path.join(top, _VOUCHED_PUSH_SCRIPT)): + return None + for tokens in segments: + target = _push_target(tokens, cwd) + if target in _INTEGRATION: + return target + return None + + +def _bare_push_message(branch: str) -> str: + return ( + f"A bare `git push` to `{branch}` is blocked in this repo (nexus-9wxu6): " + f"the checkout is shared and every session commits as the same user, so " + f"the outbound range must be vouched.\n" + f"Use scripts/git-push-develop.sh [ ...] naming the commits " + f"you made; it fetches, reads origin/{branch}..{branch}, and pushes only " + f"when the range equals your list (NX_PUSH_SOURCE=HEAD from a detached " + f"worktree).\n" + f"To override, append `# routing-allow: ` (>=8 chars); the escape " + f"is logged." + ) + + # --------------------------------------------------------------------------- # Entry point. # --------------------------------------------------------------------------- @@ -516,7 +760,15 @@ def body(payload: dict[str, Any]) -> None: push_segments = _push_tokens(command) push_to_main = any(_targets_protected(t, cwd) for t in push_segments) + amend_head: str | None = None + bare_push: str | None = None if not wildcard_add and not push_to_main: + amend_head = _amend_on_foreign_tip(command, payload) + if amend_head is None: + cwd = str(payload.get("cwd") or "") or os.getcwd() + bare_push = _bare_push_to_integration(command, cwd) + + if not wildcard_add and not push_to_main and amend_head is None and bare_push is None: _allow() if _should_skip_for_reason(command): @@ -525,6 +777,16 @@ def body(payload: dict[str, Any]) -> None: _log_event("deny", command_fragment=command) # Each check keeps its OWN message. + if bare_push is not None: + _deny( + _bare_push_message(bare_push), + summary=f"bare git push to {bare_push} blocked: use scripts/git-push-develop.sh ... (nexus-9wxu6).", + ) + if amend_head is not None: + _deny( + _amend_message(amend_head), + summary="git commit --amend on a foreign tip in the primary checkout blocked (nexus-9wxu6).", + ) if push_to_main: _deny( _push_to_main_message("main"), diff --git a/tests/fixtures/hal_record_session_commits_hook.py b/tests/fixtures/hal_record_session_commits_hook.py new file mode 100644 index 000000000..389d2da39 --- /dev/null +++ b/tests/fixtures/hal_record_session_commits_hook.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""FIXTURE-OF-RECORD (tests/test_hal_git_policy_hook.py, nexus-9wxu6, +2026-09-07): checked-in COPY of the companion PostToolUse hook installed +at ``~/.claude/hooks/record_session_commits.py``. Same contract as +``hal_git_policy_hook.py``'s fixture: drift from the installed copy is +accepted; this file keeps the behaviour under CI. + +-------------------------------------------------------------------------- + +Record every commit a Claude Code session makes, so the git-policy hook's +rule 3 can tell "my tip" from "a peer's tip" in the shared primary +checkout (nexus-9wxu6). + +PostToolUse on Bash. When the command that just ran contains a +``git [-C dir] commit`` segment, append the resulting HEAD sha to +``/`` where ```` is ``~/.config/nexus/session_commits`` +(override: ``NX_SESSION_COMMITS_DIR``). One sha per line, deduplicated. +Amends are recorded too: the rewritten tip is this session's own. + +Prints nothing and always exits 0: a recorder must never fail a tool call. +""" +from __future__ import annotations + +import json +import os +import pathlib +import re +import shlex +import subprocess +import sys +from typing import Any + +_DEFAULT_DIR = pathlib.Path.home() / ".config" / "nexus" / "session_commits" +_SEGMENT_SPLIT_RE = r"(?:&&|\|\||;|\s\|\s|\bthen\b|\bdo\b)" +_ENV_ASSIGN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") + + +def _commit_dirs(command: str) -> list[str | None]: + """The ``-C`` directory (or None) of every ``git commit`` segment.""" + out: list[str | None] = [] + for segment in re.split(_SEGMENT_SPLIT_RE, command): + try: + tokens = shlex.split(segment, posix=True) + except ValueError: + tokens = segment.replace('"', " ").replace("'", " ").split() + i = 0 + while i < len(tokens) and _ENV_ASSIGN_RE.match(tokens[i]): + i += 1 + tokens = tokens[i:] + if len(tokens) < 2 or tokens[0] != "git": + continue + c_dir: str | None = None + j = 1 + while j < len(tokens) and tokens[j].startswith("-"): + if tokens[j] in {"-C", "-c"}: + if tokens[j] == "-C" and j + 1 < len(tokens): + c_dir = tokens[j + 1] + j += 2 + else: + j += 1 + if j < len(tokens) and tokens[j] == "commit": + out.append(c_dir) + return out + + +_PRINTED_SHA_RE = re.compile(r"^\[[^\]\n]+ ([0-9a-f]{7,40})\]", re.MULTILINE) + + +def _printed_shas(payload: dict[str, Any]) -> list[str]: + """The short shas ``git commit`` printed (``[develop abc1234] msg``) in + the tool's own output. Preferred over a fresh ``rev-parse HEAD``: a peer + commit landing between the command and this hook would otherwise be + recorded as this session's.""" + resp = payload.get("tool_response") + texts: list[str] = [] + if isinstance(resp, dict): + for key in ("stdout", "stderr", "output"): + v = resp.get(key) + if isinstance(v, str): + texts.append(v) + elif isinstance(resp, str): + texts.append(resp) + return [m.group(1) for t in texts for m in _PRINTED_SHA_RE.finditer(t)] + + +def _resolve(cwd: str, rev: str) -> str | None: + try: + r = subprocess.run( + ["git", "rev-parse", "--verify", "-q", rev + "^{commit}"], + cwd=cwd, capture_output=True, text=True, timeout=5, + ) + except Exception: + return None + return r.stdout.strip() or None if r.returncode == 0 else None + + +def _head(cwd: str) -> str | None: + try: + r = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=cwd, capture_output=True, text=True, timeout=5, + ) + except Exception: + return None + return r.stdout.strip() or None if r.returncode == 0 else None + + +def record(payload: dict[str, Any]) -> list[str]: + if payload.get("tool_name") != "Bash": + return [] + tool_input = payload.get("tool_input") or {} + command = tool_input.get("command") if isinstance(tool_input, dict) else "" + session_id = str(payload.get("session_id") or "") + if not isinstance(command, str) or not command or not session_id: + return [] + dirs = _commit_dirs(command) + if not dirs: + return [] + base = str(payload.get("cwd") or "") or os.getcwd() + override = os.environ.get("NX_SESSION_COMMITS_DIR") + store = pathlib.Path(override) if override else _DEFAULT_DIR + path = store / session_id + try: + known = set(path.read_text(encoding="utf-8").split()) + except OSError: + known = set() + added: list[str] = [] + printed = _printed_shas(payload) + for c_dir in dirs: + cwd = base if c_dir is None else (c_dir if os.path.isabs(c_dir) else os.path.join(base, c_dir)) + shas = [full for short in printed if (full := _resolve(cwd, short))] or [_head(cwd)] + for sha in shas: + if sha and sha not in known: + known.add(sha) + added.append(sha) + if added: + store.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as fh: + fh.write("".join(s + "\n" for s in added)) + return added + + +def main() -> None: + try: + payload = json.loads(sys.stdin.read() or "{}") + if isinstance(payload, dict): + record(payload) + except BaseException: + pass + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/tests/hooks/test_pre_close_verification_hook.py b/tests/hooks/test_pre_close_verification_hook.py index 93396ce13..3d71ca83b 100644 --- a/tests/hooks/test_pre_close_verification_hook.py +++ b/tests/hooks/test_pre_close_verification_hook.py @@ -1179,6 +1179,53 @@ def test_id_in_reason_value_is_still_a_denial_target_if_it_is_ALSO_the_close_pos assert _get_decision(parsed) == "deny", parsed assert "nexus-uncov" in _get_reason(parsed) + def test_unbalanced_quote_in_reason_still_hides_prose_ids( + self, mock_config_env, fake_nx + ) -> None: + """Critique of a71c93e92: when shlex cannot tokenize the command at + all (an unbalanced quote), the raw-scan fallback used to read the + --reason prose; the flag value is blanked before that scan.""" + env = mock_config_env({"on_close": True}) + scratch = _marker( + "review-completed,nexus-target", + "review-completed: nexus-target -- clean", + ) + fake_bin = fake_nx(scratch) + result = _run_hook( + _make_payload( + command='bd close nexus-target --reason="rides nexus-69\'s push; see nexus-lemv5' + ), + path_prefix=fake_bin, + env_overrides=env, + ) + assert result.returncode == 0, result.stderr + + def test_semicolon_inside_reason_does_not_leak_prose_ids( + self, mock_config_env, fake_nx + ) -> None: + """nexus-fv65m: a ';' (or the word 'do') inside a quoted --reason must + not break the quote before tokenizing; measured 2026-09-07, a session + name in the prose was demanded as a close target.""" + env = mock_config_env({"on_close": True}) + scratch = _marker( + "review-completed,nexus-target", + "review-completed: nexus-target -- clean", + ) + fake_bin = fake_nx(scratch) + result = _run_hook( + _make_payload( + command=( + 'bd close nexus-target --reason="shipped; rides nexus-69\'s push; ' + 'nothing to do for nexus-lemv5" 2>&1 | tail -1' + ) + ), + path_prefix=fake_bin, + env_overrides=env, + ) + assert result.returncode == 0 + out = json.loads(result.stdout) + assert out.get("hookSpecificOutput", {}).get("permissionDecision") != "deny", out + def test_loop_variable_ids_still_harvested_alongside_a_reason_flag( self, mock_config_env, fake_nx ) -> None: diff --git a/tests/hooks/test_rdr_hook.py b/tests/hooks/test_rdr_hook.py index 7eee6ccee..018f769d8 100644 --- a/tests/hooks/test_rdr_hook.py +++ b/tests/hooks/test_rdr_hook.py @@ -157,9 +157,14 @@ def test_summary_prints_for_this_repos_real_tree(rdr_hook_module, monkeypatch, c mod.main() assert excinfo.value.code == 0 out = capsys.readouterr().out - m = re.search(r"^RDR: (\d+) documents \(2 closed, 1 accepted\) in docs/rdr but NOT indexed\.$", out, re.M) + m = re.search(r"^RDR: (\d+) documents \((\d+) RDRs: 2 closed, 1 accepted\) in docs/rdr but NOT indexed\.$", out, re.M) assert m, out - assert int(m.group(1)) > 200, out + documents, rdrs = int(m.group(1)), int(m.group(2)) + assert rdrs > 200, out + # nexus-owna8: the document count is the indexer's own walk (recursive, + # joint/ and post-mortem/ included), so it reconciles with the collection. + assert documents == sum(1 for p in (REPO_ROOT / "docs" / "rdr").rglob("*.md") if p.is_file()), out + assert documents > rdrs, out assert "Run: nx index repo" in out # nexus-3o4lt: the old remedy minted curator-owner rows with absolute # paths for every RDR; the hook must never recommend it again. @@ -238,3 +243,78 @@ def test_uncommitted_rdr_file_is_not_reported(rdr_hook_module, tmp_path) -> None f.write_text("---\nstatus: draft\n---\n") subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) assert mod._unchecked_fix_edits(tmp_path, [f], {"204": "draft"}, {"204": "abc1234"}) == [] + + +def test_collection_exists_asks_the_store_not_the_listing(rdr_hook_module, monkeypatch) -> None: + """nexus-owna8: existence comes from the T3 client's own answer.""" + mod = rdr_hook_module + + class _T3: + def collection_exists(self, name): + return name == "rdr__1-1__voyage-context-3__v1" + monkeypatch.setattr("nexus.db.make_t3", lambda: _T3()) + assert mod._collection_exists("rdr__1-1__voyage-context-3__v1") + assert not mod._collection_exists("rdr__other__voyage-context-3__v1") + + +def test_resolution_failure_is_logged_not_swallowed(rdr_hook_module, monkeypatch, tmp_path) -> None: + mod = rdr_hook_module + logged: list[dict] = [] + + class _Logger: + def warning(self, event, **kw): + logged.append({"event": event, **kw}) + + import structlog + monkeypatch.setattr(structlog, "get_logger", lambda *a, **k: _Logger()) + + def boom(): + raise ConnectionError("engine down") + monkeypatch.setattr("nexus.catalog.factory.make_catalog_reader", boom) + monkeypatch.setattr("nexus.repo_identity._repo_identity", lambda r: ("isolated", "abcdef12")) + name = mod._resolve_rdr_collection(tmp_path) + assert name == "rdr__isolated-abcdef12__voyage-context-3__v1" + assert any(e["event"] == "rdr_hook_collection_resolution_failed" and e["source"] == "catalog" for e in logged), logged + + +def test_indexed_document_count_matches_the_indexer_walk(rdr_hook_module, tmp_path) -> None: + rdr_dir = tmp_path / "docs" / "rdr" + (rdr_dir / "post-mortem").mkdir(parents=True) + (rdr_dir / "joint").mkdir() + for name in ("rdr-201-thing.md", "README.md", "AGENTS.md"): + (rdr_dir / name).write_text("x") + (rdr_dir / "post-mortem" / "rdr-191-postmortem.md").write_text("x") + (rdr_dir / "joint" / "JDR-001.md").write_text("x") + (rdr_dir / "notes.txt").write_text("x") + assert rdr_hook_module._indexed_document_count(rdr_dir) == 5 + assert len(rdr_hook_module._rdr_files(rdr_dir)) == 1 + + +def test_slow_t3_answer_falls_back_to_the_listing_within_the_hook_budget( + rdr_hook_module, monkeypatch, +) -> None: + """Review of a71c93e92: the T3 client's request timeout (30s) exceeds + the hook's 10s cap, so a slow store had the harness kill the hook before + the fallback ran. The T3 call now has its own deadline.""" + import subprocess as sp + import time + + mod = rdr_hook_module + monkeypatch.setattr(mod, "_T3_DEADLINE_S", 0.2) + + class _SlowT3: + def collection_exists(self, name): + time.sleep(2) + return True + monkeypatch.setattr("nexus.db.make_t3", lambda: _SlowT3()) + + class _Done: + returncode = 0 + stdout = "rdr__1-1__voyage-context-3__v1\n" + monkeypatch.setattr(sp, "run", lambda *a, **k: _Done()) + monkeypatch.setattr(mod.subprocess, "run", lambda *a, **k: _Done()) + + started = time.monotonic() + assert mod._collection_exists("rdr__1-1__voyage-context-3__v1") + assert not mod._collection_exists("rdr__other__voyage-context-3__v1") + assert time.monotonic() - started < 1.5 diff --git a/tests/scripts/test_build_lease_callers_wait.py b/tests/scripts/test_build_lease_callers_wait.py new file mode 100644 index 000000000..ce679a2a5 --- /dev/null +++ b/tests/scripts/test_build_lease_callers_wait.py @@ -0,0 +1,46 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Every producer of service/target waits on the shared build lease +(nexus-pv93h): no shell script outside the lease library itself calls the +refuse-immediately ``build_lease_acquire``. The --shakeout native build was +the last bare caller and exited 75 the instant a cached gate-jar copy held +the lease on 2026-09-07; ``build_lease_acquire_wait`` bounded by +``NX_BUILD_LEASE_WAIT`` is the only acquire a caller may use. +""" +from __future__ import annotations + +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +LIB = REPO_ROOT / "scripts" / "lib" / "build-lease.sh" +#: The lease library and the tests that stand up a holder on purpose. +EXEMPT = { + LIB, + REPO_ROOT / "scripts" / "lib" / "build-lease_test.sh", + REPO_ROOT / "scripts" / "mvnw-leased_test.sh", +} +#: A call anywhere on a non-comment line: start of line, or after `&&`, `||`, `;`, `(`, `{`, `then`, `do`. +_BARE = re.compile(r"(?:^|&&|\|\||;|\(|\{|\bthen\b|\bdo\b)\s*build_lease_acquire\s") + + +def _shell_files(): + for d in ("scripts", "tests", "conexus", "service", "deploy"): + base = REPO_ROOT / d + if base.exists(): + yield from base.rglob("*.sh") + + +def test_no_bare_acquire_outside_the_lease_library() -> None: + offenders = [] + checked = 0 + for path in _shell_files(): + if path in EXEMPT: + continue + checked += 1 + for n, line in enumerate(path.read_text(errors="replace").splitlines(), 1): + if line.lstrip().startswith("#"): + continue + if _BARE.search(line): + offenders.append(f"{path.relative_to(REPO_ROOT)}:{n}: {line.strip()}") + assert checked > 10, "the sweep examined almost nothing; check the roots" + assert not offenders, "use build_lease_acquire_wait service \"${NX_BUILD_LEASE_WAIT:-3600}\" ...:\n" + "\n".join(offenders) diff --git a/tests/scripts/test_git_push_develop_sh.py b/tests/scripts/test_git_push_develop_sh.py new file mode 100644 index 000000000..986e0934c --- /dev/null +++ b/tests/scripts/test_git_push_develop_sh.py @@ -0,0 +1,237 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""scripts/git-push-develop.sh (nexus-9wxu6): the vouched push. + +Every session on the shared box commits as the same git user, so the +outbound range origin/develop..develop cannot be attributed by author. The +script pushes only when the range equals the set of commits the caller +vouches for on its command line. These tests drive a real bare origin and +two clones standing in for two sessions of one checkout. +""" +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +SCRIPT = REPO_ROOT / "scripts" / "git-push-develop.sh" + +_GIT_ID = ["-c", "user.email=t@t", "-c", "user.name=t"] + + +def _git(*args: str, cwd: Path) -> str: + return subprocess.run( + ["git", *_GIT_ID, *args], cwd=cwd, check=True, + capture_output=True, text=True, + ).stdout.strip() + + +def _commit(work: Path, name: str) -> str: + (work / name).write_text(name) + _git("add", name, cwd=work) + _git("commit", "-q", "-m", name, cwd=work) + return _git("rev-parse", "HEAD", cwd=work) + + +@pytest.fixture() +def repos(tmp_path): + origin = tmp_path / "origin" + origin.mkdir() + _git("init", "-q", "--bare", "--initial-branch=develop", cwd=origin) + work = tmp_path / "work" + work.mkdir() + _git("init", "-q", "--initial-branch=develop", cwd=work) + _git("remote", "add", "origin", str(origin), cwd=work) + _commit(work, "base") + _git("push", "-q", "-u", "origin", "develop", cwd=work) + return origin, work + + +def _run(work: Path, *vouch: str, env: dict | None = None) -> subprocess.CompletedProcess: + return subprocess.run( + [str(SCRIPT), *vouch], cwd=work, env={**os.environ, **(env or {})}, + capture_output=True, text=True, timeout=60, + ) + + +def _remote_tip(origin: Path) -> str: + return _git("rev-parse", "refs/heads/develop", cwd=origin) + + +class TestScriptShape: + def test_executable(self) -> None: + assert SCRIPT.exists() and os.access(SCRIPT, os.X_OK) + + +class TestHappyPath: + def test_nothing_outbound_and_nothing_vouched_is_a_noop(self, repos) -> None: + origin, work = repos + proc = _run(work) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert proc.stdout.startswith("PUSH_NOOP") + + def test_fully_vouched_range_pushes(self, repos) -> None: + origin, work = repos + a = _commit(work, "a") + b = _commit(work, "b") + proc = _run(work, a, b[:7]) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert proc.stdout.strip() == f"PUSH_OK n=2 tip={b}" + assert _remote_tip(origin) == b + + def test_pushes_develop_from_a_detached_worktree(self, repos, tmp_path) -> None: + origin, work = repos + a = _commit(work, "a") + wt = tmp_path / "wt" + _git("worktree", "add", "-q", "--detach", str(wt), "HEAD~1", cwd=work) + proc = _run(wt, a) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert _remote_tip(origin) == a + + +class TestDetachedSource: + def test_head_of_a_detached_worktree_pushes_without_touching_the_local_branch(self, repos, tmp_path) -> None: + origin, work = repos + peer = _commit(work, "peer-unpushed") + wt = tmp_path / "wt" + _git("worktree", "add", "-q", "--detach", str(wt), "origin/develop", cwd=work) + mine = _commit(wt, "mine") + proc = _run(wt, mine, env={"NX_PUSH_SOURCE": "HEAD"}) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert proc.stdout.strip() == f"PUSH_OK n=1 tip={mine}" + assert _remote_tip(origin) == mine + assert _git("rev-parse", "refs/heads/develop", cwd=work) == peer + + def test_detached_source_still_refuses_an_unvouched_commit(self, repos, tmp_path) -> None: + origin, work = repos + before = _remote_tip(origin) + wt = tmp_path / "wt" + _git("worktree", "add", "-q", "--detach", str(wt), "origin/develop", cwd=work) + _commit(wt, "a") + b = _commit(wt, "b") + proc = _run(wt, b, env={"NX_PUSH_SOURCE": "HEAD"}) + assert proc.returncode == 2 + assert _remote_tip(origin) == before + + +class TestBackMerge: + """The release and plugin-cut back-merges: origin/main merged into + develop carries main-only commits nobody on develop authored.""" + + def _main_ahead(self, repos, tmp_path): + origin, work = repos + other = tmp_path / "other" + _git("clone", "-q", "-b", "develop", str(origin), str(other), cwd=tmp_path) + _git("checkout", "-q", "-b", "main", cwd=other) + rel = _commit(other, "release-only") + _git("push", "-q", "origin", "main", cwd=other) + _git("fetch", "-q", "origin", cwd=work) + return origin, work, rel + + def test_vouched_merge_covers_what_it_merges_in(self, repos, tmp_path) -> None: + origin, work, rel = self._main_ahead(repos, tmp_path) + _git("merge", "-q", "--no-ff", "--no-edit", "origin/main", cwd=work) + merge = _git("rev-parse", "HEAD", cwd=work) + proc = _run(work, merge) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert proc.stdout.strip() == f"PUSH_OK n=2 tip={merge}" + assert _remote_tip(origin) == merge + + def test_fast_forward_back_merge_vouches_head_and_its_merged_in_branch(self, repos, tmp_path) -> None: + """Right after a release the back-merge fast-forwards onto main's PR + merge commit; vouching HEAD covers the release branch under it.""" + origin, work = repos + other = tmp_path / "other" + _git("clone", "-q", "-b", "develop", str(origin), str(other), cwd=tmp_path) + _git("checkout", "-q", "-b", "release/x", cwd=other) + bump = _commit(other, "bump") + _git("checkout", "-q", "-b", "main", "origin/develop", cwd=other) + _git("merge", "-q", "--no-ff", "--no-edit", "release/x", cwd=other) + pr_merge = _git("rev-parse", "HEAD", cwd=other) + _git("push", "-q", "origin", "main", cwd=other) + _git("fetch", "-q", "origin", cwd=work) + _git("merge", "-q", "--no-edit", "origin/main", cwd=work) + assert _git("rev-parse", "HEAD", cwd=work) == pr_merge + proc = _run(work, "HEAD") + assert proc.returncode == 0, proc.stdout + proc.stderr + assert proc.stdout.strip() == f"PUSH_OK n=2 tip={pr_merge}" + assert bump in _git("rev-list", "refs/heads/develop", cwd=origin) + + def test_vouched_merge_does_not_cover_a_peer_commit_beneath_it(self, repos, tmp_path) -> None: + origin, work, rel = self._main_ahead(repos, tmp_path) + before = _remote_tip(origin) + peer = _commit(work, "peer") + _git("merge", "-q", "--no-edit", "origin/main", cwd=work) + merge = _git("rev-parse", "HEAD", cwd=work) + proc = _run(work, merge) + assert proc.returncode == 2 + assert peer[:7] in proc.stdout and rel[:7] not in proc.stdout + assert _remote_tip(origin) == before + + +class TestRefusals: + def test_unvouched_commit_in_range_is_refused_and_named(self, repos) -> None: + origin, work = repos + mine = _commit(work, "mine") + peer = _commit(work, "peer") + before = _remote_tip(origin) + proc = _run(work, mine) + assert proc.returncode == 2 + assert proc.stdout.startswith("PUSH_REFUSED_FOREIGN 1 of 2") + assert peer[:7] in proc.stdout and "peer" in proc.stdout + assert mine[:7] not in proc.stdout.splitlines()[1] + assert _remote_tip(origin) == before + + def test_no_vouch_with_outbound_commits_is_refused(self, repos) -> None: + origin, work = repos + before = _remote_tip(origin) + _commit(work, "x") + proc = _run(work) + assert proc.returncode == 2 + assert proc.stdout.startswith("PUSH_REFUSED_FOREIGN 1 of 1") + assert _remote_tip(origin) == before + + def test_already_pushed_vouch_is_stale(self, repos) -> None: + origin, work = repos + a = _commit(work, "a") + assert _run(work, a).returncode == 0 + proc = _run(work, a) + assert proc.returncode == 3 + assert proc.stdout.startswith("PUSH_REFUSED_STALE_VOUCH 1") + + def test_stale_vouch_with_other_outbound_commit_does_not_push(self, repos) -> None: + origin, work = repos + a = _commit(work, "a") + assert _run(work, a).returncode == 0 + b = _commit(work, "b") + proc = _run(work, a, b) + assert proc.returncode == 3 + assert _remote_tip(origin) == a + + def test_bad_sha_is_refused_before_any_push(self, repos) -> None: + origin, work = repos + a = _commit(work, "a") + proc = _run(work, a, "deadbeefdeadbeef") + assert proc.returncode == 5 + assert proc.stdout.startswith("PUSH_REFUSED_BAD_SHA") + assert _remote_tip(origin) != a + + def test_diverged_local_branch_is_refused(self, repos, tmp_path) -> None: + origin, work = repos + other = tmp_path / "other" + _git("clone", "-q", "-b", "develop", str(origin), str(other), cwd=tmp_path) + _commit(other, "remote-side") + _git("push", "-q", "origin", "develop", cwd=other) + a = _commit(work, "local-side") + proc = _run(work, a) + assert proc.returncode == 4 + assert proc.stdout.startswith("PUSH_REFUSED_DIVERGED") + + def test_missing_remote_branch_is_refused(self, repos) -> None: + origin, work = repos + a = _commit(work, "a") + proc = _run(work, a, env={"NX_PUSH_BRANCH": "nope"}) + assert proc.returncode == 6 + assert proc.stdout.startswith("PUSH_REFUSED_NO_BRANCH") diff --git a/tests/test_build_lease_session_gate.py b/tests/test_build_lease_session_gate.py new file mode 100644 index 000000000..fd7458522 --- /dev/null +++ b/tests/test_build_lease_session_gate.py @@ -0,0 +1,87 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""The suite refuses to START while a service build holds the shared lease, +or waits for it when asked (nexus-pv93h). + +Before this gate, a full run beside any Maven run on the box collected and +then errored every substrate-backed test at setup: 1216 setup errors in one +lint-bucket run on 2026-09-07, all one fact. Now ``pytest_sessionstart`` on +the controller decides once. These tests drive a real child pytest against +a lease held under ``NX_BUILD_LEASE_ROOT`` so the wiring is what is proven, +not only the helper. +""" +from __future__ import annotations + +import os +import subprocess +import sys +import threading +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +#: A file with no substrate need of its own, so only the session gate can +#: decide the child's exit status. +TARGET = "tests/scripts/test_git_push_develop_sh.py" + + +def _hold(root: Path, pid: int) -> None: + d = root / "service" + d.mkdir(parents=True) + (d / "pid").write_text(f"{pid}\n") + (d / "ts").write_text("2026-09-07T20:53:27Z\n") + (d / "label").write_text("gate-test\n") + (d / "command").write_text("scripts/mvnw-leased.sh ./mvnw test\n") + + +def _child(root: Path, **extra: str) -> subprocess.CompletedProcess: + env = {k: v for k, v in os.environ.items() if k not in {"NX_TEST_T2_SUBSTRATE", "NX_BUILD_LEASE_WAIT"}} + env.update(NX_BUILD_LEASE_ROOT=str(root), PYTEST_ADDOPTS="", **extra) + return subprocess.run( + [sys.executable, "-m", "pytest", "--collect-only", "-q", "-p", "no:cacheprovider", TARGET], + cwd=REPO_ROOT, env=env, capture_output=True, text=True, timeout=180, + ) + + +def test_a_held_lease_refuses_the_session_once_with_exit_75(tmp_path: Path) -> None: + _hold(tmp_path, os.getpid()) + proc = _child(tmp_path) + assert proc.returncode == 75, proc.stdout + proc.stderr + out = proc.stdout + proc.stderr + assert "refusing to start" in out and "gate-test" in out and "NX_BUILD_LEASE_WAIT" in out + assert "collected" not in proc.stdout.lower() or "0 tests collected" in proc.stdout.lower() + + +def test_a_no_substrate_run_is_never_gated(tmp_path: Path) -> None: + _hold(tmp_path, os.getpid()) + proc = _child(tmp_path, NX_TEST_T2_SUBSTRATE="none") + assert proc.returncode == 0, proc.stdout + proc.stderr + + +def test_a_dead_holder_does_not_gate(tmp_path: Path) -> None: + _hold(tmp_path, 4194304) + proc = _child(tmp_path) + assert proc.returncode == 0, proc.stdout + proc.stderr + + +def test_waiting_starts_the_session_once_the_holder_exits(tmp_path: Path) -> None: + holder = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(3)"]) + # Reap concurrently: an unreaped child is a zombie, and a zombie still + # answers kill(pid, 0), so the gate would keep seeing a live holder. + reaper = threading.Thread(target=holder.wait, daemon=True) + reaper.start() + try: + _hold(tmp_path, holder.pid) + proc = _child(tmp_path, NX_BUILD_LEASE_WAIT="60") + finally: + reaper.join(timeout=30) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert "build lease: held — waiting" in proc.stderr + assert "build lease: clear after" in proc.stderr + + +@pytest.mark.parametrize("bound", ["0", "5"]) +def test_an_exhausted_wait_still_refuses(tmp_path: Path, bound: str) -> None: + _hold(tmp_path, os.getpid()) + proc = _child(tmp_path, NX_BUILD_LEASE_WAIT=bound) + assert proc.returncode == 75, proc.stdout + proc.stderr diff --git a/tests/test_engine_version.py b/tests/test_engine_version.py index a67396ac5..56f42897f 100644 --- a/tests/test_engine_version.py +++ b/tests/test_engine_version.py @@ -12,9 +12,50 @@ from __future__ import annotations +import re +from pathlib import Path + +import pytest + from nexus.engine_version import REQUIRED_ENGINE_VERSION, parse_engine_version +_CHANGELOG = Path(__file__).resolve().parents[1] / "CHANGELOG.md" +_ENGINE_TAG_RE = re.compile(r"engine-service-v(\d+)\.(\d+)\.(\d+)") + + +def _floor_named_by_changelog(text: str) -> tuple[int, int, int]: + """The engine the newest RELEASED CHANGELOG section pairs with: the + first ``engine-service-vX.Y.Z`` under the first ``## [X.Y.Z]`` header + (``[Unreleased]`` is skipped, so an engine cut ahead of the client + never moves the floor). Fails loud on a CHANGELOG with no released + section or a released section naming no engine (nexus-9gggv).""" + sections = re.split(r"^## ", text, flags=re.MULTILINE)[1:] + released = [s for s in sections if not s.lstrip().startswith("[Unreleased]")] + assert released, "CHANGELOG.md has no released section to derive the engine floor from" + head, body = released[0].split("\n", 1) + m = _ENGINE_TAG_RE.search(body) + assert m, ( + f"CHANGELOG.md section {head.strip()!r} names no engine-service-vX.Y.Z pairing; " + "every release records the engine it was gated with (nexus-9gggv)" + ) + return tuple(int(g) for g in m.groups()) # type: ignore[return-value] + + +class TestFloorNamedByChangelog: + def test_reads_the_first_engine_of_the_newest_released_section(self) -> None: + text = "## [Unreleased]\n\nPairs with engine-service-v0.1.200 (not yet)\n\n## [7.36.0] - 2026-09-07\n\nPairs with engine-service-v0.1.108 (additive). Was engine-service-v0.1.107.\n\n## [7.35.0]\n\nengine-service-v0.1.107\n" + assert _floor_named_by_changelog(text) == (0, 1, 108) + + def test_no_released_section_fails_loud(self) -> None: + with pytest.raises(AssertionError, match="no released section"): + _floor_named_by_changelog("## [Unreleased]\n\nengine-service-v0.1.1\n") + + def test_released_section_without_a_pairing_fails_loud(self) -> None: + with pytest.raises(AssertionError, match="names no engine-service"): + _floor_named_by_changelog("## [7.37.0] - 2026-09-09\n\nClient only.\n\n## [7.36.0]\n\nengine-service-v0.1.108\n") + + class TestRequiredEngineVersion: def test_pinned_floor_is_current(self) -> None: # (0,1,5)->(0,1,8) for nexus-x2g1z; ->(0,1,34) for 6.5.0: the client @@ -518,7 +559,14 @@ def test_pinned_floor_is_current(self) -> None: # conexus 7.36.0; engine deployed before the client tag (additive # choreography). Local-mode installs get nx catalog restore and the # tombstone protection only through this pin. - assert REQUIRED_ENGINE_VERSION == (0, 1, 108) + # nexus-9gggv (2026-09-08): the tuple is no longer hand-typed here. + # Every client release records its engine pairing in CHANGELOG.md's + # newest released section (the first engine-service-vX.Y.Z it + # names), and that section is edited at release anyway, so the + # floor is derived from it: a bump without a CHANGELOG pairing, or a + # pairing without the bump, is the red. The trail above stays as + # the provenance history. + assert REQUIRED_ENGINE_VERSION == _floor_named_by_changelog(_CHANGELOG.read_text(encoding="utf-8")) class TestParseEngineVersion: diff --git a/tests/test_hal_git_policy_hook.py b/tests/test_hal_git_policy_hook.py index 030ade612..68ef0b4c9 100644 --- a/tests/test_hal_git_policy_hook.py +++ b/tests/test_hal_git_policy_hook.py @@ -384,3 +384,213 @@ def test_quote_inside_the_verb_is_still_blocked(repo_on): work = repo_on("feature/x") out = _decision(_run(_bash('gi"t push origin main', str(work)))) assert out["permissionDecision"] == "deny", out + + +# ── Rule 3: `git commit --amend` on a foreign tip in the primary (nexus-9wxu6) ── +# +# THE INCIDENT (2026-09-07, five sessions in one checkout). An amend in the +# shared primary rewrote a peer's commit, because HEAD had moved under the +# session between its commit and its amend. Ownership is read from the +# companion PostToolUse recorder's per-session file, never from the author +# field (every session commits as the same user). + +RECORDER = PROJECT_ROOT / "tests" / "fixtures" / "hal_record_session_commits_hook.py" + + +def _run_recorder(payload: dict, env: dict) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, str(RECORDER)], + input=json.dumps(payload), + capture_output=True, text=True, timeout=20, env={**os.environ, **env}, + ) + + +def _run_with_env(payload: dict, env: dict) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, str(HOOK)], + input=json.dumps(payload), + capture_output=True, text=True, timeout=20, env={**os.environ, **env}, + ) + + +def _commit(work, name: str) -> str: + (work / name).write_text(name) + _git("add", name, cwd=work) + _git("-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", "-m", name, cwd=work) + return subprocess.run(["git", "rev-parse", "HEAD"], cwd=work, check=True, + capture_output=True, text=True).stdout.strip() + + +@pytest.fixture() +def primary(repo_on, tmp_path): + work = repo_on("develop") + store = tmp_path / "session_commits" + return work, store, {"NX_SESSION_COMMITS_DIR": str(store)} + + +def _amend_payload(work, session_id: str | None = "sess-A", cmd: str = "git commit --amend --no-edit") -> dict: + payload = _bash(cmd, cwd=str(work)) + if session_id is not None: + payload["session_id"] = session_id + return payload + + +def test_amend_in_primary_with_unrecorded_head_is_denied(primary): + work, _store, env = primary + d = _decision(_run_with_env(_amend_payload(work), env)) + assert d["permissionDecision"] == "deny" + assert "nexus-9wxu6" in d["reason"] + + +def test_amend_in_primary_on_own_recorded_commit_is_allowed(primary): + work, store, env = primary + sha = _commit(work, "mine") + recorded = _run_recorder({**_bash("git commit -q -m mine", cwd=str(work)), "session_id": "sess-A"}, env) + assert recorded.returncode == 0 and recorded.stdout == "" + assert (store / "sess-A").read_text().split() == [sha] + d = _decision(_run_with_env(_amend_payload(work), env)) + assert d["permissionDecision"] == "allow" + + +def test_amend_after_a_peer_commit_on_top_is_denied(primary): + work, _store, env = primary + _commit(work, "mine") + _run_recorder({**_bash("git commit -q -m mine", cwd=str(work)), "session_id": "sess-A"}, env) + _commit(work, "peer") + _run_recorder({**_bash("git commit -q -m peer", cwd=str(work)), "session_id": "sess-B"}, env) + d = _decision(_run_with_env(_amend_payload(work, "sess-A"), env)) + assert d["permissionDecision"] == "deny" + d = _decision(_run_with_env(_amend_payload(work, "sess-B"), env)) + assert d["permissionDecision"] == "allow" + + +def test_amend_without_a_session_id_is_denied(primary): + work, _store, env = primary + sha = _commit(work, "mine") + _run_recorder({**_bash("git commit -q -m mine", cwd=str(work)), "session_id": "sess-A"}, env) + d = _decision(_run_with_env(_amend_payload(work, session_id=None), env)) + assert d["permissionDecision"] == "deny" + assert sha[:12] in d["reason"] + + +def test_amend_in_a_linked_worktree_is_never_blocked(primary, tmp_path): + work, _store, env = primary + wt = tmp_path / "wt" + _git("worktree", "add", "-q", "--detach", str(wt), "HEAD", cwd=work) + d = _decision(_run_with_env(_amend_payload(wt), env)) + assert d["permissionDecision"] == "allow" + + +def test_amend_via_dash_C_targets_the_named_repo(primary, tmp_path): + work, _store, env = primary + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + payload = _bash(f"git -C {work} commit --amend --no-edit", cwd=str(elsewhere)) + payload["session_id"] = "sess-A" + d = _decision(_run_with_env(payload, env)) + assert d["permissionDecision"] == "deny" + + +def test_plain_commit_in_primary_is_untouched(primary): + work, _store, env = primary + d = _decision(_run_with_env({**_bash("git commit -m x", cwd=str(work)), "session_id": "s"}, env)) + assert d["permissionDecision"] == "allow" + + +def test_amend_hidden_in_a_compound_command_is_caught(primary): + work, _store, env = primary + cmd = "git status && git commit --amend --no-edit" + d = _decision(_run_with_env(_amend_payload(work, cmd=cmd), env)) + assert d["permissionDecision"] == "deny" + + +def test_amend_escape_allows_and_logs(primary, tmp_path, monkeypatch): + work, _store, env = primary + log = tmp_path / "log.jsonl" + env = {**env, "NX_ROUTING_LOG_PATH": str(log)} + cmd = "git commit --amend --no-edit # routing-allow: HEAD predates the recorder" + d = _decision(_run_with_env(_amend_payload(work, cmd=cmd), env)) + assert d["permissionDecision"] == "allow" + events = [json.loads(l) for l in log.read_text().splitlines() if l.strip()] + assert [e["outcome"] for e in events] == ["escape"] + + +def test_amend_outside_a_repo_fails_open(tmp_path): + payload = {**_bash("git commit --amend", cwd=str(tmp_path)), "session_id": "s"} + d = _decision(_run_with_env(payload, {"NX_SESSION_COMMITS_DIR": str(tmp_path / "sc")})) + assert d["permissionDecision"] == "allow" + + +def test_recorder_records_dash_C_and_dedupes(primary, tmp_path): + work, store, env = primary + sha = _commit(work, "one") + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + payload = {**_bash(f"git -C {work} commit -m one", cwd=str(elsewhere)), "session_id": "sess-A"} + _run_recorder(payload, env) + _run_recorder(payload, env) + assert (store / "sess-A").read_text().split() == [sha] + + +def test_recorder_ignores_non_commit_commands(primary): + work, store, env = primary + _run_recorder({**_bash("git status", cwd=str(work)), "session_id": "sess-A"}, env) + assert not (store / "sess-A").exists() + + +def test_amend_glued_to_a_closing_paren_is_caught(primary): + work, _store, env = primary + cmd = f"(cd {work} && git commit --amend --no-edit)" + d = _decision(_run_with_env(_amend_payload(work, cmd=cmd), env)) + assert d["permissionDecision"] == "deny" + + +def test_recorder_prefers_the_sha_git_commit_printed(primary): + work, store, env = primary + mine = _commit(work, "mine") + peer = _commit(work, "peer-landed-before-the-hook-ran") + payload = {**_bash("git commit -m mine", cwd=str(work)), "session_id": "sess-A", + "tool_response": {"stdout": f"[develop {mine[:7]}] mine\n 1 file changed\n"}} + _run_recorder(payload, env) + assert (store / "sess-A").read_text().split() == [mine] + assert peer not in (store / "sess-A").read_text() + + +# ── Rule 4: bare `git push` to develop where the vouched script exists ────── + + +def _with_script(work): + (work / "scripts").mkdir(exist_ok=True) + (work / "scripts" / "git-push-develop.sh").write_text("#!/bin/sh\n") + + +@pytest.mark.parametrize("cmd", ["git push", "git push origin develop", "git push -u origin HEAD:develop", + "git fetch && git push origin develop"]) +def test_bare_push_to_develop_is_blocked_where_the_script_exists(cmd, repo_on): + work = repo_on("develop") + _with_script(work) + d = _decision(_run(_bash(cmd, cwd=str(work)))) + assert d["permissionDecision"] == "deny" + assert "git-push-develop.sh" in d["reason"] + + +def test_bare_push_to_develop_is_allowed_where_no_script_exists(repo_on): + work = repo_on("develop") + d = _decision(_run(_bash("git push origin develop", cwd=str(work)))) + assert d["permissionDecision"] == "allow" + + +@pytest.mark.parametrize("cmd", ["git push origin feature/x", "git push origin v1.2.3", "git push --tags", + "scripts/git-push-develop.sh abc1234"]) +def test_other_pushes_and_the_script_itself_are_allowed(cmd, repo_on): + work = repo_on("develop") + _with_script(work) + d = _decision(_run(_bash(cmd, cwd=str(work)))) + assert d["permissionDecision"] == "allow" + + +def test_bare_push_to_develop_escape_allows(repo_on): + work = repo_on("develop") + _with_script(work) + d = _decision(_run(_bash("git push origin develop # routing-allow: release back-merge", cwd=str(work)))) + assert d["permissionDecision"] == "allow" diff --git a/tests/test_health_redact_credentials.py b/tests/test_health_redact_credentials.py new file mode 100644 index 000000000..f094847e8 --- /dev/null +++ b/tests/test_health_redact_credentials.py @@ -0,0 +1,61 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""nexus-8ooxn / nexus-hcy4w: a service error body that echoes the rejected +credential is redacted before it reaches a structlog line. The doctor's +printed output already omitted it; the log line did not, and under xdist +the worker's console renderer landed that line in the CliRunner output. +""" +from __future__ import annotations + +from nexus.health import redact_credentials + + +def test_api_key_value_is_redacted() -> None: + assert redact_credentials("HTTP 401: invalid api_key SUPERSECRET") == "HTTP 401: invalid api_key [redacted]" + assert "SUPERSECRET" not in redact_credentials("token=SUPERSECRET; retry") + assert redact_credentials("Bearer abc.def.ghi expired") == "Bearer [redacted] expired" + + +def test_text_without_credentials_is_unchanged() -> None: + msg = "connection refused: 127.0.0.1:26901" + assert redact_credentials(msg) == msg + + +def test_prose_that_merely_uses_a_key_word_is_unchanged() -> None: + """Review of 4c092e40a: the first regex swallowed the word after any key + word, so the managed-service remedy ("invalid token\\nthe managed nexus + service ...") lost its own next word. Prose survives; values do not.""" + remedy = "HTTP 401: invalid token\nthe managed nexus service refused the request" + assert redact_credentials(remedy) == remedy + assert redact_credentials("token endpoint returned 404 for /oauth/token") == ( + "token endpoint returned 404 for /oauth/token" + ) + assert redact_credentials("secret sauce") == "secret sauce" + + +def test_client_error_message_is_redacted_at_the_source(monkeypatch) -> None: + """Critique of 4c092e40a: the credential entered VectorServiceError's + message in http_vector_client, so every renderer of that message (the + doctor's log line, ``nx search``, ``nx doc``) echoed it. Redaction now + happens where the body enters the message.""" + import io + import urllib.error + + import pytest + + import nexus.db.http_vector_client as hv + + body = b'{"error":"invalid api_key sk-live-ABCDEF1234567890","detail":"token=sk-live-ABCDEF1234567890 rejected"}' + + def _raise(*a, **k): + raise urllib.error.HTTPError(url="http://svc/v1/x", code=401, msg="err", hdrs={}, fp=io.BytesIO(body)) + + monkeypatch.setattr(hv, "_request", _raise) + monkeypatch.setattr(hv, "_managed_remedy", lambda: None) + monkeypatch.setattr(hv, "_local_voyage_restart_remedy", lambda code, text: None) + with pytest.raises(hv.VectorServiceError) as excinfo: + hv._post("/v1/vectors/query", {"q": 1}) + assert "sk-live" not in str(excinfo.value), str(excinfo.value) + assert "api_key [redacted]" in str(excinfo.value) + with pytest.raises(hv.VectorServiceError) as excinfo: + hv._get("/v1/vectors/collections") + assert "sk-live" not in str(excinfo.value), str(excinfo.value) diff --git a/tests/test_nexus_config_dir_setattr_lint.py b/tests/test_nexus_config_dir_setattr_lint.py index 8e3c428d9..98f1bb0ba 100644 --- a/tests/test_nexus_config_dir_setattr_lint.py +++ b/tests/test_nexus_config_dir_setattr_lint.py @@ -471,7 +471,7 @@ def _all_import_sites() -> tuple[dict[str, list[int]], dict[str, list[int]]]: # `from nexus.config import nexus_config_dir` import (inside # remediation_opt_in()) was deleted with the whole file, shrinking the # total by 1. -_TOTAL_BY_VALUE_IMPORT_CEILING = 77 # -1: _session_end_census.capability_census_log_path deleted at nexus-gjv9b PART 3; +1: upgrade_finish.py aspect-worker respawn (nexus-06fu4/restart-stale fix); the 3o4lt bump to 79 was reverted by switching doc_indexer to a module import +_TOTAL_BY_VALUE_IMPORT_CEILING = 78 # +1: _session_end_launcher._sweep_local_garbage (nexus-fjwk7), deferred # -1: _session_end_census.capability_census_log_path deleted at nexus-gjv9b PART 3; +1: upgrade_finish.py aspect-worker respawn (nexus-06fu4/restart-stale fix); the 3o4lt bump to 79 was reverted by switching doc_indexer to a module import def test_module_level_by_value_import_ratchet() -> None: diff --git a/tests/test_pipefail_early_exit_consumer_lint.py b/tests/test_pipefail_early_exit_consumer_lint.py index 13574b31e..608cffa9d 100644 --- a/tests/test_pipefail_early_exit_consumer_lint.py +++ b/tests/test_pipefail_early_exit_consumer_lint.py @@ -941,7 +941,7 @@ def test_scope_precondition_a_script_with_the_hazard_shape_but_no_pipefail_is_no "tests/e2e/migration-rehearsal/run.sh:209", "tests/e2e/migration-rehearsal/run.sh:222", "tests/e2e/migration-rehearsal/run.sh:233", - "tests/e2e/migration-rehearsal/run.sh:701", + "tests/e2e/migration-rehearsal/run.sh:704", # --- tests/e2e/mac-signed-binary-gate.sh (7 entries): needs an # actually-signed macOS binary + `spctl`/`codesign` on real macOS # to safely verify a rewrite of the signature-inspection logic. diff --git a/tests/test_session_end_garbage_sweep.py b/tests/test_session_end_garbage_sweep.py new file mode 100644 index 000000000..8daa7000e --- /dev/null +++ b/tests/test_session_end_garbage_sweep.py @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""nexus-fjwk7: session close reaps the config directory's mint-lock litter. + +``t1_mint_.lock`` files are the flock inode of a T1 mint and were +never reaped; hundreds accumulated on one box. ``nexus.garbage`` already +knew how to sweep them (``nx doctor --fix``); the session-end launcher now +runs that sweep as its own failure-isolated step. +""" +from __future__ import annotations + +import os +import time +from pathlib import Path + +from nexus import _session_end_launcher as launcher +from nexus.garbage import MINT_LOCK_MAX_AGE_DAYS + + +def test_session_end_sweeps_stale_mint_locks(tmp_path: Path, monkeypatch) -> None: + cfg = tmp_path / "cfg" + cfg.mkdir() + stale = cfg / "t1_mint_deadbeef.lock" + stale.touch() + old = time.time() - (MINT_LOCK_MAX_AGE_DAYS + 1) * 86400 + os.utime(stale, (old, old)) + fresh = cfg / "t1_mint_cafebabe.lock" + fresh.touch() + monkeypatch.setenv("NEXUS_CONFIG_DIR", str(cfg)) + launcher._sweep_local_garbage() + assert not stale.exists(), "a day-old lock with no lease is reaped" + assert fresh.exists(), "a fresh lock is left alone" + + +def test_sweep_failure_never_raises(monkeypatch) -> None: + def boom(*a, **k): + raise RuntimeError("store unavailable") + monkeypatch.setattr("nexus.garbage.sweep_local_garbage", boom) + launcher._sweep_local_garbage() # must not raise diff --git a/tests/test_stale_dim_table_reference_lint.py b/tests/test_stale_dim_table_reference_lint.py index a443f8836..740772194 100644 --- a/tests/test_stale_dim_table_reference_lint.py +++ b/tests/test_stale_dim_table_reference_lint.py @@ -61,6 +61,21 @@ real multi-language comment parser; that tradeoff is revisited only if a real incident turns on this distinction, not preemptively. + REVISITED 2026-09-08 (nexus-9gggv): the incident came, from the cost + side rather than the detection side. Three fix-forward commits on + 2026-09-07 moved count pins because a COMMENT named chunks_384, each + one a release-battery red on prose. What is built is deliberately + NOT the comment parser the paragraph above declines: it is a + line-prefix heuristic (``_COMMENT_LINE_RE``, per file suffix: ``#`` + for .py/.sh, ``//`` ``/*`` ``*`` for .java, ``