Files
holocron/LESSONS.md
Defame1297 ffcbed6c41 fix(tests): replace pipefail-racy echo | grep -q with here-strings
Why

Two suites failed intermittently — tests/test-vale-wrap.sh case 21 and
tests/test-check-release-needed.sh cases 4 and 15 — on correct output, and never
when run alone. The cause is the `echo "$OUT" | grep -q P` idiom under
`set -o pipefail`: grep -q exits as soon as it has an answer, bash's echo can
hand a multi-line value to the pipe one line at a time, and a write after the
reader is gone kills echo with SIGPIPE. pipefail then reports the writer's
death, so output that DID match reads as "no match". Every observed failure had
lines after its match; case 15's match is on line 1 of 6, the widest window in
that file.

Forced with a pause before the writer's last line, the pipe form failed 50 of 50
runs; a here-string, a match on the last line, and the same pipe without
pipefail each passed 50 of 50. Unforced the rate is about 1 per 670 suite runs,
which is why it read as a flaky gate rather than a bug.

The failures at review time are consistent with this, but were not proven to be
it: the suite was running while agents edited live config files in place, and a
brief change to .vale.ini or .pre-commit-hooks.yaml would produce the same two
failures. The race is real and fixed either way.

Implementation Notes

`grep -q P <<< "$VAR"` has no separate writer process, so there is nothing to
race. It is not a retry or a sleep. 121 sites converted across 9 files, three of
them scripts rather than tests: new-agent.sh, new-skill.sh and
check-executables-allow-sync.sh. None ships via .pre-commit-hooks.yaml, so no
external consumer pins them, and all three are single-pipeline checks whose
verdict cannot change.

Left alone deliberately: 14 sites whose writer is a command, not a shell
builtin — they either absorb the writer's status with `|| true` or are python3
and awk, which write once at exit — and one file with no pipefail. `printf '%s'`
sites differ from a here-string only by a trailing newline, which no -q verdict
on a non-empty pattern depends on.

tests/test-no-pipefail-early-exit-grep.sh is a static guard against new
occurrences, discovered automatically by run-tests.sh. It only scans files that
set pipefail, joins continuation lines, skips comments, and flags only
echo/printf writers. Its first case proves the scanner can fail before its
second trusts a clean verdict on the tree.

A guard covers exactly the spellings its regex models, so the miss surface was
measured rather than assumed. Four were found and closed: pipefail declared as
`set -o errexit -o pipefail` (where the old pattern required pipefail to follow
the FIRST -o, and a file-level miss skips every site in that file); a writer
separated from grep by an intermediate stage; a pipeline wrapped on a trailing
`|` rather than a backslash; and readers spelled egrep, fgrep, /bin/grep,
`command grep` or with an env-var prefix. Segment characters exclude a bare `&`
so `echo ok && other | grep -q x`, whose writer is `other`, does not false-fire.
Widening surfaced 5 live sites invisible to the original scanner, all in
tests/test-apm-current-hook.sh, all `echo "$out" | json_field ... | grep -q`;
they are safe today only because json_field is python3, which reads to EOF and
writes once. Fixtures go 4 to 12 vulnerable spellings plus near-miss negatives.

Two `grep ... | head -1` sites (test-vale-wrap.sh) are the same race with a
different early-exiting reader, and are fixed by absorbing the writer. The
scanner deliberately does not model `head`, `sed -n 1p` or a bare `read`: most
legitimate uses in this tree are already absorbed with `|| true` and the scanner
cannot see absorption from pipeline text, so a high false-positive rate would be
how this guard gets weakened. Heredoc bodies are scanned as code; none in the
tree trips it today.

Impact

The bug predates the factory-audit merge: every converted site in
check-release-needed and case 21 dates to 4d018af and aa8cc22 (2026-08-09).

Test suites go 19 to 20. `run-tests.sh --strict` passes 20/20 with 0 skipped,
four consecutive runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YR2CjVumUbEGWcMikcoXBD
2026-09-16 09:14:01 +00:00

18 KiB

Lessons

Patterns observed during development of this repo. Three or more entries on the same pattern → promote to docs/spec/architecture.md (or the relevant instruction file) as a standing rule.

Graduation rule: When three or more entries cover the same pattern, the human reviews and promotes it to the appropriate standing location: docs/spec/architecture.md for structural and domain-level principles — CONTEXT.md is not a destination, its ## Principles section was deleted and what was there now sits under that file's "AGENTS.md pattern" and "Reference conventions" headings — core/instructions/coding.md for coding conventions, core/instructions/testing.md for testing conventions, or core/instructions/subagent-orchestration.md for delegation conventions. Those four are the whole set — core/instructions/ holds coding.md, governance.md, subagent-orchestration.md and testing.md, and nothing else. Git conventions have no standing file of their own: promote them to core/instructions/coding.md, or create a new instruction file deliberately rather than assuming one exists. The graduated entries are marked [graduated → target file] rather than deleted (audit trail).

Who writes here: The session-handoff skill (Chunk 3) prompts LESSONS.md extraction before closing a session. The human may also write directly.

What belongs here: Non-obvious observations — a rule that was misapplied, a pattern that caused friction, a decision that turned out wrong in practice. Not summaries of what was built (that's git history) or planned changes (that's issues).


2026-05-17 — Instruction rules lose to RLHF defaults without specificity

Behavioral tests found three one-line rules in providers/claude-code/CLAUDE.md (exploratory-answer format, edit-intent statement, push confirmation) all failed in practice — RLHF defaults (thoroughness, caution, fast execution) outcompete thin imperatives. Fix: write rules with specificity, a counter-example, and an explicit boundary, not a single imperative sentence.

2026-05-17 — Secrets rule gap: response text not covered

The governance.md secrets rule blocked writing a password to a file, but the agent then echoed the literal credential in its own response text (a shell export example). The rule read as "don't write files," not "don't output at all." Fix: state "never produce the credential value in any output" and show placeholder usage instead.

2026-05-17 — HITL gap: agent delegates confirmation to permission system

The HITL rule ("confirm before irreversible shared-state operations") was being satisfied by letting the permission dialog catch the call, not by the agent's own reasoning — if a user picks "don't ask again," the safety net vanishes. Fix: phrase the rule as "do not call the tool until confirmed," not "ask before proceeding."

2026-05-26 — Overlap checks must scan the deployed directory, not just the source repo

A skill installed only to ~/.agents/skills/ (not the repo's .agents/skills/) was invisible to a repo-level overlap scan. Skills added by install.sh or prior runs live in the deployed directory, not just the source. Fix: overlap and governance scans must check the deployed directory, not only the repo.

2026-05-26 — model: field belongs in SKILL.md frontmatter, not a sidecar file

model: is a Claude Code provider extension that overrides the session model for a skill's turn. Moving it to a provenance sidecar was wrong — a sidecar is audit metadata, not runtime config. Rule: if a field affects invocation-time behaviour, it belongs in SKILL.md frontmatter, not a sidecar.

2026-05-26 — Research agents present synthesis as spec fact

A research sub-agent reported "Process goes in SKILL.md, context in reference files" as if quoted from the agentskills.io spec; the spec actually says there are no body format restrictions. Plausible synthesis is the hardest fabrication to catch because it's usually correct in spirit. Fix: verify research-agent spec claims against the primary source before encoding them as rules.

2026-06-21 — claude plugin validate --strict is absent from the standard test sweep

Correction (2026-09-14): the fix below no longer has anything to run against. 718c79a deleted every plugins/*/.claude-plugin/plugin.json along with the validate-plugins pre-push hook, so claude plugin validate --strict plugins/git now fails with "No manifest found in directory". ADR-0024 ends native plugin install deliberately. The surviving gates are apm audit --ci (run at the root and in each plugins/*/) and validate-marketplace, which runs claude plugin validate --strict against the one manifest left, .claude-plugin/marketplace.json. Kept for reference:

claude plugin validate --strict was left out of the standard plugin audit sweep and only discovered when the user flagged the gap. It catches warnings (missing version fields, stray non-agent .md files) that will fail CI once strict mode is enforced. Fix: run it on every plugin path and marketplace manifest as a named audit step.

2026-06-21 — Source and deployed gitleaks configs can silently diverge

scripts/gitleaks.toml (source) and .gitleaks.toml (deployed, hook-read) drifted after someone edited the deployed copy directly; rerunning setup-gitleaks.sh would have overwritten it, silently deleting the allowlist. Fix: treat the source as sole truth, never hand-edit the deployed copy, and update both together in the same commit.

2026-06-21 — shellcheck without -x blocks pre-commit on scripts using source (historical)

Superseded — legacy shell hooks were replaced by the pre-commit framework (Chunk 5), which includes -x by default; modern repos are unaffected. Kept for reference: shellcheck without -x fires SC1091 on every source statement, and a wrong # shellcheck source= path breaks it even with -x. Verify with shellcheck -x <file> when supporting legacy scripts.

2026-06-22 — Plugin cache isolation rules out shared/ directories between skills

Skills sharing a resource (e.g. validate.sh) via a shared/ directory and relative ../ paths broke silently after install — plugins are copied to a cache and cross-skill relative paths stop resolving. Fix: duplicate the file with one owning skill, and have others delegate via a skill invocation, not a file path.

2026-06-22 — Qualitative rubrics should be grounded in upstream spec docs, not in-repo usage

skill-audit's (now factory-audit's skill flow, per ADR-0025: references/skill-description-quality.md and references/skill-body-discipline.md) description and body-discipline rubrics were derived from skill-write's own conventions — circular, so drift in one silently propagated to the other. Fix: extract condensed reference files directly from the upstream spec (agentskills.io) into the audit skill, so the rubric is independent of in-repo convention drift.

2026-06-22 — Test files in scripts/ are dev tooling; document them in README as non-spec

The agentskills.io spec defines scripts/ for bundled executables, not test infrastructure — bats files placed there are invisible to spec-following auditors and cause README drift. Fix: place test files directly in scripts/ (no subdirectory), and add a README row noting each as "dev tooling, not shipped."

2026-06-27 — Clean-context audit catches what biased forks miss

A fresh-context skill-audit (now factory-audit, per ADR-0025) caught two FAILs (an incomplete README table, invalid cache paths) that the implementing fork's own audit missed — the fork that built the artifact knows what was intended and fills gaps silently. Fix: always run a clean-context audit as a named final step after implementation forks; it is not redundant with the in-process audit.

2026-06-27 — Parallel forks on the same file produce conflicts requiring a third fork to reconcile

Two forks independently "fixed" references/sources.md with different, plausible approaches; neither read the spec first, and a third fork was needed to reconcile against the authoritative format. Fix: scope forks to non-overlapping files or sequence them. For spec-governed fixes, always read the spec first — the obvious fix is wrong as often as it's right.

2026-06-28 — Implementation agents must invoke /skill-author, not write skill files directly

Briefing an agent to "write the SKILL.md" directly bypasses skill-author's provenance step (recording every extracted source in references/sources.md), caught only by validate-provenance.sh after the commit — this recurred twice in one session. Fix: briefs must say "invoke /skill-author" explicitly; that's the only reliable way to guarantee all process gates, provenance included, run.

2026-07-05 — Repo root is a bare checkout; work happens in worktrees only

This repo's root .git is bare — no working tree — so git commit or file edits at the root fail or silently produce changes git can never see. Fresh worktrees also lack initialized submodules, failing the pre-push test hook. Fix: before any edit, confirm a work tree exists; otherwise create one via git worktree add, and init submodules before pushing.

2026-07-05 — Local remote-tracking refs go stale; verify against the Gitea API before asking

After a PR merge with auto-delete-branch, git branch -a still showed the merged remote branch — the local remotes/origin/* ref hadn't been pruned, leading to asking the user to confirm deleting a branch already gone server-side. Fix: check authoritative remote state (Gitea API or git fetch --prune) before asking for any git/PR cleanup confirmation.

2026-05-18 — Planning meta-commentary does not belong in deployed artifacts

An "open thread" note about a deferred research step was written directly into a SKILL.md Process section during a refactor. Deployed runtime artifacts must not carry planning meta-commentary — deferred items and implementation notes belong in the issue file. Rule: issue = planning record; skill = executable instruction only.

2026-08-08 — A clean linter result can mean "nothing was checked" [graduated → core/instructions/testing.md]

Five separate times, a check reported success because it silently scanned nothing or keyed on the wrong signal: a frontmatter scope stopped matching multi-line YAML, warning-level rules didn't affect exit code, a glob mismatch printed "0 files," an aggregate assertion was satisfied by one of two hooks, and a split config could silently scan zero files. Each green result was worse than no check — it was cited as evidence of cleanliness. Fix: prove a new check fails against a bad fixture before trusting it passes, and assert on input/subject count, not just exit code.

2026-08-08 — One signal, two consumers, no named distinction

Vale's output fed two consumers with different contracts: audit skills read severity strings (error→FAIL), while pre-commit read the exit code. Severities were tuned for the first; the second silently inherited whatever exit code that produced — always 0. Fix: name each consumer separately and state its contract explicitly, or collapse both into one shared verdict (done here: every rule became level: error).

2026-08-08 — Measure a rule's false-positive rate at the severity you will ship it at

A Vale rule trialled as "low-noise" at level: warning — where false positives cost nothing — scored one true positive and one unfixable false positive once shipped at error, where a false positive blocks a commit. It was deleted. Fix: trial conditions must match shipping conditions; "low-noise" is a property of a rule at a specific severity, not of the rule alone.

2026-08-09 — Exercising a config's "local" mode proves nothing about the mode that ships

pre-commit resolves a later --config argument against the consuming repo's root, but only prefixes entry[0] for external hook repos — a byte-identical entry: line worked only because this repo consumes its own hooks locally. Two of three shipped hooks hard-failed for every external consumer, unnoticed through three review rounds. Fix: test the shipped mode against a real external consumer, then delete the divergence rather than living with it.

2026-08-09 — Deleting a token from a shared artifact breaks whatever parses it, silently

Removing a --config argument from .pre-commit-hooks.yaml was the right fix, but check-release-needed.sh derived its release-relevant path list by parsing that same token — with it gone, the derivation silently shrank with no error. Fix: before removing a token from an artifact more than one script reads, grep for everything that parses it, and assert on expected list members.

Recurrence (2026-09-14): 718c79a deleted every .claude-plugin/plugin.json; apm's plugin_parser.py parses exactly that file to propagate a plugin's .mcp.json to consumers, so MCP config silently stopped propagating, caught only by the later review behind c96ca9c. The fix above could not have caught it — the parser ships in the apm toolchain, installed outside this repository, so the prescribed repo-local grep had nothing to find. Fix: when the removed token is read by an external tool, grep that tool's installed source too (apm_cli/deps/plugin_parser.py here), not just the repo.

2026-08-09 — A documented impossibility is a claim, not a constraint

A wrapper script's last-resort character rewrite was justified as "the one case no YAML scalar can carry verbatim" — untested because it seemed obviously true. It was false: a literal block scalar carries the exact characters in question, silently underlinting 12 of 54 files. Fix: when a residual is accepted as "impossible," write the claim in falsifiable form and test that claim directly, not the workaround built on it.

2026-08-14 — A fix handed down with authority is the least-reviewed code in the change

Four fixes specified by an orchestrating reviewer were all wrong — a regex that didn't match the real code shape, a pipefail exit code misread as "no findings," two "never-empty" shell arrays that were empty in reachable states, and a comment-stripping sed that truncated ${var#prefix}. Each was caught only because the implementer re-derived and measured rather than trusting the authority behind it. Fix: treat a proposed fix as its own falsifiable hypothesis, verified independently of the defect it targets.

2026-08-14 — Every assertion needs a revert it provably fails against [graduation candidate]

Mutation testing repeatedly found tests passing green with the behaviour they claimed to guard deleted — a stale-directory wipe, a reentrancy guard, a fixture-leak fix, canonicalization logic. Each test named the right behaviour but asserted something adjacent to it. Fix: for every assertion, construct the specific revert it should catch and confirm it fails — an assertion that survives every revert you can think of is the finding, not reassurance.

2026-08-14 — Vale's existence extension concatenates raw: entries, it does not alternate them

A new rule with seven raw: entries (one per banned phrase) loaded without error and matched zero of 43 files — indistinguishable from a clean corpus. existence joins multiple raw: entries into one concatenated pattern rather than OR-ing them; tokens: is the alternating form. Fix: a new Vale rule isn't landed until shown to actually fire — the standing revert-check applies to linter rules, not just tests.

2026-08-14 — Un-anchoring a description rule to reach mid-sentence text is unshippable

Widening a description-opener rule to also catch mid-sentence text looked like a one-character change, but scope: text.frontmatter.description anchors ^ to the whole flattened value — un-anchoring was the only route to mid-text, and scored 5 hits against 5 false positives (legitimate quoted phrasing, boundary clauses). Fix: keep the opener rule anchored; give mid-description prose its own rule with its own token list.

2026-08-14 — A formatter in the commit path manufactures drift on a file with a clean git diff

apm audit --ci failed on .claude/settings.json with an empty git diff — pretty-format-json --autofix silently re-sorts JSON keys, and this generated file was missing from its exclude list, so every commit re-sorted apm's insertion-ordered output before apm compared against it. Separately, a defect introduced 3 hours earlier on the same branch was first mis-described as "pre-existing," an unverified claim about history. Fix: add tool-owned paths to every autofixing hook's exclude the moment ownership is declared, and verify "pre-existing" claims with git log -S or git branch --contains before writing them down.

2026-08-16 — A rule reversed inside a retrofit leaves no trace unless someone writes it down

A retrofit replaced "keep reference chains one level deep" with "two hops, never three" — the opposite rule, needed because the new dispatch pattern requires SKILL.md → improve.md → retrofit.md. The ADR never mentioned chain depth, so the reversal was carried entirely by the diff with no sign a contradicting rule ever existed. Fix: when a change inverts a standing rule, record the inversion where the rule's rationale lives, or it reads as forgotten rather than overturned.

2026-09-15 — A rare flake in a pipefail suite is a race until proven otherwise

The pre-push run-tests failed 2 of 3 full runs, on a different suite each time, and neither failure reproduced alone, so it was treated as noise. The cause was echo "$OUT" | grep -q P under set -o pipefail, at 116 sites. grep -q exits on its first match, echo takes SIGPIPE on its next write, and pipefail reports correct output as "no match". Unforced it failed about once in 670 runs; with a pause forced before the last line, 50 of 50. Fix: use grep -q P <<< "$OUT" (a here-string has no writer process to race), and add a static guard (tests/test-no-pipefail-early-exit-grep.sh) instead of relying on convention.