diff --git a/.github/workflows/why-anchor.yml b/.github/workflows/why-anchor.yml new file mode 100644 index 0000000..f77085c --- /dev/null +++ b/.github/workflows/why-anchor.yml @@ -0,0 +1,51 @@ +name: why-anchor + +# Post-merge re-anchoring (docs/ci.md, DESIGN.md §4). Anchors are re-stamped +# only from main, never from a branch: `as_of` records the commit a span was +# verified at, and a squash merge rewrites branch commits into one new commit, +# so an as_of stamped on a branch names something that never reaches main. Run +# from main, every as_of is a commit main keeps. +# +# The result is PR'd back rather than pushed, so branch protection stays on and +# the frontmatter-only diff is reviewable. The as_of values inside the files +# name main commits, so squashing *this* PR does not orphan them. +on: + push: + branches: [main] + +# One at a time: two merges in quick succession would otherwise race to write +# the same branch. +concurrency: + group: why-anchor + cancel-in-progress: false + +jobs: + anchor: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 # anchor resolution traces line ranges from as_of to HEAD + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run why -- anchor + - uses: peter-evans/create-pull-request@v6 + with: + branch: why-anchors + add-paths: .why + title: "why: re-anchor from main" + commit-message: "why anchor: re-stamp from ${{ github.sha }}" + body: | + Mechanical re-anchoring from `main` (`${{ github.sha }}`). + + `why anchor` rewrites only `why.anchors` entries — never narrative — + so this diff is frontmatter-only. Anchors are stamped here rather + than on the contributing branch because a squash merge discards + branch commits, and an `as_of` must name a commit main keeps. diff --git a/.github/workflows/why-capture.yml b/.github/workflows/why-capture.yml index 235e85e..9cd826e 100644 --- a/.github/workflows/why-capture.yml +++ b/.github/workflows/why-capture.yml @@ -9,7 +9,9 @@ on: jobs: capture: - if: github.event.pull_request.head.ref != 'why-drafts' + # `why`'s own bot PRs carry no rationale to capture — drafting from them + # would recurse and record the machinery instead of a decision. + if: github.event.pull_request.head.ref != 'why-drafts' && github.event.pull_request.head.ref != 'why-anchors' runs-on: ubuntu-latest permissions: contents: write diff --git a/.github/workflows/why-pr-gate.yml b/.github/workflows/why-pr-gate.yml index 80d0a0d..ca0b477 100644 --- a/.github/workflows/why-pr-gate.yml +++ b/.github/workflows/why-pr-gate.yml @@ -1,8 +1,14 @@ name: why-pr-gate # PR gate (docs/ci.md): the archive may not merge in a state it cannot back. -# `why lint` enforces the schema; `why anchor --check` fails on anchor drift -# without writing anything — run `why anchor` locally and commit the result. +# `why lint` enforces the schema. `why anchor --check --allow-drift` fails only +# on an anchor this PR *destroyed* — code it claimed is gone, which no +# re-anchoring can recover and which the author has to resolve. +# +# Drift (a span that merely moved) is deliberately not a failure: fixing it here +# would mean running `why anchor` on this branch, stamping an `as_of` that the +# squash merge then discards (DESIGN.md §4). The why-anchor job re-stamps drift +# from main after the merge, where the commit survives. on: pull_request: @@ -19,4 +25,4 @@ jobs: cache: npm - run: npm ci - run: npm run why -- lint - - run: npm run why -- anchor --check + - run: npm run why -- anchor --check --allow-drift diff --git a/AUTOBUILD.md b/.sandcastle/README.md similarity index 81% rename from AUTOBUILD.md rename to .sandcastle/README.md index 991301f..414c87f 100644 --- a/AUTOBUILD.md +++ b/.sandcastle/README.md @@ -1,19 +1,25 @@ -# AUTOBUILD — the zero-human pipeline +# The Sandcastle pipeline -How `why` gets built from concept to working software with no human gate. The -division of labor: +An optional issue→PR pipeline: labelled GitHub issues become implemented, +reviewed, merged PRs, with an agent gatekeeper standing in for the human merge +gate. It built the initial `why` codebase (phases 1–4, issues #1–#22) and is +kept here for bulk work that suits it — a batch of well-specified, +independent tasks. Day-to-day development does not use it; see +[CONTRIBUTING.md](../CONTRIBUTING.md) for the normal workflow. + +Nothing in the shipped product depends on this directory. It is repo tooling. | Role | Who | Where | |---|---|---| -| Product/architecture | Claude (chat sessions with Dan) | README.md, DESIGN.md, HOWTO.md | -| Issue authoring | Claude | [issues/](issues/) — filed by the bootstrap script | +| Product/architecture | Chat sessions | README.md, DESIGN.md, HOWTO.md | +| Issue authoring | Chat sessions | GitHub issues, labelled `Sandcastle` | | Planning, implementation, internal review, PR assembly | Sandcastle agents (Docker-sandboxed) | `@copperbox/sandcastle-workflow` | -| **Final review, remediation, merge, escalation** | **Gatekeeper agent** | [.sandcastle/gatekeeper.mts](.sandcastle/gatekeeper.mts) | +| **Final review, remediation, merge, escalation** | **Gatekeeper agent** | [gatekeeper.mts](gatekeeper.mts) | | Phase sequencing | Gatekeeper (promotion) | `phase:N` labels | ## The loop -`npm run sandcastle:auto` ([scripts/autonomous-loop.sh](scripts/autonomous-loop.sh)) alternates two runs forever: +`npm run sandcastle:auto` ([scripts/autonomous-loop.sh](../scripts/autonomous-loop.sh)) alternates two runs forever: ``` ┌──────────────────────────────────────────────────────────┐ @@ -42,9 +48,9 @@ division of labor: → nothing left anywhere → exit 4: DONE ``` -Phase 1 issues are filed pre-queued; phases 2–4 sit as labeled backlog until -everything before them has merged, so the dependency order across phases is -enforced by the ratchet, not by hope. +Issues carrying a `phase:N` label sit as backlog until everything in earlier +phases has merged, so dependency order across a batch is enforced by the +ratchet rather than by hope. A batch with no `phase:N` labels just drains. ## Design decisions (and their whys — this file eats its own dog food) @@ -96,8 +102,8 @@ enforced by the ratchet, not by hope. issues), invariants that are checkable rather than aspirational (CODING_STANDARDS.md), a verify the agent can't overrule, and a merge-is-expensive prompt stance. It is still one model family grading its - own homework; the Phase 2 torture test and Phase 3 hand-grading exist - precisely to audit the gate's judgment after the fact. + own homework — which is why the pipeline is reserved for well-specified + batch work and not the default path for changes to this repo. - **Cost.** Every issue is multiple agent runs (plan, implement, review, merge, gate, possibly fix rounds). The per-phase ratchet caps the blast radius of a bad stretch; `GATE_MAX_ROUNDS` caps per-PR spend. @@ -110,17 +116,21 @@ enforced by the ratchet, not by hope. - **Host-side gate agents.** Build agents run in Docker; gate agents run on the host with tool allowlists (read/git-read/npm for review; +edit/commit for fix, push done by the script). Tighter than the build side's sandbox? - No — narrower tools, weaker walls. Acceptable for a private repo of our own - generated code; revisit before pointing this at anything public. + No — narrower tools, weaker walls. **This was scoped to a private repo of + our own generated code. The repo is now public, so issue text is + attacker-controllable input that reaches a host-side agent: never point the + loop at issues you did not write.** Contributor PRs go through the normal + human review in [CONTRIBUTING.md](../CONTRIBUTING.md), never through this + gate. ## Runbook ```bash # once cp .sandcastle/.env.example .sandcastle/.env # fill in both tokens -scripts/bootstrap-github.sh # repo + labels + issue backlog + image +npx sandcastle docker build-image # build agents' sandbox image -# the whole build +# per batch: file the issues, label them "Sandcastle", then npm run sandcastle:auto # pieces, when wanted diff --git a/.sandcastle/config.mts b/.sandcastle/config.mts index 01bd90f..f871e48 100644 --- a/.sandcastle/config.mts +++ b/.sandcastle/config.mts @@ -1,6 +1,5 @@ -// Sandcastle configuration for the `why` autonomous build. -// See AUTOBUILD.md for how this composes with the gatekeeper into a -// zero-human-gate pipeline. +// Sandcastle configuration for the optional agent pipeline. +// See README.md in this directory for how this composes with the gatekeeper. import { defineConfig } from "@copperbox/sandcastle-workflow"; @@ -11,8 +10,9 @@ export default defineConfig({ // npm-shaped repo: version-bump each feature PR. release: { enabled: true }, - // Private repo, single trusted owner; the defaults are fine but explicit - // here because the gatekeeper relies on its own comments being trusted. + // Explicit because the gatekeeper relies on its own comments being trusted. + // These were chosen when the repo was private with a single trusted owner; + // the repo is now public, so only ever queue issues written by a maintainer. security: { trustedCommentsOnly: true, lockOnQueue: false }, // The gatekeeper does its own remediation (it cannot route feedback through @@ -23,8 +23,8 @@ export default defineConfig({ implementNotes: [ "DESIGN.md at the repo root is the source of truth for the `why` schema and", - "architecture; issues/ carries the per-task specs. Read the DESIGN.md sections an", - "issue cites before writing code. examples/harbor/ is the fixture bundle —", + "architecture; the issue body carries the per-task spec. Read the DESIGN.md", + "sections an issue cites before writing code. examples/harbor/ is the fixture bundle —", "tests should run against it rather than inventing new fixtures. Never let a", "code path emit a silently-wrong anchor, and never assert rationale above its", "evidence — these two rules override convenience every time.", diff --git a/.why/decisions/anchors-are-written-from-main.md b/.why/decisions/anchors-are-written-from-main.md new file mode 100644 index 0000000..7819cba --- /dev/null +++ b/.why/decisions/anchors-are-written-from-main.md @@ -0,0 +1,49 @@ +--- +type: decision +title: Anchors are written from main by CI, so the PR gate stops failing on drift +description: A why-anchor job re-stamps anchors on every push to main, and the PR + gate runs --allow-drift, failing only on an anchor the change destroyed. +tags: + - anchoring + - ci +timestamp: 2026-07-15 +why: + status: active + happened_on: 2026-07-15 + confidence: recorded + anchors: + - path: .github/workflows/why-anchor.yml + as_of: 0f75578 + state: live + - path: .github/workflows/why-pr-gate.yml + as_of: 0f75578 + state: live + - path: docs/ci.md + as_of: 0f75578 + state: live +--- + +`why anchor` runs in CI on every push to `main` and PRs the frontmatter-only result back on the `why-anchors` branch. Contributors do not run it on their branches, and the PR gate no longer asks them to: it runs `why anchor --check --allow-drift`, which reports drift and exits 1 only on an anchor the change *destroyed*. + +# Why + +The gate caused the bug it was meant to prevent. `why-pr-gate.yml` failed on any anchor drift, and its own comment said "run `why anchor` locally and commit the result" — but a contributor's HEAD is exactly what a squash merge discards. Following the instruction stamped an `as_of` that named nothing a day later. Both orphans in this bundle were created that way: `518bf47` by [1] (a `why anchor` run on `why-drafts`), and `44d4118` by [2]. Every `as_of` CI has ever written is a clean ancestor, because `why capture` already reads the merge commit from `main` — the one component doing this correctly was proof the approach works before it was generalized. + +So the gate was demanding the one thing that cannot be done correctly from a branch. Ancestry gating and the verified merge-base stamp ([as_of must be an ancestor](/decisions/as-of-must-be-an-ancestor.md)) contain the damage, but they cannot prevent it: when a branch *is* what changed a span — or when a file is *born* on the branch, as `.sandcastle/README.md` was — no surviving commit holds that span, so the only truthful stamp is a HEAD the squash then throws away. Running from `main` dissolves that case rather than mitigating it: the merge commit both exists and contains the code. + +Squashing the `why-anchors` PR does not re-orphan anything, which is the crux and the natural objection. A squash rewrites *commits*; the `as_of` **values** inside the files name `main` commits and survive the squash as ordinary content. + +# Costs + +Accepted deliberately, because the alternative is a gate that teaches people to corrupt the archive: + +- **The bundle becomes eventually consistent.** Between a merge and the re-anchor PR landing, `main`'s anchors can be stale. They degrade honestly (`lost` or `unverified`), never to a wrong span, so §2's promise holds — but `why blame` can be briefly out of date, and the anchor update no longer rides in the same review as the code change. +- **The gate is weaker.** Drift merges. A destroyed anchor still blocks, which keeps the part worth keeping: you cannot delete the code a concept describes without being told. +- **It is a convention until enforced.** Nothing stops a human running `why anchor` on a branch. The stamping rule limits the blast radius, and `CLAUDE.md` and `CONTRIBUTING.md` say not to. + +Rejected: keeping the strict gate (it cannot be satisfied honestly from a branch); pushing straight to `main` from CI (works, and is defensible for a frontmatter-only diff, but breaks branch protection for adopters copying the recipe). + +# Citations + +[1] [4f7d259 — the `why anchor` run on why-drafts that stamped an orphaned as_of](https://github.com/copperbox/why/commit/4f7d2594da7110c4de539406389733756194866f) +[2] [7391fd7 — the same mechanism recurring on release-prep](https://github.com/copperbox/why/commit/7391fd70d006f779dba69a4121780f7ef826c78d) diff --git a/.why/decisions/as-of-is-provenance.md b/.why/decisions/as-of-is-provenance.md index 0ded58e..db54f77 100644 --- a/.why/decisions/as-of-is-provenance.md +++ b/.why/decisions/as-of-is-provenance.md @@ -1,9 +1,10 @@ --- type: decision title: as_of is provenance, so doctor does not flag clean-ancestor anchors -description: A live anchor whose as_of is a clean ancestor of HEAD is stable, not stale; why doctor only flags unresolved or diverged as_ofs. -tags: [anchoring, doctor] -timestamp: 2026-07-13 +description: A live anchor whose as_of is a clean ancestor of HEAD is stable, + not stale; why doctor only flags unresolved or diverged as_ofs. +tags: [ anchoring, doctor ] +timestamp: 2026-07-15 why: status: active happened_on: 2026-07-13 @@ -21,6 +22,16 @@ a commit unrelated to HEAD (diverged/rebased history) or one the repository cannot resolve at all. A live anchor whose `as_of` is a clean ancestor of HEAD is left alone — it is healthy provenance, not a problem. +**Scope, amended 2026-07-15:** the rule protects a *readable* `as_of` — a +clean ancestor, which is evidence the span survived unchanged since that +commit. An `as_of` that is a non-ancestor (or does not resolve) carries no +such meaning, and "never re-stamp a stable anchor" does not extend to it: +`why anchor` repairs such an orphan when the claim re-verifies at HEAD without +reading `as_of` — see +[orphaned as_of is repaired](/decisions/orphaned-as-of-is-repaired.md). That +repair is what makes the `not-ancestor` finding this decision kept genuinely +actionable. + # Why `why anchor` and `why doctor` were built against inconsistent readings of what diff --git a/.why/decisions/as-of-must-be-an-ancestor.md b/.why/decisions/as-of-must-be-an-ancestor.md new file mode 100644 index 0000000..ec300db --- /dev/null +++ b/.why/decisions/as-of-must-be-an-ancestor.md @@ -0,0 +1,58 @@ +--- +type: decision +title: as_of is only readable as history when it is an ancestor of HEAD +description: why anchor gates every read of history through as_of on ancestry, + stamps the surviving merge-base when it can verify the span there, and + degrades an unreadable as_of to `unverified` instead of `lost`. +tags: + - anchoring + - doctor +timestamp: 2026-07-15 +why: + status: active + happened_on: 2026-07-14 + confidence: recorded + anchors: + - path: src/anchor.ts + symbol: historyOrigin + as_of: 0f75578 + state: live + - path: src/anchor.ts + symbol: stampFor + as_of: 0f75578 + state: live + - path: test/anchor.test.ts + as_of: 0f75578 + state: live + - path: README.md + as_of: 0f75578 + state: live +--- + +An anchor's `as_of` is used two ways, and both are now gated: + +- **Reading.** `historyOrigin` accepts an `as_of` as a history origin only when it resolves *and* is an ancestor of HEAD. Rename-following and blame-tracing refuse to read through anything else. +- **Writing.** `stampFor` stamps HEAD by default, but when HEAD is not contained in the integration branch (`refs/remotes/origin/HEAD`) it prefers the merge-base — the newest commit certain to survive a squash — and only when the identical span verifiably holds there. + +An `as_of` that fails the read gate while its path still resolves is reported `unverified`, not `lost`: nothing is rewritten and `why doctor` names the gap. + +# Why + +This repo squash-merges, and `why anchor` stamped `git.headShort` unconditionally. Running it on `why-drafts` produced [1], which stamped `as_of: 518bf47` onto seven anchors; the squash landed that work as `0f75578` and discarded `518bf47`. The same mechanism re-armed on `release-prep`, stamping `44d4118` onto four more. + +Two findings forced this design, both measured rather than reasoned: + +**Ancestry, not reachability, is the real gate.** `git blame --reverse` needs `as_of` to be an ancestor, so the orphan failed identically in a fresh clone and locally — no divergence, which is why the bug read as merely cosmetic. But `renamedTo` uses `git diff as_of HEAD`, which git answers between *any two commits in the object store*. With a whole-file anchor on a file renamed after the branch point, the same bundle at the same commit reported `moved` locally and `lost` in a `--no-local` clone of `main` — and `why anchor` would have written the two clones different bundles. That is the operator-dependent answer, and it is the one path that could emit a rename detected *through a commit that never landed*: silently wrong, not merely lost. + +**The line ranges were not the cause.** [2] attributed five rotted anchors to fragile `lines: 1-N` whole-file ranges. Isolating the variables on a fresh clone of `0f75578` refutes that: keeping `lines: 1-35` and swapping only `as_of` to a real ancestor resolves the anchor (`already current`), and re-stamping the orphaned `as_of` cleared all five with the ranges left intact. `git blame --reverse` silently clamps an over-long `-L` end to the file length at `as_of`, so a whole-file range was self-healing. Dropping the ranges did fix the symptom, but by removing the code path that consults `as_of` — masking the dangling sha rather than repairing it. The dangling `as_of` was necessary and sufficient; the ranges were neither. + +**Why the merge-base stamp must verify.** `git blame --reverse` interprets `-L` against the `as_of` revision, not HEAD (`-L35,35` against a 34-line `as_of` errors). So stamping an unverified merge-base with HEAD-valid lines would re-point the anchor at whatever text held those line numbers back then — the exact failure this module exists to prevent. For a span the branch just changed, the merge-base is precisely where it is *not* valid, so an unverifiable merge-base loses to a truthful HEAD, orphan and all. + +**Why `unverified` rather than `lost`.** Path and symbol resolution are stronger evidence about where code lives than `as_of` ever was. Downgrading a live, path-confirmed anchor to `lost` because its provenance is unreadable asserts *below* the evidence — the mirror of the sin the ladder forbids — and false `lost` is how a health report gets ignored, which is the product. + +Migration: eleven anchors carried a non-ancestor `as_of`. Eight were re-stamped to `0f75578` only after verifying the claim there (path present, or the line span byte-identical to HEAD). Three anchoring `.sandcastle/README.md` were left at `44d4118`: that file was born on `release-prep`, so no surviving commit contained it at the time and there was no honest `as_of` to move them to. Leaving them was sound; leaving them *permanently* was not — after the squash they would flag `not-ancestor` yellow with nothing able to clear it (a whole-file anchor resolves by path at HEAD and never consults `as_of`, so re-anchoring walks past it). [orphaned as_of is repaired](/decisions/orphaned-as-of-is-repaired.md) closes that gap: the post-merge run on `main` re-stamps them to the squash commit, which does contain the file. [as_of is provenance](/decisions/as-of-is-provenance.md) still holds for what it protects — a stable anchor's clean-ancestor `as_of` is never re-stamped. + +# Citations + +[1] [4f7d259 — the `why anchor` run on why-drafts that stamped the orphan](https://github.com/copperbox/why/commit/4f7d2594da7110c4de539406389733756194866f) +[2] [7391fd7 — fix: green the self-hosted gate, whose line-range diagnosis this corrects](https://github.com/copperbox/why/commit/7391fd70d006f779dba69a4121780f7ef826c78d) diff --git a/.why/decisions/audit-expires-on-evidence-only.md b/.why/decisions/audit-expires-on-evidence-only.md index b1ef917..12d393f 100644 --- a/.why/decisions/audit-expires-on-evidence-only.md +++ b/.why/decisions/audit-expires-on-evidence-only.md @@ -16,11 +16,9 @@ why: confidence: recorded anchors: - path: src/audit.ts - lines: 1-640 as_of: a735c62 state: live - path: test/audit.test.ts - lines: 1-460 as_of: a735c62 state: live --- diff --git a/.why/decisions/autonomous-build-via-sandcastle.md b/.why/decisions/autonomous-build-via-sandcastle.md index e00c83e..2e3d848 100644 --- a/.why/decisions/autonomous-build-via-sandcastle.md +++ b/.why/decisions/autonomous-build-via-sandcastle.md @@ -1,16 +1,17 @@ --- type: decision title: Autonomous build via Sandcastle + gatekeeper -description: Phases 1–4 are implemented by an issue→PR pipeline with an agent gatekeeper replacing the human merge gate. -tags: [process, autobuild] +description: Phases 1–4 are implemented by an issue→PR pipeline with an agent + gatekeeper replacing the human merge gate. +tags: [ process, autobuild ] timestamp: 2026-07-13 why: status: active happened_on: 2026-07-11 confidence: recorded anchors: - - path: AUTOBUILD.md - as_of: 9f0dc16 + - path: .sandcastle/README.md + as_of: 44d4118 state: live --- @@ -25,4 +26,4 @@ Recorded at project bootstrap [1]: remediation happens in the gate rather than t # Citations [1] [bootstrap commit 9f0dc16 — PLAN.md Decision log, 2026-07-11](https://github.com/copperbox/why/commit/9f0dc16ff06e3790eed67bfd62207e2839afb7b7) -[2] [AUTOBUILD.md](https://github.com/copperbox/why/blob/main/AUTOBUILD.md) +[2] [AUTOBUILD.md as of the bootstrap commit (now .sandcastle/README.md)](https://github.com/copperbox/why/blob/9f0dc16ff06e3790eed67bfd62207e2839afb7b7/AUTOBUILD.md) diff --git a/.why/decisions/capture-drafts-in-a-dot-directory.md b/.why/decisions/capture-drafts-in-a-dot-directory.md index f3d26ec..26a9d24 100644 --- a/.why/decisions/capture-drafts-in-a-dot-directory.md +++ b/.why/decisions/capture-drafts-in-a-dot-directory.md @@ -16,15 +16,12 @@ why: confidence: recorded anchors: - path: src/capture.ts - lines: 1-610 as_of: a735c62 state: live - path: docs/capture.md - lines: 1-109 as_of: a735c62 state: live - path: skills/capture/SKILL.md - lines: 1-107 as_of: a735c62 state: live --- diff --git a/.why/decisions/escalation-circuit-breaker.md b/.why/decisions/escalation-circuit-breaker.md index 571adfc..f977ab5 100644 --- a/.why/decisions/escalation-circuit-breaker.md +++ b/.why/decisions/escalation-circuit-breaker.md @@ -1,16 +1,17 @@ --- type: decision title: "Circuit breaker: repeated escalation halts the loop for a chat" -description: An issue's second gate escalation removes it from the queue, labels it needs-chat, and the gate exits HALTED instead of promoting past the hole. -tags: [process, autobuild] +description: An issue's second gate escalation removes it from the queue, labels + it needs-chat, and the gate exits HALTED instead of promoting past the hole. +tags: [ process, autobuild ] timestamp: 2026-07-13 why: status: active happened_on: 2026-07-12 confidence: recorded anchors: - - path: AUTOBUILD.md - as_of: 9f0dc16 + - path: .sandcastle/README.md + as_of: 44d4118 state: live --- @@ -29,4 +30,4 @@ Recorded at decision time [1]: rewriting a failing spec is the one act the pipel # Citations [1] [bootstrap commit 9f0dc16 — PLAN.md Decision log, 2026-07-12](https://github.com/copperbox/why/commit/9f0dc16ff06e3790eed67bfd62207e2839afb7b7) -[2] [AUTOBUILD.md](https://github.com/copperbox/why/blob/main/AUTOBUILD.md) +[2] [AUTOBUILD.md as of the bootstrap commit (now .sandcastle/README.md)](https://github.com/copperbox/why/blob/9f0dc16ff06e3790eed67bfd62207e2839afb7b7/AUTOBUILD.md) diff --git a/.why/decisions/index.md b/.why/decisions/index.md index f2c733b..88333c6 100644 --- a/.why/decisions/index.md +++ b/.why/decisions/index.md @@ -2,9 +2,13 @@ # Concepts +* [Anchors are written from main by CI, so the PR gate stops failing on drift](anchors-are-written-from-main.md) - A why-anchor job re-stamps anchors on every push to main, and the PR gate runs --allow-drift, failing only on an anchor the change destroyed. * [as_of is provenance, so doctor does not flag clean-ancestor anchors](as-of-is-provenance.md) - A live anchor whose as_of is a clean ancestor of HEAD is stable, not stale; why doctor only flags unresolved or diverged as_ofs. +* [as_of is only readable as history when it is an ancestor of HEAD](as-of-must-be-an-ancestor.md) - why anchor gates every read of history through as_of on ancestry, stamps the surviving merge-base when it can verify the span there, and degrades an unreadable as_of to `unverified` instead of `lost`. +* [`why audit` expires a constraint only on hard evidence, then walks the blast radius](audit-expires-on-evidence-only.md) - Audit flips a constraint to expired only on a non-zero check exit or an explicit "no longer true" answer — never on a timeout or error — then reports scar tissue and files deduped blast-radius questions, exiting 1 on any new expiry. * [Autonomous build via Sandcastle + gatekeeper](autonomous-build-via-sandcastle.md) - Phases 1–4 are implemented by an issue→PR pipeline with an agent gatekeeper replacing the human merge gate. * [`why blame` warns on every expired constraint](blame-warns-on-every-expired-constraint.md) - Expired-constraint warnings render on every blame query, not only when an edge connects the constraint to the matched code. +* [Merge-time capture emits lint-gated drafts into a dot-directory that never serves](capture-drafts-in-a-dot-directory.md) - why capture drafts a concept from a merged PR into .why/.drafts/ — a dot-dir okf-mcp's walk skips, so machine output never serves — with confidence recorded only when rationale text was found, and promotion out is atomic and lint-gated. * [The CLI direct-run guard resolves symlinks](cli-entry-guard-resolves-symlinks.md) - why's entry-point guard realpath-resolves argv[1] before comparing to import.meta.url, so a symlinked launch still runs main() — the VS Code extension shells out through .bin symlinks. * [Consumption before archaeology](consumption-before-archaeology.md) - Build the read side (Phase 1 CLI over hand-written bundles) before the dig pipeline (Phase 3). * [`why doctor` reports expired constraints as their own yellow section](doctor-expired-constraints-section.md) - Doctor carries an expiredConstraints section, and a live anchor whose as_of doesn't resolve reports stale rather than being skipped. @@ -14,7 +18,15 @@ * [Issues are the spec surface](issues-are-the-spec-surface.md) - Each Phase 1–4 task is a self-contained issue with testable acceptance criteria; implementers and the gate judge against issue text. * [Extension keys namespaced under one `why:` map](namespaced-why-frontmatter.md) - All why-specific frontmatter lives under a single `why:` key instead of flat top-level keys. * [OKF/okf-mcp as the substrate](okf-as-substrate.md) - Bundles are plain OKF markdown served by okf-mcp, not a bespoke store. +* [an orphaned as_of on a claim verified at HEAD is repaired, not kept](orphaned-as-of-is-repaired.md) - why anchor re-stamps an as_of that no clone of the integration branch can read — but only when the claim re-verified at HEAD without it, and only to a commit that survives a squash. * [Planning docs retired once the build completed](planning-docs-retired.md) - Remove PLAN.md and NOTES.md now the build is done; HOWTO.md is the operator guide and the .why/ bundle is the decision memory. +* [`why serve` is a read-only, foreground, localhost-only viewer — the one no-daemon exception](serve-local-ui.md) - A thin HTTP server on 127.0.0.1 that wraps the same library calls the CLI uses, never writes, reloads per request, and serves self-contained in-memory-bundled assets. * [why serve is a scoped exception to the no-daemon rule](serve-no-daemon-exception.md) - why serve is the only foreground, blocking subcommand — an optional, localhost-only, read-only viewer; DESIGN §8's 'no daemon, run-to-completion' bullet was amended to carve the exception rather than dropped. +* [Syntax highlighting lives in the serve UI's client renderer, not the data contract](serve-syntax-highlighting.md) - The file view colors code with a hand-rolled, dependency-free highlighter in the browser; it is pure presentation and asserts nothing about the why. * [The serve UI ships zero external assets](serve-ui-ships-zero-external-assets.md) - why serve bundles all assets in-repo via esbuild and pulls nothing from a CDN — a no-external-URL test enforces it, and the hand-rolled highlighter and canvas graph exist so no third-party runtime is fetched. +* [The CLI direct-run guard resolves the entry path's symlinks before comparing](symlink-safe-direct-run-guard.md) - isDirectRun realpath-resolves the entry path and builds its URL with pathToFileURL, so a symlinked launch (npm bins, the VS Code extension) still runs main(). * [The UI contract enforces the confidence ladder in-schema](ui-contract-enforces-ladder-in-schema.md) - story.schema.json rejects a forged hedged:false on a sub-corroborated hit via an if/then, validated by an independent ajv — so the §2 ladder holds structurally even if an upstream renderer lies. +* [The UI ⇄ backend boundary is a versioned JSON data contract; UIs are dumb renderers](ui-data-contract.md) - story, coverage, and graph are JSON-Schema'd payloads carrying schemaVersion; the §2 hedging invariant is enforced structurally in the schema, and UIs render precomputed data without re-deriving it. +* [Coverage decorations are two independently-toggleable lanes, with the scrollbar mark off by default](vscode-decoration-lanes.md) - The extension's always-on paint was split into gutter and overview-ruler lanes gated by why.decorations.* settings and a Toggle Annotations command; the scrollbar mark ships off because it stays visible when scrolled away. Hovers stay ungated. +* [The VS Code extension is a standalone package that shells out to the CLI](vscode-extension-standalone.md) - vscode-why/ is its own package (not a workspace member) so root release/publish never touch it; it renders by shelling out to the why CLI, with all logic in an electron-free src/core/. +* [`why` self-hosts: three CI workflows run the tool against its own bundle](why-self-hosts-its-ci.md) - The repo runs why lint/anchor as a PR gate, a weekly audit that PRs the .why write-back, and post-merge capture onto why-drafts — all running the CLI from source since the repo is the package, with docs/ci.md pinned verbatim by test. diff --git a/.why/decisions/issues-are-the-spec-surface.md b/.why/decisions/issues-are-the-spec-surface.md index 81feadd..4c4e0ee 100644 --- a/.why/decisions/issues-are-the-spec-surface.md +++ b/.why/decisions/issues-are-the-spec-surface.md @@ -1,16 +1,17 @@ --- type: decision title: Issues are the spec surface -description: Each Phase 1–4 task is a self-contained issue with testable acceptance criteria; implementers and the gate judge against issue text. -tags: [process, autobuild] +description: Each Phase 1–4 task is a self-contained issue with testable + acceptance criteria; implementers and the gate judge against issue text. +tags: [ process, autobuild ] timestamp: 2026-07-13 why: status: active happened_on: 2026-07-11 confidence: recorded anchors: - - path: AUTOBUILD.md - as_of: 9f0dc16 + - path: .sandcastle/README.md + as_of: 44d4118 state: live --- diff --git a/.why/decisions/orphaned-as-of-is-repaired.md b/.why/decisions/orphaned-as-of-is-repaired.md new file mode 100644 index 0000000..cc6cd67 --- /dev/null +++ b/.why/decisions/orphaned-as-of-is-repaired.md @@ -0,0 +1,46 @@ +--- +type: decision +title: an orphaned as_of on a claim verified at HEAD is repaired, not kept +description: why anchor re-stamps an as_of that no clone of the integration + branch can read — but only when the claim re-verified at HEAD without it, and + only to a commit that survives a squash. +tags: + - anchoring + - doctor +timestamp: 2026-07-15 +why: + status: active + happened_on: 2026-07-15 + confidence: recorded + anchors: + - path: src/anchor.ts + symbol: repairOrphan + as_of: 0f75578 + state: live + - path: test/anchor.test.ts + as_of: 0f75578 + state: live +--- + +# an orphaned as_of on a claim verified at HEAD is repaired, not kept + +`why anchor` repairs an **orphaned** `as_of` — one that does not resolve, or resolves to a non-ancestor of HEAD — on an anchor whose claim re-verified at HEAD *without reading `as_of` at all*: a whole-file path that is present, or a symbol re-found at its recorded span. The repair stamp must be durable: HEAD when the integration branch contains it (or git records no integration branch), the merge-base on a diverged branch and only when the span verifiably holds there, and otherwise nothing — the orphan is left reported for the post-merge run rather than re-stamped to a branch HEAD the next squash would discard and re-orphan. + +A bare `path + lines` claim with an unreadable `as_of` is untouched: nothing verified those lines, so re-stamping would assert a span above its evidence. It stays `unverified`, and clearing it takes a human or a re-dig. + +# Why + +[as_of must be an ancestor](/decisions/as-of-must-be-an-ancestor.md) fixed the *reading* half of the squash-orphan problem and deliberately left three anchors on `.sandcastle/README.md` at `44d4118` — a branch commit no surviving commit could replace at the time. Measured after simulating that squash: `why doctor` flagged all three `not-ancestor` yellow, and **nothing could clear them**. A whole-file anchor resolves by path at HEAD and never consults `as_of`, so it classifies `current` and write-mode `why anchor` walks straight past it; the post-merge `why-anchor` job re-stamps only drift, so it walked past too. + +That is the exact pathology [as_of is provenance](/decisions/as-of-is-provenance.md) was written to kill — un-clearable yellows draining the signal from the whole report — recreated three anchors at a time. That decision kept `not-ancestor` on the explicit grounds that it is "genuinely actionable"; nothing had yet made it actionable. Repair does, and it *completes* the provenance rule rather than overriding it: a clean-ancestor `as_of` means the span survived unchanged since that commit and is still never rewritten, while an orphaned `as_of` means nothing any clone of the integration branch can read — there is no provenance left to preserve. The provenance decision's scope was amended to say so. + +Two honesty gates, both covered by tests: + +- **Repair never guesses.** It fires only when the claim was verified at HEAD without `as_of` — path existence or a re-found symbol. A bare line claim reaches `unverified` instead and is left byte-for-byte, because `git blame --reverse` reads `-L` against the `as_of` revision and no surviving commit verifiably contains those lines. +- **Repair never churns.** On a branch diverged from the integration branch, a repair that could only stamp branch HEAD is declined — the next squash would re-orphan it. The merge-base is stamped only when the identical span verifiably holds there (`spanHoldsAt`, span identity via reverse blame, not text equality). + +Verified end-to-end on a `--no-local` clone with the squash of `release-prep` simulated: three `stale as_of` yellows, repaired to the squash commit in one write-mode run, `why doctor` at zero, and a second run re-derives every stamp (`64 already current`). + +# Citations + +[1] [PR #39 — release-prep: fixed reading through orphans, deferred this repair](https://github.com/copperbox/why/pull/39) diff --git a/.why/decisions/serve-local-ui.md b/.why/decisions/serve-local-ui.md index 1209c1d..79a4773 100644 --- a/.why/decisions/serve-local-ui.md +++ b/.why/decisions/serve-local-ui.md @@ -19,15 +19,12 @@ why: as_of: 61a4e85 state: live - path: src/serve.ts - lines: 1-290 as_of: 61a4e85 state: live - path: src/serve-assets.ts - lines: 1-63 as_of: 61a4e85 state: live - path: ui/story-panel.js - lines: 1-125 as_of: 61a4e85 state: live --- diff --git a/.why/decisions/serve-no-daemon-exception.md b/.why/decisions/serve-no-daemon-exception.md index d319de1..e973b3a 100644 --- a/.why/decisions/serve-no-daemon-exception.md +++ b/.why/decisions/serve-no-daemon-exception.md @@ -15,7 +15,6 @@ why: confidence: recorded anchors: - path: src/serve.ts - lines: 1-290 as_of: 61a4e85 state: live - path: DESIGN.md diff --git a/.why/decisions/serve-syntax-highlighting.md b/.why/decisions/serve-syntax-highlighting.md index 67dfdf5..080c2cb 100644 --- a/.why/decisions/serve-syntax-highlighting.md +++ b/.why/decisions/serve-syntax-highlighting.md @@ -15,13 +15,12 @@ why: confidence: recorded anchors: - path: ui/highlight.js - lines: 1-202 as_of: 9c434ae state: live - path: ui/app.js symbol: showFile lines: 132-179 - as_of: 518bf47 + as_of: 0f75578 state: live - path: ui/style.css lines: 127-135 diff --git a/.why/decisions/serve-ui-ships-zero-external-assets.md b/.why/decisions/serve-ui-ships-zero-external-assets.md index 320c75b..77c4ca8 100644 --- a/.why/decisions/serve-ui-ships-zero-external-assets.md +++ b/.why/decisions/serve-ui-ships-zero-external-assets.md @@ -14,15 +14,12 @@ why: confidence: recorded anchors: - path: src/serve-assets.ts - lines: 1-63 as_of: 61a4e85 state: live - path: ui/graph.js - lines: 1-164 as_of: 61a4e85 state: live - path: ui/highlight.js - lines: 1-202 as_of: 9c434ae state: live --- diff --git a/.why/decisions/symlink-safe-direct-run-guard.md b/.why/decisions/symlink-safe-direct-run-guard.md index 6b6dcaa..3a06a83 100644 --- a/.why/decisions/symlink-safe-direct-run-guard.md +++ b/.why/decisions/symlink-safe-direct-run-guard.md @@ -1,6 +1,6 @@ --- type: decision -title: The CLI direct-run guard resolves argv[1]'s symlinks before comparing +title: The CLI direct-run guard resolves the entry path's symlinks before comparing description: isDirectRun realpath-resolves the entry path and builds its URL with pathToFileURL, so a symlinked launch (npm bins, the VS Code extension) still runs main(). @@ -16,7 +16,7 @@ why: - path: src/cli.ts symbol: isDirectRun lines: 645-653 - as_of: 518bf47 + as_of: 0f75578 state: live - path: test/cli.test.ts lines: 73-95 diff --git a/.why/decisions/ui-contract-enforces-ladder-in-schema.md b/.why/decisions/ui-contract-enforces-ladder-in-schema.md index 6aa1ea2..617b91f 100644 --- a/.why/decisions/ui-contract-enforces-ladder-in-schema.md +++ b/.why/decisions/ui-contract-enforces-ladder-in-schema.md @@ -14,13 +14,11 @@ why: confidence: recorded anchors: - path: schemas/story.schema.json - lines: 1-185 as_of: 58dc0db state: live - path: test/ui-contract.test.ts - lines: 1-351 - as_of: 394ff31 - state: lost + as_of: 0f75578 + state: live --- `story.schema.json` encodes the DESIGN §2 hedging invariant as an `if`/`then`: a payload asserting `hedged: false` on a non-`question` hit below the corroboration threshold fails validation. The contract is checked by ajv (a dev-only dependency), and the schemas' own doc examples are validated in tests. diff --git a/.why/decisions/ui-data-contract.md b/.why/decisions/ui-data-contract.md index 4941f05..41ee723 100644 --- a/.why/decisions/ui-data-contract.md +++ b/.why/decisions/ui-data-contract.md @@ -17,26 +17,21 @@ why: anchors: - path: DESIGN.md lines: 178 - as_of: 58dc0db - state: lost + as_of: 0f75578 + state: live - path: docs/ui-contract.md - lines: 1-287 - as_of: 518bf47 + as_of: 0f75578 state: live - path: schemas/story.schema.json - lines: 1-185 as_of: 58dc0db state: live - path: schemas/coverage.schema.json - lines: 1-64 as_of: 58dc0db state: live - path: schemas/graph.schema.json - lines: 1-48 as_of: 58dc0db state: live - path: src/export.ts - lines: 1-166 as_of: 58dc0db state: live - path: src/blame.ts diff --git a/.why/decisions/vscode-decoration-lanes.md b/.why/decisions/vscode-decoration-lanes.md index 2a906ac..2545b31 100644 --- a/.why/decisions/vscode-decoration-lanes.md +++ b/.why/decisions/vscode-decoration-lanes.md @@ -37,10 +37,6 @@ why: lines: 53-72 as_of: e31b5b389e5a8a2c040098a88c5db748c206eb93 state: live - - path: issues/504-decoration-toggle.md - lines: 1-53 - as_of: e31b5b389e5a8a2c040098a88c5db748c206eb93 - state: live --- # Coverage decorations are two independently-toggleable lanes, with the scrollbar mark off by default @@ -49,7 +45,7 @@ The extension's inline coverage paint is no longer a single always-on decoration # Why -Recorded in the PR description, the merge commit message, and the spec of record in `issues/504-decoration-toggle.md` [1][2]. The paint is passive: before this it was on whenever the extension was active, and the only way to quiet it was to disable the extension outright. That is the right default for discovery but intrusive during regular editing — the scrollbar mark most of all, because it stays visible even when the covered code is scrolled away, so it ships off by default while the gutter stripe stays on. +Recorded in the PR description, the merge commit message, and the spec of record that was `issues/504-decoration-toggle.md` at the time [1][2][3]. The paint is passive: before this it was on whenever the extension was active, and the only way to quiet it was to disable the extension outright. That is the right default for discovery but intrusive during regular editing — the scrollbar mark most of all, because it stays visible even when the covered code is scrolled away, so it ships off by default while the gutter stripe stays on. The shape follows from that. The lanes are separate decoration types because a single type carrying both a border and an `overviewRulerColor` cannot hide one without the other. A hidden lane is cleared rather than skipped so no mark lingers after a toggle-off. The repaint listens on configuration change instead of re-running the CLI because coverage is already cached, which is what makes the toggle feel like one keystroke. Only the always-on paint is gated: hovers and *Show Story* are on-demand rather than passive, so they are never the thing you are trying to mute and stay available regardless. @@ -61,3 +57,4 @@ The shape follows from that. The lanes are separate decoration types because a s [1] [PR #38: vscode: toggle for coverage decorations (scrollbar mark off by default)](https://github.com/copperbox/why/pull/38) [2] [merge commit e31b5b3](https://github.com/copperbox/why/commit/e31b5b389e5a8a2c040098a88c5db748c206eb93) +[3] [issues/504-decoration-toggle.md, the spec of record, at its last living commit](https://github.com/copperbox/why/blob/e31b5b389e5a8a2c040098a88c5db748c206eb93/issues/504-decoration-toggle.md) diff --git a/.why/decisions/vscode-extension-standalone.md b/.why/decisions/vscode-extension-standalone.md index 50dc49b..53342ce 100644 --- a/.why/decisions/vscode-extension-standalone.md +++ b/.why/decisions/vscode-extension-standalone.md @@ -18,24 +18,19 @@ why: as_of: 61a4e85 state: live - path: vscode-why/package.json - lines: 1-92 - as_of: 518bf47 + as_of: 0f75578 state: live - path: vscode-why/src/extension.ts - lines: 1-282 - as_of: 518bf47 + as_of: 0f75578 state: live - path: vscode-why/src/core/contract.ts - lines: 1-287 as_of: 61a4e85 state: live - path: vscode-why/src/core/cli-locate.ts - lines: 1-53 as_of: 61a4e85 state: live - path: docs/vscode.md - lines: 1-154 - as_of: 518bf47 + as_of: 0f75578 state: live --- diff --git a/.why/decisions/why-self-hosts-its-ci.md b/.why/decisions/why-self-hosts-its-ci.md index 397fb52..ee8974b 100644 --- a/.why/decisions/why-self-hosts-its-ci.md +++ b/.why/decisions/why-self-hosts-its-ci.md @@ -15,23 +15,18 @@ why: confidence: recorded anchors: - path: .github/workflows/why-pr-gate.yml - lines: 1-22 as_of: a735c62 state: live - path: .github/workflows/why-audit.yml - lines: 1-55 as_of: a735c62 state: live - path: .github/workflows/why-capture.yml - lines: 1-35 - as_of: 518bf47 + as_of: 0f75578 state: live - path: docs/ci.md - lines: 1-199 as_of: a735c62 state: live - path: test/ci.test.ts - lines: 1-118 as_of: a735c62 state: live --- diff --git a/.why/log.md b/.why/log.md index 8349ef6..d12494f 100644 --- a/.why/log.md +++ b/.why/log.md @@ -1,5 +1,12 @@ # Update Log +## 2026-07-15 +* re-point anchor: repairStamp renamed to repairOrphan (call-site const made the symbol ambiguous to the grep resolver) +* retitle: drop brackets from title so the generated index bullet stays lintable (W001) +* amend as-of-must-be-an-ancestor: the .sandcastle orphans are now repaired post-merge (orphaned-as-of-is-repaired) +* amend as-of-is-provenance: scope the never-re-stamp rule to readable (clean-ancestor) as_of; orphans are repairable +* decision: why anchor repairs orphaned as_of when the claim verifies at HEAD (durable stamps only) + ## 2026-07-14 * capture: record the zero-external-asset serve UI invariant (PRs #28, #33) * capture: record the in-schema confidence-ladder enforcement (PR #27) diff --git a/CLAUDE.md b/CLAUDE.md index cb3de4e..5382bb9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,29 +2,20 @@ Decision archaeology for codebases: recover the *why* behind code from git/PR/issue history into an OKF markdown bundle, keep it anchored to the living code, and audit whether its constraints are still true. -## How this project gets built - -Implementation is **autonomous**: issues in `issues/` are filed to GitHub and -built by the Sandcastle pipeline, with an agent gatekeeper reviewing, fixing, -and merging PRs — see `AUTOBUILD.md`. Chat sessions here do architecture, not -implementation: refine DESIGN.md, author/adjust issues, improve the gate and -its prompts, and un-stick escalation loops (an issue that escalated twice -needs its spec rewritten, not another attempt). Don't hand-implement a feature -an open issue covers — fix the issue text instead. Before the GitHub repo -exists, `scripts/bootstrap-github.sh` is the launch switch; it commits, so it -runs only when Dan says go. - ## Session start -0. **Check for tripped breakers first** (once the GitHub repo exists): - `gh issue list --label needs-chat --state open`. A hit means the autonomous - loop is halted waiting for exactly this conversation — read the issue's - gatekeeper agenda comment, decide with Dan whether each surviving finding - is an implementation defect (tighten acceptance criteria) or a spec defect - (rewrite scope), edit the issue, then re-arm: - `gh issue edit --remove-label needs-chat --add-label Sandcastle`. -1. The build is complete — all phases shipped. Remaining or newly-scoped work lives as issues in `issues/` (filed to GitHub, built by Sandcastle) and as open `question` concepts in [.why/](.why/index.md). The project's decision memory *is* the `.why/` bundle, not a plan file: consult it before changing load-bearing code, and record durable choices back into it. `HOWTO.md` is the operator's guide for running `why` on any repo. -2. `DESIGN.md` is the source of truth for the schema and architecture. If reality disagrees with it, fix DESIGN.md in the same session and record the change as a `decision` concept in `.why/`. +1. `why` is built and shipping — all ten subcommands, both viewers, and the three + CI recipes are live. Work now arrives as GitHub issues and as open `question` + concepts in [.why/](.why/index.md). +2. **The project's decision memory *is* the `.why/` bundle.** Consult it before + changing load-bearing code — `why blame ` on this repo answers "why is + this like this" faster than reading the diff history — and record durable + choices back into it. +3. `DESIGN.md` is the source of truth for the schema and architecture. If reality + disagrees with it, fix DESIGN.md in the same session and record the change as + a `decision` concept in `.why/`. +4. `CONTRIBUTING.md` is the development workflow (branch, `npm run verify`, PR). + `HOWTO.md` is the operator's guide for running `why` on any repo. ## Ground rules @@ -32,8 +23,16 @@ runs only when Dan says go. - **Anchors are live or lost, never silently wrong.** No code path may quietly emit a stale anchor. - Every `why` bundle must stay a valid plain-OKF bundle — all extensions live in the `why:` frontmatter map and section conventions. If a feature needs to break that, it's a design discussion, not a patch. - `examples/harbor/` is both the demo and the test fixture — schema changes must update it in the same session. +- Run `npm run verify` before proposing a change as done. The PR gate runs `why lint` + `why anchor --check --allow-drift` on this repo's own bundle. +- **Never run `why anchor` (write mode) on a branch.** `as_of` must name a commit `main` keeps, and a squash merge discards branch commits — the `why-anchor` job re-stamps drift from `main` after the merge. Reporting drift on a branch is expected; the gate only fails when a change *destroys* an anchor. ## Neighbors - `../okf-mcp` — the OKF MCP server this builds on (same author, source available locally). Its README documents the full tool surface; prefer depending on it over reimplementing bundle handling. - Serve the example: `cd ../okf-mcp && npm run dev -- --bundle harbor=../why/examples/harbor inspect` + +## Repo tooling + +`.sandcastle/` holds an optional agent pipeline that built the initial codebase +and is kept for well-specified batch work; it is not the default path for +changes and nothing shipped depends on it. See `.sandcastle/README.md`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5977fda --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,89 @@ +# Contributing to `why` + +Thanks for taking an interest. `why` is decision archaeology for codebases — +see [README.md](README.md) for what it does and [DESIGN.md](DESIGN.md) for the +schema and architecture, which is the source of truth for both. + +## Setup + +Node ≥ 20. + +```bash +git clone https://github.com/copperbox/why && cd why +npm install +npm run verify # typecheck + tests + vscode extension tests +npm run why -- doctor # run the CLI from source against this repo's own bundle +``` + +`npm run why -- ` runs the CLI from TypeScript source via `tsx`; use it +instead of a global install while developing. + +## Workflow + +1. Open an issue before large changes so the design gets discussed first — a PR + that contradicts DESIGN.md is a design conversation, not a patch. +2. Branch from `main`. +3. Make the change, with tests that fail without it. +4. `npm run verify` must pass. +5. Open a PR describing what changed and why. + +The PR gate runs `why lint` and `why anchor --check --allow-drift` against this +repo's own `.why/` bundle, plus the test suite. + +**Don't run `why anchor` on your branch.** If your change moves code that +concepts anchor to, the gate reports the drift and lets it through: the +`why-anchor` job re-stamps it from `main` after the merge. Anchoring from a +branch would write an `as_of` pointing at a commit the squash merge discards +(DESIGN.md §4), which is the one thing that cannot be done correctly from here. + +The gate does fail if your change **destroys** an anchor — deletes code a +concept claims. No re-anchoring recovers that; update the concept, or file a +`question` if the rationale no longer has a home. + +## The invariants + +These are the promises the tool makes to its users. A change that breaks one +gets rejected regardless of how convenient it is: + +- **Anchors are live or `lost`, never silently wrong.** Any code path that + resolves or rewrites anchors must either succeed verifiably or mark the + anchor `lost`. No best-effort guesses. +- **Rationale never exceeds its evidence.** Code that renders or writes + concepts must respect the confidence ladder (DESIGN.md §2); anything below + `corroborated` renders hedged. Unrecoverable rationale becomes a `question` + concept, never a plausible guess. +- **Every bundle stays valid plain OKF.** `why`-specific data lives only in the + `why:` frontmatter map and section conventions. Nothing may write a bundle + that `okf-mcp validate` rejects. +- **DESIGN.md is the contract.** If an implementation deviates from it, the PR + changes DESIGN.md in the same diff with the reasoning, or it is wrong. + +## Style + +- TypeScript, strict mode, ESM (`type: module`). +- camelCase functions/variables, PascalCase types. Named exports only. +- Small focused modules, one concern per file. +- Comments state constraints the code can't show — no narration. + +## Testing + +- New behavior needs tests that fail without the change. +- Use `examples/harbor/` as the fixture bundle. It is both the demo and the + test fixture, so a schema change updates it in the same PR. +- If a test needs a git history, build a throwaway repo in a temp dir inside + the test — never depend on this repo's own history. +- Test names describe behavior ("blame renders expired constraints as + warnings"), not implementation. + +## Dependencies + +Prefer `@copperbox/okf-mcp` for all bundle reading and writing over hand-rolled +parsing. New dependencies need a one-line justification in the PR body. + +## Decisions get recorded + +This repo runs `why` on itself. When you make a durable design choice, record it +as a concept in [`.why/`](.why/index.md) — that bundle, not a wiki, is the +project's memory. Merged PRs get drafted into `.why/.drafts/` automatically by +`why capture`; promoting a draft into a real concept is a normal part of +finishing work. diff --git a/DESIGN.md b/DESIGN.md index 16567fe..5579933 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -40,7 +40,8 @@ why: - path: src/lock.rs symbol: acquire_shared # optional but strongly preferred over bare lines lines: 41-58 - as_of: a3f9c2e # commit at which path+lines were valid + as_of: a3f9c2e # commit at which path+lines were valid; must be + # an ancestor of the integration branch (§4) state: live # live | lost — maintained by `why anchor`, never by hand --- ``` @@ -120,9 +121,23 @@ The hard engineering problem. Design decisions: **Resolution order** (`why anchor`, run in CI or pre-commit): -1. **Symbol-first.** If `symbol` is set, find it at HEAD (ctags/tree-sitter per language; fall back to a grep heuristic). Found in the same file → update `lines`, done. Found in a different file → follow only if git history connects them (rename/move detection via `git log --follow -M -C` between `as_of` and HEAD). +1. **Symbol-first.** If `symbol` is set, find it at HEAD (ctags/tree-sitter per language; fall back to a grep heuristic). Found in the same file → update `lines`, done. Found in a different file → follow only if git history connects them (rename/move detection via `git log --follow -M -C` between `as_of` and HEAD) **and `as_of` is an ancestor of HEAD** — see "`as_of` must be an ancestor" below. 2. **Blame-trace.** No symbol, or symbol gone: trace the anchored lines forward from `as_of` with incremental `git blame`-style tracking (the same problem `git log -L` solves). Lines that survive → new range. -3. **Lost.** Neither resolves → set `state: lost`, keep the last-known anchor for forensics, surface in `why doctor`. A lost anchor on an `active` concept is a warning; ten lost anchors after a big refactor is the signal to re-dig that area. +3. **Unverified.** `as_of` is not an ancestor of HEAD (or does not resolve here), so there is no history to trace from, but the anchored path is still present at HEAD → leave the entry exactly as recorded, report `unverified as_of`. The claim is neither confirmed nor refuted: the code is there, the provenance is unreadable. `why anchor` writes nothing, and `why doctor` reports it. This is the fate only of a bare line claim — a whole-file or symbol claim that re-verifies at HEAD without reading `as_of` gets its orphaned `as_of` repaired instead (see "Which commit `why anchor` stamps"). +4. **Lost.** None of the above resolves → set `state: lost`, keep the last-known anchor for forensics, surface in `why doctor`. A lost anchor on an `active` concept is a warning; ten lost anchors after a big refactor is the signal to re-dig that area. + +**`as_of` must be an ancestor.** An `as_of` is readable as history only when it is an ancestor of HEAD. This is not pedantry — it is the difference between a reproducible tool and a superstition: + +- git answers `diff`/`blame` between *any* two commits it happens to have locally, related or not. A rename detected across a non-ancestor `as_of` therefore resolves on the machine whose clone still has that branch and fails in a fresh clone of the integration branch — the same bundle, the same commit, two different answers. +- `git blame --reverse` interprets `-L` against the **`as_of`** revision, not HEAD. So a plausible-but-unverified `as_of` does not merely fail; it re-points the anchor at whatever text occupied those line numbers there. That is the silently-wrong anchor §2's promise forbids. + +Ancestry is the only property of an `as_of` that every clone of the same history agrees on, so it gates every read of history through one. + +**Which commit `why anchor` stamps.** HEAD is the commit the span was verified against, so HEAD is the default and always truthful. But a squash merge (or rebase) rewrites a whole branch into one new commit, so an `as_of` stamped on a branch names a commit that never reaches the integration branch — truthful when written, unresolvable a day later. So when HEAD is *not* contained in the integration branch (`refs/remotes/origin/HEAD`, when git records one), `why anchor` prefers the merge-base — the newest commit certain to survive — **but only when the identical span verifiably holds there**. It usually does not for a span the branch just changed, and inventing one would be exactly the silently-wrong anchor above; so an unverifiable merge-base loses to a truthful HEAD, which may later orphan and degrade to `unverified` (step 3). + +A stable anchor with a *readable* `as_of` is never re-stamped: a clean-ancestor `as_of` is provenance — the span survived unchanged since that commit ([as_of is provenance](.why/decisions/as-of-is-provenance.md)). An **orphaned** `as_of` on an otherwise-current claim carries no such meaning — no clone of the integration branch can resolve it — so `why anchor` repairs it, under two conditions ([decision](.why/decisions/orphaned-as-of-is-repaired.md)). First, the claim must have re-verified at HEAD without reading `as_of` at all: the path is present (whole-file claim) or the symbol was re-found — a bare line claim stays `unverified` (step 3) because re-stamping it would assert lines nothing verified. Second, the stamp must be durable: HEAD when `refs/remotes/origin/HEAD` contains it or git records no integration branch, the merge-base on a diverged branch only when the span verifiably holds there — and otherwise no repair, leaving the orphan reported for the post-merge run rather than stamping a branch HEAD the next squash would discard and re-orphan. + +**Anchors are written from the integration branch.** The stamping rule above is damage control for a `why anchor` run somewhere it shouldn't be; the operational rule is that anchors are re-stamped by CI on `main` after a merge, never by a contributor on a branch (docs/ci.md). Only the integration branch hands out commits it keeps, so this is the only place every `as_of` is durable by construction — including for a file *born* on a squashed branch, where no earlier commit exists to point at and no branch-side stamp could have been right. The PR gate therefore reports drift without failing on it (`why anchor --check --allow-drift`) and fails only on an anchor the change destroyed. The cost is that the bundle is eventually consistent: between a merge and the re-anchor landing, `main`'s anchors can be stale — honestly `lost` or `unverified`, never silently wrong. **Anchors update mechanically, concepts don't.** `why anchor` rewrites only the `why.anchors` entries (via okf-mcp `update_concept`, which patches frontmatter without touching the body). It never edits narrative. @@ -182,7 +197,7 @@ Same data over MCP: agents mount the bundle via okf-mcp and get story-of-this-co ## Open problems -Tracked honestly; the build's phased issues in [issues/](issues/) each retired or narrowed one. +Tracked honestly. These are the questions v1 does not settle. 1. **Anchor drift under heavy refactoring.** Symbol + blame-trace should survive renames and moves; wholesale rewrites (v1 → v2 of a subsystem) are genuinely new code — is `lost` + re-dig the right answer, or should decisions carry forward through a human-confirmed "successor anchor"? *Phase 2 decides with real data.* 2. **Hallucination pressure at scale.** One agent per episode with citation requirements is the design; does it hold when episodes are thin (terse commit messages, no PRs)? May need an adversarial verify pass — a second agent trying to refute each ≥`inferred` claim. *Phase 3 measures on a real repo before adding cost.* diff --git a/HOWTO.md b/HOWTO.md index e625852..cc23f11 100644 --- a/HOWTO.md +++ b/HOWTO.md @@ -121,10 +121,10 @@ by the people who lived it — that review is itself high-quality evidence. --- -## 3. Wire up CI — three jobs, then you can forget about it +## 3. Wire up CI — four jobs, then you can forget about it The whole point is that no human has to *remember* to keep the archive honest. -Three GitHub Actions jobs do it. This repo runs all three on its own `.why/` +Four GitHub Actions jobs do it. This repo runs all four on its own `.why/` bundle as the live demo — copy them from [`.github/workflows/`](.github/workflows) and read [docs/ci.md](docs/ci.md) for the annotated versions. In a consuming repo, replace `npm ci` + `npm run why --` @@ -134,25 +134,62 @@ Every job needs **`fetch-depth: 0`** — anchor tracing and capture read history from the `as_of`/merge commit forward; a shallow clone makes honest anchors unresolvable. +One rule explains the shape: **anchors are written from `main`, never from a +branch.** An `as_of` records the commit a span was verified at, and if you +squash-merge (most repos do), a branch's commits are rewritten into one new +commit — so an `as_of` stamped on a branch names something `main` never had. +Only `main` hands out commits `main` keeps. + | Job | Trigger | Command | Why | |---|---|---|---| -| **PR gate** | every PR | `why lint` + `why anchor --check` | the archive may not merge in a state it can't back | +| **PR gate** | every PR | `why lint` + `why anchor --check --allow-drift` | the archive may not merge in a state it can't back | +| **Re-anchor** | push to `main` | `why anchor` | spans that moved get re-stamped at a commit that survives | | **Weekly audit** | cron (e.g. Mon) | `why audit` | constraints get re-verified; expiry becomes a visible event | | **Post-merge capture** | PR closed | `why capture --pr ` | new decisions get drafted while rationale is fresh | -### 3.1 PR gate — `why lint` + `why anchor --check` - -Fails on schema errors (missing sections, bad edge targets, uncited -confidence claims) and on **anchor drift** — `why anchor --check` re-resolves -every anchor against the PR's HEAD and exits 1 without writing. The fix a -contributor makes is to run `why anchor` locally and commit the frontmatter -update. An anchor already committed as `state: lost` does *not* fail the gate -(that's `doctor`'s job to surface, not a merge blocker). - -This is the job that makes "anchors are live or lost, never silently wrong" a -mechanical guarantee instead of a hope. - -### 3.2 Weekly audit — `why audit` +### 3.1 PR gate — `why lint` + `why anchor --check --allow-drift` + +Fails on schema errors (missing sections, bad edge targets, uncited confidence +claims), and on an anchor the PR **destroyed** — code a concept claimed, now +gone. That is the author's to resolve, because no re-anchoring brings it back: +update the concept or file a `question`. + +It does *not* fail on drift (a span that merely moved). Drift is reported and +left to the re-anchor job. This is deliberate: the only way a contributor could +"fix" drift on their branch is `why anchor`, which stamps an `as_of` at a branch +HEAD the squash then discards — the gate would be demanding the one thing that +cannot be done right from a branch. An anchor already committed as +`state: lost` does not fail either (that's `doctor`'s job to surface, not a +merge blocker). + +Drop `--allow-drift` for the strict question — "is this bundle fully current +with this commit?" — which is the right check on `main`, not on a PR. + +### 3.2 Re-anchor — `why anchor` on `main` + +Triggers on every push to `main`, re-resolves every anchor against the commit +that actually landed, and PRs the frontmatter-only result back. Running from +`main` is what makes every `as_of` durable — including for a file *born* on the +squashed branch, which has no earlier commit to point at and which a +contributor could never have anchored correctly. It also repairs the orphans a +squash leaves behind: an `as_of` naming a discarded branch commit is re-stamped +to the landed commit whenever the claim re-verifies at HEAD without it (a +present whole-file path, a re-found symbol). A bare `path + lines` claim is the +exception — nothing can verify the lines without readable history, so it stays +`unverified as_of` rather than guessed at (DESIGN.md §4). + +It PRs back rather than pushing, so branch protection stays on. Squashing that +PR is harmless: the `as_of` values inside name `main` commits, and content +survives a squash unchanged. Exclude the branch it opens from your capture job, +or capture will draft a concept about the anchor bot. + +The trade: anchors on `main` are briefly stale between a merge and the +re-anchor PR landing. The bundle is eventually consistent — during that window +`why blame` may report an old span, and it reports an honest `lost`, never a +wrong one. Together these two jobs are what make "anchors are live or lost, +never silently wrong" a mechanical guarantee instead of a hope. + +### 3.3 Weekly audit — `why audit` Sweeps every active constraint: runs `verify.method: check` commands directly, flags overdue `review_by` dates, and lists `method: ask` constraints for an @@ -168,7 +205,7 @@ The `ask`-constraints the job merely *reports* — answering them is an agent session: `why audit --questions-out questions.json`, an agent fills it in, `why audit --answers answers.json` applies it. The CLI never calls an LLM. -### 3.3 Post-merge capture — `why capture --pr ` +### 3.4 Post-merge capture — `why capture --pr ` When a PR closes, drafts a concept from its description and review thread into `.why/.drafts/` — merged → `decision`, closed-unmerged → `attempt`, anchors @@ -226,13 +263,17 @@ explicitly — put them in your contributing guide and your `CLAUDE.md`. Once bootstrapped, the cadence is light: -- **Per PR** — the gate runs automatically. Contributors run `why anchor` - locally and commit its frontmatter update when the gate flags drift. -- **Per merge** — capture drafts automatically onto `why-drafts`. +- **Per PR** — the gate runs automatically. Contributors do nothing about + drift; they act only when the gate says an anchor was *destroyed*, which + means a concept lost the code it described. +- **Per merge** — re-anchoring re-stamps drift from `main` onto `why-anchors`, + and capture drafts onto `why-drafts`. Both arrive as PRs. - **Weekly** — the audit runs. Pair a weekly look at the `why-drafts` branch (promote or discard accumulated capture drafts via `skills/capture` or by hand) with triaging any audit issue and its write-back PR. This is the one - standing ~30-minute ritual. + standing ~30-minute ritual. The `why-anchors` PRs are mechanical and + frontmatter-only — merge them promptly (or enable auto-merge); every hour one + sits is an hour `main`'s anchors are stale. - **On big refactors** — a wave of `lost` anchors in `why doctor` is the signal to **re-dig that area**: the code is genuinely new, so let the archaeology catch up rather than forcing stale anchors forward. @@ -296,7 +337,8 @@ skills for the agent sessions that run the archaeology and promotion passes. - [ ] `why init --capture-snippet`, commit the empty bundle - [ ] Cold-start dig over tells + hot files; open as reviewed PRs - [ ] `why anchor && why lint && why doctor` all clean -- [ ] PR gate workflow (`lint` + `anchor --check`, `fetch-depth: 0`) +- [ ] PR gate workflow (`lint` + `anchor --check --allow-drift`, `fetch-depth: 0`) +- [ ] Re-anchor workflow on push to `main` (`why-anchors` branch), excluded from capture - [ ] Weekly audit workflow (issue + write-back PR on exit 1) - [ ] Post-merge capture workflow (`why-drafts` branch) - [ ] Contributing guide + `CLAUDE.md`: consult-before-change, record-while-fresh, file-a-question-not-a-guess diff --git a/README.md b/README.md index 08c6f64..4205105 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Links between concepts are ordinary markdown links; the section a link sits in ( Two properties are non-negotiable and shape everything: 1. **Honesty about confidence.** Reconstructed history is partly inference. Every concept carries `confidence: recorded | corroborated | inferred | speculative`, and rationale below `corroborated` is always rendered with hedging. A wrong "why" stated confidently is worse than no "why". Unrecoverable rationale becomes a `question` concept, not a guess. -2. **Anchors are live or dead, never silently stale.** Every code-touching concept anchors to `path + symbol + line range + as-of commit`. `why anchor` re-resolves anchors across renames and refactors; an anchor it cannot re-resolve is marked `lost` and surfaces in `why doctor` — it never silently points at the wrong code. +2. **Anchors are live or dead, never silently stale.** Every code-touching concept anchors to `path + symbol + line range + as-of commit`. `why anchor` re-resolves anchors across renames and refactors; an anchor it cannot re-resolve is marked `lost` and surfaces in `why doctor` — it never silently points at the wrong code. Where it can confirm the code but not the provenance, it says exactly that (`unverified as_of`) rather than guessing in either direction — see [Squash merges and `as_of`](#squash-merges-and-as_of). ## Why OKF as the substrate @@ -85,17 +85,48 @@ Named here so we never pretend otherwise (expanded in [DESIGN.md §Open problems - **Hallucinated rationale** is the hard trust problem. The confidence ladder and evidence-citation requirements exist because a decision archive people can't trust is worse than none. - **Cold start** is the hard adoption problem. Nobody hand-writes ADRs retroactively; `why dig` must produce a genuinely useful first bundle from history alone, unattended, or the tool never gets a chance. +## Squash merges and `as_of` + +If your project squash-merges (or rebases) pull requests — most do — this section is worth two minutes, because it explains a report you will eventually see. + +Every anchor records an `as_of`: the commit at which `why` last verified that this concept was about this code. It is provenance, and it is also how re-anchoring works — `why anchor` traces your lines *forward from `as_of`* to find where they live now. + +A squash merge throws that commit away. Ten commits on `add-rate-limiter` become one brand-new commit on `main`, and the branch commits are never part of `main`'s history. So an anchor stamped while you were on the branch names a commit that, after the merge, exists only in the clone that made it: + +``` +main A ─────────────── S S = the squash. Your branch's commits + \ / are not in main's history at all. +branch B ─── C ───┘ C = what `as_of: c0ffee` pointed to +``` + +Anchor `as_of: c0ffee` still resolves on *your* laptop, because your clone kept the branch. In CI — a fresh clone of `main` — that commit does not exist. A tool that read history through it would answer differently on each machine, which is worse than a tool that fails: it would be a health report you cannot reproduce. + +**What `why` does about it.** + +- **`why anchor` avoids creating the problem.** When you are on a branch that git can tell is not the integration branch, it stamps the *merge-base* — the newest commit certain to survive the squash — instead of your branch HEAD, but only when it can verify the identical span is really there. When it can't verify that (usually because your branch is what changed those lines), it stamps HEAD honestly rather than assert a span it didn't check. +- **Resolution never reads through an orphan.** An `as_of` that isn't an ancestor of HEAD is not used for rename-following or blame-tracing, even when your local clone could technically answer. This is what keeps CI and your laptop agreeing. +- **An unreadable `as_of` is not a dead anchor.** If the path is still there but the recorded *lines* can't be re-traced — no symbol to re-find, no readable `as_of` to trace from — `why anchor` reports `unverified as_of`, changes nothing, and `why doctor` lists it. The code is fine; only the provenance is unreadable. Calling that `lost` would be a false alarm, and false alarms are how a health report gets ignored. +- **An orphaned `as_of` is repaired, not kept as a scar.** When the claim re-verifies at HEAD without reading `as_of` at all — the file is present for a whole-file anchor, or the symbol is re-found at its recorded span — `why anchor` re-stamps the orphan, and only to a commit that survives: HEAD on the integration branch, or the merge-base on a feature branch when the identical span verifiably holds there. When neither is available it declines and leaves the orphan reported, rather than write a branch HEAD the next squash would discard. + +**What you'll see.** `why doctor` may report a few `stale as_of` findings right after a squash-merged PR — yellow, not red, and they don't fail CI: the anchor isn't asserting anything false, it just can't show its work. The post-merge `why-anchor` job clears them on the next run from `main`, re-stamping each orphan whose claim still verifies at HEAD to the commit that landed. + +One case stays yellow: a bare `path + lines` anchor whose `as_of` was squashed away. With no symbol to re-find and no history to trace from, nothing can verify what those line numbers name anymore, so `why anchor` reports `unverified as_of` and refuses to guess — re-stamping would assert a span nothing verified. That finding is accurate and needs a human (or a re-dig) to confirm the lines; it is one more reason DESIGN.md prefers `symbol` over bare line ranges. + +An `as_of` that is a clean *ancestor* of HEAD is never re-stamped, orphan repair included: an old ancestor `as_of` means the anchor has *survived unchanged* since then — provenance, not drift ([decision](.why/decisions/as-of-is-provenance.md)). + +Nothing here requires configuration. `why` reads your integration branch from `refs/remotes/origin/HEAD` (git records it at clone time; `git remote set-head origin -a` refreshes it). Where there is no such record — a single-branch CI clone, a repo with no remote — `why` stamps HEAD, which is what it did before and remains truthful. + ## Self-hosted `why` runs on its own repository: [`.why/`](.why/index.md) is this repo's live decision archive — the project's bootstrap decisions captured as `decision` concepts (confidence `recorded`, citations to the actual commits and PRs) — and the living demo of the schema on a real codebase. It stays true -mechanically: every PR runs `why lint` + `why anchor --check`, a weekly job -runs `why audit`, and merged PRs get drafted into `.why/.drafts/` by -`why capture` — the exact workflows documented in [docs/ci.md](docs/ci.md), -active under [`.github/workflows/`](.github/workflows). Browse it like any -bundle: +mechanically: every PR runs `why lint` + `why anchor --check --allow-drift`, +every push to `main` re-anchors from the commit that landed, a weekly job runs +`why audit`, and merged PRs get drafted into `.why/.drafts/` by `why capture` — +the exact workflows documented in [docs/ci.md](docs/ci.md), active under +[`.github/workflows/`](.github/workflows). Browse it like any bundle: ```bash npx -y @copperbox/okf-mcp --bundle why=.why inspect @@ -103,11 +134,11 @@ npx -y @copperbox/okf-mcp --bundle why=.why inspect ## Getting started -`why` is built and functional: all ten subcommands ship, the three CI recipes -run on this repo's own `.why/` bundle, and both viewers (`why serve` and the -VS Code extension) render live. **[HOWTO.md](HOWTO.md) is the adoption guide** — -scaffold a bundle, cold-start a dig, wire up the PR gate / weekly audit / -post-merge capture jobs, and the team habits that make it pay off. +All ten subcommands ship, the four CI recipes run on this repo's own `.why/` +bundle, and both viewers (`why serve` and the VS Code extension) render live. +**[HOWTO.md](HOWTO.md) is the adoption guide** — scaffold a bundle, cold-start +a dig, wire up the PR gate / re-anchor / weekly audit / post-merge capture jobs, and the +team habits that make it pay off. ```bash npx -y @copperbox/why init --capture-snippet # scaffold .why/ + teach CLAUDE.md @@ -125,6 +156,7 @@ and each operational recipe has a page under [`docs/`](docs). |---|---| | [HOWTO.md](HOWTO.md) | Adoption guide — get a team running `why` on any repo | | [DESIGN.md](DESIGN.md) | Full schema and architecture spec — the source of truth | +| [CONTRIBUTING.md](CONTRIBUTING.md) | Development setup, the invariants, and how to send a PR | | [CLAUDE.md](CLAUDE.md) | Orientation for agent sessions in this repo | | [examples/harbor/](examples/harbor/) | A hand-authored example bundle for a fictional service — the schema, fully realized in six concepts | | [.why/](.why/index.md) | This repo's own decision archive — the self-hosted bundle, kept honest by the CI recipes in [docs/ci.md](docs/ci.md) | diff --git a/docs/ci.md b/docs/ci.md index 7e4d836..3b898ed 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -1,10 +1,19 @@ # CI recipes — the operational story -Three jobs keep an archive honest without anyone remembering to run anything: -a PR gate (the bundle may not merge in a state it cannot back), a weekly -audit (constraints are re-verified and expiry becomes a visible event), and -post-merge capture (new decisions are drafted while the rationale is fresh). All -three are run-to-completion commands — no daemon (DESIGN.md §8). +Four jobs keep an archive honest without anyone remembering to run anything: a +PR gate (the bundle may not merge in a state it cannot back), post-merge +re-anchoring (spans that moved are re-stamped from main), a weekly audit +(constraints are re-verified and expiry becomes a visible event), and post-merge +capture (new decisions are drafted while the rationale is fresh). All four are +run-to-completion commands — no daemon (DESIGN.md §8). + +**Anchors are written from `main`, never from a branch.** This is the rule the +whole shape follows from. An `as_of` records the commit a span was verified at, +and a squash merge rewrites a branch's commits into one new commit — so an +`as_of` stamped on a branch names a commit that never reaches `main`, and a +day later names nothing at all. Only `main` hands out commits that `main` +keeps. Everything below is a consequence: the gate does not ask contributors to +re-anchor, and a separate job does it after the merge. The workflows below are live in this repository under [`.github/workflows/`](../.github/workflows), running `why` on its own @@ -14,14 +23,27 @@ package. In a repo that consumes `why` as a dependency, replace `npm ci` + `npm run why -- …` with `npx -y @copperbox/why …` and drop the Node setup to taste; everything else transfers unchanged. -## The PR gate: `why lint` + `why anchor --check` +## The PR gate: `why lint` + `why anchor --check --allow-drift` `why lint` fails on schema errors (missing required sections, bad edge -targets, confidence claims without citations). `why anchor --check` re-resolves -every anchor claim against the PR's HEAD and exits 1 on drift *without -writing anything* — the fix is to run `why anchor` locally and commit the -frontmatter update it makes. An anchor already committed as `state: lost` -does not fail the gate; surfacing those is `why doctor`'s job. +targets, confidence claims without citations). + +`why anchor --check --allow-drift` re-resolves every anchor claim against the +PR's HEAD, writes nothing, and exits 1 on exactly one thing: an anchor this PR +**destroyed** — code a concept claimed, now gone. That is the author's problem, +because no amount of re-anchoring recovers it; the concept needs updating or +re-digging. + +Drift — a span that merely moved — deliberately does **not** fail. It is +reported, then left to the `why-anchor` job. Failing on drift would force the +author to run `why anchor` on their branch, which stamps `as_of` at a branch +HEAD the squash merge then throws away: the gate would be demanding the one +thing that cannot be done correctly from a branch. An anchor already committed +as `state: lost` does not fail either; surfacing those is `why doctor`'s job. + +Use plain `why anchor --check` (no `--allow-drift`) when you want the strict +question — "is this bundle fully up to date with this commit?" — which is the +right check on `main`, not on a PR. `.github/workflows/why-pr-gate.yml`: @@ -29,8 +51,14 @@ does not fail the gate; surfacing those is `why doctor`'s job. name: why-pr-gate # PR gate (docs/ci.md): the archive may not merge in a state it cannot back. -# `why lint` enforces the schema; `why anchor --check` fails on anchor drift -# without writing anything — run `why anchor` locally and commit the result. +# `why lint` enforces the schema. `why anchor --check --allow-drift` fails only +# on an anchor this PR *destroyed* — code it claimed is gone, which no +# re-anchoring can recover and which the author has to resolve. +# +# Drift (a span that merely moved) is deliberately not a failure: fixing it here +# would mean running `why anchor` on this branch, stamping an `as_of` that the +# squash merge then discards (DESIGN.md §4). The why-anchor job re-stamps drift +# from main after the merge, where the commit survives. on: pull_request: @@ -47,7 +75,92 @@ jobs: cache: npm - run: npm ci - run: npm run why -- lint - - run: npm run why -- anchor --check + - run: npm run why -- anchor --check --allow-drift +``` + +## Post-merge re-anchoring: `why anchor` from `main` + +The job that makes the rule above true. It triggers on every push to `main`, +re-resolves every anchor against the commit that actually landed, and PRs the +frontmatter-only result back. Because it runs with `main` checked out, every +`as_of` it writes names a commit `main` keeps — including for a file *born* on +the squashed branch, which has no earlier commit to point at and is the one +case a contributor could never have anchored correctly. + +That includes repairs. An anchor whose `as_of` the squash orphaned but whose +claim still verifies at HEAD — a whole-file path that is present, a symbol +re-found at its recorded span — is re-stamped to the landed commit, so the +`stale as_of` findings a squash leaves behind clear on the next merge. The one +exception is a bare `path + lines` claim: with no symbol to re-find and no +readable history to trace from, nothing can verify the lines, and `why` +reports `unverified as_of` rather than guess (DESIGN.md §4). + +It PRs back rather than pushing, so branch protection stays on and the diff is +reviewable. Squashing *this* PR is harmless: the `as_of` values inside the +files name `main` commits, and content survives a squash unchanged. The job is +idempotent — a run with nothing to re-stamp opens no PR — so the push its own +merge generates simply finds nothing to do. + +Two things to know. Anchors on `main` are briefly stale between a merge and +this PR landing: the bundle is eventually consistent, and `why blame` may +report an old span in that window (it reports an honest `lost`, never a wrong +one). And the `why-anchors` branch is excluded from `why-capture` below, or +capture would draft a concept about the anchor bot. + +`.github/workflows/why-anchor.yml`: + +```yaml +name: why-anchor + +# Post-merge re-anchoring (docs/ci.md, DESIGN.md §4). Anchors are re-stamped +# only from main, never from a branch: `as_of` records the commit a span was +# verified at, and a squash merge rewrites branch commits into one new commit, +# so an as_of stamped on a branch names something that never reaches main. Run +# from main, every as_of is a commit main keeps. +# +# The result is PR'd back rather than pushed, so branch protection stays on and +# the frontmatter-only diff is reviewable. The as_of values inside the files +# name main commits, so squashing *this* PR does not orphan them. +on: + push: + branches: [main] + +# One at a time: two merges in quick succession would otherwise race to write +# the same branch. +concurrency: + group: why-anchor + cancel-in-progress: false + +jobs: + anchor: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 # anchor resolution traces line ranges from as_of to HEAD + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run why -- anchor + - uses: peter-evans/create-pull-request@v6 + with: + branch: why-anchors + add-paths: .why + title: "why: re-anchor from main" + commit-message: "why anchor: re-stamp from ${{ github.sha }}" + body: | + Mechanical re-anchoring from `main` (`${{ github.sha }}`). + + `why anchor` rewrites only `why.anchors` entries — never narrative — + so this diff is frontmatter-only. Anchors are stamped here rather + than on the contributing branch because a squash merge discards + branch commits, and an `as_of` must name a commit main keeps. ``` `fetch-depth: 0` matters: blame-tracing an anchor from its `as_of` commit to @@ -156,6 +269,9 @@ on: jobs: capture: + # `why`'s own bot PRs carry no rationale to capture — drafting from them + # would recurse and record the machinery instead of a decision. + if: github.event.pull_request.head.ref != 'why-drafts' && github.event.pull_request.head.ref != 'why-anchors' runs-on: ubuntu-latest permissions: contents: write diff --git a/docs/digging.md b/docs/digging.md index f0d9ee2..0a0cb6c 100644 --- a/docs/digging.md +++ b/docs/digging.md @@ -37,11 +37,6 @@ histories — and flags *tells*: reverts, fix-after-fix chains, sudden churn on long-quiet files, comment tells (`HACK`, `workaround`, `for now`, …). Tells mark high-value dig sites; use them to order step 3. -(This subcommand is specified by `issues/301-dig-episodes.md`, earlier in -Phase 3 than the skills. If your build predates it, write the episodes JSON -by hand — `docs/dig-evidence.md` documents the tolerant shape step 2 -accepts.) - ### 2. Assemble evidence packs ```bash diff --git a/issues/101-cli-and-bundle-loading.md b/issues/101-cli-and-bundle-loading.md deleted file mode 100644 index f0d864a..0000000 --- a/issues/101-cli-and-bundle-loading.md +++ /dev/null @@ -1,24 +0,0 @@ -# CLI foundation: subcommand dispatch, bundle discovery, and schema-aware loading -Labels: Sandcastle, phase:1 - -## Context - -`src/cli.ts` is a stub that knows the command names and nothing else. Everything in Phase 1 (DESIGN.md §8 "Implementation shape") builds on a real CLI foundation and a typed in-memory view of a `.why/` bundle. Bundle *parsing* must come from `@copperbox/okf-mcp` (add it as a dependency) — do not hand-roll frontmatter or link parsing. - -## Scope - -- Subcommand dispatch with per-command flag parsing (node's `util.parseArgs` is enough; no CLI framework). -- Bundle discovery: walk up from cwd to the nearest `.why/` directory; `--bundle ` overrides. A missing bundle is a clear error naming `why init`. -- `src/bundle.ts`: load a bundle via okf-mcp's library surface into typed structures — for each concept: id, OKF frontmatter, the parsed `why:` extension map (see DESIGN.md §2 for every field, including per-type `status` vocab, `confidence`, `anchors[]`, `verify`), body sections, and outgoing links tagged with the section they appear in (DESIGN.md §3 table). -- Malformed `why:` data must load permissively (OKF spirit): collect per-concept problems into a diagnostics list instead of throwing; `why lint` (separate issue) will render them. - -## Acceptance criteria - -- [ ] `npx tsx src/cli.ts blame --bundle examples/harbor` reaches the blame handler (which may still be unimplemented) instead of erroring on flags. -- [ ] `loadBundle("examples/harbor")` returns 6 concepts; `decisions/queue-based-locking` has 2 anchors, confidence `recorded`, and a `# Because of` link set of exactly `{incidents/2024-03-lock-stall, attempts/striped-rwlock}`. -- [ ] A concept with `confidence: banana` loads with a diagnostic, not an exception; the diagnostic names the file, the field, and the allowed values. -- [ ] Tests cover: discovery walking up from a nested cwd, `--bundle` override, missing-bundle error text, and the harbor assertions above. - -## Out of scope - -Rendering, linting rules, anchor resolution — later issues. Do not modify `examples/harbor/`. diff --git a/issues/102-why-init.md b/issues/102-why-init.md deleted file mode 100644 index 5b3717b..0000000 --- a/issues/102-why-init.md +++ /dev/null @@ -1,24 +0,0 @@ -# `why init` — scaffold a .why/ bundle -Labels: Sandcastle, phase:1 - -## Context - -DESIGN.md §1 (bundle layout) and §8 (`why init` scaffolds `.why/`, root `index.md` frontmatter, and a CLAUDE.md capture snippet). - -## Scope - -- `why init` in the current git repo: create `.why/` with the five type directories, a root `index.md` whose frontmatter declares `okf_version: "0.1"`, `generated: false`, and a one-line `description` naming the repo, and a `log.md` seeded with an init entry. -- Print a short next-steps message (mount command for okf-mcp, pointer to `why dig` as "coming later" if unimplemented). -- `--capture-snippet` writes (or appends, idempotently, between ``/`` markers) a short knowledge-capture instruction block into the repo's `CLAUDE.md` telling resident agents to consult the bundle before non-trivial work and record durable decisions after. -- Refuse to clobber: an existing `.why/` exits 1 with a clear message; `--force` is NOT offered (deleting an archive should never be one flag away). - -## Acceptance criteria - -- [ ] In a temp git repo, `why init` creates the layout above and running okf-mcp's validate over the new bundle yields zero errors. -- [ ] Running `why init` twice exits 1 the second time and changes nothing (directory mtime-level check not required; content equality is). -- [ ] `--capture-snippet` run twice produces exactly one snippet block. -- [ ] Tests build their own temp repos (per CODING_STANDARDS.md) and cover all three behaviors. - -## Out of scope - -Any digging or anchoring. No network, no gh calls. diff --git a/issues/103-why-lint.md b/issues/103-why-lint.md deleted file mode 100644 index 8b9363e..0000000 --- a/issues/103-why-lint.md +++ /dev/null @@ -1,26 +0,0 @@ -# `why lint` — schema checks on top of OKF validation -Labels: Sandcastle, phase:1 - -## Context - -DESIGN.md §3 ends with the `why lint` contract. OKF conformance is okf-mcp's job; this command enforces the `why`-schema layer above it, using the loaded bundle + diagnostics from the CLI-foundation issue. - -## Scope - -Rules, each with a stable id (`W###`), a severity (error/warning), file, and message: - -- `why:` field validity: unknown `status` for the concept's type, unknown `confidence`, malformed `anchors` entries, malformed `verify` blocks (exact vocab tables: DESIGN.md §2). -- Required sections: `# Why` on every `decision`; `# Citations` on any concept with confidence `inferred` or `speculative`; `# Still true?` on every `constraint`. -- Edge-section targets: links under `# Because of` / `# Instead of` / `# Superseded by` / `# Led to` must resolve to concepts of the types the DESIGN.md §3 table allows. -- Consistency: `status: superseded` ⇔ a `# Superseded by` section with ≥1 link; `status: expired` on a constraint requires `expired_on`. -- Output: human-readable by default (grouped by file), `--json` for machines; exit 0 clean, 1 on any error-severity finding, 2 on usage errors. - -## Acceptance criteria - -- [ ] `why lint examples/harbor` (or with `--bundle`) exits 0 with no findings — and if it doesn't, the fix is to this command or (only with clear justification in the PR body) to the example bundle. -- [ ] Tests include a fixtures directory of deliberately-broken concepts (built by the tests in a temp dir, not committed to `examples/`), covering every rule id at least once, asserting rule id + severity + file. -- [ ] `--json` output round-trips through `JSON.parse` and carries the same findings. - -## Out of scope - -Anchor liveness (that's `why doctor`), OKF-level conformance (delegate to okf-mcp, surface its findings pass-through under a distinct rule id). diff --git a/issues/104-why-blame-static.md b/issues/104-why-blame-static.md deleted file mode 100644 index 72a97a5..0000000 --- a/issues/104-why-blame-static.md +++ /dev/null @@ -1,26 +0,0 @@ -# `why blame` — the story behind a file or line range (static anchors) -Labels: Sandcastle, phase:1 - -## Context - -DESIGN.md §7 specifies the query; the README's example session is the rendering target. This issue is the *static* version: anchors are trusted as written (no re-resolution — Phase 2 adds that). This is the command that makes the whole tool legible, so rendering quality is in scope, not polish. - -## Scope - -- `why blame [:line[-line]]`: find concepts whose anchors cover the target (path match; line-range overlap when lines are given; whole-file anchors match any line). -- Expand one hop along `# Because of`, `# Instead of`, `# Superseded by` edges. -- Render per DESIGN.md §7: newest-first; status glyph (● active, ⚠ expired/superseded, ? question); type, `happened_on`, confidence; one-line description; indented edge lines (`because of ▸`, `instead of ▸`, `evidence ▸` from citations). -- Hedging is mandatory rendering logic, not copy: `inferred` prefixes its rationale line with "likely —"; `speculative` with "speculation, thin evidence —". -- Expired upstream constraints render as warnings with the downstream note (see the README example's last block). -- No anchored concepts → say so and list anchored concepts in the same directory (nearest-first), never empty output. `--json` emits the resolved structure. - -## Acceptance criteria - -- [ ] `why blame src/lock.rs:47 --bundle examples/harbor` output contains: the queue-based-locking block with its `because of`/`instead of` lines, and the expired-Acme warning block — assert on substrings, not exact bytes. -- [ ] `why blame config/defaults.toml:31` surfaces the open question with `?` glyph and no invented rationale. -- [ ] A path with no anchors produces the nearest-concepts fallback (test it). -- [ ] Hedging: a temp-fixture concept with `confidence: inferred` renders the hedge prefix (test fails if hedging is removed). - -## Out of scope - -Anchor re-resolution, `as_of` drift handling (Phase 2 — assume anchors are current), MCP exposure. diff --git a/issues/201-anchor-index.md b/issues/201-anchor-index.md deleted file mode 100644 index d4d3875..0000000 --- a/issues/201-anchor-index.md +++ /dev/null @@ -1,23 +0,0 @@ -# Anchor index: span→concept map with cache -Labels: phase:2 - -## Context - -DESIGN.md §4 and §7 step 1. Phase 2 makes anchors live; this issue is the shared lookup layer `blame`, `anchor`, and `doctor` all sit on. - -## Scope - -- Build an in-memory index from every concept's `why.anchors`: path → ordered interval list → concept ids, distinguishing `live`/`lost` state and whole-file anchors. -- Disk cache keyed by (bundle content hash, repo HEAD): stale key → rebuild. Cache lives under `.why/.cache/` (add to the init scaffold's generated `.gitignore` handling if needed — decide and document in the PR). -- `why blame` switches to the index; behavior from the Phase 1 issue must not regress. -- Lookup API returns, per hit: concept id, the anchor entry, and whether the anchor's `as_of` matches current HEAD (staleness flag — resolution itself is the next issue). - -## Acceptance criteria - -- [ ] All Phase 1 blame tests still pass unchanged. -- [ ] Overlap semantics tested: point query inside range, range query straddling two anchors, whole-file anchor, `lost` anchors excluded from normal lookup but retrievable via an explicit flag. -- [ ] Cache test: build, mutate a concept file, prove rebuild; mutate nothing, prove reuse (observable via injected build counter or similar — not timing). - -## Out of scope - -Re-resolving stale anchors (next issue). Symbol parsing. diff --git a/issues/202-blame-trace-resolver.md b/issues/202-blame-trace-resolver.md deleted file mode 100644 index 97b5d97..0000000 --- a/issues/202-blame-trace-resolver.md +++ /dev/null @@ -1,24 +0,0 @@ -# Blame-trace resolver: track a line range from as_of to HEAD -Labels: phase:2 - -## Context - -DESIGN.md §4 resolution order, step 2. The core liveness primitive: given `{path, lines, as_of}`, compute where those lines live at HEAD — or prove they're gone. Start with `git log -L,: ..HEAD --follow`-based tracing; measuring where that breaks is part of the deliverable. - -## Scope - -- `traceRange(repo, anchor) → { path, lines } | { lost: true, reason }`, pure library function, no bundle knowledge. -- Must handle: file renamed (follow), range shifted by edits above it, range partially edited (shrink to surviving lines; document the policy in code), file deleted (`lost: "file-deleted"`), range fully rewritten (`lost: "content-rewritten"`). -- Deterministic and side-effect-free: read-only git commands only. -- A `NOTES.md` section (or PR body) recording observed failure modes of the `-L` approach on the test matrix — this feeds the DESIGN.md open problem #1 decision. - -## Acceptance criteria - -- [ ] Tests construct throwaway git repos (temp dir, scripted commits) covering every case above; no test depends on this repo's history. -- [ ] Shift case: 10 lines inserted above the range → traced range moves down 10, same content. -- [ ] Rename case: `git mv` + edit elsewhere → traced path updates, lines stable. -- [ ] Rewrite case: replacing the function body wholesale → `lost: "content-rewritten"`, not a wrong range. **A silently-wrong result in any test is a failed PR, per the project invariant.** - -## Out of scope - -Symbol-based resolution, writing anchors back, CLI wiring. diff --git a/issues/203-symbol-resolver.md b/issues/203-symbol-resolver.md deleted file mode 100644 index 3d86fec..0000000 --- a/issues/203-symbol-resolver.md +++ /dev/null @@ -1,22 +0,0 @@ -# Symbol resolver: find a named symbol at HEAD -Labels: phase:2 - -## Context - -DESIGN.md §4 resolution order, step 1: symbol lookup is preferred over line tracing when an anchor carries `symbol`. Cross-file moves are only followed when git history connects the files. - -## Scope - -- `findSymbol(repo, path, symbol) → { path, lines } | null` using tree-sitter grammars for ts/js, rust, python, go (`web-tree-sitter` or the per-language npm grammars; pick and justify in the PR body); regex-heuristic fallback for other extensions (function/def/fn/class declaration patterns), clearly marked lower-confidence in the return type. -- Same-file hit: return its current span. Not in file: search files git connects to the original (`git log --follow --name-only` between the anchor's `as_of` and HEAD); a hit in an unconnected file is NOT followed (return null — the invariant beats recall). -- Multiple matches in one file (overloads/re-declarations): return null with a `reason: ambiguous` — never guess. - -## Acceptance criteria - -- [ ] Temp-repo tests per language for: symbol present, symbol renamed away (null), file `git mv`ed with symbol intact (followed), same-named symbol in an unrelated file (not followed), duplicate symbol (ambiguous → null). -- [ ] Fallback heuristic tested on a `.toml`-adjacent case: returns lower-confidence marker or null, never a fabricated range. -- [ ] No network at test time: grammar assets vendored or installed via package.json, not fetched in code. - -## Out of scope - -Anchor writing, resolution-order orchestration (next issue). diff --git a/issues/204-why-anchor.md b/issues/204-why-anchor.md deleted file mode 100644 index b1cae2d..0000000 --- a/issues/204-why-anchor.md +++ /dev/null @@ -1,23 +0,0 @@ -# `why anchor` — full re-resolution with frontmatter-only writes -Labels: phase:2 - -## Context - -DESIGN.md §4: compose symbol-first then blame-trace into the real command. Writes go through okf-mcp's `update_concept`-equivalent library path so ONLY `why.anchors` entries change — narrative bodies are untouchable by machinery. - -## Scope - -- `why anchor`: for every anchor in the bundle, run the §4 resolution order; update `lines`/`path`/`as_of` (to HEAD) on success; set `state: lost` (keeping last-known values) on failure. Print a summary table: resolved / moved / lost / already-current. -- `--check`: CI mode — resolve everything, write nothing, exit 1 if any anchor would change or be lost (so a PR that moves anchored code without running `why anchor` fails its gate). -- `--concept ` scopes to one concept. -- A `lost` anchor whose symbol/range reappears later resolves back to `live` (test this — recovery must not require hand-editing). - -## Acceptance criteria - -- [ ] End-to-end temp-repo test: seed a mini bundle + code, commit, refactor (rename file, shift lines, delete one function), run `why anchor` — assert one moved, one shifted, one lost, and the frontmatter diff touches only `why.anchors` (byte-compare the rest of each file). -- [ ] `--check` exits nonzero on the same scenario without writing. -- [ ] Idempotence: second run reports all current, writes nothing. - -## Out of scope - -`why doctor` reporting (next issue), torture-scale evaluation (after that). diff --git a/issues/205-why-doctor.md b/issues/205-why-doctor.md deleted file mode 100644 index 1207f07..0000000 --- a/issues/205-why-doctor.md +++ /dev/null @@ -1,23 +0,0 @@ -# `why doctor` — bundle health report -Labels: phase:2 - -## Context - -DESIGN.md §4 (lost anchors surface here) and §8. The maintenance dashboard: one command that says whether the archive can be trusted right now. - -## Scope - -Report, grouped and counted: lost anchors (with concept + last-known location), stale `as_of`s (anchor live but `as_of` ≠ HEAD ancestor-of check), constraints with `verify.method: review-by` past due, constraints with `status: unknown`, open `question` concepts (age-sorted), and lint errors (reuse `why lint` internals — do not re-implement rules). - -- Human output default; `--json` for machines; exit 0 healthy / 1 any red-severity item (lost anchors and lint errors are red; the rest yellow). -- Keep it read-only. - -## Acceptance criteria - -- [ ] Temp-bundle test with one of each condition asserts every section appears with the right count and severity. -- [ ] `examples/harbor` (which contains an expired constraint and an open question by design) produces yellow findings for those and exits per the severity rules — pin this in a test so the example and the command stay in sync. -- [ ] `--json` schema is stable and tested. - -## Out of scope - -Fixing anything (doctor diagnoses; `anchor`/`audit` treat). diff --git a/issues/206-anchor-torture-test.md b/issues/206-anchor-torture-test.md deleted file mode 100644 index f357f4f..0000000 --- a/issues/206-anchor-torture-test.md +++ /dev/null @@ -1,22 +0,0 @@ -# Anchor torture test: replay a real history, measure survival -Labels: phase:2 - -## Context - -PLAN.md Phase 2's gate: **>90% of anchors either resolve correctly or honestly report lost — zero silently-wrong anchors.** This issue builds the measurement harness; the number it produces decides DESIGN.md open problem #1. - -## Scope - -- `test/torture/` harness (runs under `npm run test:torture`, excluded from default `verify` for speed): given a git repo URL/path, a start ref, and a set of seeded anchors (path+symbol+lines at start ref), replay history commit-by-commit (or PR-merge-by-merge) to HEAD, running the resolver at each step; at the end, compare resolved locations against ground truth. -- Ground truth: for the harness's built-in scenario, generate a synthetic repo with scripted refactors so truth is known exactly. Additionally support `--repo ` for manual runs against real repos (ground truth then checked by spot-audit output: a markdown report of every anchor's journey). -- Output: survival stats (correct / honestly-lost / WRONG), per-failure journey traces. WRONG > 0 fails the harness run. - -## Acceptance criteria - -- [ ] Synthetic scenario covers ≥ 8 refactor classes (rename, move, split file, inline, shift, rewrite, delete, revert) and passes with zero WRONG. -- [ ] Report generation tested (structure, not prose). -- [ ] README section in `test/torture/` documenting how to run it against an arbitrary repo and read the report. - -## Out of scope - -Acting on the results (that's a human/planner decision recorded in PLAN.md). diff --git a/issues/301-dig-episodes.md b/issues/301-dig-episodes.md deleted file mode 100644 index 1841921..0000000 --- a/issues/301-dig-episodes.md +++ /dev/null @@ -1,23 +0,0 @@ -# `why dig --episodes` — deterministic episode extraction and tells -Labels: phase:3 - -## Context - -DESIGN.md §6 step 1: the deterministic half of archaeology. Agents do the judgment later; this issue gives them well-shaped raw material. - -## Scope - -- Walk `git log` over a ref range (default: high-water mark → HEAD; full history on first run) and cluster commits into episodes: merge/PR boundaries first (parse `Merge pull request #N` and squash-merge `(#N)` suffixes), then temporal + file-overlap clustering (same author, <48h apart, overlapping paths) for direct commits. -- Per episode, emit JSON: commits (sha, message, author, date), files touched with churn counts, extracted PR/issue references, date range. -- Tells detection, flagged on the episode and summarized globally: reverts (`Revert "`), fix-chains (≥2 `fix`-prefixed commits touching the same file within an episode window), sudden churn on old-quiet files (churn z-score over trailing window), and comment tells — added lines matching `HACK|workaround|do not|don't|because|temporarily` in diffs. -- `--json` to stdout or `--out `; stable schema documented in a `docs/dig-episodes.md`. - -## Acceptance criteria - -- [ ] Temp-repo tests: PR-merge history clusters by merge; direct-commit history clusters by the heuristic; a scripted revert and a fix-chain are both flagged with the right episode. -- [ ] Runs against a real repo (point it at this repo's own history in a test marked slow/optional) without crashing and produces parseable output. -- [ ] Schema documented and asserted by a test that validates emitted JSON against it. - -## Out of scope - -Network calls (`gh` evidence is the next issue), any agent involvement, writing concepts. diff --git a/issues/302-dig-evidence.md b/issues/302-dig-evidence.md deleted file mode 100644 index 9850943..0000000 --- a/issues/302-dig-evidence.md +++ /dev/null @@ -1,22 +0,0 @@ -# `why dig --evidence` — evidence-pack assembly -Labels: phase:3 - -## Context - -DESIGN.md §6 step 2: turn an episode into everything a reconstruction agent may cite, so the agent never fetches on its own (bounded context, no prompt-time surprises). - -## Scope - -- Input: an episode JSON (from `--episodes`). Output: an evidence pack — one markdown document per episode: commit messages in full, PR title/body/review-thread comments and issue title/body/comments via `gh` (graceful degradation with a clear `[unavailable: …]` marker when there's no remote, no `gh` auth, or the PR predates the remote), plus diffs clipped per-file with a size budget. -- `--evidence-dir `: merge in local exported context (postmortems, chat exports) — files whose names mention an episode's PR/issue numbers or touched paths get included, with provenance headers. -- Deterministic ordering and stable formatting (packs get cached and diffed). -- Respect a total size budget (`--max-chars`, sane default); when clipping, say what was clipped where. - -## Acceptance criteria - -- [ ] Tests with a temp repo + fixture `gh` (inject a command-runner seam; do NOT hit the network in tests) cover: full pack, missing-remote degradation, evidence-dir matching by issue number and by path, budget clipping markers. -- [ ] Pack format documented in `docs/dig-evidence.md` with an annotated example. - -## Out of scope - -Reconstruction prompts, concept writing. diff --git a/issues/303-dig-skills.md b/issues/303-dig-skills.md deleted file mode 100644 index 19de393..0000000 --- a/issues/303-dig-skills.md +++ /dev/null @@ -1,22 +0,0 @@ -# Dig skills: reconstruction and synthesis agent prompts -Labels: phase:3 - -## Context - -DESIGN.md §6 steps 3–4: the judgment half. Ships as Claude Code skills (markdown prompt-docs under `skills/`), NOT as code that calls an LLM API — the CLI stays deterministic; agents drive the CLI. - -## Scope - -- `skills/dig/SKILL.md`: instructs an agent to take one evidence pack and draft concepts — embedding the schema contract (DESIGN.md §2–3 restated operationally), the confidence ladder with assignment examples, the cite-everything rule, prefer-`question`-over-`speculative`, and update-don't-duplicate (search the bundle first). Output via okf-mcp write tools against the repo's `.why/` bundle; every written concept must pass `why lint` before the agent finishes. -- `skills/dig-synthesize/SKILL.md`: the cross-episode pass — merge duplicates, wire `# Superseded by` chains, promote recurring forces into `constraint`s, file `question`s for unresolved gaps; ends by running `why lint` and `why doctor` and fixing what it introduced. -- A `docs/digging.md` runbook: the end-to-end sequence (episodes → evidence → per-episode skill runs → synthesis), including the era-chunked cold-start order from DESIGN.md §6. - -## Acceptance criteria - -- [ ] Both skills contain the confidence ladder verbatim-equivalent to DESIGN.md §2 and an explicit "when in doubt, file a question" rule — reviewer should reject paraphrases that weaken it. -- [ ] Skills reference only CLI commands and okf-mcp tools that exist at this point in the plan; no fictional tooling. -- [ ] Runbook walked through against `examples/harbor` as a dry description (no live agent run required in CI). - -## Out of scope - -Executing a live dig in CI (that's PLAN.md Phase 3's real-world test, run by the orchestrator, not by this PR). diff --git a/issues/304-dig-incremental.md b/issues/304-dig-incremental.md deleted file mode 100644 index 24f6766..0000000 --- a/issues/304-dig-incremental.md +++ /dev/null @@ -1,22 +0,0 @@ -# Incremental digs: high-water mark state -Labels: phase:3 - -## Context - -DESIGN.md §6: routine digs process only new history. State must be explicit, inspectable, and safe to delete. - -## Scope - -- `.why/.dig-state.json`: last processed commit per branch, schema-versioned. `--episodes` default range starts there; `--from`/`--full` override. -- State is written only after a successful episode emission, atomically (write-temp-rename). -- Deleting the state file is documented (in `docs/digging.md`) as "re-dig everything, synthesis dedupes" — and must actually be safe: episode extraction is deterministic, and the dig skill's update-don't-duplicate rule handles re-encounters. -- `why doctor` gains one line: dig-state freshness (commits since last dig). - -## Acceptance criteria - -- [ ] Temp-repo tests: first run full-history, second run empty (no new commits), third run after 2 new commits yields exactly the new-episode delta; killed-mid-run simulation (state file untouched by a failed emission). -- [ ] Doctor freshness line tested. - -## Out of scope - -Multi-branch strategies beyond the current branch; remote state. diff --git a/issues/401-why-audit.md b/issues/401-why-audit.md deleted file mode 100644 index 7badb90..0000000 --- a/issues/401-why-audit.md +++ /dev/null @@ -1,23 +0,0 @@ -# `why audit` — constraint re-verification and the scar-tissue report -Labels: phase:4 - -## Context - -DESIGN.md §5 — the payoff feature: constraints must be falsifiable, and expiry must propagate to the decisions downstream. This is the command that tells a team "the wound healed; you can simplify now." - -## Scope - -- Sweep `active` constraints: run `verify.method: check` commands (sandboxed to the repo dir, with a timeout, output captured); collect `method: ask` items into an agent-consumable questionnaire (`--questions-out`, markdown) rather than calling an LLM from the CLI; flag overdue `review_by` dates. -- A failed `check` (or an `--answers` file marking an `ask` as no-longer-true) flips the constraint: `status: expired`, `expired_on` set, `# Still true?` section appended with the evidence — via the frontmatter/section-patch write path, preserving the rest of the file. -- Blast radius: for each newly-expired constraint, walk `# Because of` edges backwards; every `active` decision reached gets (a) a line in the audit report and (b) a generated `question` concept ("constraint X expired — is decision Y still needed?") linking both, unless an equivalent open question already exists (dedupe by link pair). -- Report: human summary + `--json`; exit 1 when anything expired (CI signal for "the archive learned something"). - -## Acceptance criteria - -- [ ] Temp-bundle tests: passing check (no change), failing check (flip + blast radius + question generated), duplicate-question suppression, overdue review_by flagged, `ask` items exported not executed. -- [ ] `examples/harbor`: audit must NOT double-flag the already-expired Acme constraint but should detect the 47s decision's existing candidacy (assert the report mentions it without writing a duplicate question). -- [ ] Byte-preservation test: an audited-but-unchanged concept file is untouched. - -## Out of scope - -Automatically changing code or reverting decisions — audit reports, humans/agents act. diff --git a/issues/402-merge-capture.md b/issues/402-merge-capture.md deleted file mode 100644 index 740e560..0000000 --- a/issues/402-merge-capture.md +++ /dev/null @@ -1,21 +0,0 @@ -# Merge-time capture: record decisions while they're fresh -Labels: phase:4 - -## Context - -DESIGN.md open problem #5: digging is the cold start; the steady state is capture at decision time, confidence `recorded`, because the PR discussion still holds the why. - -## Scope - -- `why capture --pr ` (and `--commit ` fallback): assemble the PR's evidence (reuse the dig evidence module), then emit a *draft* concept file into `.why/drafts/` — frontmatter pre-filled (type guessed decision/attempt by merge-vs-close, `happened_on`, anchors from the diff's hunks via the anchor machinery, citations to the PR), body sections stubbed with the extracted rationale candidates quoted verbatim. -- Drafts are explicitly not part of the served bundle (okf-mcp excludes dot-dirs only, so name it `.why/drafts/`... verify; if drafts would serve, prefix filenames or use a dot-dir and document). Promotion out of drafts is an editorial act: `why capture --promote ` moves it into the type directory after lint passes. -- A `docs/capture.md` recipe for wiring it as a post-merge CI job plus a capture skill (`skills/capture/SKILL.md`) that turns drafts into finished concepts (the judgment step, mirroring the dig skill's rules). - -## Acceptance criteria - -- [ ] Fixture-`gh` tests: draft generation from a merged PR (rationale candidates quoted, citations present, anchors derived), close-unmerged → `attempt` draft, promotion refuses lint-failing drafts. -- [ ] Served-bundle test: drafts never appear in bundle loads or `why blame` results. - -## Out of scope - -Running the capture skill in this PR; org-wide/webhook plumbing. diff --git a/issues/403-ci-recipes.md b/issues/403-ci-recipes.md deleted file mode 100644 index 1ef272f..0000000 --- a/issues/403-ci-recipes.md +++ /dev/null @@ -1,23 +0,0 @@ -# CI recipes and the self-hosting switch -Labels: phase:4 - -## Context - -PLAN.md Phase 4 final task: the operational story, plus the moment `why` starts running on its own repository. - -## Scope - -- `docs/ci.md`: copy-pasteable GitHub Actions workflows — PR gate (`why lint` + `why anchor --check`), weekly `why audit` (opening a GitHub issue from the report when something expires), post-merge `why capture`. -- Add exactly those workflows to THIS repo under `.github/workflows/`, active. -- Run `why init` on this repo and seed `.why/` by converting PLAN.md's Decision log entries into proper concepts (correct types, confidence `recorded`, citations to the relevant commits/PRs of this repo), then delete the Decision log section from PLAN.md in favor of a pointer to the bundle. -- README gains a short "self-hosted" section pointing at `.why/` as the living demo. - -## Acceptance criteria - -- [ ] `why lint` and `why doctor` pass on the new `.why/` bundle. -- [ ] Workflows are syntactically valid (actionlint or equivalent check in tests) and reference only npm scripts that exist. -- [ ] PLAN.md decision entries all have a corresponding concept (spot-assert two of them in tests by slug). - -## Out of scope - -Publishing to npm; multi-repo/org bundles. diff --git a/issues/501-ui-data-contract.md b/issues/501-ui-data-contract.md deleted file mode 100644 index a1d04e2..0000000 --- a/issues/501-ui-data-contract.md +++ /dev/null @@ -1,27 +0,0 @@ -# UI data contract: versioned JSON schemas for story, coverage, and graph -Labels: phase:5 - -## Context - -Two UIs are coming (`why serve`, a VS Code extension) and both must be *dumb renderers* over one contract — anchor resolution and confidence semantics stay in the engine, presentation layers contain no logic that could drift. This issue formalizes what `why blame --json` started (issue "why blame — static") into a stable, versioned surface. DESIGN.md §7 governs semantics. - -## Scope - -- `schemas/` — JSON Schema documents, each with a top-level `schemaVersion`: - - **story** (`why blame --json` output): target span; hits[] with concept id, type, title, description, status, `happened_on`, confidence, anchor spans, edges grouped by relation (`becauseOf`, `insteadOf`, `supersededBy`), citations (label + url), and warnings (e.g. expired upstream constraint with its blast-radius note); plus the nearest-concepts fallback shape for uncovered targets. - - **coverage** (`why export ui-index [--out ]`, new subcommand): per file, ordered non-overlapping-where-possible spans → {concept id, type, status, confidence, glyph}, computed from the anchor index at HEAD; carries the resolved commit sha so consumers can detect staleness. - - **graph**: the bundle as nodes/typed-edges (relation names from DESIGN.md §3), suitable for direct rendering. -- **Hedging lives in the data, not the renderer**: story hits carry a precomputed `renderedRationale` where sub-`corroborated` confidence already includes its hedge prefix, and a boolean `hedged`. A renderer that ignores confidence still cannot display unhedged speculation — this is the §2 invariant made structural. -- `docs/ui-contract.md`: each schema annotated with an example, the compatibility policy (additive = minor, breaking = major bump in `schemaVersion`), and renderer guidance (glyph vocabulary, status→treatment table). -- `why blame --json` and `why export ui-index` outputs validated against the schemas in tests (ajv or hand-rolled — justify in PR body). - -## Acceptance criteria - -- [ ] Schemas validate real outputs over `examples/harbor` (story for `src/lock.rs:47` including its expired-constraint warning; ui-index over a temp repo whose files match the harbor anchors). -- [ ] A story hit with `confidence: inferred` has `hedged: true` and a `renderedRationale` starting with the hedge prefix — asserted in tests. -- [ ] Invalid/hand-mutated payloads fail schema validation in tests (prove the schemas actually constrain). -- [ ] `docs/ui-contract.md` exists with all three schemas exampled and the versioning policy stated. - -## Out of scope - -Any UI. Serving over HTTP (next issue). Changing blame's human-readable output. diff --git a/issues/502-why-serve.md b/issues/502-why-serve.md deleted file mode 100644 index e6fe93c..0000000 --- a/issues/502-why-serve.md +++ /dev/null @@ -1,29 +0,0 @@ -# `why serve` — standalone local UI: blame gutter + story panel + graph -Labels: phase:6 - -## Context - -The flagship visual: `git blame` and `why blame` side by side in a browser, for the local checkout. Localhost-only, read-only, self-contained — the okf-mcp `graph html` export proved the pattern (embedded assets, hand-rolled canvas force sim, zero CDN/network); this UI follows the same philosophy. Consumes ONLY the UI data contract (previous issue) — if the UI needs data the contract lacks, extend the contract first. - -## Scope - -- `why serve [--port ]`: local HTTP server bound to 127.0.0.1 (random free port by default, printed with the URL). Read-only — no endpoint mutates the bundle or the repo. -- JSON endpoints wrapping the engine: file tree (`git ls-files`), per-file blame (`git blame --porcelain`: commit, author, date per line), coverage (ui-index), story (blame --json for a span), graph, and doctor summary — every payload schema-valid per the contract. -- SPA (single self-contained JS/CSS bundle via esbuild at build time; no external URLs at runtime): - - File tree sidebar → file view: line numbers, git-blame column (author/age), and a `why` gutter stripe per covered span — color by confidence, distinct treatment for expired-constraint scar tissue and open questions (glyph vocabulary from `docs/ui-contract.md`). - - Click a covered line → story panel: cards rendering the story schema — status glyph, title, type, date, confidence badge, `renderedRationale` (already hedged), edge lists, citation links, expired-upstream warnings visually loud. - - Graph tab: the bundle graph with type-colored nodes and relation-labeled edges (adapt the okf-mcp `visualize.ts` approach; do not fetch it at runtime). - - Header: doctor summary chips (lost anchors, overdue reviews, open questions) linking to a plain list view. -- Keep server logic thin: endpoints call the same library functions the CLI uses; no reimplementation of blame/coverage/story assembly. - -## Acceptance criteria - -- [ ] Endpoint tests against a temp repo (fabricated source files matching a harbor-derived bundle's anchors): every endpoint returns schema-valid JSON; story for the lock.rs span includes the expired-constraint warning. -- [ ] Self-containment test: built assets contain no `http(s)://` references (same guarantee okf-mcp's html export makes). -- [ ] A DOM-level smoke test (jsdom or equivalent — no real browser in default verify) renders the story panel from fixture JSON and asserts the hedge prefix and expired warning are present in the DOM. -- [ ] Optional slow-tier browser test (behind `npm run test:e2e`, excluded from default verify) documented in the PR; CI-required tests must pass in the sandbox without a display. -- [ ] `why serve` on this repo itself (once `.why/` exists from the self-hosting issue) starts and serves without error — manual check noted in PR body. - -## Out of scope - -Editing concepts from the UI, auth, remote access, multi-repo. The VS Code extension (next issue). diff --git a/issues/503-vscode-extension.md b/issues/503-vscode-extension.md deleted file mode 100644 index 360733b..0000000 --- a/issues/503-vscode-extension.md +++ /dev/null @@ -1,30 +0,0 @@ -# VS Code extension: why annotations inline, GitLens-style -Labels: phase:6 - -## Context - -The highest-leverage surface: the moment someone is about to edit weird code is the moment the why matters, and that moment happens in the editor. The extension is a pure contract consumer — it shells out to the `why` CLI and renders; zero engine logic in extension code. - -## Scope - -- New `vscode-why/` directory with its own `package.json` (VS Code engine field, activation on workspaces containing `.why/`), own `npm run verify` (typecheck + unit tests). Root `verify` runs it too **if and only if** its unit tests need no display server; anything needing VS Code's electron host goes behind a separate script (see testing note below). -- CLI integration: locate `why` (workspace `node_modules/.bin`, then PATH, then `why.cliPath` setting); run `why export ui-index` and `why blame --json` via child_process with the workspace root as cwd. CLI missing → one non-modal info message per session, then silence. -- Features: - - Line decorations from coverage: a subtle gutter mark per covered span, themed by confidence/status (colors via VS Code theme tokens, not hardcoded hex). - - Hover provider on covered lines: markdown card — status glyph, title, confidence badge, `renderedRationale` (pre-hedged by contract), citation links, expired-upstream warning first when present. - - Command `why: Show Story` (editor context menu + palette): opens a webview panel with the full story cards for the cursor's span — reuse the serve SPA's card renderer bundle if it factors cleanly; otherwise render the same markdown as hovers in a list. Judge the trade in the PR body. - - Refresh: on file save, on `.why/**` change, and on `.git/HEAD` change (debounced); a manual `why: Refresh` command as the escape hatch. - - Staleness: when the ui-index's recorded sha ≠ current HEAD, show a muted "as of " note in hovers rather than hiding data or resolving anchors itself. -- Packaging: `vsce package` script producing a `.vsix` (not published anywhere by this issue); exclude `vscode-why/` from the root package's npm `files` and from the release version-bump automation. - -## Acceptance criteria - -- [ ] Unit tests (plain node:test, no electron) cover: CLI discovery order, ui-index/story parsing against contract fixture JSON, decoration-set computation from coverage fixtures, staleness-note logic, and hover markdown generation (assert hedge prefix and warning ordering). -- [ ] The extension host integration test (`@vscode/test-electron`) exists but runs behind `npm run test:integration` in `vscode-why/`, documented as requiring a display (xvfb in CI later) — NOT part of default verify, so the sandboxed pipeline stays green. -- [ ] Root `npm run verify` passes with the new directory present and still passes if `vscode-why/node_modules` is absent (workspace isolation — no root-level type or test breakage). -- [ ] `vsce package` succeeds in CI-like conditions (`--no-dependencies` acceptable), `.vsix` land as a build artifact path documented in the PR. -- [ ] `docs/vscode.md`: install-from-vsix instructions and a manual QA script against a temp repo (fabricated files matching harbor anchors), including what each hover should show for the lock.rs and defaults.toml lines. - -## Out of scope - -Marketplace publishing, inline end-of-line blame text (decorations + hover only for v1), edit/capture flows, remote workspaces. diff --git a/issues/504-decoration-toggle.md b/issues/504-decoration-toggle.md deleted file mode 100644 index 5c137ae..0000000 --- a/issues/504-decoration-toggle.md +++ /dev/null @@ -1,53 +0,0 @@ -# VS Code extension: toggle for coverage decorations - -Labels: phase:6 - -> Implemented directly on the `decoration-toggle` branch (not via the -> Sandcastle queue) — this file is the spec of record. - -## Context - -The gutter stripe + overview-ruler mark (the "green line" in the number column -and scrollbar) is painted on every covered span whenever the extension is -active. It's the right default for discovery, but it's persistent and some -find it intrusive during regular editing — the scrollbar mark especially, -since it stays visible even when scrolled away. There was no way to quiet it -short of disabling the whole extension. Add per-lane settings plus a -one-keystroke command to mute/unmute, with the scrollbar mark off by default. -Hovers and `why: Show Story` are on-demand, not passive, so they stay -available regardless — only the always-on paint is gated. - -## What shipped - -- Three nested settings under `contributes.configuration`: - - `why.decorations.enabled` (boolean, default `true`) — master toggle. - - `why.decorations.gutter` (boolean, default `true`) — the number-column stripe. - - `why.decorations.overviewRuler` (boolean, default `false`) — the scrollbar mark. -- `why: Toggle Annotations` command (palette, `why` category) flips - `why.decorations.enabled`, writing to the workspace scope when the setting is - already set there, else global. -- The single per-token decoration type was split into two lanes (gutter-only - border, ruler-only overview mark) so the lanes toggle independently. `paint` - reads the settings via the pure `visibleLanes` helper in - `src/core/decorations.ts` (master overrides both lanes); a hidden lane is - cleared, not skipped, so no mark lingers after a toggle-off. -- `onDidChangeConfiguration` repaints visible editors on any `why.decorations` - change — no CLI re-run (coverage is already cached). - -## Acceptance criteria - -- [x] Defaults paint the gutter stripe only; the scrollbar/overview-ruler mark - is off until `why.decorations.overviewRuler` is enabled. -- [x] `why.decorations.enabled` = `false` (Settings UI or command) clears all - marks within a repaint — no reload, no CLI re-run; hovers still work. -- [x] `why: Toggle Annotations` flips the setting at the right scope and the - marks appear/disappear each invocation. -- [x] Unit test (plain `node:test`, no electron) covers `visibleLanes`: - defaults, master override, and each lane on its own. -- [x] `docs/vscode.md` documents the settings + command; `package.json` - `contributes` declares the command and the three config properties. - -## Out of scope - -A default keybinding (users bind `why.toggleAnnotations` themselves), a -status-bar affordance, and gating hovers. diff --git a/package-lock.json b/package-lock.json index 43b9a64..6e0b210 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@copperbox/why", - "version": "0.8.1", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@copperbox/why", - "version": "0.8.1", + "version": "1.0.0", "license": "MIT", "dependencies": { "@copperbox/okf-mcp": "^0.19.1", diff --git a/package.json b/package.json index b34587c..35a7536 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@copperbox/why", - "version": "0.8.1", + "version": "1.0.0", "description": "Decision archaeology for codebases — recover, anchor, and audit the why behind code.", "type": "module", "license": "MIT", diff --git a/scripts/bootstrap-github.sh b/scripts/bootstrap-github.sh deleted file mode 100755 index a4b5688..0000000 --- a/scripts/bootstrap-github.sh +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env bash -# One-shot bootstrap: local repo -> GitHub repo + labels + the full issue -# backlog from issues/*.md. Idempotent: safe to re-run; existing repo, labels, -# and already-filed issue titles are skipped. -# -# Usage: scripts/bootstrap-github.sh [owner/repo-name] [--public] -# Defaults to the copperbox org; pass an explicit owner/name to override. -set -euo pipefail -root="$(cd "$(dirname "$0")/.." && pwd)" -cd "$root" - -REPO_NAME="${1:-copperbox/why}" -VISIBILITY="--private" -[ "${2:-}" = "--public" ] && VISIBILITY="--public" - -command -v gh >/dev/null || { echo "gh CLI is required"; exit 1; } -gh auth status >/dev/null 2>&1 || { echo "gh is not authenticated (gh auth login)"; exit 1; } -[ -f .sandcastle/.env ] || echo "⚠ .sandcastle/.env missing — copy .sandcastle/.env.example and fill in tokens before running the loop." - -# --- git + remote ------------------------------------------------------------ -if [ ! -d .git ]; then - git init -b main -fi -if ! git rev-parse HEAD >/dev/null 2>&1; then - git add -A - git commit -m "bootstrap: why scaffold, .sandcastle autonomous pipeline, issue backlog - -Co-Authored-By: Claude Fable 5 " -fi -if ! git remote get-url origin >/dev/null 2>&1; then - gh repo create "$REPO_NAME" "$VISIBILITY" --source . --remote origin --push -else - git push -u origin main -fi - -# --- labels ------------------------------------------------------------------- -make_label() { gh label create "$1" --color "$2" --description "$3" 2>/dev/null || true; } -make_label "Sandcastle" "1d76db" "queued for the autonomous build loop" -make_label "sandcastle:in-review" "c5def5" "parked: implemented, PR open" -make_label "sandcastle:in-progress" "fbca04" "actively being worked" -make_label "needs-chat" "b60205" "circuit breaker: spec needs a human chat before re-queueing" -for n in 1 2 3 4 5 6; do - make_label "phase:$n" "0e8a16" "why build phase $n (PLAN.md)" -done - -# --- issues ------------------------------------------------------------------- -# File format: line 1 "# ", line 2 "Labels: a, b", body from line 4. -existing="$(gh issue list --state all --limit 500 --json title --jq '.[].title')" -filed=0 skipped=0 -for f in issues/*.md; do - title="$(head -1 "$f" | sed 's/^# //')" - if grep -Fxq "$title" <<<"$existing"; then - skipped=$((skipped + 1)); continue - fi - labels="$(sed -n '2s/^Labels:[[:space:]]*//p' "$f" | tr -d ' ')" - tail -n +4 "$f" | gh issue create --title "$title" --label "$labels" --body-file - >/dev/null - echo " filed: $title [$labels]" - filed=$((filed + 1)) -done -echo "issues: $filed filed, $skipped already present" - -# --- sandbox image ------------------------------------------------------------ -[ -d node_modules ] || npm install -npx sandcastle docker build-image || echo "⚠ docker image build failed — build it manually before running the loop." - -cat <<'EOF' - -Bootstrap complete. Start the zero-human build with: - - npm run sandcastle:auto - -Watch progress on GitHub (issues + PRs are the whole state). Stop any time -with Ctrl-C; every bit of state is on GitHub, so it resumes where it left off. -EOF diff --git a/src/anchor.ts b/src/anchor.ts index 74ada31..4aa3edc 100644 --- a/src/anchor.ts +++ b/src/anchor.ts @@ -1,8 +1,17 @@ // `why anchor` (DESIGN.md §4): an anchor is a claim — "at commit as_of, this // concept was about these lines" — and this module re-evaluates every claim -// against HEAD. Resolution order: symbol-first, then blame-trace, then -// honestly `lost`. Nothing here may emit a plausible-but-unverified span: -// every failure mode falls through to `lost`, never to a guess. +// against HEAD. Resolution order: symbol-first, then blame-trace, then either +// `unverified` (the path is live but as_of gives nothing to trace from) or +// honestly `lost`. Nothing here may emit a plausible-but-unverified span: no +// failure mode falls through to a guess. +// +// Reading history through `as_of` is gated on ancestry (`historyOrigin`), +// writing `as_of` prefers the newest *surviving* commit but only where the +// span verifiably holds (`stampFor`), and an orphaned `as_of` on a claim that +// re-verifies at HEAD without it is repaired to a durable commit +// (`repairOrphan`). All three exist because a squash merge discards the branch +// commit an anchor was verified at — see DESIGN.md §4 "as_of and squash +// merges". // // Writes go through okf-mcp's updateConcept so only the `why.anchors` entries // change — narrative bodies and every other frontmatter key (timestamp @@ -32,6 +41,8 @@ export class GitView { private readonly renameMaps = new Map<string, Map<string, string> | undefined>(); private readonly commitShas = new Map<string, string | undefined>(); private readonly ancestry = new Map<string, boolean>(); + private readonly revContent = new Map<string, string | undefined>(); + private survivingBaseCache?: { sha: string | undefined }; private constructor( readonly root: string, @@ -89,16 +100,82 @@ export class GitView { /** Whether `sha` is an ancestor of (or equal to) HEAD. */ async isAncestor(sha: string): Promise<boolean> { if (!this.ancestry.has(sha)) { - let related: boolean; + this.ancestry.set(sha, await this.contains("HEAD", sha)); + } + return this.ancestry.get(sha)!; + } + + private async contains(ref: string, sha: string): Promise<boolean> { + try { + await run(this.root, ["merge-base", "--is-ancestor", sha, ref]); + return true; + } catch { + return false; + } + } + + /** + * An `as_of` usable as a history origin, or undefined. Usable means it both + * resolves *and* is an ancestor of HEAD. Ancestry is not pedantry: git will + * happily diff or blame between two commits that share no history, so an + * `as_of` a squash merge discarded still answers rename questions in a clone + * that kept the branch it lived on, and answers nothing in a clone that did + * not. Ancestry is the only property of an `as_of` that every clone of the + * same history agrees on, so it is the gate for reading history through one. + */ + async historyOrigin(asOf: string | undefined): Promise<string | undefined> { + if (asOf === undefined) return undefined; + const sha = await this.commitSha(asOf); + if (sha === undefined) return undefined; + return (await this.isAncestor(sha)) ? sha : undefined; + } + + /** File content at an arbitrary commit, or undefined when absent there. */ + async fileAt(rev: string, path: string): Promise<string | undefined> { + const key = `${rev}:${path}`; + if (!this.revContent.has(key)) { + let content: string | undefined; try { - await run(this.root, ["merge-base", "--is-ancestor", sha, "HEAD"]); - related = true; + content = await run(this.root, ["show", `${rev}:${path}`]); } catch { - related = false; + content = undefined; } - this.ancestry.set(sha, related); + this.revContent.set(key, content); + } + return this.revContent.get(key); + } + + async shortSha(rev: string): Promise<string> { + return (await run(this.root, ["rev-parse", "--short", rev])).trim(); + } + + /** + * The newest commit certain to outlive this branch: the merge-base with the + * integration branch git records as the remote's default + * (`refs/remotes/origin/HEAD`). Undefined when there is no such record — + * nothing to be certain about — or when HEAD is already contained in it, in + * which case HEAD survives and is the honest stamp. See `stampFor`. + */ + async survivingBase(): Promise<string | undefined> { + if (this.survivingBaseCache === undefined) { + this.survivingBaseCache = { sha: await this.computeSurvivingBase() }; + } + return this.survivingBaseCache.sha; + } + + private async computeSurvivingBase(): Promise<string | undefined> { + let ref: string; + try { + ref = (await run(this.root, ["symbolic-ref", "refs/remotes/origin/HEAD"])).trim(); + } catch { + return undefined; // no recorded integration branch — HEAD is all we know + } + if (await this.contains(ref, this.headFull)) return undefined; // HEAD survives as-is + try { + return (await run(this.root, ["merge-base", "HEAD", ref])).trim(); + } catch { + return undefined; // unrelated histories — nothing shared to fall back to } - return this.ancestry.get(sha)!; } /** @@ -270,57 +347,77 @@ interface ResolvedSpan { } /** - * DESIGN.md §4 resolution order for one anchor. Returns undefined when - * nothing resolves — the caller marks the anchor lost. + * The outcome of re-evaluating one claim. + * + * `unverified` is the honest middle the resolver used to lack: the anchor's + * path is confirmed at HEAD, but its `as_of` is unusable as a history origin + * (see `GitView.historyOrigin`), so the *line* claim cannot be re-traced. The + * anchor is not lost — the code is right there — but nothing here may re-line + * it either. Collapsing this case into `lost` is what made a squash-orphaned + * `as_of` read as rotted code; collapsing it into `span` would assert a range + * nothing verified. It is its own answer. */ -async function resolveClaim(git: GitView, anchor: Anchor): Promise<ResolvedSpan | undefined> { +type Claim = + | { kind: "span"; span: ResolvedSpan } + | { kind: "unverified" } + | { kind: "lost" }; + +/** DESIGN.md §4 resolution order for one anchor. */ +async function resolveClaim(git: GitView, anchor: Anchor): Promise<Claim> { const path = normalizePath(anchor.path); const range = anchor.lines === undefined ? undefined : parseLineRange(anchor.lines); + const origin = await git.historyOrigin(anchor.as_of); // 1. Symbol-first: same file, then a git-connected rename target. A found // symbol re-lines the claim; a whole-file anchor keeps its granularity. if (anchor.symbol !== undefined) { - const found = await findSymbolAtHead(git, anchor, path); - if (found) return found; + const found = await findSymbolAtHead(git, anchor, path, origin); + if (found) return { kind: "span", span: found }; } // Whole-file anchors (no lines) claim the file itself: existence is the // whole claim — unless a symbol was set and is now gone, which step 1 // already failed to find, and asserting "live" would outrun the evidence. if (anchor.lines === undefined) { - if (anchor.symbol !== undefined) return undefined; - if ((await git.fileAtHead(path)) !== undefined) return { path }; - if (anchor.as_of !== undefined) { - const renamed = await git.renamedTo(anchor.as_of, path); + if (anchor.symbol !== undefined) return { kind: "lost" }; + if ((await git.fileAtHead(path)) !== undefined) return { kind: "span", span: { path } }; + if (origin !== undefined) { + const renamed = await git.renamedTo(origin, path); if (renamed !== undefined && (await git.fileAtHead(renamed)) !== undefined) { - return { path: renamed }; + return { kind: "span", span: { path: renamed } }; } } - return undefined; + return { kind: "lost" }; } - // 2. Blame-trace the recorded lines from as_of forward. An unparseable - // range or an as_of this repo cannot resolve leaves nothing to trace. - if (range === undefined || anchor.as_of === undefined) return undefined; - const asOfSha = await git.commitSha(anchor.as_of); - if (asOfSha === undefined) return undefined; - if (asOfSha === git.headFull) { + // 2. Blame-trace the recorded lines from as_of forward. + if (range === undefined) return { kind: "lost" }; + if (origin === undefined) { + // No usable origin to trace from. The file still being at HEAD confirms + // the anchor is not lost; the lines stay exactly as recorded, unverified. + return (await git.fileAtHead(path)) !== undefined ? { kind: "unverified" } : { kind: "lost" }; + } + if (origin === git.headFull) { // The claim is already about HEAD; verify it instead of tracing. const content = await git.fileAtHead(path); - if (content === undefined) return undefined; - return range.end <= content.split("\n").length ? { path, lines: range } : undefined; + if (content === undefined) return { kind: "lost" }; + return range.end <= content.split("\n").length + ? { kind: "span", span: { path, lines: range } } + : { kind: "lost" }; } - return git.traceLines(path, range, anchor.as_of); + const traced = await git.traceLines(path, range, origin); + return traced === undefined ? { kind: "lost" } : { kind: "span", span: traced }; } async function findSymbolAtHead( git: GitView, anchor: Anchor, path: string, + origin: string | undefined, ): Promise<ResolvedSpan | undefined> { const candidates = [path]; - if ((await git.fileAtHead(path)) === undefined && anchor.as_of !== undefined) { - const renamed = await git.renamedTo(anchor.as_of, path); + if ((await git.fileAtHead(path)) === undefined && origin !== undefined) { + const renamed = await git.renamedTo(origin, path); if (renamed !== undefined) candidates.push(renamed); } for (const candidate of candidates) { @@ -336,7 +433,102 @@ async function findSymbolAtHead( // --- Classification and the report --------------------------------------- -export type AnchorOutcome = "current" | "resolved" | "moved" | "lost"; +export type AnchorOutcome = "current" | "resolved" | "moved" | "repaired" | "unverified" | "lost"; + +/** + * The commit to record as `as_of` for a re-anchored claim. + * + * HEAD is the commit the span was actually verified against, so HEAD is the + * default and is always truthful. But when HEAD sits on a branch that will not + * reach the integration branch verbatim — a squash merge rewrites the whole + * branch into one new commit — a truthful HEAD stamp is about to become an + * `as_of` that names nothing. So prefer the newest surviving commit, but only + * when the very same span verifiably holds there. + * + * The verification is the point, not a nicety. For a span this branch just + * changed, the merge-base is precisely where the span is *not* valid, and + * `git blame --reverse` reads `-L` against the `as_of` revision — so stamping + * an unverified merge-base would silently re-point the anchor at whatever text + * occupied those line numbers back then. That is the silently-wrong anchor + * this module exists to prevent, so an unverifiable merge-base loses to HEAD + * every time, orphan or not. `resolveClaim` degrades such an orphan to + * `unverified` rather than `lost`, and `why doctor` reports the gap. + */ +async function stampFor(git: GitView, span: ResolvedSpan): Promise<string> { + const base = await git.survivingBase(); + if (base === undefined) return git.headShort; + return (await spanHoldsAt(git, base, span)) ? await git.shortSha(base) : git.headShort; +} + +/** + * Whether `span` names the same code at `rev` that it names at HEAD. + * + * The question is identity, not resemblance. Comparing the text at those line + * numbers answers the wrong one: a span this branch shifted can land on numbers + * that coincidentally held byte-identical text at `rev` — a duplicated config + * block, boilerplate, a repeated import — and stamping `rev` then re-points the + * anchor at that other code. So ask git, with the same reverse blame the + * resolver uses: an `as_of` is honest exactly when re-resolving from it + * reproduces this span. That equivalence is also what keeps `why anchor` + * idempotent — a stamp it writes is one the next run re-derives, rather than + * one the next run re-traces to the wrong code and calls `lost`. + * + * A whole-file anchor claims its path, so the path existing at `rev` is the + * whole claim; there is no span to re-derive. + */ +async function spanHoldsAt(git: GitView, rev: string, span: ResolvedSpan): Promise<boolean> { + const there = await git.fileAt(rev, span.path); + if (there === undefined) return false; + if (span.lines === undefined) return true; + const traced = await git.traceLines(span.path, span.lines, rev); + return ( + traced !== undefined && + traced.path === span.path && + traced.lines.start === span.lines.start && + traced.lines.end === span.lines.end + ); +} + +/** + * The repaired result for an otherwise-current anchor whose recorded `as_of` + * is orphaned, or undefined when there is nothing to repair — or nothing + * durable to repair it with. + * + * A readable `as_of` — a clean ancestor of HEAD — is provenance: it means the + * span survived unchanged since that commit, and it is never rewritten + * (.why/decisions/as-of-is-provenance.md). An orphaned `as_of` means nothing: + * no clone of the integration branch resolves it (typically a squash merge + * discarded the branch commit it names), and because the anchor still resolves + * cleanly, no re-anchoring would ever touch it — the one `why doctor` finding + * nothing could clear. Repair is honest exactly because a claim that reaches + * the `current` classification with an unusable origin was verified at HEAD + * without reading `as_of` at all: the path exists, or the symbol was re-found. + * (A bare line claim with an unusable origin is `unverified` and never gets + * here — re-stamping it would assert lines nothing verified.) + * + * The stamp must be durable, or the next squash recreates the orphan. Where + * `survivingBase` is undefined — HEAD is on the integration branch, or git + * records no integration branch and HEAD is all there is — HEAD is that stamp. + * On a branch that has diverged from the integration branch it is the + * merge-base, and only when the span verifiably holds there; otherwise repair + * declines and leaves the orphan to the post-merge run on the integration + * branch, rather than write a branch HEAD the squash will discard and + * re-orphan. + */ +async function repairOrphan( + git: GitView, + anchor: Anchor, + span: ResolvedSpan, +): Promise<Omit<AnchorResult, "conceptId" | "index"> | undefined> { + if (anchor.as_of === undefined) return undefined; // nothing recorded, nothing to repair + if ((await git.historyOrigin(anchor.as_of)) !== undefined) return undefined; // readable provenance — keep it + const base = await git.survivingBase(); + if (base !== undefined && !(await spanHoldsAt(git, base, span))) return undefined; + // Everything but the unreadable as_of was confirmed at HEAD, so everything + // but as_of is kept exactly as written. + const after: Anchor = { ...anchor, as_of: base === undefined ? git.headShort : await git.shortSha(base) }; + return { before: anchor, after, outcome: "repaired", changed: true, recovered: false }; +} export interface AnchorResult { conceptId: string; @@ -370,21 +562,40 @@ function sameLines(before: string | undefined, after: LineRange | undefined): bo return parsed !== undefined && parsed.start === after.start && parsed.end === after.end; } -function classify(git: GitView, anchor: Anchor, span: ResolvedSpan | undefined): Omit<AnchorResult, "conceptId" | "index"> { - if (span === undefined) { +async function classify( + git: GitView, + anchor: Anchor, + claim: Claim, +): Promise<Omit<AnchorResult, "conceptId" | "index">> { + if (claim.kind === "lost") { // Lost keeps every last-known value (forensics + the recovery path); // only the state flips, and only once. const after: Anchor = { ...anchor, state: "lost" }; return { before: anchor, after, outcome: "lost", changed: anchor.state !== "lost", recovered: false }; } + if (claim.kind === "unverified") { + // The path is confirmed at HEAD but as_of gives nothing to trace from, so + // the recorded lines can be neither re-lined nor honestly rewritten. Leave + // the entry byte-for-byte as written and let `why doctor` name the gap. + return { before: anchor, after: anchor, outcome: "unverified", changed: false, recovered: false }; + } + const span = claim.span; const wasLive = (anchor.state ?? "live") === "live"; if (span.path === normalizePath(anchor.path) && sameLines(anchor.lines, span.lines) && wasLive) { - return { before: anchor, after: anchor, outcome: "current", changed: false, recovered: false }; + return ( + (await repairOrphan(git, anchor, span)) ?? { + before: anchor, + after: anchor, + outcome: "current", + changed: false, + recovered: false, + } + ); } const after: Anchor = { path: span.path }; if (anchor.symbol !== undefined) after.symbol = anchor.symbol; if (span.lines !== undefined) after.lines = formatLines(span.lines); - after.as_of = git.headShort; + after.as_of = await stampFor(git, span); after.state = "live"; return { before: anchor, @@ -433,8 +644,8 @@ export async function resolveAnchors( continue; } for (const [index, anchor] of concept.why.anchors.entries()) { - const span = await resolveClaim(git, anchor); - results.push({ conceptId: concept.id, index, ...classify(git, anchor, span) }); + const claim = await resolveClaim(git, anchor); + results.push({ conceptId: concept.id, index, ...(await classify(git, anchor, claim)) }); } } return { head: git.headShort, results, skipped }; @@ -490,6 +701,10 @@ function detailFor(result: AnchorResult): string { return anchorSpan(result.before); case "lost": return `${anchorSpan(result.before)} (last known${result.before.as_of === undefined ? "" : `, as_of ${result.before.as_of}`})`; + case "unverified": + return `${anchorSpan(result.before)} (path live; as_of ${result.before.as_of ?? "unset"} is not an ancestor of HEAD)`; + case "repaired": + return `${anchorSpan(result.before)} (orphaned as_of ${result.before.as_of} → ${result.after.as_of})`; default: return `${anchorSpan(result.before)} → ${anchorSpan(result.after)}`; } @@ -499,15 +714,17 @@ const OUTCOME_LABELS: Record<AnchorOutcome, string> = { current: "already current", resolved: "resolved", moved: "moved", + repaired: "repaired as_of", + unverified: "unverified as_of", lost: "lost", }; export function renderAnchorReport( report: AnchorReport, - mode: { check: boolean; written: string[] }, + mode: { check: boolean; written: string[]; allowDrift?: boolean }, ): string[] { const lines: string[] = []; - const counts: Record<AnchorOutcome, number> = { current: 0, resolved: 0, moved: 0, lost: 0 }; + const counts: Record<AnchorOutcome, number> = { current: 0, resolved: 0, moved: 0, repaired: 0, unverified: 0, lost: 0 }; for (const result of report.results) counts[result.outcome]++; const total = report.results.length; @@ -520,15 +737,37 @@ export function renderAnchorReport( lines.push(` ${label.padEnd(21)} ${result.conceptId.padEnd(idWidth)} ${detailFor(result)}`); } lines.push(""); - lines.push( - `${counts.moved} moved · ${counts.resolved} resolved · ${counts.lost} lost · ${counts.current} already current`, - ); + // Repaired and unverified surface only when non-zero: both are rare and + // worth reading, and a permanent `0` would train the eye past them. + const tally = [`${counts.moved} moved`, `${counts.resolved} resolved`]; + if (counts.repaired > 0) tally.push(`${counts.repaired} repaired as_of`); + tally.push(`${counts.lost} lost`); + if (counts.unverified > 0) tally.push(`${counts.unverified} unverified as_of`); + tally.push(`${counts.current} already current`); + lines.push(tally.join(" · ")); } for (const conceptId of report.skipped) { lines.push(`skipped ${conceptId}: its why.anchors have schema problems — run \`why lint\``); } const changed = report.results.filter((r) => r.changed).length; - if (mode.check) { + const destroyed = report.results.filter((r) => r.outcome === "lost" && r.changed).length; + if (mode.check && mode.allowDrift === true) { + // The PR gate's reading: this branch's HEAD is about to be squashed away, + // so telling the author to run `why anchor` here would stamp an as_of that + // names nothing (DESIGN.md §4). Drift is the why-anchor job's problem; + // only an anchor this change destroyed is the author's. + if (destroyed > 0) { + lines.push( + `${destroyed} anchor${destroyed === 1 ? "" : "s"} lost — the code ${destroyed === 1 ? "it claims" : "they claim"} is gone. Update the concept${destroyed === 1 ? "" : "s"} or re-dig; no re-anchoring can recover ${destroyed === 1 ? "it" : "them"}.`, + ); + } + const drifted = changed - destroyed; + if (drifted > 0) { + lines.push( + `${drifted} anchor${drifted === 1 ? "" : "s"} drifted — the why-anchor job re-stamps ${drifted === 1 ? "it" : "them"} from main after the merge (nothing written)`, + ); + } + } else if (mode.check) { if (changed > 0) { lines.push(`${changed} anchor${changed === 1 ? "" : "s"} out of date — run \`why anchor\` to update (nothing written)`); } diff --git a/src/cli.ts b/src/cli.ts index c41e546..de745ca 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -173,15 +173,26 @@ async function runBlame({ values, positionals, bundle, io }: CommandContext): Pr /** `why anchor` — re-resolve every anchor claim against HEAD (DESIGN.md §4). */ async function runAnchor({ values, positionals, bundle, io }: CommandContext): Promise<number> { if (positionals.length > 0) { - io.err("why anchor: takes no positional arguments — usage: why anchor [--check] [--concept <id>]"); + io.err( + "why anchor: takes no positional arguments — usage: why anchor [--check [--allow-drift]] [--concept <id>]", + ); return 2; } const check = values.check === true; + const allowDrift = values["allow-drift"] === true; + if (allowDrift && !check) { + io.err("why anchor: --allow-drift only means something with --check — usage: why anchor --check --allow-drift"); + return 2; + } try { const report = await resolveAnchors(bundle!, { concept: values.concept as string | undefined }); const written = check ? [] : await writeAnchorUpdates(bundle!, report); - for (const line of renderAnchorReport(report, { check, written })) io.out(line); - return check && report.results.some((r) => r.changed) ? 1 : 0; + for (const line of renderAnchorReport(report, { check, written, allowDrift })) io.out(line); + if (!check) return 0; + // --allow-drift is the PR gate's question (DESIGN.md §4, docs/ci.md): drift + // is expected on a branch and gets re-stamped from main after the merge, so + // only an anchor this change *destroys* is worth blocking on. + return report.results.some((r) => (allowDrift ? r.outcome === "lost" && r.changed : r.changed)) ? 1 : 0; } catch (e) { if (e instanceof AnchorError) { io.err(`why anchor: ${e.message}`); @@ -521,7 +532,12 @@ const COMMAND_SPECS: Record<Command, CommandSpec> = { run: runBlame, }, anchor: { - options: { ...BUNDLE_OPTIONS, check: { type: "boolean" }, concept: { type: "string" } }, + options: { + ...BUNDLE_OPTIONS, + check: { type: "boolean" }, + "allow-drift": { type: "boolean" }, + concept: { type: "string" }, + }, needsBundle: true, run: runAnchor, }, diff --git a/src/dig-state.ts b/src/dig-state.ts index 5732934..80f8cfb 100644 --- a/src/dig-state.ts +++ b/src/dig-state.ts @@ -1,4 +1,4 @@ -// Incremental dig state (DESIGN.md §6, issues/304-dig-incremental.md): the +// Incremental dig state (DESIGN.md §6): the // high-water mark under <bundle>/.dig-state.json that lets routine // `why dig --episodes` runs process only new history. The contract, in // priority order: diff --git a/src/doctor.ts b/src/doctor.ts index 74079d7..af6047a 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -26,10 +26,18 @@ export interface AnchorItem { /** * Why a live anchor's as_of is a real concern: it names a commit unrelated to - * HEAD (history diverged or was rebased away), or one this repository cannot - * resolve at all. A clean ancestor behind HEAD is stable provenance — the - * `why anchor` claim (DESIGN.md §2/§4: "the commit at which path+lines were - * valid") has simply survived unchanged since then — so it is not flagged. + * HEAD (history diverged, or a squash merge discarded the branch it was + * stamped on), or one this repository cannot resolve at all. A clean ancestor + * behind HEAD is stable provenance — the `why anchor` claim (DESIGN.md §2/§4: + * "the commit at which path+lines were valid") has simply survived unchanged + * since then — so it is not flagged. + * + * These are the anchors `why anchor` reports as `unverified as_of`: the code + * is present, the provenance is unreadable. That is maintenance debt (yellow), + * not a broken archive (red) — the anchor is not asserting anything false. The + * two reasons differ only by whether this clone happens to still have the + * commit, which is why neither is trusted to read history through (DESIGN.md + * §4 "`as_of` must be an ancestor") and why both read as the same finding. */ export type StaleReason = "not-ancestor" | "unresolved"; diff --git a/test/anchor.test.ts b/test/anchor.test.ts index 82c7067..4cfd907 100644 --- a/test/anchor.test.ts +++ b/test/anchor.test.ts @@ -302,6 +302,73 @@ test("why anchor is idempotent: the second run reports current and writes nothin assert.equal(await main(["anchor", "--check", "--bundle", whyRoot], repo, check.io), 0); }); +// --- The PR gate's question (docs/ci.md) --------------------------------- +// +// A contributor cannot re-anchor correctly from a branch: their HEAD is what +// the squash discards. So the gate tolerates drift (the why-anchor job +// re-stamps it from main) and blocks only on an anchor the PR destroyed. + +test("--check --allow-drift tolerates drift and blocks only on a destroyed anchor", async () => { + const { repo, whyRoot } = await seedScenario(); + + // seedScenario's refactor renames a file (moved), shifts lines (resolved), + // and deletes a function (lost) — one of each, in one run. + const strict = capture(); + assert.equal(await main(["anchor", "--check", "--bundle", whyRoot], repo, strict.io), 1); + + const lenient = capture(); + const code = await main(["anchor", "--check", "--allow-drift", "--bundle", whyRoot], repo, lenient.io); + assert.equal(code, 1, "a deleted function destroyed an anchor — that still blocks"); + const text = lenient.out.join("\n"); + assert.ok(text.includes("1 anchor lost"), text); + assert.ok(text.includes("2 anchors drifted"), text); + assert.ok(!text.includes("run `why anchor`"), "must not tell the author to stamp a doomed as_of"); +}); + +test("--check --allow-drift passes when nothing is destroyed, however much drifted", async () => { + const repo = await makeRepo("why-drift-"); + await write(repo, "config/defaults.toml", DEFAULTS_TOML); + git(repo, "add", "-A"); + git(repo, "commit", "-qm", "seed"); + const c1 = git(repo, "rev-parse", "--short", "HEAD"); + + const whyRoot = await scaffoldBundle(repo); + await write(repo, ".why/decisions/47s-request-deadline.md", deadlineDecision(c1)); + + await write(repo, "config/defaults.toml", DEFAULTS_TOML_SHIFTED); + git(repo, "add", "-A"); + git(repo, "commit", "-qm", "shift the block"); + + const before = await readFile(join(whyRoot, "decisions/47s-request-deadline.md"), "utf8"); + const { io, out } = capture(); + const code = await main(["anchor", "--check", "--allow-drift", "--bundle", whyRoot], repo, io); + assert.equal(code, 0, `drift alone must not block a PR:\n${out.join("\n")}`); + assert.ok(out.join("\n").includes("why-anchor job re-stamps"), out.join("\n")); + assert.equal( + await readFile(join(whyRoot, "decisions/47s-request-deadline.md"), "utf8"), + before, + "--check writes nothing, with or without --allow-drift", + ); +}); + +test("an anchor already recorded lost does not block the gate forever", async () => { + const { repo, whyRoot } = await seedScenario(); + // Record reality once, from a state where the deletion is already committed. + assert.equal(await main(["anchor", "--bundle", whyRoot], repo, capture().io), 0); + + const { io, out } = capture(); + const code = await main(["anchor", "--check", "--allow-drift", "--bundle", whyRoot], repo, io); + assert.equal(code, 0, `a committed state: lost is doctor's business, not the gate's:\n${out.join("\n")}`); +}); + +test("--allow-drift without --check is a usage error, not a silent no-op", async () => { + const { repo, whyRoot } = await seedScenario(); + const { io, err } = capture(); + const code = await main(["anchor", "--allow-drift", "--bundle", whyRoot], repo, io); + assert.equal(code, 2); + assert.ok(err.join("\n").includes("--allow-drift"), err.join("\n")); +}); + test("a lost anchor whose symbol reappears resolves back to live", async () => { const { repo, whyRoot } = await seedScenario(); const first = capture(); @@ -350,6 +417,496 @@ test("--concept scopes resolution and writes to that concept only", async () => assert.ok(missing.err.join("\n").includes("no-such-thing"), missing.err.join("\n")); }); +// --- as_of and squash merges (DESIGN.md §4) ------------------------------ +// +// A squash merge rewrites a branch into one new commit, so an `as_of` stamped +// on the branch names a commit that never reaches the integration branch. It +// survives in the clone that made it and nowhere else — the failure these +// tests pin is a tool whose answer depends on the operator's local branches. + +interface SquashRepo { + repo: string; + whyRoot: string; + /** Last commit on main before the branch — an ancestor of HEAD. */ + base: string; + /** The branch commit the squash discarded: present here, absent in a fresh clone. */ + orphan: string; +} + +const wholeFileConcept = (path: string, asOf: string) => `--- +type: decision +title: A whole-file claim +description: Anchored to a path, so the path existing is the whole claim. +timestamp: 2026-07-14 +why: + status: active + happened_on: 2026-07-14 + confidence: recorded + anchors: + - path: ${path} + as_of: "${asOf}" + state: live +--- + +# A whole-file claim + +Body text. +`; + +const linesConcept = (asOf: string) => `--- +type: decision +title: A line-range claim +description: Anchored to lines, so re-lining needs a usable as_of to trace from. +timestamp: 2026-07-14 +why: + status: active + happened_on: 2026-07-14 + confidence: recorded + anchors: + - path: config/defaults.toml + lines: 3-4 + as_of: "${asOf}" + state: live +--- + +# A line-range claim + +Body text. +`; + +/** + * main → a branch commit → a squash of that branch back onto main. The branch + * ref is kept, so `orphan` is reachable in this repo exactly as a merged PR's + * branch still is on the machine that pushed it. + */ +async function seedSquashRepo(rename: boolean): Promise<SquashRepo> { + const repo = await makeRepo("why-squash-"); + git(repo, "checkout", "-q", "-b", "main"); + await write(repo, "config/defaults.toml", DEFAULTS_TOML); + await write(repo, "src/lock.rs", LOCK_RS); + git(repo, "add", "-A"); + git(repo, "commit", "-qm", "seed"); + const base = git(repo, "rev-parse", "--short", "HEAD"); + + git(repo, "checkout", "-q", "-b", "drafts"); + await write(repo, "config/defaults.toml", DEFAULTS_TOML_SHIFTED); + git(repo, "add", "-A"); + git(repo, "commit", "-qm", "draft: shift defaults"); + const orphan = git(repo, "rev-parse", "--short", "HEAD"); + + git(repo, "checkout", "-q", "main"); + git(repo, "merge", "--squash", "-q", "drafts"); + git(repo, "commit", "-qm", "squash of drafts (#1)"); + if (rename) { + // A rename that lands on main *after* the branch point, so git can detect + // it across `orphan..HEAD` even though orphan is not an ancestor. + git(repo, "mv", "src/lock.rs", "src/locking.rs"); + git(repo, "commit", "-qm", "rename lock.rs"); + } + + const whyRoot = await scaffoldBundle(repo); + return { repo, whyRoot, base, orphan }; +} + +test("a squash-orphaned as_of degrades to unverified, not lost, and rewrites nothing", async () => { + const { repo, whyRoot, orphan } = await seedSquashRepo(false); + await write(repo, ".why/decisions/lines-claim.md", linesConcept(orphan)); + const before = await readFile(join(whyRoot, "decisions/lines-claim.md"), "utf8"); + + const { io, out } = capture(); + assert.equal(await main(["anchor", "--bundle", whyRoot], repo, io), 0, out.join("\n")); + const text = out.join("\n"); + + // The file is right there; only its provenance is unreadable. Calling that + // `lost` is the false alarm that trains people to ignore the health report. + assert.ok(text.includes("unverified as_of"), text); + assert.ok(text.includes("0 lost"), text); + + const after = await readFile(join(whyRoot, "decisions/lines-claim.md"), "utf8"); + assert.equal(after, before, "an unverifiable as_of must not provoke a rewrite"); + const anchor = await anchorOf(whyRoot, "decisions/lines-claim"); + assert.equal(anchor.state, "live"); + assert.equal(anchor.lines, "3-4", "the recorded lines stay exactly as written"); + assert.equal(anchor.as_of, orphan, "as_of is kept for forensics, not silently repaired"); +}); + +test("rename detection refuses to read history through a non-ancestor as_of", async () => { + const { repo, whyRoot, base, orphan } = await seedSquashRepo(true); + + // Sanity: git *will* answer the rename question across the orphan, which is + // exactly why the ancestry gate has to be explicit rather than incidental. + const detected = git(repo, "diff", "--name-status", "-M", orphan, "HEAD"); + assert.ok(/^R\d*\tsrc\/lock\.rs\tsrc\/locking\.rs$/m.test(detected), detected); + + await write(repo, ".why/decisions/orphan-claim.md", wholeFileConcept("src/lock.rs", orphan)); + const { io, out } = capture(); + assert.equal(await main(["anchor", "--bundle", whyRoot], repo, io), 0, out.join("\n")); + + // Following that rename would resolve here and go lost in a fresh clone — + // the same bundle answering differently per operator. Refuse it. + const orphaned = await anchorOf(whyRoot, "decisions/orphan-claim"); + assert.equal(orphaned.state, "lost", "a rename seen only through an orphan is not evidence"); + assert.equal(orphaned.path, "src/lock.rs"); + + // The same rename through an ancestor as_of is real history: follow it. + await write(repo, ".why/decisions/ancestor-claim.md", wholeFileConcept("src/lock.rs", base)); + const second = capture(); + assert.equal(await main(["anchor", "--bundle", whyRoot], repo, second.io), 0); + const followed = await anchorOf(whyRoot, "decisions/ancestor-claim"); + assert.equal(followed.state, "live"); + assert.equal(followed.path, "src/locking.rs", "an ancestor as_of still follows renames"); +}); + +// --- Choosing the as_of to stamp ----------------------------------------- + +/** Record `main` as the remote's default branch, then branch off it. */ +function withIntegrationBranch(repo: string): void { + git(repo, "update-ref", "refs/remotes/origin/main", git(repo, "rev-parse", "main")); + git(repo, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"); +} + +/** A stale `lines` the symbol step will correct, so the anchor really moves. */ +const symbolConcept = (asOf: string) => `--- +type: decision +title: 47s request deadline +description: Server-side deadline pinned 2s past the gateway cap. +timestamp: 2026-07-11 +why: + status: active + happened_on: 2024-01-15 + confidence: corroborated + anchors: + - path: config/defaults.toml + symbol: request_deadline + lines: 1 + as_of: "${asOf}" + state: live +--- + +# 47s request deadline + +47 = 45 + 2, so the gateway timeout always fires first. +`; + +test("on a branch, as_of is stamped at the surviving merge-base when the span holds there", async () => { + const repo = await makeRepo("why-stamp-"); + git(repo, "checkout", "-q", "-b", "main"); + await write(repo, "config/defaults.toml", DEFAULTS_TOML); + git(repo, "add", "-A"); + git(repo, "commit", "-qm", "seed"); + const seed = git(repo, "rev-parse", "--short", "HEAD"); + + await write(repo, "CHANGELOG.md", "# changelog\n"); + git(repo, "add", "-A"); + git(repo, "commit", "-qm", "later work on main"); + const mainTip = git(repo, "rev-parse", "--short", "HEAD"); + withIntegrationBranch(repo); + + // A feature branch that leaves the anchored file alone. Its own HEAD will be + // squashed away at merge, so stamping HEAD would orphan the anchor — while + // the span is provably identical at the merge-base, which survives. + git(repo, "checkout", "-q", "-b", "feature"); + await write(repo, "README.md", "# unrelated\n"); + git(repo, "add", "-A"); + git(repo, "commit", "-qm", "unrelated work"); + const featureTip = git(repo, "rev-parse", "--short", "HEAD"); + + const whyRoot = await scaffoldBundle(repo); + await write(repo, ".why/decisions/47s-request-deadline.md", symbolConcept(seed)); + + const { io, out } = capture(); + assert.equal(await main(["anchor", "--bundle", whyRoot], repo, io), 0, out.join("\n")); + + const anchor = await anchorOf(whyRoot, "decisions/47s-request-deadline"); + assert.equal(anchor.lines, "4", "the symbol step re-lined the claim"); + assert.equal(anchor.as_of, mainTip, "stamp the newest commit certain to survive the squash"); + assert.notEqual(anchor.as_of, featureTip, "HEAD here is exactly what a squash discards"); + assert.notEqual(anchor.as_of, seed, "and it is a fresh stamp, not the old value kept"); +}); + +test("as_of falls back to HEAD when the span cannot be verified at the merge-base", async () => { + const repo = await makeRepo("why-stamp-head-"); + git(repo, "checkout", "-q", "-b", "main"); + await write(repo, "config/defaults.toml", DEFAULTS_TOML); + git(repo, "add", "-A"); + git(repo, "commit", "-qm", "seed"); + const mainTip = git(repo, "rev-parse", "--short", "HEAD"); + withIntegrationBranch(repo); + + // This branch moves the anchored lines, so the merge-base is precisely where + // the new span is *not* valid. Stamping it would re-point the anchor at + // whatever text held those line numbers back then — the silently-wrong + // anchor. HEAD is truthful even though the squash will orphan it. + git(repo, "checkout", "-q", "-b", "feature"); + await write(repo, "config/defaults.toml", DEFAULTS_TOML_SHIFTED); + git(repo, "add", "-A"); + git(repo, "commit", "-qm", "shift the deadline block"); + const featureTip = git(repo, "rev-parse", "--short", "HEAD"); + + const whyRoot = await scaffoldBundle(repo); + await write(repo, ".why/decisions/47s-request-deadline.md", deadlineDecision(mainTip)); + + const { io, out } = capture(); + assert.equal(await main(["anchor", "--bundle", whyRoot], repo, io), 0, out.join("\n")); + + const anchor = await anchorOf(whyRoot, "decisions/47s-request-deadline"); + assert.equal(anchor.lines, "5-6", "the span moved on this branch"); + assert.equal(anchor.as_of, featureTip, "an unverifiable merge-base loses to a truthful HEAD"); + assert.notEqual(anchor.as_of, mainTip); +}); + +/** + * Two byte-identical blocks. The anchor claims the second; deleting the first + * shifts it onto the line numbers the first used to occupy — so the text at the + * merge-base matches the text at HEAD while naming entirely different code. + */ +const clientDeadlineDecision = (asOf: string) => `--- +type: decision +title: 47s client deadline +description: The client deadline is pinned 2s past the gateway cap. +timestamp: 2026-07-11 +why: + status: active + happened_on: 2024-01-15 + confidence: corroborated + anchors: + - path: config/defaults.toml + lines: 5-6 + as_of: "${asOf}" + state: live +--- + +# 47s client deadline + +47 = 45 + 2, so the gateway timeout always fires first. +`; + +const DUPLICATE_BLOCKS = `[server] +request_deadline = 47 +retry_jitter = false +[client] +request_deadline = 47 +retry_jitter = false +`; +const CLIENT_BLOCK_ONLY = `[client] +request_deadline = 47 +retry_jitter = false +`; + +test("a merge-base whose text merely matches is not a verified span", async () => { + const repo = await makeRepo("why-stamp-dup-"); + git(repo, "checkout", "-q", "-b", "main"); + await write(repo, "config/defaults.toml", DUPLICATE_BLOCKS); + git(repo, "add", "-A"); + git(repo, "commit", "-qm", "seed"); + const mainTip = git(repo, "rev-parse", "--short", "HEAD"); + withIntegrationBranch(repo); + + // Drop [server], so the anchored [client] block slides from 5-6 up to 2-3 — + // where [server] used to sit, with identical text. Byte-comparing the span at + // the merge-base therefore says "holds" about the wrong block entirely. + git(repo, "checkout", "-q", "-b", "feature"); + await write(repo, "config/defaults.toml", CLIENT_BLOCK_ONLY); + git(repo, "add", "-A"); + git(repo, "commit", "-qm", "drop the server block"); + const featureTip = git(repo, "rev-parse", "--short", "HEAD"); + + const whyRoot = await scaffoldBundle(repo); + await write(repo, ".why/decisions/47s-request-deadline.md", clientDeadlineDecision(mainTip)); + + const { io, out } = capture(); + assert.equal(await main(["anchor", "--bundle", whyRoot], repo, io), 0, out.join("\n")); + + const first = await anchorOf(whyRoot, "decisions/47s-request-deadline"); + assert.equal(first.lines, "2-3", "the [client] block moved up"); + assert.equal(first.as_of, featureTip, "identical text at the merge-base is not the same code"); + assert.notEqual(first.as_of, mainTip, "stamping it would name [server], which this is not about"); + + // The real cost of a false stamp: the next run traces from the deleted + // [server] block, finds nothing, and buries an anchor whose code is untouched. + const { io: io2, out: out2 } = capture(); + assert.equal(await main(["anchor", "--bundle", whyRoot], repo, io2), 0, out2.join("\n")); + + const second = await anchorOf(whyRoot, "decisions/47s-request-deadline"); + assert.equal(second.state, "live", "a second run must not lose an anchor whose code is present"); + assert.equal(second.lines, "2-3", "re-anchoring is idempotent"); + assert.equal(second.as_of, first.as_of, "and the stamp is one the next run re-derives"); +}); + +// --- Repairing an orphaned as_of ------------------------------------------ + +/** The symbol resolves at HEAD to exactly the recorded span, so the claim is + * current — the orphaned as_of is the only thing wrong with the anchor. */ +const repairSymbolConcept = (asOf: string) => `--- +type: decision +title: 47s request deadline +description: Server-side deadline pinned 2s past the gateway cap. +timestamp: 2026-07-11 +why: + status: active + happened_on: 2024-01-15 + confidence: corroborated + anchors: + - path: config/defaults.toml + symbol: request_deadline + lines: 4 + as_of: "${asOf}" + state: live +--- + +# 47s request deadline + +47 = 45 + 2, so the gateway timeout always fires first. +`; + +test("an orphaned as_of is repaired when the claim re-verifies on the integration branch", async () => { + const repo = await makeRepo("why-repair-"); + git(repo, "checkout", "-q", "-b", "main"); + await write(repo, "config/defaults.toml", DEFAULTS_TOML); + git(repo, "add", "-A"); + git(repo, "commit", "-qm", "seed"); + + // The branch births a file; the squash then discards the only commit the + // anchors' as_of ever named. + git(repo, "checkout", "-q", "-b", "feature"); + await write(repo, "docs/notes.md", "# born on the branch\n"); + git(repo, "add", "-A"); + git(repo, "commit", "-qm", "add notes"); + const orphan = git(repo, "rev-parse", "--short", "HEAD"); + + git(repo, "checkout", "-q", "main"); + git(repo, "merge", "--squash", "-q", "feature"); + git(repo, "commit", "-qm", "squash of feature (#1)"); + const squash = git(repo, "rev-parse", "--short", "HEAD"); + withIntegrationBranch(repo); // HEAD is contained in origin/HEAD, so HEAD is durable + + const whyRoot = await scaffoldBundle(repo); + await write(repo, ".why/decisions/born-on-branch.md", wholeFileConcept("docs/notes.md", orphan)); + await write(repo, ".why/decisions/47s-request-deadline.md", repairSymbolConcept(orphan)); + + const { io, out } = capture(); + assert.equal(await main(["anchor", "--bundle", whyRoot], repo, io), 0, out.join("\n")); + assert.ok(out.join("\n").includes("repaired as_of"), out.join("\n")); + + const wholeFile = await anchorOf(whyRoot, "decisions/born-on-branch"); + assert.equal(wholeFile.as_of, squash, "the squash is the surviving commit that contains the file"); + assert.equal(wholeFile.path, "docs/notes.md"); + assert.equal(wholeFile.state, "live"); + + const symbol = await anchorOf(whyRoot, "decisions/47s-request-deadline"); + assert.equal(symbol.as_of, squash, "a re-found symbol verifies the claim without reading as_of"); + assert.equal(symbol.lines, "4", "the span itself did not move"); + + // Idempotent: a stamp repair writes is one the next run re-derives and keeps. + const second = capture(); + assert.equal(await main(["anchor", "--bundle", whyRoot], repo, second.io), 0, second.out.join("\n")); + assert.ok(second.out.join("\n").includes("2 already current"), second.out.join("\n")); + assert.equal((await anchorOf(whyRoot, "decisions/born-on-branch")).as_of, squash); +}); + +test("repair declines on a diverged branch when the span cannot be verified at the merge-base", async () => { + const repo = await makeRepo("why-repair-branch-"); + git(repo, "checkout", "-q", "-b", "main"); + await write(repo, "config/defaults.toml", DEFAULTS_TOML); + git(repo, "add", "-A"); + git(repo, "commit", "-qm", "seed"); + withIntegrationBranch(repo); + + // An orphan: the tip of a branch main never merged. + git(repo, "checkout", "-q", "-b", "elsewhere"); + await write(repo, "scratch.txt", "gone tomorrow\n"); + git(repo, "add", "-A"); + git(repo, "commit", "-qm", "never merged"); + const orphan = git(repo, "rev-parse", "--short", "HEAD"); + + // The anchored file is born on *this* branch, so the merge-base cannot + // verify it — the only available stamp is a branch HEAD the coming squash + // discards, which would merely recreate the orphan. Repair must decline. + git(repo, "checkout", "-q", "main"); + git(repo, "checkout", "-q", "-b", "feature"); + await write(repo, "docs/notes.md", "# born on the branch\n"); + git(repo, "add", "-A"); + git(repo, "commit", "-qm", "add notes"); + const featureTip = git(repo, "rev-parse", "--short", "HEAD"); + + const whyRoot = await scaffoldBundle(repo); + await write(repo, ".why/decisions/born-on-branch.md", wholeFileConcept("docs/notes.md", orphan)); + const before = await readFile(join(whyRoot, "decisions/born-on-branch.md"), "utf8"); + + const { io, out } = capture(); + assert.equal(await main(["anchor", "--bundle", whyRoot], repo, io), 0, out.join("\n")); + assert.ok(out.join("\n").includes("already current"), out.join("\n")); + + assert.equal( + await readFile(join(whyRoot, "decisions/born-on-branch.md"), "utf8"), + before, + "no branch-side churn: an undurable repair writes nothing", + ); + const anchor = await anchorOf(whyRoot, "decisions/born-on-branch"); + assert.equal(anchor.as_of, orphan, "left for the post-merge run on the integration branch"); + assert.notEqual(anchor.as_of, featureTip); +}); + +test("on a diverged branch, repair stamps the merge-base when the span holds there", async () => { + const repo = await makeRepo("why-repair-base-"); + git(repo, "checkout", "-q", "-b", "main"); + await write(repo, "config/defaults.toml", DEFAULTS_TOML); + await write(repo, "docs/notes.md", "# on main since the start\n"); + git(repo, "add", "-A"); + git(repo, "commit", "-qm", "seed"); + const mainTip = git(repo, "rev-parse", "--short", "HEAD"); + withIntegrationBranch(repo); + + git(repo, "checkout", "-q", "-b", "elsewhere"); + await write(repo, "scratch.txt", "gone tomorrow\n"); + git(repo, "add", "-A"); + git(repo, "commit", "-qm", "never merged"); + const orphan = git(repo, "rev-parse", "--short", "HEAD"); + + git(repo, "checkout", "-q", "main"); + git(repo, "checkout", "-q", "-b", "feature"); + await write(repo, "README.md", "# unrelated\n"); + git(repo, "add", "-A"); + git(repo, "commit", "-qm", "unrelated work"); + const featureTip = git(repo, "rev-parse", "--short", "HEAD"); + + const whyRoot = await scaffoldBundle(repo); + await write(repo, ".why/decisions/born-on-main.md", wholeFileConcept("docs/notes.md", orphan)); + + const { io, out } = capture(); + assert.equal(await main(["anchor", "--bundle", whyRoot], repo, io), 0, out.join("\n")); + + const anchor = await anchorOf(whyRoot, "decisions/born-on-main"); + assert.equal(anchor.as_of, mainTip, "the merge-base survives the squash and verifiably has the file"); + assert.notEqual(anchor.as_of, featureTip, "a branch HEAD stamp would re-orphan at the squash"); +}); + +test("on the integration branch itself, as_of is stamped at HEAD", async () => { + const repo = await makeRepo("why-stamp-main-"); + git(repo, "checkout", "-q", "-b", "main"); + await write(repo, "config/defaults.toml", DEFAULTS_TOML); + git(repo, "add", "-A"); + git(repo, "commit", "-qm", "seed"); + const seed = git(repo, "rev-parse", "--short", "HEAD"); + withIntegrationBranch(repo); + + await write(repo, "config/defaults.toml", DEFAULTS_TOML_SHIFTED); + git(repo, "add", "-A"); + git(repo, "commit", "-qm", "shift"); + git(repo, "update-ref", "refs/remotes/origin/main", git(repo, "rev-parse", "main")); + const mainTip = git(repo, "rev-parse", "--short", "HEAD"); + + const whyRoot = await scaffoldBundle(repo); + await write(repo, ".why/decisions/47s-request-deadline.md", deadlineDecision(seed)); + + const { io, out } = capture(); + assert.equal(await main(["anchor", "--bundle", whyRoot], repo, io), 0, out.join("\n")); + + // HEAD is contained in the integration branch, so HEAD survives and is both + // the truthful and the durable stamp — no merge-base indirection. + assert.equal((await anchorOf(whyRoot, "decisions/47s-request-deadline")).as_of, mainTip); +}); + // The grep-heuristic symbol finder never guesses: ambiguity and absence both // fail the symbol step rather than emit a plausible-but-wrong span. test("findSymbolSpan: brace blocks, indent blocks, single lines — and no guessing", () => { diff --git a/test/ci.test.ts b/test/ci.test.ts index a196e32..c7ee946 100644 --- a/test/ci.test.ts +++ b/test/ci.test.ts @@ -19,13 +19,13 @@ const root = fileURLToPath(new URL("..", import.meta.url)); const read = (path: string) => readFileSync(join(root, path), "utf8"); const WORKFLOWS_DIR = ".github/workflows"; -const EXPECTED_WORKFLOWS = ["why-pr-gate.yml", "why-audit.yml", "why-capture.yml"]; +const EXPECTED_WORKFLOWS = ["why-pr-gate.yml", "why-anchor.yml", "why-audit.yml", "why-capture.yml"]; function workflowFiles(): string[] { return readdirSync(join(root, WORKFLOWS_DIR)).filter((f) => /\.ya?ml$/.test(f)).sort(); } -test("the three issue-403 workflows exist under .github/workflows/", () => { +test("the why workflows exist under .github/workflows/", () => { const files = workflowFiles(); for (const expected of EXPECTED_WORKFLOWS) { assert.ok(files.includes(expected), `${WORKFLOWS_DIR}/${expected} is missing (have: ${files.join(", ")})`); diff --git a/test/dig-state.test.ts b/test/dig-state.test.ts index dbd7414..83c21cb 100644 --- a/test/dig-state.test.ts +++ b/test/dig-state.test.ts @@ -1,4 +1,4 @@ -// Incremental dig state (DESIGN.md §6, issues/304): the high-water mark under +// Incremental dig state (DESIGN.md §6): the high-water mark under // .why/.dig-state.json. The contract under test: the mark advances only after // a successful emission (atomically), an empty range emits nothing and touches // nothing, deleting the file just means "re-dig everything", and a state file