From ec545da887feb01d9acecbbd5770fb2112008289 Mon Sep 17 00:00:00 2001 From: Hellblazer Date: Mon, 7 Sep 2026 17:27:52 -0700 Subject: [PATCH 01/23] fix(dt): page coverage after review [24937] [24938] (nexus-i0cwh) A pipeline buffer written before pages_with_text existed now reads as unverified, never as a 100% gap: the field stays None end to end when the extraction metadata lacks it. A MinerU batch names every page it covers (page_numbers) so the collector counts the whole batch, not its first page. A DEVONthink record with no usable pageCount is tallied as unverified with its own reason instead of vanishing from the summary. DEVONthink's record name becomes the catalog title beside url and year, which the bead's acceptance names. The failure message names the oracle it uses. --- src/nexus/commands/dt.py | 27 +++++++++++--- src/nexus/doc_indexer.py | 9 ++--- src/nexus/pdf_extractor.py | 16 ++++++--- src/nexus/pipeline_stages.py | 5 ++- tests/test_commands_dt.py | 39 +++++++++++++++++++++ tests/test_pdf_extractor_pages_with_text.py | 15 ++++++++ 6 files changed, 97 insertions(+), 14 deletions(-) diff --git a/src/nexus/commands/dt.py b/src/nexus/commands/dt.py index 97bfdd6ac..d5dba3cf8 100644 --- a/src/nexus/commands/dt.py +++ b/src/nexus/commands/dt.py @@ -336,6 +336,11 @@ def _stamp_dt_uri_on_entry(file_path: Path, uuid: str, facts: dict | None = None meta: dict = {"devonthink_uri": dt_uri} fields: dict = {} if facts: + # The catalog title is DEVONthink's record name (the bead's + # acceptance names DEVONthink's title/url/year), not the + # indexer's PDF-derived guess. + if facts.get("name") and facts["name"] != getattr(entry, "title", ""): + fields["title"] = facts["name"] if facts.get("url"): meta["devonthink_url"] = facts["url"] if facts.get("page_count"): @@ -936,6 +941,7 @@ def index_cmd( coverage_failed = 0 coverage_unverified = 0 unverified_no_pages = 0 + unverified_no_page_count = 0 page_gap_allowed = 0 _reset_facts_cache() # nexus-l6tr7: refusals that PROPAGATED (streaming/incremental path) and @@ -1245,11 +1251,16 @@ def index_cmd( # was checked when it was indexed). if ext == ".pdf" and chunks: facts = _dt_record_facts(uuid) if pages_seen is not None else None - if pages_seen is None or facts is None: + if pages_seen is None or facts is None or facts["page_count"] <= 0: + # Unverified, never covered (code review [24938] finding 2: + # a DT record with no usable pageCount must not vanish from + # the tally). coverage_unverified += 1 if pages_seen is None: unverified_no_pages += 1 - elif facts["page_count"] > 0: + elif facts is not None: + unverified_no_page_count += 1 + else: missing = _missing_pages(pages_seen, facts["page_count"]) _stamp_page_gap(uuid, missing) # records a gap, or clears a stale one if missing: @@ -1257,7 +1268,8 @@ def index_cmd( detail = ( f"page coverage: {seen_n} of {facts['page_count']} pages produced text; " f"missing pages {', '.join(str(n) for n in missing)} (DEVONthink pageCount vs " - "chunk page_number). Re-run with --extractor mineru, or --allow-page-gap to accept." + "the pages the extractor produced text for). Re-run with --extractor mineru, " + "or --allow-page-gap to accept." ) if allow_page_gap: page_gap_allowed += 1 @@ -1314,11 +1326,16 @@ def index_cmd( click.echo(summary) if coverage_unverified: reasons = [] - if coverage_unverified - unverified_no_pages: + unreachable = coverage_unverified - unverified_no_pages - unverified_no_page_count + if unreachable: reasons.append( - f"{coverage_unverified - unverified_no_pages} with the DEVONthink MCP unreachable " + f"{unreachable} with the DEVONthink MCP unreachable " "(pageCount could not be read; re-run with DEVONthink running)" ) + if unverified_no_page_count: + reasons.append( + f"{unverified_no_page_count} where DEVONthink reports no pageCount for the record" + ) if unverified_no_pages: reasons.append( f"{unverified_no_pages} where the extractor reported no per-page text " diff --git a/src/nexus/doc_indexer.py b/src/nexus/doc_indexer.py index a98310ecc..6da9dfd3c 100644 --- a/src/nexus/doc_indexer.py +++ b/src/nexus/doc_indexer.py @@ -2161,7 +2161,8 @@ def _pdf_chunks( ) if extraction_stats is not None: extraction_stats["page_count"] = int(result.metadata.get("page_count", 0) or 0) - extraction_stats["pages_with_text"] = list(result.metadata.get("pages_with_text") or []) + _pwt = result.metadata.get("pages_with_text") + extraction_stats["pages_with_text"] = list(_pwt) if _pwt is not None else None chunker = PDFChunker(chunk_chars=chunk_chars) if chunk_chars is not None else PDFChunker() chunks = chunker.chunk(result.text, result.metadata) if not chunks: @@ -2845,7 +2846,7 @@ def _rollback_if_freshly_minted(exc: BaseException) -> None: "title": all_meta[0].get("title", "") if all_meta else "", "author": all_meta[0].get("source_author", "") if all_meta else "", "page_count": _extraction_stats.get("page_count", 0), - "pages_with_text": list(_extraction_stats.get("pages_with_text", [])), + "pages_with_text": _extraction_stats.get("pages_with_text"), } return count @@ -2929,7 +2930,7 @@ def _register_in_catalog(meta_list: list[dict], chunk_count: int) -> None: "title": metadatas[0].get("title", "") if metadatas else "", "author": metadatas[0].get("source_author", "") if metadatas else "", "page_count": _extraction_stats.get("page_count", 0), - "pages_with_text": list(_extraction_stats.get("pages_with_text", [])), + "pages_with_text": _extraction_stats.get("pages_with_text"), } return count @@ -3107,7 +3108,7 @@ def _register_in_catalog(meta_list: list[dict], chunk_count: int) -> None: "title": metadatas_list[0].get("source_title", "") if metadatas_list else "", "author": metadatas_list[0].get("source_author", "") if metadatas_list else "", "page_count": _extraction_stats.get("page_count", 0), - "pages_with_text": list(_extraction_stats.get("pages_with_text", [])), + "pages_with_text": _extraction_stats.get("pages_with_text"), } return len(prepared) diff --git a/src/nexus/pdf_extractor.py b/src/nexus/pdf_extractor.py index fe660dc08..af9e0e3ac 100644 --- a/src/nexus/pdf_extractor.py +++ b/src/nexus/pdf_extractor.py @@ -963,7 +963,8 @@ def extract( f"PDF not found or not a regular file: {pdf_path}" ) - # nexus-i0cwh: every backend fires on_page per page with + # nexus-i0cwh: every backend fires on_page per page (or per MinerU + # batch, which then names its pages in ``page_numbers``) with # ``text_length``; the pages that produced text are the coverage # oracle's input (chunk page numbers only mark where chunks START, # so a 20-page deck in 7 chunks would read as 13 missing pages). @@ -974,8 +975,9 @@ def _collect(page_index: int, page_text: str, page_metadata: dict) -> None: length = page_metadata.get("text_length") if length is None: length = len(page_text or "") - if isinstance(number, int) and length and int(length) > 0: - pages_with_text.add(number) + if length and int(length) > 0: + numbers = page_metadata.get("page_numbers") or ([number] if isinstance(number, int) else []) + pages_with_text.update(int(n) for n in numbers) if on_page is not None: on_page(page_index, page_text, page_metadata) @@ -1443,7 +1445,13 @@ def _append_batch(s: int, md: str, content_list: list[dict], # without per-page md from MinerU). on_page/md_parts/content fire # once for the batch; per_page_lengths is the distributed form. if on_page is not None: - on_page(s, md, {"page_number": s + 1, "text_length": len(md)}) + # page_numbers names every page the batch md covers, so the + # pages_with_text collector counts the whole batch, not its + # first page (critique [24937] S3). + on_page(s, md, { + "page_number": s + 1, "text_length": len(md), + "page_numbers": list(range(s + 1, s + 1 + max(1, batch_pages))), + }) md_parts.append(md) _rebase_page_idx(content_list, s) all_content_list.extend(content_list) diff --git a/src/nexus/pipeline_stages.py b/src/nexus/pipeline_stages.py index 4e6064081..2868697c4 100644 --- a/src/nexus/pipeline_stages.py +++ b/src/nexus/pipeline_stages.py @@ -1113,7 +1113,10 @@ def pipeline_index_pdf( if extraction_stats is not None: _em = getattr(extraction_result, "metadata", None) or {} extraction_stats["page_count"] = int(_em.get("page_count", 0) or 0) - extraction_stats["pages_with_text"] = list(_em.get("pages_with_text") or []) + # None when the extraction predates the field (a resumed pipeline + # buffer written by an older version): "unverified", never "no pages". + _pwt = _em.get("pages_with_text") + extraction_stats["pages_with_text"] = list(_pwt) if _pwt is not None else None # Resolve collection once for all post-passes (avoids repeated API calls). col = t3.get_or_create_collection(collection) diff --git a/tests/test_commands_dt.py b/tests/test_commands_dt.py index 325765201..3900a78b9 100644 --- a/tests/test_commands_dt.py +++ b/tests/test_commands_dt.py @@ -2248,6 +2248,32 @@ def test_missing_pages(self): assert _missing_pages([], 0) == [] assert _missing_pages([2, 2, 1], 2) == [] + def test_stamp_writes_devonthink_title_url_year(self, monkeypatch, tmp_path): + """critique [24937] Critical 2: DEVONthink's name becomes the catalog title.""" + import nexus.commands.dt as dt_mod + from types import SimpleNamespace + + updates: list[tuple] = [] + + class _Reader: + def find_by_file_path(self, p): + return SimpleNamespace(tumbler="1.12.9", title="pdf guess", year=0) + def close(self): pass + + class _Writer: + def update(self, tumbler, **fields): + updates.append((tumbler, fields)) + def close(self): pass + + monkeypatch.setattr("nexus.catalog.factory.make_catalog_reader", lambda: _Reader()) + monkeypatch.setattr("nexus.catalog.factory.make_catalog_writer", lambda priority="": _Writer()) + facts = {"name": "DT Name", "url": "https://x/y.pdf", "year": 2013, "page_count": 20} + assert dt_mod._stamp_dt_uri_on_entry(tmp_path / "a.pdf", "U1", facts=facts) + tumbler, fields = updates[0] + assert fields["title"] == "DT Name" and fields["year"] == 2013 + assert fields["source_uri"] == "x-devonthink-item://U1" + assert fields["meta"]["devonthink_url"] == "https://x/y.pdf" and fields["meta"]["devonthink_page_count"] == 20 + def test_record_facts_from_dt_properties(self, monkeypatch): import nexus.commands.dt as dt_mod @@ -2323,6 +2349,19 @@ def record(uuid, path, *, collection, corpus, dry_run, extractor="auto", force=F assert "1 page coverage unverified" in result.output assert "no per-page text" in result.output + def test_zero_page_count_is_unverified_not_silent(self, runner, fake_selectors, monkeypatch): + """code review [24938] finding 2: DT reachable but pageCount 0.""" + from nexus.cli import main + import nexus.commands.dt as dt_mod + + fake_selectors["selection"].return_value = [("U", "/a.pdf")] + self._dispatch(monkeypatch, [1, 2]) + monkeypatch.setattr(dt_mod, "_dt_record_facts", lambda uuid: {"name": "a", "url": "", "year": 0, "page_count": 0}) + result = runner.invoke(main, ["dt", "index", "--selection"]) + assert result.exit_code == 0, result.output + assert "1 page coverage unverified" in result.output + assert "reports no pageCount" in result.output + def test_unreachable_dt_is_unverified_not_covered(self, runner, fake_selectors, monkeypatch): from nexus.cli import main import nexus.commands.dt as dt_mod diff --git a/tests/test_pdf_extractor_pages_with_text.py b/tests/test_pdf_extractor_pages_with_text.py index a5de6a69f..c59511f00 100644 --- a/tests/test_pdf_extractor_pages_with_text.py +++ b/tests/test_pdf_extractor_pages_with_text.py @@ -42,3 +42,18 @@ def test_caller_on_page_still_fires(monkeypatch, tmp_path: Path) -> None: seen: list[int] = [] PDFExtractor().extract(pdf, extractor="docling", on_page=lambda i, t, m: seen.append(m["page_number"])) assert seen == [1, 2] + + +def test_mineru_batch_metadata_counts_every_page_of_the_batch(monkeypatch, tmp_path: Path) -> None: + """A batch callback carrying page_numbers marks all of them (critique [24937] S3).""" + pdf = tmp_path / "x.pdf" + pdf.write_bytes(b"%PDF-1.4\n") + + def dispatch(self, pdf_path, *, extractor, on_formula_oom, on_page): + on_page(0, "batch text " * 10, {"page_number": 1, "text_length": 100, "page_numbers": [1, 2, 3]}) + on_page(3, "", {"page_number": 4, "text_length": 0, "page_numbers": [4, 5]}) + return ExtractionResult(text="batch text", metadata={"page_count": 5}) + monkeypatch.setattr(PDFExtractor, "_extract_dispatch", dispatch) + monkeypatch.setattr("nexus.pdf_extractor._enforce_extraction_quality", lambda *a, **k: None) + result = PDFExtractor().extract(pdf, extractor="mineru") + assert result.metadata["pages_with_text"] == [1, 2, 3] From 3df3a3824b05751f1b454540fd98062d9ef1bbeb Mon Sep 17 00:00:00 2001 From: Hellblazer Date: Mon, 7 Sep 2026 17:34:12 -0700 Subject: [PATCH 02/23] test(doc_indexer): streaming return_metadata pins carry page_count and pages_with_text (nexus-i0cwh) The fake pipeline reports no extraction stats, so pages_with_text is None (unverified), and the empty-meta shape carries page_count 0 and an empty pages_with_text list. --- tests/test_doc_indexer.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_doc_indexer.py b/tests/test_doc_indexer.py index eebe9881b..1c73b947c 100644 --- a/tests/test_doc_indexer.py +++ b/tests/test_doc_indexer.py @@ -2465,7 +2465,9 @@ def test_streaming_return_metadata_reflects_pipeline_chunks_no_catalog(self, tmp "title": "My Paper", "author": "A. Thor", "page_count": 0, - "pages_with_text": [], + # None: the fake pipeline reports no extraction stats, which reads + # as "unverified", never as "no pages" (nexus-i0cwh). + "pages_with_text": None, } # Kill control: assert the metadata-read call actually used the # content_hash identity, not merely that the mock happened to return @@ -2500,7 +2502,9 @@ def test_streaming_return_metadata_uses_manifest_when_doc_id_known(self, tmp_pat "title": "My Paper", "author": "A. Thor", "page_count": 0, - "pages_with_text": [], + # None: the fake pipeline reports no extraction stats, which reads + # as "unverified", never as "no pages" (nexus-i0cwh). + "pages_with_text": None, } mock_meta_for_doc_id.assert_called_once() call_args = mock_meta_for_doc_id.call_args @@ -2542,7 +2546,7 @@ def test_streaming_return_metadata_empty_when_pipeline_wrote_nothing(self, tmp_p already-complete) must still return the all-empty dict, not raise -- the fail-loud guard is for chunks>0-but-no-metadata only.""" result, _ = self._run(tmp_path, pipeline_count=0, populated_metadatas=[], doc_id="") - assert result == {"chunks": 0, "pages": [], "title": "", "author": ""} + assert result == {"chunks": 0, "pages": [], "title": "", "author": "", "page_count": 0, "pages_with_text": []} def test_streaming_return_metadata_fail_loud_on_inconsistent_empty_read_no_catalog(self, tmp_path): """FAIL LOUD (no-catalog branch): the pipeline reports chunks From 2f6547720501af216b684ddf135ff1dc861c9418 Mon Sep 17 00:00:00 2001 From: Hellblazer Date: Mon, 7 Sep 2026 17:39:30 -0700 Subject: [PATCH 03/23] test(doc_indexer): the streaming path's empty result reports pages_with_text None (nexus-i0cwh) The fake pipeline reports no extraction stats; the dict index_pdf builds on the streaming path carries pages_with_text None (unverified), not an empty list. --- tests/test_doc_indexer.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_doc_indexer.py b/tests/test_doc_indexer.py index 1c73b947c..bfee4af49 100644 --- a/tests/test_doc_indexer.py +++ b/tests/test_doc_indexer.py @@ -2546,7 +2546,9 @@ def test_streaming_return_metadata_empty_when_pipeline_wrote_nothing(self, tmp_p already-complete) must still return the all-empty dict, not raise -- the fail-loud guard is for chunks>0-but-no-metadata only.""" result, _ = self._run(tmp_path, pipeline_count=0, populated_metadatas=[], doc_id="") - assert result == {"chunks": 0, "pages": [], "title": "", "author": "", "page_count": 0, "pages_with_text": []} + # The streaming path builds its dict from the run's extraction stats; + # a fake pipeline reports none, so pages_with_text is None (unverified). + assert result == {"chunks": 0, "pages": [], "title": "", "author": "", "page_count": 0, "pages_with_text": None} def test_streaming_return_metadata_fail_loud_on_inconsistent_empty_read_no_catalog(self, tmp_path): """FAIL LOUD (no-catalog branch): the pipeline reports chunks From 65c3dc49baf10bb9777c3ce3ffaca2fa29f2e75e Mon Sep 17 00:00:00 2001 From: Hellblazer Date: Mon, 7 Sep 2026 17:50:19 -0700 Subject: [PATCH 04/23] fix(aspects): cost banner names the measured model, constant re-based on haiku, usage logged before the envelope check, extraction refusal counts orphans (nexus-oc98c, nexus-3ygp3) Review of cc4f23479 found two operator-facing strings still claiming the ambient default model as the cost basis and the $1.18 constant still measured on the wrong model. The constant is now the mean of three haiku dispatches through the production prompt over real papers ($0.20), the banner names the configured model, usage is logged before the result-key check so a malformed envelope's spend is still recorded, and the extraction verb's refusal names orphan aspect rows the way the audit does. --- src/nexus/aspect_extractor.py | 6 ++- src/nexus/commands/enrich.py | 49 ++++++++++++++--------- tests/test_enrich_aspects.py | 4 +- tests/test_enrich_aspects_identity_key.py | 7 ++++ 4 files changed, 43 insertions(+), 23 deletions(-) diff --git a/src/nexus/aspect_extractor.py b/src/nexus/aspect_extractor.py index 847cce49f..b55616f10 100644 --- a/src/nexus/aspect_extractor.py +++ b/src/nexus/aspect_extractor.py @@ -1419,12 +1419,13 @@ def _invoke_once_batch(prompt: str, *, timeout: int, model: str | None = None) - outer = json.loads(result.stdout) except (ValueError, TypeError) as exc: raise _TransientFailure(f"outer json parse failure: {exc}") from exc + if isinstance(outer, dict): + _log_usage(outer, model) # a malformed envelope still cost money if not isinstance(outer, dict) or "result" not in outer: raise _HardFailure( "claude --output-format json wrapper missing 'result' key" ) - _log_usage(outer, model) inner_text = outer["result"] if not isinstance(inner_text, str): raise _HardFailure( @@ -1714,13 +1715,14 @@ def _invoke_once(prompt: str, *, model: str | None = None) -> dict: outer = json.loads(result.stdout) except (ValueError, TypeError) as exc: raise _TransientFailure(f"outer json parse failure: {exc}") from exc + if isinstance(outer, dict): + _log_usage(outer, model) # a malformed envelope still cost money if not isinstance(outer, dict) or "result" not in outer: raise _HardFailure( "claude --output-format json wrapper missing 'result' key " f"(got keys: {list(outer.keys()) if isinstance(outer, dict) else type(outer).__name__})", ) - _log_usage(outer, model) inner_text = outer["result"] if not isinstance(inner_text, str): raise _HardFailure( diff --git a/src/nexus/commands/enrich.py b/src/nexus/commands/enrich.py index f5988e55f..7b6ecfd4d 100644 --- a/src/nexus/commands/enrich.py +++ b/src/nexus/commands/enrich.py @@ -966,12 +966,14 @@ def _coll_entries() -> list: # # Since nexus-oc98c production passes the config's model_version as # ``--model`` (haiku for scholarly-paper-v1) and logs each extraction's -# actual cost and model (``aspect_extractor_usage``). Measured 2026-09-07 -# on haiku with real 54k to 109k token papers: $0.13 to $0.31 per paper. -# This constant is therefore an upper bound from the wrong model; the -# log line is the figure to trust, and the constant should be refreshed -# from it by re-running ``-m integration -k LiveMeasurement -s``. -_PER_PAPER_COST_USD = 1.18 +# actual cost and model (``aspect_extractor_usage``), so that 1.18 figure +# is from the wrong model. Re-measured 2026-09-07 on haiku through the +# production prompt over three real papers reassembled from T3 (100, 133 +# and 216 chunks; 54k, 62k and 109k prompt tokens): $0.131, $0.159 and +# $0.306. The constant is their mean; the per-document actual is the log +# line, and this is refreshed by re-running ``-m integration -k +# LiveMeasurement -s`` when the model or its pricing changes. +_PER_PAPER_COST_USD = 0.20 # Default per the RDR's original Phase 2 spec. The P1.3 spike's # 16.7% strict-equality "stability" rate measures whether the model @@ -1158,12 +1160,13 @@ def enrich_aspects( cost_str = "Estimated cost: $0 (deterministic parser, no API calls)" else: cost_estimate = len(entries) * _PER_PAPER_COST_USD - # RDR-196 .p0b (nexus-nyry9.6): "at Haiku rates" was FALSE -- the - # extractor never passes --model, so it runs the CLI's ambient - # default model, not Haiku. Name the real basis instead: a single - # measured sample dispatch (see _PER_PAPER_COST_USD's docstring), - # not a live-metered per-run figure. - cost_str = f"Estimated cost: ~${cost_estimate:.2f} (single-sample measured estimate, default model)" + # A per-paper mean from measured dispatches on the config's model + # (see _PER_PAPER_COST_USD), not a live-metered per-run figure; + # the per-document actual lands in the aspect_extractor_usage log. + cost_str = ( + f"Estimated cost: ~${cost_estimate:.2f} " + f"(mean of measured {config.model_version} dispatches)" + ) click.echo( f"{len(entries)} document(s) in '{collection}' " f"(extractor={config.extractor_name}, " @@ -1255,12 +1258,15 @@ def _select_entries( click.echo("Catalog is empty — index or store documents first (nx index repo / nx store put).") return None entries = cat.list_by_collection(collection) - if not entries: - # nexus-3ygp3: the same vacuous shape as the --missing audit. A - # bare name that select_config prefix-matched but the catalog - # does not know produced "No documents to process" at exit 0, - # which reads as a completed, zero-cost extraction. - raise click.ClickException(_no_catalog_rows_message(collection)) + # nexus-3ygp3: zero catalog rows is a refusal, never "No documents to + # process" at exit 0 (a bare name select_config prefix-matched but the + # catalog does not know read as a completed, zero-cost extraction). + # The refusal is raised after the aspect-side read below so it can + # name the rows that exist under this name with no entry to claim + # them, as the --missing audit does; --all takes no such read and + # refuses without the count. + orphan_rows: int | None = None + raw_empty = not entries if re_extract or not extract_all: # ONE T2 open serves both filters. nexus-ym9ey originally added a @@ -1280,6 +1286,9 @@ def _select_entries( config_extractor_name, extractor_version, ) } if re_extract else set() + orphan_rows = len(existing_paths) + if not entries: + raise click.ClickException(_no_catalog_rows_message(collection, orphan_rows)) if re_extract: # "Ensure every entry is at >= version": rows below the threshold, @@ -1312,6 +1321,8 @@ def _select_entries( f"--extractor-version X to refresh only outdated rows." ) + if raw_empty: + raise click.ClickException(_no_catalog_rows_message(collection, orphan_rows)) return entries @@ -1457,7 +1468,7 @@ def _dry_run_predict_skips( actual_cost = (len(entries) - skipped) * _PER_PAPER_COST_USD click.echo( f" Predicted actual cost (excluding skips): ~${actual_cost:.2f} " - f"(single-sample measured estimate, default model)" + "(mean of measured dispatches on the configured model)" ) diff --git a/tests/test_enrich_aspects.py b/tests/test_enrich_aspects.py index 0b2036407..d7e58766b 100644 --- a/tests/test_enrich_aspects.py +++ b/tests/test_enrich_aspects.py @@ -346,7 +346,7 @@ def _no_t3(): # Reports the no-API-cost branch, not the measured-estimate branch. assert "deterministic parser" in result.output assert "$0" in result.output - assert "single-sample measured estimate" not in result.output + assert "mean of measured" not in result.output def test_dry_run_knowledge_collection_still_reports_measured_cost( self, env, monkeypatch: pytest.MonkeyPatch, @@ -366,7 +366,7 @@ def _no_t3(): enrich, ["aspects", "knowledge__delos", "--dry-run"], ) assert result.exit_code == 0, result.output - assert "single-sample measured estimate" in result.output + assert "mean of measured claude-haiku-4-5-20251001 dispatches" in result.output assert f"${3 * _PER_PAPER_COST_USD:.2f}" in result.output diff --git a/tests/test_enrich_aspects_identity_key.py b/tests/test_enrich_aspects_identity_key.py index e94e8362b..d9dcfaf21 100644 --- a/tests/test_enrich_aspects_identity_key.py +++ b/tests/test_enrich_aspects_identity_key.py @@ -221,6 +221,13 @@ def test_the_extraction_verb_refuses_the_same_unknown_collection(wiring, monkeyp assert result.exit_code != 0, result.output assert "No catalog rows in 'knowledge__dt-papers'" in result.output assert "No documents to process" not in result.output + # The gap-fill path reads the aspect side, so the refusal names the + # orphans exactly as the audit does (the wiring fixture holds four). + assert "4 aspect row(s) exist under that exact name" in result.output + # --all takes no aspect-side read and still refuses. + result = CliRunner().invoke(enrich, ["aspects", "knowledge__dt-papers", "--dry-run", "--all"]) + assert result.exit_code != 0, result.output + assert "No catalog rows in 'knowledge__dt-papers'" in result.output # -------------------------------------------------------------------------- From 40f2b88b9e63eabaf012fd5a3104d633774fa43d Mon Sep 17 00:00:00 2001 From: Hellblazer Date: Mon, 7 Sep 2026 17:50:33 -0700 Subject: [PATCH 05/23] test(command-context): the devonthink-index preamble test names a local-mode collection token (nexus-i0cwh) The mode-declarations census reads a cloud embedder token as a cloud_mode claim; the fake collection list now carries the bge token, so no exclusion entry is needed. --- tests/test_command_context_command.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_command_context_command.py b/tests/test_command_context_command.py index c3f7d68e7..b050213e7 100644 --- a/tests/test_command_context_command.py +++ b/tests/test_command_context_command.py @@ -1949,7 +1949,9 @@ def test_devonthink_index_exits_zero_and_names_the_verb(tmp_path: Path, monkeypa import nexus.commands.command_context as cc monkeypatch.setattr(cc, "_dt_reachable", lambda: (True, "http")) - monkeypatch.setattr(cc, "_knowledge_collections", lambda: ["knowledge__dt-papers__voyage-context-3__v1"]) + # A local-mode collection name on purpose: the mode-declarations census + # reads a cloud embedder token as a cloud_mode claim. + monkeypatch.setattr(cc, "_knowledge_collections", lambda: ["knowledge__dt-papers__bge-768__v1"]) from nexus.cli import main result = CliRunner().invoke(main, ["command-context", "devonthink-index", "--", "6BCD2BC1-8421-4134-BA67-A5F3E658AA95"]) From 1289f3c2a2518b0fd62b6c381c88ce1a08e6629d Mon Sep 17 00:00:00 2001 From: Hellblazer Date: Mon, 7 Sep 2026 18:50:15 -0700 Subject: [PATCH 06/23] test(t3): the end-to-end gc test asserts the ruled contract: a tombstoned document's chunk survives until purge-trash and the verb names it (nexus-dkymw) tests/db/test_i711w_gap_xfails.py's integration-only gc journey still asserted the pre-ruling contract (the tombstoned doc's chunk is an orphan and gets collected); the alive-set change (545ed060d) flipped the unit-level pins but this test runs only under the local-service gate, where it went red on the 7.36.0 battery. It now asserts the chunk survives and the report line counts it as tombstone-protected. --- tests/db/test_i711w_gap_xfails.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/tests/db/test_i711w_gap_xfails.py b/tests/db/test_i711w_gap_xfails.py index 921f490ed..60d243ce7 100644 --- a/tests/db/test_i711w_gap_xfails.py +++ b/tests/db/test_i711w_gap_xfails.py @@ -342,12 +342,20 @@ def test_t3_gc_verb_collects_tombstoned_docs_chunks( ) assert "live-0" in surviving, result.output - # CORRECT CONTRACT: the tombstoned doc's chunk is an orphan and gets - # collected. (Service today: h_dead is still in the alive-set the - # verb reads, so the chunk survives — this is the assertion that - # xfails.) - assert "dead-0" not in surviving, ( - "tombstoned document's chunk must be GC'd by the verb; " + # CONTRACT OF RECORD (Sam's ruling 2026-09-07, nexus-dkymw, superseding + # nexus-mqd6t for the alive-set read): a tombstoned document's chunk is + # PROTECTED from nx t3 gc until nexus.purge_trash reclaims the row, so + # nx catalog restore can bring the document back whole inside the + # purge window. The verb must therefore leave dead-0 in place and say + # why on its report line (engine field tombstone_protected_count, + # nexus-zewg3). This assertion used to be the inverse (the chunk is an + # orphan and gets collected); that was the pre-ruling contract. + assert "dead-0" in surviving, ( + "tombstoned document's chunk must SURVIVE the verb until purge-trash " + f"reclaims the row (nexus-dkymw); gc output:\n{result.output}" + ) + assert "Protected by pending tombstones: 1 chunk" in result.output, ( + "the verb must name the tombstone-protected chunk on its report line; " f"gc output:\n{result.output}" ) From 268f33fe98cff18932e8dde968e7473f029ba8cd Mon Sep 17 00:00:00 2001 From: Hellblazer Date: Mon, 7 Sep 2026 20:26:14 -0700 Subject: [PATCH 07/23] docs(wire): zewg3 pairing shipped in v7.36.0; Unshipped is empty again --- docs/wire-contract-pending.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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. From 50f446c346160c2e8f8e542159a65dcaa4ac0693 Mon Sep 17 00:00:00 2001 From: Hellblazer Date: Mon, 7 Sep 2026 21:14:48 -0700 Subject: [PATCH 08/23] feat(git): vouched push script and foreign-tip amend guard for the shared checkout (nexus-9wxu6) Every session on the box commits as the same git user, so the outbound range origin/develop..develop cannot be attributed by author. On 2026-09-07 four pushes carried a peer's unpushed commits and one amend in the primary rewrote a peer's commit. scripts/git-push-develop.sh ...: fetch, resolve the tip once, read the range, push only when the range equals the vouched set; refuse foreign, stale, diverged or unresolvable with a PUSH_REFUSED_* line. NX_PUSH_SOURCE=HEAD pushes a detached worktree's commits and leaves the local branch alone. Policy hook (fixture of record tests/fixtures/hal_git_policy_hook.py): rule 3 denies git commit --amend in the primary checkout when HEAD is not in this session's commit record; rule 4 denies a bare git push to develop in a repo that ships the script. The record is written by the PostToolUse companion tests/fixtures/hal_record_session_commits_hook.py, which prefers the sha git commit printed over a fresh rev-parse. --- AGENTS.md | 1 + docs/contributing.md | 1 + scripts/git-push-develop.sh | 121 ++++++++ tests/fixtures/hal_git_policy_hook.py | 266 +++++++++++++++++- .../hal_record_session_commits_hook.py | 154 ++++++++++ tests/scripts/test_git_push_develop_sh.py | 182 ++++++++++++ tests/test_hal_git_policy_hook.py | 210 ++++++++++++++ 7 files changed, 933 insertions(+), 2 deletions(-) create mode 100755 scripts/git-push-develop.sh create mode 100644 tests/fixtures/hal_record_session_commits_hook.py create mode 100644 tests/scripts/test_git_push_develop_sh.py diff --git a/AGENTS.md b/AGENTS.md index 1cc9cc207..e41faf795 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). diff --git a/docs/contributing.md b/docs/contributing.md index ddebc805c..fe253a016 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. diff --git a/scripts/git-push-develop.sh b/scripts/git-push-develop.sh new file mode 100755 index 000000000..d43da64be --- /dev/null +++ b/scripts/git-push-develop.sh @@ -0,0 +1,121 @@ +#!/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. +# +# 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") +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 "If a commit is yours, pass its sha. If it is a peer's, leave it: they push their own range." + 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/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/scripts/test_git_push_develop_sh.py b/tests/scripts/test_git_push_develop_sh.py new file mode 100644 index 000000000..a5d884ed7 --- /dev/null +++ b/tests/scripts/test_git_push_develop_sh.py @@ -0,0 +1,182 @@ +# 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 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_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" From 6486399a6bfb6da4d404eda484675c7fbc4e3728 Mon Sep 17 00:00:00 2001 From: Hellblazer Date: Mon, 7 Sep 2026 21:17:24 -0700 Subject: [PATCH 09/23] fix(git): a vouched merge commit covers what it merges in, so the release and plugin-cut back-merges push through git-push-develop.sh (nexus-9wxu6) --- docs/contributing.md | 4 +- scripts/cut_plugin_release.py | 2 +- scripts/git-push-develop.sh | 14 ++++++ tests/scripts/test_git_push_develop_sh.py | 55 +++++++++++++++++++++++ 4 files changed, 72 insertions(+), 3 deletions(-) diff --git a/docs/contributing.md b/docs/contributing.md index fe253a016..66b0ff4de 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -528,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 @@ -608,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/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 index d43da64be..3574a0865 100755 --- a/scripts/git-push-develop.sh +++ b/scripts/git-push-develop.sh @@ -17,6 +17,13 @@ # 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) @@ -76,6 +83,13 @@ for arg in "$@"; do 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 diff --git a/tests/scripts/test_git_push_develop_sh.py b/tests/scripts/test_git_push_develop_sh.py index a5d884ed7..986e0934c 100644 --- a/tests/scripts/test_git_push_develop_sh.py +++ b/tests/scripts/test_git_push_develop_sh.py @@ -116,6 +116,61 @@ def test_detached_source_still_refuses_an_unvouched_commit(self, repos, tmp_path 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 From a21acca3cb9ef37dc45b4a3bfc3baa9641d34c21 Mon Sep 17 00:00:00 2001 From: Hellblazer Date: Mon, 7 Sep 2026 21:25:06 -0700 Subject: [PATCH 10/23] docs(release): step 8b and the release skill back-merge push through git-push-develop.sh; the refusal names the peer's commit as theirs (nexus-9wxu6) --- .claude/skills/release/SKILL.md | 2 +- AGENTS.md | 2 +- scripts/git-push-develop.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 2070c87e3..0c5ae73a8 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -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 e41faf795..60f077942 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -298,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/scripts/git-push-develop.sh b/scripts/git-push-develop.sh index 3574a0865..8125c5b29 100755 --- a/scripts/git-push-develop.sh +++ b/scripts/git-push-develop.sh @@ -118,7 +118,7 @@ if [[ ${#foreign[@]} -gt 0 ]]; then for sha in "${foreign[@]}"; do git log -1 --format=' %h %an %s' "$sha" done - echo "If a commit is yours, pass its sha. If it is a peer's, leave it: they push their own range." + 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 From 23cccd9aca7df85c85c41923dbc2d1732ad1de53 Mon Sep 17 00:00:00 2001 From: Hellblazer Date: Mon, 7 Sep 2026 21:41:42 -0700 Subject: [PATCH 11/23] feat(test): the suite and the native-build harnesses wait on the shared build lease or refuse once (nexus-pv93h) The lease is shared by every worktree (nexus-g6xpa), so while any Maven run holds it every substrate-backed test errored at setup: 1216 setup errors in one lint-bucket run on 2026-09-07. --shakeout's native build took the refuse-immediately acquire and exited 75 the instant a cached gate-jar copy held the lease. pytest: pytest_sessionstart on the xdist controller consults the lease once. Unset NX_BUILD_LEASE_WAIT refuses the whole session with one line and exit 75 naming the holder; NX_BUILD_LEASE_WAIT= polls the holder every 5s up to the bound, announcing once and per minute. Waiters key on the holder pid, or the recorded build process group when present, never on the lease path, which never disappears on its own. _boot()'s per-test check stays as the late-race backstop. NX_TEST_T2_SUBSTRATE=none runs are never gated. Harnesses: run.sh --shakeout and gc-ab/run-ab.sh take build_lease_acquire_wait like every other producer; a ratchet test refuses any bare build_lease_acquire outside the lease library. The lease root is resolved per call, and the freshness tests isolate themselves from the box's lease: they read the live one and failed whenever any Maven run held it. --- tests/conftest.py | 34 ++++++ tests/db/_service_fixture.py | 100 +++++++++++++++-- tests/db/test_service_jar_freshness.py | 101 ++++++++++++++++++ tests/e2e/gc-ab/run-ab.sh | 2 +- tests/e2e/migration-rehearsal/run.sh | 7 +- .../scripts/test_build_lease_callers_wait.py | 38 +++++++ tests/test_build_lease_session_gate.py | 87 +++++++++++++++ 7 files changed, 358 insertions(+), 11 deletions(-) create mode 100644 tests/scripts/test_build_lease_callers_wait.py create mode 100644 tests/test_build_lease_session_gate.py diff --git a/tests/conftest.py b/tests/conftest.py index 01759f0ac..8233cb61e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -173,6 +173,38 @@ 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. The per-test check + in ``_boot()`` stays as the backstop for a build that starts later. + + ``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: # noqa: BLE001 — the gate must never break collection on its own bug + 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 +285,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/scripts/test_build_lease_callers_wait.py b/tests/scripts/test_build_lease_callers_wait.py new file mode 100644 index 000000000..662f86aee --- /dev/null +++ b/tests/scripts/test_build_lease_callers_wait.py @@ -0,0 +1,38 @@ +# 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" +EXEMPT = {LIB, REPO_ROOT / "scripts" / "lib" / "build-lease_test.sh"} +_BARE = re.compile(r"^\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 _BARE.match(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/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 From c34ff86ec71a01ae7cca0608ccfaef35df09d19d Mon Sep 17 00:00:00 2001 From: Hellblazer Date: Mon, 7 Sep 2026 21:45:30 -0700 Subject: [PATCH 12/23] test(lint): bead-id manifest carries the day's beads; the run.sh pipefail exemption follows its line (nexus-pv93h) --- tests/fixtures/bead_ids.txt | 14 ++++++++++++++ tests/test_pipefail_early_exit_consumer_lint.py | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) 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/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. From 0e3726138c59cb47969b0ba36fc364295f3faa6e Mon Sep 17 00:00:00 2001 From: Hellblazer Date: Mon, 7 Sep 2026 21:51:57 -0700 Subject: [PATCH 13/23] test: the build-lease gate says so when it fails open; the bare-acquire ratchet matches mid-line calls (nexus-pv93h) --- tests/conftest.py | 5 ++++- tests/scripts/test_build_lease_callers_wait.py | 7 +++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 8233cb61e..059015d7a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -193,7 +193,10 @@ def _gate_on_build_lease() -> None: 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: # noqa: BLE001 — the gate must never break collection on its own bug + 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 diff --git a/tests/scripts/test_build_lease_callers_wait.py b/tests/scripts/test_build_lease_callers_wait.py index 662f86aee..0447fc09c 100644 --- a/tests/scripts/test_build_lease_callers_wait.py +++ b/tests/scripts/test_build_lease_callers_wait.py @@ -14,7 +14,8 @@ REPO_ROOT = Path(__file__).resolve().parents[2] LIB = REPO_ROOT / "scripts" / "lib" / "build-lease.sh" EXEMPT = {LIB, REPO_ROOT / "scripts" / "lib" / "build-lease_test.sh"} -_BARE = re.compile(r"^\s*build_lease_acquire\s") +#: 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(): @@ -32,7 +33,9 @@ def test_no_bare_acquire_outside_the_lease_library() -> None: continue checked += 1 for n, line in enumerate(path.read_text(errors="replace").splitlines(), 1): - if _BARE.match(line): + 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) From d5e30df87c79664f7215038b1d52f62ea60289b5 Mon Sep 17 00:00:00 2001 From: Hellblazer Date: Mon, 7 Sep 2026 21:52:45 -0700 Subject: [PATCH 14/23] test: the bare-acquire ratchet exempts mvnw-leased_test.sh, which holds the lease on purpose (nexus-pv93h) --- tests/scripts/test_build_lease_callers_wait.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/scripts/test_build_lease_callers_wait.py b/tests/scripts/test_build_lease_callers_wait.py index 0447fc09c..ce679a2a5 100644 --- a/tests/scripts/test_build_lease_callers_wait.py +++ b/tests/scripts/test_build_lease_callers_wait.py @@ -13,7 +13,12 @@ REPO_ROOT = Path(__file__).resolve().parents[2] LIB = REPO_ROOT / "scripts" / "lib" / "build-lease.sh" -EXEMPT = {LIB, REPO_ROOT / "scripts" / "lib" / "build-lease_test.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") From 5c30d810ba72d258f32e8e0c8a57b3b3f8626fe3 Mon Sep 17 00:00:00 2001 From: Hellblazer Date: Mon, 7 Sep 2026 21:55:36 -0700 Subject: [PATCH 15/23] docs(lease): pytest refuses by default and waits on NX_BUILD_LEASE_WAIT; the _boot backstop is per worker boot (nexus-pv93h) --- AGENTS.md | 2 +- conexus/skills/orchestration/SKILL.md | 2 +- tests/conftest.py | 7 +++++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 60f077942..71eb37745 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,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. 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/tests/conftest.py b/tests/conftest.py index 059015d7a..827a160b1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -183,8 +183,11 @@ def _gate_on_build_lease() -> None: (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. The per-test check - in ``_boot()`` stays as the backstop for a build that starts later. + 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. """ From a71c93e92074ab2e05c04b672467bd6fb63d7dfd Mon Sep 17 00:00:00 2001 From: Hellblazer Date: Mon, 7 Sep 2026 22:09:32 -0700 Subject: [PATCH 16/23] fix: four groom beads, small and evidence-backed (nexus-fv65m, nexus-fjwk7, nexus-i24r4, nexus-owna8) fv65m: the close hook tokenizes the whole command quote-aware before it splits on operators, so a ';' or the word 'do' inside a quoted --reason no longer breaks the quotes and harvests prose ids (a session name, a peer's bead) as close targets; measured twice on 2026-09-07. fjwk7: the session-end launcher runs nexus.garbage's sweep as its own failure-isolated step, so t1_mint_.lock litter older than a day with no live lease is reaped on every session close instead of only by nx doctor --fix. i24r4: a run that is not a managed install no longer writes the last_seen_version stamp; a dev checkout on a release branch used to record its version and the next installed-tool invocation reported an upgrade that never happened. The finish pass was already dev-guarded. owna8: the SessionStart rdr hook asks the T3 client whether the resolved collection exists instead of substring-matching nx collection list, and a catalog resolution failure is logged rather than swallowed, which is what forced the path-derived fallback whose owner id differs from the catalog's and produced the false NOT indexed verdict. --- conexus/PENDING_RELEASE.md | 3 +- .../scripts/pre_close_verification_hook.sh | 37 ++++++++++++++---- conexus/hooks/scripts/rdr_hook.py | 35 +++++++++++++++-- src/nexus/_session_end_launcher.py | 23 +++++++++++ src/nexus/upgrade_finish.py | 12 +++--- .../hooks/test_pre_close_verification_hook.py | 26 +++++++++++++ tests/hooks/test_rdr_hook.py | 32 ++++++++++++++++ tests/test_session_end_garbage_sweep.py | 38 +++++++++++++++++++ tests/test_upgrade_finish.py | 31 +++++++++++++-- 9 files changed, 216 insertions(+), 21 deletions(-) create mode 100644 tests/test_session_end_garbage_sweep.py diff --git a/conexus/PENDING_RELEASE.md b/conexus/PENDING_RELEASE.md index 622817ebf..c3c80a2e0 100644 --- a/conexus/PENDING_RELEASE.md +++ b/conexus/PENDING_RELEASE.md @@ -31,4 +31,5 @@ mechanize, it matters enough to ship. ## Awaiting the next release or plugin cut (pinned: v7.36.0) -(none) +- `conexus/hooks/scripts/pre_close_verification_hook.sh` — nexus-fv65m: the command is tokenized quote-aware before it is split on operators, so a ';' or 'do' inside a quoted --reason no longer harvests prose ids as close targets. +- `conexus/hooks/scripts/rdr_hook.py` — nexus-owna8: collection existence is asked of the T3 client, not a substring of `nx collection list`; a resolution failure is logged instead of swallowed. diff --git a/conexus/hooks/scripts/pre_close_verification_hook.sh b/conexus/hooks/scripts/pre_close_verification_hook.sh index ef8df59f3..174f277d0 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,34 @@ 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)) + +for tokens, raw in tokenized: + if tokens is None: + _scan_text(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..a59724e17 100755 --- a/conexus/hooks/scripts/rdr_hook.py +++ b/conexus/hooks/scripts/rdr_hook.py @@ -87,17 +87,44 @@ 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 + + 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).""" + try: + from nexus.db import make_t3 # noqa: PLC0415 + + return bool(make_t3().collection_exists(target)) + 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"], @@ -105,7 +132,7 @@ def _collection_exists(target: str) -> bool: ) if result.returncode == 0: return target in result.stdout - except Exception: + except Exception: # noqa: BLE001 — best-effort fallback pass return False 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/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/hooks/test_pre_close_verification_hook.py b/tests/hooks/test_pre_close_verification_hook.py index 93396ce13..42369d934 100644 --- a/tests/hooks/test_pre_close_verification_hook.py +++ b/tests/hooks/test_pre_close_verification_hook.py @@ -1179,6 +1179,32 @@ 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_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..b1a28aef3 100644 --- a/tests/hooks/test_rdr_hook.py +++ b/tests/hooks/test_rdr_hook.py @@ -238,3 +238,35 @@ 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 diff --git a/tests/test_session_end_garbage_sweep.py b/tests/test_session_end_garbage_sweep.py new file mode 100644 index 000000000..04830e65e --- /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.setattr("nexus.config.nexus_config_dir", lambda: 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(): + raise RuntimeError("no config dir") + monkeypatch.setattr("nexus.config.nexus_config_dir", boom) + launcher._sweep_local_garbage() # must not raise diff --git a/tests/test_upgrade_finish.py b/tests/test_upgrade_finish.py index 93bb82d83..1bdb75c63 100644 --- a/tests/test_upgrade_finish.py +++ b/tests/test_upgrade_finish.py @@ -365,6 +365,8 @@ def test_first_run_stamps_quietly(self, tmp_path): with patch( "nexus.upgrade_finish.install_mtime_and_version", return_value=(0.0, "6.7.1"), + ), patch( + "nexus.upgrade_finish.running_from_tool_install", return_value=True, ): assert check_version_transition(tmp_path) is None assert (tmp_path / "last_seen_version").read_text().strip() == "6.7.1" @@ -487,6 +489,8 @@ def test_first_ever_run_also_calls_the_backfill(self, tmp_path): with patch( "nexus.upgrade_finish.install_mtime_and_version", return_value=(0.0, "6.7.1"), + ), patch( + "nexus.upgrade_finish.running_from_tool_install", return_value=True, ), patch( "nexus.config.backfill_install_mode_record", ) as backfill: @@ -956,8 +960,12 @@ def test_process_lookup_error_still_means_dead(self) -> None: class TestCrossVenvGuard: def test_dev_venv_never_runs_the_finish_pass(self, tmp_path): """Critique 38b7db3d C2: a dev checkout's venv mtime says nothing - about production processes — the transition consumes the stamp but - the restart pass never runs from a non-tool interpreter.""" + about production processes, so the restart pass never runs from a + non-tool interpreter. nexus-i24r4 tightened the other half: a dev + venv no longer consumes the stamp either, because a stamp written + with the checkout's version made the next managed-install run + report 'upgraded 7.34.0 -> 7.33.0' for an upgrade that never + happened. The stamp belongs to the managed install alone.""" (tmp_path / "last_seen_version").write_text("6.7.0\n") with patch( "nexus.upgrade_finish.install_mtime_and_version", @@ -970,7 +978,7 @@ def test_dev_venv_never_runs_the_finish_pass(self, tmp_path): ) as detect: assert check_version_transition(tmp_path) is None detect.assert_not_called() - assert (tmp_path / "last_seen_version").read_text().strip() == "6.7.1" + assert (tmp_path / "last_seen_version").read_text().strip() == "6.7.0" class TestDetectEngineConvergence: @@ -3401,3 +3409,20 @@ def test_the_action_line_does_not_claim_a_restart_it_did_not_do( assert "restarted" not in joined.lower(), ( "claimed a restart while starting nothing" ) + + +class TestDevCheckoutNeverStamps: + """nexus-i24r4: a run that is not a managed install must not touch the + version stamp. A dev-checkout `uv run nx` on a release branch used to + write ITS version, and the next installed-tool invocation then reported + 'upgraded 7.34.0 -> 7.33.0' for an upgrade that never happened.""" + + def test_dev_checkout_leaves_stamp_untouched(self, tmp_path, monkeypatch): + from nexus import upgrade_finish as uf + + stamp = tmp_path / uf.STAMP_FILENAME + stamp.write_text("7.33.0\n") + monkeypatch.setattr(uf, "install_mtime_and_version", lambda: (0.0, "7.34.0")) + monkeypatch.setattr(uf, "running_from_tool_install", lambda: False) + assert uf.check_version_transition(tmp_path, preview=False) is None + assert stamp.read_text().strip() == "7.33.0" From 9a1d504708d63f36955e6568a118bb5066664bf0 Mon Sep 17 00:00:00 2001 From: Hellblazer Date: Mon, 7 Sep 2026 22:14:32 -0700 Subject: [PATCH 17/23] test: the session-end sweep test sets NEXUS_CONFIG_DIR; by-value import ratchet to 78 (nexus-fjwk7) --- tests/test_nexus_config_dir_setattr_lint.py | 2 +- tests/test_session_end_garbage_sweep.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) 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_session_end_garbage_sweep.py b/tests/test_session_end_garbage_sweep.py index 04830e65e..8daa7000e 100644 --- a/tests/test_session_end_garbage_sweep.py +++ b/tests/test_session_end_garbage_sweep.py @@ -25,14 +25,14 @@ def test_session_end_sweeps_stale_mint_locks(tmp_path: Path, monkeypatch) -> Non os.utime(stale, (old, old)) fresh = cfg / "t1_mint_cafebabe.lock" fresh.touch() - monkeypatch.setattr("nexus.config.nexus_config_dir", lambda: cfg) + 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(): - raise RuntimeError("no config dir") - monkeypatch.setattr("nexus.config.nexus_config_dir", boom) + def boom(*a, **k): + raise RuntimeError("store unavailable") + monkeypatch.setattr("nexus.garbage.sweep_local_garbage", boom) launcher._sweep_local_garbage() # must not raise From 4c092e40a810202ec2fb2925ac8e49ea04932337 Mon Sep 17 00:00:00 2001 From: Hellblazer Date: Mon, 7 Sep 2026 22:19:01 -0700 Subject: [PATCH 18/23] fix: plugin examples name a subject collection, and service error bodies are redacted before logging (nexus-fjc8v, nexus-8ooxn, nexus-hcy4w) fjc8v: 63 store_put / store_list examples across 26 skill, agent and command files named collection="knowledge", and t3_collection_name promoted that bare placeholder to knowledge__knowledge (1464 chunks live on one tenant). The examples now read collection="", and using-nx-skills carries the rule: a durable subject area a reader would browse, reused before minted, per docs/collections.md. 8ooxn / hcy4w: the vector-service reachability check logged the raw exception text, and a 401 body echoes the rejected credential; under xdist the worker's console renderer put that structlog line into the CliRunner output, so test_doctor_single_db_no_secret_leak failed twice in full parallel runs and passed alone. redact_credentials replaces the value after api_key/token/bearer/password/secret/authorization before any of the three log calls. --- conexus/PENDING_RELEASE.md | 27 ++++++++++++++++++++ conexus/agents/_shared/CONTEXT_PROTOCOL.md | 4 +-- conexus/agents/architect-planner.md | 12 ++++----- conexus/agents/code-review-expert.md | 4 +-- conexus/agents/codebase-deep-analyzer.md | 10 ++++---- conexus/agents/debugger.md | 10 ++++---- conexus/agents/deep-analyst.md | 6 ++--- conexus/agents/deep-research-synthesizer.md | 14 +++++----- conexus/agents/developer.md | 10 ++++---- conexus/agents/strategic-planner.md | 4 +-- conexus/agents/substantive-critic.md | 4 +-- conexus/agents/test-validator.md | 4 +-- conexus/commands/knowledge-tidy.md | 4 +-- conexus/skills/architecture/SKILL.md | 4 +-- conexus/skills/catalog/SKILL.md | 2 +- conexus/skills/code-review/SKILL.md | 2 +- conexus/skills/codebase-analysis/SKILL.md | 4 +-- conexus/skills/debugging/SKILL.md | 2 +- conexus/skills/deep-analysis/SKILL.md | 2 +- conexus/skills/nexus/SKILL.md | 4 +-- conexus/skills/nexus/reference.md | 6 ++--- conexus/skills/rdr-gate/SKILL.md | 4 +-- conexus/skills/rdr-research/SKILL.md | 2 +- conexus/skills/research-synthesis/SKILL.md | 2 +- conexus/skills/strategic-planning/SKILL.md | 2 +- conexus/skills/substantive-critique/SKILL.md | 2 +- conexus/skills/using-nx-skills/SKILL.md | 1 + conexus/skills/writing-nx-skills/SKILL.md | 2 +- src/nexus/health.py | 23 ++++++++++++++--- tests/test_health_redact_credentials.py | 20 +++++++++++++++ 30 files changed, 131 insertions(+), 66 deletions(-) create mode 100644 tests/test_health_redact_credentials.py diff --git a/conexus/PENDING_RELEASE.md b/conexus/PENDING_RELEASE.md index c3c80a2e0..cfc93ac7f 100644 --- a/conexus/PENDING_RELEASE.md +++ b/conexus/PENDING_RELEASE.md @@ -33,3 +33,30 @@ mechanize, it matters enough to ship. - `conexus/hooks/scripts/pre_close_verification_hook.sh` — nexus-fv65m: the command is tokenized quote-aware before it is split on operators, so a ';' or 'do' inside a quoted --reason no longer harvests prose ids as close targets. - `conexus/hooks/scripts/rdr_hook.py` — nexus-owna8: collection existence is asked of the T3 client, not a substring of `nx collection list`; a resolution failure is logged instead of swallowed. +- `conexus/agents/_shared/CONTEXT_PROTOCOL.md` — nexus-fjc8v: examples name `collection=""` instead of the placeholder `knowledge` (a subject area per docs/collections.md; the bare name minted knowledge__knowledge, 1464 chunks live). +- `conexus/agents/architect-planner.md` — nexus-fjc8v: examples name `collection=""` instead of the placeholder `knowledge` (a subject area per docs/collections.md; the bare name minted knowledge__knowledge, 1464 chunks live). +- `conexus/agents/code-review-expert.md` — nexus-fjc8v: examples name `collection=""` instead of the placeholder `knowledge` (a subject area per docs/collections.md; the bare name minted knowledge__knowledge, 1464 chunks live). +- `conexus/agents/codebase-deep-analyzer.md` — nexus-fjc8v: examples name `collection=""` instead of the placeholder `knowledge` (a subject area per docs/collections.md; the bare name minted knowledge__knowledge, 1464 chunks live). +- `conexus/agents/debugger.md` — nexus-fjc8v: examples name `collection=""` instead of the placeholder `knowledge` (a subject area per docs/collections.md; the bare name minted knowledge__knowledge, 1464 chunks live). +- `conexus/agents/deep-analyst.md` — nexus-fjc8v: examples name `collection=""` instead of the placeholder `knowledge` (a subject area per docs/collections.md; the bare name minted knowledge__knowledge, 1464 chunks live). +- `conexus/agents/deep-research-synthesizer.md` — nexus-fjc8v: examples name `collection=""` instead of the placeholder `knowledge` (a subject area per docs/collections.md; the bare name minted knowledge__knowledge, 1464 chunks live). +- `conexus/agents/developer.md` — nexus-fjc8v: examples name `collection=""` instead of the placeholder `knowledge` (a subject area per docs/collections.md; the bare name minted knowledge__knowledge, 1464 chunks live). +- `conexus/agents/strategic-planner.md` — nexus-fjc8v: examples name `collection=""` instead of the placeholder `knowledge` (a subject area per docs/collections.md; the bare name minted knowledge__knowledge, 1464 chunks live). +- `conexus/agents/substantive-critic.md` — nexus-fjc8v: examples name `collection=""` instead of the placeholder `knowledge` (a subject area per docs/collections.md; the bare name minted knowledge__knowledge, 1464 chunks live). +- `conexus/agents/test-validator.md` — nexus-fjc8v: examples name `collection=""` instead of the placeholder `knowledge` (a subject area per docs/collections.md; the bare name minted knowledge__knowledge, 1464 chunks live). +- `conexus/commands/knowledge-tidy.md` — nexus-fjc8v: examples name `collection=""` instead of the placeholder `knowledge` (a subject area per docs/collections.md; the bare name minted knowledge__knowledge, 1464 chunks live). +- `conexus/skills/architecture/SKILL.md` — nexus-fjc8v: examples name `collection=""` instead of the placeholder `knowledge` (a subject area per docs/collections.md; the bare name minted knowledge__knowledge, 1464 chunks live). +- `conexus/skills/catalog/SKILL.md` — nexus-fjc8v: examples name `collection=""` instead of the placeholder `knowledge` (a subject area per docs/collections.md; the bare name minted knowledge__knowledge, 1464 chunks live). +- `conexus/skills/code-review/SKILL.md` — nexus-fjc8v: examples name `collection=""` instead of the placeholder `knowledge` (a subject area per docs/collections.md; the bare name minted knowledge__knowledge, 1464 chunks live). +- `conexus/skills/codebase-analysis/SKILL.md` — nexus-fjc8v: examples name `collection=""` instead of the placeholder `knowledge` (a subject area per docs/collections.md; the bare name minted knowledge__knowledge, 1464 chunks live). +- `conexus/skills/debugging/SKILL.md` — nexus-fjc8v: examples name `collection=""` instead of the placeholder `knowledge` (a subject area per docs/collections.md; the bare name minted knowledge__knowledge, 1464 chunks live). +- `conexus/skills/deep-analysis/SKILL.md` — nexus-fjc8v: examples name `collection=""` instead of the placeholder `knowledge` (a subject area per docs/collections.md; the bare name minted knowledge__knowledge, 1464 chunks live). +- `conexus/skills/nexus/SKILL.md` — nexus-fjc8v: examples name `collection=""` instead of the placeholder `knowledge` (a subject area per docs/collections.md; the bare name minted knowledge__knowledge, 1464 chunks live). +- `conexus/skills/nexus/reference.md` — nexus-fjc8v: examples name `collection=""` instead of the placeholder `knowledge` (a subject area per docs/collections.md; the bare name minted knowledge__knowledge, 1464 chunks live). +- `conexus/skills/rdr-gate/SKILL.md` — nexus-fjc8v: examples name `collection=""` instead of the placeholder `knowledge` (a subject area per docs/collections.md; the bare name minted knowledge__knowledge, 1464 chunks live). +- `conexus/skills/rdr-research/SKILL.md` — nexus-fjc8v: examples name `collection=""` instead of the placeholder `knowledge` (a subject area per docs/collections.md; the bare name minted knowledge__knowledge, 1464 chunks live). +- `conexus/skills/research-synthesis/SKILL.md` — nexus-fjc8v: examples name `collection=""` instead of the placeholder `knowledge` (a subject area per docs/collections.md; the bare name minted knowledge__knowledge, 1464 chunks live). +- `conexus/skills/strategic-planning/SKILL.md` — nexus-fjc8v: examples name `collection=""` instead of the placeholder `knowledge` (a subject area per docs/collections.md; the bare name minted knowledge__knowledge, 1464 chunks live). +- `conexus/skills/substantive-critique/SKILL.md` — nexus-fjc8v: examples name `collection=""` instead of the placeholder `knowledge` (a subject area per docs/collections.md; the bare name minted knowledge__knowledge, 1464 chunks live). +- `conexus/skills/writing-nx-skills/SKILL.md` — nexus-fjc8v: examples name `collection=""` instead of the placeholder `knowledge` (a subject area per docs/collections.md; the bare name minted knowledge__knowledge, 1464 chunks live). +- `conexus/skills/using-nx-skills/SKILL.md` — nexus-fjc8v: Common Mistakes row on placeholder collections. 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/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/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/src/nexus/health.py b/src/nexus/health.py index 11e62754c..0ee603d8e 100644 --- a/src/nexus/health.py +++ b/src/nexus/health.py @@ -23,6 +23,19 @@ _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). +_CREDENTIAL_RE = re.compile( + r"(?i)\b(api[_-]?key|token|bearer|password|secret|authorization)\b(\s*[:=]?\s*)(\S+)" +) + + +def redact_credentials(text: str) -> str: + """*text* with the value after any credential key word replaced by + ``[redacted]``. Applied to error bodies before they are logged.""" + return _CREDENTIAL_RE.sub(lambda m: f"{m.group(1)}{m.group(2)}[redacted]", text) + _CHECK = "✓" _WARN = "✗" # RDR-129 B4 (nexus-uq8a4): a third, soft state — the check could not complete @@ -1093,8 +1106,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 +1130,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 +1141,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/tests/test_health_redact_credentials.py b/tests/test_health_redact_credentials.py new file mode 100644 index 000000000..21a97e303 --- /dev/null +++ b/tests/test_health_redact_credentials.py @@ -0,0 +1,20 @@ +# 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 From 427a66e29e92dac72e198f6f04983e37dbaa3527 Mon Sep 17 00:00:00 2001 From: Hellblazer Date: Mon, 7 Sep 2026 22:36:34 -0700 Subject: [PATCH 19/23] fix: groom review findings for the seven bead fixes (nexus-i24r4, nexus-owna8, nexus-fv65m, nexus-8ooxn, nexus-hcy4w, nexus-fjc8v) Reviewer and critic findings on a71c93e92, 9a1d50470 and 4c092e40a: - nexus-i24r4: test_cli_banner_era_survives_real_transition_order relied on a dev checkout writing the stamp, which the fix ended. The test now runs the trigger as a tool install with every leg after the stamp write stubbed; test_preview_never_calls_the_backfill gets the same patch its siblings had, so the guard no longer exits before the logic it pins. - nexus-owna8: the SessionStart summary reports the indexer's own recursive document count beside the RDR count (298 documents, 215 RDRs), so the two numbers reconcile with the collection; the T3 existence call runs under a 4s deadline inside the hook's 10s cap, with the listing as fallback. - nexus-fv65m: on malformed quoting the raw-scan fallback blanks the --reason/--description/--notes/-m value first, so an unbalanced quote cannot harvest prose ids as close targets. - nexus-8ooxn, nexus-hcy4w: redaction moves to nexus.redact and is applied where the service error body enters VectorServiceError's message, so nx search, nx doc and the doctor all see the redacted form; the regex leaves prose alone (the managed-service remedy's "invalid token\nthe ..." kept its next word). - nexus-fjc8v: the one remaining collection="knowledge" example in conexus/README.md. --- conexus/PENDING_RELEASE.md | 4 +- conexus/README.md | 2 +- .../scripts/pre_close_verification_hook.sh | 9 +++- conexus/hooks/scripts/rdr_hook.py | 42 ++++++++++++--- src/nexus/db/http_vector_client.py | 9 ++-- src/nexus/health.py | 11 +--- src/nexus/redact.py | 40 ++++++++++++++ .../hooks/test_pre_close_verification_hook.py | 21 ++++++++ tests/hooks/test_rdr_hook.py | 52 ++++++++++++++++++- tests/test_health_redact_credentials.py | 41 +++++++++++++++ tests/test_stranded_install.py | 16 ++++-- tests/test_upgrade_finish.py | 2 + 12 files changed, 220 insertions(+), 29 deletions(-) create mode 100644 src/nexus/redact.py diff --git a/conexus/PENDING_RELEASE.md b/conexus/PENDING_RELEASE.md index cfc93ac7f..6034fc1b0 100644 --- a/conexus/PENDING_RELEASE.md +++ b/conexus/PENDING_RELEASE.md @@ -31,8 +31,8 @@ mechanize, it matters enough to ship. ## Awaiting the next release or plugin cut (pinned: v7.36.0) -- `conexus/hooks/scripts/pre_close_verification_hook.sh` — nexus-fv65m: the command is tokenized quote-aware before it is split on operators, so a ';' or 'do' inside a quoted --reason no longer harvests prose ids as close targets. -- `conexus/hooks/scripts/rdr_hook.py` — nexus-owna8: collection existence is asked of the T3 client, not a substring of `nx collection list`; a resolution failure is logged instead of swallowed. +- `conexus/hooks/scripts/pre_close_verification_hook.sh` — nexus-fv65m: the command is tokenized quote-aware before it is split on operators, so a ';' or 'do' inside a quoted --reason no longer harvests prose ids as close targets; with an unbalanced quote the flag value is blanked before the raw-scan fallback. +- `conexus/hooks/scripts/rdr_hook.py` — nexus-owna8: collection existence is asked of the T3 client, not a substring of `nx collection list`; a resolution failure is logged instead of swallowed; the T3 call has a 4s deadline inside the hook's 10s cap; the summary counts every document the indexer walks (recursive) beside the RDR count. - `conexus/agents/_shared/CONTEXT_PROTOCOL.md` — nexus-fjc8v: examples name `collection=""` instead of the placeholder `knowledge` (a subject area per docs/collections.md; the bare name minted knowledge__knowledge, 1464 chunks live). - `conexus/agents/architect-planner.md` — nexus-fjc8v: examples name `collection=""` instead of the placeholder `knowledge` (a subject area per docs/collections.md; the bare name minted knowledge__knowledge, 1464 chunks live). - `conexus/agents/code-review-expert.md` — nexus-fjc8v: examples name `collection=""` instead of the placeholder `knowledge` (a subject area per docs/collections.md; the bare name minted knowledge__knowledge, 1464 chunks live). 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/hooks/scripts/pre_close_verification_hook.sh b/conexus/hooks/scripts/pre_close_verification_hook.sh index 174f277d0..391f3ab87 100755 --- a/conexus/hooks/scripts/pre_close_verification_hook.sh +++ b/conexus/hooks/scripts/pre_close_verification_hook.sh @@ -390,9 +390,16 @@ except ValueError: 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(raw) + _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 a59724e17..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 @@ -114,21 +116,37 @@ def _log_resolution_error(source: str, exc: BaseException) -> None: 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).""" + 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 - return bool(make_t3().collection_exists(target)) + 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 @@ -274,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: @@ -301,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/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 0ee603d8e..1caf87083 100644 --- a/src/nexus/health.py +++ b/src/nexus/health.py @@ -17,6 +17,7 @@ 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 @@ -26,16 +27,6 @@ #: 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). -_CREDENTIAL_RE = re.compile( - r"(?i)\b(api[_-]?key|token|bearer|password|secret|authorization)\b(\s*[:=]?\s*)(\S+)" -) - - -def redact_credentials(text: str) -> str: - """*text* with the value after any credential key word replaced by - ``[redacted]``. Applied to error bodies before they are logged.""" - return _CREDENTIAL_RE.sub(lambda m: f"{m.group(1)}{m.group(2)}[redacted]", text) - _CHECK = "✓" _WARN = "✗" # RDR-129 B4 (nexus-uq8a4): a third, soft state — the check could not complete 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/tests/hooks/test_pre_close_verification_hook.py b/tests/hooks/test_pre_close_verification_hook.py index 42369d934..3d71ca83b 100644 --- a/tests/hooks/test_pre_close_verification_hook.py +++ b/tests/hooks/test_pre_close_verification_hook.py @@ -1179,6 +1179,27 @@ 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: diff --git a/tests/hooks/test_rdr_hook.py b/tests/hooks/test_rdr_hook.py index b1a28aef3..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. @@ -270,3 +275,46 @@ def boom(): 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/test_health_redact_credentials.py b/tests/test_health_redact_credentials.py index 21a97e303..f094847e8 100644 --- a/tests/test_health_redact_credentials.py +++ b/tests/test_health_redact_credentials.py @@ -18,3 +18,44 @@ def test_api_key_value_is_redacted() -> None: 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_stranded_install.py b/tests/test_stranded_install.py index caedad918..adf7f982a 100644 --- a/tests/test_stranded_install.py +++ b/tests/test_stranded_install.py @@ -517,9 +517,10 @@ def test_cli_banner_era_survives_real_transition_order( direct hop reports the TRUE pre-PG era even though the trigger clobbers the stamp in the same invocation; the second invocation (stamp now = running version) degrades to the fallback clause and - never claims the running version as the era. Safe to run for real: - in a dev checkout check_version_transition stamps and returns - before any restart logic (running_from_tool_install() is False).""" + never claims the running version as the era. nexus-i24r4: a dev + checkout no longer stamps at all (only a managed install owns the + stamp), so the trigger is run as a tool install with every leg after + the stamp write stubbed to a no-op; the stamp write itself is real.""" config = tmp_path / "cfg" config.mkdir() monkeypatch.setenv("NEXUS_CONFIG_DIR", str(config)) @@ -528,6 +529,15 @@ def test_cli_banner_era_survives_real_transition_order( (config / "t2.db").write_bytes(b"x") (config / STAMP_FILENAME).write_text("5.2.0\n") monkeypatch.setattr(stranded_install, "LAST_MIGRATION_CAPABLE", _PIN) + monkeypatch.setattr(upgrade_finish, "running_from_tool_install", lambda: True) + monkeypatch.setattr(upgrade_finish, "detect_stale_processes", lambda: None) + monkeypatch.setattr(upgrade_finish, "restart_stale", lambda report, dry_run=False: []) + for leg in ( + "converge_engine", "converge_service_autostart_unit", "heal_diag_view", + "unload_stale_t2_launchagent", "unload_stale_service_launchagent", + ): + monkeypatch.setattr(upgrade_finish, leg, lambda *a, **k: []) + monkeypatch.setattr(upgrade_finish, "pending_data_rung_callout", lambda: []) runner = CliRunner() first = runner.invoke(main, ["doctor", "--help"], obj={}) diff --git a/tests/test_upgrade_finish.py b/tests/test_upgrade_finish.py index 1bdb75c63..994e1896f 100644 --- a/tests/test_upgrade_finish.py +++ b/tests/test_upgrade_finish.py @@ -503,6 +503,8 @@ def test_preview_never_calls_the_backfill(self, tmp_path): with patch( "nexus.upgrade_finish.install_mtime_and_version", return_value=(0.0, "6.7.1"), + ), patch( + "nexus.upgrade_finish.running_from_tool_install", return_value=True, ), patch( "nexus.config.backfill_install_mode_record", ) as backfill: From d256ae57711d4f6a0381e93152d40a3c1750e4e3 Mon Sep 17 00:00:00 2001 From: Hellblazer Date: Mon, 7 Sep 2026 22:50:38 -0700 Subject: [PATCH 20/23] test(release): derive the engine-floor pin from the CHANGELOG pairing; stale-dim count pins tally code lines only (nexus-9gggv) Two pins moved by hand on every cut. The floor assertion in test_engine_version.py hardcoded REQUIRED_ENGINE_VERSION and was edited in every release commit (missed once, on 7.23.0). It now equals the first engine-service-vX.Y.Z the newest released CHANGELOG.md section names, which every release since 7.26.0 records anyway; [Unreleased] is skipped so an engine cut ahead of the client never moves the floor, and a section with no pairing fails loud. The comment trail stays as provenance. The stale-dim-table count pins moved whenever a comment named a retired table (three fix-forward commits on 2026-09-07). The pinned tally now skips comment lines; a pin of 0 means the file's mentions are all prose and any code hit is a violation. 56 of 70 pins regenerated from the live code-only counts. The unlisted-file guard and the frozen-file non-vacuity keep the raw scan. --- .claude/skills/release/SKILL.md | 2 +- tests/test_engine_version.py | 50 +++++- tests/test_stale_dim_table_reference_lint.py | 163 +++++++++++-------- 3 files changed, 142 insertions(+), 73 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 0c5ae73a8..7033700cc 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 (it did on every release since 7.26.0), 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. diff --git a/tests/test_engine_version.py b/tests/test_engine_version.py index a67396ac5..144f6048f 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()) class TestParseEngineVersion: diff --git a/tests/test_stale_dim_table_reference_lint.py b/tests/test_stale_dim_table_reference_lint.py index a443f8836..f7d901b7e 100644 --- a/tests/test_stale_dim_table_reference_lint.py +++ b/tests/test_stale_dim_table_reference_lint.py @@ -80,7 +80,9 @@ The fix: every mixed-use file gets a COUNT PIN, not a blanket exemption — ``_COUNT_PINNED_FILE_ALLOWLIST: dict[str, tuple[int, str]]`` records the -CURRENT live hit count alongside the reason. ``test_count_pinned_allowlist_ +CURRENT live hit count ON CODE LINES alongside the reason (comment lines are +not tallied, nexus-9gggv: a pin of 0 means the file's mentions are all +prose and any code hit is a violation). ``test_count_pinned_allowlist_ matches_live_hit_counts`` asserts the live count still equals the pin, bidirectionally: @@ -226,7 +228,7 @@ # ── src/nexus: straddle-era functional code (still must recognize/handle # the legacy per-dim tables for a pre-unify install mid-upgrade) ──── "src/nexus/health.py": ( - 4, + 0, "Straddle-era health checks: doctor-path probes that must still " "recognize a pre-unify install's chunks_384/768/1024 tables while " "the unified-vs-legacy era is ambiguous. Named explicitly in the " @@ -237,7 +239,7 @@ # retired with it), so the entry retires too rather than pointing at a # nonexistent path. "src/nexus/db/chash_tables.py": ( - 17, + 12, "Legacy-era chash-bearing-table emitters (CHASH_BEARING_TABLES, " "legacy_chash_conformance_statements): their entire job is naming " "the OLD per-dim tables for straddle-window diagnostics and the " @@ -287,7 +289,7 @@ # were fixed to name nexus.chunks; live count is now 0, entry removed # for the same reason. "src/nexus/db/t2/http_taxonomy_store.py": ( - 1, + 0, "Comment narrating which legacy centroid table a bge-768/voyage-1024 " "collection used to land in; historical only." ), @@ -306,14 +308,14 @@ # ── service/src/main/java: straddle-era functional (constraint-name # mapping used to validate a pre-unify install's own constraints) ── "service/src/main/java/dev/nexus/service/db/SchemaMigrator.java": ( - 4, + 3, "CHASH_LEN_CONSTRAINTS maps each legacy per-dim table's own " "chash-length constraint name for straddle-era VALIDATE CONSTRAINT " "handling on a pre-unify install; functional, not stale." ), # ── service/src/main/java: pure historical javadoc/comment ────────── "service/src/main/java/dev/nexus/service/vectors/TaxonomyCentroidRepository.java": ( - 1, + 0, "Javadoc narrating the pre-unify 'three per-dim tables' shape " "(line ~30); historical only. (Separately, this same file's " "dimensionProbe javadoc had a stale 'count() over-counts' claim " @@ -321,7 +323,7 @@ "does not move this pin.)" ), "service/src/main/java/dev/nexus/service/vectors/PgVectorRepository.java": ( - 11, + 0, "12 -> 11 at nexus-zrcj7 (2026-09-03): retiring the raw search SQL " "onto schema functions deleted one historical-prose hit with the " "code it annotated; still zero executable per-dim SQL. Earlier: " @@ -335,7 +337,7 @@ "deliberately phrased without a bare banned token." ), "service/src/main/java/dev/nexus/service/db/ChashSqlIdioms.java": ( - 3, + 0, "Javadoc narrating the RDR-191 unification (three occurrences, all " "'{@code nexus.chunks_384/768/1024} collapsed into...'); historical " "only." @@ -344,21 +346,21 @@ # — the whole class was deleted at nexus-lgdel.l1 (the chash-rekey rung # it implemented server-side retired along with the client-side rung). "service/src/main/java/dev/nexus/service/db/ChashCensus.java": ( - 2, + 0, "Comments narrating the RDR-191 unification of the three per-dim " "chash-bearing tables; historical only." ), "service/src/main/java/dev/nexus/service/db/TenantScope.java": ( - 1, + 0, "Javadoc narrating the RDR-191 unification; historical only." ), "service/src/main/java/dev/nexus/service/db/ChashRepository.java": ( - 2, + 0, "Javadoc narrating that the (pre-unify) chunks_384/768/1024 tables " "ARE the chash-keyed store; historical only." ), "service/src/main/java/dev/nexus/service/db/CatalogRepository.java": ( - 23, + 0, "Javadoc/comments: an incident postmortem citing 'chunks_1024 alone " "took 195s to VACUUM' (a historical performance number, not a live " "reference) plus RDR-191 unification narration; historical only, " @@ -367,7 +369,7 @@ "// comment)." ), "service/src/main/java/dev/nexus/service/db/StagingPromoteOps.java": ( - 1, + 0, "Comment narrating the pre-unify per-dim dispatch shape " "('(chunks_384|768|1024)'); historical only. (nexus-lgdel.l1: the " "'hardcoded to chunks_768/dim=768' narration this pin also used to " @@ -388,7 +390,7 @@ "fallback; mirrors that file's own allowlist reason." ), "tests/test_health_service_checks.py": ( - 13, + 9, "Fixtures for health.py's straddle-era legacy-leg probes and the " "chash_conformance_report wire-compat table_name labels " "('nexus.chunks_384' as a counts-view filter value); mirrors " @@ -416,7 +418,7 @@ # — deleted at nexus-lgdel.l1 alongside chash_rekey.py itself (its # SUBJECT was that rung's verify() correctness, the deleted capability). "tests/e2e/migration-rehearsal/seed_legacy.py": ( - 2, + 1, "A LEGACY store-state seeding script by name and purpose: seeds a " "pre-unify per-dim database for upgrade-ladder rehearsal, so it " "must dispatch rows to chunks_384/768/1024 by construction. 3->2 at " @@ -437,7 +439,7 @@ "worked example table/constraint name; not real changelog content." ), "tests/test_changelog_vectors005_nine_body_drift_lint.py": ( - 2, + 0, "Comment citing 'chunks_384' as one example shape the drift lint's " "own dim-token regex must normalize across (alongside " "'embedding_384', 'vector(384)'); explanatory, not a live reference." @@ -458,7 +460,7 @@ ), # ── tests: pure historical comment, no functional per-dim target ────── "tests/test_health.py": ( - 2, + 1, "Comment + a fixture psql error string narrating a straddle-era " "constraint-does-not-exist case for chunks_384; mirrors health.py's " "allowlist reason." @@ -472,17 +474,17 @@ "table this code expects to exist post-unify." ), "tests/catalog/test_collection_scoped_tables_schema_parity.py": ( - 1, + 0, "Comment narrating the RDR-191 Phase 4 unification " "(chunks_384/768/1024 collapsed to one); historical only." ), "tests/db/test_http_chash_integration.py": ( - 1, + 0, "Comment narrating that a test's chosen collection segment used to " "route to chunks_768 pre-unify; historical only." ), "tests/test_rehearsal_seed_coverage_lint.py": ( - 9, + 0, "Comments narrating which straddle-era per-dim content " "(chunks_384/768/1024, taxonomy_centroids_384/768/1024) the " "rehearsal seed must cover; historical/explanatory, matches " @@ -514,7 +516,7 @@ # subject under test, not debris. Canonical LEGITIMATE per the bead's # own caution. ────────────────────────────────────────────────────── "service/src/test/java/dev/nexus/service/VectorsUnifyChunksIntegrationTest.java": ( - 35, + 18, "-1 (36->35, 2026-08-30, f472cb3f8): unRekeyedLegacyChash_bootSurvives_" "octetCheckStaysNotValid deleted, its premise inverted by hygiene-001-5/-8. " "RDR-191 Phase 4 core: the ALWAYS-COPY migration test collapsing " @@ -529,14 +531,14 @@ "already use, just two more call sites of it." ), "service/src/test/java/dev/nexus/service/VectorsUnifyCentroidsIntegrationTest.java": ( - 19, + 12, "The centroid-family sibling of VectorsUnifyChunksIntegrationTest: " "tests taxonomy-007-unify-centroids.xml collapsing " "taxonomy_centroids_384/768/1024 into ONE taxonomy_centroids. Same " "reason as that file." ), "service/src/test/java/dev/nexus/service/SchemaMigratorIntegrationTest.java": ( - 26, + 13, "SchemaMigrator end-to-end integration test: runs the FULL " "changelog including the vectors-004/taxonomy-007 unification " "changesets and their rollback blocks, so it necessarily names the " @@ -545,7 +547,7 @@ "own caution about SchemaMigratorIntegrationTest." ), "service/src/test/java/dev/nexus/service/SchemaUpgradeRehearsalIntegrationTest.java": ( - 41, + 21, "nexus-4m6i0.6 upgrade-rehearsal suite: injects a pre-unify schema " "divergence and upgrades it to HEAD across the vectors-004/" "taxonomy-007 changesets, then asserts the per-dim tables are GONE " @@ -567,7 +569,7 @@ "every other entry in this pin, not a live reference." ), "service/src/test/java/dev/nexus/service/Taxonomy010BackfillDirectIntegrationTest.java": ( - 3, + 0, "nexus-tk070.p3b (RDR-194 P3b, 2026-08-16): class javadoc explains " "why this test does NOT go through SchemaUpgradeRehearsalIntegration" "Test's old-tag-hop seeding mechanism for its positive KEEP arm -- " @@ -578,7 +580,7 @@ "own pin documents, not a live reference." ), "service/src/test/java/dev/nexus/service/SchemaRollbackRoundTripIntegrationTest.java": ( - 2, + 1, "nexus-lelhx (2026-08-15): two new comment references, both " "historical narration of the pre-RDR-191 per-dim shape — the " "changeset that gave rdr180-3-convert-chunks-384 a real rollback " @@ -591,7 +593,7 @@ ), # ── straddle-era FK test, Phase 5 LANDED (nexus-o8dil.49) ──────────────── "service/src/test/java/dev/nexus/service/CollectionRegistryFkTest.java": ( - 12, + 7, "RDR-156 nexus-70r3c.1 FK+hygiene suite. RDR-191 Phase 5 " "(nexus-o8dil.49, fk-004-chunks-collection-registry.xml) landed the " "unified chunks_collection_fk on 2026-08-15 — every test method that " @@ -619,7 +621,7 @@ # table_name column, same pattern as the Python conformance-report # test and the changelog directory allowlist. ───────────────────── "service/src/test/java/dev/nexus/service/ChashConformanceReportIntegrationTest.java": ( - 3, + 2, "tableRow(report, \"nexus.chunks_768\") reads chash_conformance_" "report(dim)'s DELIBERATE wire-compat table_name label — the same " "label test_du2dw_chash_conformance_report_engine.py and the " @@ -644,23 +646,23 @@ # unification, mirrors the service/src/main/java historical-prose # group above; no live per-dim SQL target remains in any of these) ── "service/src/test/java/dev/nexus/service/CatalogDeleteCollectionCascadeTest.java": ( - 7, + 0, "Comments narrating the RDR-191 unification of chunks_384/768/1024 " "and taxonomy_centroids_384/768/1024 into nexus.chunks / " "nexus.taxonomy_centroids; historical only." ), "service/src/test/java/dev/nexus/service/CatalogEngineDefects70Test.java": ( - 1, + 0, "Javadoc narrating a seeded nexus.chunks row as 'RDR-191 unified; " "formerly chunks_1024'; historical only." ), "service/src/test/java/dev/nexus/service/CatalogManifestSweepRepositoryTest.java": ( - 5, + 0, "Comments narrating the RDR-191 unification of chunks_384/768/1024 " "into nexus.chunks; historical only." ), "service/src/test/java/dev/nexus/service/CatalogPurgeTrashTest.java": ( - 8, + 2, "6 of the 8 hits are comments/javadoc narrating chunks_384 as the " "pre-unify fixture table (historical, mirrors " "CatalogDeleteCollectionCascadeTest). The other 2 (lines 380, 540) " @@ -674,45 +676,45 @@ "in an assertion description rather than a map-key lookup." ), "service/src/test/java/dev/nexus/service/CatalogPurgeTrashVacuumTest.java": ( - 1, + 0, "Comment narrating nexus.chunks as 'RDR-191 unified; formerly " "chunks_384', the table this VACUUM-observability fixture actually " "wrote to; historical only." ), "service/src/test/java/dev/nexus/service/CatalogRenameCollectionTest.java": ( - 5, + 0, "Comments narrating the RDR-191 unification; historical only, " "mirrors CatalogDeleteCollectionCascadeTest's allowlist reason." ), "service/src/test/java/dev/nexus/service/CatalogRepositoryTest.java": ( - 2, + 0, "Comments narrating the RDR-191 unification (chunks_384/768/1024 -> " "nexus.chunks, chunks_768 as a pre-unify FK-target example); " "historical only." ), "service/src/test/java/dev/nexus/service/ChashProbePlanShapeTest.java": ( - 1, + 0, "Javadoc narrating the RDR-191 Phase 4 retarget from three per-dim " "tables to the unified nexus.chunks with a single index; historical " "only." ), "service/src/test/java/dev/nexus/service/ChashRepositoryTest.java": ( - 2, + 0, "Javadoc/comment narrating the RDR-191 Phase 4 lane D5 collapse of " "chunks_384/768/1024; historical only." ), "service/src/test/java/dev/nexus/service/ChunksRlsBehavioralTest.java": ( - 2, + 0, "Javadoc narrating the RDR-191 Phase 4 unification of " "nexus.chunks_384/768/1024 into nexus.chunks; historical only." ), "service/src/test/java/dev/nexus/service/CollectionRegistryFkExtraTest.java": ( - 1, + 0, "Javadoc narrating chunks_384/768/1024 as unified into nexus.chunks " "under RDR-191 Phase 4; historical only." ), "service/src/test/java/dev/nexus/service/CollectionVectorStatsTest.java": ( - 3, + 0, "Javadoc/comments narrating collection_vector_stats as aggregating " "across the (now-unified) chunks_384/768/1024; historical only." ), @@ -736,24 +738,24 @@ # (test_no_live_code_references_a_retired_per_dim_table), which will # catch any future re-introduction. "service/src/test/java/dev/nexus/service/db/RawSqlGateTest.java": ( - 3, + 0, "Javadoc narrating the RAW-SQL canary's RETARGET from " "chunks_384/768/1024 and taxonomy_centroids_384/768/1024 onto the " "unified tables, plus a reference to grants-003's frozen pre-unify " "MAINTAIN list; historical only." ), "service/src/test/java/dev/nexus/service/db/TenantScopeVacuumAllowlistTest.java": ( - 2, + 0, "Comments narrating the pre-unification five-table allowlist and " "its RDR-191 collapse into nexus.chunks; historical only." ), "service/src/test/java/dev/nexus/service/db/TenantScopeVacuumMaintainGrantParityTest.java": ( - 1, + 0, "Javadoc narrating chunks_384/768/1024 under the pre-unify " "grants-003-purge-vacuum-maintain changeset; historical only." ), "service/src/test/java/dev/nexus/service/EmbeddingModeFailLoudTest.java": ( - 1, + 0, "Comment narrating the live nexus-pebfx.8 failure class as a " "prefix-routing 400 against 'the chunks_384 table'; historical " "only, describes a fixed bug." @@ -765,22 +767,22 @@ # own discipline ("a pin of 0 is not a real exemption") the entry is # removed outright rather than pinned at 0. "service/src/test/java/dev/nexus/service/ManifestCollectionStampTest.java": ( - 1, + 0, "Javadoc narrating a planted nexus.chunks row as 'RDR-191 unified; " "formerly chunks_1024'; historical only." ), "service/src/test/java/dev/nexus/service/ManifestFunctionsTest.java": ( - 1, + 0, "Comment/javadoc narrating the RDR-191 unification of " "chunks_384/768/1024 into nexus.chunks; historical only." ), "service/src/test/java/dev/nexus/service/ManifestVerifyTest.java": ( - 2, + 0, "Comment/javadoc narrating the OR-across-chunks_384/768/1024 shape " "the unified table's no-OR query replaced; historical only." ), "service/src/test/java/dev/nexus/service/nativeimage/JooqRecordReflectionFeatureTest.java": ( - 2, + 0, "Comment narrating the RDR-191 changeset pair collapsing six " "per-dim tables into two unified tables; historical only." ), @@ -797,29 +799,29 @@ # own discipline ("a pin of 0 is not a real exemption") the entry is # removed outright rather than pinned at 0. "service/src/test/java/dev/nexus/service/PgVectorRepositoryContractTest.java": ( - 1, + 0, "Comment/javadoc narrating the RDR-191 Phase 4 unification and the " "pre-unify 'nothing in chunks_768' assertion shape it replaced; " "historical only." ), "service/src/test/java/dev/nexus/service/PgVectorRepositoryRawSqlPlanShapeTest.java": ( - 1, + 0, "Javadoc narrating the chunks_384/768/1024 -> nexus.chunks " "unification; historical only." ), "service/src/test/java/dev/nexus/service/PgVectorServingContractTest.java": ( - 1, + 0, "Comment narrating a superuser fixture table as 'RDR-191 Phase 4; " "formerly chunks_1024'; historical only." ), "service/src/test/java/dev/nexus/service/Rdr71gw2CollectionNotNullTest.java": ( - 3, + 0, "Comments/javadoc narrating that the pre-fk-002 gap ran against " "nexus.chunks_384/768/1024 directly, and nexus.chunks as 'RDR-191 " "unified; formerly chunks_384/768/1024'; historical only." ), "service/src/test/java/dev/nexus/service/RdrO8dil7GlobalManifestAntiJoinTest.java": ( - 3, + 0, "Javadoc/comments narrating a manifest row as 'RDR-191 Phase 4 " "unified; formerly chunks_384/768/1024'; historical only." ), @@ -827,13 +829,13 @@ # (5 hits) — deleted at nexus-lgdel.l1 alongside RekeyOps.java itself # (its SUBJECT was the deleted rekey rung's server-side correctness). "service/src/test/java/dev/nexus/service/SoftDeleteTest.java": ( - 2, + 0, "Javadoc/comments narrating the pre-unify 384/768/1024 fixture " "shape and nexus.chunks as 'RDR-191 Phase 4 unified; formerly a " "chunks_384 row'; historical only." ), "service/src/test/java/dev/nexus/service/StagingPromoteOpsIntegrationTest.java": ( - 3, + 0, "Comments narrating a branch that used to be hardcoded to " "chunks_768 only, and the RDR-191 repoint collapsing " "chunks_384/768/1024; historical only." @@ -851,12 +853,12 @@ # own discipline ("a pin of 0 is not a real exemption") the entry is # removed outright rather than pinned at 0. "service/src/test/java/dev/nexus/service/TaxonomyCentroidSchemaLiquibaseTest.java": ( - 1, + 0, "Javadoc narrating nexus.taxonomy_centroids_384/768/1024 as unified " "into ONE table; historical only." ), "service/src/test/java/dev/nexus/service/vectors/PgVectorRepositoryDimGuardTest.java": ( - 3, + 0, "Javadoc narrating the pre-unify per-dim chunks_384/768/1024 tables " "the dim guard used to key off of; historical only." ), @@ -890,17 +892,29 @@ def _iter_scope_files() -> list[Path]: return paths -def _scan_text(text: str, *, file_label: str) -> list[Offender]: +#: A line that is prose, not code: a shell/Python comment, a Java line +#: comment or block-comment body, or an XML/HTML comment opener. The count +#: pins tally CODE lines only (nexus-9gggv, 2026-09-08): three fix-forward +#: commits on 2026-09-07 moved pins because a COMMENT named chunks_384, and +#: a comment cannot be a stale reference the way a query can. The unlisted- +#: file guard still scans every line, so prose in live code is still caught +#: where nothing was ever exempted. +_COMMENT_LINE_RE = re.compile(r"^\s*(?:#|//|/\*|\*|