feat(analyze-service): version the recon index + a deterministic autocommit - #361
Open
ZacxDev wants to merge 3 commits into
Open
feat(analyze-service): version the recon index + a deterministic autocommit#361ZacxDev wants to merge 3 commits into
ZacxDev wants to merge 3 commits into
Conversation
ZacxDev
added a commit
that referenced
this pull request
Aug 7, 2026
…ry, and an unmarked client-sensitive file read as publishable (#362) * fix(analyze-service): an alias could silently shadow a real index entry, and an unmarked client-sensitive file read as publishable Four surgical repairs to the `/analyze-service` index schema, from an experiment that hand-authored a `process` entry and an `org` entry in the current schema to find where it breaks. The larger "generalize into subsystems" proposal was REJECTED on that evidence — no `type:`-driven sections, no dependency graph, no CLI. Spec/prose only; no code, no behaviour outside the command. 1. Addressing — the shadowing bug. Two defects were conflated. A slug can name two different KINDS of thing (`devrc/repo-cos` is both a code subsystem and the weekly ritual about it), and the old resolver matched filename *and* `aliases:` in one breath — so an alias silently won and a later real entry became unreachable, with no error. Now: an optional `<slug>.<kind>.md` filename used ONLY when disambiguation is needed; a two-tier resolver where an alias can never outrank a filename match; and >1 hit in a tier is a hard ambiguity ERROR that lists candidates instead of picking one. Bare `<slug>.md` resolves exactly as before — backward compatibility is stated as a hard requirement, and none of the 20 existing files is renamed or resolves differently. Also folds `_` -> `-` during normalization (the old rule kept `_`, so `image_ingestion` != `image-ingestion` while the index links `_`-spelled MEMORY.md slugs), keeping the pre-fold spelling in `aliases:`. 2. Sensitivity marking. New `sensitivity:` front-matter field, `client-confidential | personal | public`, with a FAIL-SAFE default: absent or unrecognized means client-confidential, never public. Not hypothetical — live entries carry a client bastion's public IP and SSH port, client hostnames, and a named client engineer. The no-remote / never-copy-into-a- public-repo rule is written down alongside it, pointing at 60e6d9d, which exists because this exact data class had to be scrubbed retroactively. Marking only; enforcement lands separately. 3. Front-matter repair. `repo:` -> `scope:`, and a scope may be a non-repo word (a ritual is owned by no repo; a client spans several). `<repo-slug>` derivation stays the default so nothing breaks; older files carrying `repo:` still read as `scope`. `namespace:` becomes OPTIONAL rather than removed — genuinely load-bearing for k8s infra, meaningless for the non-infra entries that were writing `n/a`. 4. Process-liveness convention. "Is this still being followed?" is THE question for a process subsystem, but the anti-bloat rule forbids persisting live status — correctly, for infra, where a kubectl is two seconds away. For a ritual there is no live probe. Convention: persist the DERIVATION METHOD and the stale-tell, never the current reading. Written as an application of the existing rule, not an exception to it. Byte budget: 12,460 -> 15,358 (+2,898). Offsetting evictions in the same commit — the recon-start index-read paragraph was stated twice nearly verbatim (kept the operational copy in step 1), the "Always re-derived live" tags on Lives at/Config duplicated the Provenance-honesty line below them, and the "Pointers, not copies" bullet restated the schema it sits under. Verification: the public-IP and client-hostname gates pass on this file, and were negative-controlled — injecting a routable IP into it makes test_no_public_ips fail with this exact path, so the green is a real read of this file rather than a scan that misses it. The full pytest suite fails identically (same 26, same names) at this branch and at the untouched base checkout, so nothing here is a regression; those 26 are ad-hoc-nix-shell toolchain gaps, not code. Scope note: deliberately does NOT touch scripts/analyze-service-index/, nix/home.nix or scripts/claude-hooks/ — PR #361 owns those and this file is not in its diff, so the two do not overlap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(analyze-service): the store is a git repo now — "there is no commit step" invited destructive git into an irreplaceable, unbacked-up store `claude/commands/analyze-service.md` is the protocol an agent reads *before* writing to `~/.claude/analyze-service-index/`. That store is now one git repo per scope (verified: `<scope>/` is a repo on `trunk`, no remote; the store root is not a repo). Three statements in the file predated that. Two were reworded earlier in this PR; the dangerous one survived: - "The write is local and final; there is no commit/worktree step (the file is outside every repo)." + "The write is local and final — nothing leaves the machine: no remote, no push. But it is not outside git; committing the scope repo is the store's own concern (an out-of-band autocommit), never this command's, so write the file and run no git command (Store safety)." An agent told "outside every repo" has no reason to check for one before reaching for the stash stack, a hard reset, a clean, or a checkout-- inside a store that is curated, irreplaceable, unbacked-up and client-sensitive. `refs/stash` is repo-global and concurrent sessions share it. Per RULES.md, a comment is a claim too — a false safety comment is what leads a maintainer to delete the guard it describes. Also fixed, same staleness class, found by sweeping the rest of the file: - The earlier rewording ("outside every repo you work in" / "never lands inside a cluster repo or devrc") was true but still only said where the store ISN'T, so it left the same inference intact. The Location bullet now states positively that each scope is its own remote-less git repo. - "Lazy — nothing pre-created. ~/.claude/analyze-service-index/ may not exist" — the store root exists today; scoped to "a scope dir or service file may not exist yet". Adds a Store safety block mirroring the scope README's wording (the four destructive git operations, no remote, no push, no copying into a public repo) so the two agree rather than drift. Wording is deliberately true both before and after the autocommit timer lands — that timer is NOT deployed yet (`analyze-service-index-commit.timer` does not exist on this host), so the file asserts a design, not a deployed state. Byte discipline: this file loads on every run and had grown +2,898 (+23%) in this PR. Net this round: 15,358 -> 15,349 (-9). Paid for by evicting the "NOT notable" list (a verbatim restatement of "Bloat discipline" — one rule, one place), folding the sensitivity bullet's handling rules into the new Store safety block, and compressing prose without dropping a rule. Nix-managed: takes effect only after `home-manager switch`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…commit `~/.claude/analyze-service-index/` is the write-back store of /analyze-service. Measured 2026-08-06 on the workbench: 20 files, 56,862 bytes, mtimes running through that day — with NO git history, NO backup, and NO host sync (ship.sh rsyncs only ~/.claude/skills/). Nothing in any .nix referenced it. The content is curated and hand-confirmed: gotchas, incident tie-ins and pointers that were true at a moment in time, so it is NOT re-derivable by re-running recon. One bad agent Write destroyed it silently and permanently. The store itself is now a git repo (done out-of-band; local history only, no remote — see below). This commit is the part that keeps it that way. WHY A TIMER, NOT A LINE IN THE WRITE-BACK PROTOCOL -------------------------------------------------- The store is written by an agent's Write tool mid-recon, so no git operation happens naturally. The obvious alternative — appending "then commit" to analyze-service.md's write-back protocol — is the exact mechanism already MEASURED to fail here: claude/skills/close-the-loop/STATE.md records opt-in prose steps not sticking, which is why that work pivoted to autonomous loops. A backup that depends on an agent remembering is not a backup. PRINCIPLES.md: prefer the deterministic fix. The prose step also only fires on the write-back path, and would silently cover none of the hand edits, moves or deletions. ONE REPO PER SCOPE ------------------ The store root is deliberately NOT a repo; each `<scope>/` under it is its own independent repository, so a future scope can carry its own policy. A store-root repo would silently absorb every scope added later. The committer therefore walks scope directories and commits each independently — one failing scope does not cost the others their backup, but it does fail the run. 🔴 NO REMOTE, NO PUSH, NO NETWORK --------------------------------- The scopes hold client-identifying infrastructure detail (public IPs and ports for client hosts, internal hostnames, a named client engineer). The script never adds a remote, never pushes and never fetches, and a test asserts no such call site exists AND that a run with a remote configured still touches nothing on the far side. cf. 60e6d9d, which exists because this class of data already had to be scrubbed retroactively out of this public repo. Nothing was scrubbed or redacted here — that is a separate decision. DESIGN NOTES ------------ * Staging is explicit: the union of on-disk `*.md` and already-tracked paths, passed as literal pathspecs. Never `git add -A`/`--all`/`.` (RULES.md). That one call also covers deletions — `git add <deleted-tracked-path>` stages the removal (measured), so no `-u` and no `-A` anywhere. * Because staging is a filtered allowlist, the tree is asserted CLEAN after committing and the run FAILS if it is not. An unexpected file becomes a failed unit (and a notify-failure@ toast) rather than being blind-staged or silently left behind. * Exit codes are checked separately from output everywhere: an empty `git status --porcelain` is also what a FAILED status call produces, and an empty result cannot distinguish the two mechanisms (RULES.md). * Self-bootstrapping — it initialises a scope that has no repo and seeds a git identity when none resolves, because the laptop's store has never been initialised and a systemd unit cannot answer "please tell me who you are". * NOT gated on serverMode, deliberately, and pinned by a test. The gated units beside it need the homelab kubeconfig or a server role; this needs only a local disk and git. It follows claude-log-rotate, the other ~/.claude maintenance unit that runs everywhere. Gating it would leave the laptop's store unversioned forever while the workbench looked healthy — the exact gap being closed. VERIFIED -------- * 38 new tests, zero skips. `nix build .#checks.x86_64-linux.pytests`: 6545 collected / 6544 passed / 1 skipped (the pre-existing pinned skip) / 0 failed. nodetests green too. * Mutation swept, 15 mutations. 14 killed on the first pass; ONE SURVIVED — the post-commit clean assertion, because every case that left a stray file also staged nothing, so the earlier guard always won and the later one never executed (RULES.md → unreachable guards). Added a case that reaches it, then re-swept with three differently-constructed mutants of that guard; all three now killed. * Ran as a REAL systemd --user oneshot with the nix-generated PATH: committed a dirty store, committed a modification, FAILED loudly with .git/index.lock held (negative control), recovered on the next run, and was a clean no-op against the live store. The generated unit + timer were read out of the built home-manager generation; `systemd-analyze calendar` confirms the schedule. MIN_TESTS 5600 -> 5638 (+38). No new HERMETIC_TARGETS entry needed — scripts/tests is already a directory target. The comment also records that the floor has drifted ~900 below the real total again; correcting that is a separate change. NOT verified live: the timer firing on its own schedule (read from the generated unit, not waited out), and the real store's dirty-COMMIT path — the live store was exercised only on the clean no-op path, deliberately, rather than fabricating content inside curated client data. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…list An adversarial audit built its mutants differently from the author's sweep and 11 of 27 survived the 38 tests. Reproduced independently here with a third, differently-constructed sweep (whole-block substitution, obfuscation, and exfiltration mutants across all three surfaces): 14 of 26 valid mutants survived the pre-fix tree. 🔴 RETRACTED — this commit's original subject and BLOCKING entry claimed the timer "had no Install.WantedBy … the timer NEVER FIRED — the whole feature was a no-op". That is FALSE and the message has been rewritten to remove it. VERIFIED: `git show fc1eede:nix/home.nix` already contains `Install = { WantedBy = [ "timers.target" ]; };`, and the entire nix/home.nix delta between fc1eede and this commit is 14 lines, ALL of them comments (11 insertions / 3 deletions, one comment block about the daily cadence). The true and much weaker claim is the one below. SIGNIFICANT * The Install block had NO TEST. Deleting it passed all 38 tests, so the timer's enablement was an unguarded invariant — a surviving mutant, not a live defect. Now pinned by test_the_timer_is_actually_installed_into_timers_target, and verified at the artifact level: `xdg.configFile` contains systemd/user/timers.target.wants/analyze-service-index-commit.timer, and that entry disappears when the block is deleted. * The no-exfiltration guard enumerated five forbidden git subcommands. It is now an ASSERTED LEDGER — the set of git subcommands the script invokes must EQUAL a named set, failing when it grows *or* shrinks, plus a variable-supplied subcommand is a violation on its own. `git archive`, `git bundle` and `_s=pu"sh"; git $_s origin …` had all walked past the blocklist. Backed by a behavioural test that snapshots a sandboxed HOME and asserts a run writes nothing outside $STORE. ⚠ A plain `cp -r` of a scope is NOT caught by this and never can be — it invokes no git at all. The follow-up commit says so and adds the control that does stop it. * test_a_configured_remote_is_never_pushed_to was a vacuous zero: it pointed origin at a nonexistent path, so neither assertion could ever move. Measured: it killed ZERO mutants, including a literal `git push origin trunk`. The far side is now a real bare repo, with a positive control proving a push there does register. * `2>&1` folded stderr into the dirty-state predicate. Reproduced: an unreadable subdirectory makes `git status` exit 0 while warning, so a CLEAN tree read as dirty, failed the unit naming the wrong cause, inflated n_changed, and embedded the warning in the commit message. stdout and stderr are now captured separately; the warning is surfaced, never treated as data. * The post-commit guard misdiagnosed the one race the design guarantees. A benign .md write landing between `git add` and `git commit` produced rc=1 and "Something is not covered by the *.md allowlist" about a file that IS covered. The leftover set is now partitioned: covered-or-tracked paths mean the tree was re-dirtied (retried once, then a warning), and rc=1 is reserved for genuinely uncovered paths. * MIN_TESTS was 5638 against a real total of 6545 — 907 of slack, more than the entire 783-test initiatives suite could vanish through. Raised to the measured total. (Re-measured again in the follow-up commit, on the MERGED tree — this number was taken on a branch two commits behind main.) * The serverMode pin grepped for the token `mkIf`, which `= lib.optionalAttrs serverMode {` sails past while producing exactly the gating the test forbids. Now asserted positively: `= {` with nothing between. MINOR * The "no candidate paths" guard had zero coverage (reachable, now covered). * Scope enumeration was neither newline- nor symlink-safe and failed SILENTLY (exit 0, "nothing to do") — the exact outcome this work exists to prevent. A newline in a scope name split it into two phantom scopes; a symlinked scope was not enumerated at all. Both left content unversioned while reporting success. Now NUL-delimited, and find's rc is checked in the *.md probe. ⚠ The `-L` this added to the CANDIDATE walk is a live regression; the follow-up commit reverts it to `-H` and explains why. * `--print-plan` was honoured only as $1, so `commit.sh <STORE> --print-plan` silently COMMITTED (measured: it initialised a repo and wrote a commit). Real argument loop; unknown options and extra operands are now errors. * `desc` was a loop global with no `*)` default, so an unexpected state reused the previous scope's description. Reset per iteration, default added. * Newly covered: the README.md exemption, `git init -b`, the commit.gpgsign pin (including a behavioural case with signing forced on globally), and TimeoutStartSec. * The daily-cadence rationale was stated wider than it holds — content created AND destroyed between two 03:30 runs is unrecoverable, which is the motivating scenario. Scoped to what it actually buys. Also fixed a self-inflicted two-tier defect caught by the authoritative gate: _race_hook wrote its own `#!/usr/bin/env bash`, which works on the dev host and does not exist in the nix sandbox. It now uses testlib.mockbin.write_exec, the repo's one-place rule for this (test_runtime_shebangs.py enforces it). Tests 38 -> 59 in this file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…g it — the guard was bypassable without editing the script
Third fix round. Rounds 1 and 2 each tried to make exfiltration *visible* to a
static reading of commit.sh, and each was evaded a new way; round 2 also shipped
a live regression. So this round changes the control rather than adding a fourth
layer of patterns.
🔴 THE HOLE THAT NEEDED NO EDIT TO commit.sh AT ALL
Reproduced end to end (git 2.55.0), before fixing: a global
`[core] hooksPath = <dir>` holding a post-commit hook copied a scope's
client-identifying content clean out of the store, while the script printed
"committed <sha>", "ok — 1 scope(s) processed" and exited 0. `init.templateDir`
is the same attack one level earlier — it bakes the hook into the repository this
script itself bootstraps. No static ledger could ever have seen either: the
payload was in ~/.gitconfig.
Fixed WITHOUT enumerating dangerous keys (that is the mistake the ledger
already made twice): GIT_CONFIG_NOSYSTEM=1 plus GIT_CONFIG_GLOBAL=/dev/null
removes the entire ambient surface, every key. Repo-local `.git/hooks` — which
an errant agent Write can plant, squarely in this unit's threat model — is
closed by pinning core.hooksPath at an empty directory via GIT_CONFIG_COUNT
(measured to override even a repo-local setting).
🔴 CONTAINMENT IS NOW THE PRIMARY CONTROL
The unit gains ProtectSystem=strict, ProtectHome=tmpfs, BindPaths (the store,
the ONE writable path), BindReadOnlyPaths (the script), PrivateTmp,
PrivateNetwork and NoNewPrivileges. VERIFIED LIVE on the workbench AND the
laptop, running the committer under the EXACT directives and PATH home-manager
renders, against a sandbox store, results read from the journal:
* the committer works normally (two scopes, one with a newline in its name)
and its commits persist; a second run is a clean no-op;
* `cp -r <scope> <dir>` → "Read-only file system", rc=1, nothing lands
(uncontained positive control: rc=0, 27 files copied);
* bash's BUILTIN `/dev/tcp` egress → "Network is unreachable", rc=1, against
rc=0 uncontained. No PATH restriction can reach that one — it needs no binary.
⚠ Stated plainly in the code because it reads like a control and is not: the
unit PATH is NOT containment. `cp` and `tee` are in pkgs.coreutils, which this
script genuinely needs, so no honest PATH removes them. What makes `cp` useless
is having nowhere to write.
REQUIRED FIXES
* `find -L` in list_candidates was a LIVE REGRESSION from round 2. It descends
symlinked subdirectories and emits paths beyond them; git refuses those
("pathspec 'linkdir/inner.md' is beyond a symbolic link"), so `git add` exited
128. MEASURED: byte-identical failure on the next run — it never self-heals —
with `ls-files` EMPTY, i.e. the scope's real index files left unversioned for
as long as the symlink existed. `-L` is now on list_scopes ONLY (where
-maxdepth 1 makes it safe); the candidate walk and the *.md probe use `-H`,
which is all the symlinked-SCOPE case ever needed. Both are covered, and the
probe/walk seam is covered too — mutating the probe alone moved nothing.
* find's rc in list_candidates was discarded by a pipe into `sort -zu`.
Enumeration now goes to files (which also stops `$(…)` eating the NUL
framing). An incomplete enumeration is RECORDED, not aborted on: everything
readable is still committed — refusing outright would let a bad `sub/` block
`alpha.md` from ever being backed up — and the scope then fails loudly.
* `_CMD_CHARS` gains `{` and `\`.
* The wrapper class is closed at the root, not by listing wrappers. An
unplaceable `git` used to `continue`; MEASURED, that single line made `env`,
`timeout`, `nohup`, `command`, `eval "…"`, `xargs`, `sudo` and `{` all reach
`git push` with the ledger reporting nothing. It is now a violation unless
pinned in an exact prose ledger — which is 3 lines, because commit.sh's
fourteen "git <verb> failed" strings were consolidated into one helper.
* The remote test only exercised the dirty path; a clean-path variant is added.
* Nothing asserted ExecStart is the only Exec* directive — a one-line
`ExecStartPost = rsync …` exfiltrated the whole store daily with no test
moving. The Service block's directive key set is now pinned EXACTLY.
* A `*.md` pattern in a scope .gitignore made the unit permanently red. Now
pre-filtered through `git check-ignore` and reported as its own named error,
index untouched. (The "half-staged index" also reported did NOT reproduce:
`git add` validates every pathspec before staging any of it. Not claimed.)
* Two false claims corrected. commit.sh's "an allowlist has no such blind spot"
was false — `cp -r` invokes no git and is invisible to it, permanently; the
comment now says so and points at the control that does stop it. The previous
commit's "the timer NEVER FIRED" claim is retracted in its own message.
* MIN_TESTS re-measured on the MERGED tree: 6595 (was 6566, taken on a branch
two commits behind main — #363 added 13 tests, this branch adds 16). Floor is
live: raised to 99999 it fails with its own message, "only 6595 tests were
collected".
ALSO
* The exit-0 race branch is pinned — rc=0 AND the specific warning AND a new
greppable ASI-RACE-UNSETTLED marker, so a chronically-racing scope is not
invisible behind a zero exit.
* test_an_identity_is_seeded_when_git_cannot_resolve_one was green in the
sandbox and red on a host with a system gitconfig. Fixed in the PRODUCT, not
the fixture: the script ignores system and global config, so the test now
passes no config environment at all.
* The unit PATH pin covers gnugrep/gnused, both of which the script uses.
* `_race_hook` could no longer work (hooks are now disabled by design) and
would have gone green measuring nothing. Replaced by a PATH `git` shim, which
is also the more honest stand-in — the race is a concurrent WRITER, not a hook.
* test_a_run_writes_nothing_outside_the_store now sandboxes TMPDIR: a sweep
mutant writing to ${TMPDIR}/leak was NOT caught, because the snapshot only
ever covered its own sandbox. Its remaining limit (a hardcoded absolute path
is not enumerable) is now stated rather than implied.
A systemd .path unit was evaluated and deliberately NOT implemented — see the
report: scope directories are runtime data, there is no glob form of
PathModified/PathChanged, and a watch on the store root does not see writes one
level down.
Mutation sweep, third construction (single-atom edits + additive insertions,
across BOTH surfaces — previous rounds used whole-block substitution and
predicate inversion): 37 mutants, 35 killed each by ITS OWN test, verified by
failing-test name. Both harness controls hold: a no-op mutant survives (it can
report survival) and a hardcoded-path exfil mutant survives BY DESIGN, which is
the residual the containment closes and the static tier cannot.
Tests 59 -> 75 in this file.
nix build .#checks.x86_64-linux.pytests: collected=6595 passed=6594 skipped=1
failed=0, floor 6595, RESULT: PASS. nodetests: tests=1024 pass=1024, RESULT: PASS.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ZacxDev
force-pushed
the
feat/analyze-service-index-autocommit
branch
from
August 7, 2026 05:45
51cc025 to
a5e1f98
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
~/.claude/analyze-service-index/— the write-back store of/analyze-service— was 57 KB of curated, unversioned, unbacked-up nuance on a single disk with no history. Measured on the workbench 2026-08-06: 20 files, 56,862 bytes, mtimes running through that day. Not a git repo, not referenced by any.nix, and not synced between hosts (ship.shrsyncs only~/.claude/skills/).It is not re-derivable by re-running recon — it records gotchas, incident tie-ins and pointers that were true at a moment in time. One bad agent
Writedestroyed it silently and permanently.What landed
The store itself is now versioned (done out-of-band, not in this diff — it lives outside every repo):
cmpper blob against the baseline commit, with a positive control provingcmpcan see a differencec18ca64— pristine import, staged as 20 explicit pathspecsThis diff is the part that keeps it that way:
scripts/analyze-service-index/commit.shplus asystemd --userservice+timer innix/home.nix.Timer vs. a line in the write-back protocol
I evaluated appending a commit step to
analyze-service.md:83and rejected it:claude/skills/close-the-loop/STATE.mdrecords opt-in prose steps not sticking, which is why that work pivoted to autonomous loops. PRINCIPLES.md: prefer the deterministic fix.The timer costs one unit and covers every writer unconditionally. Daily is adequate rather than lazy: with no remote, a commit only buys recovery from a bad write, and a same-day clobber is still recoverable from the previous day's commit.
🔴 No remote, no push, no network
The scopes hold client-identifying infrastructure detail — public IPs and ports for client hosts, internal hostnames, a named client engineer. So:
60e6d9d, which exists because this class of data already had to be scrubbed retroactively out of this public repo;serverMode: deliberately NOT gated
The units beside it (
mail-actions-archive,initiatives-sync,ch-regrowth-check) are gated because they need the homelab kubeconfig or a server role. This needs only a local disk and git, so it followsclaude-log-rotate— the other~/.claudemaintenance unit that runs everywhere. Gating it would leave the laptop's store unversioned forever while the workbench looked healthy, which is the exact silent gap being closed. Pinned by a test so it cannot be tidied into themkIf serverModeblock next to it.Safety properties
*.mdand already-tracked paths, as literal pathspecs. Never-A/--all/.. That one call also covers deletions (git add <deleted-tracked-path>stages the removal — measured), so no-uanywhere either.notify-failure@toast.git status --porcelainis also what a failed status call produces; an empty result cannot distinguish the two mechanisms.git rev-parsewalks up, and without this it would commit client-sensitive content into whatever encloses the store.Verification
Gates.
nix build .#checks.x86_64-linux.pytests→ 6545 collected / 6544 passed / 1 skipped / 0 failed (the 1 skip is the pre-existing pinned one; my 38 tests add zero skips, asEXPECTED_SKIPSis an exact set).nodetestsgreen.Mutation sweep — 15 mutations, and one genuinely survived. Disabling the post-commit clean assertion killed no test: every case that left a stray file also staged nothing, so the earlier "nothing staged" guard always fired and the later one never executed (RULES.md → unreachable guards). Added a case that reaches it — a real
.mdcommit plus a surviving non-.mdfile — then re-swept with three differently-constructed mutants of that guard (never taken / warns-but-returns-0 / filename dropped from the message). All three killed. The other 14 were killed by their intended test.Live, as a real systemd unit. Ran via
systemd-run --useras aType=oneshotwith the PATH nix generated (not my login PATH), script sha256 confirmed identical to the nix store copy:.git/index.lockheldfailed—git add failed (rc=128), and the change was not silently droppedGenerated
.service/.timerread out of the built home-manager generation; timer confirmed wired intotimers.target.wants;systemd-analyze calendarconfirms the schedule.Not verified live, stated plainly: the timer firing on its own schedule (read from the generated unit, not waited out), and the real store's dirty-commit path — the live store was exercised only on the clean no-op path, deliberately, rather than fabricating content inside curated client data.
Notes
MIN_TESTS5600 → 5638 (+38). No newHERMETIC_TARGETSentry needed —scripts/testsis already a directory target, so the file is collected by the existing one.ship.shnot run.🤖 Generated with Claude Code