feat(handoff-search): section-grained full-text index of the handoff corpus (P1) - #1209
Conversation
…corpus (P1) There is no server-side query over handoff-doc BODIES today. `initiatives/sync.py` stores derived metadata only (`summary` is one parsed line, `current_doc` is a PATH, `search_text` is session prompts) and the viewer live-reads bodies off disk with client-side JS search. So the corpus is reachable only by a human who already knows which doc to open, and `/resume` and subagents re-derive findings that are written down. P1 = derivation + write + query CLI. No HTTP service, no network binding, no pgvector — those are P2/P3. THE CORPUS COMES FROM GIT REFS, NOT THE WORKING TREE `git worktree list` in devrc alone returns 143 entries (measured), so a disk scan indexes mid-edit branches, the same doc N times, and stale orphan copies, and two runs an hour apart disagree for reasons unrelated to what anyone wrote. Each repo's mainline is DERIVED via `lib/git_mainline.py` — never hardcoded. Verified end to end: devrc resolves `origin/main` (94 docs / 959 sections), homelab-talos resolves `origin/trunk` (54 / 506). A hardcoded `main` would have indexed nothing for the second. A doc on disk and ABSENT from the mainline ref is a DURABILITY HOLE: reported, never indexed. Indexing it would let the search surface answer *from* the hole and conceal it. The one that exists today reproduces (`homelab-talos/claudedocs/handoff-limewire-torrent-comps.md`). SECTIONS, NOT DOCS `goal|state|investigation|next_step|gotcha|verify`, with `## Open investigations` split per `### ` sub-block and `## Next steps` per ranked item. A "Ruled out" paragraph is the highest-value content in this corpus and is invisible at doc granularity — a few hundred bytes inside a 40 KB document that `ts_rank` drowns. Every heading/fence/item rule is BORROWED from `lib/handoff_doc.py`, the writer of these documents and therefore the authority on their shape (`split_front_matter`, `split_sections`, `canonical_prefix`, `_item_blocks`, `_FORCING`, `_unfenced`). A second copy would be a parser free to drift from the one measured against the real corpus, which handoff_doc's own docstrings record happening three times. TWO GUARDS ON THE READ SURFACE - RECALL BANNER on every response (subsystem_recall's wording): results are POINTERS TO VERIFY, and a hit may describe a gotcha already fixed. - SILENT-ZERO GUARD: every response carries `indexed_docs=N indexed_sections=M`, and a zero beside `indexed_docs=0` renders as `BROKEN INDEX` — sharing no phrase with the genuine `NO MATCH` — and exits non-zero. The index is DERIVED and DISPOSABLE; `--rebuild` truncates and git stays the system of record. TESTING 66 hermetic tests. The DB sits behind `SectionStore`, a protocol with two real implementations (the memory one is production code — `handoff_search --offline`), so no test needs Postgres. Positive control (a term in exactly one fixture doc returns exactly that doc, count off zero) and negative control (a term in none returns 0 with indexed_docs non-zero) run through the same code path. Mutation sweep under PYTHONDONTWRITEBYTECODE=1: 9/9 killed, each by its own guard's specific assertion, plus a known-caught positive control. The `git_mainline` importer ledger caught this module as a third importer and is updated — that seam guard working exactly as designed. NOT VERIFIED: the live-Postgres path. The gate runs in a nix sandbox with no cluster and no database, so the DDL has been read and never executed, the generated tsvector column never computed, and ts_rank never ordered anything. The timer is therefore gated OFF by default (`enableHandoffIndexSync = false`) until a supervised live run validates the write path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session-Id: 90310ba1-6dc2-4779-a616-2ccfe89457d7
…ndex-p1 Claude-Session-Id: 90310ba1-6dc2-4779-a616-2ccfe89457d7
…nd the silent-zero guard could not see a FILTER First-round adversarial audit of #1209. Seven findings plus six nits; all fixed. Every fix carries a regression test watched RED at the pre-fix source and GREEN after: 56 failed / 69 passed at `05e0e0e9`'s modules, 136 passed after. A 25-mutant sweep (PYTHONDONTWRITEBYTECODE=1, __pycache__ cleared between mutants, one known-caught positive control) kills all 25, including the two the audit measured SURVIVING against the old suite: `TRUNCATE` -> `pass` and `.glob` -> `.rglob`. F1 (🔴) `--rebuild` emptied the table and exited 0 on TOTAL failure. Reproduced: `main(["--repo","/bogus/a","--repo","/bogus/b","--rebuild"])` ran TRUNCATE+COMMIT with `docs=0 sections=0` and returned 0, so the unit's `OnFailure=notify-failure@` never fired. Two independent halves: * `rebuild_refusal()` refuses the truncate when ANY repo came back UNMEASURED or the derivation is empty, and exits RC_REFUSED (4). It reads a new STRUCTURAL `RepoDerivation.unmeasured` field, never a grep over the warning prose — a reworded warning would walk past a spelled guard. * `PostgresSectionStore.write(rows, rebuild=)` replaces `truncate()` + `upsert()`. ONE transaction, ONE commit, all-or-nothing per run, in `initiatives/sync.py::write_snapshot`'s shape. The old pair committed the TRUNCATE on its own, so any exception in the row loop left the table empty AND durable (`MailDB.__exit__` only closes; the inserts were discarded). * `upsert`'s docstring claimed crash-safety that was true of `upsert` alone and false of every `--rebuild` run — i.e. of every run the timer makes. Corrected. F2 (🔴) the silent-zero guard could not see a SCOPED query. `--repo <never-indexed>` — including an absolute path, the natural thing to type on a box that pre-exports `$DEVRC` — rendered `NO MATCH — the index WAS searched … an answer about the corpus` beside a reassuring `indexed_docs=352`. Both halves false. `stats()` now takes the same filters `search()` does (one `_filter_predicates` definition feeding both the SQL builders), a fourth status `empty-scope` (exit 4) is emitted for a filter that selects nothing, and an unknown `--repo` is REJECTED naming `SELECT DISTINCT repo`. `in_scope_docs=/in_scope_sections=` print beside the totals whenever a filter is applied. Two `SCOPE_REASONS` keep "you typed a path" distinguishable from "valid filter, empty corpus". F3 (🟡) the durability report walked a different tree from the thing it was differencing against: `.glob` on disk vs `git ls-tree -r`. A doc at `claudedocs/sub/handoff-*.md` yielded `untracked=()` and printed the literal all-clear. `.rglob`, and the all-clear is now conditional on having scanned a document — zero docs prints NOT AN ALL-CLEAR. F4 (🟡) slug was the basename, so two docs with one basename shared an identity and `ON CONFLICT DO UPDATE` silently kept the last. Slug is now path-derived (`claudedocs/` stripped, so every existing top-level identity is unchanged), AND `identity_collisions()` reports any duplicate identity by occurrence count — which also covers the case a unique slug cannot: two repos whose directory basenames collide resolve to one label. `main` refuses to write one (RC_COLLISION=5). F5 (🟡) one committed `\xff` killed the whole run for every repo: `_git` decoded with the process locale under `text=True` and caught only OSError, inside a per-document loop, and the unit sets no LANG/LC_ALL. Now `encoding="utf-8", errors="replace"`. Deliberately NO `UnicodeDecodeError` in the except — the sweep proved that clause unreachable, and an except naming an impossible exception reads as coverage while providing none. F6 (🟡) `\d{4}-\d{2}-\d{2}` admitted `2026-99-99`. Postgres rejects it INSIDE the write transaction, after the truncate, deterministically — so one typo emptied the index every 6h. Validated with `date.fromisoformat`; an unparseable date is treated as absent and the scan continues to the next candidate. F7 (🟡) `main([])` upserted 509 rows to production. Dry-run is now the DEFAULT and `--write` is required (the unit's ExecStart gains it); `--write --dry-run` is a usage error. `main`, `render_derivation`, `default_repos` and the store wiring are now exercised end-to-end against a recording fake connection that asserts the statement/commit ORDER. Nits: the worktree count is stated in ONE place (re-measured, 148 in devrc) after carrying 601 in the README and 143 in nix/home.nix inside one PR; both modules are 0755 to match their shebangs; `--limit` bounded >= 1; `--repo` disambiguated in help + README (PATH here, LABEL there); `test_the_row_shape_matches_the_columns_the_ddl_declares` now parses the CREATE TABLE body instead of matching `f"{col},"` anywhere (an index column list satisfied it); the `handoff-audit.py` comment says merged-as-f71ff648 rather than open PR #1064. `test_runtime_shebangs.py` gains one allowlist entry: the new executable-bit test ASSERTS a shebang shape and writes no stub, which is the pinned shape (b) the existing `test_session_stamp_seam.py` entry already covers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session-Id: 90310ba1-6dc2-4779-a616-2ccfe89457d7
First-round audit findings — all 7 + all 6 nits FIXED (
|
| result | |
|---|---|
new tests vs. pre-fix handoff_index.py/handoff_search.py |
56 failed, 69 passed |
| new tests vs. fixed modules | 136 passed (126 in this file + the shebang guard's suite) |
A 25-mutant sweep (PYTHONDONTWRITEBYTECODE=1, __pycache__ deleted between mutants, one known-caught positive control, baseline green before and after) kills 25/25 — including the two the audit measured SURVIVING against the old 66-test suite:
| mutant | audit's result | now |
|---|---|---|
TRUNCATE {TABLE} → pass |
SURVIVED | KILLED — test_the_truncate_and_every_insert_share_ONE_transaction |
.rglob → .glob |
SURVIVED | KILLED — test_a_NESTED_untracked_doc_is_found |
Both re-confirmed on the final tree after the last edit.
🔴 F1 — --rebuild truncated before it knew it had rows, exited 0 on total failure — FIXED
Reproduced first: main(["--repo","/bogus/a","--repo","/bogus/b","--rebuild"]) against a fake connection logged TRUNCATE / COMMIT with docs=0 sections=0 and returned 0, so OnFailure=notify-failure@%n.service never fired. Now returns 4 and issues no statement at all.
Three separate changes, because the finding was three defects:
- (a) refusal + non-zero exit.
rebuild_refusal(derivations, rows)refuses when any repo came back UNMEASURED or the derivation is empty →RC_REFUSED = 4. It reads a new structural fieldRepoDerivation.unmeasured("no-such-directory"/"no-mainline-ref"), never a grep over the warning prose — a reworded warning would walk straight past a spelled guard.test_the_refusal_reads_the_structural_flag_not_the_warning_prosepins that as a differential: flag set + silent warnings ⇒ refuse; warnings shoutingUNMEASURED+ flag clear ⇒ allow. The checks run before the store is opened, and the tests inject anopen_storethat raises, so they assert the write did not happen rather than only that the code was non-zero. - (b) one transaction.
truncate()+upsert()are replaced bywrite(rows, *, rebuild)— TRUNCATE and every INSERT in one transaction with a single commit,initiatives/sync.py::write_snapshot's shape. The guard is an ordering assertion over a recording fake connection: the sequence from the TRUNCATE onward must be exactly[TRUNCATE, INSERT×9, COMMIT]. That kills both theTRUNCATE → passmutant and a mutant that re-inserts the old mid-write commit. - (c) the false docstring.
upsert's crash-safety claim was true ofupsertalone and false of every--rebuildrun — i.e. of every run the timer makes. Rewritten to describe what the code does, with the old claim quoted and marked wrong.
Also: a partial derivation (one repo UNMEASURED out of three) still refuses, tested — that shape produces rows, so a guard keyed only on emptiness would let it truncate and replace the corpus with a subset of itself.
Deliberate scope boundary, stated in the code: a non---rebuild run over an UNMEASURED repo still exits 0. It destroys nothing, the warning prints either way, and making it fatal would fire the failure toast forever on a host whose $HOMELAB/$CIVITAI checkout is legitimately absent — the case nix/home.nix explicitly says is safe to configure. What is unrecoverable is truncating, and that is what refuses.
🔴 F2 — the silent-zero guard could not see a SCOPED query — FIXED
Reproduced independently: --repo devrc matched; --repo /home/zach/workspace/devrc and --repo totally-bogus-repo both returned NO MATCH — the index WAS searched … an answer about the corpus, not a broken tool beside indexed_docs=352. Both halves false.
stats()on both backends now takes the samerepo/sectionsfilterssearch()does. The SQL predicates come from one_filter_predicates()feeding bothsearch_sqland the newstats_sql; the memory backend has one_selected()read by both its counter and its query. Pinned as a relationship, not by re-typing either clause.- New fourth status
empty-scopewith its own exit code 4 (broken-indexkeeps 3).EXIT_CODESis pinned two-way againstSTATUSES, withhit/no-matchexcluded as the two answers. --repois validated againstSELECT DISTINCT repo(store.repos(); the derived label set offline) and rejected naming the known values.in_scope_docs=/in_scope_sections=print beside the totals whenever a filter is applied — and not when it isn't, so two identical numbers never train the reader to skip both.- Two
SCOPE_REASONSkeep the two causes distinguishable in prose:unknown-repo("NO REPO IS INDEXED UNDER THE LABEL 'x'.--repotakes a repo LABEL, not a path… Indexed labels: …") vsno-rows("The filter is VALID … but this corpus holds no row under it"). This came out of the sweep: my first version collapsed the label check into the bare count, all tests stayed green, and the label list became unreachable prose. That mutant is now killed.
test_the_repo_filter_narrows_without_emptying_the_index is widened exactly as asked — it now passes three labels: one that narrows to a populated scope, one that hits, and one the index does not hold.
Live, post-fix:
indexed_docs=93 indexed_sections=968 backend=memory in_scope_docs=0 in_scope_sections=0
query='stash' repo=totally-bogus
🔴 EMPTY SCOPE — your filter selects 0 of the index's 968 section(s) …
This is NOT the answer 'the corpus does not mention that': the filter, not the corpus, is what is empty.
NO REPO IS INDEXED UNDER THE LABEL 'totally-bogus'. --repo takes a repo LABEL, not a path …
Indexed labels: devrc.
🟡 F3 — durability report blind to nested docs, false all-clear — FIXED
.glob → .rglob, so the disk half walks the same tree git ls-tree -r does — the two sides of a set difference must walk the same shape or the difference is meaningless. The all-clear is now conditional on having scanned a document; zero docs prints NOT AN ALL-CLEAR — ZERO documents were scanned. Three tests: the nested hole is found (kills the mutant), the top-level one still is (no traded blind spot), and a committed nested doc is indexed and not reported as a hole (negative control).
🟡 F4 — slug not doc-unique — FIXED, both halves
Slug is now path-derived, with claudedocs/ stripped so every existing top-level identity is byte-unchanged and only a nested doc grows a directory component. Plus identity_collisions() reports any duplicate (repo, slug, section, ordinal) and main refuses to write one (RC_COLLISION = 5), surfaced in render_derivation too.
The detector counts occurrences, not distinct paths — and that correction came from a failing test: two repos whose directory basenames collide resolve to one label (default_repos uses Path(raw).name), so their identically-named docs share every identity with identical doc_paths, which a path-de-duplicating check reads as one document. A unique slug cannot cover that; the detector can.
🟡 F5 — one non-UTF-8 doc killed the entire run — FIXED
encoding="utf-8", errors="replace". replace not ignore: a U+FFFD is visible in a result and tells the reader the source is malformed. The test asserts the other doc in the same repo still reaches a row — "it did not raise" would also be satisfied by a run that silently produced nothing.
Deviation from the finding, deliberately: I did not add UnicodeDecodeError to the except. I did, then the sweep showed removing it left the whole suite green — with errors="replace" the decode cannot raise, so the clause is unreachable. claude/RULES.md says prove a guard reachable or don't write it; an except naming an impossible exception reads as coverage and provides none. The fix is the decode parameters, and the reasoning is in the docstring.
🟡 F6 — impossible date reaching a date column — FIXED
date.fromisoformat validation; an unparseable date is treated as absent, not fatal, and the scan continues to the next candidate in the same source, then the filename. Parametrized over 2026-99-99, 1234-56-78, 2026-02-30, 2026-00-01, 2026-13-01, with a positive control that uses the leap-year pair 2026-02-29 (rejected) / 2024-02-29 (accepted) — a validator that rejected everything would otherwise pass every case.
🟡 F7 — default invocation wrote to production; --dry-run untested — FIXED
Dry-run is now the default; --write is required to mutate, --write --dry-run is a usage error (rc 2). nix/home.nix's ExecStart gains --write, and the enableHandoffIndexSync comment's validation recipe now says --rebuild --write — because a validation run of bare --rebuild would derive, report, write nothing, exit 0 and be read as the supervised live run that switch is waiting for.
main, render_derivation, default_repos, the JSON surface and the store wiring are all now exercised end-to-end against a fake connection. The production seam is pinned too (main's open_store default is _maildb_store) — otherwise the injection could have silently replaced the real path while every test stayed green. import_maildb itself remains unexercised (it imports psycopg2, absent from the sandbox) and the test says so.
Nits — all six fixed
- worktree count: stated in one place (the module docstring, re-measured 148 in devrc). README and
nix/home.nixnow point at it and quote no number — they had 601 and 143 inside one PR. Corpus figures re-measured too: devrc 94/968, homelab-talos 54/512. - exec bit: both modules
100644 → 100755, with a test pinning shebang-shape + mode together. --limit: bounded>= MIN_LIMIT(1) at parse time, rc 2, parametrized over0/-1/-10, with a positive control at--limit 1.--repoambiguity: disambiguated in both help texts and both README rows (PATH in the indexer, LABEL in the search CLI) — and the empty-scope message teaches it at the point of the mistake.test_the_row_shape_matches_the_columns_the_ddl_declares: now parses theCREATE TABLEbody and compares as a set, both directions, instead of matchingf"{col},"anywhere (the UNIQUE index's column list satisfied it). Carries its own guard that the parser found 11 columns, so neither set comparison can be vacuously about an empty set.- stale comment:
handoff-audit.pynow reads "merged asf71ff648, onmain".
One extra change, flagged
scripts/tests/test_runtime_shebangs.py gains one allowlist entry. The repo-wide scan caught the new executable-bit test — correctly by its own text-matching rules, since a quote followed by #! is one of its needles. This is the pinned shape (b) (asserts a shebang, writes no stub, execs nothing) that the existing test_session_stamp_seam.py entry already covers; the needle deliberately does not spell the prefix, or the guard's own self-check would flag the entry. The assertion was first reduced to startswith("#!") + "python3" in first — no env path — and that still tripped it, which is why the pin exists rather than a further reword.
…ndex-p1 Claude-Session-Id: 90310ba1-6dc2-4779-a616-2ccfe89457d7
The
|
| # | fact |
|---|---|
| 1 | The failing file is scripts/tests/test_cairn_write.py, added by #1210. git show --stat HEAD | grep -i cairn on my commit returns nothing — my diff cannot reach it. |
| 2 | #1216, a docs-only handoff PR, is red on the same file (TestTheActorComesFromTheTOKEN.test_a_FORGED_actor_in_the_body_is_DIS…). Its diff cannot reach cairn either. |
| 3 | My tree predated the fix. 1a4350f3 — "site the store on tmpfs so the gate stops failing", #1211 — landed on main at 14:30:17. git merge-base --is-ancestor 1a4350f3 98429e80 → false. |
#1213 records the baseline directly: "5 store-api failures among 14 open PRs, 2026-09-01", and says plainly that #1211 must not be described as demonstrated until the flake rate is re-measured. The other open-PR reds are the gate working correctly on their own diffs — #1194 on the runtime-shebang scanner, #1177 on the skill-tiers.json ledger — so "the gate is just red" would have been the wrong generalisation, which #1213 also calls out.
Action, per #1183 ("a PR red your diff cannot reach is INHERITED … then rebase; don't debug the leg"): merged origin/main (4162dab1) → 24425b69. Clean merge, and it touched none of my files:
git diff --stat HEAD^1 HEAD -- scripts/lib/handoff_index.py scripts/lib/handoff_search.py scripts/tests/test_handoff_index.py
(empty)
git merge-base --is-ancestor 1a4350f3 HEAD is now true. Locally on the merged tree, test_handoff_index.py + test_runtime_shebangs.py + test_cairn_write.py → 164 passed.
🔴 What this does NOT claim. A green on the next Tekton run will not prove the flake is fixed — #1213 makes exactly that point, since the gate validating the fix is the thing being fixed. It will only mean this PR's red is gone. And my earlier gate.sh green was a claim about the dev-host tier, which is structurally blind to this: the sandbox tier is where it fires. I am re-running both nix build .#checks.x86_64-linux.{pytests,nodetests} locally one at a time, alongside a base-vs-branch control — the same derivation built from a detached worktree at 4162dab1 (my fix absent) — so attribution rests on a measurement rather than on this reasoning.
|
Correction to row 2 of the triage table above — the conclusion stands, but the stated evidence was wrong, and the corrected version is stronger. #1216 is not red on the same file. Measured:
The comment above named What the corrected evidence supports, more strongly than the original claim:
Both files spawn real loopback servers and drive a CLI subprocess, which is the shape #1211 addressed ( Unchanged from the original triage: this is not a claim that the flake is fixed, only that this PR's red was inherited rather than caused. The sandbox tier has still never been observed green on this branch; that measurement is outstanding and is what should decide the merge. |
|
Round 1 audit claims, for the delta round to check against the diff. |
Gate results on
|
| build | nix-build-rc |
log | runner verdict |
|---|---|---|---|
control 4162dab1 pytests (my fix absent) |
0 | 133 KB | RESULT: PASS (exit=0) · collected=20353 passed=20350 skipped=3 failed=0 |
HEAD 24425b69 pytests |
0 | 134 KB | RESULT: PASS (exit=0) · collected=20479 passed=20476 skipped=3 failed=0 |
HEAD 24425b69 nodetests |
0 | 437 KB | RESULT: PASS (exit=0) · suites=5 files=41 tests=1449 pass=1449 fail=0 |
Guarded against the cached-empty-output case rather than assuming: each log opens with this derivation will be built:, so none was a replay — the 17s nodetests run in particular is real, not a no-op. panic: test timed out count is 0 in all three, and I counted per-target/per-suite result lines rather than reading a status. scripts/tests (collected=11482 confirms the target holding my new tests actually executed — a green from a target that never ran would be indistinguishable otherwise.
Tekton agrees independently, byte-for-byte on the same counts: tekton/devrc-pytests collected=20479 passed=20476 skipped=3 failed=0, tekton/devrc-nodetests tests=1449 pass=1449 fail=0. Both required contexts success.
The inherited red, closed by a controlled pair
My diff is identical across 98429e80 and 24425b69 — the merge touched none of my files. Only the base moved:
| pytests | |
|---|---|
98429e80 (base lacks 1a4350f3) |
failure — FAILING: TestAppendLands…, 3 failed |
24425b69 (base has 1a4350f3) |
success — 0 failed |
🔴 This does not say the store-api flake is fixed. #1213 makes exactly that point — the gate validating the fix is the thing being fixed, and its rank-1 verifier (re-measure the flake rate against the 5-of-14 baseline) has not run. It says only that this PR's red was inherited and is gone.
…ndex-p1 Claude-Session-Id: 90310ba1-6dc2-4779-a616-2ccfe89457d7
… the refusal guarding it was a permanently-red gate Round-2 delta audit on #1209. Seven findings; all seven fixed. F2 is the data loss. `--repo ~/workspace/devrc --rebuild --write` issued `TRUNCATE initiatives.handoff_section` — no predicate, every repo — re-inserted only devrc, printed `wrote 968 section row(s)` and exited 0. homelab-talos's ~515 sections gone, from the exact command a human types to refresh one repo. The delete is now scoped by `rebuild_delete_labels`: the labels this run MEASURED, plus (unscoped runs only) any stored label the config no longer names. Asymmetric on purpose — configured-but-unmeasured keeps its rows, not-configured-at-all is collected, so scoping the delete does not trade data loss for a stale corpus. `write(rebuild=True)` with an empty scope now raises rather than defaulting to "everything". F1 is the mirror. `rebuild_refusal` refused whenever ANY repo was UNMEASURED: one present repo plus one absent one gave rc 4, nothing written — on a unit with `OnFailure=notify-failure@%n.service` behind a 6h timer, i.e. a toast 4x/day forever with the index frozen, on a host whose only sin is not having $CIVITAI checked out. It now refuses only when ALL repos are unmeasured or the measured subset yields zero rows; a partial run indexes what resolved and says `PARTIAL INDEX` on every surface including the write's own success line. What makes that safe is F2's scoped delete, not the refusal. Both comments that called an absent checkout "safe to add" are corrected. F4: the all-clear asserted something about the DISK side and was gated on the REF-side doc count. Measured: with a real durability hole present, `chmod 0o000 claudedocs/` turned `DURABILITY HOLE` into `warnings: none — every handoff doc on disk is also in it`. And the `except OSError` around the old `rglob` could never fire — `rglob` swallows the PermissionError inside `os.scandir` (measured, CPython 3.12.14). Now `os.walk(onerror=…)`, a `DiskScan` recording whether the walk ran and what it hid, and an all-clear gated on that plus a non-empty disk side. `test_zero_documents_scanned_is_ NOT_an_all_clear` widened rather than paralleled. F3: `--offline` diagnosed an unresolvable checkout as a broken Postgres index, offering `--rebuild --write` and a systemd unit on a path that opens neither. `unmeasured` now crosses the seam and gets its own status/exit code (6); the no-repos case is a usage error (2) in the sibling CLI's own wording. F5: `--json` replaced the renderer that owned the warning block, so the machine surface carried rows and no durability-hole report. Warnings, an `all_clear` boolean and its blockers now travel in the payload. F6: `--dry-run` never evaluated the refusal, so the pre-flight nix/home.nix documents passed for a config the real run refuses. Both gates now run in both modes and return the same code. F7: `empty-scope` keeps rc 4 for BOTH reasons, documented at length with the counter-argument stated. The exit code is the one channel read without the prose, and `no-rows` vs `no-match` are the two zeros this module exists to separate. Red->green: the 29 new/widened tests all FAIL against the pre-fix modules (29 failed / 119 passed) and pass after (149 in the file, 562 across the handoff+git_mainline+shebang subset). Mutation sweep 16/16 guards KILLED under PYTHONDONTWRITEBYTECODE=1 with a fresh tree per mutant, plus a GREEN no-op negative control and a KILLED known-caught positive control. The sweep found one real gap: `all_clear_blockers`' incomplete-walk arm was unkillable through the renderer (any warning short-circuits it), so it is reached directly as a contract pin on the pure function and labelled as one. Also: the round-1 "136 passed" figure did not reproduce; the accurate numbers are stated above. Corpus figures re-measured over all four handles (439 docs / 3,978 sections) and re-labelled as a SCALE, since they have now moved three times in this module's lifetime. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session-Id: 90310ba1-6dc2-4779-a616-2ccfe89457d7
Round-2 delta audit — all 7 findings fixed (
|
RED at 36a1fdb1 |
test_one_unmeasured_repo_among_several_is_NOT_refused → AssertionError: assert 'REFUSING --rebuild: 1 of 2 repo(s) came back UNMEASURED — gone (no-such-directory)…' is None |
GREEN at 0e4ea9fa |
✅ + 4 more: test_a_partial_derivation_says_PARTIAL_INDEX_loudly, test_an_ALL_unmeasured_derivation_is_still_refused, test_a_partial_derivation_with_no_rows_is_refused, test_a_partial_rebuild_WRITES_and_says_partial_through_main |
Both ends of the range are pinned, because "refuse on ANY" and "refuse on ALL" differ only when the set is mixed.
🟡 2 — a scoped --rebuild --write truncated the whole table · FIXED (scoped delete)
handoff_index.py PostgresSectionStore.write + new pure rebuild_delete_labels
Took the preferred option: scope the delete, so the natural command is correct rather than erroring.
- TRUNCATE initiatives.handoff_section
+ DELETE FROM initiatives.handoff_section WHERE repo = ANY(%s)
The scope is decided in one place by rebuild_delete_labels(derivations, stored, *, scoped). You asked what happens to a repo that disappears from the config — it is still collected, and the asymmetry is the design:
| repo state | rows |
|---|---|
| configured and measured | deleted + re-inserted |
| configured but UNMEASURED | preserved — this is what makes finding 1's relaxation safe |
| not configured at all (unscoped run only) | deleted — nothing will ever refresh it |
any of the above, on a --repo-scoped run |
only the named repos; a scoped run was never told what the full config is |
write(rebuild=True) with an empty scope now raises ValueError rather than defaulting to "everything". main cannot reach it (the refusal guarantees a measured repo), so it is reached directly in a test — it is a library-level belt for a future caller, and is labelled as one.
⚠ Noted in-code: a scoped DELETE is not as cheap as TRUNCATE (tuple versions + autovacuum vs. reclaiming the relation). At low-thousands of rows that is not worth buying a whole-table wipe with; stated so a future reader at a different scale re-derives it rather than inherits it.
RED at 36a1fdb1 |
test_a_scoped_rebuild_deletes_ONLY_the_repo_it_was_pointed_at → assert 'TRUNCATE initiatives.handoff_section' == 'DELETE FROM initiatives.handoff_section WHERE repo = ANY(%s)', with the run's own stdout showing wrote 9 section row(s) … (after TRUNCATE, one transaction) and rc 0 |
GREEN at 0e4ea9fa |
✅ + test_the_scope_is_the_MEASURED_labels_not_every_derived_one, test_a_FULL_run_still_collects_a_repo_that_left_the_config, test_a_rebuild_with_an_EMPTY_scope_raises_rather_than_wiping |
The recording connection now captures bound params, not just SQL text — the defect is a delete whose WHERE clause is right and whose scope is wrong, and a text-only recorder cannot see that. RecordingConn.kinds() deliberately still spells TRUNCATE though nothing emits it: the assertions say "TRUNCATE" not in kinds, and a classifier that cannot spell the word satisfies that vacuously.
🟡 3 — --offline misdiagnosed an unmeasurable repo set · FIXED
handoff_search.py _offline_store / offline_targets / run_search
unmeasured now crosses the seam as the structural per-repo reason (never a grep over warning prose) and gets its own status unmeasured-corpus, exit code 6, and a rendered block that shares no opening phrase with any other zero — and, critically, names neither a table nor a unit, because every remedy the broken-index block offers is about a Postgres index this code path never touches.
Both measured variants, live:
(a) --offline --offline-repo /does/not/exist
🔴 UNMEASURABLE CORPUS — all 1 repo(s) this run was pointed at failed to resolve, so no
corpus was ever built: exist (no-such-directory).
This is NOT a broken index and NOT an answer about the corpus. There is no table to
rebuild and no unit to check on this path — the repos themselves are what did not resolve.
rc=6 (no "BROKEN INDEX", no "--rebuild --write", no "handoff-index-sync")
(b) all four handles unset, no --offline-repo
handoff-search: no repos to index. Pass --offline-repo, or set one of: $DEVRC, $HOMELAB, $DATAPACKET, $CIVITAI
rc=2
Wording for (b) matches the sibling CLI, and is asserted as a cross-CLI differential rather than against a hand-copied literal — pinning the string would let the two front ends drift apart again the next time either is reworded.
RED at 36a1fdb1 |
test_an_UNRESOLVABLE_offline_repo_is_not_a_broken_index, test_no_repos_at_all_is_a_usage_error_in_the_siblings_wording, test_the_json_surface_carries_the_unmeasured_repos, test_the_five_statuses_render_with_no_shared_opening_phrase |
GREEN at 0e4ea9fa |
✅ |
Boundary preserved and pinned: a partially unmeasured corpus that still holds rows answers normally (test_a_PARTIALLY_unmeasured_offline_corpus_still_answers). Widening this to "any unmeasured repo" would repeat finding 1's permanently-red mistake in the read path.
🟡 4 — the all-clear was gated on the REF count while asserting about the DISK side · FIXED
handoff_index.py new DiskScan + all_clear_blockers; TestTheAllClearIsEarned widened, not paralleled
🔴 The mechanism was not the one the finding named, and that matters. The old except OSError: return () could never fire: Path.rglob walks via os.scandir and swallows the PermissionError internally. Measured on CPython 3.12.14 — rglob over a chmod 0o000 directory returns [] and raises nothing. So the swallowed error was not being swallowed by that clause; it never reached it. The fix is os.walk(onerror=…), the only form that hands the error back.
DiskScan records paths / scanned / absent / errors, with complete = scanned and not errors, defaulting to scanned=False so an unset field can never license an all-clear. The sentence is now gated on the measurement it describes: every walk ran, hid nothing, and saw at least one path.
Reproduced end to end on one repo with a real hole, as a differential where only the permission bit changes:
BASELINE (36a1fdb1) FIXED (0e4ea9fa)
readable 🔴 DURABILITY HOLE — 1 handoff doc … 🔴 DURABILITY HOLE — 1 handoff doc …
chmod 0o000 ## warnings: none — every repo resolved a ⚠ UNSCANNABLE DISK — probe: 1 directory
mainline ref and every handoff doc on under claudedocs/ could not be read …
disk is also in it. /…/probe/claudedocs: Permission denied
The uncommitted doc had not moved in either column.
RED at 36a1fdb1 |
test_an_UNREADABLE_claudedocs_is_NOT_an_all_clear, test_a_disk_side_that_saw_ZERO_paths_is_NOT_an_all_clear, test_the_blockers_NAME_which_measurement_is_missing, test_an_incomplete_walk_is_named_in_the_blockers_LIST_itself, plus the widened test_a_real_scan_with_no_findings_IS_an_all_clear |
GREEN at 0e4ea9fa |
✅ |
Also consolidated: the disk half now matches with _HANDOFF_NAME, the same predicate the ref half uses, instead of the HANDOFF_GLOB shell pattern — two spellings of "is this a handoff doc" across the two halves of one set difference is the duplicated predicate one level down from the rglob/glob bug already fixed here.
🟢 5 — --json suppressed the warnings · FIXED
--json now emits an object, not a bare row list: rows, warnings, all_clear (boolean), all_clear_blockers, totals, and a per-repo array carrying unmeasured, untracked, disk_scan_complete, disk_paths_seen, disk_scan_errors. Extracted derivation_warnings() so both renderers read one function. No consumers of the old list shape exist (P1, timer gated off), so this is a rename rather than a migration. Live:
$ handoff_index.py --repo ~/workspace/homelab-talos --json
keys ['all_clear', 'all_clear_blockers', 'repos', 'rows', 'totals', 'warnings']
all_clear False
warnings ['🔴 DURABILITY HOLE — 1 handoff doc in homelab-talos is on DISK and NOT in the mainline ref…']
RED: test_the_json_surface_carries_the_WARNINGS_the_text_one_prints + test_the_json_all_clear_is_a_BOOLEAN_a_caller_can_branch_on → GREEN.
🟢 6 — --dry-run never evaluated rebuild_refusal · FIXED
Both gates (collision and refusal — same class of hole) are now evaluated before the --write branch and return the same exit code in both modes; the only difference between the two runs is whether a store is opened. The dry run additionally says this was a DRY RUN — … the SAME check would stop the --write run with this same exit code.
Asserted as the pair, same argv one flag apart, because a test on the dry run alone cannot tell "the gate now fires in dry-run" from "the gate fires for everything" — and with test_a_dry_run_over_a_HEALTHY_config_still_exits_zero as the negative control, so a green dry-run remains obtainable and nix/home.nix's instruction stays usable. That comment is updated to say the dry-run half is only a pre-flight since it learned to fail.
🟢 7 — empty-scope rc 4 · KEPT, documented at length
Chose the first option. Rationale, stated with the counter-argument in front of it in the module docstring: the exit code is the one channel a scripted caller reads without the prose, and no-rows (searched ZERO sections) versus no-match (searched N>0, found nothing) are precisely the two zeros this module exists to keep apart — collapsing them there re-creates the defect at the only layer where the loud rendering cannot help. The contract is stated as "no non-answer exits 0", with no exceptions, because a rule with one carve-out is a rule people forget.
The cost is acknowledged, not waved away: a set -e script running --repo devrc --section gotcha over a corpus with no gotchas now dies. A caller that wants the zero should branch on scope_reason (in the text and in --json), not on the exit code.
The exit-code ledger was restructured to make this checkable. STATUSES is now partitioned three ways — EXIT_CODES (status-keyed), ANSWER_STATUSES (0 by definition), REASON_KEYED_STATUSES — with exit_code_for() as the one reader and SCOPE_REASON_EXIT_CODES pinned two-way against SCOPE_REASONS. The partition test asserts coverage and pairwise disjointness: overlap would mean two rules claim one status and the winner is whichever branch is tested first. Unknown-key fallback points non-zero, the direction a caller notices.
Full ledger: 0 hit/no-match · 2 usage · 3 broken-index · 4 empty-scope (both reasons) · 6 unmeasured-corpus.
Also — the "136 passed" figure
It does not reproduce, and it is dropped rather than restated. Re-measured on this tree:
| scope | count |
|---|---|
test_handoff_index.py alone |
149 passed (was 126 before this commit — your 126 reproduces exactly) |
+ test_runtime_shebangs.py + test_git_mainline.py + test_handoff_doc.py |
562 passed |
Corpus figures got the same treatment. Re-measured over all four handles: 439 docs / 3,978 sections (devrc 96/992, homelab-talos 54/522, datapacket-talos 288/2450, civitai 1/14) — which also makes the brief's second-hand ~424 docs first-hand, and slightly low. The docstring now labels these a SCALE, not a value to check against: devrc read 94/968 earlier the same day and 959 before that, and this module has now carried three generations of the number.
Red → green matrix
Built by git archive HEAD of the pre-fix scripts/lib/ into a scratch tree, dropping in the new test file — so the modules are genuinely the ones the audit measured, not a reconstruction.
| tree | result |
|---|---|
pre-fix modules (36a1fdb1) + new tests |
29 failed, 119 passed |
fixed modules (0e4ea9fa) + new tests |
149 passed |
Findings 1, 2 and 4 specifically: each fails on the current code with an assertion naming the observed wrong behaviour, quoted per-finding above — not merely with an import error.
Mutation sweep — 16/16 guards KILLED
Every mutant got its own fresh tree, the old string was asserted to occur exactly once before replacement, __pycache__ was cleared, and the runner ran under PYTHONDONTWRITEBYTECODE=1.
| id | guard | verdict |
|---|---|---|
| G-A | rebuild_refusal refuses only when ALL are unmeasured |
KILLED |
| G-B | delete scope excludes UNMEASURED repos | KILLED |
| G-C | a full run collects repos dropped from the config | KILLED |
| G-D | a scoped run claims only what it was pointed at | KILLED |
| G-E | the delete carries a real predicate | KILLED |
| G-F | an unscoped rebuild raises | KILLED |
| G-G | DiskScan.complete — errors make a walk incomplete |
KILLED |
| G-H | the walk reports its errors (onerror=) |
KILLED |
| G-I | an incomplete walk blocks the all-clear | KILLED (see below) |
| G-J | a zero-path disk side blocks the all-clear | KILLED |
| G-K | the gates are evaluated in dry-run | KILLED |
| G-L | an unmeasurable corpus is not a broken index | KILLED |
| G-M | no repos is a usage error | KILLED |
| G-N | empty-scope is a non-answer |
KILLED |
| G-O | --json carries the warnings |
KILLED |
| G-P | a partial run says so | KILLED |
| NC | negative control — no-op replacement | GREEN (the tree is green without a mutation, so every KILLED above is a fact about a guard and not about a broken harness) |
| PC | positive control — slug_for stops stripping handoff- |
KILLED by 4 named parametrisations |
Mutations are isolated to the narrowest expression that can be wrong: G-A mutates the comparison (len(bad) == len(derivations) → len(bad) >= 1, i.e. exactly the old behaviour), not the enclosing if; G-I and G-J each leave the other two blocker arms intact.
🔴 The sweep found one real gap, and it is reported rather than smoothed over. G-I SURVIVED on the first pass. all_clear_blockers' incomplete-walk arm cannot change render_derivation's output at all: an incomplete walk always also emits an ⚠ UNSCANNABLE DISK warning (both read DiskScan.complete), and any warning sends the renderer down its if warnings: branch, which never consults the blockers. Through the text report the arm is a second copy of one predicate and is unkillable.
It is kept — because derivation_json publishes all_clear_blockers as its own machine-readable field, and a consumer asking "what stopped the all-clear" must get the complete answer from that list rather than parse prose out of warnings — and it is now reached directly as a contract pin on the pure function, with a hand-built derivation isolating the arm from the other two, plus a negative control on the same shape. Both the docstring and the test say plainly that this is a contract pin, not a regression test for a defect anyone observed in the rendered report.
Gate — all three legs, stated separately
| leg | verdict |
|---|---|
1. subset nix develop … -c python3 -m pytest <4 files> -q -p no:cacheprovider |
562 passed (test_handoff_index.py alone: 149 passed) |
2. nix develop … -c bash scripts/gate.sh --tier both |
GATE: RESULT=PASS exit=0 · pytest RESULT: PASS (exit=0), TOTAL collected=20508 passed=20505 skipped=3 failed=0 (floor: 18404 = sum of 30 per-target floors) · node RESULT: PASS (exit=0), TOTAL suites=5 files=41 tests=1449 pass=1449 fail=0 skipped=0 |
3a. nix build …#checks.x86_64-linux.pytests --no-link -L (alone) |
NIXBUILD_RC=0, runner's own devrc-pytests> RESULT: PASS (exit=0), devrc-pytests> TOTAL collected=20508 passed=20505 skipped=3 failed=0. 134 KB of log — not the cached case. |
3b. nix build …#checks.x86_64-linux.nodetests --no-link -L (alone) |
NIXBUILD_RC=0, runner's own devrc-nodetests> RESULT: PASS (exit=0), devrc-nodetests> TOTAL suites=5 files=41 tests=1449 pass=1449 fail=0. 437 KB of log — not cached. |
The two nix checks were built one at a time, never combined, and I waited out two sibling sessions' nix builds before starting 3a (PIDs resolved by exec name plus /proc/<pid>/cmdline; no -f pattern anywhere near a kill). A third sibling build started during 3b — that run came back green, and a green under contention is trustworthy even though a red would not be.
Verdicts read from the runners' own RESULT: lines, never a piped exit code. Note those lines carry a devrc-pytests> prefix, so an anchored ^RESULT: grep matches nothing.
🔴 tekton/devrc-pytests is RED on 0e4ea9fa, and it is the tracked #1213 flake — third time on this PR
tekton/devrc-nodetests is green. tekton/devrc-pytests reports:
FAILED: pytests — FAILING: TestAppendLands.test_a_bullet_is_appended_and_the_status_is_named
| TOTAL collected=20508 passed=20504 skipped=3
Recording the discriminating evidence rather than the conclusion:
| # | fact |
|---|---|
| 1 | The failing test is scripts/tests/test_cairn_write.py::TestAppendLands. git diff --name-only HEAD~1 HEAD returns nix/home.nix, scripts/README.md, scripts/lib/handoff_{index,search}.py, scripts/tests/test_handoff_index.py — my diff cannot reach it. |
| 2 | Same derivation, same tree, different environment: PASSES. Leg 3a above collected the identical 20508 and reported passed=20505 failed=0. Tekton's passed=20504 means exactly one test failed. So the failure is environment-dependent, not tree-dependent. |
| 3 | This exact test failed on this PR at 98429e80, and TestTheActorComesFromTheTOKEN in the sibling test_subsystem_store_api.py failed at d4b3472c — both before this commit existed. It also hit the docs-only PR #1216. |
| 4 | It is tracked as #1213 (store-api/cairn gate flake, measured 5-of-14 baseline). #1211 / 1a4350f3 reduced it and is already in this tree; round 1's own note says explicitly that this "does not say the store-api flake is fixed". |
So the leading reading is the known flake, not this change — but I am stating it as a ranking with its evidence, not a verdict, and I have not re-run Tekton to confirm: Tekton triggers on push, and a re-run needs a fresh commit, which I did not want to manufacture. The merge is genuinely blocked until that check is green (enforce_admins: true, both contexts required), so this is yours to re-trigger or to weigh against #1213.
gh pr view 1209 --json mergeable,mergeStateStatus → MERGEABLE / UNSTABLE — no conflicts; the instability is the check above.
Not done / not verified
- The live-Postgres path is still unexercised, and this round changed it:
TRUNCATE→ a parameterisedDELETE … WHERE repo = ANY(%s). What is pinned hermetically is the SQL text, the bound scope, and the statement/commit ordering through a recording connection. Nothing has confirmed Postgres accepts the statement, thatANY(%s)binds a Python list as psycopg2 is expected to, or that the delete and the inserts really are one transaction on a real server. A supervised--rebuild --writeis still the gate beforeenableHandoffIndexSyncis flipped, and it now has one more thing to watch than it did. - Not deployed. No
home-manager switchwas run; the unit still ships gated off. nix/home.nixwas syntax-checked only (nix-instantiate --parse), not switched.
|
Round 2 audit claims, for the round-3 delta to check against the diff. |
… — 62% of the corpus, every 6h, exit 0 Round-3 delta audit on #1209. Round 2 replaced an unpredicated TRUNCATE with a scoped `DELETE … WHERE repo = ANY(%s)`; the scope it chose created two 🔴. F1 — the PARTIAL-INDEX warning promised rows were preserved in the transaction that deleted them. `partial_scope_warnings` reasons over CONFIGURED labels; `rebuild_delete_labels` computed its extra "disappeared from config" set over STORED labels. A renamed checkout makes one repo both: the table holds `civitai`, `$CIVITAI` now yields `civitai-old` (UNMEASURED), so stored `civitai` reads as "not configured at all" and was COLLECTED — while the run printed "Their existing rows are left untouched (the rebuild delete is scoped to what MEASURED)". Measured at base: `rebuild_delete_labels` returned ('plimforth', 'zarfrepo'). F2 — the timer's own environment made it delete the majority of the corpus. The unit set only $DEVRC and $HOMELAB while a human --dry-run measures four repos, so an armed unscoped `--rebuild --write` classified datapacket-talos and civitai as disappeared-from-config: ~2,476 of ~4,008 sections deleted per 6h tick, exit 0, `## warnings: none` printed above it, no PARTIAL fired because from that environment nothing IS unmeasured. Measured at base through `main` over a recording connection: DELETE bound to ['marganserrepo','trundlerepo','zarfrepo'] for a run told about one. OPERATOR DECISION, implemented: a rebuild NEVER deletes rows for a repo it was not told about. The collection moved to an explicit `--prune` the timer does not pass; it requires --rebuild, is incompatible with --repo, and is REFUSED while any repo is UNMEASURED (which is what makes F1's two-spellings collision unreachable — it requires a configured-but-unmeasured label). Orphaned labels are now REPORTED on every write with the command that removes them, so not collecting them does not trade data loss for a silent stale corpus. Defence in depth: the unit derives its Environment from nix/agent-handles.nix — the same file programs/zsh and the opencode plugin read — so config and environment cannot silently disagree, and a test fails if the two sets diverge. Verified through the flake: the unit now carries all four REPO_ENV_HANDLES. F2 aggravator: the delete scope was computed only inside the --write branch, so --dry-run — the pre-flight home.nix documents — could not show it. With the collection behind --prune, a default rebuild's scope is a fact about the derivation alone, so dry-run prints `## rebuild delete scope`. --prune's extra set lives in the table; a dry-run says in those words that it cannot show it. F3 — `UNMEASURABLE CORPUS` counted FAILURES and called it ATTEMPTS: one repo resolving and one absent printed "all 1 repo(s) this run was pointed at failed to resolve, so no corpus was ever built" for a run pointed at 2. The status now requires len(unmeasured) == len(targets), so "all" is true by construction. F4 — Y3's defect survived in its sibling case: `--offline` over a repo that RESOLVES and holds zero handoff docs still rendered `🔴 BROKEN INDEX … Rebuild it (--rebuild --write) or check the handoff-index-sync unit`, rc 3, on a path that opens no database. New status `derived-zero-docs` (rc 7) naming neither. `broken-index` is now reachable only from a store that was READ. F5 — `handoff_paths_in_ref` folded a failed `git ls-tree` and a genuinely empty ref into one `()`. Survivable while it only moved a count; not once that MEASURED set decides a DELETE — the repo landed in scope, its rows were deleted, none were re-inserted, exit 0. It now returns None on git failure and `derive_repo` records `unmeasured="ls-tree-failed"`. Nits: `all_clear_blockers`' docstring described an aggregate arm in per-repo language (the aggregate is correct — per-repo would be permanently red for any repo without claudedocs/); `--json --write` emitted JSON then prose on stdout so json.loads(stdout) raised — prose now goes to stderr in --json mode. Tests: 26 new/updated assertions RED at 0e4ea9f, all 175 GREEN at HEAD. The F1 fixture was rewritten — the old one used `cantread`/`dropped-from-config` as disjoint identities, so it structurally could not produce the collision; the new values are pairwise distinct AND carry two spellings of one repo. Claude-Session-Id: 90310ba1-6dc2-4779-a616-2ccfe89457d7
…ndex-p1 Claude-Session-Id: 90310ba1-6dc2-4779-a616-2ccfe89457d7
…ifier makes the two counts equal The mutation sweep found one survivor: swapping the UNMEASURABLE CORPUS renderer's `len(outcome.targets)` back to `len(outcome.unmeasured)` — the exact F3 defect — stayed green across the whole suite. Not an equivalent mutant, and not a gap in the fix. `run_search` only emits `unmeasured-corpus` when `len(unmeasured) == len(targets)`, so EVERY fixture that reaches that branch through the classifier makes the two constants coincide. claude/RULES.md → a fixture that can only ever produce the constant's own value cannot see a mutant that reads a different field, and it SURVIVES a fully green suite. `render` is its own function with its own contract, so it is now reached directly with an outcome the classifier would never build — 3 repos pointed at, 2 unmeasured — where the two counts differ and only one is right. Re-swept: the mutant is KILLED, 22/22 real mutants dead, positive control killed, negative control (comment-only no-op) survived. Claude-Session-Id: 90310ba1-6dc2-4779-a616-2ccfe89457d7
Round-3 delta audit — dispositionAll five findings and both nits fixed in 🔴 F1 — the PARTIAL-INDEX warning promised rows it deleted — FIXED
Reproduced at base ( — the table's Fix — the collection is behind an explicit Fixture gap closed. The old test used Seam guard, not a component one —
🔴 F2 — the timer's environment made it delete 62% of the corpus — FIXEDReproduced at base through Operator decision implemented as specified. A rebuild never deletes rows for a repo it was not told about. Not silently left, either. Defence in depth, as asked.
Aggravator — dry-run could not show the delete scope. Half closed, half named. With the collection behind 🟡 F3 —
|
| gate | verdict |
|---|---|
1. subset (nix develop … pytest test_handoff_index.py test_handoff_audit.py test_handoff_doc.py test_resume_state_handoff_resolution.py -q -p no:cacheprovider) |
779 passed in 49.41s — and 176 passed for test_handoff_index.py alone on the clean committed tree |
2. scripts/gate.sh --tier both (dev-host tier) |
pytest → RESULT: PASS (exit=0), TOTAL collected=20590 passed=20587 skipped=3 failed=0 (floor: 18404)node → RESULT: PASS (exit=0), TOTAL suites=5 files=41 tests=1449 pass=1449 fail=0 (floor: 1367)GATE: RESULT=PASS exit=0 |
3a. nix build .#checks.x86_64-linux.pytests (sandbox tier) |
devrc-pytests> RESULT: all good / devrc-pytests> RESULT: PASS (exit=0), TOTAL collected=20590 passed=20587 skipped=3 failed=0; NIXPY_RC=0; 0 panic: test timed out, 0 error: |
3b. nix build .#checks.x86_64-linux.nodetests (sandbox tier) |
devrc-nodetests> RESULT: PASS (exit=0), TOTAL suites=5 files=41 tests=1449 pass=1449 fail=0; NIXNODE_RC=0; 8,834-line log, so not the cached-silent case |
Both nix checks were built one at a time, never in one invocation. /proc was scanned by exec name for a sibling nix build before starting — none was running.
Live verification (not only tests)
--repo … --rebuild --prune → rc 2 "--prune and --repo contradict each other…"
--prune (no --rebuild) → rc 2 "--prune only widens a --rebuild's delete…"
--rebuild --prune, one repo renamed → rc 4 "REFUSING --rebuild --prune: 1 of 2 repo(s) came back UNMEASURED…"
--rebuild (dry-run, this worktree) → rc 0, prints:
## rebuild delete scope
DELETE (measured, will be re-derived): devrc-handoff-search
KEPT (configured but UNMEASURED): (none)
No --prune, so a stored label this config does not name is REPORTED and kept,
never deleted. This list is the COMPLETE delete scope.
handoff_search --offline <resolves-empty> <absent> → rc 7
🔴 ZERO HANDOFF DOCS DERIVED — the 1 repo(s) this run read (hz-empty) …
Separately, 1 of the 2 repo(s) named did not resolve AT ALL: hz-absent … the ones listed above are fine.
The nix/home.nix change was verified by evaluating the real flake, not by parsing: nix eval .#homeConfigurations.zach.config.systemd.user.services.handoff-index-sync.Service.Environment returns all four REPO_ENV_HANDLES.
Stated plainly
- The Postgres path is still exercised by no test. The gate has no database; every claim about
PostgresSectionStoreis about code that has been read, not run. The delete's bound scope is asserted against a recording connection, which is what the F1/F2 evidence rests on — not against a server. --prunehas never been run against a real table. Its argv gating, its refusal and its scope arithmetic are tested; the DELETE it would issue has only ever been recorded, not executed.- Merged-tree caveat.
mainmoved twice during this work;106840e3mergesorigin/mainand the upstream commits touch none of the five files here (git log 45d143ca..origin/main -- <those paths>is empty). All three gates ran on that merged tree.maincan move again before merge. - The timer stays
enableHandoffIndexSync = false. Nothing here arms it.
Line references above are against 926b82ce (post-merge). The one 0e4ea9fa reference is explicitly labelled — it names where the defect was, not where the fix is.
|
Round 3 audit claims, for the round-4 delta to check against the diff. |
…ndex-p1 Claude-Session-Id: 90310ba1-6dc2-4779-a616-2ccfe89457d7
…— the same 62% delete, from the operator's shell (#1209 round 4) Round-4 delta audit, seven findings. The 🔴 one is `--prune`'s precondition being only half-checked. F1 🔴 `--prune` refused on an UNMEASURED repo and nothing else. An UNSET handle produces no derivation at all, so it can never BE unmeasured — the guard was structurally blind to it, and **the narrowest configs produce zero unmeasured repos, so it was weakest exactly where the risk is highest.** MEASURED end to end at 0906bbb: `$DEVRC`/`$HOMELAB` set, `$DATAPACKET`/ `$CIVITAI` unset (the ordinary state on a host without those checkouts — `nix/agent-handles.nix` existence-guards every handle), table holding all four labels ⇒ 2 repos derived, both measured, `bad == []`, no refusal, DELETE bound to `['civitairepo','datapacketrepo','devrcrepo','homelabrepo']`, rc 0. Same blast radius as the unit's two-handle `Environment`, relocated to the operator's own shell. New `prune_config_refusal` (pure) + `unset_repo_handles` refuse it by name; `main` runs the config-width guard before the read-failure one and the two messages open with different tokens so no assertion can confuse them. Every non-prune path is untouched, so the timer cannot go red. Aggravator, same change: the ORPHANED LABELS warning fired in exactly that state, named those repos, and offered `--rebuild --prune --write` as its FIRST remedy — the tool suggesting the destructive command over the fix that keeps the data. Remedies reordered (re-add the handle first), and "Nothing will ever refresh them" corrected: this run speaks for THIS host's config, and the index is shared. F2 🟡 After a successful `--prune`, the run reported the rows it had just DELETEd as "They are NOT deleted … Remove them deliberately with `--rebuild --prune --write`". `orphan_labels` read the PRE-write `stored` list; it now subtracts the run's BOUND DELETE SCOPE — the thing that actually happened, not an `args.prune` boolean that could drift from it. There was no end-to-end test of the successful prune path through `main` at all; there is now, plus its one-flag differential. F3 🟡 `scripts/README.md`'s `lib/handoff_search.py` row still said FOUR zeros and four exit codes; `derived-zero-docs`/rc 7 was missing. Corrected AND pinned two ways against `handoff_search.STATUSES`/`EXIT_CODES`. F4 🟢 The non-prune plan line promised "a stored label this config does not name is REPORTED and kept" — a report that needs `store.repos()`, which a dry-run never opens. `rebuild_plan_lines` now takes `write` and says what the run it describes can actually do. Same field fixes the nit where the `--prune` plan claimed "a --dry-run CANNOT show it" during `--write` runs. F5 🟢 Both round-4 survivors killed, and shown dying: * `rebuild_plan_lines`' `if scoped:` → `if False:` SURVIVED (176 passed) at base — a scoped run fell through to the no-prune branch and printed "REPORTED and kept", false twice over since `orphan_labels` returns () when scoped. Now DIED. * `orphan_label_warning`'s "only ever" → "ALWAYS" SURVIVED (176 passed) at base against three substring assertions. The sentence is now pinned as a WHOLE NORMALISED STRING. Now DIED. A cosmetic reword costs a test edit; that is the intended price of a machine-readable claim. F6 🟢 The unit/module handle guard checked `REPO_ENV_HANDLES − declared` only while `nix/home.nix` said it "fails if the two sets ever diverge" — and they DID diverge (nix declares five repo handles, the module reads four) with the suite green. WIDENED rather than merely re-described: the other direction is now pinned against an ENUMERATED ledger (`CIVITAI_CLI`, with its reason), so a new nix handle forces a decision instead of being silently excluded. The home.nix comment now states the asymmetry instead of implying symmetry. F7 🟢 REPRODUCED first, as asked — it does reproduce. Committing a handoff doc and deleting its blob from `.git/objects` leaves `git ls-tree` listing the path while `git show <ref>:<path>` fails: `derive_repo` returns `docs=0` with `unmeasured is None`, and rc 7 said the repos "resolved a mainline ref and hold no `claudedocs/handoff-*.md` in it" — sending the reader to WRITE a doc that is already committed. Same `None`-vs-`()` conflation as `handoff_paths_in_ref`, one function later. `RepoDerivation.unreadable` carries the paths, the rc-7 renderer splits on it, and both machine surfaces (`handoff_search --json`, `handoff_index --json`) carry the discriminator. Nits: the orphan report is "on every `--write` run", not "every run" (two comments + the README); `--prune --repo` without `--rebuild` now answers the `--repo` conflict — the one that survives adding `--rebuild` — instead of nudging toward the dangerous combination. Testing: every fix carries a regression test watched RED at 0906bbb and GREEN at HEAD (20 red at base, 200 green at HEAD). F6's widening is an INVARIANT GUARD, not a regression test — it is green at base by construction and is instead mutation-verified. 18-mutant battery, fresh tree per mutant, `PYTHONDONTWRITEBYTECODE=1`, no-op negative control SURVIVED + known-caught positive control DIED: 18/18 as expected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session-Id: 90310ba1-6dc2-4779-a616-2ccfe89457d7
Round-4 delta audit — all seven findings dispositionedBase for every RED measurement below:
F1 🔴 — the guard was weakest exactly where the risk was highest
Measured at At
Consequence, stated plainly: Aggravator, fixed in the same change. The ORPHANED LABELS warning fired in exactly that state, named those repos, and offered F2 🟡 — the report contradicted the transaction above itAt base, a successful …about the row it had just DELETEd. There was no end-to-end test of the successful prune path through F3 🟡 — pinned, because it was cheapRow corrected to five zeros / five exit codes with F4 🟢 / the plan-line nits
F5 🟢 — both survivors, shown surviving and then dying
The prose one is now pinned as the whole normalised string, not three substrings. A cosmetic reword fails the suite — the intended cost of a machine-readable claim. The scoped one is killed by asserting the scoped sentence PRESENT and the no-prune sentence ABSENT; either assertion alone is walkable. F6 🟢 — widened, not just re-describedThe two sets did diverge (nix declares five repo handles, the module reads four) while I widened the check rather than only fixing the sentence, because the two directions are not the same kind of fact and only one of them was uncheckable:
⚠ This one is an INVARIANT GUARD, not regression coverage — it is green at base by construction, and I am labelling it as such rather than counting it in the red→green matrix. It is mutation-verified instead (adding a sixth nix handle kills it). F7 🟢 — it reproducesThe auditor did not reproduce it; I did, before fixing. Commit a handoff doc, delete its blob from The repo holds one. Testing20 tests RED at Three of the new tests are deliberate controls that are green at base and stay green (the complete-config differential, the non-prune blast-radius control, the without-prune orphan differential); F6's widening is an invariant guard as noted. Neither is counted as regression coverage. Mutation battery — 18/18 as expected. Fresh tree per mutant, 🔴 A NEW finding I am NOT fixing here, stated plainlyReproducing F7 exposed a sibling data-loss path that F7 itself does not close, and it is the same class as the A repo whose mainline lists handoff docs that git cannot produce is classified MEASURED ( The structural fix is to make Closing condition: a PR that makes an all-docs-unreadable repo structurally UNMEASURED, with an end-to-end delete-scope test showing its rows survive a Gate — all four runs, on the MERGED tree (
|
|
Round 4 audit claims, for the round-5 delta to check against the diff. |
…ndex-p1 Claude-Session-Id: 90310ba1-6dc2-4779-a616-2ccfe89457d7
…ndex-p1 Claude-Session-Id: 90310ba1-6dc2-4779-a616-2ccfe89457d7
…it prints in — four were false, and the plan block described a run that never ran (#1209 round 5) Two SHAPE sweeps, not four point fixes. SWEEP 1 — operator guidance that names a command without checking the state that command requires. Grepped every remedy/next-step sentence the two modules emit and checked each against the state it is printed in. 4 of 14 were wrong; all 4 fixed, the other 10 hold. 1. `prune_config_refusal` offered "Set every handle and re-run --prune from a host that has all the checkouts — or drop --prune". For the case an operator most often runs --prune for — a RETIRED repo — NEITHER exists: `nix/agent-handles.nix` existence-guards every handle on its directory, so a deleted checkout is an unset handle on EVERY host, and dropping --prune un-blocks the rebuild while leaving the exact rows the operator came to remove. The route that works — drop the handle from agent-handles.nix AND REPO_ENV_HANDLES, ship, then prune — was named nowhere. Now named. 2. `orphan_label_warning` recommended `--rebuild --prune --write` for a repo "genuinely gone", the one state its own parenthetical rules out. Same bug printed in two places; same fix. 3. `rebuild_refusal`'s ALL-unmeasured arm offered "re-run without --rebuild to refresh what CAN be measured" in the one state where nothing can be. MEASURED, same argv minus --rebuild: rc 0, `wrote 0 section row(s)`, store opened — the silent success the sentence above it gives as the reason to refuse. 4. `handoff_search`'s NO MATCH told every reader to "widen --repo / --section", including the common unfiltered case where there is no filter to widen. `outcome.filtered` was already read two lines above. SWEEP 2 — sentences written for the wrong run. `rebuild_plan_lines` is handed `args.write` (the INTENT) and its branches describe an OUTCOME, and `main` printed the block ABOVE every gate. MEASURED, both rc 4 with the store never opened: `--rebuild --prune --write` with one handle unset printed "this run opens the table, and the full bound scope is printed with the row count below" (recorded SQL log `[]`); `--rebuild --write` all-unmeasured printed "is REPORTED (see ORPHANED LABELS below) and kept" above a run that emitted no such section. A third branch had the same falsity mode via a collision or zero-row derivation. Fixed by ORDERING — the block now prints only after every gate passes — chosen over threading a "this run will actually write" boolean, because that boolean IS "no gate fired": a second spelling of the gate's own verdict, free to drift from it (the argument `orphan_labels` already makes for taking the BOUND SCOPE rather than an `args.prune` flag). Ordering also makes the two sentences that speak about a DIFFERENT run sound, since the gates are mode-identical. Two unpinned claims, both mutants the audit found SURVIVING a 200-test suite, both now killed by their own specific test: * the guard ORDER `main` claims (`prune_config_refusal` before `rebuild_refusal`) — the existing test built the two states SEPARATELY, and order is unobservable in a state where only one guard can fire. The COMBINED state (a handle UNSET *and* a set handle pointing at a bad path) is now built and pinned. No safety consequence either way; which DIAGNOSIS the operator reads first is the stake. * every W7 fixture had exactly ONE unreadable doc, so `d.unreadable[:1]` is the identity and survived. Two repos with 3 and 4 unreadable docs — every number an assertion names pairwise distinct and distinct from 1 and from the repo count. RECORDED, NOT FIXED (its own round): a repo that MEASURES and whose every doc is UNREADABLE has its rows deleted and zero re-inserted at rc 0. MEASURED on two repos, one healthy: `DELETE ['plimforthrepo','zarfrepo']`, two INSERTs both for zarfrepo, rc 0. `RepoDerivation.unreadable` is the discriminator and sits one function away. The note is in `rebuild_delete_labels`, in the tree, because a gap that lives only in a review thread stops being read. Prose fixes are pinned as WHOLE NORMALISED STRINGS (`_EXPECTED_ORPHAN_WARNING`, new `_EXPECTED_PRUNE_CONFIG_REFUSAL`) — a cosmetic reword now fails the suite, which is the intended cost. The retired-repo remedy also has a BEHAVIOURAL proof, not just words: three runs over one fixture showing that only the config change unblocks the prune, while both old remedies stay dead ends. Red->green: 10 new/updated tests fail on pre-change library code at the same base; 217 pass after (was 200). Mutation sweep: 12 mutants, all killed, with a no-op negative control (survived) and a known-caught positive control (killed). Claude-Session-Id: 90310ba1-6dc2-4779-a616-2ccfe89457d7
Round 5 — per-finding dispositionCommit Both reported 🟡s turned out to be one shape each, so both were swept rather than point-fixed. 🟡-1 — a remedy sentence is a claim about the state it is printed in — FIXED (4 instances)Swept every remedy/next-step sentence the two modules emit — 14 of them. 4 were wrong; the other 10 hold.
The route that actually works for a retired repo — drop the handle from 🟡-2 — the plan sentences were written for the wrong run — FIXED
A third branch had the same falsity mode: Chose ordering over a "will actually write" boolean, and the reason is the one this file already argues for Operator-visible consequence, documented in 🟢-1 — the guard order is now pinned — FIXEDThe existing test built the two states separately, and order is unobservable where only one guard can fire. The combined state (a handle UNSET and a set handle pointing at a bad path) is now built. No safety consequence either way — both refuse at rc 4; the stake is which diagnosis the operator reads first. 🟢-2 — N>1 unreadable docs are now measured — FIXEDEvery W7 fixture had exactly one unreadable doc, so EvidenceRed → green. 10 new/updated tests fail against the pre-change library at the same base and pass after ( The rest are not regression tests and are not claimed as such: the guard-order and N>1 tests pin behaviour that was already correct — their evidence is the mutant, not a red baseline. Same for the behavioural retired-repo test and the two positive controls. The two previously-surviving mutants, now dying — each with its own guard's failure, not a bystander's: Mutation sweep — fresh tree per mutant, The harness refused two ambiguous patterns ( Gate — merged tree, base
Sibling agents were running their own Still open — not fixed here
|
…a code failure (#1244) `tekton/devrc-pytests` fails on `test_cairn_write.py::TestAppendLands:: test_a_bullet_is_appended_and_the_status_is_named` on PRs whose diff cannot reach it. The gate prints AssertionError: 🔴 cairn: the write did NOT happen — ... unreachable: timed out assert 7 == 0 which is a sentence about the write CLIENT describing what was a disk stall. MECHANISM, read off the CI log rather than inferred by analogy. On `devrc-ci-jfg67` the store root is `/tmp/nix-build-devrc-pytests.drv-0/…/popen-gw1/…` — the step container's ephemeral layer — and the client reports `timed out`, not a refused connection. `server.py:_replace_bytes` fsyncs the file and then the parent directory INSIDE the request, before the response is written; `run_cairn` passes `--timeout 5`; so one fsync slower than five seconds is a refusal at exit 7. That bound is TWELVE TIMES tighter than the store-api half's `HANG_TIMEOUT` of 60.0, which is why this file is the more frequent casualty of the same node contention. 🔴 The store-api root cause was NOT assumed to transfer. `test_cairn_write.py` imports `http.server` and stands up its own loopback servers, imports no store-api `server.py`, and carried no `MECHANISM` classifier — all verified before relying on any of it. WHAT THIS CHANGES: the message, and nothing else. * `scripts/testlib/hang_mechanism.py` (new) — a `MECHANISM =` verdict from the live thread stacks, the shape `test_subsystem_store_api.py` already had. Under the reproduction the failure now reads `MECHANISM = SERVER_BLOCKED_IN_FSYNC (handler threads=2 [...=BLOCKED_ELSEWHERE ...=SERVER_BLOCKED_IN_FSYNC], accept loop parked=True)` plus the store's filesystem. * 🔴 DIAGNOSIS, NOT TOLERANCE. No bound moved, nothing retries, no test was made to pass. A gate that reports a code failure for an I/O stall trains everyone to click through, and that was the whole cost. * 🔴 The headline is deliberately NOT a consensus of the handler threads. Two servers are live here — the store and the shim in front of it — so on a timeout they legitimately disagree (shim in `urlopen`, store in `fsync`). Measured. A rule requiring agreement would answer AMBIGUOUS for the textbook case. * 🔴 It scans frames' SOURCE LINES, never their FILENAME — the known, unfixed defect `_HUNG_SERVER_RULES` carries, where a worktree named `devrc-fsync` misclassifies every hang. `test_subsystem_store_api.py` was NOT rewired onto the shared module; it has its own tests and that is its own edit. Two copies today, known debt. * `scripts/ci-repro/slow_cairn_fsync.py` (new) — stalls `os.fsync` in the test process. Not `LD_PRELOAD`: the server is in-process while the client is a subprocess, so preloading would stall both sides and muddy which timed out. MEASURED on `origin/main` at 946a51f, store on tmpfs for BOTH runs (i.e. with `store_siting`'s mitigation fully in force — this is a LATENCY dependency, not a filesystem one): control (inert) 4 passed in 3.69s, intercepted_fsyncs=8 armed SLOW_CAIRN_FSYNC_S=8 4 failed in 28.95s, text identical to CI `intercepted_fsyncs=8` is the positive control: the append path really does issue the two `_replace_bytes` fsyncs per write. Both selftest arms watched to fire (inert-run abort; zero-fsync abort on a `2 passed` selection). Mutation matrix on the new classifier — every mutant killed, each by the right test: filename folded back into the scanned text -> path test, with its own message `fsync` rule removed from RULES -> 5 tests consensus rule (disagreement => no verdict) -> headline test classify always answers fsync -> 3 tests, incl. the pull-apart outermost frame wins -> innermost test NOT FIXED HERE, and neither is claimed: the stall itself is node-local device contention, whose levers are infra and ranked in the ci-repro README. The disk-siting half was already fixed by b4fde33 (#1219). ⚠ A PR branched before that commit still carries the old disk-backed fixture — branch protection sets `strict: false` and never rebases — which is why #1209 and #1233 were still failing. Claude-Session-Id: 90310ba1-6dc2-4779-a616-2ccfe89457d7 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s, an untracked doc rescued, an (#1264) Claude-Session-Id: 90310ba1-6dc2-4779-a616-2ccfe89457d7
… a probe is about to be re-run (#1295) `scripts/lib/handoff_search.py` shipped in #1209 and NOTHING called it: `git grep handoff_search` found the module, its sibling writer, a README row and a shebang test. An index with no reader is a cost with no benefit, and it fails silently — every `/resume` went on re-deriving findings the corpus already held. WHERE, AND WHY THIS SITE `/resume` step 3, immediately beside the existing per-item pre-flight (`git -C <repo> log --since=<doc-date>`), not step 4's `cairn recall` block. * The value is the `Ruled out:` bullets, and they only discriminate against a QUESTION. At orientation time you have a topic slug, which mostly retrieves the doc you are already reading. At step 3 you have the open item in hand, so the cross-doc hits are the ones that stop a re-run. * Step 4 is a different corpus (the subsystem-index store), not this one. * The adjacent command already carries exactly this rhetoric — "the cost is one command; the cost of skipping it is a whole session". NOT the `/handoff` "Run this first" template: `test_handoff_skill_size.py` leaves ~291 B under its enforced floor, so the line would have cost an eviction of an instruction in the same commit — and a template only reaches docs written AFTER it, while this tool's whole value is retrieval over the 379 docs that already exist. NOT a wrapper script either: the CLI is already the right shape and already renders the banner, the `indexed_docs=` scope line and the five zero-causes; a wrapper would have to re-render or swallow them, and swallowing is the failure the tool was built to prevent. `--offline` deliberately: it answers from git refs with no database. The Postgres path has never been executed by any test and the sync unit ships disarmed (`enableHandoffIndexSync = false`), so wiring the consumer to it would have made the step a connection error. WHAT THE GUARD PINS (scripts/tests/test_resume_handoff_search_wiring.py) Three claims, each DERIVED from the tool rather than restated: * the command as a WHOLE NORMALISED STRING — an edit dropping `--offline` goes red instead of silently starting to require a database; * the SEAM — every flag the skill prescribes is probed behaviourally against `handoff_search.main()`, so a rename in the tool breaks the test rather than leaving the skill prescribing a dead command; * the silent-zero contract — the non-answer exit codes are read out of `EXIT_CODES` / `SCOPE_REASON_EXIT_CODES`, so carving out a sixth zero goes red until `/resume` is taught about it. Every contract assertion runs against a slice delimited by two pinned sentinels, so a hit elsewhere in the 40 KB body cannot satisfy it. RED -> GREEN, base d86b4e4: 3 failed / 3 passed before the skill edit; 6 passed after. MUTATION SWEEP — fresh minimal repo mirror per mutant, PYTHONDONTWRITEBYTECODE=1, 7 mutants, all killed by the SPECIFIC guard whose claim each violates: M1 skill drops --offline -> the shape pin M2 skill drops exit code 7 -> the derived ledger M3 skill drops POINTER TO VERIFY -> the posture check M4 whole block deleted -> 3 guards, incl. the sentinel M5 tool renames --offline -> the seam probe M6 tool adds a 6th non-answer code -> the derived ledger M7 tool stops printing indexed_docs= -> the posture check Controls: an unmutated mirror and a whitespace-only skill edit both green. The first build of that harness reported 8 kills off a RED no-op control — the mirror omitted a transitive import (`handoff_doc`), so every "kill" was a fact about the harness. Recorded because that is the whole reason the control exists. BYTES: claude/skills/resume/SKILL.md 40,581 -> 41,852 (+1,271). That file has no enforced ceiling (`git grep -l MIN_HEADROOM_BYTES scripts/`). ~40% of the delta is the four-code non-answer ledger, which is the honesty half of the wiring: a consumer that prints the hits and drops the distinction hands the reader "the corpus is silent" when the truth was "the index is empty". Claude-Session-Id: 90310ba1-6dc2-4779-a616-2ccfe89457d7 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
What this is
P1 of a handoff-doc full-text search index. Two new modules plus a
serverMode-gated systemd unit.There is no server-side query over handoff-doc bodies today, and that is the gap this closes:
scripts/initiatives/sync.pystores derived metadata only —summaryis one parsed line,current_docis a PATH,search_textis session prompts. Not one byte of a doc body reaches Postgres.scripts/initiatives/viewer.pylive-reads bodies off disk (read_doc_detail_live, 512 KB cap) and its search is client-side JS over what the page already shipped.So the corpus is reachable only by a human who already knows which doc to open, and
/resumeand subagents re-derive findings that are already written down.Scope is P1 only. No HTTP service, no network binding, no firewall change, no pgvector/embeddings.
Design
The corpus comes from git refs, not the working tree
Measured:
git worktree listin devrc alone returns 143 entries. A working-tree scan therefore indexes a doc as it exists mid-edit in somebody's branch, the same doc N times over N worktrees, and stale orphan copies — and two runs an hour apart disagree for reasons unrelated to what anyone wrote.Source is
git ls-tree+git showagainst each repo's derived mainline, via the existingscripts/lib/git_mainline.py. Never hardcoded. Verified end to end on both repos this host holds:origin/mainorigin/trunkA hardcoded
mainwould have indexed nothing for the second — which is the exact failuregit_mainline.pyexists to prevent.⚠ The brief's wider figure of ~424 docs / 8.6 MB over four repos (adding two client checkouts) is second-hand and was not re-derived here; the table above is what I ran.
An untracked doc is reported, never indexed
A handoff doc on disk and absent from the mainline ref is a durability hole — one
git checkoutby a concurrent session from silent deletion. Indexing it would let the search surface answer from the hole and thereby conceal it. The one that exists today reproduces:Sections, not docs
goal | state | investigation | next_step | gotcha | verify, with## Open investigationssplit per###sub-block and## Next stepssplit per ranked item.Rationale: a "Ruled out" paragraph is the highest-value content in this corpus and is invisible at doc granularity — a few hundred bytes inside a 40 KB document, which
ts_rankdrowns. The retrieval unit should be the size of the finding.The parser is borrowed, not reimplemented
Every heading/fence/item rule comes from
scripts/lib/handoff_doc.py— the writer of these documents and therefore the executable authority on their shape:split_front_matter(the closed-----at-line-1 rule),split_sections(fence-aware H2 walk),canonical_prefix,_item_blocks(a ranked item is a block, not a line — 179 of 257 devrc items wrap),_FORCING,_unfenced.Two private names are imported deliberately; that is cheaper than re-spelling them, and
handoff_doc's own docstrings record three separate occasions where a second copy of one of these rules drifted. A test pins this module's per-item(rank, forcing_kind)sequence againsthandoff_doc.ranked_itemsso the two can never disagree about one document.Two guards on the read surface
Recall banner on every response, in
subsystem_recall.py's wording and posture (same agent reads both surfaces in one session; two spellings of one provenance claim is how a caveat stops being read): results are POINTERS TO VERIFY, nothing was re-derived, a hit may describe a gotcha that has since been fixed.Silent-zero guard. Every response carries the literal
indexed_docs=N indexed_sections=M, and the two zeros render with sentences that share no opening phrase:statusishit/no-match/broken-index— values sharing no spelling — andbroken-indexexits non-zero, because an empty index is a broken environment, not a reading.Derived and disposable
--rebuildtruncates and re-derives. Git stays the system of record; nothing is stored that a re-run cannot rebuild.Controls I ran
Mutation sweep, under
PYTHONDONTWRITEBYTECODE=1(a same-length edit in the same second is invisible to CPython's mtime-seconds+size cache and would score a live mutant as SURVIVED). Eacholdstring was checked to occur exactly once before replacing. 9/9 killed, each by its own guard's specific assertion:split_front_matter(text)→fm = texttest_an_unterminated_block_is_not_front_matter(+1)assert 'cg-9999' is Nonekind if kind in FORCING_KINDS else None→kind or Nonetest_an_unrecognised_kind_folds_to_none_not_to_itself(+3)assert 'sprocket' is Nonetest_untracked_docs_is_a_one_way_difference(+1)assert ('…handoff-c.md',) == ('…handoff-a.md',)indexed_docs == 0→< 0test_an_empty_index_is_broken_not_a_no_match(+3)assert 'no-match' == 'broken-index'SECTION_BOOST.get(...)→DEFAULT_BOOSTtest_investigation_and_gotcha_outrank_a_plain_section…assert ['goal','gotcha','investigation'] == ['investigation','gotcha','goal']THEN {boost}→THEN 1.0test_the_boost_numbers_live_in_exactly_one_placeassert "WHEN 'investigation' THEN 2.0" in "…THEN 1.0…""findings"fromPREFIX_SECTIONtest_every_canonical_prefix_has_a_section_and_vice_versahandoff_doc.CANONICAL_HEADING_PREFIXESgit show ref:path→ read from disktest_the_corpus_comes_from_the_ref_not_the_working_treeassert 'quixotry' in 'The grumbleflitch edit.'slug_forstops strippinghandoff-assert 'handoff-widget-relay' == 'widget-relay'Each mutant was checked for reachability, not just breakability: G4 mutates the narrowest expression that can be wrong (the comparison operator, not the whole arm), and G5's fixture gives three sections identical token coverage so only the multiplier can order them.
Positive / negative control pair, through the same code path and the same store:
zarfwidget(in exactly one fixture doc) returns exactly that doc withindexed_docs=2;hexapoddery(in none) returns 0 withindexed_docs=2unchanged. The pair is what makes either readable — a reassuring zero alone is indistinguishable from a harness wired to nothing.Fixture hygiene: every distinctive term is an invented nonsense word occurring in exactly one place, pairwise distinct and distinct from any constant an assertion names, so a mutant hardcoding a literal cannot survive by accident.
Gate — all three legs, stated separately
nix develop … -c python3 -m pytest test_handoff_index.py -q)66 passedscripts/gate.sh(dev-host tier)GATE: RESULT=PASS exit=0— pytestRESULT: PASS (exit=0),TOTAL collected=20211 passed=20208 skipped=3 failed=0; nodeRESULT: PASS (exit=0),tests=1449 pass=1449nix build …#checks.x86_64-linux.pytests(alone)NIXBUILD_RC=0, runner's own lineRESULT: PASS (exit=0),TOTAL collected=20211 passed=20208 skipped=3 failed=0nix build …#checks.x86_64-linux.nodetests(alone)NIXBUILD_RC=0, runner's own lineRESULT: PASS (exit=0),tests=1449 pass=1449 fail=0The two nix checks were built one at a time, never combined. Verdicts are read from the runners' own
RESULT:lines, not a piped exit code.The first gate run was RED, and it was a real finding.
test_git_mainline.py::test_the_LEDGER_of_importers_is_pinned_both_waysfailed:git_mainlinekeeps a two-way-pinned ledger of its importers, andhandoff_index.pyarrived as a third without being enumerated. That seam guard did exactly its job; the ledger is updated with the reason. (An earlier run also failed onlogrotatemissing from PATH — a missing environment, not a code failure; the gate says so itself and names the fix, which is to run inside the repo's own dev shell.)🔴 What I could NOT verify
The live-Postgres path is not exercised by anything. The authoritative gate runs in a nix sandbox with no cluster and no database, so for
PostgresSectionStore:GENERATED ALWAYS AS (to_tsvector('english', …)) STOREDcolumn is legal as written;tsvhas never been computed and the GIN index never built;ts_rankhas never ordered anything — the ranking claims in the docstrings are about code that has been read, not run;ON CONFLICTidentity has never been exercised against a real unique index.What is pinned hermetically: the SQL text the class builds, that the boost numbers live in exactly one table shared by both backends, the row shape against the declared columns, and every caller path above it. That is a claim about the query construction, not about the database.
Consequently the timer ships gated off (
enableHandoffIndexSync = false), matchinginitiatives-sync's original posture for exactly this reason: a routineship.shmust not be able to silently arm an unvalidated prod-write timer that creates a new schema object in a prod database. Flip it after a supervised--dry-runthen--rebuildhas been watched to work.Not deployed. No
home-manager switchwas run. The unit exists innix/home.nixand has never been started.Ranking quality is unmeasured. The
investigation ×2.0 / gotcha ×1.75weights are a display preference in one named table a reader can argue with, not a measurement over labelled queries.subsystem_recall.pyhas a labelled fixture corpus for its scorer; this does not, and building one is P2 work.The two backends rank differently and that is stated, not smoothed over. Postgres uses
ts_rankover an english-stemmed tsvector;--offlinecounts distinct query tokens. They share the boost table and the recency tiebreak and nothing else, so an offline result must never be read as a prediction of the indexed one. Every response printsbackend=.On PR #1064 (
feat/handoff-audit)Read first, as briefed. It does not fit, and nothing is duplicated.
scripts/handoff-audit.pyglobsclaudedocs/handoff-*.mdoff disk — the working-tree source this module exists to avoid — and its parser isskill-audit.py's byte/heading walk aimed at budget measurement, not at retrieval units. The overlap is the corpus, not the reader. It borrows skill-audit's walk; this borrowshandoff_doc's, which is the stronger authority for this purpose because it is the writer of the documents. Recorded in the module docstring so the next reader does not re-litigate it.Files
scripts/lib/handoff_index.pyscripts/lib/handoff_search.pyscripts/tests/test_handoff_index.pynix/home.nixscripts/tests/test_git_mainline.pyscripts/README.mdTotal 2,284 insertions, 1 deletion. The two
lib/modules are comment-heavy by this repo's convention — the reasoning that would otherwise be lost lives beside the code.