From ef9aeffe02bcfdce23a3e712d671a379a6698cd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 01:21:24 +0000 Subject: [PATCH 01/24] Add local-first harness evolution blueprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design documentation only — no CLI, store, or primitive behavior changes. Maps nine externally supplied proposals (two-layer golden/branch-local knowledge, deterministic retrieval, structural indexing, layer-aware compounding, plan/verify enrichment, knowledge lifecycle commands, worktree hardening, token budgeting, provenance governance) onto the existing harness architecture, marking already-satisfied proposals as verify-and-document and phasing the rest. Two owner-fixed decisions are normative in the blueprint: branch-local knowledge layers extend the existing external store at ~/.harness/knowledge// (never an in-repo .harness/knowledge/), and structural parsing adopts web-tree-sitter as an optional WASM tier behind the existing extract seam with the lexical extractor as the permanent fallback. The capability registry is deliberately untouched: the blueprint ships with its Human Decision pending, and registry candidate entries are the first post-approval step. Current-state docs gain only clearly labeled planned-evolution pointers. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0169cWqYhLedkYhL8hEyudSq --- docs/MEMORY-MODEL.md | 9 + docs/architecture/engineer-harness.md | 1 + .../proposals/harness-evolution-blueprint.md | 470 ++++++++++++++++++ 3 files changed, 480 insertions(+) create mode 100644 knowledge/proposals/harness-evolution-blueprint.md diff --git a/docs/MEMORY-MODEL.md b/docs/MEMORY-MODEL.md index 9d892dab..3c1b1ea0 100644 --- a/docs/MEMORY-MODEL.md +++ b/docs/MEMORY-MODEL.md @@ -634,6 +634,15 @@ change a learning's status when a CLI command is more convenient than a direct e remain first-class paths; hand-editing is no longer a discouraged shortcut, it is absorbed with full provenance either way. +## Planned evolution (proposal, not current behavior) + +A pending design proposal — the +[Harness Evolution Blueprint](../knowledge/proposals/harness-evolution-blueprint.md) — +maps a branch-local knowledge overlay inside the existing `~/.harness/knowledge//` +store (golden `learnings/` plus per-branch buckets) and commit-SHA provenance on episodes +and learnings. Nothing on this page changes until that proposal's Human Decision records +approval and the work ships; this page continues to describe current behavior only. + ## Related - [`.github/skills/references/harness-tool-contract.md`](../.github/skills/references/harness-tool-contract.md) diff --git a/docs/architecture/engineer-harness.md b/docs/architecture/engineer-harness.md index c801852d..ca9099a6 100644 --- a/docs/architecture/engineer-harness.md +++ b/docs/architecture/engineer-harness.md @@ -224,5 +224,6 @@ The verification suite checks the thin Engineer contract, plan and policy schema - [Host Compatibility Matrix](../../evals/host-compatibility.yaml) - [Install Guide](../install.md) - [Harness Quickstart](../onboarding/harness-quickstart.md) +- [Harness Evolution Blueprint](../../knowledge/proposals/harness-evolution-blueprint.md) (proposal — planned evolution, not current behavior) Historical proposals, comparative reviews, and implementation roadmaps are removed from active documentation after implementation. Their audit remains in Git and pull-request history; durable decisions are promoted to this architecture or team knowledge before completed plans are deleted. diff --git a/knowledge/proposals/harness-evolution-blueprint.md b/knowledge/proposals/harness-evolution-blueprint.md new file mode 100644 index 00000000..21a56e96 --- /dev/null +++ b/knowledge/proposals/harness-evolution-blueprint.md @@ -0,0 +1,470 @@ +# Harness Evolution Blueprint: Local-First Adaptive Engineering System + +Status: **proposal — pending Human Decision. Design documentation only; nothing in this +document describes current behavior, and no CLI, store, or primitive change may be built +from it until the `## Human Decision` section records an approval** (per the Decision +Handling semantics in +[`capability-gap-proposal.md`](../../.github/skills/references/capability-gap-proposal.md): +blank or incomplete = pending, do not create or modify primitives). + +This blueprint adapts nine externally supplied proposals — a two-layer golden/branch-local +knowledge model, deterministic retrieval, structural codebase indexing, layer-aware +compounding, structurally enriched plans and verification, knowledge lifecycle commands, +multi-project/worktree hardening, local token budgeting, and provenance governance — onto +the existing Adaptive Engineer Harness architecture. It maps each proposal to what already +exists, what is genuinely new, and how the new parts compose with the contracts pinned by +`docs/MEMORY-MODEL.md`, `docs/architecture/engineer-harness.md`, and the harness test +suite. Two proposals arrive largely satisfied; the blueprint marks them as +verify-and-document rather than re-proposing built behavior. + +## 1. Fixed decisions + +Two design decisions were made by the repository owner during intake and are normative for +every section below. Alternatives are not reconsidered here. + +- **D1 — Branch-local knowledge extends the existing external store.** The two-layer model + is implemented *inside* `~/.harness/knowledge//`: golden is the existing + `learnings/` tree unchanged; branch-local is a new `branches//` sibling. + The raw proposal's in-repo `.harness/knowledge/golden|branches/` layout is rejected: it + would not survive `git clean`/re-clone, would duplicate state per worktree, and would + abandon the store's single-writer lock, transaction/rollback, commit-per-change history, + and governance ledger — all of which the external store provides for free. +- **D2 — Structural parsing adopts web-tree-sitter as an optional tier.** Lazy-loaded WASM + grammars sit behind the existing injectable `extract` parameter of `buildRepoMap` + (`packages/harness/lib/repo-map/index.mjs`). The lexical extractor + (`lexical-extractor.mjs`) remains the zero-install default and the permanent fallback; + grammar absence is silent, never an error. This honors the seam already documented in + [`harness-tool-contract.md`](../../.github/skills/references/harness-tool-contract.md) + while accepting the proposals' judgment that declaration-level structure is worth + building ahead of the original telemetry trigger. + +## 2. Current-architecture anchors + +Every subsystem this blueprint touches, with its single source of truth. Designs below +reference these files; none of them change in this proposal-only delivery. + +| Subsystem | SSOT | Contract relevant here | +|---|---|---| +| T2 store identity + transactions | `packages/harness/lib/knowledge/store.mjs` | `repoId` = normalized origin remote + 8-hex hash (fallback `local-`); store is a CLI-managed local git repo, shared by every worktree/clone of the same remote; `.lock` single-writer; commit per mutation | +| Sole learning writer | `packages/harness/lib/knowledge/apply.mjs` | Ops `ADD/STRENGTHEN/SUPERSEDE/MERGE/NOOP`; `LEARNING_BYTE_CAP` 1200; `MAX_OPS_PER_RUN` 5; `DOMAIN_ACTIVE_CAP` 25; secret scan; imperative lint; quarantine strikes | +| Governance ledger | `store.mjs` `readGovernance` | Append-only `governance.jsonl`, latest-per-id replay, **`action: promote` is sticky/terminal** — later non-promote entries never override it | +| Learning retrieval | `packages/harness/lib/knowledge/retrieve.mjs` | Top-3 injection, provisional 0.5 damp, status exclusions, no recency term | +| Orientation + context pack | `packages/harness/lib/orient.mjs`, `context-pack.mjs` | Fully deterministic, no model calls; `MAX_BYTES` 2048; priority-ordered sections; untrusted-memory preambles; `inertLine` + secret redaction at the data boundary | +| Document recall | `bm25.mjs`, `postings-index.mjs`, `recall-rank.mjs` | BM25 with weighted-overlap fallback; recency blend; insight damp | +| Repo map (structural, lexical tier) | `packages/harness/lib/repo-map/index.mjs`, `lexical-extractor.mjs` | `extract(rel, content)` injectable seam; 1000-token map budget; `docs/codebase-map.md` at 2500 tokens | +| Index staleness | `packages/harness/lib/index-status.mjs` | `meta.headSha` stamp; `commitsSince`/`filesChanged` drift signal | +| Verification evidence | `packages/harness/lib/verify.mjs`, `evidence.mjs` | Binding `{base, planDigest, changedFiles, workspaceDigest}` — the strongest provenance in the system today | +| Events | `packages/harness/lib/events.mjs` | `EVENT_TYPES` allow-list; known latent gap: `init_repo`/`recall`/`validate_plan`/`index` writes are silently dropped | +| Doctor | `packages/harness/lib/doctor.mjs` | H1–H17, K1–K4 (K4 = stranded store identity), V1–V9 | +| Capability governance | `knowledge/capability-registry.yaml`, `capability-gap-proposal.md` | Lifecycle `candidate → experimental → active → deprecated → retired`; registry mutation requires an approved proposal via `/create-primitive` | +| Memory model + threat model | `docs/MEMORY-MODEL.md` | Three tiers, one writer each; T2 = `f(T1, model, governance ledger)`; day-granular episode dates are load-bearing in the model-lane recency rule | + +## 3. Per-proposal mapping + +### P1 — Two-layer knowledge model (golden + branch-local) + +**Exists today.** One layer. `learnings//.md` under the store root is the +entire T2 corpus; it is shared across every branch and worktree of the repo, and +`docs/MEMORY-MODEL.md` documents that sharing as intended Phase-1 behavior. + +**Gap.** Feature-branch work writes into the same corpus the default branch reads. +A learning derived from an experiment that is later abandoned pollutes golden knowledge; +nothing records which branch or commit produced a claim. + +**Adapted design (per D1).** Extend the store layout, inside the existing single git repo: + +``` +~/.harness/knowledge// + learnings//.md # golden — unchanged, sole writer consolidate --apply + governance.jsonl # unchanged — golden-scoped human authority + consolidated.jsonl # unchanged + INDEX.md # unchanged (golden index) + branches// + meta.json # { branch, branchKey, baseSha, createdAt, promotable } + learnings//.md # same schema as golden + provenance fields + INDEX.md +``` + +- Because branch buckets live inside the same store repo, they inherit the `.lock` + single-writer discipline, `withStoreTransaction` rollback, commit-per-mutation history, + secret scanning, and byte caps with **zero new machinery**. +- Every episode and learning gains optional, reader-tolerant provenance frontmatter: + `commit:` (full SHA at capture time), `branch:` (raw branch name), `base:` (merge-base + with the configured default branch). Absent fields mean "pre-provenance artifact"; + no reader may hard-require them (forward-compatible with the existing corpus). +- Writes during feature-branch work default to the branch-local layer. Golden writes + require either being on the default branch or an explicit `--layer golden` override + (see P4 for routing details). +- Promotion branch-local → golden is an explicit, reviewable, single-writer operation + (see §5) — never implicit on merge. + +**Phase:** 1 (layout + read path), 2 (write routing + promotion). **Risks:** store growth +(bounded by prune, §P6); two same-id claims diverging between layers (resolved by +shadowing semantics, §4). + +### P2 — Deterministic `orient`/`recall` — already satisfied; verify + document + +**Exists today.** `harness orient` and `harness recall` never invoke a model. The read +path is BM25/overlap ranking, plan matching, learning ranking, repo-map generation, and +budgeted pack assembly — all local, all deterministic (locale-independent tie-breaks are +tested). Hard budgets: 2048-byte pack, top-3 recall, top-3 learnings, 1000-token repo map. +Evals (`evals/tasks/orient-context-pack-integration/`, +`retrieval-phrasing-stability/`) pin the behavior. + +**Genuinely new.** +1. **Branch detection** — orient records the current branch/worktree (one + `git rev-parse --abbrev-ref HEAD` + worktree detection) into `.harness/session.json` + and the pack header. +2. **Layer overlay** — golden actives merged with branch-local actives for the current + branch-key; branch-local shadows a same-id golden claim (§4). +3. **Golden-plus-delta fallback** — when no branch bucket exists, orient appends one + advisory line built from the *already computed* `index-status.mjs` signals + (`commitsSince`, `filesChanged` vs the indexed head) so the agent knows how far the + checkout has drifted from what golden knowledge and the structural index describe. No + new computation; a presentation change within the existing pack budget. + +**Phase:** 1. **Risks:** none material — additive lines inside an unchanged cap. + +### P3 — Structural codebase index + +**Exists today.** The lexical tier: `buildRepoMap` ranks `git ls-files` sources by +import-degree (basename-stem approximation) + symbol density + query overlap, regenerated +every orient into `.harness/repo-map.md`, plus the committed query-less +`docs/codebase-map.md`. `extract` is already an injectable parameter; the tree-sitter tier +is a documented seam. `meta.json` for the BM25 index already stamps `headSha`. + +**Gap.** No persistent symbol/declaration tables, no caller/callee or dependency +relationships beyond basename matching, no complexity signals, no incremental reindex, no +structural diff. + +**Adapted design (per D2).** + +- **Extractor:** new `packages/harness/lib/repo-map/treesitter-extractor.mjs` + implementing the same `extract(rel, content)` shape with an extended v2 result: + `{ symbols, imports, defs, refs, complexity }` (v1 fields unchanged so the lexical tier + and existing consumers keep working). Grammars are lazy-loaded WASM via web-tree-sitter; + languages without a grammar (SQL, HCL, config) fall back to the lexical extractor + per-file. +- **Distribution:** WASM grammars ship as an optional install extra (hydrated under + `~/.copilot/.harness-bin/` alongside the runtime). **No runtime network, ever** — a + missing grammar is a silent lexical fallback, preserving local-first and offline + operation. This is the one place D2 costs something: grammar bytes ride the package. +- **Storage:** `knowledge/.harness-index/structural/` — a sibling of the BM25 postings + index: derived, rebuildable, gitignored, and **never inside the knowledge store**. The + structural index and the knowledge corpus stay independently cacheable and evolvable, + as the proposal requires. + - `files.json` — per-file `{ hash, mtime, size, symbols, imports, complexity }` + - `symbols.json` — declaration table with def/ref locations + - `graph.json` — caller/callee + module dependency edges + - `meta.json` — `{ sha, branch, generatedAt, extractorTier, grammarVersions }` +- **Incremental:** mtime+size fast path, sha256 content-hash confirm — only changed files + re-parse. Branch switches and small edits stay cheap; a full rebuild is always safe. +- **Structural diff:** `harness index --structural --since ` reindexes only + `git diff --name-only ` files and reports symbols added/removed/changed — the + "what structurally changed since main?" answer without a rebuild. +- **Watch-safety:** all writes atomic temp+rename through the existing `fs-safe.mjs` + containment (`writeFileContained`, symlink-ancestor checks). +- **Adoption gate:** opt-in `harness index --structural` first; consumers (orient repo + map, P5 plan enrichment) prefer the structural tables when present and current, else + lexical. Default-on only after `harness report` telemetry shows the structural tier + earns its parse cost (this keeps the spirit of the original "built only when telemetry + shows the lexical map misleads" clause while building the capability now). + +**Phase:** 3. **Risks:** WASM payload size; grammar/version skew across hosts (recorded in +`meta.json`, surfaced by doctor S1); parse-cost regressions on huge repos (bounded by the +existing `MAX_FILES_SCANNED`/`MAX_FILE_BYTES` caps, which the structural tier inherits). + +### P4 — Layer-aware compounding and knowledge writes + +**Exists today.** Three capture lanes with one writer each (`/auto-compound` verified +fixes, `compound --insight` evidence-free insights, `harness remember` human teachings); +`consolidate --apply` as sole T2 writer; compounding gated on passed verification +evidence. The proposal's contribute/compound/record-failure primitives map 1:1 onto these +lanes — no new primitive is needed. + +**Gap.** No lane is layer-aware; everything lands in the single shared corpus. + +**Adapted design.** Routing is derived from git context at write time, never from a +sticky mode: + +| Git context at write | Default destination | Override | +|---|---|---| +| Feature branch | branch-local bucket for that branch-key | `--layer golden` (logged) | +| Default branch | golden | — (already golden) | +| Detached HEAD / temporary experiment | ephemeral bucket `branches/detached-/`, `promotable: false` | none — never promotable, only prunable | + +- `consolidate --candidates` and `--apply` operate per-layer: on a feature branch the + packet clusters that branch's episodes into its bucket; golden consolidation runs on the + default branch. `consolidate --apply` remains the **sole writer of learning content for + both layers** — the delta contract, byte caps, secret scan, lint, and quarantine apply + identically. +- Only verified outcomes are eligible for compounding — unchanged, enforced by the + existing evidence-freshness gate in `harness compound`. +- Episode capture (T1, in the working tree) is inherently branch-scoped already — episode + files ride the feature branch and merge with it. The new provenance fields (P1) make + that scoping explicit and machine-readable rather than changing where episodes live. + +**Phase:** 2. **Risks:** misrouting (doctor K6 detects a branch bucket receiving writes +whose `branch:` provenance disagrees with its `meta.json`); user surprise at layer +defaults (mitigated: every write's layer is printed in the command output and recorded in +the store commit message). + +### P5 — Plans and verification enriched with structural data + +**Exists today.** `## Impacted Files` is a scope allowlist enforced by `plan-scope.mjs` +at verify time; `plan-new` scaffolds it from `--impacted`; verify binds evidence to +`{base, planDigest, changedFiles, workspaceDigest}`. Nothing surfaces callers, dependents, +or complexity when a plan is drafted. + +**Adapted design.** + +- **Plan enrichment (advisory):** when the structural index is present and current, + `plan-new` (and `/deepen-plan`) append a generated `Structural context` note under + `## Research Notes` for each impacted file: direct callers/dependents, exported symbols, + and hotspot flags (top-decile complexity or import-degree). Budgeted like every other + surface (≤ ~200 tokens), clearly marked as generated, never a gate input. +- **Verification enrichment (advisory first):** `harness verify` gains a + `structural-expectations` check that compares the structural diff of the change against + the plan: changed exported symbols should belong to impacted files; removed public + symbols with surviving callers are flagged. **Advisory (exit-code-neutral) until a + policy knob in `.github/harness/policy.yaml` opts it into warn/enforce** — consistent + with the existing enforcement ladder, and no new hard gate ships silently. +- Plans remain the primary durable intent/activity artifacts — this proposal adds inputs + to them, not a parallel record. + +**Phase:** 3 (enrichment), 4 (verify expectations). **Risks:** stale structural data +misleading a plan (mitigated: enrichment refuses to run when `meta.sha` ≠ current HEAD and +says so); advisory noise (mitigated: hotspot thresholds tuned via `harness report` before +any enforcement). + +### P6 — Knowledge lifecycle commands + +**Exists today.** `harness knowledge` (mode, commit-mode, purge, migrate-store), +`harness learnings`/`learning` (listing, why-chains, retire/dispute/confirm/promote), +`harness consolidate` (status/candidates/apply/rebuild), doctor K1–K4. + +**Adapted design.** Three subcommands extending the existing `knowledge` command group: + +- **`harness knowledge status`** — layer-aware store report: golden count per domain, + branch buckets with age/`baseSha` drift/promotability, current-branch delta vs golden + (ids only in branch-local, ids shadowing golden), stale buckets (branch deleted or + merged), and the structural-index freshness line. Read-only; becomes the bare + `harness knowledge` output's richer sibling. +- **`harness knowledge promote [--branch ] [--ids a,b] [--all]`** — moves selected + branch-local learnings into golden through the §5 contract. Never bypasses + `consolidate --apply` semantics; mode-gated exactly like consolidation (`on` applies, + `suggest` requires `--yes`, `off`/`freeze`/`capture-only` reject with `E_MODE`). +- **`harness knowledge prune [--branch ] [--merged] [--stale ]`** — removes + branch buckets after merge or abandonment. Like `purge`, **never mode-gated** — human + deletion always wins. Each removal is a store commit; `--merged` resolves via + `git branch --merged` / remote-tracking state in the workspace. +- **Doctor extensions:** K5 — branch bucket whose branch no longer exists locally or on + the remote (hint: `knowledge prune`); K6 — layer misroute (bucket contents whose + provenance disagrees with bucket meta); S1 — structural index health (parse-failure + rate, grammar availability, `meta.sha` drift, orphaned cache entries). +- **Required pre-work (latent bug):** `events.mjs` `EVENT_TYPES` silently drops + `init_repo`/`recall`/`validate_plan`/`index` events today (footnoted in + `harness-tool-contract.md`). The new lifecycle commands need telemetry, so Phase 1 + fixes the allow-list first — otherwise promote/prune/status usage would be invisible to + `harness report` the same way. + +**Phase:** 1 (`status`, K5, events fix), 2 (`promote`, `prune`, K6), 3 (S1). +**Risks:** `promote` name collision — see §5's ledger-action hazard. + +### P7 — Multi-project and worktree hardening + +**Exists today.** Per-repo isolation is done: store identity is per-remote (`repoId`), +each workspace owns its `.harness/` tree, telemetry is per-project-slug, and K4 + +`knowledge migrate-store` handle identity migration. Worktrees share the store by design; +`.gitignore` anticipates `.worktrees`. Global/user knowledge (`~/.copilot/knowledge/`, +`profile.md`) is already secondary to project-scoped recall in the documented lookup +order (`knowledge-locations.md`). + +**Gap.** No branch identity inside the shared store; detached HEAD is +indistinguishable from branch work. + +**Adapted design.** + +- **Branch-key normalization**, mirroring the proven `repoId` pattern: + `-<8hex>` where slug = branch name lowercased, `/` and every character outside + `[a-z0-9._-]` mapped to `-`, runs collapsed, truncated to 64 chars; 8hex = first 8 hex + of sha256 of the raw branch name. Collision-proof (hash disambiguates truncations and + case-folds), Windows-case-insensitivity-safe, and path-length-safe. +- **Worktree identity:** two worktrees on the *same* branch share one bucket — same + knowledge, same claims; this is the correct default and is documented rather than + fought. Worktree path is recorded in write provenance for audit, not identity. +- **Detached HEAD / experiments:** `branches/detached-/` with + `promotable: false` in `meta.json` — a non-promotable ephemeral bucket, prunable at any + time, exactly as the proposal requires. +- **Store-identity interaction:** branch buckets live inside the store directory, so K4's + stranded-store detection and `knowledge migrate-store` carry them automatically — + no separate migration path. + +**Phase:** 1 (key + detached bucket), 2 (prune integration). **Risks:** long branch names +on Windows deep paths (mitigated by the 64-char truncation + hash). + +### P8 — Local token budgeting and progressive disclosure — mostly satisfied + +**Exists today.** Hard, locally computed budgets at every surface, pinned by tests: +pack 2048 bytes with priority-ordered truncation, repo map 1000 tokens, codebase map 2500, +learning 1200 bytes, top-3 injections, F0–F3 disclosure tiers in `context-budget.md`, +`token-meter.mjs` estimation, `harness report` budget-breach detection. No model is +involved in budgeting or prioritization anywhere. + +**Genuinely new.** Only the layer ordering inside the *unchanged* caps: branch-local → +golden → structural delta → broader team knowledge (global solutions), which maps onto the +existing pack section priority list as a refinement of the `## Learnings (memory)` and +`## Recall (top matches)` section internals. No cap changes, no new tiers. + +**Phase:** 1 (rides the P2 overlay). **Risks:** none beyond P2's. + +### P9 — Governance and provenance + +**Exists today.** Capability registry v2 with lifecycle + tombstones + owners; governance +ledger with sticky promote; verify evidence binding; index `headSha`; episode `sha256` +verification at apply time. Process knowledge (plans, solutions, learnings) vs structural +knowledge already have disjoint writers and stores. + +**Adapted design.** + +- **Uniform generation-context stamp** `{ sha, branch, baseSha, generatedAt }` applied + consistently: structural index `meta.json` (new), knowledge manifest `meta.json` + (extends existing `headSha`), episodes/learnings (the P1 provenance fields), branch + bucket `meta.json` (new). Verify evidence already carries the equivalent binding and is + unchanged. +- **Process vs structural distinction:** made explicit as a documentation rule — episodes + and learnings (process/experience knowledge) live in T1/T2 with human governance; + structural facts are always **derived, never stored as knowledge** (consistent with + MEMORY-MODEL's "Derived, never stored" principle), rebuildable from source at any + commit. +- **Security interaction (must-read for reviewers):** `docs/MEMORY-MODEL.md`'s + model-lane recency rule deliberately relies on day-granular episode dates + ("episode day > record day" strictly). SHA provenance *strengthens* this — commit + ancestry is a verifiable happened-after proof that day granularity cannot fake — but + swapping the recency rule onto commit ancestry changes the threat model (a same-day + replay must still never overturn a same-day human veto). Any such change is Phase 3+ + and requires its own threat-model review before design; until then SHA provenance is + recorded but the recency rule keeps its current day-granular semantics. + +**Phase:** 1 (stamps), 3+ (any recency-rule evolution, separately reviewed). + +## 4. Overlay and ranking semantics + +Concrete rules for the layered read path (P1/P2/P8), chosen for determinism and zero new +trust classes: + +1. Candidate set = golden actives ∪ branch-local actives for the current branch-key. + Detached-HEAD buckets are read only when HEAD is detached at a matching context. +2. **Shadowing:** a branch-local learning with the same id as a golden learning replaces + it in the candidate set (the branch's re-teach wins locally; golden is untouched on + disk). The pack renders a shadow marker so the agent knows a golden claim was + overridden. +3. **Ranking:** the existing `scoreLearning` runs unchanged over the merged set; + branch-local wins ties at equal score. Top-3 injection and the 2048-byte pack cap are + unchanged. +4. **Trust framing:** branch-local claims render inside the existing untrusted-memory + advisory framing (`LEARNINGS_DATA_PREAMBLE`, `inertLine`, secret redaction, + `[unverified memory — advisory]` for insight-derived claims). Branch-local adds a + `[branch-local]` marker, not a new trust class — the injection-defense analysis in + MEMORY-MODEL applies verbatim. +5. When no branch bucket exists: golden only, plus the one-line structural/knowledge + delta from `index-status.mjs` (P2.3). + +## 5. Promotion and pruning contract + +Promotion (branch-local → golden) is the one new mutation class, and it reuses the +consolidation machinery wholesale: + +- `knowledge promote` **emits a reviewable op-set** (same shape and location discipline as + `.harness/consolidate-ops.json`) mapping each selected branch-local learning to an + `ADD`, `STRENGTHEN`, or `SUPERSEDE` against golden — chosen mechanically (no golden id → + ADD; same id shadowing → SUPERSEDE; episodes-only overlap → STRENGTHEN). +- The op-set is applied **only** through the `consolidate --apply` writer: byte cap, + `MAX_OPS_PER_RUN` delta contract, secret scan, imperative lint, protected-target dispute + rules, and quarantine strikes all bind identically. A promotion that would supersede a + protected golden learning (≥3 verified fixes or `source: human`) is rejected and marks + it disputed for human review, exactly like any other SUPERSEDE. +- **Ledger action name — hazard:** the governance ledger's `action: promote` is + sticky/terminal in `readGovernance` replay (it records promotion *to a T3 primitive* + and can never be overridden by later non-promote entries). Branch→golden promotion + therefore records `action: absorb-branch` entries — reusing `promote` would make every + branch-promoted learning permanently ungovernable. This constraint is normative for the + implementation phase. +- Promotion succeeds → the source branch-local entries are tombstoned in the bucket + (`promoted_to_golden: `), and the bucket becomes prunable. +- **Pruning** removes a bucket in one store commit. It is a human-authority path like + `purge`: never mode-gated, always available, never blocked by pending promotion state + (abandoning a branch abandons its knowledge — that is the feature). +- Branch buckets carry **no** `governance.jsonl` of their own in Phase 1–2: the golden + ledger remains the only human-authority record; branch buckets are ephemeral by + definition. Revisit only if branch-local disputes emerge in practice (§8). + +## 6. Capability-gap summary + +Condensed per `capability-gap-proposal.md`; each row becomes a full proposal + registry +`candidate` entry as the **first post-approval step** (registry is a governed primitive +path — deliberately untouched while the Human Decision below is pending). + +| Future primitive | Type | Boundary reason | Phase | +|---|---|---|---| +| `knowledge status`/`promote`/`prune` surfaces | CLI subcommands + `harness-tool-contract.md` rows | Deterministic store lifecycle — CLI-first, no model judgment | 1–2 | +| Layered read path in orient | CLI behavior (no new primitive) | Extension of existing orient contract | 1 | +| Structural index tier | CLI (`index --structural`) + optional extractor module | Derived artifact, engine-level; no skill/agent judgment involved | 3 | +| Structural plan/verify enrichment | CLI behavior + one advisory check | Rides existing plan-new/verify contracts; policy-gated before enforcement | 3–4 | + +No new skills or agents are proposed: every workflow lands in existing lanes +(`/recall`, `/auto-compound`, `/consolidate`, `/harness-doctor`) whose SKILL.md files gain +short layer-awareness notes in the phase that ships the behavior. + +## 7. Phasing + +Each phase after 0 is delivered through the repo's own pipeline — a dated plan in +`docs/plans/` (created when the one-live-plan slot is free), gated edits, and the trusted +checks in `.github/harness/checks.yaml` (`harness-tests`, `prompt-contracts`, +`host-contracts`, `build-assets`). + +- **Phase 0 — this PR.** Blueprint + two current-doc pointers. Request the Human + Decision. +- **Phase 1 — provenance + layered reads.** Generation-context stamps; events allow-list + fix; branch-key normalization + `branches/` layout + detached bucket; layered read path + in orient/retrieve (§4); `knowledge status`; doctor K5. No write-routing changes yet — + golden remains the only write destination, so Phase 1 is fully backward-compatible. +- **Phase 2 — layered writes + lifecycle.** Layer routing in + compound/remember/consolidate (P4 table); `knowledge promote` (§5) and `prune`; + doctor K6; registry `candidate → experimental` entries for the shipped surfaces. +- **Phase 3 — structural index.** Tree-sitter WASM tier behind the extract seam; + persistent structural tables + incremental hashing + `--since`; orient/plan enrichment; + doctor S1. +- **Phase 4 — structural verification + tuning.** Advisory `structural-expectations` + verify check with policy-ladder opt-in; delta-fallback polish; telemetry-gated decision + on structural-tier default-on. + +## 8. Risks and open questions + +- **Store growth.** Branch buckets accumulate in long-lived repos. Default prune policy + (e.g. auto-hint at `knowledge status` when a bucket's branch is merged/deleted; no + auto-delete) needs a decision in Phase 2 — proposed default: hint only, never silent + deletion, consistent with human-authority deletion semantics. +- **Windows path lengths.** `/branches//learnings//.md` + under `%USERPROFILE%` approaches MAX_PATH on deep profiles; the 64-char branch-key + truncation plus existing slug caps keep worst cases bounded, but Phase 1 tests must + include a long-branch-name Windows-shaped fixture. +- **WASM grammar distribution.** Package size vs language coverage: proposed initial set + is the repo's active domains (TS/JS, Python, Java, SQL-fallback-lexical) with the rest + lexical. Needs a size budget decision in Phase 3. +- **Recency-rule evolution.** SHA provenance invites replacing the day-granular + "episode day > record day" rule with commit-ancestry proofs; deliberately deferred to a + dedicated threat-model review (P9). +- **Branch-local governance.** No per-bucket ledger in Phase 1–2 (buckets are ephemeral); + if humans start disputing branch-local claims before promotion, revisit. +- **Merge-timing semantics.** After a feature branch merges, its bucket's claims are + candidates for promotion — but nothing forces promotion before prune. Accepted: + knowledge loss on prune-without-promote is the operator's explicit choice, mirroring + the proposal's "promotion is explicit and reviewable, only". + +## Human Decision + +- **Decision:** +- **Reviewer:** +- **Date:** +- **Conditions or required edits:** From 514dc6db3263d43340cb35c430c0491938a3404e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 02:46:46 +0000 Subject: [PATCH 02/24] Note CLI workbench compatibility in evolution blueprint Adds a section committing the blueprint's proposed command surfaces (knowledge lifecycle, structural index) to be conforming citizens of the separately planned CLI workbench/TUI track: registry-dispatchable, versioned JSON envelope with JSONL streaming, cooperative cancellation with distinct cancelled/timed-out outcomes, and a stable generation stamp the workbench can render. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0169cWqYhLedkYhL8hEyudSq --- .../proposals/harness-evolution-blueprint.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/knowledge/proposals/harness-evolution-blueprint.md b/knowledge/proposals/harness-evolution-blueprint.md index 21a56e96..f10ee7f1 100644 --- a/knowledge/proposals/harness-evolution-blueprint.md +++ b/knowledge/proposals/harness-evolution-blueprint.md @@ -462,6 +462,34 @@ checks in `.github/harness/checks.yaml` (`harness-tests`, `prompt-contracts`, knowledge loss on prune-without-promote is the operator's explicit choice, mirroring the proposal's "promotion is explicit and reviewable, only". +## 9. Relationship to the CLI workbench (TUI) track + +A parallel evolution track — the harness CLI workbench with a TUI session ledger (command +registry dispatching every command, versioned JSON envelope plus JSONL streaming, +append-only run journal with distinct `cancelled` and `timed-out` outcomes, async runner +with cooperative cancellation, gate-state surfaces) — is planned separately and is not +governed by this blueprint. The two tracks compose, and this blueprint's new surfaces must +not paint the workbench into a corner: + +- Every command proposed here (`knowledge status`/`promote`/`prune`, `index --structural` + and any structural query surface) must be registry-dispatchable and emit the versioned + JSON envelope, with JSONL streaming for long operations (structural indexing, promotion + application), so the TUI renders them without bespoke adapters. +- The generation-context stamp (P9) is the `gen ` identity the TUI shows beside + knowledge and search results — keep it stable, short, and cheap to read. +- The structural index (P3) is the backing store for the workbench's + `search --scope code` symbol hits; expose its query path as a command surface, not only + as orient-internal plumbing, so the TUI can call it directly. +- Long-running operations proposed here must support cooperative cancellation and record + distinct `cancelled` vs `timed-out` outcomes in events, matching the run journal's + status model. +- `knowledge status` output should serve both the workbench's one-line footer summary and + its expanded ledger view: summary scalars first, detail arrays after. + +Where the workbench track defines its own contracts (registry, envelope schema, run +journal), those contracts govern; this blueprint commits its surfaces only to being +conforming citizens of them. + ## Human Decision - **Decision:** From afedfc246955246c5ec1e03ffb48368ccc290629 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 02:58:29 +0000 Subject: [PATCH 03/24] Make the three-audience output contract explicit in the evolution blueprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blueprint section 9 previously required only a human rendering and the workbench's JSON envelope for new command surfaces. This names the third audience — the agent — as a first-class rendering: budgeted, deterministic, injection-framed text following the existing context-pack/plan-slice/get pattern, with the rule that agents consume the budgeted lane and envelope JSON never enters model context. The structural query surface (P3) now points to that contract for its agent rendering. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0169cWqYhLedkYhL8hEyudSq --- .../proposals/harness-evolution-blueprint.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/knowledge/proposals/harness-evolution-blueprint.md b/knowledge/proposals/harness-evolution-blueprint.md index f10ee7f1..c6b65bab 100644 --- a/knowledge/proposals/harness-evolution-blueprint.md +++ b/knowledge/proposals/harness-evolution-blueprint.md @@ -169,6 +169,8 @@ structural diff. lexical. Default-on only after `harness report` telemetry shows the structural tier earns its parse cost (this keeps the spirit of the original "built only when telemetry shows the lexical map misleads" clause while building the capability now). +- **Output lanes:** the structural query surface renders per the three-audience contract + in §9 — its agent rendering is token-capped and framed, never raw index JSON. **Phase:** 3. **Risks:** WASM payload size; grammar/version skew across hosts (recorded in `meta.json`, surfaced by doctor S1); parse-cost regressions on huge repos (bounded by the @@ -485,6 +487,21 @@ not paint the workbench into a corner: status model. - `knowledge status` output should serve both the workbench's one-line footer summary and its expanded ledger view: summary scalars first, detail arrays after. +- **Three-audience output contract.** Every command surface proposed here renders one + canonical result three ways, and all three renderings are deterministic CLI work — + never a model pass: + 1. **Human** — the styled ledger (`lib/style.mjs` conventions) for eyes. + 2. **Program/TUI** — the versioned JSON envelope for the workbench and other tooling. + 3. **Agent** — a budgeted text rendering for LLM consumption, following the existing + pattern (2048-byte context pack, 220-token plan slice, bounded `get` excerpts). + Agents consume the budgeted lane, never the JSON envelope: JSON is token-inefficient + and its arrays are unbounded, so envelope output must never enter model context. Agent + renderings inherit the existing hardening and metering — data-not-instructions framing, + `inertLine`, and secret redaction wherever the content is retrieved text, plus measured + bytes so `harness report`'s utilization SLOs see their cost. The structural query's + agent rendering carries a token cap, with the repo map's 1000-token budget as the + precedent. This is what makes the workbench's dual human/LLM promise real: no tokens + are ever spent translating one audience's output into another's. Where the workbench track defines its own contracts (registry, envelope schema, run journal), those contracts govern; this blueprint commits its surfaces only to being From d11a0a7289db132bfe8499d3a7ed4d9af2435173 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 04:45:09 +0000 Subject: [PATCH 04/24] Tighten evolution blueprint and close review findings Merges the owner's tightened draft with fixes for the findings from the three-lens adversarial review. Keeps the draft's improvements: repo-keyed structural index at ~/.harness/index//structural/, the shadowed-claim promotion rule, branch rename handling, and the pinned TS/JS+Python+Java grammar set. Closes the review's design-soundness findings as normative clauses: protected golden claims can never be shadowed on the read path and the governance ledger binds both layers; promotion runs in an explicit lane (per-bucket ledgers, hash-verified candidacy exemption, never-strike rejections, chunked --all); the absorb-branch ledger action can never become an id's standing decision; hand-edit absorption, purge, rebuild, and mirroring get layer-aware semantics in section 5a; provenance fields must be emitted by both serializers; default-branch determination fails closed to branch-local; WASM grammars gain integrity verification; Phase 1 is reduced to reads-only so it is internally consistent; the events pre-work claim is corrected to hygiene. Restores section 9 (workbench relationship and the three-audience output contract). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0169cWqYhLedkYhL8hEyudSq --- .../proposals/harness-evolution-blueprint.md | 661 ++++++++---------- 1 file changed, 278 insertions(+), 383 deletions(-) diff --git a/knowledge/proposals/harness-evolution-blueprint.md b/knowledge/proposals/harness-evolution-blueprint.md index c6b65bab..a2433072 100644 --- a/knowledge/proposals/harness-evolution-blueprint.md +++ b/knowledge/proposals/harness-evolution-blueprint.md @@ -1,11 +1,11 @@ # Harness Evolution Blueprint: Local-First Adaptive Engineering System -Status: **proposal — pending Human Decision. Design documentation only; nothing in this -document describes current behavior, and no CLI, store, or primitive change may be built -from it until the `## Human Decision` section records an approval** (per the Decision -Handling semantics in +Status: **proposal — pending Human Decision.** +Design documentation only. Nothing in this document describes current behavior, and no +CLI, store, or primitive change may be built from it until the `## Human Decision` +section records an approval (per Decision Handling semantics in [`capability-gap-proposal.md`](../../.github/skills/references/capability-gap-proposal.md): -blank or incomplete = pending, do not create or modify primitives). +blank or incomplete = pending; do not create or modify primitives). This blueprint adapts nine externally supplied proposals — a two-layer golden/branch-local knowledge model, deterministic retrieval, structural codebase indexing, layer-aware @@ -14,455 +14,350 @@ multi-project/worktree hardening, local token budgeting, and provenance governan the existing Adaptive Engineer Harness architecture. It maps each proposal to what already exists, what is genuinely new, and how the new parts compose with the contracts pinned by `docs/MEMORY-MODEL.md`, `docs/architecture/engineer-harness.md`, and the harness test -suite. Two proposals arrive largely satisfied; the blueprint marks them as -verify-and-document rather than re-proposing built behavior. +suite. Two proposals arrive largely satisfied and are marked verify-and-document. ## 1. Fixed decisions Two design decisions were made by the repository owner during intake and are normative for -every section below. Alternatives are not reconsidered here. - -- **D1 — Branch-local knowledge extends the existing external store.** The two-layer model - is implemented *inside* `~/.harness/knowledge//`: golden is the existing - `learnings/` tree unchanged; branch-local is a new `branches//` sibling. - The raw proposal's in-repo `.harness/knowledge/golden|branches/` layout is rejected: it - would not survive `git clean`/re-clone, would duplicate state per worktree, and would - abandon the store's single-writer lock, transaction/rollback, commit-per-change history, - and governance ledger — all of which the external store provides for free. -- **D2 — Structural parsing adopts web-tree-sitter as an optional tier.** Lazy-loaded WASM - grammars sit behind the existing injectable `extract` parameter of `buildRepoMap` - (`packages/harness/lib/repo-map/index.mjs`). The lexical extractor - (`lexical-extractor.mjs`) remains the zero-install default and the permanent fallback; - grammar absence is silent, never an error. This honors the seam already documented in - [`harness-tool-contract.md`](../../.github/skills/references/harness-tool-contract.md) - while accepting the proposals' judgment that declaration-level structure is worth - building ahead of the original telemetry trigger. +every section below. Alternatives are not reconsidered. + +- **D1 — Branch-local knowledge extends the existing external store.** + The two-layer model is implemented *inside* `~/.harness/knowledge//`: golden + remains the existing `learnings/` tree; branch-local is a new `branches//` + sibling. The raw proposal's in-repo `.harness/knowledge/golden|branches/` layout is + rejected: it would not survive `git clean`/re-clone, would duplicate state per worktree, + and would abandon the store's single-writer lock, transaction/rollback, + commit-per-change history, and governance ledger. + +- **D2 — Structural parsing adopts web-tree-sitter as an optional tier.** + Lazy-loaded WASM grammars sit behind the existing injectable `extract` parameter of + `buildRepoMap` (`packages/harness/lib/repo-map/index.mjs`). The lexical extractor + remains the zero-install default and permanent fallback; grammar absence is silent, + never an error. ## 2. Current-architecture anchors -Every subsystem this blueprint touches, with its single source of truth. Designs below -reference these files; none of them change in this proposal-only delivery. - | Subsystem | SSOT | Contract relevant here | |---|---|---| -| T2 store identity + transactions | `packages/harness/lib/knowledge/store.mjs` | `repoId` = normalized origin remote + 8-hex hash (fallback `local-`); store is a CLI-managed local git repo, shared by every worktree/clone of the same remote; `.lock` single-writer; commit per mutation | -| Sole learning writer | `packages/harness/lib/knowledge/apply.mjs` | Ops `ADD/STRENGTHEN/SUPERSEDE/MERGE/NOOP`; `LEARNING_BYTE_CAP` 1200; `MAX_OPS_PER_RUN` 5; `DOMAIN_ACTIVE_CAP` 25; secret scan; imperative lint; quarantine strikes | -| Governance ledger | `store.mjs` `readGovernance` | Append-only `governance.jsonl`, latest-per-id replay, **`action: promote` is sticky/terminal** — later non-promote entries never override it | -| Learning retrieval | `packages/harness/lib/knowledge/retrieve.mjs` | Top-3 injection, provisional 0.5 damp, status exclusions, no recency term | -| Orientation + context pack | `packages/harness/lib/orient.mjs`, `context-pack.mjs` | Fully deterministic, no model calls; `MAX_BYTES` 2048; priority-ordered sections; untrusted-memory preambles; `inertLine` + secret redaction at the data boundary | -| Document recall | `bm25.mjs`, `postings-index.mjs`, `recall-rank.mjs` | BM25 with weighted-overlap fallback; recency blend; insight damp | -| Repo map (structural, lexical tier) | `packages/harness/lib/repo-map/index.mjs`, `lexical-extractor.mjs` | `extract(rel, content)` injectable seam; 1000-token map budget; `docs/codebase-map.md` at 2500 tokens | -| Index staleness | `packages/harness/lib/index-status.mjs` | `meta.headSha` stamp; `commitsSince`/`filesChanged` drift signal | -| Verification evidence | `packages/harness/lib/verify.mjs`, `evidence.mjs` | Binding `{base, planDigest, changedFiles, workspaceDigest}` — the strongest provenance in the system today | -| Events | `packages/harness/lib/events.mjs` | `EVENT_TYPES` allow-list; known latent gap: `init_repo`/`recall`/`validate_plan`/`index` writes are silently dropped | -| Doctor | `packages/harness/lib/doctor.mjs` | H1–H17, K1–K4 (K4 = stranded store identity), V1–V9 | -| Capability governance | `knowledge/capability-registry.yaml`, `capability-gap-proposal.md` | Lifecycle `candidate → experimental → active → deprecated → retired`; registry mutation requires an approved proposal via `/create-primitive` | -| Memory model + threat model | `docs/MEMORY-MODEL.md` | Three tiers, one writer each; T2 = `f(T1, model, governance ledger)`; day-granular episode dates are load-bearing in the model-lane recency rule | +| T2 store identity + transactions | `packages/harness/lib/knowledge/store.mjs` | `repoId` = normalized origin remote + 8-hex hash; CLI-managed local git repo shared by every worktree/clone; `.lock` single-writer; commit per mutation | +| Sole learning writer | `packages/harness/lib/knowledge/apply.mjs` | Ops `ADD/STRENGTHEN/SUPERSEDE/MERGE/NOOP`; byte/ops/domain caps; secret scan; imperative lint; quarantine | +| Governance ledger | `store.mjs` `readGovernance` | Append-only `governance.jsonl`; latest-per-id replay; `action: promote` is sticky/terminal | +| Learning retrieval | `packages/harness/lib/knowledge/retrieve.mjs` | Top-3 injection, provisional damp, status exclusions | +| Orientation + context pack | `orient.mjs`, `context-pack.mjs` | Fully deterministic, no model calls; 2048-byte pack; priority-ordered sections; untrusted-memory preambles; `inertLine` + secret redaction at the data boundary | +| Document recall | `bm25.mjs`, `postings-index.mjs`, `recall-rank.mjs` | BM25 + weighted-overlap fallback | +| Repo map (structural, lexical tier) | `repo-map/index.mjs`, `lexical-extractor.mjs` | Injectable `extract(rel, content)` seam | +| Index staleness | `index-status.mjs` | `meta.headSha` stamp; drift signals | +| Verification evidence | `verify.mjs`, `evidence.mjs` | Binding `{base, planDigest, changedFiles, workspaceDigest}` | +| Events | `events.mjs` | `EVENT_TYPES` allow-list (known latent gap: `init_repo`/`recall`/`validate_plan`/`index` writes are silently dropped; `knowledge`-type events already flow) | +| Doctor | `doctor.mjs` | H1–H17, K1–K4, V1–V9 | +| Capability governance | `knowledge/capability-registry.yaml`, `capability-gap-proposal.md` | Lifecycle + registry mutation rules | +| Memory model + threat model | `docs/MEMORY-MODEL.md` | Three tiers, one writer each; day-granular episode dates are load-bearing | ## 3. Per-proposal mapping ### P1 — Two-layer knowledge model (golden + branch-local) -**Exists today.** One layer. `learnings//.md` under the store root is the -entire T2 corpus; it is shared across every branch and worktree of the repo, and -`docs/MEMORY-MODEL.md` documents that sharing as intended Phase-1 behavior. +**Exists today.** Single shared corpus under `learnings/`. -**Gap.** Feature-branch work writes into the same corpus the default branch reads. -A learning derived from an experiment that is later abandoned pollutes golden knowledge; -nothing records which branch or commit produced a claim. +**Gap.** Feature-branch work writes into the same corpus the default branch reads; +abandoned experiments can pollute golden knowledge; no branch/commit provenance. -**Adapted design (per D1).** Extend the store layout, inside the existing single git repo: +**Adapted design (D1).** ``` ~/.harness/knowledge// - learnings//.md # golden — unchanged, sole writer consolidate --apply - governance.jsonl # unchanged — golden-scoped human authority - consolidated.jsonl # unchanged - INDEX.md # unchanged (golden index) + learnings//.md # golden — unchanged + governance.jsonl # single ledger, binding on BOTH layers (see §5) + consolidated.jsonl # golden ledger + INDEX.md branches// - meta.json # { branch, branchKey, baseSha, createdAt, promotable } - learnings//.md # same schema as golden + provenance fields + meta.json # { branch, branchKey, baseSha, createdAt, promotable } + consolidated.jsonl # per-bucket ledger (see §5 — promotion lane) + learnings//.md # same schema + provenance fields INDEX.md ``` -- Because branch buckets live inside the same store repo, they inherit the `.lock` - single-writer discipline, `withStoreTransaction` rollback, commit-per-mutation history, - secret scanning, and byte caps with **zero new machinery**. +- Branch buckets inherit the store's lock, transactions, commit history, secret scanning, + and byte caps. **They do not inherit the store's maintenance paths for free** — every + root-anchored reader/writer (hand-edit absorption's learning-path matcher, the `purge` + cascade, `consolidate --rebuild`, index regeneration, listing, commit-mode mirroring) + must become layer-aware in the phase that ships buckets. The normative semantics are in + §5a; treating these as free inheritance was reviewed and rejected. - Every episode and learning gains optional, reader-tolerant provenance frontmatter: - `commit:` (full SHA at capture time), `branch:` (raw branch name), `base:` (merge-base - with the configured default branch). Absent fields mean "pre-provenance artifact"; - no reader may hard-require them (forward-compatible with the existing corpus). -- Writes during feature-branch work default to the branch-local layer. Golden writes - require either being on the default branch or an explicit `--layer golden` override - (see P4 for routing details). -- Promotion branch-local → golden is an explicit, reviewable, single-writer operation - (see §5) — never implicit on merge. - -**Phase:** 1 (layout + read path), 2 (write routing + promotion). **Risks:** store growth -(bounded by prune, §P6); two same-id claims diverging between layers (resolved by -shadowing semantics, §4). - -### P2 — Deterministic `orient`/`recall` — already satisfied; verify + document - -**Exists today.** `harness orient` and `harness recall` never invoke a model. The read -path is BM25/overlap ranking, plan matching, learning ranking, repo-map generation, and -budgeted pack assembly — all local, all deterministic (locale-independent tie-breaks are -tested). Hard budgets: 2048-byte pack, top-3 recall, top-3 learnings, 1000-token repo map. -Evals (`evals/tasks/orient-context-pack-integration/`, -`retrieval-phrasing-stability/`) pin the behavior. + `commit:`, `branch:`, `base:`. Absent fields mean "pre-provenance artifact". Both + serializers (`store.mjs` and `apply.mjs`) must emit these fields on re-render — + reader tolerance alone is insufficient, since any STRENGTHEN, absorb, or purge delink + re-renders the file and would silently drop them. +- Golden is the authoritative baseline. A new feature branch is assumed to start from the + latest fetched main and then add local work on top. +- **Default-branch determination (normative):** an explicit `defaultBranch` field in the + store `config.json`, seeded from `origin/HEAD` when resolvable. When neither is + available, write routing fails closed **to branch-local** (never to golden), and doctor + surfaces the unresolved default. +- Writes on a feature branch default to the branch-local layer. Golden writes require + either being on the default branch or an explicit `--layer golden` override. Layer + routing is derived from git context **at write time** — the branch recorded at orient + is advisory display only; a write whose current HEAD disagrees with the oriented branch + warns before routing. +- Promotion branch-local → golden is explicit and reviewable (see §5). + +**Phase:** 1 (provenance + read path), 2 (bucket layout, write routing, promotion). + +### P2 — Deterministic `orient` / `recall` — already satisfied; verify + document + +**Exists today.** Fully local, deterministic, budgeted. **Genuinely new.** -1. **Branch detection** — orient records the current branch/worktree (one - `git rev-parse --abbrev-ref HEAD` + worktree detection) into `.harness/session.json` - and the pack header. -2. **Layer overlay** — golden actives merged with branch-local actives for the current - branch-key; branch-local shadows a same-id golden claim (§4). -3. **Golden-plus-delta fallback** — when no branch bucket exists, orient appends one - advisory line built from the *already computed* `index-status.mjs` signals - (`commitsSince`, `filesChanged` vs the indexed head) so the agent knows how far the - checkout has drifted from what golden knowledge and the structural index describe. No - new computation; a presentation change within the existing pack budget. - -**Phase:** 1. **Risks:** none material — additive lines inside an unchanged cap. +1. Branch / worktree detection recorded into session and pack header. +2. Layer overlay: golden actives ∪ branch-local actives; branch-local shadows same-id + golden claims, subject to the protected-claim gate in §4. +3. When no branch bucket exists: golden + one advisory drift line from existing + `index-status.mjs` signals (`commitsSince`, `filesChanged`) — labeled as recall-index + drift, which is what those signals measure. Phase 3+ may upgrade this to a real + structural delta. + +**Phase:** 1. ### P3 — Structural codebase index -**Exists today.** The lexical tier: `buildRepoMap` ranks `git ls-files` sources by -import-degree (basename-stem approximation) + symbol density + query overlap, regenerated -every orient into `.harness/repo-map.md`, plus the committed query-less -`docs/codebase-map.md`. `extract` is already an injectable parameter; the tree-sitter tier -is a documented seam. `meta.json` for the BM25 index already stamps `headSha`. - -**Gap.** No persistent symbol/declaration tables, no caller/callee or dependency -relationships beyond basename matching, no complexity signals, no incremental reindex, no -structural diff. - -**Adapted design (per D2).** - -- **Extractor:** new `packages/harness/lib/repo-map/treesitter-extractor.mjs` - implementing the same `extract(rel, content)` shape with an extended v2 result: - `{ symbols, imports, defs, refs, complexity }` (v1 fields unchanged so the lexical tier - and existing consumers keep working). Grammars are lazy-loaded WASM via web-tree-sitter; - languages without a grammar (SQL, HCL, config) fall back to the lexical extractor - per-file. -- **Distribution:** WASM grammars ship as an optional install extra (hydrated under - `~/.copilot/.harness-bin/` alongside the runtime). **No runtime network, ever** — a - missing grammar is a silent lexical fallback, preserving local-first and offline - operation. This is the one place D2 costs something: grammar bytes ride the package. -- **Storage:** `knowledge/.harness-index/structural/` — a sibling of the BM25 postings - index: derived, rebuildable, gitignored, and **never inside the knowledge store**. The - structural index and the knowledge corpus stay independently cacheable and evolvable, - as the proposal requires. - - `files.json` — per-file `{ hash, mtime, size, symbols, imports, complexity }` - - `symbols.json` — declaration table with def/ref locations - - `graph.json` — caller/callee + module dependency edges - - `meta.json` — `{ sha, branch, generatedAt, extractorTier, grammarVersions }` -- **Incremental:** mtime+size fast path, sha256 content-hash confirm — only changed files - re-parse. Branch switches and small edits stay cheap; a full rebuild is always safe. -- **Structural diff:** `harness index --structural --since ` reindexes only - `git diff --name-only ` files and reports symbols added/removed/changed — the - "what structurally changed since main?" answer without a rebuild. -- **Watch-safety:** all writes atomic temp+rename through the existing `fs-safe.mjs` - containment (`writeFileContained`, symlink-ancestor checks). -- **Adoption gate:** opt-in `harness index --structural` first; consumers (orient repo - map, P5 plan enrichment) prefer the structural tables when present and current, else - lexical. Default-on only after `harness report` telemetry shows the structural tier - earns its parse cost (this keeps the spirit of the original "built only when telemetry - shows the lexical map misleads" clause while building the capability now). +**Exists today.** Lexical tier only; injectable `extract` seam already present. + +**Adapted design (D2).** + +- New `treesitter-extractor.mjs` implementing the same `extract(rel, content)` shape with + extended v2 result `{ symbols, imports, defs, refs, complexity }`. +- Languages in the first optional WASM set: **TypeScript/JavaScript, Python, Java**. All + other languages (and missing grammars) fall back silently to the lexical extractor. +- Grammars ship as an optional install extra under `~/.copilot/.harness-bin/`. No runtime + network. **Integrity (normative):** the package carries a lockfile of sha256 digests + for every grammar; each grammar's hash is verified before instantiation, and on any + mismatch the extractor falls back to lexical *loudly* (doctor S1 fails, not warns). +- **Storage location (resolved):** structural index lives **outside** the knowledge git + store at `~/.harness/index//structural/` so it can be freely deleted or + rebuilt without touching governance history, and never collides across repos. + - `files.json`, `symbols.json`, `graph.json`, `meta.json` +- Extracted content is untrusted repo text: symbols and excerpts pass the existing + `scanSecrets`/`redactSecrets` boundary at index-write time and `inertLine` at every + render, like all retrieved data. +- Incremental: mtime+size fast path, content-hash confirm. +- `harness index --structural --since ` for targeted structural diffs (refs + validated via `git rev-parse --verify`, always passed after `--`). +- Opt-in first; consumers prefer structural tables when present and current, else + lexical. - **Output lanes:** the structural query surface renders per the three-audience contract in §9 — its agent rendering is token-capped and framed, never raw index JSON. -**Phase:** 3. **Risks:** WASM payload size; grammar/version skew across hosts (recorded in -`meta.json`, surfaced by doctor S1); parse-cost regressions on huge repos (bounded by the -existing `MAX_FILES_SCANNED`/`MAX_FILE_BYTES` caps, which the structural tier inherits). +**Phase:** 3. ### P4 — Layer-aware compounding and knowledge writes -**Exists today.** Three capture lanes with one writer each (`/auto-compound` verified -fixes, `compound --insight` evidence-free insights, `harness remember` human teachings); -`consolidate --apply` as sole T2 writer; compounding gated on passed verification -evidence. The proposal's contribute/compound/record-failure primitives map 1:1 onto these -lanes — no new primitive is needed. +**Routing table (derived from git context at write time):** -**Gap.** No lane is layer-aware; everything lands in the single shared corpus. +| Git context | Default destination | Override | +|---|---|---| +| Feature branch | branch-local bucket | `--layer golden` (logged) | +| Default branch | golden | — | +| Detached HEAD / experiment | `branches/detached-/` (`promotable: false`) | none | -**Adapted design.** Routing is derived from git context at write time, never from a -sticky mode: +- `consolidate --apply` remains the sole writer of learning content for both layers. +- Only verified outcomes are eligible for compounding (unchanged). +- Golden consolidation skips episodes whose `branch:` provenance names a non-default + branch that has not been promoted — merged evidence does not become golden claims + without the explicit §5 step. -| Git context at write | Default destination | Override | -|---|---|---| -| Feature branch | branch-local bucket for that branch-key | `--layer golden` (logged) | -| Default branch | golden | — (already golden) | -| Detached HEAD / temporary experiment | ephemeral bucket `branches/detached-/`, `promotable: false` | none — never promotable, only prunable | - -- `consolidate --candidates` and `--apply` operate per-layer: on a feature branch the - packet clusters that branch's episodes into its bucket; golden consolidation runs on the - default branch. `consolidate --apply` remains the **sole writer of learning content for - both layers** — the delta contract, byte caps, secret scan, lint, and quarantine apply - identically. -- Only verified outcomes are eligible for compounding — unchanged, enforced by the - existing evidence-freshness gate in `harness compound`. -- Episode capture (T1, in the working tree) is inherently branch-scoped already — episode - files ride the feature branch and merge with it. The new provenance fields (P1) make - that scoping explicit and machine-readable rather than changing where episodes live. - -**Phase:** 2. **Risks:** misrouting (doctor K6 detects a branch bucket receiving writes -whose `branch:` provenance disagrees with its `meta.json`); user surprise at layer -defaults (mitigated: every write's layer is printed in the command output and recorded in -the store commit message). +**Phase:** 2. ### P5 — Plans and verification enriched with structural data -**Exists today.** `## Impacted Files` is a scope allowlist enforced by `plan-scope.mjs` -at verify time; `plan-new` scaffolds it from `--impacted`; verify binds evidence to -`{base, planDigest, changedFiles, workspaceDigest}`. Nothing surfaces callers, dependents, -or complexity when a plan is drafted. - -**Adapted design.** - -- **Plan enrichment (advisory):** when the structural index is present and current, - `plan-new` (and `/deepen-plan`) append a generated `Structural context` note under - `## Research Notes` for each impacted file: direct callers/dependents, exported symbols, - and hotspot flags (top-decile complexity or import-degree). Budgeted like every other - surface (≤ ~200 tokens), clearly marked as generated, never a gate input. -- **Verification enrichment (advisory first):** `harness verify` gains a - `structural-expectations` check that compares the structural diff of the change against - the plan: changed exported symbols should belong to impacted files; removed public - symbols with surviving callers are flagged. **Advisory (exit-code-neutral) until a - policy knob in `.github/harness/policy.yaml` opts it into warn/enforce** — consistent - with the existing enforcement ladder, and no new hard gate ships silently. -- Plans remain the primary durable intent/activity artifacts — this proposal adds inputs - to them, not a parallel record. - -**Phase:** 3 (enrichment), 4 (verify expectations). **Risks:** stale structural data -misleading a plan (mitigated: enrichment refuses to run when `meta.sha` ≠ current HEAD and -says so); advisory noise (mitigated: hotspot thresholds tuned via `harness report` before -any enforcement). +- Plan enrichment (advisory): when structural index is current, append a short + `Structural context` note under Research Notes (callers/dependents, exported symbols, + hotspot flags). Budgeted, clearly marked generated, excluded from the plan contract + digest, and rendered through the same data-framing as all retrieved content. +- Verification enrichment (advisory first): `structural-expectations` check comparing + structural diff against the plan. Exit-code-neutral until a policy knob opts it into + warn/enforce — this requires per-check severity in the verify model and policy schema, + named in §6 as its own capability row. + +**Phase:** 3 (enrichment), 4 (verify expectations). ### P6 — Knowledge lifecycle commands -**Exists today.** `harness knowledge` (mode, commit-mode, purge, migrate-store), -`harness learnings`/`learning` (listing, why-chains, retire/dispute/confirm/promote), -`harness consolidate` (status/candidates/apply/rebuild), doctor K1–K4. - -**Adapted design.** Three subcommands extending the existing `knowledge` command group: - -- **`harness knowledge status`** — layer-aware store report: golden count per domain, - branch buckets with age/`baseSha` drift/promotability, current-branch delta vs golden - (ids only in branch-local, ids shadowing golden), stale buckets (branch deleted or - merged), and the structural-index freshness line. Read-only; becomes the bare - `harness knowledge` output's richer sibling. -- **`harness knowledge promote [--branch ] [--ids a,b] [--all]`** — moves selected - branch-local learnings into golden through the §5 contract. Never bypasses - `consolidate --apply` semantics; mode-gated exactly like consolidation (`on` applies, - `suggest` requires `--yes`, `off`/`freeze`/`capture-only` reject with `E_MODE`). -- **`harness knowledge prune [--branch ] [--merged] [--stale ]`** — removes - branch buckets after merge or abandonment. Like `purge`, **never mode-gated** — human - deletion always wins. Each removal is a store commit; `--merged` resolves via - `git branch --merged` / remote-tracking state in the workspace. -- **Doctor extensions:** K5 — branch bucket whose branch no longer exists locally or on - the remote (hint: `knowledge prune`); K6 — layer misroute (bucket contents whose - provenance disagrees with bucket meta); S1 — structural index health (parse-failure - rate, grammar availability, `meta.sha` drift, orphaned cache entries). -- **Required pre-work (latent bug):** `events.mjs` `EVENT_TYPES` silently drops - `init_repo`/`recall`/`validate_plan`/`index` events today (footnoted in - `harness-tool-contract.md`). The new lifecycle commands need telemetry, so Phase 1 - fixes the allow-list first — otherwise promote/prune/status usage would be invisible to - `harness report` the same way. - -**Phase:** 1 (`status`, K5, events fix), 2 (`promote`, `prune`, K6), 3 (S1). -**Risks:** `promote` name collision — see §5's ledger-action hazard. +- `harness knowledge status` — layer-aware report (golden counts, branch buckets, drift, + promotability, structural freshness). +- `harness knowledge promote [--branch ] [--ids a,b] [--all]` — emits a reviewable + op-set applied only through the §5 promotion lane. +- `harness knowledge prune [--branch ] [--merged] [--stale ]` — + human-authority deletion; never mode-gated. Supports both merged and abandoned + (unmerged) branches. +- Doctor: K5 (orphan buckets), K6 (layer misroute), S1 (structural health). +- Hygiene pre-work: add the four silently-dropped event types + (`init_repo`/`recall`/`validate_plan`/`index`) to the `EVENT_TYPES` allow-list. + `knowledge`-type events already flow, so the new lifecycle subcommands are visible to + `harness report` from day one; the allow-list fix is independent cleanup, not a + dependency. + +**Phase:** 1 (`status`, events fix), 2 (`promote`, `prune`, K5, K6), 3 (S1). ### P7 — Multi-project and worktree hardening -**Exists today.** Per-repo isolation is done: store identity is per-remote (`repoId`), -each workspace owns its `.harness/` tree, telemetry is per-project-slug, and K4 + -`knowledge migrate-store` handle identity migration. Worktrees share the store by design; -`.gitignore` anticipates `.worktrees`. Global/user knowledge (`~/.copilot/knowledge/`, -`profile.md`) is already secondary to project-scoped recall in the documented lookup -order (`knowledge-locations.md`). - -**Gap.** No branch identity inside the shared store; detached HEAD is -indistinguishable from branch work. - -**Adapted design.** - -- **Branch-key normalization**, mirroring the proven `repoId` pattern: - `-<8hex>` where slug = branch name lowercased, `/` and every character outside - `[a-z0-9._-]` mapped to `-`, runs collapsed, truncated to 64 chars; 8hex = first 8 hex - of sha256 of the raw branch name. Collision-proof (hash disambiguates truncations and - case-folds), Windows-case-insensitivity-safe, and path-length-safe. -- **Worktree identity:** two worktrees on the *same* branch share one bucket — same - knowledge, same claims; this is the correct default and is documented rather than - fought. Worktree path is recorded in write provenance for audit, not identity. -- **Detached HEAD / experiments:** `branches/detached-/` with - `promotable: false` in `meta.json` — a non-promotable ephemeral bucket, prunable at any - time, exactly as the proposal requires. -- **Store-identity interaction:** branch buckets live inside the store directory, so K4's - stranded-store detection and `knowledge migrate-store` carry them automatically — - no separate migration path. - -**Phase:** 1 (key + detached bucket), 2 (prune integration). **Risks:** long branch names -on Windows deep paths (mitigated by the 64-char truncation + hash). +- Branch-key: `-<8hex>` (slug normalized, 64-char max, hash disambiguates). +- Same branch in multiple worktrees shares one bucket (correct default). +- Detached HEAD → non-promotable `detached-` bucket. +- **Branch rename:** auto-migrate the bucket to the new key when possible (rare; + best-effort). If auto-migration cannot be performed safely, leave the old bucket and + surface it in `knowledge status` for manual prune or migrate. +- **Branch-name reuse:** `meta.baseSha` is checked for ancestry at read time + (`git merge-base --is-ancestor`); a bucket whose recorded base is not an ancestor of + the current branch (force-push reuse with unrelated history) is excluded from the + overlay and surfaced in `knowledge status`. -### P8 — Local token budgeting and progressive disclosure — mostly satisfied +**Phase:** 1 (key + detached), 2 (prune + rename handling). -**Exists today.** Hard, locally computed budgets at every surface, pinned by tests: -pack 2048 bytes with priority-ordered truncation, repo map 1000 tokens, codebase map 2500, -learning 1200 bytes, top-3 injections, F0–F3 disclosure tiers in `context-budget.md`, -`token-meter.mjs` estimation, `harness report` budget-breach detection. No model is -involved in budgeting or prioritization anywhere. +### P8 — Local token budgeting and progressive disclosure — mostly satisfied -**Genuinely new.** Only the layer ordering inside the *unchanged* caps: branch-local → -golden → structural delta → broader team knowledge (global solutions), which maps onto the -existing pack section priority list as a refinement of the `## Learnings (memory)` and -`## Recall (top matches)` section internals. No cap changes, no new tiers. +Only change: layer ordering inside existing caps → branch-local → golden → structural +delta → broader team knowledge. -**Phase:** 1 (rides the P2 overlay). **Risks:** none beyond P2's. +**Phase:** 1 (rides P2). ### P9 — Governance and provenance -**Exists today.** Capability registry v2 with lifecycle + tombstones + owners; governance -ledger with sticky promote; verify evidence binding; index `headSha`; episode `sha256` -verification at apply time. Process knowledge (plans, solutions, learnings) vs structural -knowledge already have disjoint writers and stores. - -**Adapted design.** - -- **Uniform generation-context stamp** `{ sha, branch, baseSha, generatedAt }` applied - consistently: structural index `meta.json` (new), knowledge manifest `meta.json` - (extends existing `headSha`), episodes/learnings (the P1 provenance fields), branch - bucket `meta.json` (new). Verify evidence already carries the equivalent binding and is - unchanged. -- **Process vs structural distinction:** made explicit as a documentation rule — episodes - and learnings (process/experience knowledge) live in T1/T2 with human governance; - structural facts are always **derived, never stored as knowledge** (consistent with - MEMORY-MODEL's "Derived, never stored" principle), rebuildable from source at any - commit. -- **Security interaction (must-read for reviewers):** `docs/MEMORY-MODEL.md`'s - model-lane recency rule deliberately relies on day-granular episode dates - ("episode day > record day" strictly). SHA provenance *strengthens* this — commit - ancestry is a verifiable happened-after proof that day granularity cannot fake — but - swapping the recency rule onto commit ancestry changes the threat model (a same-day - replay must still never overturn a same-day human veto). Any such change is Phase 3+ - and requires its own threat-model review before design; until then SHA provenance is - recorded but the recency rule keeps its current day-granular semantics. - -**Phase:** 1 (stamps), 3+ (any recency-rule evolution, separately reviewed). +- Uniform generation-context stamp `{ sha, branch, baseSha, generatedAt }` on structural + meta, knowledge manifests, episodes/learnings, and branch-bucket meta. +- Process knowledge (episodes, learnings) stays in T1/T2 under human governance. +- Structural facts remain derived, never stored as knowledge. +- Provenance fields (branch names, worktree paths, bucket keys) are attacker-influenced + strings wherever a checkout can come from a fork: every rendering passes `inertLine` + with a length cap, including the pack header. +- SHA provenance is recorded but the existing day-granular recency rule is left unchanged + until a separate threat-model review. + +**Phase:** 1 (stamps). ## 4. Overlay and ranking semantics -Concrete rules for the layered read path (P1/P2/P8), chosen for determinism and zero new -trust classes: - -1. Candidate set = golden actives ∪ branch-local actives for the current branch-key. - Detached-HEAD buckets are read only when HEAD is detached at a matching context. -2. **Shadowing:** a branch-local learning with the same id as a golden learning replaces - it in the candidate set (the branch's re-teach wins locally; golden is untouched on - disk). The pack renders a shadow marker so the agent knows a golden claim was - overridden. -3. **Ranking:** the existing `scoreLearning` runs unchanged over the merged set; - branch-local wins ties at equal score. Top-3 injection and the 2048-byte pack cap are - unchanged. -4. **Trust framing:** branch-local claims render inside the existing untrusted-memory - advisory framing (`LEARNINGS_DATA_PREAMBLE`, `inertLine`, secret redaction, - `[unverified memory — advisory]` for insight-derived claims). Branch-local adds a - `[branch-local]` marker, not a new trust class — the injection-defense analysis in - MEMORY-MODEL applies verbatim. -5. When no branch bucket exists: golden only, plus the one-line structural/knowledge - delta from `index-status.mjs` (P2.3). +1. Candidate set = golden actives ∪ current branch-local actives. +2. **Shadowing (gated):** a branch-local learning with the same id replaces the golden + claim in the candidate set, **unless the golden claim is protected** (≥3 verified + fixes or `source: human`) — a protected claim is never shadowed; the branch-local + claim renders as an additional, subordinate entry instead. The pack renders a + `[branch-local]` / shadow marker either way. This mirrors the write-path + protected-target rule so the read path cannot bypass a gate the writer enforces. +3. **Governance binds both layers:** the overlay consults `readGovernance` per id — an + id under a standing `retire`/`dispute`/`promote` decision is never surfaced from a + branch bucket. Reusing a governed id in a bucket triggers the standing decision, it + does not escape it. +4. Ranking uses existing `scoreLearning`; branch-local wins equal-score ties (the layer + tiebreak applies before the id tiebreak, since shadowed ids are identical). Top-3 and + 2048-byte pack caps unchanged. +5. Branch-local claims stay inside the existing untrusted-memory advisory framing; only + an extra marker is added. +6. No branch bucket → golden + one-line drift advisory from `index-status.mjs`. ## 5. Promotion and pruning contract -Promotion (branch-local → golden) is the one new mutation class, and it reuses the -consolidation machinery wholesale: - -- `knowledge promote` **emits a reviewable op-set** (same shape and location discipline as - `.harness/consolidate-ops.json`) mapping each selected branch-local learning to an - `ADD`, `STRENGTHEN`, or `SUPERSEDE` against golden — chosen mechanically (no golden id → - ADD; same id shadowing → SUPERSEDE; episodes-only overlap → STRENGTHEN). -- The op-set is applied **only** through the `consolidate --apply` writer: byte cap, - `MAX_OPS_PER_RUN` delta contract, secret scan, imperative lint, protected-target dispute - rules, and quarantine strikes all bind identically. A promotion that would supersede a - protected golden learning (≥3 verified fixes or `source: human`) is rejected and marks - it disputed for human review, exactly like any other SUPERSEDE. -- **Ledger action name — hazard:** the governance ledger's `action: promote` is - sticky/terminal in `readGovernance` replay (it records promotion *to a T3 primitive* - and can never be overridden by later non-promote entries). Branch→golden promotion - therefore records `action: absorb-branch` entries — reusing `promote` would make every - branch-promoted learning permanently ungovernable. This constraint is normative for the - implementation phase. -- Promotion succeeds → the source branch-local entries are tombstoned in the bucket - (`promoted_to_golden: `), and the bucket becomes prunable. -- **Pruning** removes a bucket in one store commit. It is a human-authority path like - `purge`: never mode-gated, always available, never blocked by pending promotion state - (abandoning a branch abandons its knowledge — that is the feature). -- Branch buckets carry **no** `governance.jsonl` of their own in Phase 1–2: the golden - ledger remains the only human-authority record; branch buckets are ephemeral by - definition. Revisit only if branch-local disputes emerge in practice (§8). +- `knowledge promote` emits a reviewable op-set (`ADD` / `STRENGTHEN` / `SUPERSEDE`) + applied **only** through `consolidate --apply` running in an explicit **promotion + lane**. The lane exists because the standard candidacy gate would otherwise reject + every promotion: branch-local episodes are already consumed in the bucket's own + `consolidated.jsonl`, and their files live on the source branch. Normatively: + - Each bucket carries its own `consolidated.jsonl`; branch consolidation consumes + episodes per-bucket, golden consolidation consumes them in the golden ledger. + - Promotion ops are exempt from the golden candidacy check — their evidence was + disk-verified (sha256) at branch-apply time and is re-validated from the recorded + hashes, not from working-tree presence. + - Promotion rejections **never record quarantine strikes**; promotion is a distinct + rejection class, not a content failure of the underlying episodes. + - All other writer rules bind unchanged: byte cap, secret scan, imperative lint, and + the protected-target dispute rule. + - `promote --all` respects `MAX_OPS_PER_RUN` by chunking: deterministic ordering is + the cursor (as in `consolidate --candidates`), and the command reports + `remaining: N` until the bucket is drained. +- **Shadowed-claim promotion rule:** because golden is the authoritative baseline and a + feature branch is assumed to start from latest main, a branch-local claim that shadows + a golden claim is mapped to `SUPERSEDE` (or `STRENGTHEN` when appropriate). The + promoted claim carries the branch-local evidence as the new authoritative version. If + the golden claim is protected (≥3 verified fixes or `source: human`), the promotion is + rejected and the golden claim is marked disputed for human review — exactly as any + other SUPERSEDE of a protected target. +- **Ledger action:** branch→golden promotion records **`absorb-branch`** (never the + sticky `promote` action). **Replay rule (normative):** `readGovernance`'s standing- + decision replay considers only the decision set (`retire`, `dispute`, `confirm`, + `promote`); `absorb-branch` entries are recorded for audit but never become the + latest-standing decision for an id. Required regression test: `retire` → + `absorb-branch` → `consolidate --rebuild --yes` still lands `retired`. +- On successful promotion the source branch-local entries are tombstoned + (`promoted_to_golden:`), the tombstone is a retrieval exclusion (added to + `retrievalExclusion` alongside `promoted_to`), and the bucket becomes prunable. +- Pruning is human-authority, never mode-gated, and supports both merged and abandoned + branches. + +### 5a. Layer-aware store maintenance (normative) + +The following maintenance paths are root-anchored to `learnings/` today and MUST become +layer-aware in the phase that ships buckets — each is a data-loss or laundering hazard +otherwise: + +- **Hand-edit absorption** recognizes `branches//learnings/**` paths; a hand edit + in a bucket is snapshotted and absorbed exactly like a golden hand edit, never left + for transaction rollback to destroy. +- **`knowledge purge ` / `purge --all`** cascade across all layers: a purged + episode's branch-local learnings are delinked/removed too, and `purge --all` wipes + `branches/`. Human deletion always wins in every layer. +- **`consolidate --rebuild --yes`** wipes and re-derives **per layer**, routing episodes + by their `branch:` provenance. An episode without provenance in a store that has + `branches/` routes to branch-local review — never silently into golden. Rebuild must + not launder unpromoted branch claims into golden. +- **Commit-mode mirroring** (`knowledge commit repo`) remains golden-only and + branch-oblivious; mirroring buckets is out of scope for Phases 1–4. +- **Store schema version:** `ensureStore` writes a schema marker; an older CLI on a + versioned store refuses with a hint rather than operating layer-blind. ## 6. Capability-gap summary -Condensed per `capability-gap-proposal.md`; each row becomes a full proposal + registry -`candidate` entry as the **first post-approval step** (registry is a governed primitive -path — deliberately untouched while the Human Decision below is pending). - -| Future primitive | Type | Boundary reason | Phase | -|---|---|---|---| -| `knowledge status`/`promote`/`prune` surfaces | CLI subcommands + `harness-tool-contract.md` rows | Deterministic store lifecycle — CLI-first, no model judgment | 1–2 | -| Layered read path in orient | CLI behavior (no new primitive) | Extension of existing orient contract | 1 | -| Structural index tier | CLI (`index --structural`) + optional extractor module | Derived artifact, engine-level; no skill/agent judgment involved | 3 | -| Structural plan/verify enrichment | CLI behavior + one advisory check | Rides existing plan-new/verify contracts; policy-gated before enforcement | 3–4 | +| Future primitive | Type | Phase | +|---|---|---| +| `knowledge status` / `promote` / `prune` | CLI subcommands + contract | 1–2 | +| Layered read path in orient | CLI behavior | 1 | +| Structural index tier | CLI + optional extractor | 3 | +| Structural plan/verify enrichment | CLI behavior + advisory check | 3–4 | +| Per-check severity in verify + policy schema | Schema change (`policy.yaml` v2) | 4 | -No new skills or agents are proposed: every workflow lands in existing lanes -(`/recall`, `/auto-compound`, `/consolidate`, `/harness-doctor`) whose SKILL.md files gain -short layer-awareness notes in the phase that ships the behavior. +No new skills or agents. Existing SKILL.md files receive short layer-awareness notes in +the shipping phase. ## 7. Phasing -Each phase after 0 is delivered through the repo's own pipeline — a dated plan in -`docs/plans/` (created when the one-live-plan slot is free), gated edits, and the trusted -checks in `.github/harness/checks.yaml` (`harness-tests`, `prompt-contracts`, -`host-contracts`, `build-assets`). - -- **Phase 0 — this PR.** Blueprint + two current-doc pointers. Request the Human - Decision. -- **Phase 1 — provenance + layered reads.** Generation-context stamps; events allow-list - fix; branch-key normalization + `branches/` layout + detached bucket; layered read path - in orient/retrieve (§4); `knowledge status`; doctor K5. No write-routing changes yet — - golden remains the only write destination, so Phase 1 is fully backward-compatible. -- **Phase 2 — layered writes + lifecycle.** Layer routing in - compound/remember/consolidate (P4 table); `knowledge promote` (§5) and `prune`; - doctor K6; registry `candidate → experimental` entries for the shipped surfaces. -- **Phase 3 — structural index.** Tree-sitter WASM tier behind the extract seam; - persistent structural tables + incremental hashing + `--since`; orient/plan enrichment; +- **Phase 0** — this blueprint + Human Decision. +- **Phase 1** — provenance stamps (emitted by both serializers), events allow-list fix, + branch/worktree detection + branch-key derivation, layered read path (reads golden + plus any buckets present; buckets appear once Phase 2 writes them), `knowledge status` + (golden-only columns until Phase 2). Fully backward-compatible — no write path + changes. +- **Phase 2** — `branches/` layout + detached bucket + per-bucket ledgers, layer-aware + write routing, the §5a maintenance-path work (absorb/purge/rebuild/mirror/schema), + `knowledge promote` (promotion lane) + `prune`, doctor K5 + K6, rename auto-migration + (best-effort). +- **Phase 3** — tree-sitter WASM tier (TS/JS, Python, Java) with grammar integrity + checks, persistent structural tables, incremental + `--since`, orient/plan enrichment, doctor S1. -- **Phase 4 — structural verification + tuning.** Advisory `structural-expectations` - verify check with policy-ladder opt-in; delta-fallback polish; telemetry-gated decision - on structural-tier default-on. +- **Phase 4** — per-check severity (policy v2), advisory structural-expectations verify + check, telemetry-gated decision on structural default-on. ## 8. Risks and open questions -- **Store growth.** Branch buckets accumulate in long-lived repos. Default prune policy - (e.g. auto-hint at `knowledge status` when a bucket's branch is merged/deleted; no - auto-delete) needs a decision in Phase 2 — proposed default: hint only, never silent - deletion, consistent with human-authority deletion semantics. -- **Windows path lengths.** `/branches//learnings//.md` - under `%USERPROFILE%` approaches MAX_PATH on deep profiles; the 64-char branch-key - truncation plus existing slug caps keep worst cases bounded, but Phase 1 tests must - include a long-branch-name Windows-shaped fixture. -- **WASM grammar distribution.** Package size vs language coverage: proposed initial set - is the repo's active domains (TS/JS, Python, Java, SQL-fallback-lexical) with the rest - lexical. Needs a size budget decision in Phase 3. -- **Recency-rule evolution.** SHA provenance invites replacing the day-granular - "episode day > record day" rule with commit-ancestry proofs; deliberately deferred to a - dedicated threat-model review (P9). -- **Branch-local governance.** No per-bucket ledger in Phase 1–2 (buckets are ephemeral); - if humans start disputing branch-local claims before promotion, revisit. -- **Merge-timing semantics.** After a feature branch merges, its bucket's claims are - candidates for promotion — but nothing forces promotion before prune. Accepted: - knowledge loss on prune-without-promote is the operator's explicit choice, mirroring - the proposal's "promotion is explicit and reviewable, only". +- Store growth from long-lived branch buckets → `knowledge status` always hints; prune + remains explicit human action for both merged and abandoned branches. +- Windows path lengths → 64-char branch-key truncation + existing slug caps; Phase 1 + must include a long-branch Windows-shaped fixture. +- WASM size → initial set limited to TypeScript/JavaScript, Python, Java. +- Store lock contention → one `.lock` now serializes every worktree and branch; + long-running promotion must land cancellation on transaction checkpoint boundaries so + the rollback floor holds. +- Recency-rule evolution using commit ancestry is deliberately deferred to a separate + threat-model review. +- Branch-local governance ledger is omitted in Phase 1–2 (buckets are ephemeral; the + golden ledger binds both layers per §4). ## 9. Relationship to the CLI workbench (TUI) track From e57b6863ff20525e7a00a29c70f3b11387efd721 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 06:23:39 +0000 Subject: [PATCH 05/24] Record blueprint approval and add phase implementation plans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the Human Decision (approved 2026-08-06) in the evolution blueprint with the section 4/5/5a normative clauses as binding conditions. Adds the live Phase 1 plan (provenance stamps, events allow-list fix, branch detection and key derivation, gated layered read path, knowledge status) as a dated, schema-v1, gate-ready plan — validated green by harness validate-plan — plus undated Phase 2-4 drafts that respect the one-live-plan rule and are promoted to dated plans when their phase starts. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0169cWqYhLedkYhL8hEyudSq --- ...8-06-feat-harness-evolution-phase1-plan.md | 159 ++++++++++++++++++ docs/plans/harness-evolution-phase-drafts.md | 118 +++++++++++++ .../proposals/harness-evolution-blueprint.md | 14 +- 3 files changed, 287 insertions(+), 4 deletions(-) create mode 100644 docs/plans/2026-08-06-feat-harness-evolution-phase1-plan.md create mode 100644 docs/plans/harness-evolution-phase-drafts.md diff --git a/docs/plans/2026-08-06-feat-harness-evolution-phase1-plan.md b/docs/plans/2026-08-06-feat-harness-evolution-phase1-plan.md new file mode 100644 index 00000000..9ff9faac --- /dev/null +++ b/docs/plans/2026-08-06-feat-harness-evolution-phase1-plan.md @@ -0,0 +1,159 @@ +--- +plan_schema: 1 +title: "Ship harness evolution Phase 1: provenance, events fix, branch detection, layered reads, knowledge status" +type: feat +status: planned +plan_lock: true +phase: 1 +priority: P1 +risk: amber +autonomy: balanced +intent: "Implement Phase 1 of the approved Harness Evolution Blueprint: git provenance stamps emitted durably by both learning serializers and all episode writers, the events allow-list fix, branch/worktree detection with branch-key derivation, the gated layered read path, and the knowledge status report — with zero write-path changes and byte-identical behavior when no branch buckets exist" +expected_outputs: + - "Provenance frontmatter (commit, branch, base) written by episode lanes and preserved by both learning serializers across re-renders" + - "EVENT_TYPES accepts init_repo, recall, validate_plan, and index events" + - "Deterministic branch-key derivation and detached-HEAD detection in a reusable git-context module" + - "Layered read path with protected-shadow and governance gates, inert when no buckets exist" + - "harness knowledge status layer-aware report" +success_criteria: + - "New episodes and learnings carry commit/branch/base provenance, and a STRENGTHEN, hand-edit absorb, or purge delink re-render preserves it" + - "The four previously dropped event types are recorded and readable via harness events" + - "Branch-key derivation is deterministic, collision-safe, and Windows-path-safe including 64-char truncation and a long/unicode branch fixture" + - "With a fixture bucket present the overlay applies the protected-shadow and governance gates; with no buckets, retrieval output is byte-identical to pre-change behavior" + - "harness knowledge status reports golden domain counts, bucket rows when present, and index drift without mutating anything" +verification: + required: [harness-tests, prompt-contracts] + criteria: + AC1: [harness-tests] + AC2: [harness-tests] + AC3: [harness-tests] + AC4: [harness-tests, prompt-contracts] + AC5: [harness-tests] +reviews: + required: [security-sentinel, architecture-strategist] + completed: [] + critical_open: [] +skills_used: [] +org_objectives: [] +domains: [knowledge, cli] +specialists: [] +capability_gaps: [] +created: 2026-08-06 +updated: 2026-08-06 +--- + +# Ship harness evolution Phase 1: provenance, events fix, branch detection, layered reads, knowledge status + +## Overview + +Phase 1 of the approved [Harness Evolution Blueprint](../../knowledge/proposals/harness-evolution-blueprint.md) (Human Decision: Approved 2026-08-06). Delivers the read-only, fully backward-compatible foundation for branch-safe knowledge: provenance stamps, the events hygiene fix, branch detection and key derivation, the gated layer overlay, and `knowledge status`. No write path changes — golden remains the only write destination until Phase 2. + +## Context + +- The T2 store is remote-keyed and branch-blind; nothing records which commit or branch produced a claim. Blueprint P1/P9 add optional, reader-tolerant `commit:`/`branch:`/`base:` frontmatter — and require both serializers to *emit* them, because `serializeLearning` (`packages/harness/lib/knowledge/store.mjs`) and `renderLearning` (`packages/harness/lib/knowledge/apply.mjs`) build fixed field lists that silently drop unknown keys on any re-render (STRENGTHEN, absorb, purge delink). +- `EVENT_TYPES` (`packages/harness/lib/events.mjs`) silently drops `init_repo`/`recall`/`validate_plan`/`index` writes — a latent bug footnoted in `harness-tool-contract.md`. `knowledge`-type events already flow, so this is hygiene, not a dependency. +- The layered read path must ship with its safety gates from day one (blueprint §4, approval condition): a branch-local claim never shadows a *protected* golden claim (≥3 verified fixes or `source: human`), and the governance ledger binds both layers — an id under standing `retire`/`dispute`/`promote` is never surfaced from a bucket. +- Provenance strings (branch names, worktree paths) are attacker-influenced on fork checkouts: every rendering passes `inertLine` with a length cap, including the context-pack header (blueprint P9). +- Phase boundaries: bucket *writes*, promotion, prune, doctor K5/K6, and the §5a maintenance-path work are Phase 2 (see `docs/plans/harness-evolution-phase-drafts.md`). This plan must not implement them. + +## Intent Contract + +- **Goal:** Land the blueprint's Phase 1 — provenance stamps emitted durably, the events allow-list fix, branch/worktree detection with deterministic branch-key derivation, the gated layered read path, and `harness knowledge status` — with zero write-path changes and byte-identical retrieval when no buckets exist. +- **Expected outputs:** as frontmatter `expected_outputs`. +- **Success criteria:** as frontmatter `success_criteria`. +- **Verification checks:** `harness-tests`, `prompt-contracts` (named in `.github/harness/checks.yaml`; no model-authored shell strings). +- **Organizational objective:** Branch-safe, provenance-carrying knowledge per the approved blueprint, delivered through the repo's own gated pipeline. + +## Memory Cards + +- The sticky `promote` governance action and the latest-per-id replay live in `readGovernance` — any new ledger action must never become a standing decision. source: `packages/harness/lib/knowledge/store.mjs` +- Both learning serializers emit fixed field lists; unknown frontmatter is parsed but dropped on re-render. source: `packages/harness/lib/knowledge/apply.mjs` +- `EVENT_TYPES` is an allow-list; `writeEvent` with an unlisted type silently no-ops. source: `packages/harness/lib/events.mjs` +- Retrieval exclusions and ranking share one encoding between production and eval to prevent drift — the overlay must be one exported function used by both. source: `packages/harness/lib/knowledge/retrieve.mjs`, `packages/harness/lib/knowledge/eval.mjs` +- Context-pack interpolation passes `inertLine` + `redactSecrets` at the data boundary; new header fields must too. source: `packages/harness/lib/context-pack.mjs` + +## Acceptance Criteria + +- [ ] **AC1** New episodes (all three capture lanes) and learnings carry `commit:`/`branch:`/`base:` provenance; a STRENGTHEN, hand-edit absorb, or purge-delink re-render preserves the fields; absent fields on legacy artifacts never error. +- [ ] **AC2** `init_repo`, `recall`, `validate_plan`, and `index` events are accepted by `EVENT_TYPES`, written by their existing call sites, and readable via `harness events`. +- [ ] **AC3** A reusable git-context module derives `{branch, branchKey, worktree, detached, baseSha}`: branch-key is `-<8hex>` (lowercased, non-`[a-z0-9._-]` → `-`, collapsed, 64-char cap; 8-hex = sha256 of the raw name), deterministic across platforms, with tests covering slash/unicode/200-char branch names, detached HEAD, and worktrees. +- [ ] **AC4** The layered read path is one exported overlay function used by both retrieval and eval: golden ∪ bucket actives, protected golden claims never shadowed (subordinate render instead), governed ids never surfaced from buckets, branch-local tie-break before id tie-break. With no `branches/` directory the output is byte-identical to current behavior (regression-tested), and `retrieval-phrasing-stability` still passes. +- [ ] **AC5** `harness knowledge status [--json]` reports golden per-domain counts, bucket rows (branch, key, age, baseSha, promotability) when buckets exist, and the recall-index drift line — read-only, styled ledger + JSON output, `knowledge`-type event emitted. + +## Technical Notes + +- New module `packages/harness/lib/git-context.mjs`; consumed by orient (session + pack header via `inertLine` with length cap), episode writers, and `knowledge status`. +- Provenance emission points: `compound` fix/insight lanes, `remember`, plus both serializers. `base:` = merge-base with the configured default branch; default-branch resolution = store `config.json` `defaultBranch` → `origin/HEAD` → unresolved (recorded as absent — never guessed). +- Provenance adds ~110–140 bytes against `LEARNING_BYTE_CAP` (1200): the cap check must exclude the provenance block or the cap must be raised for provenance-bearing learnings — decide in implementation, record in Implementation Notes, and cover the near-cap STRENGTHEN case with a test so no quarantine strike can result. +- `knowledge status` CATALOG entry + `harness-tool-contract.md` row (additive; keep existing test-pinned lines untouched). + +## Plan + +### Phase 1 — Foundations + +- [ ] Add the four event types to `EVENT_TYPES`; tests prove the previously dropped writes now record. +- [ ] Create `lib/git-context.mjs` (branch, branch-key, worktree, detached, merge-base) with the AC3 test matrix, including a Windows-shaped long-branch fixture. +- [ ] Thread git context into `orient`: session field + pack-header line rendered through `inertLine` with a length cap. + +### Phase 2 — Provenance + +- [ ] Emit `commit:`/`branch:`/`base:` in all three episode lanes. +- [ ] Emit and preserve the fields in `serializeLearning` and `renderLearning`; regression tests for STRENGTH/absorb/purge-delink re-renders and for legacy artifacts without the fields. +- [ ] Resolve the byte-cap interaction (exclude-or-raise) with a near-cap test. + +### Phase 3 — Layered reads and status + +- [ ] Implement the overlay as one exported function with the §4 gates; wire into `retrieve.mjs` and `eval.mjs`; byte-identical no-bucket regression test. +- [ ] Implement `harness knowledge status` (ledger + `--json`), CATALOG + flags + contract-doc row, `knowledge` event. +- [ ] Run named checks; update MEMORY-MODEL only if any shipped behavior contradicts it (expected: no change needed in Phase 1). + +## Research Notes + +Blueprint §§3–5a carry the verified design constraints; the three-lens review findings (promotion-lane necessity, serializer field-drop, absorb-path regex, replay stickiness) are incorporated there. Structural work, write routing, and lifecycle mutations are explicitly out of scope. + +## Impacted Files + +- `packages/harness/lib/git-context.mjs` — new file +- `packages/harness/lib/events.mjs` — modified +- `packages/harness/lib/knowledge/store.mjs` — modified +- `packages/harness/lib/knowledge/apply.mjs` — modified +- `packages/harness/lib/knowledge/retrieve.mjs` — modified +- `packages/harness/lib/knowledge/eval.mjs` — modified +- `packages/harness/lib/knowledge/remember.mjs` — modified +- `packages/harness/lib/compound.mjs` — modified +- `packages/harness/lib/orient.mjs` — modified +- `packages/harness/lib/context-pack.mjs` — modified +- `packages/harness/lib/commands.mjs` — modified +- `packages/harness/lib/flags.mjs` — modified +- `packages/harness/bin/harness.mjs` — modified +- `packages/harness/test/` — new and modified tests +- `.github/skills/references/harness-tool-contract.md` — additive row for `knowledge status` + +## Verification Plan + +Named checks only: `harness-tests` (full suite, includes all new tests) and `prompt-contracts` (repo-level contract assertions, including the tool-contract pins the additive row must not break). Both defined in `.github/harness/checks.yaml`. + +## Verification Evidence + +(Filled by `harness verify --plan `.) + +## Risk & Review Routing + +- **Risk: amber.** Touches both store serializers (data-shape change, mitigated by reader-tolerance + regression tests) and the retrieval path (mitigated by the byte-identical no-bucket regression and the shared-encoding rule with eval). +- Security review (`security-sentinel` persona): provenance strings through `inertLine`, no new unredacted surface, governance gates on the overlay. +- Architecture review (`architecture-strategist` persona): overlay as single shared function, phase-boundary discipline (no Phase 2 write machinery). + +## Implementation Notes + +(Filled during implementation.) + +## Review Findings + +(Filled by `/code-review`.) + +## Activity + +### 2026-08-06 — Planned + +- Created from the approved Harness Evolution Blueprint (Human Decision recorded 2026-08-06); scoped to Phase 1 only. +- **Status:** planned, `plan_lock: true`, phase 1. diff --git a/docs/plans/harness-evolution-phase-drafts.md b/docs/plans/harness-evolution-phase-drafts.md new file mode 100644 index 00000000..32e0ba24 --- /dev/null +++ b/docs/plans/harness-evolution-phase-drafts.md @@ -0,0 +1,118 @@ +# Harness Evolution — Phase 2–4 plan drafts + +Draft successors to the live Phase 1 plan +(`2026-08-06-feat-harness-evolution-phase1-plan.md`), derived from the approved +[Harness Evolution Blueprint](../../knowledge/proposals/harness-evolution-blueprint.md) +(Human Decision: Approved 2026-08-06). This file is deliberately **undated**: the repo +retains at most one live dated plan at a time, so each draft below is promoted to a +dated, schema-v1, gate-ready plan when its phase starts and the slot is free. Until +then these are scoping records, not executable plans — `harness gate` never accepts +this file. + +The blueprint's approval conditions bind every phase: the §4 protected-shadow and +governance-binding gates, the §5 promotion-lane mechanics and `absorb-branch` replay +rule, and the §5a layer-aware maintenance semantics. + +--- + +## Phase 2 draft — Layered writes, promotion, prune + +**Goal.** Branch buckets become writable and manageable: layer-aware write routing, +the promotion lane, prune, and the §5a maintenance-path work. + +**Scope (from blueprint §7 Phase 2, §5, §5a):** + +- `branches//` layout with `meta.json`, per-bucket `consolidated.jsonl`, + bucket `INDEX.md`; detached-HEAD buckets (`promotable: false`). +- Write routing from git context at write time (feature branch → bucket; default branch + → golden; `--layer golden` override, logged; orient-branch staleness warning). +- Default-branch resolution hardened: store `config.json` `defaultBranch`, `origin/HEAD` + seed, fail-closed to branch-local, doctor check for unresolved default. +- Promotion lane in `consolidate --apply`: candidacy exemption backed by recorded + sha256s, never-strike promotion rejection class, chunked `promote --all` under + `MAX_OPS_PER_RUN`, shadowed-claim SUPERSEDE/STRENGTHEN mapping with the + protected-target dispute rule. +- `absorb-branch` ledger action + replay rule (never a standing decision) with the + required regression test: `retire` → `absorb-branch` → `rebuild --yes` still lands + `retired`. `promoted_to_golden:` tombstone added to `retrievalExclusion`. +- §5a maintenance paths made layer-aware: hand-edit absorption under `branches/**`, + purge cascade across layers, per-layer rebuild routing episodes by `branch:` + provenance (fail-closed to branch-local review), golden-only commit-mode mirror, + store schema version marker with old-CLI refuse-with-hint. +- Golden consolidation skips unpromoted non-default-branch episodes (blueprint P4). +- `knowledge prune` (`--branch`/`--merged`/`--stale`, never mode-gated), branch-rename + best-effort auto-migration, branch-name-reuse ancestry check + (`git merge-base --is-ancestor`) excluding mismatched buckets. +- Doctor K5 (orphan buckets) and K6 (layer misroute). +- Registry `candidate` entries for the shipped surfaces via `/create-primitive`. +- Layer split in report/SLO accounting (`layer` on learning event entries) so + utilization is attributed correctly from the first bucket write. + +**Draft acceptance shape:** routing table proven per git context; promotion round-trips +a bucket into golden under all writer rules with zero quarantine strikes; the replay +regression test passes; purge/absorb/rebuild layer tests pass; no-bucket behavior +remains byte-identical. + +**Risk:** amber–red (store mutation semantics). Reviews: security-sentinel, +architecture-strategist, data-integrity-guardian personas. + +--- + +## Phase 3 draft — Structural index (optional tree-sitter tier) + +**Goal.** Declaration-level structural index behind the existing `extract` seam, +feeding orient and plan enrichment. + +**Scope (from blueprint §7 Phase 3, P3, P5):** + +- `treesitter-extractor.mjs` implementing `extract(rel, content)` v2 + (`{symbols, imports, defs, refs, complexity}`); grammars TS/JS, Python, Java; silent + lexical fallback per file; async-lifecycle accommodation for the currently + synchronous `buildRepoMap`/orient path (design task — the seam is shape-compatible + but not lifecycle-compatible). +- Grammar integrity: in-package sha256 lockfile, verify before instantiate, loud + lexical fallback (doctor S1 fails, not warns), pinned `web-tree-sitter`. +- Storage at `~/.harness/index//structural/` (`files/symbols/graph/meta`), + incremental via mtime+size fast path + content-hash confirm, atomic writes through + `fs-safe.mjs`. +- `harness index --structural [--since ]` (ref validated via + `git rev-parse --verify`, passed after `--`). +- Extracted content through `scanSecrets`/`redactSecrets` at index-write and + `inertLine` at render; structural query surface per the §9 three-audience contract + (agent rendering token-capped, repo-map 1000-token precedent). +- Plan enrichment: generated `Structural context` under Research Notes, budgeted, + excluded from the plan contract digest, refuses on stale `meta.sha`. +- Doctor S1 (structural health); orient consumers prefer structural tables when + present and current. + +**Draft acceptance shape:** AST extraction matrix for the three grammar languages plus +lexical fallback; incremental cache-hit and `--since` tests; integrity-mismatch loud +fallback; no-network guard test for the whole read path. + +**Risk:** amber (new dependency + async rework). Reviews: security-sentinel, +performance-oracle, architecture-strategist personas. + +--- + +## Phase 4 draft — Structural verification and telemetry decision + +**Goal.** Advisory structural expectations in verify, and the evidence-based decision +on structural default-on. + +**Scope (from blueprint §7 Phase 4, P5):** + +- Per-check severity in the verify model and `policy.yaml` v2 (named capability row in + blueprint §6): advisory checks are exit-code-neutral until policy opts them into + warn/enforce. +- `structural-expectations` check: structural diff vs plan — changed exported symbols + within impacted files; removed public symbols with surviving callers flagged; stale + baseline warns by default. +- Telemetry-gated decision on structural default-on, using `harness report` parse-cost + and usage data accumulated since Phase 3. + +**Draft acceptance shape:** advisory check never flips outcome without policy opt-in; +policy v2 schema round-trips; expectation failure modes covered; default-on decision +recorded with evidence. + +**Risk:** amber (verify semantics). Reviews: architecture-strategist, +security-sentinel personas. diff --git a/knowledge/proposals/harness-evolution-blueprint.md b/knowledge/proposals/harness-evolution-blueprint.md index a2433072..9175e664 100644 --- a/knowledge/proposals/harness-evolution-blueprint.md +++ b/knowledge/proposals/harness-evolution-blueprint.md @@ -404,7 +404,13 @@ conforming citizens of them. ## Human Decision -- **Decision:** -- **Reviewer:** -- **Date:** -- **Conditions or required edits:** +- **Decision:** Approved +- **Reviewer:** Krish (repo owner) +- **Date:** 2026-08-06 +- **Conditions or required edits:** The §4 protected-shadow and governance-binding gates, + the §5 promotion-lane mechanics and `absorb-branch` replay rule, and the §5a + layer-aware maintenance semantics are binding constraints on every implementation + phase. Phases ship through the repo's plan pipeline — one live dated plan at a time, + gated edits, trusted named checks — with registry `candidate` entries added via + `/create-primitive` in the phase that ships each surface. The structural tier remains + opt-in until the Phase 4 telemetry decision. From 4fe37e7c41eaf9e4c66a62e8b3dc57a17eb0ac69 Mon Sep 17 00:00:00 2001 From: Krish Date: Thu, 6 Aug 2026 04:32:57 -0400 Subject: [PATCH 06/24] feat: branch-aware two-layer knowledge store with provenance, gated overlay, promotion lane, and lifecycle commands --- .../references/harness-tool-contract.md | 37 +- docs/MEMORY-MODEL.md | 87 ++++- ...8-06-feat-harness-evolution-phase1-plan.md | 85 ++++- packages/harness/bin/harness.mjs | 4 + packages/harness/lib/commands.mjs | 155 +++++++++ packages/harness/lib/compound.mjs | 13 + packages/harness/lib/context-pack.mjs | 23 +- packages/harness/lib/doctor.mjs | 83 ++++- packages/harness/lib/events.mjs | 11 +- packages/harness/lib/flags.mjs | 23 ++ packages/harness/lib/git-context.mjs | 145 ++++++++ packages/harness/lib/knowledge/admin.mjs | 119 +++++-- packages/harness/lib/knowledge/apply.mjs | 293 ++++++++++++++-- .../harness/lib/knowledge/consolidate.mjs | 63 +++- packages/harness/lib/knowledge/eval.mjs | 9 +- packages/harness/lib/knowledge/layer.mjs | 195 +++++++++++ packages/harness/lib/knowledge/overlay.mjs | 215 ++++++++++++ packages/harness/lib/knowledge/promote.mjs | 155 +++++++++ packages/harness/lib/knowledge/prune.mjs | 136 ++++++++ packages/harness/lib/knowledge/remember.mjs | 25 +- packages/harness/lib/knowledge/retrieve.mjs | 28 +- packages/harness/lib/knowledge/status.mjs | 115 +++++++ packages/harness/lib/knowledge/store.mjs | 124 ++++++- packages/harness/lib/orient.mjs | 22 ++ packages/harness/lib/report.mjs | 24 +- .../harness/test/events-allow-list.test.mjs | 68 ++++ packages/harness/test/git-context.test.mjs | 169 +++++++++ packages/harness/test/harness-cli.test.mjs | 5 +- .../harness/test/knowledge-promote.test.mjs | 321 ++++++++++++++++++ .../harness/test/knowledge-status.test.mjs | 160 +++++++++ .../harness/test/layer-maintenance.test.mjs | 165 +++++++++ packages/harness/test/layer-routing.test.mjs | 317 +++++++++++++++++ .../harness/test/layered-overlay.test.mjs | 244 +++++++++++++ packages/harness/test/provenance.test.mjs | 278 +++++++++++++++ .../harness/test/store-migration.test.mjs | 16 + 35 files changed, 3808 insertions(+), 124 deletions(-) create mode 100644 packages/harness/lib/git-context.mjs create mode 100644 packages/harness/lib/knowledge/layer.mjs create mode 100644 packages/harness/lib/knowledge/overlay.mjs create mode 100644 packages/harness/lib/knowledge/promote.mjs create mode 100644 packages/harness/lib/knowledge/prune.mjs create mode 100644 packages/harness/lib/knowledge/status.mjs create mode 100644 packages/harness/test/events-allow-list.test.mjs create mode 100644 packages/harness/test/git-context.test.mjs create mode 100644 packages/harness/test/knowledge-promote.test.mjs create mode 100644 packages/harness/test/knowledge-status.test.mjs create mode 100644 packages/harness/test/layer-maintenance.test.mjs create mode 100644 packages/harness/test/layer-routing.test.mjs create mode 100644 packages/harness/test/layered-overlay.test.mjs create mode 100644 packages/harness/test/provenance.test.mjs diff --git a/.github/skills/references/harness-tool-contract.md b/.github/skills/references/harness-tool-contract.md index 7d0b1a4b..b25b593f 100644 --- a/.github/skills/references/harness-tool-contract.md +++ b/.github/skills/references/harness-tool-contract.md @@ -57,15 +57,15 @@ This table tracks only what differs in runtime character across commands — whi |---------|------|--------|-------| | `install` / `upgrade` | human/CI | none | mutates `~/.copilot/` | | `doctor` | human/CI | none | read-only (`--host vscode` runs an isolated hook-lifecycle fixture) | -| `init-repo` | human/CI | none¹ | mutates workspace (`.harness/`, `docs/plans/`, `docs/codebase-map.md`) | +| `init-repo` | human/CI | writes¹ | mutates workspace (`.harness/`, `docs/plans/`, `docs/codebase-map.md`) | | `status` / `uninstall` | human/CI | none | read-only / mutates `~/.copilot/` (uninstall removes hydrated files) | | `orient` | agent-runtime | writes | mutates `.harness/` (context-pack, repo-map, session) | -| `recall` | agent-runtime | none¹ | read-only | +| `recall` | agent-runtime | writes¹ | read-only | | `gate` | agent-runtime | writes | mutates session state | | `verify` | agent-runtime | writes | mutates (evidence file + session) | -| `validate-plan` | agent-runtime | none¹ | read-only | +| `validate-plan` | agent-runtime | writes¹ | read-only | | `plan-new` | agent-runtime | none | mutates workspace (writes the plan; `--stdout` prints instead) | -| `index` | agent-runtime | none¹ | mutates the knowledge index (`--status` read-only) | +| `index` | agent-runtime | writes¹ | mutates the knowledge index (`--status` read-only) | | `get` | agent-runtime | none | read-only | | `compound` | agent-runtime | writes | mutates (index + solution doc + telemetry) | | `consolidate` | agent-runtime | writes | read-only (`--status`/`--candidates`); mutates the learnings store (`--apply`/`--rebuild --yes`) | @@ -77,7 +77,7 @@ This table tracks only what differs in runtime character across commands — whi | `events` | agent-runtime | none | read-only | | `report` | agent-runtime | none | read-only (`--sync` writes `~/.harness/telemetry/`) | -¹ `init-repo`/`recall`/`validate-plan`/`index` each call `writeEvent` (types `init_repo`/`recall`/`validate_plan`/`index`), but none of those four type strings is in the `EVENT_TYPES` allow-list (`events.mjs`) — `writeEvent` silently no-ops for an unlisted type, so the call exists in code yet nothing actually lands in `events.jsonl`; "none" is the ledger truth, not a simplification. +¹ `init-repo`/`recall`/`validate-plan`/`index` historically called `writeEvent` (types `init_repo`/`recall`/`validate_plan`/`index`) while those four type strings were absent from the `EVENT_TYPES` allow-list (`events.mjs`), so the calls silently no-opped. The allow-list now includes all four (harness evolution Phase 1 hygiene) — the events record in `events.jsonl` like every other lifecycle write. **Query construction (deterministic-retrieval discipline):** build `--query` from the user's salient nouns and identifiers **verbatim** (e.g. `SYSTEM-OVERRIDE`, `payment`, `token`) — do not paraphrase intent into synonyms. The retrieval tokenizer normalizes identifier formats and morphology, but it cannot recover a term the query never contained. Passing the literal request terms is what keeps recall stable across phrasings. @@ -245,6 +245,31 @@ Allowed outcomes are `passed`, `failed`, and `inconclusive`. Only fresh `passed` { "pass": true, "exitCode": 0, "removed": { "episode": "docs/solutions/...", "learnings": ["..."], "links": ["..."], "ledger": 1 }, "blockedReason": null } ``` +**knowledge status** — read-only layer-aware report (golden per-domain counts, branch-bucket rows when buckets exist, recall-index drift). Emits a `knowledge` event; never creates or mutates the store. Bucket `promotable` is derived from the key shape (`detached-*` is never promotable); `ancestryOk: false` marks a bucket whose recorded base is not an ancestor of the current HEAD (excluded from the read overlay). +```json +{ + "pass": true, + "exitCode": 0, + "storeExists": true, + "mode": "on", + "commit": "none", + "context": { "branch": "feature/x", "branchKey": "feature-x-1a2b3c4d", "detached": false }, + "golden": { "active": 12, "total": 14, "domains": [{ "domain": "sql", "active": 12, "total": 14 }] }, + "buckets": [{ "key": "feature-x-1a2b3c4d", "branch": "feature/x", "baseSha": "", "ageDays": 3, "promotable": true, "active": 2, "total": 2, "promoted": 0, "prunable": false, "ancestryOk": true }], + "drift": { "indexed": true, "stale": false, "commitsSince": null, "filesChanged": null, "recommendation": "index is current with HEAD" } +} +``` + +**knowledge promote** — emits a reviewable, digest-bound branch→golden op-set at `.harness/promote-ops.json` (never writes the store itself); applied only through `consolidate --apply` in promotion mode, where evidence re-validates from the sha256s recorded at branch-apply time, rejections never record quarantine strikes, promoted sources are tombstoned `promoted_to_golden:` (a retrieval exclusion), and an `absorb-branch` audit entry lands in the governance ledger (audit-only — the replay never lets it become an id's standing decision). `--all` chunks under the 5-op delta contract with deterministic id ordering as the cursor and `remaining: N` reporting. Detached-HEAD buckets (`detached-*`) are never promotable — derived from the key shape. +```json +{ "pass": true, "exitCode": 0, "opsPath": ".harness/promote-ops.json", "ops": 2, "remaining": 0, "skipped": [{ "id": "sql/x", "reason": "standing governance decision: retire" }], "bucketKey": "feature-x-1a2b3c4d", "nextTools": ["harness consolidate --apply --ops .harness/promote-ops.json"] } +``` + +**knowledge prune** — deletes branch buckets (`--branch `, `--merged` via workspace git state plus fully-tombstoned buckets, `--stale `; selectors combine). Human authority, never mode-gated — exactly like purge. Removal is one store commit. +```json +{ "pass": true, "exitCode": 0, "removed": ["feature-x-1a2b3c4d"], "blockedReason": null } +``` + **eval-knowledge** — deterministic retrieval PROXY (hit/false-surface/token cost per arm on a temporally held-out split); never a model-graded net-benefit number, and no benefit claim is published from it ```json { @@ -261,7 +286,7 @@ Allowed outcomes are `passed`, `failed`, and `inconclusive`. Only fresh `passed` } ``` -Lifecycle events are limited to `session_start`, `orient`, `gate`, `pre_tool`, `post_tool`, `skill_activation`, `verify`, `compound`, `consolidate`, `remember`, `learning`, `knowledge`, and `session_end`. Non-lifecycle commands `get`, `report`, `learnings`, and `eval-knowledge` never append events by design — they never call `writeEvent` at all. `init-repo`, `recall`, `validate-plan`, and `index` also never append events, but not by that same deliberate omission: all four DO call `writeEvent` (types `init_repo`/`recall`/`validate_plan`/`index`), and those types are simply absent from the allow-list above, so the calls silently no-op — see the Command catalog table's footnote. Every append-attempting command never stores prompt or query content; `skill_activation` stores only the skill and session binding. +Lifecycle events are limited to `session_start`, `orient`, `gate`, `pre_tool`, `post_tool`, `skill_activation`, `verify`, `compound`, `consolidate`, `remember`, `learning`, `knowledge`, `session_end`, `init_repo`, `recall`, `validate_plan`, and `index` (the last four were formerly dropped by the allow-list despite their call sites — fixed as harness evolution Phase 1 hygiene; see the Command catalog table's footnote). Non-lifecycle commands `get`, `report`, `learnings`, and `eval-knowledge` never append events by design — they never call `writeEvent` at all. Every append-attempting command never stores prompt or query content; `skill_activation` stores only the skill and session binding. ## Host hook boundary diff --git a/docs/MEMORY-MODEL.md b/docs/MEMORY-MODEL.md index 3c1b1ea0..f008d62f 100644 --- a/docs/MEMORY-MODEL.md +++ b/docs/MEMORY-MODEL.md @@ -12,7 +12,7 @@ local-only. Team sync is a future phase, deferred by design. | Tier | Name | Location | Written by | Role | |------|------|----------|-----------|------| | T1 | Episodic | `docs/solutions/` (+ global solutions), plans, activity logs | `/auto-compound` (verified `kind: fix`), `compound --insight` (`kind: insight`), `harness remember` (`kind: human-teaching`) | Immutable ground truth. The episode schema is the public stability contract. | -| T2 | Semantic ("learnings") | `~/.harness/knowledge//` — CLI-managed local git repo, outside the working tree, never pushed | `harness consolidate --apply` only | Condensed, one-claim-per-file knowledge. A regenerable view of T1 — never the asset. | +| T2 | Semantic ("learnings") | `~/.harness/knowledge//` — CLI-managed local git repo, outside the working tree, never pushed; golden `learnings/` plus per-branch `branches//` buckets (see [Layered store](#layered-store-branch-local-knowledge-shipped-blueprint-phases-12)) | `harness consolidate --apply` only | Condensed, one-claim-per-file knowledge. A regenerable view of T1 — never the asset. | | T3 | Behavioral | `.github/` instructions / skills / checks | `/create-primitive` + human PR | Knowledge become behavior. | Since the governance ledger shipped (M4), T2 is **not** a pure function of `(T1, current @@ -599,9 +599,12 @@ ingestion path had no `scanSecrets` before — residual #5's regex-grade caveat ## Hand-editability -A direct, non-CLI edit to a file under `~/.harness/knowledge//learnings/` is +A direct, non-CLI edit to a file under `~/.harness/knowledge//learnings/` — or +under a branch bucket's `branches//learnings/` (absorbed identically; the +bucket key is recorded in the snapshot's frontmatter) — is absorbed automatically — every mutation entry point (`consolidate --apply`, `remember`, -`learning retire|dispute|confirm|promote`, `knowledge purge`, `consolidate --rebuild --yes`) +`learning retire|dispute|confirm|promote`, `knowledge purge`, `knowledge prune`, +`consolidate --rebuild --yes`) runs `git status --porcelain` in the store first and commits any dirty edit as its own `human edit: ` commit, landing before that entry point's own commit. @@ -634,14 +637,76 @@ change a learning's status when a CLI command is more convenient than a direct e remain first-class paths; hand-editing is no longer a discouraged shortcut, it is absorbed with full provenance either way. -## Planned evolution (proposal, not current behavior) - -A pending design proposal — the -[Harness Evolution Blueprint](../knowledge/proposals/harness-evolution-blueprint.md) — -maps a branch-local knowledge overlay inside the existing `~/.harness/knowledge//` -store (golden `learnings/` plus per-branch buckets) and commit-SHA provenance on episodes -and learnings. Nothing on this page changes until that proposal's Human Decision records -approval and the work ships; this page continues to describe current behavior only. +## Layered store: branch-local knowledge (shipped, blueprint Phases 1–2) + +The approved [Harness Evolution Blueprint](../knowledge/proposals/harness-evolution-blueprint.md) +(Human Decision 2026-08-06) Phases 1–2 are implemented. Current behavior: + +- **Layout.** Golden remains `learnings/` at the store root; branch-local layers live at + `branches//` siblings, each with its own `learnings/`, per-bucket + `consolidated.jsonl`, `INDEX.md`, and a `meta.json` cache + (`{ branch, branchKey, baseSha, createdAt, promotable }` — a cache, never authority: + promotability is derived from the key shape at decision time, and `detached-*` keys are + never promotable). `ensureStore` stamps `store.json { schema: 2 }`; a CLI older than a + store's recorded schema refuses with an upgrade hint instead of operating layer-blind. +- **Branch keys.** `-<8hex>` — the branch name lowercased with everything outside + `[a-z0-9._-]` collapsed to `-`, capped at 64 chars, plus the first 8 hex of sha256 over + the RAW name (`git-context.mjs`). Detached HEAD (including rebase/bisect states) derives + `detached-<12-char-short-sha>`. +- **Provenance.** Episodes captured by the CLI and learnings written by `consolidate + --apply` carry optional `commit:`/`branch:`/`base:` frontmatter. Both learning + serializers emit AND preserve the fields across every re-render (STRENGTHEN, hand-edit + absorb, purge delink); legacy artifacts without them never error. The + `LEARNING_BYTE_CAP` check excludes the provenance lines from the measured size, so a + near-cap learning gaining provenance can never trip `E_BYTE_CAP` or a quarantine strike. +- **Write routing** is derived from git context AT WRITE TIME: feature branch → + bucket; default branch → golden; detached → detached bucket; `--layer golden` is an + explicit, logged override. Default-branch resolution is store `config.json` + `defaultBranch` → `origin/HEAD` → unresolved, and an unresolved default fails closed TO + BRANCH-LOCAL, never golden (doctor K7 surfaces it). The orient-recorded branch is + advisory only — a write whose HEAD disagrees warns. +- **Read overlay.** Retrieval and the knowledge eval share one overlay + (`overlay.mjs`): golden actives ∪ current-branch bucket actives; a branch-local claim + shadows a same-id golden claim UNLESS the golden claim is protected (≥3 verified + `kind: fix` links or `source: human` — never shadowed; the branch claim renders as an + additional subordinate entry); ids under a standing `retire`/`dispute`/`promote` + decision are never surfaced from a bucket; branch-local wins equal-score ties (layer + tiebreak before the id tiebreak); a bucket whose recorded `baseSha` is not an ancestor + of HEAD (force-push name reuse) is excluded whole. With no `branches/` directory the + read path is byte-identical to the pre-layer behavior. +- **Golden consolidation eligibility (P4).** Once a store has buckets, golden candidacy + requires `branch:` provenance naming the resolved default branch — an episode from an + unpromoted non-default branch, or one with no provenance, routes to branch-local review + and never silently into golden (this is also what a per-layer + `consolidate --rebuild --yes` re-derivation enforces; rebuild wipes each bucket's + learnings/ledger too, keeping `meta.json` as the layer identity). +- **Promotion** (`harness knowledge promote`) emits a reviewable, digest-bound op-set at + `.harness/promote-ops.json`; only `consolidate --apply` in promotion mode applies it. + Promotion ops are exempt from golden candidacy — evidence re-validates from the sha256s + recorded at branch-apply time, never working-tree presence; promotion rejections never + record quarantine strikes; a shadow-of-golden maps to SUPERSEDE (STRENGTHEN when the + overlap is episodes-only); a protected golden target rejects and is marked disputed. + Success tombstones each source `promoted_to_golden:` (a retrieval exclusion alongside + `promoted_to`) and records **`absorb-branch`** in the governance ledger — an AUDIT + action: `readGovernance`'s replay considers only `retire`/`dispute`/`confirm`/`promote`, + so an absorb-branch entry can never become an id's standing decision (regression-pinned: + retire → absorb-branch → rebuild still lands retired). +- **Maintenance is layer-aware (§5a).** Hand edits under `branches//learnings/**` + absorb exactly like golden ones (the bucket key is recorded in the snapshot + frontmatter); `knowledge purge `/`--all` cascade across every layer (`--all` + wipes `branches/` whole), with an id's governance record dropped only once no layer + holds it; commit-mode mirroring stays golden-only — buckets are never mirrored. +- **Lifecycle.** `harness knowledge status` is the read-only layer report (golden + per-domain counts, bucket rows with age/base/promotability/ancestry, recall-index + drift); `harness knowledge prune [--branch ] [--merged] [--stale ]` deletes + buckets — human authority, never mode-gated, one store commit. Doctor K5 flags orphan + buckets (branch gone locally and on remotes), K6 flags bucket contents whose `branch:` + provenance disagrees with the bucket's meta. Branch renames auto-migrate a bucket to + the new key when exactly one gone-branch, ancestry-verified candidate exists; anything + ambiguous is left for `knowledge status`/K5 and manual prune. + +Phase 3 (structural index) and Phase 4 (per-check verify severity) remain unshipped +design; nothing on this page describes them. ## Related diff --git a/docs/plans/2026-08-06-feat-harness-evolution-phase1-plan.md b/docs/plans/2026-08-06-feat-harness-evolution-phase1-plan.md index 9ff9faac..7d7147a3 100644 --- a/docs/plans/2026-08-06-feat-harness-evolution-phase1-plan.md +++ b/docs/plans/2026-08-06-feat-harness-evolution-phase1-plan.md @@ -2,9 +2,9 @@ plan_schema: 1 title: "Ship harness evolution Phase 1: provenance, events fix, branch detection, layered reads, knowledge status" type: feat -status: planned +status: review plan_lock: true -phase: 1 +phase: 3 priority: P1 risk: amber autonomy: balanced @@ -74,11 +74,11 @@ Phase 1 of the approved [Harness Evolution Blueprint](../../knowledge/proposals/ ## Acceptance Criteria -- [ ] **AC1** New episodes (all three capture lanes) and learnings carry `commit:`/`branch:`/`base:` provenance; a STRENGTHEN, hand-edit absorb, or purge-delink re-render preserves the fields; absent fields on legacy artifacts never error. -- [ ] **AC2** `init_repo`, `recall`, `validate_plan`, and `index` events are accepted by `EVENT_TYPES`, written by their existing call sites, and readable via `harness events`. -- [ ] **AC3** A reusable git-context module derives `{branch, branchKey, worktree, detached, baseSha}`: branch-key is `-<8hex>` (lowercased, non-`[a-z0-9._-]` → `-`, collapsed, 64-char cap; 8-hex = sha256 of the raw name), deterministic across platforms, with tests covering slash/unicode/200-char branch names, detached HEAD, and worktrees. -- [ ] **AC4** The layered read path is one exported overlay function used by both retrieval and eval: golden ∪ bucket actives, protected golden claims never shadowed (subordinate render instead), governed ids never surfaced from buckets, branch-local tie-break before id tie-break. With no `branches/` directory the output is byte-identical to current behavior (regression-tested), and `retrieval-phrasing-stability` still passes. -- [ ] **AC5** `harness knowledge status [--json]` reports golden per-domain counts, bucket rows (branch, key, age, baseSha, promotability) when buckets exist, and the recall-index drift line — read-only, styled ledger + JSON output, `knowledge`-type event emitted. +- [x] **AC1** New episodes (all three capture lanes) and learnings carry `commit:`/`branch:`/`base:` provenance; a STRENGTHEN, hand-edit absorb, or purge-delink re-render preserves the fields; absent fields on legacy artifacts never error. +- [x] **AC2** `init_repo`, `recall`, `validate_plan`, and `index` events are accepted by `EVENT_TYPES`, written by their existing call sites, and readable via `harness events`. +- [x] **AC3** A reusable git-context module derives `{branch, branchKey, worktree, detached, baseSha}`: branch-key is `-<8hex>` (lowercased, non-`[a-z0-9._-]` → `-`, collapsed, 64-char cap; 8-hex = sha256 of the raw name), deterministic across platforms, with tests covering slash/unicode/200-char branch names, detached HEAD, and worktrees. +- [x] **AC4** The layered read path is one exported overlay function used by both retrieval and eval: golden ∪ bucket actives, protected golden claims never shadowed (subordinate render instead), governed ids never surfaced from buckets, branch-local tie-break before id tie-break. With no `branches/` directory the output is byte-identical to current behavior (regression-tested), and `retrieval-phrasing-stability` still passes. +- [x] **AC5** `harness knowledge status [--json]` reports golden per-domain counts, bucket rows (branch, key, age, baseSha, promotability) when buckets exist, and the recall-index drift line — read-only, styled ledger + JSON output, `knowledge`-type event emitted. ## Technical Notes @@ -91,21 +91,21 @@ Phase 1 of the approved [Harness Evolution Blueprint](../../knowledge/proposals/ ### Phase 1 — Foundations -- [ ] Add the four event types to `EVENT_TYPES`; tests prove the previously dropped writes now record. -- [ ] Create `lib/git-context.mjs` (branch, branch-key, worktree, detached, merge-base) with the AC3 test matrix, including a Windows-shaped long-branch fixture. -- [ ] Thread git context into `orient`: session field + pack-header line rendered through `inertLine` with a length cap. +- [x] Add the four event types to `EVENT_TYPES`; tests prove the previously dropped writes now record. +- [x] Create `lib/git-context.mjs` (branch, branch-key, worktree, detached, merge-base) with the AC3 test matrix, including a Windows-shaped long-branch fixture. +- [x] Thread git context into `orient`: session field + pack-header line rendered through `inertLine` with a length cap. ### Phase 2 — Provenance -- [ ] Emit `commit:`/`branch:`/`base:` in all three episode lanes. -- [ ] Emit and preserve the fields in `serializeLearning` and `renderLearning`; regression tests for STRENGTH/absorb/purge-delink re-renders and for legacy artifacts without the fields. -- [ ] Resolve the byte-cap interaction (exclude-or-raise) with a near-cap test. +- [x] Emit `commit:`/`branch:`/`base:` in all three episode lanes. +- [x] Emit and preserve the fields in `serializeLearning` and `renderLearning`; regression tests for STRENGTH/absorb/purge-delink re-renders and for legacy artifacts without the fields. +- [x] Resolve the byte-cap interaction (exclude-or-raise) with a near-cap test. ### Phase 3 — Layered reads and status -- [ ] Implement the overlay as one exported function with the §4 gates; wire into `retrieve.mjs` and `eval.mjs`; byte-identical no-bucket regression test. -- [ ] Implement `harness knowledge status` (ledger + `--json`), CATALOG + flags + contract-doc row, `knowledge` event. -- [ ] Run named checks; update MEMORY-MODEL only if any shipped behavior contradicts it (expected: no change needed in Phase 1). +- [x] Implement the overlay as one exported function with the §4 gates; wire into `retrieve.mjs` and `eval.mjs`; byte-identical no-bucket regression test. +- [x] Implement `harness knowledge status` (ledger + `--json`), CATALOG + flags + contract-doc row, `knowledge` event. +- [x] Run named checks; update MEMORY-MODEL only if any shipped behavior contradicts it (expected: no change needed in Phase 1). ## Research Notes @@ -145,7 +145,43 @@ Named checks only: `harness-tests` (full suite, includes all new tests) and `pro ## Implementation Notes -(Filled during implementation.) +- **Byte-cap decision (AC1 technical note):** the `LEARNING_BYTE_CAP` (1200) check + EXCLUDES the provenance frontmatter lines from the measured size (`provenanceBytes`, + `store.mjs`) — the cap keeps measuring the claim, not the bookkeeping, so a near-cap + learning gaining `commit:`/`branch:`/`base:` can never hit `E_BYTE_CAP` or record a + quarantine strike. Covered by the near-cap regression in `test/provenance.test.mjs`. +- **Episode lanes:** the CLI's sole episode writer is `runInsightCompound` + (`compound.mjs`) — `compound --insight` (kind insight) and `harness remember` (kind + human-teaching) both funnel through it, so one emission point covers both CLI lanes. + Fix-kind episodes are skill-authored solution docs; they stay reader-tolerant (absent + provenance never errors) and gain provenance when the authoring skill adds it. +- **Provenance semantics:** fresh ADD/SUPERSEDE/MERGE writes stamp write-time HEAD; + STRENGTHEN and every parse→serialize round trip (absorb, purge delink, lifecycle + promote) PRESERVE the original fields — a claim's recorded origin never migrates to a + later commit. Branch names are yamlQuoted at rest, `inertLine`-capped at render. +- **Overlay:** one exported function (`loadLayeredLearnings`, `overlay.mjs`) shared by + `retrieve.mjs` and `eval.mjs`; §4 gates implemented there (protected-shadow with + subordinate render, governance exclusion for retire/dispute/promote, ancestry gate, + branch-wins-ties via `layerTieRank` before the id tiebreak). No-bucket byte-identity is + regression-tested (`test/layered-overlay.test.mjs`). +- **Branch-key edge:** a fully non-latin branch name slugs to the `branch` fallback with + the 8-hex raw-name hash disambiguating (`git-context.mjs`). +- **Phase 2 (shipped in this same PR per the draft scope):** layer routing fails closed + to branch-local on an unresolvable default branch (doctor K7 surfaces it); per-layer + candidacy (P4) excludes non-default-branch and provenance-less episodes from golden + once buckets exist; consolidation debt/candidates mirror the routed lane (golden ledger + ∪ current bucket ledger); promotion evidence re-validates from recorded sha256s with + promotion-class (never-strike) rejections; `absorb-branch` is audit-only in the + governance replay (regression: retire → absorb-branch → rebuild still lands retired); + purge cascades across all layers and drops an id's governance record only when no + layer still holds the id; store schema marker `store.json {schema: 2}` with + refuse-with-hint for newer stores; bucket strikes/quarantines live in the bucket's own + ledger. `consolidate --status` layer additions are additive fields (`layer`, + `bucketKey`); golden domain-pressure display stays golden-scoped. +- **Pre-existing tests updated for shipped behavior:** `harness-cli.test.mjs` (recall + event now records — the old assertion pinned the dropped-write bug), + `store-migration.test.mjs` (fixtures pin `defaultBranch` so identity-migration tests + stay on the golden lane). ## Review Findings @@ -157,3 +193,18 @@ Named checks only: `harness-tests` (full suite, includes all new tests) and `pro - Created from the approved Harness Evolution Blueprint (Human Decision recorded 2026-08-06); scoped to Phase 1 only. - **Status:** planned, `plan_lock: true`, phase 1. + +### 2026-08-06 — Implemented (Phases 1 + 2) + +- Phase 1 delivered: events allow-list fix (AC2), `lib/git-context.mjs` (AC3), + provenance emission/preservation with the byte-cap exclusion decision (AC1), the + shared layered read overlay with §4 gates (AC4), `harness knowledge status` (AC5), + and the orient session/pack-header branch line. +- Phase 2 (draft scope) delivered in the same PR: bucket layout + write routing + + fail-closed default-branch resolution, §5a maintenance (bucket absorb, cross-layer + purge, per-layer rebuild, golden-only mirror, store schema marker), promotion lane + with `absorb-branch` replay rule and required regression test, `knowledge prune`, + doctor K5/K6 (+K7 unresolved-default advisory), branch-rename best-effort migration, + read-time ancestry exclusion, P4 golden-candidacy rule, and the report/SLO layer split. +- Full harness suite green: 713 tests. Key decisions recorded in Implementation Notes. +- **Status:** review — awaiting `/code-review`. diff --git a/packages/harness/bin/harness.mjs b/packages/harness/bin/harness.mjs index e7503d22..f739023a 100755 --- a/packages/harness/bin/harness.mjs +++ b/packages/harness/bin/harness.mjs @@ -180,6 +180,9 @@ const CATALOG = [ sig: ' | --status | purge | commit | migrate-store', options: [ ['--status', 'show the active mode (default)'], + ['status', 'layer-aware report: golden domain counts, branch buckets, recall-index drift (read-only)'], + ['promote [--branch ] [--ids a,b] [--all]', 'emit a reviewable branch→golden promotion op-set (.harness/promote-ops.json)'], + ['prune [--branch ] [--merged] [--stale ]', 'delete branch buckets (human authority, never mode-gated)'], ['purge ', 'cascade-delete an episode and dependent learnings'], ['purge --all', 'reset the learnings store (episodes remain, become debt)'], ['commit ', 'repo mirrors ACTIVE learnings into docs/knowledge/learnings (opt-in, never git-commits the product repo); none is the default'], @@ -192,6 +195,7 @@ const CATALOG = [ ['--candidates', 'deterministic work packet for the consolidation skill'], ['--apply --ops ', 'validate and apply an ops JSON (sole writer); suggest mode requires --yes'], ['--rebuild --yes', 'T2 reset for model-upgrade regeneration (git history retains learnings)'], + ['--layer golden', 'explicit golden-layer override for --apply (writes otherwise route by write-time git context)'], ] }, { name: 'remember', desc: 'teach the harness a durable claim (human-teaching episode + learning)', sig: '"" --trigger "" [--domain ]', diff --git a/packages/harness/lib/commands.mjs b/packages/harness/lib/commands.mjs index ff94f797..4fd19275 100644 --- a/packages/harness/lib/commands.mjs +++ b/packages/harness/lib/commands.mjs @@ -469,6 +469,12 @@ export async function cmdOrient(argv) { // credits only delivered learnings. learnings: result.deliveredLearnings || (result.learnings || []).map((l) => l.id), learningsBytes: result.learningsBytes, + // Layer attribution (blueprint P6/report split): recorded only when a + // branch-bucket learning actually surfaced, so pre-bucket event shapes + // are unchanged. `harness report` splits SLO totals per layer from this. + ...((result.learnings || []).some((l) => l.layer) + ? { learningLayers: Object.fromEntries((result.learnings || []).map((l) => [l.id, l.layer || 'golden'])) } + : {}), }); if (flags.json) { @@ -858,6 +864,7 @@ export async function cmdConsolidate(argv) { approve: flags.yes, log: logger, copilotHome, + layer: flags.layer, }); writeEvent(workspace, flags, { type: 'consolidate', @@ -1287,6 +1294,154 @@ export async function cmdKnowledge(argv) { return result.exitCode; } + // Layer-aware read-only report (blueprint P6, Phase 1): golden per-domain + // counts, branch-bucket rows when buckets exist, and the recall-index drift + // line. 'status' is never a member of KNOWLEDGE_MODES, so there's no + // ambiguity with the mode-set branch below. + if (subcommand === 'status') { + const { knowledgeStatus } = await import('./knowledge/status.mjs'); + const report = knowledgeStatus({ workspace, copilotHome }); + writeEvent(workspace, flags, { + type: 'knowledge', + command: 'knowledge', + decision: 'status', + result: 'pass', + exitCode: 0, + }); + if (flags.json) { + emitJson(flags, report); + return 0; + } + const { inertLine } = await import('./knowledge/store.mjs'); + const keyWidth = keyWidthFor(['knowledge', 'golden', 'drift', ...report.golden.domains.map((d) => d.domain), ...report.buckets.map((b) => b.key)]); + const contextNote = report.context + ? report.context.detached + ? 'detached HEAD' + : `branch ${inertLine(report.context.branch).slice(0, 80)}` + : 'no git context'; + console.log( + ui.line({ + state: report.storeExists ? 'ok' : 'pending', + key: 'knowledge', + value: report.storeExists ? `mode ${report.mode} · commit ${report.commit}` : 'no store yet', + note: contextNote, + keyWidth, + }) + ); + console.log( + ui.line({ key: 'golden', value: `${report.golden.active} active · ${report.golden.total} total`, keyWidth }) + ); + for (const d of report.golden.domains) { + console.log(ui.line({ key: d.domain, value: `${d.active} active · ${d.total} total`, keyWidth })); + } + for (const b of report.buckets) { + const noteParts = []; + if (b.branch) noteParts.push(inertLine(b.branch).slice(0, 80)); + if (b.ageDays !== null) noteParts.push(`${b.ageDays}d old`); + if (b.baseSha) noteParts.push(`base ${b.baseSha.slice(0, 12)}`); + noteParts.push(b.promotable ? 'promotable' : 'never promotable'); + if (b.prunable) noteParts.push('prunable — harness knowledge prune'); + if (b.ancestryOk === false) noteParts.push('base not an ancestor of HEAD — excluded from overlay'); + console.log( + ui.line({ + state: b.ancestryOk === false ? 'warn' : 'ok', + key: b.key, + value: `${b.active} active · ${b.total} total${b.promoted ? ` · ${b.promoted} promoted` : ''}`, + note: noteParts.join(' · '), + keyWidth, + }) + ); + } + if (report.drift) { + console.log( + ui.line({ + state: report.drift.indexed ? (report.drift.stale ? 'warn' : 'ok') : 'pending', + key: 'drift', + value: report.drift.indexed ? (report.drift.stale ? 'recall index stale' : 'recall index current') : 'no recall index', + note: report.drift.recommendation, + keyWidth, + }) + ); + } + return 0; + } + + // Branch→golden promotion emitter (blueprint §5): writes ONLY the + // reviewable op-set at .harness/promote-ops.json — the store mutates + // exclusively through `consolidate --apply` running in promotion mode. + if (subcommand === 'promote') { + const { buildPromotionOps } = await import('./knowledge/promote.mjs'); + const logger = (m) => log(flags, m); + const ids = flags.ids ? flags.ids.split(',').map((s) => s.trim()).filter(Boolean) : null; + const result = buildPromotionOps({ workspace, branchKey: flags.branch, ids, all: flags.all, log: logger }); + writeEvent(workspace, flags, { + type: 'knowledge', + command: 'knowledge', + decision: 'promote', + result: result.pass ? 'pass' : 'fail', + exitCode: result.exitCode, + blockedReason: result.blockedReason, + }); + if (flags.json) { + emitJson(flags, result); + } else if (!result.pass) { + for (const l of ui.errorBlock({ code: 'E_USAGE', message: result.blockedReason, exit: result.exitCode })) { + console.error(l); + } + } else { + console.log( + ui.line({ + state: 'ok', + key: 'promote', + value: `${result.ops} op(s) → ${result.opsPath}`, + note: `${result.bucketKey}${result.remaining ? ` · remaining ${result.remaining}` : ''}${result.skipped.length ? ` · skipped ${result.skipped.length}` : ''}`, + }) + ); + for (const s of result.skipped) console.log(ui.paint('muted', ` skip ${s.id} — ${s.reason}`)); + printNext(result.nextTools?.[0]); + } + return result.exitCode; + } + + // Bucket deletion (blueprint P6/§5): human authority, never mode-gated — + // exactly like purge. One store commit removes the selected buckets. + if (subcommand === 'prune') { + const { pruneBuckets } = await import('./knowledge/prune.mjs'); + const logger = (m) => log(flags, m); + const result = pruneBuckets({ + workspace, + branchKey: flags.branch, + merged: flags.merged, + staleDays: flags.stale, + log: logger, + }); + writeEvent(workspace, flags, { + type: 'knowledge', + command: 'knowledge', + decision: 'prune', + result: result.pass ? 'pass' : 'fail', + exitCode: result.exitCode, + blockedReason: result.blockedReason, + }); + if (flags.json) { + emitJson(flags, result); + } else if (!result.pass) { + for (const l of ui.errorBlock({ code: 'E_USAGE', message: result.blockedReason, exit: result.exitCode })) { + console.error(l); + } + } else { + console.log( + ui.line({ + state: 'warn', + key: 'prune', + value: `${result.removed.length} bucket(s) removed`, + note: result.removed.join(' · '), + }) + ); + } + return result.exitCode; + } + // commit is checked BEFORE the mode-set branch below — 'commit' // itself is never a member of KNOWLEDGE_MODES, so there's no ambiguity, but // ordering matches the brief's explicit branch order (purge → commit → diff --git a/packages/harness/lib/compound.mjs b/packages/harness/lib/compound.mjs index 12689681..df1c708b 100644 --- a/packages/harness/lib/compound.mjs +++ b/packages/harness/lib/compound.mjs @@ -9,6 +9,7 @@ import { loadPolicy } from './policy.mjs'; import { recordSkillUsage } from './telemetry.mjs'; import { scanSecrets } from './secret-scan.mjs'; import { readStoreConfig } from './knowledge/store.mjs'; +import { deriveGitContext } from './git-context.mjs'; import { assertNoSymlinkAncestors, realpathParentContained } from './fs-safe.mjs'; // Byte-exact snapshot/restore of a single retrieval-state file, used to roll @@ -181,6 +182,18 @@ export function runInsightCompound({ workspace, copilotHome, flags, log = () => if (tags) fmLines.push(`tags: ${tags}`); if (flags.trigger) fmLines.push(`trigger: ${yamlQuote(flags.trigger)}`); if (flags.claim) fmLines.push(`claim: ${yamlQuote(flags.claim)}`); + // Git provenance (blueprint P1/P9): optional commit/branch/base stamped at + // capture time from the CURRENT workspace HEAD. This is the sole CLI + // episode writer — `compound --insight` (kind: insight) and + // `harness remember` (kind: human-teaching) both land here — so every + // CLI-captured episode carries provenance; skill-authored fix episodes stay + // reader-tolerant (absent fields are fine everywhere). Shas are stamped + // bare; the branch name is attacker-influenced text on fork checkouts, so + // it rides through yamlQuote like every other quoted field here. + const gitContext = deriveGitContext({ workspace, home }); + if (gitContext.headSha) fmLines.push(`commit: ${gitContext.headSha}`); + if (gitContext.branch) fmLines.push(`branch: ${yamlQuote(gitContext.branch)}`); + if (gitContext.baseSha) fmLines.push(`base: ${gitContext.baseSha}`); const doc = `---\n${fmLines.join('\n')}\n---\n\n${body.trim()}\n`; const secrets = scanSecrets(doc); if (secrets.length) { diff --git a/packages/harness/lib/context-pack.mjs b/packages/harness/lib/context-pack.mjs index 9ff207d1..1a4c81a9 100644 --- a/packages/harness/lib/context-pack.mjs +++ b/packages/harness/lib/context-pack.mjs @@ -39,11 +39,15 @@ export function buildLearningsLines(learnings) { lines.push(`Applied learnings: ${learnings.map((l) => l.id).join(', ')}`); for (const l of learnings) { const fence = l.advisory ? ' [unverified memory — advisory]' : ''; + // Layer marker (blueprint §4): a branch-bucket claim is flagged + // [branch-local] — subordinate when it shadows a protected golden claim — + // inside the same untrusted-memory advisory framing as every other entry. + const layerMark = l.layer === 'branch' ? (l.subordinate ? ' [branch-local, subordinate]' : ' [branch-local]') : ''; // inertLine: a legacy or hand-edited learning can still carry an // embedded control char in its trigger/claim (see store.mjs's doc // comment) — collapsed to a space so it can never inject extra // structure into this trusted context surface. - lines.push(`- [${l.id}]${fence} ${inertLine(l.trigger)} → ${inertLine(l.claimLine)}`); + lines.push(`- [${l.id}]${layerMark}${fence} ${inertLine(l.trigger)} → ${inertLine(l.claimLine)}`); } return lines; } @@ -77,6 +81,12 @@ export function learningsSectionBytes(packBody) { return Buffer.byteLength(packBody.slice(start, end), 'utf8'); } +// Pack-header provenance cap: branch names are attacker-influenced strings on +// fork checkouts (blueprint P9) — every rendered fragment passes inertLine AND +// a hard length cap so a hostile name can neither inject structure nor flood +// the 2 KB budget. +const HEADER_BRANCH_CAP = 80; + export function buildContextPack({ query, recall, @@ -88,6 +98,7 @@ export function buildContextPack({ repoMapRef, gatePreview, nextTools, + gitContext, }) { // Order by priority so the 2 KB cap truncates the least-important content // last: high-value fixed sections (active plan, plan view, goal, gate, next @@ -100,6 +111,16 @@ export function buildContextPack({ '> Excludes plan ## Activity and ## Verification Evidence by design.', ]; + // Git provenance header (blueprint P2): one line naming the branch (or + // detached state) and short head/base shas, so the model and a human both + // see which line of history this orientation was derived from. + if (gitContext && (gitContext.branch || gitContext.detached)) { + const label = gitContext.detached ? '(detached)' : inertLine(gitContext.branch).slice(0, HEADER_BRANCH_CAP); + const headPart = gitContext.headSha ? ` @ ${inertLine(String(gitContext.headSha)).slice(0, 12)}` : ''; + const basePart = gitContext.baseSha ? ` · base ${inertLine(String(gitContext.baseSha)).slice(0, 12)}` : ''; + lines.push(`> Branch: ${label}${headPart}${basePart}`); + } + if (activePlan) { // inertLine the plan path (filename-derived, same class as the Plans // bullets below); status/plan_lock/phase are frontmatter tokens/booleans. diff --git a/packages/harness/lib/doctor.mjs b/packages/harness/lib/doctor.mjs index a65bd021..09a81735 100644 --- a/packages/harness/lib/doctor.mjs +++ b/packages/harness/lib/doctor.mjs @@ -14,8 +14,11 @@ import { readSession, writeSession } from './session.mjs'; import { parseVSCodeSettings } from './vscode-settings.mjs'; import { resolveVSCodeSettingsPaths } from './paths.mjs'; import { loadRetired, findStaleOrphans } from './sync.mjs'; -import { storeDir, storeDirForId, repoId, localRepoId } from './knowledge/store.mjs'; +import { storeDir, storeDirForId, repoId, localRepoId, listLearnings } from './knowledge/store.mjs'; import { consolidateStatus } from './knowledge/consolidate.mjs'; +import { listBuckets } from './knowledge/overlay.mjs'; +import { branchExists } from './knowledge/layer.mjs'; +import { deriveGitContext, resolveDefaultBranch } from './git-context.mjs'; import { loadReportEvents, knowledgeSlos } from './report.mjs'; const require = createRequire(import.meta.url); @@ -410,6 +413,84 @@ function knowledgeChecks({ workspace, copilotHome }) { // Advisory; never fail doctor on a knowledge-check error. } + // K5 (blueprint P6): a bucket whose branch no longer exists locally or on + // any remote is an orphan — its work was merged, deleted, or abandoned; + // the bucket sits as store growth until a human prunes it. Detached + // buckets carry no branch to check and are aged out via prune --stale + // instead. `branchExists` returning null means git state was unverifiable + // — never reported as an orphan. + try { + const dir = storeDir(workspace); + if (fs.existsSync(dir)) { + const orphans = []; + for (const bucket of listBuckets(dir)) { + const branch = bucket.meta?.branch; + if (!branch) continue; + if (branchExists(workspace, branch) === false) orphans.push(bucket.key); + } + checks.push({ + id: 'K5', + name: 'No orphan branch buckets (branch gone locally and on remotes)', + pass: orphans.length === 0, + hint: orphans.length + ? `orphan bucket(s): ${orphans.join(', ')} — run: harness knowledge prune --branch (or --merged/--stale)` + : 'harness knowledge prune', + optional: true, + }); + } + } catch { + // Advisory; never fail doctor on a knowledge-check error. + } + + // K6 (blueprint P6): layer misroute — bucket contents whose `branch:` + // provenance disagrees with the bucket's own meta.json branch. A learning + // carrying another branch's provenance inside this bucket means a write + // was routed into the wrong layer (or a bucket dir was hand-moved). + try { + const dir = storeDir(workspace); + if (fs.existsSync(dir)) { + const misrouted = []; + for (const bucket of listBuckets(dir)) { + const metaBranch = bucket.meta?.branch; + if (!metaBranch) continue; + for (const l of listLearnings(bucket.dir)) { + if (l.fm.branch && l.fm.branch !== metaBranch) misrouted.push(`${bucket.key}:${l.id}`); + } + } + checks.push({ + id: 'K6', + name: 'Bucket contents match their bucket branch (no layer misroute)', + pass: misrouted.length === 0, + hint: misrouted.length + ? `misrouted learning(s): ${misrouted.slice(0, 5).join(', ')} — inspect the bucket, then knowledge prune or re-consolidate on the right branch` + : 'inspect with harness knowledge status', + optional: true, + }); + } + } catch { + // Advisory; never fail doctor on a knowledge-check error. + } + + // K7 (blueprint P1): the default branch drives write-layer routing; when it + // is unresolvable (no store config.json defaultBranch, no origin/HEAD), + // writes fail closed to branch-local — surfaced so a team can pin it. + try { + const dir = storeDir(workspace); + if (fs.existsSync(dir)) { + const context = deriveGitContext({ workspace }); + const unresolved = Boolean(context.branch) && !resolveDefaultBranch(workspace, {}); + checks.push({ + id: 'K7', + name: 'Default branch resolvable for knowledge layer routing', + pass: !unresolved, + hint: 'set defaultBranch in the store config.json or run: git remote set-head origin -a — until then writes fail closed to branch-local', + optional: true, + }); + } + } catch { + // Advisory; never fail doctor on a knowledge-check error. + } + return checks; } diff --git a/packages/harness/lib/events.mjs b/packages/harness/lib/events.mjs index ece578ec..7805205e 100644 --- a/packages/harness/lib/events.mjs +++ b/packages/harness/lib/events.mjs @@ -21,6 +21,15 @@ export const EVENT_TYPES = new Set([ 'learning', 'knowledge', 'session_end', + // Formerly silently dropped (harness-tool-contract.md footnote): these four + // commands always CALLED writeEvent, but their types were absent from this + // allow-list, so the writes no-opped. Allow-listed as Phase 1 hygiene + // (harness evolution blueprint P6) — the call sites in commands.mjs are + // unchanged; the events simply record now. + 'init_repo', + 'recall', + 'validate_plan', + 'index', ]); function shouldSkipEvents(flags = {}) { @@ -75,7 +84,7 @@ export function writeEvent(workspace, flags, payload) { }; if (payload.blockedReason) event.blockedReason = payload.blockedReason; if (payload.usage) event.usage = payload.usage; - for (const field of ['tool', 'mutation', 'targets', 'targetResolved', 'gate', 'decision', 'durationMs', 'success', 'learnings', 'learningsBytes']) { + for (const field of ['tool', 'mutation', 'targets', 'targetResolved', 'gate', 'decision', 'durationMs', 'success', 'learnings', 'learningsBytes', 'learningLayers']) { if (payload[field] !== undefined) event[field] = payload[field]; } diff --git a/packages/harness/lib/flags.mjs b/packages/harness/lib/flags.mjs index caf9ed39..6c7c35ff 100644 --- a/packages/harness/lib/flags.mjs +++ b/packages/harness/lib/flags.mjs @@ -25,6 +25,13 @@ function parsePhase(raw) { return raw; } +function parseLayer(raw) { + if (!['golden', 'branch'].includes(raw)) { + invalidFlag('--layer', raw, 'must be golden or branch'); + } + return raw; +} + export function parseFlags(argv) { const flags = { dryRun: false, @@ -78,6 +85,12 @@ export function parseFlags(argv) { to: null, why: null, yes: false, + layer: null, + branch: null, + ids: null, + all: false, + merged: false, + stale: null, }; for (let i = 0; i < argv.length; i++) { @@ -176,6 +189,16 @@ export function parseFlags(argv) { if (next !== undefined && !next.startsWith('--')) flags.why = argv[++i]; } else if (a === '--yes') flags.yes = true; + else if (a.startsWith('--layer=')) flags.layer = parseLayer(a.split('=')[1]); + else if (a === '--layer') flags.layer = parseLayer(argv[++i]); + else if (a.startsWith('--branch=')) flags.branch = a.split('=').slice(1).join('='); + else if (a === '--branch') flags.branch = argv[++i]; + else if (a.startsWith('--ids=')) flags.ids = a.split('=').slice(1).join('='); + else if (a === '--ids') flags.ids = argv[++i]; + else if (a === '--all') flags.all = true; + else if (a === '--merged') flags.merged = true; + else if (a.startsWith('--stale=')) flags.stale = parsePositiveInt(a.split('=')[1], '--stale'); + else if (a === '--stale') flags.stale = parsePositiveInt(argv[++i], '--stale'); } return flags; diff --git a/packages/harness/lib/git-context.mjs b/packages/harness/lib/git-context.mjs new file mode 100644 index 00000000..b8dc4d4d --- /dev/null +++ b/packages/harness/lib/git-context.mjs @@ -0,0 +1,145 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { storeDir } from './knowledge/store.mjs'; + +/** + * Branch/worktree detection and branch-key derivation (harness evolution + * blueprint P1/P7). Read-only and fail-tolerant: a non-git workspace, an + * unborn branch, or a missing git binary degrade to null fields — never a + * throw into a caller's orientation or write path. + * + * Everything here is derived from the CURRENT git state at call time. Nothing + * is cached: layer routing must reflect write-time HEAD (blueprint P1), and a + * branch recorded earlier (e.g. at orient) is advisory display only. + */ + +const BRANCH_SLUG_CAP = 64; +const DETACHED_SHORT_SHA_LEN = 12; + +function gitOut(cwd, args) { + try { + const res = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 10_000 }); + return res.status === 0 ? res.stdout.trim() : null; + } catch { + return null; + } +} + +/** + * Deterministic, filesystem-safe slug for a RAW branch name: lowercased, + * every char outside [a-z0-9._-] collapsed to '-' (runs collapse to one), + * trimmed of leading/trailing '-', capped at 64 chars. A branch name that + * slugs to nothing (e.g. fully non-latin) falls back to 'branch' — the + * 8-hex hash suffix in branchKeyFor still disambiguates. + */ +export function branchSlug(branch) { + return ( + String(branch) + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/-{2,}/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, BRANCH_SLUG_CAP) || 'branch' + ); +} + +/** + * The bucket key for a branch: `-<8hex>` where the 8 hex chars are the + * first 8 of sha256 over the RAW branch name (pre-slug), so two branches + * whose slugs collide ("Feature/Foo" vs "feature/foo", 65+-char names that + * truncate identically) still get distinct keys. Deterministic across + * platforms and runs — pure string function, no git involved. + */ +export function branchKeyFor(branch) { + const hash = crypto.createHash('sha256').update(String(branch)).digest('hex').slice(0, 8); + return `${branchSlug(branch)}-${hash}`; +} + +/** The non-promotable bucket key for a detached HEAD at `headSha`. */ +export function detachedKeyFor(headSha) { + return `detached-${String(headSha).slice(0, DETACHED_SHORT_SHA_LEN)}`; +} + +/** True for the `detached-<12hex>` key shape — never promotable, derived from + * the key at decision time (bucket meta.json is a cache, never authority). */ +export function isDetachedKey(key) { + return /^detached-[0-9a-f]{12}$/.test(String(key || '')); +} + +/** + * Default-branch resolution (blueprint P1, normative): the store config.json + * `defaultBranch` field wins; else the branch `origin/HEAD` points at; else + * null — NEVER guessed. Callers that route writes fail closed to branch-local + * on null; `base:` provenance is simply omitted. + */ +export function resolveDefaultBranch(workspace, { home } = {}) { + try { + const dir = storeDir(workspace, { home }); + const parsed = JSON.parse(fs.readFileSync(path.join(dir, 'config.json'), 'utf8')); + if (parsed && typeof parsed.defaultBranch === 'string' && parsed.defaultBranch.trim()) { + return { name: parsed.defaultBranch.trim(), source: 'config' }; + } + } catch { + // absent/corrupt config — fall through to origin/HEAD + } + const originHead = gitOut(workspace, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']); + if (originHead && originHead.startsWith('refs/remotes/origin/')) { + return { name: originHead.slice('refs/remotes/origin/'.length), source: 'origin-head' }; + } + return null; +} + +/** merge-base of HEAD with the resolved default branch, or null when the + * default branch is unresolvable or shares no history. Tries the remote + * tracking ref first (the fetched baseline a feature branch actually forked + * from), then the local branch. */ +function mergeBaseWithDefault(workspace, defaultBranch) { + if (!defaultBranch) return null; + for (const ref of [`refs/remotes/origin/${defaultBranch.name}`, `refs/heads/${defaultBranch.name}`]) { + if (gitOut(workspace, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]) === null) continue; + const base = gitOut(workspace, ['merge-base', 'HEAD', ref]); + if (base) return base; + } + return null; +} + +/** + * Derive `{ branch, branchKey, worktree, detached, headSha, baseSha }` from a + * workspace directory. + * + * - `branch` — the RAW current branch name (`git symbolic-ref --short HEAD`), + * null when detached or not a repo. symbolic-ref (not + * `rev-parse --abbrev-ref`) so an unborn branch (fresh `git init`, no + * commits) still reports its name, and a rebase/bisect state — where HEAD + * is genuinely detached and abbrev-ref would print the literal `HEAD` — + * resolves to detached like any other detached state. + * - `branchKey` — `-<8hex>` (branchKeyFor), or `detached-<12hex>` on a + * detached HEAD, or null when neither a branch nor a commit exists. + * - `worktree` — `git rev-parse --show-toplevel` (the checkout root this + * workspace resolves into; a linked worktree reports its own root). + * - `detached` — true whenever HEAD is not on a branch (plain detach, + * rebase, bisect). + * - `headSha` — full current commit sha, null on an unborn branch. + * - `baseSha` — merge-base with the configured default branch + * (resolveDefaultBranch), null when unresolvable. Never guessed. + */ +export function deriveGitContext({ workspace, home } = {}) { + const empty = { branch: null, branchKey: null, worktree: null, detached: false, headSha: null, baseSha: null }; + if (!workspace) return empty; + const worktree = gitOut(workspace, ['rev-parse', '--show-toplevel']); + if (!worktree) return empty; + + const headSha = gitOut(workspace, ['rev-parse', 'HEAD']); + const branch = gitOut(workspace, ['symbolic-ref', '--quiet', '--short', 'HEAD']); + const detached = !branch && Boolean(headSha); + + let branchKey = null; + if (branch) branchKey = branchKeyFor(branch); + else if (detached) branchKey = detachedKeyFor(headSha); + + const baseSha = headSha ? mergeBaseWithDefault(workspace, resolveDefaultBranch(workspace, { home })) : null; + + return { branch: branch || null, branchKey, worktree, detached, headSha: headSha || null, baseSha }; +} diff --git a/packages/harness/lib/knowledge/admin.mjs b/packages/harness/lib/knowledge/admin.mjs index 69f18e1c..f681d1f9 100644 --- a/packages/harness/lib/knowledge/admin.mjs +++ b/packages/harness/lib/knowledge/admin.mjs @@ -25,6 +25,7 @@ import { } from './store.mjs'; import { rebuildIndex, todayClamped } from './apply.mjs'; import { consolidateStatus, LEARNING_BYTE_CAP, isActiveFm } from './consolidate.mjs'; +import { listBuckets, branchesRoot } from './overlay.mjs'; import { scanSecrets } from '../secret-scan.mjs'; import { assertNoSymlinkAncestors, assertRealpathContained, writeFileContained } from '../fs-safe.mjs'; import { runIndexKnowledge } from '../index-knowledge.mjs'; @@ -244,7 +245,11 @@ export function absorbHandEdits({ workspace, home, log = () => {} }) { const { status: code, path: rel } = parsePorcelainLine(line); const m = LEARNING_FILE_RE.exec(rel); if (!m) continue; // non-learning file — left for the normal commit - const [, domain, slug] = m; + // Bucket capture (blueprint §5a): a hand edit under + // branches//learnings/** absorbs exactly like a golden one; the + // bucket key is recorded in the snapshot frontmatter below so the + // provenance names which layer the human touched. + const [, bucketKey, domain, slug] = m; const id = `${domain}/${slug}`; if (code.includes('D')) { @@ -273,6 +278,10 @@ export function absorbHandEdits({ workspace, home, log = () => {} }) { `date: ${at}`, `trigger: ${yamlQuote(trigger)}`, ]; + // Layer provenance (blueprint §5a): a bucket hand edit's snapshot names + // its bucket so a later rebuild routes the human authority back to the + // branch layer it was taught in, never silently into golden. + if (bucketKey) fmLines.push(`bucket: ${yamlQuote(bucketKey)}`); const doc = `---\n${fmLines.join('\n')}\n---\n\n${body.trim()}\n`; let snapshot = null; @@ -707,10 +716,17 @@ export function purgeEpisode({ workspace, target, copilotHome, home, log = () => // bail with zero side effects (no commit) instead of reporting a false // "pass" for a target nothing ever cited. Read fresh, under the lock — // not before it — so this can never validate against a stale snapshot - // another writer has since moved past. - const matchingLearnings = listLearnings(dir).filter((l) => (l.fm.episodes || []).some((e) => e.path === target)); - const ledger = readLedger(dir); - const ledgerHits = ledger.filter((e) => e.path === target).length; + // another writer has since moved past. The cascade is LAYER-AWARE + // (blueprint §5a): every layer root — golden plus every branch bucket — + // is scanned; human deletion always wins in every layer. + const roots = [dir, ...listBuckets(dir).map((b) => b.dir)]; + const matchingByRoot = roots.map((root) => ({ + root, + learnings: listLearnings(root).filter((l) => (l.fm.episodes || []).some((e) => e.path === target)), + ledger: readLedger(root), + })); + const matchingLearnings = matchingByRoot.flatMap((m) => m.learnings); + const ledgerHits = matchingByRoot.reduce((n, m) => n + m.ledger.filter((e) => e.path === target).length, 0); // Debris (a prior crash's stranded staging temp) also counts as "something // to purge": bailing here would leave that content on disk while reporting @@ -728,49 +744,56 @@ export function purgeEpisode({ workspace, target, copilotHome, home, log = () => const removedLearnings = []; const removedLinks = []; - for (const l of matchingLearnings) { - const episodes = l.fm.episodes || []; - // Decide by the post-filter count, not the pre-filter episode count: a - // learning can cite the same path twice with different sha256 values - // (ADD then STRENGTHEN after the episode file was edited), so "one - // episode total" is not the same thing as "one episode after this path - // is removed" — removeEpisodeLink strips every link to `target` - // regardless of sha256, so this must match that filter exactly. - const remaining = episodes.filter((e) => e.path !== target); - if (remaining.length === 0) { - // No evidence left once every link to this path is gone. - fs.rmSync(l.file, { force: true }); - removedLearnings.push(l.id); - } else { - removeEpisodeLink(l.file, target); - removedLinks.push(l.id); + let ledgerRemoved = 0; + for (const m of matchingByRoot) { + for (const l of m.learnings) { + const episodes = l.fm.episodes || []; + // Decide by the post-filter count, not the pre-filter episode count: a + // learning can cite the same path twice with different sha256 values + // (ADD then STRENGTHEN after the episode file was edited), so "one + // episode total" is not the same thing as "one episode after this path + // is removed" — removeEpisodeLink strips every link to `target` + // regardless of sha256, so this must match that filter exactly. + const remaining = episodes.filter((e) => e.path !== target); + if (remaining.length === 0) { + // No evidence left once every link to this path is gone. + fs.rmSync(l.file, { force: true }); + removedLearnings.push(l.id); + } else { + removeEpisodeLink(l.file, target); + removedLinks.push(l.id); + } + } + const keptLedger = m.ledger.filter((e) => e.path !== target); + if (keptLedger.length !== m.ledger.length) { + fs.writeFileSync( + path.join(m.root, 'consolidated.jsonl'), + keptLedger.length ? keptLedger.map((e) => JSON.stringify(e)).join('\n') + '\n' : '', + 'utf8' + ); + ledgerRemoved += m.ledger.length - keptLedger.length; } + if (m.learnings.length) rebuildIndex(m.root); } - - const keptLedger = ledger.filter((e) => e.path !== target); - fs.writeFileSync( - path.join(dir, 'consolidated.jsonl'), - keptLedger.length ? keptLedger.map((e) => JSON.stringify(e)).join('\n') + '\n' : '', - 'utf8' - ); + rebuildIndex(dir); // Governance record (Milestone 4): a fully cascade-deleted learning's - // history is dropped too — nothing left for those records to govern — - // while a merely delinked (removedLinks) learning's governance history is - // untouched, since the learning itself still exists. + // history is dropped too — but ONLY once the id survives in NO layer + // (blueprint §5a): a bucket copy removed while a golden twin (or another + // bucket's copy) still exists must keep its governance history, since the + // surviving learning is still governed by it. if (removedLearnings.length) { - const removedIds = new Set(removedLearnings); - rewriteGovernance(dir, (e) => !removedIds.has(e.id)); + const survivingIds = new Set(roots.flatMap((root) => listLearnings(root).map((l) => l.id))); + const fullyGone = new Set(removedLearnings.filter((id) => !survivingIds.has(id))); + if (fullyGone.size) rewriteGovernance(dir, (e) => !fullyGone.has(e.id)); } - rebuildIndex(dir); - return { kind: 'success', commitMessage: `purge: ${target}`, removedLearnings, removedLinks, - ledgerRemoved: ledger.length - keptLedger.length, + ledgerRemoved, }; }); @@ -950,6 +973,13 @@ export function purgeAll({ workspace, home, log = () => {} }) { fs.rmSync(dPath, { recursive: true, force: true }); } } + // Layer cascade (blueprint §5a): purge --all wipes `branches/` whole — + // human deletion always wins in every layer; bucket learnings count + // toward the removal total too. + for (const bucket of listBuckets(dir)) { + n += listLearnings(bucket.dir).length; + } + fs.rmSync(branchesRoot(dir), { recursive: true, force: true }); fs.writeFileSync(path.join(dir, 'consolidated.jsonl'), '', 'utf8'); // Truncate rather than rewriteGovernance(dir, () => false): purge --all // erases the entire store, so there is no surviving id left for a @@ -1058,7 +1088,6 @@ export function rebuildStore({ workspace, home, yes, copilotHome, log = () => {} // idsBeforeReset: mirrorLearnings needs these ids named explicitly via // retiredIds since the store itself forgets them the instant the wipe runs. const archivedLearnings = listLearnings(dir); - const archived = archivedLearnings.length; const learningsDir = path.join(dir, 'learnings'); if (fs.existsSync(learningsDir)) { @@ -1068,6 +1097,22 @@ export function rebuildStore({ workspace, home, yes, copilotHome, log = () => {} } } fs.writeFileSync(path.join(dir, 'consolidated.jsonl'), '', 'utf8'); + // Per-layer rebuild (blueprint §5a): every bucket's learnings and ledger + // are wiped too — bucket meta.json survives as the layer's identity — so + // each lane re-derives from raw episodes routed by their `branch:` + // provenance (episodeEligibleForLayer): golden consolidation on the + // default branch takes only default-branch episodes, each branch lane + // takes its own plus provenance-less ones. Nothing is laundered into + // golden by the wipe itself. + let archivedBranch = 0; + for (const bucket of listBuckets(dir)) { + archivedBranch += listLearnings(bucket.dir).length; + fs.rmSync(path.join(bucket.dir, 'learnings'), { recursive: true, force: true }); + fs.mkdirSync(path.join(bucket.dir, 'learnings'), { recursive: true }); + fs.writeFileSync(path.join(bucket.dir, 'consolidated.jsonl'), '', 'utf8'); + rebuildIndex(bucket.dir); + } + const archived = archivedLearnings.length + archivedBranch; rebuildIndex(dir); fs.rmSync(path.join(dir, 'stale.json'), { force: true }); return { diff --git a/packages/harness/lib/knowledge/apply.mjs b/packages/harness/lib/knowledge/apply.mjs index 2e16e5ea..9f11a774 100644 --- a/packages/harness/lib/knowledge/apply.mjs +++ b/packages/harness/lib/knowledge/apply.mjs @@ -19,11 +19,16 @@ import { appendGovernance, serializeLearning, inertLine, + provenanceLines, + provenanceBytes, } from './store.mjs'; +import { deriveGitContext } from '../git-context.mjs'; import { MAX_OPS_PER_RUN, LEARNING_BYTE_CAP, QUARANTINE_THRESHOLD, DOMAIN_ACTIVE_CAP, isActiveFm, collectEpisodes, splitLedger } from './consolidate.mjs'; import { scanSecrets } from '../secret-scan.mjs'; import { absorbOrAbort, mirrorLearnings } from './admin.mjs'; import { parseMergedFrom } from './listing.mjs'; +import { resolveWriteLayer, ensureBucket, migrateRenamedBucket, episodeEligibleForLayer, storeHasBuckets } from './layer.mjs'; +import { bucketDirFor } from './overlay.mjs'; import { readFileNoFollow, assertNoSymlinkAncestors } from '../fs-safe.mjs'; /** @@ -238,7 +243,7 @@ function extractAnchors({ workspace, copilotHome, episodes }) { return [...found].sort().slice(0, ANCHOR_CAP); } -function renderLearning({ trigger, body, episodes, anchors = [], origin, status, source, supersededBy, mergedFrom, promotedTo }) { +function renderLearning({ trigger, body, episodes, anchors = [], origin, status, source, supersededBy, mergedFrom, promotedTo, provenance }) { const lines = [ '---', 'schema: 1', @@ -264,6 +269,10 @@ function renderLearning({ trigger, body, episodes, anchors = [], origin, status, if (mergedFrom?.length) lines.push(`merged_from: [${mergedFrom.join(', ')}]`); if (promotedTo) lines.push(`promoted_to: ${promotedTo}`); lines.push(`origin: ${origin}`); + // Git provenance (blueprint P1/P9) — same shared rendering serializeLearning + // (store.mjs) uses, so a fresh write and a round-trip re-render emit + // byte-identical provenance lines. Optional: absent fields render nothing. + lines.push(...provenanceLines(provenance || {})); lines.push('---', '', body.trim(), ''); return lines.join('\n'); } @@ -615,6 +624,10 @@ export function applyOps({ // lane. Never derived from anything in the ops JSON itself: a model can // never grant this to itself by asserting a field. humanPresent = false, + // `--layer golden` override (blueprint P4): explicit, logged. Any other + // value is ignored — routing is otherwise always derived from write-time + // git context, never from a flag. + layer = null, }) { // Kill switch: consolidate is a write path gated to mode 'on' — checked // first, before the ops file is even parsed, and before any lock. This is a @@ -649,6 +662,37 @@ export function applyOps({ if (parsed.schema !== 1 || !Array.isArray(parsed.ops)) { return { applied: [], governed: [], rejected: [fail('E_SCHEMA', 'ops file must be { schema: 1, ops: [...] }')], committed: false, exitCode: 1 }; } + + // PROMOTION LANE (blueprint §5): an op-set emitted by `harness knowledge + // promote` carries a `promotion` envelope naming the source bucket and a + // digest binding over the ops array. In this mode: writes land GOLDEN + // regardless of the current branch; candidacy/kind checks are replaced by + // re-validation against the sha256s recorded at branch-apply time (never + // working-tree presence); rejections NEVER record quarantine strikes (a + // distinct rejection class — the underlying episodes are not defective); + // and every promoted source is tombstoned `promoted_to_golden` with an + // `absorb-branch` audit entry (which the governance replay never treats as + // a standing decision — see readGovernance). + const promotion = + parsed.promotion && typeof parsed.promotion === 'object' && !Array.isArray(parsed.promotion) ? parsed.promotion : null; + const promotionMode = Boolean(promotion); + if (promotionMode) { + if (typeof promotion.branchKey !== 'string' || !promotion.branchKey || /[\\/]|\.\./.test(promotion.branchKey)) { + return { applied: [], governed: [], rejected: [fail('E_SCHEMA', 'promotion envelope needs a plain branchKey')], committed: false, exitCode: 1 }; + } + const digest = crypto.createHash('sha256').update(JSON.stringify(parsed.ops)).digest('hex'); + if (digest !== promotion.digest) { + // Digest binding: a hand-edited promote-ops file must be regenerated, + // never partially applied. Plain rejection — promotion class, no strike. + return { + applied: [], + governed: [], + rejected: [fail('E_SCHEMA', 'promotion op-set digest mismatch — regenerate with: harness knowledge promote')], + committed: false, + exitCode: 1, + }; + } + } // Non-object entries (null, a bare string, a number, ...) must never reach // an `op.op` deref — checked over the WHOLE array up front, before // anything else (including the file-touch-count reduce below, which derefs @@ -674,6 +718,23 @@ export function applyOps({ const origin = repoId(workspace); + // Write-time git provenance (blueprint P1/P9): derived ONCE per run from the + // CURRENT workspace HEAD — never from anything the ops JSON asserts — and + // stamped on every fresh ADD/SUPERSEDE/MERGE write. STRENGTHEN re-renders + // preserve the target's ORIGINAL provenance instead (composeStrengthenedLearning), + // so a claim's recorded origin never silently migrates to the strengthening + // commit. All fields optional: a non-git workspace stamps nothing. + const gitContext = deriveGitContext({ workspace, home }); + const writeProvenance = { commit: gitContext.headSha, branch: gitContext.branch, base: gitContext.baseSha }; + + // Write-layer routing (blueprint P4): derived from git context AT WRITE + // TIME — feature branch → bucket, default branch → golden, detached HEAD → + // non-promotable detached bucket, `--layer golden` explicit override + // (logged), unresolvable default branch fails closed to branch-local. The + // orient-recorded branch is advisory; resolveWriteLayer logs a warning when + // write-time HEAD disagrees. + const routing = resolveWriteLayer({ workspace, home, layerOverride: layer === 'golden' ? 'golden' : null, log }); + /** * Everything from the store-state snapshot through the mutation phase, * factored into one function so it can run either: @@ -692,7 +753,40 @@ export function applyOps({ * withStoreTransaction perform the rollback. */ function runOnce({ dir, git, recordCheckpoint = () => {} }) { - const existing = new Map(listLearnings(dir).map((l) => [l.id, l])); + // Layer root: every learning read/write, ledger entry, strike, and INDEX + // rebuild below is anchored here — the store root for golden, the + // branch bucket for a routed branch write. Governance stays store-rooted + // (readGovernance below): the single ledger binds BOTH layers (§4). + let layerRoot = dir; + if (promotionMode) { + // Promotion always lands GOLDEN — that is its entire job — regardless + // of which branch the CLI happens to run from. + layerRoot = dir; + } else if (routing.layer === 'branch' && routing.bucketKey) { + if (dryRun) { + // Preview must not materialize a bucket — an absent bucket simply + // reads as an empty layer. + layerRoot = bucketDirFor(dir, routing.bucketKey); + } else { + // Best-effort rename auto-migration (P7) before creating a fresh + // bucket: a bucket keyed by this branch's PRE-RENAME name (branch now + // gone, base still ancestral) is moved to the new key instead of + // being stranded next to an empty twin. + migrateRenamedBucket(dir, { workspace, context: routing.context }); + layerRoot = ensureBucket(dir, { + key: routing.bucketKey, + branch: routing.context.branch, + baseSha: routing.context.baseSha, + }); + } + } + const existing = new Map(listLearnings(layerRoot).map((l) => [l.id, l])); + + // Promotion source table: the bucket learnings whose recorded evidence + // backs each promotion op. listLearnings on a missing bucket returns []. + const promotionSources = promotionMode + ? new Map(listLearnings(bucketDirFor(dir, promotion.branchKey)).map((l) => [l.id, l])) + : null; /** * Three-strikes bookkeeping (design §3): a content-failure code raised by a @@ -720,7 +814,11 @@ export function applyOps({ * past this strike commit — same reasoning as absorbOrAbort's own call. */ function recordContentFailure(code, episodes) { - if (dryRun || !git || !CONTENT_FAILURE_CODES.has(code)) return null; + // Promotion rejections NEVER record quarantine strikes (blueprint §5): + // promotion is a distinct rejection class, not a content failure of the + // underlying episodes — a repeatedly rejected promotion must never + // march innocent, branch-verified evidence toward quarantine. + if (dryRun || !git || promotionMode || !CONTENT_FAILURE_CODES.has(code)) return null; // Dedupe by path@sha256 before recording: an op citing the same episode // twice (a malformed or duplicated op JSON, not two distinct pieces of // evidence) must record ONE strike per run, not one per reference — a @@ -737,7 +835,7 @@ export function applyOps({ }); if (!eps.length) return null; try { - const ledger = readLedger(dir); + const ledger = readLedger(layerRoot); const at = todayClamped(); const entries = []; for (const e of eps) { @@ -747,7 +845,7 @@ export function applyOps({ entries.push({ path: e.path, sha256: e.sha256, quarantined: true, learning: null, at }); } } - appendLedger(dir, entries); + appendLedger(layerRoot, entries); const commitRes = commitStore(dir, `consolidate: record failure ${code}`); if (!commitRes.ok) { rollbackStore(dir); @@ -837,8 +935,27 @@ export function applyOps({ const candidateKeys = new Set(); { const onDisk = collectEpisodes({ workspace, copilotHome }); + // Consumption is layered: the golden ledger AND the current layer's + // bucket ledger both consume — an episode consolidated in either place + // can never mint a second learning from spent evidence in this lane. const { consumed } = splitLedger(readLedger(dir)); + if (layerRoot !== dir) { + for (const key of splitLedger(readLedger(layerRoot)).consumed) consumed.add(key); + } + // Per-layer eligibility (blueprint P4 + §5a): once the store HAS + // buckets, golden candidacy requires `branch:` provenance naming the + // resolved default branch — an episode from an unpromoted non-default + // branch, or one with NO provenance, routes to branch-local review and + // never silently into golden. A bucket-less store keeps pre-layer + // behavior byte-for-byte. + const eligibility = { + layer: routing.layer, + currentBranch: routing.context?.branch || null, + defaultBranchName: routing.defaultBranch?.name || null, + storeHasBuckets: storeHasBuckets(dir), + }; for (const e of onDisk) { + if (!episodeEligibleForLayer(e.branch, eligibility)) continue; const key = `${e.path}@${e.sha256}`; if (!consumed.has(key)) candidateKeys.add(key); } @@ -891,20 +1008,72 @@ export function applyOps({ } const bad = validateEpisodes(op.episodes, i); if (bad) return rejectOp(bad.code, bad.reason, op.episodes); - // Evidence-defect gate (see verifyAdmittedEpisodeKinds doc comment): - // every episode assertion — fix, insight, AND human-teaching — must - // disk-verify before anything downstream (gainedFix, verifiedFixLinks, - // promotion math, source/status derivation) ever trusts it. - const badKind = verifyAdmittedEpisodeKinds(workspace, copilotHome, op.episodes, i); - if (badKind) return rejectOp(badKind.code, badKind.reason, op.episodes); - // Candidate-set evidence gate (P1#3): the cited evidence must be a - // CURRENT unconsolidated candidate — not an episode already consumed by - // a prior consolidation (which would let an ADD mint a second learning - // from spent evidence). A STRENGTHEN/SUPERSEDE/MERGE re-citing its - // target's own already-consolidated evidence is exempt - // (episodesNeedingCandidacy); only its genuinely-new episodes are gated. - const notCandidate = assertCandidacy(op, i); - if (notCandidate) return rejectOp(notCandidate.code, notCandidate.reason, op.episodes); + if (promotionMode) { + // PROMOTION EXEMPTION (blueprint §5, normative): promotion ops are + // exempt from the golden candidacy check and the working-tree kind + // verification — their evidence was disk-verified (sha256) at + // BRANCH-APPLY time and is re-validated here from the hashes the + // bucket learning RECORDED, never from working-tree presence (the + // source files live on the source branch and may be absent from this + // checkout). Three bindings, all plain rejections (promotion class, + // never a strike): the source learning must exist in the named + // bucket, its file must hash to the sha recorded when the op-set was + // emitted (nothing changed since review), and every op episode must + // be one the source actually recorded. + const src = op.source && typeof op.source === 'object' ? op.source : null; + const sourceLearning = src && typeof src.id === 'string' ? promotionSources.get(src.id) : null; + if (!sourceLearning) { + return { + kind: 'reject', + applied: [], + governed: [], + rejected: [fail('E_SCHEMA', `op ${i}: promotion source ${src?.id || '(none)'} not found in bucket ${promotion.branchKey}`)], + committed: false, + exitCode: 1, + }; + } + const currentSha = crypto.createHash('sha256').update(fs.readFileSync(sourceLearning.file)).digest('hex'); + if (currentSha !== src.sha256) { + return { + kind: 'reject', + applied: [], + governed: [], + rejected: [fail('E_SCHEMA', `op ${i}: promotion source ${src.id} changed since the op-set was emitted — regenerate with: harness knowledge promote`)], + committed: false, + exitCode: 1, + }; + } + const recorded = new Set((sourceLearning.fm.episodes || []).map((e) => `${e.path}@${e.sha256}`)); + for (const e of op.episodes) { + if (!recorded.has(`${e.path}@${e.sha256}`)) { + return { + kind: 'reject', + applied: [], + governed: [], + rejected: [ + fail('E_SCHEMA', `op ${i}: episode ${e.path} was never recorded on the branch-applied source ${src.id} — promotion evidence re-validates from recorded hashes only`), + ], + committed: false, + exitCode: 1, + }; + } + } + } else { + // Evidence-defect gate (see verifyAdmittedEpisodeKinds doc comment): + // every episode assertion — fix, insight, AND human-teaching — must + // disk-verify before anything downstream (gainedFix, verifiedFixLinks, + // promotion math, source/status derivation) ever trusts it. + const badKind = verifyAdmittedEpisodeKinds(workspace, copilotHome, op.episodes, i); + if (badKind) return rejectOp(badKind.code, badKind.reason, op.episodes); + // Candidate-set evidence gate (P1#3): the cited evidence must be a + // CURRENT unconsolidated candidate — not an episode already consumed by + // a prior consolidation (which would let an ADD mint a second learning + // from spent evidence). A STRENGTHEN/SUPERSEDE/MERGE re-citing its + // target's own already-consolidated evidence is exempt + // (episodesNeedingCandidacy); only its genuinely-new episodes are gated. + const notCandidate = assertCandidacy(op, i); + if (notCandidate) return rejectOp(notCandidate.code, notCandidate.reason, op.episodes); + } // merged_from is only ever a MERGE-derived (op.targets) or ADD/SUPERSEDE- // carried-forward field — an op JSON asserting it directly must be an // array of strings, or renderLearning's `mergedFrom.join(', ')` throws on @@ -1325,8 +1494,20 @@ export function applyOps({ // so by this point every human-teaching assertion re-verifies; the // re-check here is defense in depth (this derivation never throws or // rejects the op, it just withholds the elevated standing). - const source = op.episodes.length && op.episodes.every((e) => verifyHumanTeachingEpisode(workspace, copilotHome, e)) ? 'human' : 'auto'; - const status = source === 'human' ? 'active' : 'provisional'; + let source = op.episodes.length && op.episodes.every((e) => verifyHumanTeachingEpisode(workspace, copilotHome, e)) ? 'human' : 'auto'; + let status = source === 'human' ? 'active' : 'provisional'; + let provenance = writeProvenance; + if (promotionMode) { + // The promoted claim carries the BRANCH-APPLIED derivation forward: + // the bucket write already derived source/status under the full + // writer rules, and the claim's git provenance names the branch that + // produced the evidence — promotion is a layer move, not a + // re-origination at the promoting commit. + const src = promotionSources.get(op.source.id); + source = src.fm.source || 'auto'; + status = isActiveFm(src.fm) && src.fm.status === 'active' ? 'active' : src.fm.status || 'provisional'; + provenance = { commit: src.fm.commit, branch: src.fm.branch, base: src.fm.base }; + } const content = renderLearning({ trigger: op.trigger, body: op.body, @@ -1337,8 +1518,14 @@ export function applyOps({ source, supersededBy: null, mergedFrom: op.op === 'MERGE' ? op.targets : op.merged_from, + provenance, }); - if (Buffer.byteLength(content, 'utf8') > LEARNING_BYTE_CAP) { + // Byte-cap decision (Phase 1, recorded in the plan's Implementation + // Notes): the cap measures the CLAIM, so the provenance frontmatter + // lines are excluded from the measured size — a near-cap learning + // gaining commit/branch/base can never hit E_BYTE_CAP (or a quarantine + // strike) purely from the stamp. + if (Buffer.byteLength(content, 'utf8') - provenanceBytes(provenance) > LEARNING_BYTE_CAP) { return rejectOp('E_BYTE_CAP', `${id} exceeds ${LEARNING_BYTE_CAP} bytes — split into two claims`, op.episodes); } writes.push({ op, id, domain, slug, content }); @@ -1364,7 +1551,11 @@ export function applyOps({ if (op.op !== 'STRENGTHEN') continue; const target = existing.get(op.target); const content = composeStrengthenedLearning(target, op.episodes, workspace, copilotHome); - if (Buffer.byteLength(content, 'utf8') > LEARNING_BYTE_CAP) { + // Same byte-cap decision as the fresh-write check above: the preserved + // provenance lines are excluded from the measured size, so a near-cap + // learning that carries commit/branch/base can still be strengthened + // without tripping E_BYTE_CAP on bookkeeping bytes. + if (Buffer.byteLength(content, 'utf8') - provenanceBytes(target.fm) > LEARNING_BYTE_CAP) { return rejectOp( 'E_BYTE_CAP', `${op.target} exceeds ${LEARNING_BYTE_CAP} bytes after strengthening — split into two claims or supersede`, @@ -1404,7 +1595,7 @@ export function applyOps({ const governanceAt = new Date().toISOString(); for (const { op, id, domain, slug, content } of writes) { - const file = path.join(dir, 'learnings', domain, `${slug}.md`); + const file = path.join(layerRoot, 'learnings', domain, `${slug}.md`); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, content, 'utf8'); applied.push({ op: op.op, id }); @@ -1460,7 +1651,7 @@ export function applyOps({ appendGovernance(dir, { id, action: 'confirm', reason: 'superseded by re-teach', to: null, at: governanceAt }); continue; } - const file = path.join(dir, 'learnings', domain, `${slug}.md`); + const file = path.join(layerRoot, 'learnings', domain, `${slug}.md`); if (entry.action === 'promote') { // promoted_to may be entirely absent from the just-written file — // the same parse -> mutate fm -> serializeLearning re-render @@ -1499,6 +1690,36 @@ export function applyOps({ for (const e of op.episodes) ledgerEntries.push({ path: e.path, sha256: e.sha256, learning: op.target, at }); } + // Promotion tombstones + audit ledger (blueprint §5): every successfully + // promoted source is stamped `promoted_to_golden:` in its bucket (a + // retrieval exclusion — the bucket entry stops shadowing the golden claim + // it just became) and an `absorb-branch` entry lands in the governance + // ledger for AUDIT ONLY — the replay rule (readGovernance) never lets it + // become an id's standing decision, so a standing retire recorded before + // a promotion still lands retired after any later rebuild. Once every + // source is tombstoned the bucket is prunable (knowledge status/prune). + if (promotionMode && !dryRun) { + const bucketRoot = bucketDirFor(dir, promotion.branchKey); + let touchedBucket = false; + for (const a of applied) { + if (!FILE_TOUCHING.has(a.op)) continue; + const src = promotionSources.get(a.id); + if (!src) continue; + const text = fs.readFileSync(src.file, 'utf8'); + const parsedSource = parseLearningFrontmatter(text); + fs.writeFileSync(src.file, serializeLearning({ ...parsedSource.fm, promoted_to_golden: a.id }, parsedSource.body), 'utf8'); + appendGovernance(dir, { + id: a.id, + action: 'absorb-branch', + reason: `promoted from ${promotion.branchKey}`, + to: null, + at: governanceAt, + }); + touchedBucket = true; + } + if (touchedBucket) rebuildIndex(bucketRoot); + } + for (const op of planned) { if (op.op === 'NOOP') { applied.push({ op: 'NOOP', id: op.reason || null }); @@ -1512,8 +1733,8 @@ export function applyOps({ rejected.push({ ...fail('E_DISPUTED', 'disputed-pending-human'), reason: 'disputed-pending-human', target: d.target }); } - if (ledgerEntries.length) appendLedger(dir, ledgerEntries); - rebuildIndex(dir); + if (ledgerEntries.length) appendLedger(layerRoot, ledgerEntries); + rebuildIndex(layerRoot); // An apply run whose only effect was disputing targets (no ADD/STRENGTHEN/ // SUPERSEDE/MERGE/NOOP actually applied) must not commit as "noop" — that @@ -1526,7 +1747,15 @@ export function applyOps({ : disputes.length ? `dispute ${disputes.map((d) => d.target).join(', ')}` : 'noop'; - return { kind: 'success', applied, rejected, governed, commitMessage: `consolidate: ${summary}` }; + return { + kind: 'success', + applied, + rejected, + governed, + layer: routing.layer, + bucketKey: routing.layer === 'branch' ? routing.bucketKey : null, + commitMessage: `consolidate: ${summary}${routing.layer === 'branch' ? ` [${routing.bucketKey}]` : ''}`, + }; } if (dryRun) { @@ -1627,6 +1856,9 @@ export function applyOps({ storeDir: tx.dir, indexPath: path.join(tx.dir, 'INDEX.md'), governed: inner.governed, + layer: inner.layer, + bucketKey: inner.bucketKey ?? null, + ...(routing.branchWarning ? { branchWarning: routing.branchWarning } : {}), ...staleExtra, }; } @@ -1684,6 +1916,11 @@ function composeStrengthenedLearning(target, episodes, workspace, copilotHome) { // promoted_to (if any) forward, unlike a fresh ADD/SUPERSEDE/MERGE write // which never starts out already promoted. promotedTo: fm.promoted_to || null, + // Preserve the learning's ORIGINAL git provenance across the re-render + // (blueprint P1): a STRENGTHEN adds evidence to an existing claim, it does + // not re-originate it. A legacy learning without the fields stays without + // them — provenanceLines renders nothing for absent values. + provenance: { commit: fm.commit, branch: fm.branch, base: fm.base }, }); return content; } diff --git a/packages/harness/lib/knowledge/consolidate.mjs b/packages/harness/lib/knowledge/consolidate.mjs index 966f8228..6ef57a47 100644 --- a/packages/harness/lib/knowledge/consolidate.mjs +++ b/packages/harness/lib/knowledge/consolidate.mjs @@ -3,6 +3,8 @@ import path from 'node:path'; import crypto from 'node:crypto'; import { storeDir, readLedger, listLearnings, readStoreConfig, readGovernance, inertLine } from './store.mjs'; import { readFileNoFollow, assertNoSymlinkAncestors } from '../fs-safe.mjs'; +import { resolveWriteLayer, episodeEligibleForLayer, storeHasBuckets } from './layer.mjs'; +import { bucketDirFor } from './overlay.mjs'; export const CONSOLIDATION_THRESHOLD = 5; export const MAX_OPS_PER_RUN = 5; @@ -141,6 +143,10 @@ export function collectEpisodes({ workspace, copilotHome }) { tags: fm.tags ? fm.tags.split(',').map((t) => t.trim()) : [], excerpt: excerpt(text), date: fm.date || null, + // Git provenance (blueprint P1/P4): the branch this episode was + // captured on, when its frontmatter recorded one. Layer routing + // (episodeEligibleForLayer) keys off this; absent = pre-provenance. + branch: fm.branch || null, }); } } @@ -231,14 +237,56 @@ export function promotionCandidates(learnings) { return out; } +/** + * Layer view for the read-side consolidation surfaces (--status / + * --candidates), mirroring applyOps' own write-time routing so the packet a + * skill reads and the write the CLI validates agree on layer, consumption, + * and per-layer episode eligibility (blueprint P4). A store without buckets + * short-circuits to the pre-layer golden view with zero git spawns. + */ +function layerView({ workspace, home, dir }) { + const hasBuckets = storeHasBuckets(dir); + let routing = null; + if (hasBuckets) { + try { + routing = resolveWriteLayer({ workspace, home }); + } catch { + routing = null; + } + } + const layer = routing?.layer === 'branch' ? 'branch' : 'golden'; + const bucketKey = layer === 'branch' ? routing.bucketKey : null; + const layerRoot = layer === 'branch' ? bucketDirFor(dir, bucketKey) : dir; + return { + layer, + bucketKey, + layerRoot, + eligibility: { + layer, + currentBranch: routing?.context?.branch || null, + defaultBranchName: routing?.defaultBranch?.name || null, + storeHasBuckets: hasBuckets, + }, + }; +} + export function consolidateStatus({ workspace, copilotHome, home }) { // Non-creating read: --status must never materialize a store that isn't // there yet — an absent store just reports empty ledger/learnings. const dir = storeDir(workspace, { home }); const { mode } = readStoreConfig(workspace, { home }); const episodes = collectEpisodes({ workspace, copilotHome }); + const view = layerView({ workspace, home, dir }); const { consumed, quarantined } = splitLedger(readLedger(dir)); + let layerQuarantined = []; + if (view.layer === 'branch') { + // The current bucket's ledger consumes (and quarantines) too. + const bucketSplit = splitLedger(readLedger(view.layerRoot)); + for (const key of bucketSplit.consumed) consumed.add(key); + layerQuarantined = bucketSplit.quarantined; + } const unconsolidated = episodes + .filter((e) => episodeEligibleForLayer(e.branch, view.eligibility)) .filter((e) => !consumed.has(`${e.path}@${e.sha256}`)) .map(({ path: p, sha256, kind, title }) => ({ path: p, sha256, kind, title })); const learnings = listLearnings(dir); @@ -253,11 +301,13 @@ export function consolidateStatus({ workspace, copilotHome, home }) { threshold: CONSOLIDATION_THRESHOLD, due, unconsolidated, - quarantined, + quarantined: [...quarantined, ...layerQuarantined], learnings: { active: active.length, total: learnings.length }, domains: domainPressure(learnings), promotionCandidates: promotionCandidates(learnings), storeDir: dir, + layer: view.layer, + ...(view.bucketKey ? { bucketKey: view.bucketKey } : {}), nextTools: due ? ['harness consolidate --candidates'] : [], }; } @@ -337,9 +387,14 @@ export function consolidateCandidates({ workspace, copilotHome, home }) { } // Non-creating read: --candidates must never materialize a store either — - // an absent store just means no active learnings to report. + // an absent store just means no active learnings to report. The learning + // list mirrors the ROUTED write layer (layerView) so the skill proposes + // STRENGTHEN/SUPERSEDE targets that actually exist where the apply will + // land — a branch lane lists the bucket's learnings, never golden ids the + // bucket write would E_TARGET on. const dir = storeDir(workspace, { home }); - const active = activeLearnings(listLearnings(dir)); + const view = layerView({ workspace, home, dir }); + const active = activeLearnings(listLearnings(view.layerRoot)); const totalBytes = active.reduce((n, l) => n + l.bytes, 0); const includeBodies = totalBytes <= LEARNING_BODY_BUDGET_BYTES; const learnings = active.map((l) => ({ @@ -383,6 +438,8 @@ export function consolidateCandidates({ workspace, copilotHome, home }) { domains: status.domains, governed, storeDir: dir, + layer: view.layer, + ...(view.bucketKey ? { bucketKey: view.bucketKey } : {}), // Only present when the episode section was actually bounded (P2) — a // packet under budget carries neither field, matching every other // optional-flag shape in this response (e.g. `body` above). diff --git a/packages/harness/lib/knowledge/eval.mjs b/packages/harness/lib/knowledge/eval.mjs index 22afe9cd..875f2258 100644 --- a/packages/harness/lib/knowledge/eval.mjs +++ b/packages/harness/lib/knowledge/eval.mjs @@ -1,7 +1,8 @@ import fs from 'node:fs'; -import { storeDir, listLearnings, readLedger, readStaleExclusions } from './store.mjs'; +import { storeDir, readLedger, readStaleExclusions } from './store.mjs'; import { collectEpisodes } from './consolidate.mjs'; import { rankLearnings, retrievalExclusion } from './retrieve.mjs'; +import { loadLayeredLearnings } from './overlay.mjs'; import { tokenize } from '../tokenize.mjs'; /** @@ -93,7 +94,11 @@ export function evalKnowledge({ workspace, copilotHome, home, negativeQueries = const train = dated.filter((e) => e.date <= cutoff); const heldOut = dated.filter((e) => e.date > cutoff); - const learnings = listLearnings(dir); + // Share the PRODUCTION candidate set (loadLayeredLearnings, overlay.mjs — + // the same golden ∪ branch-bucket overlay retrieve.mjs's rankLearnings + // loads through) AND the production eligibility gate (retrievalExclusion), + // so the eval measures only learnings a real orient could actually surface. + const learnings = loadLayeredLearnings({ workspace, home }).learnings; // Share the PRODUCTION retrieval eligibility gate (retrievalExclusion, // retrieve.mjs) so the eval measures only learnings a real orient could // actually surface — excluding promoted/superseded/retired/disputed AND diff --git a/packages/harness/lib/knowledge/layer.mjs b/packages/harness/lib/knowledge/layer.mjs new file mode 100644 index 00000000..8da1ea96 --- /dev/null +++ b/packages/harness/lib/knowledge/layer.mjs @@ -0,0 +1,195 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { deriveGitContext, resolveDefaultBranch, isDetachedKey } from '../git-context.mjs'; +import { branchesRoot, bucketDirFor, readBucketMeta, listBuckets, bucketAncestryOk } from './overlay.mjs'; +import { readSession } from '../session.mjs'; + +/** + * Layer-aware WRITE routing (blueprint P4, normative routing table): + * + * | Git context | Destination | + * |------------------------|------------------------------------------| + * | Feature branch | branch bucket (`branches//`) | + * | Default branch | golden | + * | Detached HEAD | `branches/detached-/` (never | + * | | promotable — derived from the key shape) | + * | `--layer golden` | golden (explicit override, logged) | + * | Non-git workspace | golden (no branch concept exists) | + * + * The layer is derived from git context AT WRITE TIME — the branch recorded + * at orient is advisory only; when the two disagree the routing result + * carries a warning the caller logs. Default-branch resolution follows + * git-context.mjs's normative order (store config.json `defaultBranch` → + * `origin/HEAD` → unresolved); when the default is UNRESOLVABLE on a real + * branch, routing fails closed TO BRANCH-LOCAL — never golden. + */ +export function resolveWriteLayer({ workspace, home, layerOverride = null, log = () => {} } = {}) { + const context = deriveGitContext({ workspace, home }); + const defaultBranch = resolveDefaultBranch(workspace, { home }); + + // Orient-recorded branch is advisory: warn when write-time HEAD disagrees. + let branchWarning = null; + try { + const session = readSession(workspace); + const oriented = session?.gitBranch || null; + const current = context.branch || (context.detached ? '(detached)' : null); + if (oriented && current && oriented !== current) { + branchWarning = `oriented on branch ${oriented} but writing from ${current} — layer routed from the write-time HEAD`; + log(branchWarning); + } + } catch { + branchWarning = null; + } + + if (layerOverride === 'golden') { + log('layer override: --layer golden (explicit) — writing to the golden layer'); + return { layer: 'golden', bucketKey: null, context, defaultBranch, override: true, branchWarning }; + } + + if (context.detached) { + return { layer: 'branch', bucketKey: context.branchKey, context, defaultBranch, detached: true, branchWarning }; + } + if (!context.branch) { + // Non-git workspace (or unborn detached state with no commit): no branch + // concept exists, so the pre-layer behavior — golden — stands. + return { layer: 'golden', bucketKey: null, context, defaultBranch, branchWarning }; + } + if (defaultBranch && context.branch === defaultBranch.name) { + return { layer: 'golden', bucketKey: null, context, defaultBranch, branchWarning }; + } + // Feature branch — or a real branch with an UNRESOLVABLE default, which + // fails closed to branch-local (never golden). + return { + layer: 'branch', + bucketKey: context.branchKey, + context, + defaultBranch, + failedClosed: !defaultBranch, + branchWarning, + }; +} + +/** + * Create (or refresh the cache of) a branch bucket inside an already-locked + * store transaction. meta.json is a CACHE, never authority — promotability is + * derived from the key shape at decision time (`detached-*` never + * promotable); the recorded flag is display convenience only. + */ +export function ensureBucket(dir, { key, branch = null, baseSha = null }) { + const bucketDir = bucketDirFor(dir, key); + fs.mkdirSync(path.join(bucketDir, 'learnings'), { recursive: true }); + const ledgerPath = path.join(bucketDir, 'consolidated.jsonl'); + if (!fs.existsSync(ledgerPath)) fs.writeFileSync(ledgerPath, '', 'utf8'); + const indexPath = path.join(bucketDir, 'INDEX.md'); + if (!fs.existsSync(indexPath)) { + fs.writeFileSync(indexPath, '# Learnings Index (branch bucket)\n\n_Rebuilt by `harness consolidate --apply`._\n', 'utf8'); + } + const metaPath = path.join(bucketDir, 'meta.json'); + if (!fs.existsSync(metaPath)) { + const meta = { + branch, + branchKey: key, + baseSha, + createdAt: new Date().toISOString(), + promotable: !isDetachedKey(key), + }; + fs.writeFileSync(metaPath, JSON.stringify(meta) + '\n', 'utf8'); + } + return bucketDir; +} + +/** Every ref name (`refs/heads/...`, `refs/remotes/...`) in the workspace. */ +function listRefs(workspace) { + try { + const res = spawnSync('git', ['show-ref'], { cwd: workspace, encoding: 'utf8', timeout: 10_000 }); + if (res.status !== 0) return null; + return res.stdout + .split('\n') + .filter(Boolean) + .map((l) => l.split(' ')[1]) + .filter(Boolean); + } catch { + return null; + } +} + +/** True when `branch` exists locally or on any remote; null when git state is + * unreadable (callers treat null as "cannot verify", never as missing). */ +export function branchExists(workspace, branch) { + if (!branch) return null; + const refs = listRefs(workspace); + if (refs === null) return null; + return refs.some((r) => r === `refs/heads/${branch}` || (r.startsWith('refs/remotes/') && r.endsWith(`/${branch}`))); +} + +/** + * Best-effort branch-rename auto-migration (blueprint P7), run inside the + * write transaction when routing targets a bucket that does not exist yet: + * when exactly ONE existing bucket names a branch that no longer exists + * locally or on any remote AND its recorded base is an ancestor of the + * current HEAD, that bucket is renamed to the new key and its meta cache + * rewritten. Anything ambiguous (zero or several candidates, unverifiable + * git state, detached buckets) is left untouched — the orphan surfaces via + * `knowledge status` and doctor K5 for manual prune or migrate. + */ +export function migrateRenamedBucket(dir, { workspace, context }) { + if (!context?.branchKey || !context.branch) return null; + if (fs.existsSync(bucketDirFor(dir, context.branchKey))) return null; + const candidates = []; + for (const bucket of listBuckets(dir)) { + if (isDetachedKey(bucket.key)) continue; + const branch = bucket.meta?.branch; + if (!branch || branch === context.branch) continue; + if (branchExists(workspace, branch) !== false) continue; // exists or unverifiable — not a rename candidate + if (bucketAncestryOk(workspace, bucket.meta) !== true) continue; // unrelated or unverifiable history + candidates.push(bucket); + } + if (candidates.length !== 1) return null; + const [source] = candidates; + const target = bucketDirFor(dir, context.branchKey); + try { + fs.renameSync(source.dir, target); + const meta = readBucketMeta(target) || {}; + fs.writeFileSync( + path.join(target, 'meta.json'), + JSON.stringify({ ...meta, branch: context.branch, branchKey: context.branchKey }) + '\n', + 'utf8' + ); + return { migrated: true, from: source.key, to: context.branchKey }; + } catch { + return null; + } +} + +/** + * Per-layer episode eligibility (blueprint P4 + §5a rebuild routing), applied + * to consolidation candidacy once a store HAS buckets: + * + * - GOLDEN lane: only episodes whose `branch:` provenance names the + * resolved default branch are eligible. An episode naming an unpromoted + * non-default branch is skipped (merged evidence never becomes a golden + * claim without the explicit promotion step), and an episode WITHOUT + * provenance routes to branch-local review — never silently golden. + * - BRANCH lane: episodes from the CURRENT branch plus provenance-less + * episodes (the branch-local review destination) are eligible; episodes + * naming a DIFFERENT branch are that branch's business. + * + * A store with no buckets predates the layer model: everything stays + * eligible, preserving pre-layer behavior byte-for-byte. + */ +export function episodeEligibleForLayer(episodeBranch, { layer, currentBranch, defaultBranchName, storeHasBuckets }) { + if (!storeHasBuckets) return true; + if (layer === 'golden') { + return Boolean(episodeBranch) && Boolean(defaultBranchName) && episodeBranch === defaultBranchName; + } + return !episodeBranch || episodeBranch === currentBranch; +} + +export function storeHasBuckets(dir) { + try { + return fs.existsSync(branchesRoot(dir)) && listBuckets(dir).length > 0; + } catch { + return false; + } +} diff --git a/packages/harness/lib/knowledge/overlay.mjs b/packages/harness/lib/knowledge/overlay.mjs new file mode 100644 index 00000000..225054ac --- /dev/null +++ b/packages/harness/lib/knowledge/overlay.mjs @@ -0,0 +1,215 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { storeDir, listLearnings, readGovernance } from './store.mjs'; +import { deriveGitContext } from '../git-context.mjs'; + +/** + * The layered read path (harness evolution blueprint §4) — ONE exported + * overlay shared by production retrieval (retrieve.mjs) and the knowledge + * eval (eval.mjs), so the two can never drift on which learnings are + * candidates (the same reason retrievalExclusion is shared). + * + * Candidate set = golden actives ∪ current branch-key bucket actives, with + * the approval-condition gates applied here — never bypassable downstream: + * + * 1. PROTECTED-SHADOW: a branch-local learning with the same id REPLACES the + * golden claim UNLESS the golden claim is protected (≥3 verified + * `kind: fix` episode links or `source: human`) — a protected claim is + * never shadowed; the branch-local claim stays as an additional, + * SUBORDINATE entry instead. This mirrors the write path's + * protected-target rule so the read path cannot bypass a gate the writer + * enforces. + * 2. GOVERNANCE BINDS BOTH LAYERS: an id under a standing + * retire/dispute/promote decision (readGovernance replay) is NEVER + * surfaced from a bucket — reusing a governed id in a bucket triggers the + * standing decision, it does not escape it. + * 3. ANCESTRY (P7, branch-name reuse): a bucket whose recorded meta.baseSha + * is not an ancestor of the current HEAD (force-push name reuse with + * unrelated history) is excluded from the overlay entirely and surfaced + * by `knowledge status`. + * + * Branch-layer entries carry `layer: 'branch'` (plus `subordinate: true` in + * the protected-shadow case); golden entries are returned EXACTLY as + * listLearnings hands them back — no extra fields — so with no `branches/` + * directory the output is byte-identical to pre-overlay behavior (regression- + * tested). Ranking uses the layer as a tiebreak BEFORE the id tiebreak: + * branch-local wins equal-score ties, except a subordinate entry never + * outranks the protected golden claim it shadows. + */ + +const GOVERNED_EXCLUSION_ACTIONS = new Set(['retire', 'dispute', 'promote']); +const PROTECTED_FIX_THRESHOLD = 3; +const SHA_RE = /^[0-9a-f]{40}$/; + +/** The write path's protected-target predicate (apply.mjs's + * isDisputedTargetFm), re-stated for the read path: too well-evidenced or + * human-taught to be displaced without a human. */ +export function isProtectedFm(fm) { + const fixes = (fm.episodes || []).filter((e) => e.kind === 'fix').length; + return fixes >= PROTECTED_FIX_THRESHOLD || fm.source === 'human'; +} + +export function branchesRoot(dir) { + return path.join(dir, 'branches'); +} + +export function bucketDirFor(dir, key) { + return path.join(branchesRoot(dir), key); +} + +/** Parsed bucket meta.json, or null. Meta is a CACHE, never authority — + * promotability and detachment are re-derived from the key shape at decision + * time; meta only carries display/ancestry hints. */ +export function readBucketMeta(bucketDir) { + try { + const parsed = JSON.parse(fs.readFileSync(path.join(bucketDir, 'meta.json'), 'utf8')); + return parsed && typeof parsed === 'object' ? parsed : null; + } catch { + return null; + } +} + +/** Every bucket under `branches/`, sorted by key: `[{ key, dir, meta }]`. */ +export function listBuckets(dir) { + const root = branchesRoot(dir); + const out = []; + let entries; + try { + entries = fs.readdirSync(root, { withFileTypes: true }); + } catch { + return out; + } + for (const e of entries) { + if (!e.isDirectory()) continue; + const bucketDir = path.join(root, e.name); + out.push({ key: e.name, dir: bucketDir, meta: readBucketMeta(bucketDir) }); + } + return out.sort((a, b) => a.key.localeCompare(b.key)); +} + +/** + * Ancestry gate for a bucket's recorded baseSha against the workspace HEAD. + * `true` = verified ancestor; `false` = verified NOT an ancestor (or the sha + * is unknown to this repo — force-push name reuse); `null` = unverifiable + * (no recorded base, or git itself unavailable) — unverifiable buckets stay + * included, since exclusion is a defense against PROVEN unrelated history, + * not against missing metadata on a legacy bucket. + */ +export function bucketAncestryOk(workspace, meta) { + if (!meta || typeof meta.baseSha !== 'string' || !SHA_RE.test(meta.baseSha)) return null; + try { + const res = spawnSync('git', ['merge-base', '--is-ancestor', meta.baseSha, 'HEAD'], { + cwd: workspace, + encoding: 'utf8', + timeout: 10_000, + }); + if (res.error) return null; + return res.status === 0 ? true : false; + } catch { + return null; + } +} + +/** + * The overlay itself. Returns `{ learnings, layered, context }`: + * `learnings` is the merged candidate list (sorted by id — the same order + * listLearnings uses); `layered` is true only when a current-branch bucket + * actually contributed; `context` is the derived git context (null on the + * no-branches fast path, where git is never invoked at all). + * + * Read-only and tolerant like every retrieval read: a missing store, missing + * bucket, or non-git workspace degrades to golden-only, never a throw. + */ +export function loadLayeredLearnings({ workspace, home } = {}) { + let dir; + try { + dir = storeDir(workspace, { home }); + } catch { + return { learnings: [], layered: false, context: null }; + } + if (!fs.existsSync(dir)) return { learnings: [], layered: false, context: null }; + const golden = listLearnings(dir); + + // Fast path — no branches/ directory: golden only, byte-identical to + // pre-overlay behavior, zero git spawns. + if (!fs.existsSync(branchesRoot(dir))) return { learnings: golden, layered: false, context: null }; + + let context = null; + try { + context = deriveGitContext({ workspace, home }); + } catch { + context = null; + } + if (!context?.branchKey) return { learnings: golden, layered: false, context }; + + const bucketDir = bucketDirFor(dir, context.branchKey); + if (!fs.existsSync(path.join(bucketDir, 'learnings'))) { + return { learnings: golden, layered: false, context }; + } + + // Ancestry gate (P7): a bucket whose recorded base provably shares no + // history with the current HEAD is a name-reuse artifact — excluded whole. + const meta = readBucketMeta(bucketDir); + if (bucketAncestryOk(workspace, meta) === false) { + return { learnings: golden, layered: false, context, excludedBucket: { key: context.branchKey, reason: 'ancestry' } }; + } + + let bucketLearnings; + try { + bucketLearnings = listLearnings(bucketDir); + } catch { + return { learnings: golden, layered: false, context }; + } + if (!bucketLearnings.length) return { learnings: golden, layered: false, context }; + + const governance = readGovernance(dir); + const goldenById = new Map(golden.map((l) => [l.id, l])); + const merged = new Map(goldenById); + + for (const b of bucketLearnings) { + // Candidate set = golden actives ∪ branch ACTIVES (§4): an inactive or + // tombstoned bucket entry (superseded, retired/disputed, promoted, or + // `promoted_to_golden` after the §5 promotion) must never enter the set — + // letting it SHADOW a golden twin and then be excluded downstream would + // hide the golden claim entirely. + if ( + b.fm.superseded_by || + b.fm.promoted_to || + b.fm.promoted_to_golden || + ['retired', 'disputed'].includes(b.fm.status) + ) { + continue; + } + // Governance binds both layers: a standing retire/dispute/promote on this + // id is never escaped by re-minting it in a bucket. + const decision = governance.get(b.id); + if (decision && GOVERNED_EXCLUSION_ACTIONS.has(decision.action)) continue; + + const goldenTwin = goldenById.get(b.id); + if (goldenTwin && isProtectedFm(goldenTwin.fm)) { + // Protected golden is never shadowed — the branch claim rides along as + // a subordinate sibling. Two entries share the id; the subordinate one + // loses every tie to its protected twin (rank tiebreak). + merged.set(`${b.id}#branch`, { ...b, layer: 'branch', subordinate: true }); + continue; + } + // Shadow (or plain addition): the branch-local claim IS the candidate. + merged.set(b.id, { ...b, layer: 'branch' }); + } + + const learnings = [...merged.values()].sort((a, b) => a.id.localeCompare(b.id) || (a.subordinate ? 1 : 0) - (b.subordinate ? 1 : 0)); + return { learnings, layered: true, context }; +} + +/** + * Layer tiebreak rank for result sorting — applied BEFORE the id tiebreak + * (blueprint §4): branch-local (0) wins equal-score ties over golden (1); + * a subordinate branch entry (2) never outranks the protected golden claim + * it shadows. Entries without layer fields (the no-bucket path) all rank 1, + * leaving the historical `score desc, id asc` order byte-identical. + */ +export function layerTieRank(entry) { + if (entry?.subordinate) return 2; + return entry?.layer === 'branch' ? 0 : 1; +} diff --git a/packages/harness/lib/knowledge/promote.mjs b/packages/harness/lib/knowledge/promote.mjs new file mode 100644 index 00000000..3b7cb2f8 --- /dev/null +++ b/packages/harness/lib/knowledge/promote.mjs @@ -0,0 +1,155 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { storeDir, listLearnings, readGovernance } from './store.mjs'; +import { isActiveFm, MAX_OPS_PER_RUN } from './consolidate.mjs'; +import { bucketDirFor, readBucketMeta, listBuckets } from './overlay.mjs'; +import { deriveGitContext, isDetachedKey } from '../git-context.mjs'; + +/** + * `harness knowledge promote` (blueprint §5): emits a REVIEWABLE op-set at + * the distinct path `.harness/promote-ops.json` — it never writes the store + * itself. The op-set is applied only through `consolidate --apply` running in + * promotion mode (apply.mjs recognizes the `promotion` envelope), where the + * §5 mechanics bind: candidacy exemption backed by the sha256s recorded at + * branch-apply time, a never-strike rejection class, the protected-target + * dispute rule, and the `absorb-branch` audit ledger action. + * + * Shadow mapping: a branch claim whose golden twin carries the SAME trigger + * and body is an episodes-only overlap → STRENGTHEN (just the new evidence); + * any other same-id twin → SUPERSEDE (the branch claim becomes the + * authoritative version); no twin → ADD. + * + * `--all` chunks under MAX_OPS_PER_RUN with deterministic id ordering as the + * cursor — the emitted set is always the first chunk; `remaining: N` reports + * what a later run will pick up once these land and are tombstoned. + */ + +export const PROMOTE_OPS_REL = path.join('.harness', 'promote-ops.json'); + +export function promotionDigest(ops) { + return crypto.createHash('sha256').update(JSON.stringify(ops)).digest('hex'); +} + +export function buildPromotionOps({ workspace, home, branchKey = null, ids = null, all = false, log = () => {} } = {}) { + const dir = storeDir(workspace, { home }); + if (!fs.existsSync(dir)) { + return { pass: false, exitCode: 2, opsPath: null, ops: 0, remaining: 0, skipped: [], blockedReason: 'no knowledge store — nothing to promote' }; + } + + // Resolve the source bucket: explicit --branch key, else the current + // branch's bucket from write-time git context. + let key = branchKey; + if (!key) { + try { + key = deriveGitContext({ workspace, home }).branchKey; + } catch { + key = null; + } + } + if (!key) { + return { pass: false, exitCode: 2, opsPath: null, ops: 0, remaining: 0, skipped: [], blockedReason: 'no branch bucket resolvable — pass --branch (see harness knowledge status)' }; + } + if (isDetachedKey(key)) { + return { pass: false, exitCode: 2, opsPath: null, ops: 0, remaining: 0, skipped: [], blockedReason: `${key} is a detached-HEAD bucket — never promotable (derived from the key shape)` }; + } + const bucketDir = bucketDirFor(dir, key); + if (!fs.existsSync(path.join(bucketDir, 'learnings'))) { + const known = listBuckets(dir).map((b) => b.key); + return { + pass: false, + exitCode: 2, + opsPath: null, + ops: 0, + remaining: 0, + skipped: [], + blockedReason: `no bucket ${key}${known.length ? ` — known buckets: ${known.join(', ')}` : ' — no buckets exist yet'}`, + }; + } + + const requested = ids && ids.length ? new Set(ids) : null; + if (!requested && !all) { + return { pass: false, exitCode: 2, opsPath: null, ops: 0, remaining: 0, skipped: [], blockedReason: 'promote needs --ids a,b or --all' }; + } + + const governance = readGovernance(dir); + const goldenById = new Map(listLearnings(dir).map((l) => [l.id, l])); + const sources = listLearnings(bucketDir) + .filter((l) => !l.fm.promoted_to_golden && isActiveFm(l.fm)) + .filter((l) => !requested || requested.has(l.id)) + .sort((a, b) => a.id.localeCompare(b.id)); // deterministic ordering IS the cursor + + const skipped = []; + const promotable = []; + for (const source of sources) { + const decision = governance.get(source.id); + if (decision && ['retire', 'dispute', 'promote'].includes(decision.action)) { + skipped.push({ id: source.id, reason: `standing governance decision: ${decision.action}` }); + continue; + } + const twin = goldenById.get(source.id); + if (twin) { + const sameClaim = (twin.fm.trigger || '') === (source.fm.trigger || '') && twin.body.trim() === source.body.trim(); + if (sameClaim) { + const known = new Set((twin.fm.episodes || []).map((e) => `${e.path}@${e.sha256}`)); + const newEpisodes = (source.fm.episodes || []).filter((e) => e.path && !known.has(`${e.path}@${e.sha256}`)); + if (!newEpisodes.length) { + skipped.push({ id: source.id, reason: 'identical to golden with no new evidence — prune the bucket instead' }); + continue; + } + promotable.push({ + op: 'STRENGTHEN', + target: source.id, + episodes: newEpisodes.map((e) => ({ path: e.path, sha256: e.sha256, kind: e.kind, plan: e.plan || null })), + source: { id: source.id, sha256: crypto.createHash('sha256').update(fs.readFileSync(source.file)).digest('hex') }, + }); + continue; + } + } + promotable.push({ + op: twin ? 'SUPERSEDE' : 'ADD', + ...(twin ? { target: source.id } : {}), + domain: source.domain, + slug: source.slug, + trigger: source.fm.trigger || '', + body: source.body, + episodes: (source.fm.episodes || []).filter((e) => e.path).map((e) => ({ path: e.path, sha256: e.sha256, kind: e.kind, plan: e.plan || null })), + source: { id: source.id, sha256: crypto.createHash('sha256').update(fs.readFileSync(source.file)).digest('hex') }, + }); + } + + if (requested) { + for (const id of requested) { + if (!sources.some((s) => s.id === id)) skipped.push({ id, reason: 'not an active, unpromoted learning in this bucket' }); + } + } + + // Chunk under MAX_OPS_PER_RUN (each promotion op touches one file). + const chunk = promotable.slice(0, MAX_OPS_PER_RUN); + const remaining = promotable.length - chunk.length; + + if (!chunk.length) { + return { pass: false, exitCode: 2, opsPath: null, ops: 0, remaining: 0, skipped, blockedReason: 'nothing promotable in this bucket' }; + } + + const opset = { + schema: 1, + promotion: { branchKey: key, meta: readBucketMeta(bucketDir), digest: promotionDigest(chunk) }, + ops: chunk, + }; + const opsFull = path.join(workspace, PROMOTE_OPS_REL); + fs.mkdirSync(path.dirname(opsFull), { recursive: true }); + fs.writeFileSync(opsFull, JSON.stringify(opset, null, 2) + '\n', 'utf8'); + log(`wrote ${PROMOTE_OPS_REL} (${chunk.length} op(s), ${remaining} remaining)`); + return { + pass: true, + exitCode: 0, + opsPath: PROMOTE_OPS_REL, + ops: chunk.length, + remaining, + skipped, + bucketKey: key, + blockedReason: null, + nextTools: [`harness consolidate --apply --ops ${PROMOTE_OPS_REL}`], + }; +} diff --git a/packages/harness/lib/knowledge/prune.mjs b/packages/harness/lib/knowledge/prune.mjs new file mode 100644 index 00000000..18b73dd2 --- /dev/null +++ b/packages/harness/lib/knowledge/prune.mjs @@ -0,0 +1,136 @@ +import fs from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { storeDir, withStoreTransaction, StoreTransactionAbort, listLearnings } from './store.mjs'; +import { listBuckets } from './overlay.mjs'; +import { absorbOrAbort } from './admin.mjs'; +import { resolveDefaultBranch } from '../git-context.mjs'; + +/** + * `harness knowledge prune` (blueprint P6/§5): delete branch buckets. HUMAN + * AUTHORITY — never mode-gated (exactly like `knowledge purge`: a person + * reaching in directly always wins, in every knowledge mode including off). + * Removal is one store commit through the standard single-writer transaction. + * + * Selectors (union when combined): + * --branch exact bucket key + * --merged buckets whose branch is merged into the resolved default + * branch (workspace git state), plus fully-tombstoned + * buckets (every entry promoted to golden — nothing left) + * --stale buckets whose meta createdAt is older than N days + */ + +/** Local branches fully merged into the resolved default branch. */ +function mergedBranches(workspace, defaultBranch) { + if (!defaultBranch) return null; + for (const ref of [`origin/${defaultBranch.name}`, defaultBranch.name]) { + const res = spawnSync('git', ['branch', '--format=%(refname:short)', '--merged', ref], { + cwd: workspace, + encoding: 'utf8', + timeout: 10_000, + }); + if (res.status === 0) { + return new Set(res.stdout.split('\n').map((s) => s.trim()).filter(Boolean)); + } + } + return null; +} + +/** True when every learning in the bucket is a promoted_to_golden tombstone + * (and there is at least one) — the bucket's work fully landed golden. */ +function fullyPromoted(bucket) { + let entries; + try { + entries = listLearnings(bucket.dir); + } catch { + return false; + } + return entries.length > 0 && entries.every((l) => Boolean(l.fm.promoted_to_golden)); +} + +export function pruneBuckets({ workspace, home, branchKey = null, merged = false, staleDays = null, log = () => {} } = {}) { + if (!branchKey && !merged && staleDays === null) { + return { pass: false, exitCode: 2, removed: [], blockedReason: 'prune needs --branch , --merged, or --stale ' }; + } + const dir = storeDir(workspace, { home }); + if (!fs.existsSync(dir)) { + return { pass: false, exitCode: 2, removed: [], blockedReason: 'nothing to prune — no knowledge store yet' }; + } + const buckets = listBuckets(dir); + if (!buckets.length) { + return { pass: false, exitCode: 2, removed: [], blockedReason: 'nothing to prune — no branch buckets exist' }; + } + + const selected = new Map(); + if (branchKey) { + const hit = buckets.find((b) => b.key === branchKey); + if (!hit) { + return { + pass: false, + exitCode: 2, + removed: [], + blockedReason: `no bucket ${branchKey} — known buckets: ${buckets.map((b) => b.key).join(', ')}`, + }; + } + selected.set(hit.key, hit); + } + if (merged) { + const defaultBranch = resolveDefaultBranch(workspace, { home }); + const mergedSet = mergedBranches(workspace, defaultBranch); + if (mergedSet === null) { + return { + pass: false, + exitCode: 2, + removed: [], + blockedReason: 'cannot determine merged branches — default branch unresolvable (set store config.json defaultBranch or origin/HEAD)', + }; + } + for (const b of buckets) { + if ((b.meta?.branch && mergedSet.has(b.meta.branch)) || fullyPromoted(b)) selected.set(b.key, b); + } + } + if (staleDays !== null) { + const cutoff = Date.now() - staleDays * 86_400_000; + for (const b of buckets) { + const createdAt = b.meta?.createdAt ? Date.parse(b.meta.createdAt) : NaN; + if (!Number.isNaN(createdAt) && createdAt < cutoff) selected.set(b.key, b); + } + } + + if (!selected.size) { + return { pass: false, exitCode: 2, removed: [], blockedReason: 'no buckets match the given selectors — nothing pruned' }; + } + + const keys = [...selected.keys()].sort(); + const tx = withStoreTransaction(workspace, { home, label: `knowledge: prune ${keys.join(', ')}` }, ({ recordCheckpoint }) => { + try { + absorbOrAbort({ workspace, home, log, recordCheckpoint }); + } catch (err) { + if (err instanceof StoreTransactionAbort) throw err; + // best effort — any other absorb hiccup never blocks a human prune. + } + for (const b of selected.values()) { + fs.rmSync(b.dir, { recursive: true, force: true }); + log(`pruned bucket ${b.key}${b.meta?.branch ? ` (${b.meta.branch})` : ''}`); + } + return { kind: 'success', commitMessage: `knowledge: prune ${keys.join(', ')}` }; + }); + + if (!tx.ok) { + return { + pass: false, + exitCode: 1, + removed: [], + blockedReason: tx.locked + ? 'E_LOCKED: another operation holds the store lock' + : `prune failed: ${tx.error?.message || 'store transaction failed'}`, + ...(tx.staleLockNote ? { staleLockRemoved: tx.staleLockNote } : {}), + }; + } + return { + pass: true, + exitCode: 0, + removed: keys, + blockedReason: null, + ...(tx.staleLockNote ? { staleLockRemoved: tx.staleLockNote } : {}), + }; +} diff --git a/packages/harness/lib/knowledge/remember.mjs b/packages/harness/lib/knowledge/remember.mjs index 36b03211..ff934520 100644 --- a/packages/harness/lib/knowledge/remember.mjs +++ b/packages/harness/lib/knowledge/remember.mjs @@ -6,6 +6,8 @@ import { runIndexKnowledge } from '../index-knowledge.mjs'; import { applyOps } from './apply.mjs'; import { normalizeSlug, readStoreConfig, storeDir, listLearnings, withStoreTransaction, StoreTransactionAbort, readLedger } from './store.mjs'; import { absorbOrAbort } from './admin.mjs'; +import { resolveWriteLayer } from './layer.mjs'; +import { bucketDirFor } from './overlay.mjs'; /** * The human teaching lane: a direct claim from a person, captured as a @@ -58,14 +60,31 @@ export function runRemember({ workspace, copilotHome, flags, argv, log = () => { // below writes the episode file, so a block here never leaves an orphan // to roll back. const dir = storeDir(workspace, { home }); - const existingLearning = fs.existsSync(dir) ? listLearnings(dir).find((l) => l.id === learningId) : null; - if (existingLearning?.fm.promoted_to) { + const goldenLearning = fs.existsSync(dir) ? listLearnings(dir).find((l) => l.id === learningId) : null; + // Route-aware target lookup (blueprint P4): remember writes through + // applyOps, which routes by WRITE-TIME git context — on a feature branch + // the write lands in the branch bucket, so the ADD-vs-SUPERSEDE decision + // must look at the ROUTED layer's learnings (a golden twin is shadowed at + // read time, not superseded by a bucket write). Golden stays consulted for + // the promoted block below: behavior that lives in a primitive is never + // re-taught in ANY layer. + let layerRoot = dir; + try { + const routing = resolveWriteLayer({ workspace, home }); + if (routing.layer === 'branch' && routing.bucketKey) layerRoot = bucketDirFor(dir, routing.bucketKey); + } catch { + layerRoot = dir; + } + const existingLearning = + layerRoot === dir ? goldenLearning : fs.existsSync(layerRoot) ? listLearnings(layerRoot).find((l) => l.id === learningId) : null; + const promotedTo = goldenLearning?.fm.promoted_to || existingLearning?.fm.promoted_to; + if (promotedTo) { return { pass: false, exitCode: 2, episodePath: null, learningId, - blockedReason: `this claim was promoted to ${existingLearning.fm.promoted_to} — update that primitive, or re-teach under a different --trigger/--domain`, + blockedReason: `this claim was promoted to ${promotedTo} — update that primitive, or re-teach under a different --trigger/--domain`, nextTools: [`harness learnings --why ${learningId}`], }; } diff --git a/packages/harness/lib/knowledge/retrieve.mjs b/packages/harness/lib/knowledge/retrieve.mjs index 6c14c17d..821378e3 100644 --- a/packages/harness/lib/knowledge/retrieve.mjs +++ b/packages/harness/lib/knowledge/retrieve.mjs @@ -1,17 +1,24 @@ import fs from 'node:fs'; -import { storeDir, listLearnings, readStaleExclusions } from './store.mjs'; +import { storeDir, readStaleExclusions } from './store.mjs'; +import { loadLayeredLearnings, layerTieRank } from './overlay.mjs'; import { tokenize } from '../tokenize.mjs'; /** * Read the raw learning set + stale-anchor exclusions for a workspace. * Read-only and advisory: never creates the store, never throws — a missing * or unreadable store just means "nothing to rank/explain". + * + * The learning set comes from the SHARED layer overlay (overlay.mjs) — the + * one function eval.mjs also uses — so production retrieval and the eval can + * never drift on the golden ∪ branch-bucket candidate set or its + * protected-shadow/governance gates. With no `branches/` directory the + * overlay returns listLearnings' output untouched (byte-identical behavior). */ function loadLearnings({ workspace, home }) { try { const dir = storeDir(workspace, { home }); if (!fs.existsSync(dir)) return { learnings: [], staleExcluded: {} }; - return { learnings: listLearnings(dir), staleExcluded: readStaleExclusions(dir).excluded }; + return { learnings: loadLayeredLearnings({ workspace, home }).learnings, staleExcluded: readStaleExclusions(dir).excluded }; } catch { return { learnings: [], staleExcluded: {} }; } @@ -46,6 +53,9 @@ function loadLearnings({ workspace, home }) { export function retrievalExclusion(l, staleExcluded = {}) { if (l.fm.superseded_by) return 'superseded'; if (l.fm.promoted_to) return 'promoted'; + // Branch→golden promotion tombstone (blueprint §5): a bucket entry whose + // claim was absorbed into golden is excluded exactly like promoted_to. + if (l.fm.promoted_to_golden) return 'promoted-to-golden'; if (l.fm.status === 'retired') return 'retired'; if (l.fm.status === 'disputed') return 'disputed'; if (staleExcluded[l.id]) return 'stale-anchor'; @@ -98,10 +108,20 @@ export function rankLearnings({ workspace, query, limit = 3, home, include }) { status: l.fm.status || 'active', advisory, score: scored.score, + // Layer marker (blueprint §4): only branch-bucket entries carry the + // extra fields — golden results stay byte-identical to pre-overlay + // output, and the no-bucket path never adds a key. + ...(l.layer === 'branch' ? { layer: 'branch', ...(l.subordinate ? { subordinate: true } : {}) } : {}), }); } - return results.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)).slice(0, limit); + // Equal-score ties break by layer BEFORE id (blueprint §4): branch-local + // wins, except a subordinate entry never outranks the protected golden + // claim it shadows. Entries without layer fields all rank identically, so + // the historical `score desc, id asc` order is unchanged without buckets. + return results + .sort((a, b) => b.score - a.score || layerTieRank(a) - layerTieRank(b) || a.id.localeCompare(b.id)) + .slice(0, limit); } /** @@ -129,6 +149,8 @@ export function explainLearnings({ workspace, query, home, include }) { base: scored.base ?? null, damping: scored.damping ?? null, score: scored.score ?? null, + // Same additive layer marker as rankLearnings — absent without buckets. + ...(l.layer === 'branch' ? { layer: 'branch' } : {}), }; }); diff --git a/packages/harness/lib/knowledge/status.mjs b/packages/harness/lib/knowledge/status.mjs new file mode 100644 index 00000000..b78b7b75 --- /dev/null +++ b/packages/harness/lib/knowledge/status.mjs @@ -0,0 +1,115 @@ +import fs from 'node:fs'; +import { storeDir, listLearnings, readStoreConfig } from './store.mjs'; +import { isActiveFm } from './consolidate.mjs'; +import { listBuckets, bucketAncestryOk } from './overlay.mjs'; +import { deriveGitContext, isDetachedKey } from '../git-context.mjs'; +import { indexStatus } from '../index-status.mjs'; + +/** + * `harness knowledge status` (blueprint P6, Phase 1): a read-only, layer-aware + * report — golden per-domain counts, branch-bucket rows when buckets exist, + * and the recall-index drift line (index-status.mjs's existing signals, + * labeled as exactly what they measure). Never creates the store, never + * mutates anything. + * + * Promotability is DERIVED from the key shape at decision time + * (`detached-*` is never promotable) — bucket meta.json is a cache, never + * authority. A bucket whose recorded baseSha provably shares no history with + * the current HEAD (force-push name reuse) is flagged `ancestryOk: false`, + * matching the overlay's read-time exclusion. + */ +export function knowledgeStatus({ workspace, copilotHome, home } = {}) { + const dir = storeDir(workspace, { home }); + const storeExists = fs.existsSync(dir); + const { mode, commit } = readStoreConfig(workspace, { home }); + + let context = null; + try { + const derived = deriveGitContext({ workspace, home }); + if (derived.branch || derived.detached) { + context = { branch: derived.branch, branchKey: derived.branchKey, detached: derived.detached }; + } + } catch { + context = null; + } + + const domainsMap = new Map(); + let goldenActive = 0; + let goldenTotal = 0; + if (storeExists) { + for (const l of listLearnings(dir)) { + goldenTotal += 1; + const bucket = domainsMap.get(l.domain) || { domain: l.domain, active: 0, total: 0 }; + bucket.total += 1; + if (isActiveFm(l.fm)) { + bucket.active += 1; + goldenActive += 1; + } + domainsMap.set(l.domain, bucket); + } + } + const domains = [...domainsMap.values()].sort((a, b) => a.domain.localeCompare(b.domain)); + + const buckets = []; + if (storeExists) { + for (const { key, dir: bucketDir, meta } of listBuckets(dir)) { + let active = 0; + let total = 0; + let promoted = 0; + try { + for (const l of listLearnings(bucketDir)) { + total += 1; + if (l.fm.promoted_to_golden) promoted += 1; + else if (isActiveFm(l.fm)) active += 1; + } + } catch { + // unreadable bucket — counts stay zero, the row still surfaces + } + const createdAt = typeof meta?.createdAt === 'string' ? meta.createdAt : null; + const ageDays = createdAt && !Number.isNaN(Date.parse(createdAt)) + ? Math.max(0, Math.floor((Date.now() - Date.parse(createdAt)) / 86_400_000)) + : null; + buckets.push({ + key, + branch: typeof meta?.branch === 'string' ? meta.branch : null, + baseSha: typeof meta?.baseSha === 'string' ? meta.baseSha : null, + createdAt, + ageDays, + // Derived from the key shape, never trusted from meta (cache only). + promotable: !isDetachedKey(key), + active, + total, + promoted, + prunable: total > 0 && active === 0, + ancestryOk: bucketAncestryOk(workspace, meta), + }); + } + } + + let drift = null; + try { + const status = indexStatus({ workspace, copilotHome }); + drift = { + indexed: Boolean(status.indexed), + stale: Boolean(status.stale), + commitsSince: status.commitsSince ?? null, + filesChanged: status.filesChanged ?? null, + recommendation: status.recommendation, + }; + } catch { + drift = null; + } + + return { + pass: true, + exitCode: 0, + storeExists, + storeDir: dir, + mode, + commit, + context, + golden: { active: goldenActive, total: goldenTotal, domains }, + buckets, + drift, + }; +} diff --git a/packages/harness/lib/knowledge/store.mjs b/packages/harness/lib/knowledge/store.mjs index 0c352b24..7c9bd524 100644 --- a/packages/harness/lib/knowledge/store.mjs +++ b/packages/harness/lib/knowledge/store.mjs @@ -87,8 +87,40 @@ export function storeDir(workspace, { home } = {}) { return storeDirForId(repoId(workspace), { home }); } +/** + * Store schema version (blueprint §5a): stamped into `store.json` by + * ensureStore, checked wherever the store is opened for use. Schema 2 = the + * layered store (golden `learnings/` + `branches//` buckets). A store + * whose recorded schema is NEWER than this CLI supports refuses with an + * upgrade hint instead of operating layer-blind — an older CLI running + * root-anchored maintenance against a layered store is a data-loss hazard, + * not a degraded mode. An absent/corrupt store.json is treated as the + * current schema (legacy stores predate the marker and are fully readable). + */ +export const STORE_SCHEMA = 2; + +export function assertStoreSchemaSupported(dir) { + let recorded = null; + try { + const parsed = JSON.parse(fs.readFileSync(path.join(dir, 'store.json'), 'utf8')); + if (parsed && Number.isInteger(parsed.schema)) recorded = parsed.schema; + } catch { + recorded = null; // absent or corrupt — legacy/current, never a refusal + } + if (recorded !== null && recorded > STORE_SCHEMA) { + const err = new Error( + `knowledge store schema ${recorded} is newer than this CLI supports (${STORE_SCHEMA}) — upgrade @dev-kit/harness before touching this store` + ); + err.code = 'E_STORE_SCHEMA'; + err.hint = 'npm install -g @dev-kit/harness@latest && harness install'; + throw err; + } + return recorded; +} + export function ensureStore(workspace, { home, dryRun = false } = {}) { const dir = storeDir(workspace, { home }); + assertStoreSchemaSupported(dir); const created = !fs.existsSync(path.join(dir, 'consolidated.jsonl')); if (dryRun) return { dir, created, git: fs.existsSync(path.join(dir, '.git')) }; fs.mkdirSync(path.join(dir, 'learnings'), { recursive: true }); @@ -100,6 +132,8 @@ export function ensureStore(workspace, { home, dryRun = false } = {}) { if (!fs.existsSync(indexPath)) fs.writeFileSync(indexPath, INDEX_STUB, 'utf8'); const ledgerPath = path.join(dir, 'consolidated.jsonl'); if (!fs.existsSync(ledgerPath)) fs.writeFileSync(ledgerPath, '', 'utf8'); + const schemaPath = path.join(dir, 'store.json'); + if (!fs.existsSync(schemaPath)) fs.writeFileSync(schemaPath, JSON.stringify({ schema: STORE_SCHEMA }) + '\n', 'utf8'); return { dir, created, git: gitOk }; } @@ -150,7 +184,17 @@ export function writeStoreConfig(workspace, { home, mode, commit } = {}) { const current = readStoreConfig(workspace, { home }); const nextMode = mode !== undefined ? mode : current.mode; const nextCommit = commit !== undefined ? commit : current.commit; - fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ mode: nextMode, commit: nextCommit }) + '\n', 'utf8'); + // Preserve any OTHER fields the raw config carries (e.g. the + // `defaultBranch` layer-routing override, git-context.mjs) — this + // read-modify-write owns only mode/commit, never the whole file. + let raw = {}; + try { + const parsed = JSON.parse(fs.readFileSync(path.join(dir, 'config.json'), 'utf8')); + if (parsed && typeof parsed === 'object') raw = parsed; + } catch { + // absent/corrupt — nothing extra to preserve + } + fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ ...raw, mode: nextMode, commit: nextCommit }) + '\n', 'utf8'); const message = mode !== undefined ? `knowledge: mode ${nextMode}` : `knowledge: commit ${nextCommit}`; return { nextMode, nextCommit, commitMessage: message }; }); @@ -246,10 +290,23 @@ function readGovernanceEntries(dir) { * one (e.g. correcting a recorded `--to` path) — only non-promote entries are * blocked from overriding a standing promote; there is no `unpromote`. */ +/** + * REPLAY RULE (blueprint §5, normative): only the human DECISION set can ever + * become an id's latest standing decision. `absorb-branch` entries — the + * audit record a branch→golden promotion appends — are deliberately NOT in + * this set: they are recorded for audit but skipped by the replay, so a + * promotion can never displace a standing retire/dispute (the required + * regression: retire → absorb-branch → `consolidate --rebuild --yes` still + * lands retired). Unknown/future actions are likewise audit-only until they + * are explicitly added here. + */ +const REPLAY_DECISION_ACTIONS = new Set(['retire', 'dispute', 'confirm', 'promote']); + export function readGovernance(dir) { const map = new Map(); for (const entry of readGovernanceEntries(dir)) { if (!entry || !entry.id) continue; + if (!REPLAY_DECISION_ACTIONS.has(entry.action)) continue; // audit-only (absorb-branch, future actions) const existing = map.get(entry.id); if (existing && existing.action === 'promote' && entry.action !== 'promote') continue; map.set(entry.id, entry); @@ -417,6 +474,46 @@ export function episodeLines(episodes) { return lines; } +/** + * Git provenance frontmatter (harness evolution blueprint P1/P9): optional, + * reader-tolerant `commit:` / `branch:` / `base:` fields on episodes and + * learnings. ONE rendering shared by BOTH learning serializers — + * `serializeLearning` below (parse → mutate → re-render round trips: absorb, + * purge delink, lifecycle promote) and apply.mjs's `renderLearning` (fresh + * ADD/SUPERSEDE/STRENGTHEN/MERGE writes) — because reader tolerance alone is + * insufficient: a serializer with a fixed field list silently DROPS the + * fields on any re-render. Shape-validated at render: commit/base must be + * full 40-hex shas; branch is an attacker-influenced string on fork + * checkouts, so it is yamlQuoted (round-tripped by `unquote`) and length- + * capped here at the write boundary (render surfaces additionally pass it + * through `inertLine`). Absent/invalid fields render nothing — a legacy + * artifact without them never errors and never gains fabricated values. + */ +const PROVENANCE_SHA_RE = /^[0-9a-f]{40}$/; +const PROVENANCE_BRANCH_CAP = 200; + +export function provenanceLines({ commit, branch, base } = {}) { + const lines = []; + if (typeof commit === 'string' && PROVENANCE_SHA_RE.test(commit)) lines.push(`commit: ${commit}`); + if (typeof branch === 'string' && branch && branch.length <= PROVENANCE_BRANCH_CAP) { + lines.push(`branch: ${yamlQuote(branch)}`); + } + if (typeof base === 'string' && PROVENANCE_SHA_RE.test(base)) lines.push(`base: ${base}`); + return lines; +} + +/** + * Byte cost of the provenance lines as they land in a rendered learning + * (each line plus its joining newline). The LEARNING_BYTE_CAP check in + * apply.mjs subtracts exactly this, so a near-cap learning gaining + * provenance can never trip E_BYTE_CAP (and never records a quarantine + * strike) purely because of the stamp — the cap keeps measuring the CLAIM, + * not the bookkeeping. Recorded as the Phase 1 byte-cap decision. + */ +export function provenanceBytes(fields) { + return provenanceLines(fields).reduce((n, line) => n + Buffer.byteLength(line, 'utf8') + 1, 0); +} + /** * Render a parsed `{ fm, body }` pair (as `parseLearningFrontmatter` above * hands back) to the canonical on-disk learning text — same field order and @@ -430,6 +527,8 @@ export function episodeLines(episodes) { * array `mergedFrom`, a freshly-stamped `last_confirmed`) rather than a * parsed `fm`, so it is intentionally NOT rebased on this function — that * would require normalizing shapes it doesn't own; see apply.mjs. + * Provenance fields parsed off disk are re-emitted via provenanceLines + * (above), so no re-render ever drops them. */ export function serializeLearning(fm, body) { const lines = [ @@ -452,7 +551,9 @@ export function serializeLearning(fm, body) { lines.push(`last_confirmed: ${fm.last_confirmed || 'null'}`); if (fm.merged_from) lines.push(`merged_from: ${fm.merged_from}`); if (fm.promoted_to) lines.push(`promoted_to: ${fm.promoted_to}`); + if (fm.promoted_to_golden) lines.push(`promoted_to_golden: ${fm.promoted_to_golden}`); lines.push(`origin: ${fm.origin || 'unknown'}`); + lines.push(...provenanceLines(fm)); lines.push('---', '', body.trim(), ''); return lines.join('\n'); } @@ -620,15 +721,20 @@ export function rollbackStore(dir, targetSha) { } /** - * `learnings//.md` — the shape absorbHandEdits (admin.mjs) - * treats as an absorbable hand edit. Exported for admin.mjs's own porcelain - * scan; no longer used by store.mjs itself (an earlier version of the - * rollback guard here matched dirty paths against it, which incorrectly - * protected a path a transaction's OWN legitimate mutation re-dirtied after - * an earlier absorb commit already captured it — see withStoreTransaction's - * checkpoint-based design below, which replaced that approach entirely). + * `learnings//.md` — golden — OR + * `branches//learnings//.md` — a branch bucket (blueprint + * §5a: hand edits under buckets are absorbed exactly like golden hand edits, + * never left for transaction rollback to destroy) — the shapes + * absorbHandEdits (admin.mjs) treats as an absorbable hand edit. Capture + * groups: [1] = bucket key (undefined for golden), [2] = domain, [3] = slug. + * Exported for admin.mjs's own porcelain scan; no longer used by store.mjs + * itself (an earlier version of the rollback guard here matched dirty paths + * against it, which incorrectly protected a path a transaction's OWN + * legitimate mutation re-dirtied after an earlier absorb commit already + * captured it — see withStoreTransaction's checkpoint-based design below, + * which replaced that approach entirely). */ -export const LEARNING_FILE_RE = /^learnings\/([^/]+)\/([^/]+)\.md$/; +export const LEARNING_FILE_RE = /^(?:branches\/([^/]+)\/)?learnings\/([^/]+)\/([^/]+)\.md$/; /** Parse one `git status --porcelain` line into its status code and path — * shared by admin.mjs's absorbHandEdits scan. */ diff --git a/packages/harness/lib/orient.mjs b/packages/harness/lib/orient.mjs index de8faf74..06495f90 100644 --- a/packages/harness/lib/orient.mjs +++ b/packages/harness/lib/orient.mjs @@ -13,12 +13,25 @@ import { parseQueryFromArgv } from './argv.mjs'; import { rankLearnings, explainLearnings } from './knowledge/retrieve.mjs'; import { readStoreConfig, storeDir } from './knowledge/store.mjs'; import { consolidateStatus } from './knowledge/consolidate.mjs'; +import { deriveGitContext } from './git-context.mjs'; import { redactRecallEntry } from './secret-scan.mjs'; export function runOrient({ workspace, copilotHome, flags, query }) { const q = query || flags.query || ''; ensureHarnessDir(workspace, flags.dryRun); + // Branch/worktree detection (blueprint P2): advisory display context — + // recorded in the session and rendered as a pack-header line. ADVISORY + // ONLY: layer routing always re-derives git context at WRITE time; a write + // whose current HEAD disagrees with this recorded branch warns. + let gitContext = null; + try { + gitContext = deriveGitContext({ workspace }); + if (!gitContext.branch && !gitContext.detached) gitContext = null; + } catch { + gitContext = null; + } + const recall = rankRecall(q, { copilotHome, workspace, @@ -156,6 +169,7 @@ export function runOrient({ workspace, copilotHome, flags, query }) { repoMapRef, gatePreview: { pass: gatePreview.pass, blockedReason: gatePreview.blockedReason }, nextTools, + gitContext, }); // Injected-token ledger: bytes of the "## Learnings (memory)" section as it @@ -188,6 +202,13 @@ export function runOrient({ workspace, copilotHome, flags, query }) { contextPack: packRel, gateStatus: gatePreview.pass ? 'pass' : 'blocked', blockedReason: gatePreview.blockedReason, + // Advisory branch context (blueprint P2/P1): display + staleness-warning + // baseline only — never an input to write-time layer routing, which + // re-derives git context fresh at every write. + gitBranch: gitContext?.branch || null, + gitBranchKey: gitContext?.branchKey || null, + gitDetached: gitContext?.detached || false, + gitHeadSha: gitContext?.headSha || null, }; writeSession(workspace, newSession, flags.dryRun); @@ -211,6 +232,7 @@ export function runOrient({ workspace, copilotHome, flags, query }) { contextPack: packRel, repoMap: repoMapRef, knowledgeDebt, + gitContext, gateStatus: newSession.gateStatus, blockedReason: newSession.blockedReason, nextTools, diff --git a/packages/harness/lib/report.mjs b/packages/harness/lib/report.mjs index 88d15705..0a8996bb 100644 --- a/packages/harness/lib/report.mjs +++ b/packages/harness/lib/report.mjs @@ -172,10 +172,19 @@ export function knowledgeSlos(events) { // is noise, not utilization, so it must not inflate the weighted rate. const citedIdOccurrences = []; let consolidations = 0; let humanActions = 0; + // Layer attribution (branch-local vs golden): orient events record a + // learningLayers map only when a branch-bucket learning surfaced; an id's + // latest recorded layer wins. Absent everywhere → no split is reported. + const layerById = new Map(); + let anyLayerInfo = false; for (const e of events) { if (e.type === 'orient' && Array.isArray(e.learnings)) { e.learnings.forEach((id) => surfaced.add(id)); surfacedOccurrences += e.learnings.length; + if (e.learningLayers && typeof e.learningLayers === 'object') { + anyLayerInfo = true; + for (const [id, layer] of Object.entries(e.learningLayers)) layerById.set(id, layer === 'branch' ? 'branch' : 'golden'); + } } if (e.type === 'verify' && Array.isArray(e.learnings)) { e.learnings.forEach((id) => cited.add(id)); @@ -186,12 +195,25 @@ export function knowledgeSlos(events) { } const citedSurfaced = [...cited].filter((id) => surfaced.has(id)).length; const citedOccurrences = citedIdOccurrences.filter((id) => surfaced.has(id)).length; + // Per-layer split (blueprint Phase 2, report/SLO layer split): unique-id + // based, attributed by each id's recorded layer (default golden). Only + // present once any layer info exists, so pre-bucket reports are unchanged. + let layers; + if (anyLayerInfo) { + layers = { golden: { surfaced: 0, cited: 0 }, branch: { surfaced: 0, cited: 0 } }; + for (const id of surfaced) { + const layer = layerById.get(id) || 'golden'; + layers[layer].surfaced += 1; + if (cited.has(id)) layers[layer].cited += 1; + } + } return { surfaced: surfaced.size, cited: cited.size, citedSurfaced, utilization: surfaced.size ? Number((citedSurfaced / surfaced.size).toFixed(2)) : null, surfacedOccurrences, citedOccurrences, utilizationWeighted: surfacedOccurrences ? Number((citedOccurrences / surfacedOccurrences).toFixed(2)) : null, consolidations, humanActions, - engagement: consolidations ? Number((humanActions / consolidations).toFixed(2)) : null }; + engagement: consolidations ? Number((humanActions / consolidations).toFixed(2)) : null, + ...(layers ? { layers } : {}) }; } /** Injected-token ledger: the COST side of the knowledge layer's accounting. diff --git a/packages/harness/test/events-allow-list.test.mjs b/packages/harness/test/events-allow-list.test.mjs new file mode 100644 index 00000000..91c5b7b1 --- /dev/null +++ b/packages/harness/test/events-allow-list.test.mjs @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; +import { EVENT_TYPES, writeEvent, readEvents } from '../lib/events.mjs'; + +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const binPath = path.join(packageRoot, 'bin', 'harness.mjs'); + +const tempDir = (p) => fs.mkdtempSync(path.join(os.tmpdir(), p)); + +function runHarness(args, options = {}) { + return spawnSync(process.execPath, [binPath, ...args], { + cwd: packageRoot, + encoding: 'utf8', + env: { ...process.env, ...(options.env || {}) }, + }); +} + +test('EVENT_TYPES accepts the four formerly dropped lifecycle types', () => { + for (const type of ['init_repo', 'recall', 'validate_plan', 'index']) { + assert.ok(EVENT_TYPES.has(type), `${type} must be allow-listed`); + } +}); + +test('writeEvent records init_repo/recall/validate_plan/index instead of silently dropping them', () => { + const workspace = tempDir('events-allow-ws-'); + for (const [type, command] of [ + ['init_repo', 'init-repo'], + ['recall', 'recall'], + ['validate_plan', 'validate-plan'], + ['index', 'index'], + ]) { + const event = writeEvent(workspace, {}, { type, command, result: 'pass', exitCode: 0 }); + assert.ok(event, `${type} write must not be dropped`); + assert.equal(event.type, type); + } + const recorded = readEvents(workspace, 20); + assert.deepEqual( + recorded.map((e) => e.type), + ['init_repo', 'recall', 'validate_plan', 'index'] + ); +}); + +test('the existing CLI call sites now land their events in events.jsonl', () => { + const workspace = tempDir('events-allow-cli-ws-'); + const copilotHome = tempDir('events-allow-cli-home-'); + + const recall = runHarness(['recall', 'orders timeout', '--workspace', workspace, '--copilot-home', copilotHome, '--json']); + assert.equal(recall.status, 0, recall.stderr); + + const index = runHarness(['index', '--workspace', workspace, '--copilot-home', copilotHome, '--json']); + assert.equal(index.status, 0, index.stderr); + + const events = readEvents(workspace, 20); + assert.ok(events.some((e) => e.type === 'recall' && e.command === 'recall'), 'recall event recorded'); + assert.ok(events.some((e) => e.type === 'index' && e.command === 'index'), 'index event recorded'); +}); + +test('an unknown event type is still silently dropped', () => { + const workspace = tempDir('events-allow-unknown-ws-'); + const event = writeEvent(workspace, {}, { type: 'not_a_type', command: 'nope' }); + assert.equal(event, null); + assert.equal(readEvents(workspace, 20).length, 0); +}); diff --git a/packages/harness/test/git-context.test.mjs b/packages/harness/test/git-context.test.mjs new file mode 100644 index 00000000..37dbbcd0 --- /dev/null +++ b/packages/harness/test/git-context.test.mjs @@ -0,0 +1,169 @@ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { test } from 'node:test'; +import { + branchSlug, + branchKeyFor, + detachedKeyFor, + isDetachedKey, + resolveDefaultBranch, + deriveGitContext, +} from '../lib/git-context.mjs'; +import { storeDir } from '../lib/knowledge/store.mjs'; + +const tempDir = (p) => fs.mkdtempSync(path.join(os.tmpdir(), p)); + +function git(cwd, args) { + return spawnSync('git', args, { + cwd, + encoding: 'utf8', + env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' }, + }); +} + +function gitWorkspace({ branch = 'main', commit = true } = {}) { + const ws = tempDir('gitctx-ws-'); + git(ws, ['init', '-q', '-b', branch]); + git(ws, ['config', 'user.email', 'test@example.test']); + git(ws, ['config', 'user.name', 'Test']); + if (commit) { + fs.writeFileSync(path.join(ws, 'file.txt'), 'x\n'); + git(ws, ['add', '.']); + git(ws, ['commit', '-qm', 'init']); + } + return ws; +} + +function head(ws) { + return git(ws, ['rev-parse', 'HEAD']).stdout.trim(); +} + +test('branchKey is -<8hex of raw name>, deterministic and platform-independent', () => { + const expectedHash = crypto.createHash('sha256').update('feature/foo').digest('hex').slice(0, 8); + assert.equal(branchKeyFor('feature/foo'), `feature-foo-${expectedHash}`); + assert.equal(branchKeyFor('feature/foo'), branchKeyFor('feature/foo')); +}); + +test('branch slug lowercases, collapses runs, trims, and caps at 64 chars', () => { + assert.equal(branchSlug('Feature//My_Thing.v2'), 'feature-my_thing.v2'); + assert.equal(branchSlug('--weird--'), 'weird'); + const long = 'users/First.Last/JIRA-1234-' + 'a'.repeat(200); + const slug = branchSlug(long); + assert.equal(slug.length, 64); + assert.match(slug, /^[a-z0-9._-]+$/); +}); + +test('colliding slugs stay distinct keys via the raw-name hash', () => { + const a = branchKeyFor('Feature/Foo'); + const b = branchKeyFor('feature/foo'); + assert.equal(a.replace(/-[0-9a-f]{8}$/, ''), b.replace(/-[0-9a-f]{8}$/, '')); + assert.notEqual(a, b); + // Two 200-char names sharing a 64-char prefix truncate to the same slug. + const base = 'release/' + 'x'.repeat(120); + assert.notEqual(branchKeyFor(`${base}-one`), branchKeyFor(`${base}-two`)); +}); + +test('unicode branch names derive a safe, deterministic, Windows-path-shaped key', () => { + const key = branchKeyFor('функция/тест'); + assert.match(key, /^branch-[0-9a-f]{8}$/); // fully non-latin slug falls back, hash disambiguates + assert.equal(key, branchKeyFor('функция/тест')); + const mixed = branchKeyFor('fix/ünïcode-Ω-path'); + assert.match(mixed, /^[a-z0-9._-]+-[0-9a-f]{8}$/); + assert.ok(mixed.length <= 64 + 1 + 8, 'key stays within slug cap + hash'); + assert.ok(!/[<>:"/\\|?*\s]/.test(mixed), 'no Windows-reserved path chars in the key'); +}); + +test('200-char branch name yields a bounded key usable as one path segment', () => { + const name = 'feature/' + 'very-long-segment-'.repeat(12); // > 200 chars + const key = branchKeyFor(name); + assert.ok(key.length <= 73, `key too long: ${key.length}`); + assert.match(key, /^[a-z0-9._-]+-[0-9a-f]{8}$/); +}); + +test('deriveGitContext reports branch, key, worktree, and head on a normal branch', () => { + const ws = gitWorkspace({ branch: 'feature/slash-branch' }); + const ctx = deriveGitContext({ workspace: ws }); + assert.equal(ctx.branch, 'feature/slash-branch'); + assert.equal(ctx.branchKey, branchKeyFor('feature/slash-branch')); + assert.equal(ctx.detached, false); + assert.equal(ctx.headSha, head(ws)); + assert.equal(fs.realpathSync(ctx.worktree), fs.realpathSync(ws)); + assert.equal(ctx.baseSha, null); // no default branch resolvable — never guessed + // Deterministic across runs. + assert.deepEqual(deriveGitContext({ workspace: ws }), ctx); +}); + +test('detached HEAD (and rebase-shaped states) derive detached-<12hex>', () => { + const ws = gitWorkspace(); + git(ws, ['checkout', '-q', '--detach']); + const ctx = deriveGitContext({ workspace: ws }); + assert.equal(ctx.branch, null); + assert.equal(ctx.detached, true); + assert.equal(ctx.branchKey, detachedKeyFor(head(ws))); + assert.match(ctx.branchKey, /^detached-[0-9a-f]{12}$/); + assert.ok(isDetachedKey(ctx.branchKey)); + assert.ok(!isDetachedKey(branchKeyFor('feature/foo'))); +}); + +test('unborn branch (fresh init, no commits) still names the branch, no shas', () => { + const ws = gitWorkspace({ branch: 'main', commit: false }); + const ctx = deriveGitContext({ workspace: ws }); + assert.equal(ctx.branch, 'main'); + assert.equal(ctx.branchKey, branchKeyFor('main')); + assert.equal(ctx.headSha, null); + assert.equal(ctx.baseSha, null); + assert.equal(ctx.detached, false); +}); + +test('non-git workspace degrades to all-null context', () => { + const ws = tempDir('gitctx-plain-'); + assert.deepEqual(deriveGitContext({ workspace: ws }), { + branch: null, + branchKey: null, + worktree: null, + detached: false, + headSha: null, + baseSha: null, + }); +}); + +test('baseSha is the merge-base with origin/HEAD when resolvable', () => { + // "Remote" repo with main. + const origin = gitWorkspace({ branch: 'main' }); + const clone = tempDir('gitctx-clone-'); + git(clone, ['clone', '-q', origin, '.']); + git(clone, ['config', 'user.email', 'test@example.test']); + git(clone, ['config', 'user.name', 'Test']); + const mainTip = head(clone); + git(clone, ['checkout', '-qb', 'feature/work']); + fs.writeFileSync(path.join(clone, 'work.txt'), 'w\n'); + git(clone, ['add', '.']); + git(clone, ['commit', '-qm', 'work']); + + const resolved = resolveDefaultBranch(clone, {}); + assert.deepEqual(resolved, { name: 'main', source: 'origin-head' }); + const ctx = deriveGitContext({ workspace: clone }); + assert.equal(ctx.branch, 'feature/work'); + assert.equal(ctx.baseSha, mainTip); + assert.notEqual(ctx.headSha, ctx.baseSha); +}); + +test('store config.json defaultBranch overrides origin/HEAD', () => { + const origin = gitWorkspace({ branch: 'main' }); + const clone = tempDir('gitctx-clone2-'); + git(clone, ['clone', '-q', origin, '.']); + git(clone, ['config', 'user.email', 'test@example.test']); + git(clone, ['config', 'user.name', 'Test']); + const home = tempDir('gitctx-home-'); + const dir = storeDir(clone, { home }); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ mode: 'on', defaultBranch: 'trunk' }) + '\n'); + assert.deepEqual(resolveDefaultBranch(clone, { home }), { name: 'trunk', source: 'config' }); + // trunk does not exist as a ref — baseSha stays null rather than guessing. + const ctx = deriveGitContext({ workspace: clone, home }); + assert.equal(ctx.baseSha, null); +}); diff --git a/packages/harness/test/harness-cli.test.mjs b/packages/harness/test/harness-cli.test.mjs index ea960809..9edef76f 100644 --- a/packages/harness/test/harness-cli.test.mjs +++ b/packages/harness/test/harness-cli.test.mjs @@ -441,7 +441,10 @@ test('lifecycle commands append schema-v2 events and omit non-lifecycle commands ); const events = readEvents(workspace); - assert.deepEqual(events.map((event) => event.type), ['orient', 'gate']); + // recall records too since the EVENT_TYPES allow-list fix (harness + // evolution Phase 1 hygiene) — its call site always existed; the type was + // simply dropped before. + assert.deepEqual(events.map((event) => event.type), ['orient', 'gate', 'recall']); for (const event of events) { assert.equal(event.version, 2); assert.match(event.id, /.+/); diff --git a/packages/harness/test/knowledge-promote.test.mjs b/packages/harness/test/knowledge-promote.test.mjs new file mode 100644 index 00000000..e5a60b91 --- /dev/null +++ b/packages/harness/test/knowledge-promote.test.mjs @@ -0,0 +1,321 @@ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { test } from 'node:test'; +import { + ensureStore, + listLearnings, + readLedger, + readGovernance, + appendGovernance, +} from '../lib/knowledge/store.mjs'; +import { applyOps } from '../lib/knowledge/apply.mjs'; +import { buildPromotionOps, PROMOTE_OPS_REL } from '../lib/knowledge/promote.mjs'; +import { pruneBuckets } from '../lib/knowledge/prune.mjs'; +import { rebuildStore } from '../lib/knowledge/admin.mjs'; +import { bucketDirFor, listBuckets, loadLayeredLearnings } from '../lib/knowledge/overlay.mjs'; +import { branchKeyFor } from '../lib/git-context.mjs'; +import { retrievalExclusion } from '../lib/knowledge/retrieve.mjs'; + +const tempDir = (p) => fs.mkdtempSync(path.join(os.tmpdir(), p)); + +function git(cwd, args) { + return spawnSync('git', args, { + cwd, + encoding: 'utf8', + env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' }, + }); +} + +/** Cloned workspace with origin/HEAD → main, on a feature branch. */ +function featureWorkspace(branch = 'feature/promo') { + const origin = tempDir('promo-origin-'); + git(origin, ['init', '-q', '-b', 'main']); + git(origin, ['config', 'user.email', 't@example.test']); + git(origin, ['config', 'user.name', 'T']); + fs.writeFileSync(path.join(origin, 'seed.txt'), 'seed\n'); + git(origin, ['add', '.']); + git(origin, ['commit', '-qm', 'seed']); + const ws = tempDir('promo-ws-'); + git(ws, ['clone', '-q', origin, '.']); + git(ws, ['config', 'user.email', 't@example.test']); + git(ws, ['config', 'user.name', 'T']); + git(ws, ['checkout', '-qb', branch]); + return ws; +} + +function writeEpisode(ws, rel) { + const full = path.join(ws, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + const text = `fix evidence for ${rel}.\n`; + fs.writeFileSync(full, text, 'utf8'); + return { path: rel, sha256: crypto.createHash('sha256').update(text).digest('hex'), kind: 'fix', plan: 'docs/plans/p1.md' }; +} + +function writeOps(ws, ops) { + const p = path.join(ws, `ops-${crypto.randomUUID()}.json`); + fs.writeFileSync(p, JSON.stringify({ schema: 1, ops })); + return p; +} + +function addOp(ws, slug, over = {}) { + return { + op: 'ADD', + domain: 'sql', + slug, + trigger: `trigger for ${slug}`, + body: `Claim body for ${slug}.`, + episodes: over.episodes || [writeEpisode(ws, `docs/solutions/perf/${slug}.md`)], + ...over, + }; +} + +/** Seed a bucket learning via the real routed write path. */ +function seedBucketLearning(ws, home, slug, over = {}) { + const applied = applyOps({ workspace: ws, opsPath: writeOps(ws, [addOp(ws, slug, over)]), home }); + assert.equal(applied.exitCode, 0, JSON.stringify(applied.rejected)); + assert.equal(applied.layer, 'branch'); + return applied; +} + +test('promote emits a reviewable, digest-bound op-set and apply lands it golden with tombstones and absorb-branch audit', () => { + const ws = featureWorkspace('feature/promo'); + const home = tempDir('promo-home-'); + seedBucketLearning(ws, home, 'claim-a'); + const { dir } = ensureStore(ws, { home }); + const key = branchKeyFor('feature/promo'); + + const emitted = buildPromotionOps({ workspace: ws, home, all: true }); + assert.equal(emitted.pass, true, emitted.blockedReason); + assert.equal(emitted.ops, 1); + assert.equal(emitted.remaining, 0); + assert.equal(emitted.bucketKey, key); + const opset = JSON.parse(fs.readFileSync(path.join(ws, PROMOTE_OPS_REL), 'utf8')); + assert.equal(opset.promotion.branchKey, key); + assert.match(opset.promotion.digest, /^[0-9a-f]{64}$/); + assert.equal(opset.ops[0].op, 'ADD'); + assert.equal(opset.ops[0].source.id, 'sql/claim-a'); + + // Apply in promotion mode — note: the source episode file could even be + // absent from this checkout; evidence re-validates from recorded hashes. + const applied = applyOps({ workspace: ws, opsPath: path.join(ws, PROMOTE_OPS_REL), home }); + assert.equal(applied.exitCode, 0, JSON.stringify(applied.rejected)); + + // Golden now carries the claim. + const golden = listLearnings(dir).find((l) => l.id === 'sql/claim-a'); + assert.ok(golden, 'promoted claim lives golden'); + assert.equal(golden.fm.branch, 'feature/promo', 'branch provenance carried forward'); + // Golden ledger consumed the episodes. + assert.ok(readLedger(dir).some((e) => e.learning === 'sql/claim-a')); + + // Source tombstoned + excluded from retrieval; bucket prunable. + const bucketDir = bucketDirFor(dir, key); + const source = listLearnings(bucketDir).find((l) => l.id === 'sql/claim-a'); + assert.equal(source.fm.promoted_to_golden, 'sql/claim-a'); + assert.equal(retrievalExclusion(source), 'promoted-to-golden'); + + // absorb-branch recorded for AUDIT — never a standing decision. + const govLines = fs + .readFileSync(path.join(dir, 'governance.jsonl'), 'utf8') + .trim() + .split('\n') + .map((l) => JSON.parse(l)); + assert.ok(govLines.some((e) => e.id === 'sql/claim-a' && e.action === 'absorb-branch')); + assert.equal(readGovernance(dir).has('sql/claim-a'), false, 'absorb-branch never becomes a standing decision'); + + // The overlay no longer shadows golden with the tombstoned bucket entry. + const overlay = loadLayeredLearnings({ workspace: ws, home }); + const surfaced = overlay.learnings.filter((l) => l.id === 'sql/claim-a'); + assert.equal(surfaced.length, 1); + assert.equal(surfaced[0].layer, undefined, 'golden claim surfaces, tombstoned bucket copy does not'); +}); + +test('REPLAY RULE regression: retire → absorb-branch → rebuild --yes still lands retired', () => { + const ws = featureWorkspace('feature/replay'); + const home = tempDir('promo-home2-'); + git(ws, ['checkout', '-q', 'main']); + const ep = writeEpisode(ws, 'docs/solutions/perf/replayed.md'); + const applied = applyOps({ workspace: ws, opsPath: writeOps(ws, [addOp(ws, 'replayed', { episodes: [ep] })]), home }); + assert.equal(applied.exitCode, 0, JSON.stringify(applied.rejected)); + assert.equal(applied.layer, 'golden'); + const { dir } = ensureStore(ws, { home }); + + // 1) A human retires the id — the standing decision. + appendGovernance(dir, { id: 'sql/replayed', action: 'retire', reason: 'human veto', to: null, at: new Date().toISOString() }); + // 2) A LATER absorb-branch audit entry lands for the same id. + appendGovernance(dir, { id: 'sql/replayed', action: 'absorb-branch', reason: 'promoted from feature-x-00000000', to: null, at: new Date().toISOString() }); + assert.equal(readGovernance(dir).get('sql/replayed').action, 'retire', 'replay skips absorb-branch — retire stands'); + + // 3) Rebuild wipes the corpus; the ledger survives. + const rebuilt = rebuildStore({ workspace: ws, home, yes: true, copilotHome: tempDir('promo-ch-') }); + assert.equal(rebuilt.pass, true, rebuilt.blockedReason); + + // 4) A fresh consolidation regenerates the id — governance reapplication + // must land it RETIRED, not whatever the fresh op claims. + const again = applyOps({ workspace: ws, opsPath: writeOps(ws, [addOp(ws, 'replayed', { episodes: [ep] })]), home }); + assert.equal(again.exitCode, 0, JSON.stringify(again.rejected)); + assert.deepEqual(again.governed, [{ id: 'sql/replayed', action: 'retire' }]); + const learning = listLearnings(dir).find((l) => l.id === 'sql/replayed'); + assert.equal(learning.fm.status, 'retired', 'the standing retire survived the absorb-branch audit entry and the rebuild'); +}); + +test('promotion of a shadowing claim maps to SUPERSEDE; a protected golden target rejects and disputes without strikes', () => { + const ws = featureWorkspace('feature/shadowed'); + const home = tempDir('promo-home3-'); + const { dir } = ensureStore(ws, { home }); + // Protected golden twin: source human. + fs.mkdirSync(path.join(dir, 'learnings', 'sql'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'learnings', 'sql', 'guarded.md'), + `---\nschema: 1\ntrigger: "guarded trigger"\nstatus: active\nsource: human\nepisodes:\nanchors: []\nsuperseded_by: null\nlast_confirmed: null\norigin: t\n---\n\nProtected golden claim.\n` + ); + seedBucketLearning(ws, home, 'guarded', { trigger: 'guarded trigger v2', body: 'Branch challenger claim.' }); + + const emitted = buildPromotionOps({ workspace: ws, home, all: true }); + assert.equal(emitted.pass, true, emitted.blockedReason); + const opset = JSON.parse(fs.readFileSync(path.join(ws, PROMOTE_OPS_REL), 'utf8')); + assert.equal(opset.ops[0].op, 'SUPERSEDE', 'shadow-of-golden maps to SUPERSEDE'); + assert.equal(opset.ops[0].target, 'sql/guarded'); + + const applied = applyOps({ workspace: ws, opsPath: path.join(ws, PROMOTE_OPS_REL), home }); + assert.equal(applied.exitCode, 0, 'dispute path exits 0 with E_DISPUTED rejection recorded'); + assert.equal(applied.rejected[0]?.code, 'E_DISPUTED'); + const golden = listLearnings(dir).find((l) => l.id === 'sql/guarded'); + assert.equal(golden.fm.status, 'disputed', 'protected target marked disputed for human review'); + assert.match(golden.body, /Protected golden claim/, 'protected claim body never overwritten'); + // Promotion rejections never strike: no failure entries anywhere. + assert.equal(readLedger(dir).filter((e) => e.failure).length, 0); + assert.equal(readLedger(bucketDirFor(dir, branchKeyFor('feature/shadowed'))).filter((e) => e.failure).length, 0); +}); + +test('episodes-only overlap maps to STRENGTHEN; identical claims are skipped', () => { + const ws = featureWorkspace('feature/overlap'); + const home = tempDir('promo-home4-'); + const { dir } = ensureStore(ws, { home }); + const goldenEp = writeEpisode(ws, 'docs/solutions/perf/golden-ev.md'); + // Unprotected golden twin with identical trigger+body. + fs.mkdirSync(path.join(dir, 'learnings', 'sql'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'learnings', 'sql', 'same-claim.md'), + `---\nschema: 1\ntrigger: "same trigger"\nstatus: active\nsource: auto\nepisodes:\n - path: ${goldenEp.path}\n sha256: "${goldenEp.sha256}"\n kind: fix\n plan: docs/plans/p1.md\nanchors: []\nsuperseded_by: null\nlast_confirmed: null\norigin: t\n---\n\nShared claim body.\n` + ); + const branchEp = writeEpisode(ws, 'docs/solutions/perf/branch-ev.md'); + seedBucketLearning(ws, home, 'same-claim', { trigger: 'same trigger', body: 'Shared claim body.', episodes: [branchEp] }); + + const emitted = buildPromotionOps({ workspace: ws, home, all: true }); + assert.equal(emitted.pass, true, emitted.blockedReason); + const opset = JSON.parse(fs.readFileSync(path.join(ws, PROMOTE_OPS_REL), 'utf8')); + assert.equal(opset.ops[0].op, 'STRENGTHEN', 'episodes-only overlap maps to STRENGTHEN'); + assert.deepEqual(opset.ops[0].episodes.map((e) => e.path), [branchEp.path]); + + const applied = applyOps({ workspace: ws, opsPath: path.join(ws, PROMOTE_OPS_REL), home }); + assert.equal(applied.exitCode, 0, JSON.stringify(applied.rejected)); + const golden = listLearnings(dir).find((l) => l.id === 'sql/same-claim'); + assert.equal(golden.fm.episodes.length, 2, 'golden gained the branch evidence'); + + // A second promote now finds only a tombstoned source — nothing promotable. + const again = buildPromotionOps({ workspace: ws, home, all: true }); + assert.equal(again.pass, false); + assert.match(again.blockedReason, /nothing promotable/); +}); + +test('--all chunks under MAX_OPS_PER_RUN with deterministic ordering and remaining count', () => { + const ws = featureWorkspace('feature/chunky'); + const home = tempDir('promo-home5-'); + for (let i = 0; i < 7; i++) seedBucketLearning(ws, home, `chunk-${String(i).padStart(2, '0')}`); + + const emitted = buildPromotionOps({ workspace: ws, home, all: true }); + assert.equal(emitted.pass, true, emitted.blockedReason); + assert.equal(emitted.ops, 5, 'chunked at MAX_OPS_PER_RUN'); + assert.equal(emitted.remaining, 2); + const opset = JSON.parse(fs.readFileSync(path.join(ws, PROMOTE_OPS_REL), 'utf8')); + assert.deepEqual( + opset.ops.map((o) => o.source.id), + ['sql/chunk-00', 'sql/chunk-01', 'sql/chunk-02', 'sql/chunk-03', 'sql/chunk-04'], + 'deterministic id ordering is the cursor' + ); + + const applied = applyOps({ workspace: ws, opsPath: path.join(ws, PROMOTE_OPS_REL), home }); + assert.equal(applied.exitCode, 0, JSON.stringify(applied.rejected)); + + // Next emission drains the remainder — the tombstoned five drop out. + const next = buildPromotionOps({ workspace: ws, home, all: true }); + assert.equal(next.pass, true, next.blockedReason); + assert.equal(next.ops, 2); + assert.equal(next.remaining, 0); + const nextSet = JSON.parse(fs.readFileSync(path.join(ws, PROMOTE_OPS_REL), 'utf8')); + assert.deepEqual(nextSet.ops.map((o) => o.source.id), ['sql/chunk-05', 'sql/chunk-06']); +}); + +test('a tampered promote-ops file is rejected by the digest binding (no strikes)', () => { + const ws = featureWorkspace('feature/tamper'); + const home = tempDir('promo-home6-'); + seedBucketLearning(ws, home, 'tampered'); + const emitted = buildPromotionOps({ workspace: ws, home, all: true }); + assert.equal(emitted.pass, true, emitted.blockedReason); + const opsFull = path.join(ws, PROMOTE_OPS_REL); + const opset = JSON.parse(fs.readFileSync(opsFull, 'utf8')); + opset.ops[0].body = 'Tampered body.'; + fs.writeFileSync(opsFull, JSON.stringify(opset)); + + const applied = applyOps({ workspace: ws, opsPath: opsFull, home }); + assert.equal(applied.exitCode, 1); + assert.match(applied.rejected[0].reason, /digest mismatch/); + const { dir } = ensureStore(ws, { home }); + assert.equal(readLedger(dir).filter((e) => e.failure).length, 0, 'digest rejection never strikes'); +}); + +test('governed and detached sources are refused at emit time', () => { + const ws = featureWorkspace('feature/governed-promo'); + const home = tempDir('promo-home7-'); + seedBucketLearning(ws, home, 'vetoed'); + const { dir } = ensureStore(ws, { home }); + appendGovernance(dir, { id: 'sql/vetoed', action: 'retire', reason: 'human veto', to: null, at: new Date().toISOString() }); + + const emitted = buildPromotionOps({ workspace: ws, home, all: true }); + assert.equal(emitted.pass, false, 'only governed sources → nothing promotable'); + assert.ok(emitted.skipped.some((s) => s.id === 'sql/vetoed' && /standing governance/.test(s.reason))); + + const detached = buildPromotionOps({ workspace: ws, home, branchKey: 'detached-abcdefabcdef', all: true }); + assert.equal(detached.pass, false); + assert.match(detached.blockedReason, /never promotable/); +}); + +test('prune removes buckets by key, by merged/tombstoned state, and by staleness in one store commit', () => { + const ws = featureWorkspace('feature/prunable'); + const home = tempDir('promo-home8-'); + seedBucketLearning(ws, home, 'landed'); + const { dir } = ensureStore(ws, { home }); + const key = branchKeyFor('feature/prunable'); + + // Promote everything → the bucket becomes fully tombstoned (prunable). + const emitted = buildPromotionOps({ workspace: ws, home, all: true }); + assert.equal(emitted.pass, true, emitted.blockedReason); + assert.equal(applyOps({ workspace: ws, opsPath: path.join(ws, PROMOTE_OPS_REL), home }).exitCode, 0); + + // --merged sweeps the fully-tombstoned bucket. + const merged = pruneBuckets({ workspace: ws, home, merged: true }); + assert.equal(merged.pass, true, merged.blockedReason); + assert.deepEqual(merged.removed, [key]); + assert.deepEqual(listBuckets(dir), []); + const gitLog = spawnSync('git', ['log', '--oneline', '-1'], { cwd: dir, encoding: 'utf8' }).stdout; + assert.match(gitLog, /knowledge: prune/); + + // --branch removes an explicit bucket; --stale sweeps by age. + const bucketDir = bucketDirFor(dir, 'old-branch-11111111'); + fs.mkdirSync(path.join(bucketDir, 'learnings'), { recursive: true }); + fs.writeFileSync( + path.join(bucketDir, 'meta.json'), + JSON.stringify({ branch: 'old-branch', branchKey: 'old-branch-11111111', createdAt: new Date(Date.now() - 40 * 86_400_000).toISOString() }) + '\n' + ); + const stale = pruneBuckets({ workspace: ws, home, staleDays: 30 }); + assert.equal(stale.pass, true, stale.blockedReason); + assert.deepEqual(stale.removed, ['old-branch-11111111']); + + // Selector required; unknown key refused. + assert.match(pruneBuckets({ workspace: ws, home }).blockedReason, /needs --branch/); + assert.match(pruneBuckets({ workspace: ws, home, branchKey: 'nope-00000000' }).blockedReason, /nothing to prune|no bucket/); +}); diff --git a/packages/harness/test/knowledge-status.test.mjs b/packages/harness/test/knowledge-status.test.mjs new file mode 100644 index 00000000..7c1181d7 --- /dev/null +++ b/packages/harness/test/knowledge-status.test.mjs @@ -0,0 +1,160 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; +import { ensureStore, storeDir } from '../lib/knowledge/store.mjs'; +import { knowledgeStatus } from '../lib/knowledge/status.mjs'; +import { branchKeyFor } from '../lib/git-context.mjs'; + +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const binPath = path.join(packageRoot, 'bin', 'harness.mjs'); +const tempDir = (p) => fs.mkdtempSync(path.join(os.tmpdir(), p)); + +function git(cwd, args) { + return spawnSync('git', args, { + cwd, + encoding: 'utf8', + env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' }, + }); +} + +function gitWorkspace(branch = 'feature/status') { + const ws = tempDir('kstatus-ws-'); + git(ws, ['init', '-q', '-b', branch]); + git(ws, ['config', 'user.email', 'test@example.test']); + git(ws, ['config', 'user.name', 'Test']); + fs.writeFileSync(path.join(ws, 'seed.txt'), 'seed\n'); + git(ws, ['add', '.']); + git(ws, ['commit', '-qm', 'seed']); + return ws; +} + +function head(ws) { + return git(ws, ['rev-parse', 'HEAD']).stdout.trim(); +} + +function writeLearning(root, id, { status = 'active', tombstone = null } = {}) { + const [domain, slug] = id.split('/'); + const dir = path.join(root, 'learnings', domain); + fs.mkdirSync(dir, { recursive: true }); + const extra = tombstone ? `promoted_to_golden: ${tombstone}\n` : ''; + fs.writeFileSync( + path.join(dir, `${slug}.md`), + `---\nschema: 1\ntrigger: "t ${slug}"\nstatus: ${status}\nsource: auto\nepisodes:\nanchors: []\nsuperseded_by: null\nlast_confirmed: null\n${extra}origin: test\n---\n\nBody.\n`, + 'utf8' + ); +} + +function writeBucket(dir, key, meta = {}) { + const bucketDir = path.join(dir, 'branches', key); + fs.mkdirSync(path.join(bucketDir, 'learnings'), { recursive: true }); + fs.writeFileSync(path.join(bucketDir, 'meta.json'), JSON.stringify(meta) + '\n'); + return bucketDir; +} + +function runHarness(args, env = {}) { + return spawnSync(process.execPath, [binPath, ...args], { + cwd: packageRoot, + encoding: 'utf8', + env: { ...process.env, ...env }, + }); +} + +test('knowledge status reports golden per-domain counts and bucket rows', () => { + const ws = gitWorkspace('feature/status'); + const home = tempDir('kstatus-home-'); + const { dir } = ensureStore(ws, { home }); + writeLearning(dir, 'sql/one'); + writeLearning(dir, 'sql/two', { status: 'retired' }); + writeLearning(dir, 'aws/three'); + + const key = branchKeyFor('feature/status'); + const createdAt = new Date(Date.now() - 3 * 86_400_000).toISOString(); + const bucketDir = writeBucket(dir, key, { branch: 'feature/status', branchKey: key, baseSha: head(ws), createdAt, promotable: true }); + writeLearning(bucketDir, 'sql/branch-claim'); + const detachedDir = writeBucket(dir, 'detached-abcdefabcdef', { branch: null, branchKey: 'detached-abcdefabcdef' }); + writeLearning(detachedDir, 'sql/experiment', { tombstone: 'sql/one' }); + + const report = knowledgeStatus({ workspace: ws, home }); + assert.equal(report.storeExists, true); + assert.deepEqual(report.golden.domains, [ + { domain: 'aws', active: 1, total: 1 }, + { domain: 'sql', active: 1, total: 2 }, + ]); + assert.equal(report.golden.active, 2); + assert.equal(report.golden.total, 3); + assert.equal(report.context.branch, 'feature/status'); + assert.equal(report.context.branchKey, key); + + const buckets = Object.fromEntries(report.buckets.map((b) => [b.key, b])); + const current = buckets[key]; + assert.equal(current.branch, 'feature/status'); + assert.equal(current.promotable, true); + assert.equal(current.active, 1); + assert.equal(current.ageDays, 3); + assert.equal(current.baseSha, head(ws)); + assert.equal(current.ancestryOk, true); + assert.equal(current.prunable, false); + + const detached = buckets['detached-abcdefabcdef']; + assert.equal(detached.promotable, false, 'detached-* is derived never-promotable from the key shape'); + assert.equal(detached.promoted, 1); + assert.equal(detached.active, 0); + assert.equal(detached.prunable, true, 'a fully tombstoned bucket is prunable'); + assert.equal(detached.ancestryOk, null, 'no recorded base — unverifiable, not excluded'); +}); + +test('a bucket with a non-ancestor base is flagged ancestryOk: false', () => { + const ws = gitWorkspace('feature/reuse'); + const home = tempDir('kstatus-home2-'); + const { dir } = ensureStore(ws, { home }); + writeBucket(dir, branchKeyFor('feature/reuse'), { branch: 'feature/reuse', baseSha: 'e'.repeat(40) }); + const report = knowledgeStatus({ workspace: ws, home }); + assert.equal(report.buckets[0].ancestryOk, false); +}); + +test('knowledge status is read-only and never materializes a store', () => { + const ws = gitWorkspace('feature/empty'); + const home = tempDir('kstatus-home3-'); + const report = knowledgeStatus({ workspace: ws, home }); + assert.equal(report.storeExists, false); + assert.deepEqual(report.golden, { active: 0, total: 0, domains: [] }); + assert.deepEqual(report.buckets, []); + assert.equal(fs.existsSync(storeDir(ws, { home })), false, 'status must not create the store'); +}); + +test('CLI: harness knowledge status --json emits the report and a knowledge event', () => { + const ws = gitWorkspace('feature/cli-status'); + const harnessHome = tempDir('kstatus-hh-'); + const copilotHome = tempDir('kstatus-ch-'); + const { dir } = ensureStore(ws, { home: harnessHome }); + writeLearning(dir, 'sql/cli-claim'); + + const res = runHarness(['knowledge', 'status', '--workspace', ws, '--copilot-home', copilotHome, '--json'], { + HARNESS_HOME: harnessHome, + }); + assert.equal(res.status, 0, res.stderr || res.stdout); + const body = JSON.parse(res.stdout); + assert.equal(body.pass, true); + assert.equal(body.golden.active, 1); + assert.equal(body.mode, 'on'); + assert.ok(body.drift, 'drift line present'); + + const events = fs + .readFileSync(path.join(ws, '.harness', 'events.jsonl'), 'utf8') + .trim() + .split('\n') + .map((l) => JSON.parse(l)); + assert.ok(events.some((e) => e.type === 'knowledge' && e.decision === 'status'), 'knowledge event emitted'); + + // Styled (non-JSON) rendering also exits 0 and shows the ledger rows. + const human = runHarness(['knowledge', 'status', '--workspace', ws, '--copilot-home', copilotHome], { + HARNESS_HOME: harnessHome, + }); + assert.equal(human.status, 0, human.stderr || human.stdout); + assert.match(human.stdout, /golden/); + assert.match(human.stdout, /sql/); +}); diff --git a/packages/harness/test/layer-maintenance.test.mjs b/packages/harness/test/layer-maintenance.test.mjs new file mode 100644 index 00000000..a7a5ab62 --- /dev/null +++ b/packages/harness/test/layer-maintenance.test.mjs @@ -0,0 +1,165 @@ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { test } from 'node:test'; +import { ensureStore, listLearnings, readLedger, readGovernance, appendGovernance } from '../lib/knowledge/store.mjs'; +import { purgeEpisode, purgeAll, rebuildStore } from '../lib/knowledge/admin.mjs'; +import { ensureBucket } from '../lib/knowledge/layer.mjs'; +import { bucketDirFor, readBucketMeta, listBuckets } from '../lib/knowledge/overlay.mjs'; +import { branchKeyFor } from '../lib/git-context.mjs'; + +const tempDir = (p) => fs.mkdtempSync(path.join(os.tmpdir(), p)); + +function git(cwd, args) { + return spawnSync('git', args, { + cwd, + encoding: 'utf8', + env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' }, + }); +} + +function gitWorkspace(branch = 'main') { + const ws = tempDir('lmaint-ws-'); + git(ws, ['init', '-q', '-b', branch]); + git(ws, ['config', 'user.email', 't@example.test']); + git(ws, ['config', 'user.name', 'T']); + fs.writeFileSync(path.join(ws, 'seed.txt'), 'seed\n'); + git(ws, ['add', '.']); + git(ws, ['commit', '-qm', 'seed']); + return ws; +} + +function writeEpisodeFile(ws, rel) { + const full = path.join(ws, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + const text = `fix evidence for ${rel}.\n`; + fs.writeFileSync(full, text, 'utf8'); + return { rel, sha256: crypto.createHash('sha256').update(text).digest('hex') }; +} + +function writeLearning(root, id, { episodes = [], body = 'Body.' } = {}) { + const [domain, slug] = id.split('/'); + const dir = path.join(root, 'learnings', domain); + fs.mkdirSync(dir, { recursive: true }); + const epLines = episodes.length + ? ['episodes:', ...episodes.flatMap((e) => [ + ` - path: ${e.rel}`, + ` sha256: "${e.sha256}"`, + ' kind: fix', + ' plan: docs/plans/p1.md', + ])] + : ['episodes:']; + fs.writeFileSync( + path.join(dir, `${slug}.md`), + ['---', 'schema: 1', `trigger: "t ${slug}"`, 'status: active', 'source: auto', ...epLines, 'anchors: []', 'superseded_by: null', 'last_confirmed: null', 'origin: t', '---', '', body, ''].join('\n'), + 'utf8' + ); +} + +test('purge cascades across golden AND bucket layers: files, links, ledgers, and index rows all go', () => { + const ws = gitWorkspace(); + const home = tempDir('lmaint-home-'); + const { dir } = ensureStore(ws, { home }); + const ep = writeEpisodeFile(ws, 'docs/solutions/perf/shared.md'); + const other = writeEpisodeFile(ws, 'docs/solutions/perf/other.md'); + + // Golden: one learning solely backed by the episode, one multi-evidence. + writeLearning(dir, 'sql/solely-golden', { episodes: [ep] }); + writeLearning(dir, 'sql/multi-golden', { episodes: [ep, other] }); + // Bucket: one learning solely backed by the same episode. + const key = branchKeyFor('feature/purge'); + const bucketDir = ensureBucket(dir, { key, branch: 'feature/purge' }); + writeLearning(bucketDir, 'sql/solely-bucket', { episodes: [ep] }); + // Ledger entries in both layers. + fs.appendFileSync(path.join(dir, 'consolidated.jsonl'), JSON.stringify({ path: ep.rel, sha256: ep.sha256, learning: 'sql/solely-golden', at: '2026-08-01' }) + '\n'); + fs.appendFileSync(path.join(bucketDir, 'consolidated.jsonl'), JSON.stringify({ path: ep.rel, sha256: ep.sha256, learning: 'sql/solely-bucket', at: '2026-08-01' }) + '\n'); + spawnSync('git', ['add', '-A'], { cwd: dir }); + spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'fixture'], { cwd: dir }); + + const result = purgeEpisode({ workspace: ws, target: ep.rel, copilotHome: tempDir('lmaint-ch-'), home }); + assert.equal(result.pass, true, result.blockedReason); + assert.deepEqual(result.removed.learnings.sort(), ['sql/solely-bucket', 'sql/solely-golden']); + assert.deepEqual(result.removed.links, ['sql/multi-golden']); + assert.equal(result.removed.ledger, 2, 'both layers ledger-cleaned'); + + assert.ok(!fs.existsSync(path.join(ws, ep.rel)), 'episode file deleted'); + assert.deepEqual(listLearnings(dir).map((l) => l.id), ['sql/multi-golden']); + assert.deepEqual(listLearnings(bucketDir), []); + assert.equal(readLedger(dir).length, 0); + assert.equal(readLedger(bucketDir).length, 0); + const multi = listLearnings(dir)[0]; + assert.deepEqual(multi.fm.episodes.map((e) => e.path), [other.rel]); +}); + +test('purge keeps the governance record while the id survives in ANY layer, drops it only when fully gone', () => { + const ws = gitWorkspace(); + const home = tempDir('lmaint-home2-'); + const { dir } = ensureStore(ws, { home }); + const ep = writeEpisodeFile(ws, 'docs/solutions/perf/gov.md'); + const goldenEp = writeEpisodeFile(ws, 'docs/solutions/perf/golden-own.md'); + + // Same id in both layers: bucket copy backed solely by ep, golden by its own. + writeLearning(dir, 'sql/dual', { episodes: [goldenEp] }); + const key = branchKeyFor('feature/gov'); + const bucketDir = ensureBucket(dir, { key, branch: 'feature/gov' }); + writeLearning(bucketDir, 'sql/dual', { episodes: [ep] }); + appendGovernance(dir, { id: 'sql/dual', action: 'dispute', reason: 'r', to: null, at: new Date().toISOString() }); + spawnSync('git', ['add', '-A'], { cwd: dir }); + spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'fixture'], { cwd: dir }); + + // Purging ep removes only the bucket copy — the golden twin survives, so + // the governance record must survive with it. + const first = purgeEpisode({ workspace: ws, target: ep.rel, copilotHome: tempDir('lmaint-ch2-'), home }); + assert.equal(first.pass, true, first.blockedReason); + assert.deepEqual(first.removed.learnings, ['sql/dual']); + assert.ok(readGovernance(dir).has('sql/dual'), 'governance survives while the golden twin exists'); + + // Purging the golden twin's own evidence removes the last copy — now the + // governance record goes too. + const second = purgeEpisode({ workspace: ws, target: goldenEp.rel, copilotHome: tempDir('lmaint-ch3-'), home }); + assert.equal(second.pass, true, second.blockedReason); + assert.ok(!readGovernance(dir).has('sql/dual'), 'governance dropped once no layer holds the id'); +}); + +test('purge --all wipes branches/ whole and counts bucket learnings', () => { + const ws = gitWorkspace(); + const home = tempDir('lmaint-home3-'); + const { dir } = ensureStore(ws, { home }); + writeLearning(dir, 'sql/golden-claim'); + const bucketDir = ensureBucket(dir, { key: branchKeyFor('feature/wipe'), branch: 'feature/wipe' }); + writeLearning(bucketDir, 'sql/bucket-claim'); + spawnSync('git', ['add', '-A'], { cwd: dir }); + spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'fixture'], { cwd: dir }); + + const result = purgeAll({ workspace: ws, home }); + assert.equal(result.pass, true, result.blockedReason); + assert.equal(result.removed.learnings, 2); + assert.ok(!fs.existsSync(path.join(dir, 'branches')), 'branches/ wiped whole'); + assert.deepEqual(listLearnings(dir), []); +}); + +test('rebuild --yes wipes each bucket per layer but keeps bucket meta as the layer identity', () => { + const ws = gitWorkspace(); + const home = tempDir('lmaint-home4-'); + const { dir } = ensureStore(ws, { home }); + writeLearning(dir, 'sql/golden-claim'); + const key = branchKeyFor('feature/rebuild'); + const bucketDir = ensureBucket(dir, { key, branch: 'feature/rebuild' }); + writeLearning(bucketDir, 'sql/bucket-claim'); + fs.appendFileSync(path.join(bucketDir, 'consolidated.jsonl'), JSON.stringify({ path: 'x.md', sha256: 'a'.repeat(64), learning: 'sql/bucket-claim', at: '2026-08-01' }) + '\n'); + spawnSync('git', ['add', '-A'], { cwd: dir }); + spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'fixture'], { cwd: dir }); + + const result = rebuildStore({ workspace: ws, home, yes: true, copilotHome: tempDir('lmaint-ch4-') }); + assert.equal(result.pass, true, result.blockedReason); + assert.equal(result.archived, 2, 'golden + bucket learnings both archived'); + assert.deepEqual(listLearnings(dir), []); + assert.deepEqual(listLearnings(bucketDir), []); + assert.equal(readLedger(bucketDir).length, 0, 'bucket ledger truncated'); + const meta = readBucketMeta(bucketDir); + assert.equal(meta.branch, 'feature/rebuild', 'bucket meta survives as the layer identity'); + assert.deepEqual(listBuckets(dir).map((b) => b.key), [key]); +}); diff --git a/packages/harness/test/layer-routing.test.mjs b/packages/harness/test/layer-routing.test.mjs new file mode 100644 index 00000000..a43ad887 --- /dev/null +++ b/packages/harness/test/layer-routing.test.mjs @@ -0,0 +1,317 @@ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { test } from 'node:test'; +import { ensureStore, listLearnings, readLedger, STORE_SCHEMA } from '../lib/knowledge/store.mjs'; +import { applyOps } from '../lib/knowledge/apply.mjs'; +import { resolveWriteLayer, ensureBucket, migrateRenamedBucket, episodeEligibleForLayer } from '../lib/knowledge/layer.mjs'; +import { bucketDirFor, readBucketMeta, listBuckets } from '../lib/knowledge/overlay.mjs'; +import { branchKeyFor, detachedKeyFor } from '../lib/git-context.mjs'; +import { absorbHandEdits, mirrorLearnings } from '../lib/knowledge/admin.mjs'; +import { runDoctor } from '../lib/doctor.mjs'; +import { writeSession } from '../lib/session.mjs'; + +const tempDir = (p) => fs.mkdtempSync(path.join(os.tmpdir(), p)); + +function git(cwd, args) { + return spawnSync('git', args, { + cwd, + encoding: 'utf8', + env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' }, + }); +} + +/** Cloned workspace: origin/HEAD → main is resolvable, like a real checkout. */ +function clonedWorkspace() { + const origin = tempDir('route-origin-'); + git(origin, ['init', '-q', '-b', 'main']); + git(origin, ['config', 'user.email', 't@example.test']); + git(origin, ['config', 'user.name', 'T']); + fs.writeFileSync(path.join(origin, 'seed.txt'), 'seed\n'); + git(origin, ['add', '.']); + git(origin, ['commit', '-qm', 'seed']); + const ws = tempDir('route-ws-'); + git(ws, ['clone', '-q', origin, '.']); + git(ws, ['config', 'user.email', 't@example.test']); + git(ws, ['config', 'user.name', 'T']); + return ws; +} + +function head(ws) { + return git(ws, ['rev-parse', 'HEAD']).stdout.trim(); +} + +function writeEpisode(ws, rel, { branch = null } = {}) { + const full = path.join(ws, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + const fm = branch ? `---\ntitle: "${rel}"\ndate: 2026-07-01\nbranch: "${branch}"\n---\n\n` : ''; + const text = `${fm}fix evidence for ${rel}.\n`; + fs.writeFileSync(full, text, 'utf8'); + return { path: rel, sha256: crypto.createHash('sha256').update(text).digest('hex'), kind: 'fix', plan: 'docs/plans/p1.md' }; +} + +function writeOps(ws, ops) { + const p = path.join(ws, `ops-${crypto.randomUUID()}.json`); + fs.writeFileSync(p, JSON.stringify({ schema: 1, ops })); + return p; +} + +function addOp(ws, over = {}) { + return { + op: 'ADD', + domain: 'sql', + slug: over.slug || 'routed-claim', + trigger: 'routing trigger tokens', + body: 'Routed claim body.', + episodes: over.episodes || [writeEpisode(ws, `docs/solutions/perf/${over.slug || 'routed-claim'}.md`)], + ...over, + }; +} + +test('routing table: default branch → golden; feature branch → bucket; detached → detached bucket; --layer golden overrides', () => { + const ws = clonedWorkspace(); + const home = tempDir('route-home-'); + + // On main (the origin/HEAD default): golden. + let routing = resolveWriteLayer({ workspace: ws, home }); + assert.equal(routing.layer, 'golden'); + assert.equal(routing.defaultBranch.name, 'main'); + + // Feature branch: bucket, keyed deterministically. + git(ws, ['checkout', '-qb', 'feature/route']); + routing = resolveWriteLayer({ workspace: ws, home }); + assert.equal(routing.layer, 'branch'); + assert.equal(routing.bucketKey, branchKeyFor('feature/route')); + assert.ok(!routing.failedClosed, 'resolvable default — routing is a plain feature-branch route, not fail-closed'); + + // Explicit override: golden, flagged as an override. + routing = resolveWriteLayer({ workspace: ws, home, layerOverride: 'golden' }); + assert.equal(routing.layer, 'golden'); + assert.equal(routing.override, true); + + // Detached HEAD: detached bucket. + git(ws, ['checkout', '-q', '--detach']); + routing = resolveWriteLayer({ workspace: ws, home }); + assert.equal(routing.layer, 'branch'); + assert.equal(routing.detached, true); + assert.equal(routing.bucketKey, detachedKeyFor(head(ws))); +}); + +test('unresolvable default branch fails closed to branch-local, never golden', () => { + const ws = tempDir('route-noremote-'); + git(ws, ['init', '-q', '-b', 'main']); + git(ws, ['config', 'user.email', 't@example.test']); + git(ws, ['config', 'user.name', 'T']); + fs.writeFileSync(path.join(ws, 'a.txt'), 'a\n'); + git(ws, ['add', '.']); + git(ws, ['commit', '-qm', 'a']); + const routing = resolveWriteLayer({ workspace: ws, home: tempDir('route-home2-') }); + assert.equal(routing.layer, 'branch'); + assert.equal(routing.failedClosed, true); +}); + +test('a feature-branch apply lands in the bucket with meta.json, ledger, and INDEX — golden untouched', () => { + const ws = clonedWorkspace(); + const home = tempDir('route-home3-'); + git(ws, ['checkout', '-qb', 'feature/bucket-write']); + const mainTip = git(ws, ['rev-parse', 'origin/main']).stdout.trim(); + + const applied = applyOps({ workspace: ws, opsPath: writeOps(ws, [addOp(ws)]), home }); + assert.equal(applied.exitCode, 0, JSON.stringify(applied.rejected)); + assert.equal(applied.layer, 'branch'); + const key = branchKeyFor('feature/bucket-write'); + assert.equal(applied.bucketKey, key); + + const { dir } = ensureStore(ws, { home }); + assert.equal(listLearnings(dir).length, 0, 'golden layer untouched'); + const bucketDir = bucketDirFor(dir, key); + const bucketLearnings = listLearnings(bucketDir); + assert.equal(bucketLearnings.length, 1); + assert.equal(bucketLearnings[0].id, 'sql/routed-claim'); + assert.equal(readLedger(bucketDir).length, 1, 'episode consumed in the BUCKET ledger'); + assert.equal(readLedger(dir).length, 0, 'golden ledger untouched'); + assert.match(fs.readFileSync(path.join(bucketDir, 'INDEX.md'), 'utf8'), /routed-claim/); + + const meta = readBucketMeta(bucketDir); + assert.equal(meta.branch, 'feature/bucket-write'); + assert.equal(meta.branchKey, key); + assert.equal(meta.baseSha, mainTip); + assert.equal(meta.promotable, true); + assert.ok(meta.createdAt); + + // On the default branch the same episode is NOT golden-eligible (P4): its + // provenance-less shape routes to branch review once buckets exist. + git(ws, ['checkout', '-q', 'main']); + const ep = writeEpisode(ws, 'docs/solutions/perf/foreign.md', { branch: 'feature/bucket-write' }); + const goldenAttempt = applyOps({ workspace: ws, opsPath: writeOps(ws, [addOp(ws, { slug: 'laundered', episodes: [ep] })]), home }); + assert.equal(goldenAttempt.exitCode, 1); + assert.match(goldenAttempt.rejected[0].reason, /not a current unconsolidated candidate/); +}); + +test('golden lane accepts default-branch-provenance episodes; branch lane accepts own and provenance-less ones (P4)', () => { + assert.equal(episodeEligibleForLayer('main', { layer: 'golden', currentBranch: 'main', defaultBranchName: 'main', storeHasBuckets: true }), true); + assert.equal(episodeEligibleForLayer('feature/x', { layer: 'golden', currentBranch: 'main', defaultBranchName: 'main', storeHasBuckets: true }), false); + assert.equal(episodeEligibleForLayer(null, { layer: 'golden', currentBranch: 'main', defaultBranchName: 'main', storeHasBuckets: true }), false, 'no provenance never silently golden'); + assert.equal(episodeEligibleForLayer(null, { layer: 'branch', currentBranch: 'feature/x', defaultBranchName: 'main', storeHasBuckets: true }), true); + assert.equal(episodeEligibleForLayer('feature/x', { layer: 'branch', currentBranch: 'feature/x', defaultBranchName: 'main', storeHasBuckets: true }), true); + assert.equal(episodeEligibleForLayer('feature/y', { layer: 'branch', currentBranch: 'feature/x', defaultBranchName: 'main', storeHasBuckets: true }), false); + // Bucket-less store: pre-layer behavior, everything eligible. + assert.equal(episodeEligibleForLayer(null, { layer: 'golden', currentBranch: null, defaultBranchName: null, storeHasBuckets: false }), true); +}); + +test('orient-recorded branch disagreement produces the advisory warning', () => { + const ws = clonedWorkspace(); + const home = tempDir('route-home4-'); + writeSession(ws, { sessionId: 's1', gitBranch: 'main' }); + git(ws, ['checkout', '-qb', 'feature/drifted']); + const warnings = []; + const routing = resolveWriteLayer({ workspace: ws, home, log: (m) => warnings.push(m) }); + assert.equal(routing.layer, 'branch'); + assert.match(routing.branchWarning, /oriented on branch main but writing from feature\/drifted/); + assert.ok(warnings.some((w) => /oriented on branch main/.test(w))); +}); + +test('commit-mode mirror stays golden-only: bucket learnings are never mirrored', () => { + const ws = clonedWorkspace(); + const home = tempDir('route-home5-'); + const { dir } = ensureStore(ws, { home }); + fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ mode: 'on', commit: 'repo' }) + '\n'); + + // Golden learning + bucket learning. + fs.mkdirSync(path.join(dir, 'learnings', 'sql'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'learnings', 'sql', 'golden-claim.md'), + `---\nschema: 1\ntrigger: "g"\nstatus: active\nsource: auto\nepisodes:\nanchors: []\nsuperseded_by: null\nlast_confirmed: null\norigin: t\n---\n\nGolden.\n` + ); + const bucketDir = ensureBucket(dir, { key: branchKeyFor('feature/m'), branch: 'feature/m' }); + fs.mkdirSync(path.join(bucketDir, 'learnings', 'sql'), { recursive: true }); + fs.writeFileSync( + path.join(bucketDir, 'learnings', 'sql', 'bucket-claim.md'), + `---\nschema: 1\ntrigger: "b"\nstatus: active\nsource: auto\nepisodes:\nanchors: []\nsuperseded_by: null\nlast_confirmed: null\norigin: t\n---\n\nBucket.\n` + ); + + const result = mirrorLearnings({ workspace: ws, home }); + assert.equal(result.mirrored, 1); + const mirrorRoot = path.join(ws, 'docs', 'knowledge', 'learnings'); + assert.ok(fs.existsSync(path.join(mirrorRoot, 'sql', 'golden-claim.md'))); + assert.ok(!fs.existsSync(path.join(mirrorRoot, 'sql', 'bucket-claim.md')), 'bucket learnings never mirrored'); + assert.doesNotMatch(fs.readFileSync(path.join(mirrorRoot, 'INDEX.md'), 'utf8'), /bucket-claim/); +}); + +test('a hand edit under branches//learnings/** is absorbed with the bucket recorded in the snapshot', () => { + const ws = clonedWorkspace(); + const home = tempDir('route-home6-'); + git(ws, ['checkout', '-qb', 'feature/hand']); + const applied = applyOps({ workspace: ws, opsPath: writeOps(ws, [addOp(ws, { slug: 'hand-claim' })]), home }); + assert.equal(applied.exitCode, 0, JSON.stringify(applied.rejected)); + + const { dir } = ensureStore(ws, { home }); + const key = branchKeyFor('feature/hand'); + const bucketDir = bucketDirFor(dir, key); + const learning = listLearnings(bucketDir).find((l) => l.id === 'sql/hand-claim'); + fs.writeFileSync(learning.file, fs.readFileSync(learning.file, 'utf8').replace('Routed claim body.', 'Hand-edited bucket body.'), 'utf8'); + + const absorbed = absorbHandEdits({ workspace: ws, home }); + assert.deepEqual(absorbed.absorbed.map((a) => a.id), ['sql/hand-claim']); + const after = listLearnings(bucketDir).find((l) => l.id === 'sql/hand-claim'); + assert.equal(after.fm.source, 'human'); + assert.match(after.body, /Hand-edited bucket body/); + const snapshot = absorbed.absorbed[0].snapshot; + assert.ok(snapshot, 'snapshot written'); + assert.match(fs.readFileSync(path.join(ws, snapshot), 'utf8'), new RegExp(`^bucket: "${key}"$`, 'm')); +}); + +test('a newer store schema makes this CLI refuse with an upgrade hint', () => { + const ws = clonedWorkspace(); + const home = tempDir('route-home7-'); + const { dir } = ensureStore(ws, { home }); + const recorded = JSON.parse(fs.readFileSync(path.join(dir, 'store.json'), 'utf8')); + assert.equal(recorded.schema, STORE_SCHEMA); + + fs.writeFileSync(path.join(dir, 'store.json'), JSON.stringify({ schema: STORE_SCHEMA + 1 }) + '\n'); + assert.throws( + () => ensureStore(ws, { home }), + (err) => err.code === 'E_STORE_SCHEMA' && /newer than this CLI supports/.test(err.message) && /@dev-kit\/harness/.test(err.hint) + ); + assert.throws(() => applyOps({ workspace: ws, opsPath: writeOps(ws, [addOp(ws, { slug: 'nope' })]), home })); +}); + +test('branch rename auto-migrates the bucket to the new key when unambiguous', () => { + const ws = clonedWorkspace(); + const home = tempDir('route-home8-'); + git(ws, ['checkout', '-qb', 'feature/old-name']); + const applied = applyOps({ workspace: ws, opsPath: writeOps(ws, [addOp(ws, { slug: 'renamed-claim' })]), home }); + assert.equal(applied.exitCode, 0, JSON.stringify(applied.rejected)); + const { dir } = ensureStore(ws, { home }); + const oldKey = branchKeyFor('feature/old-name'); + assert.ok(fs.existsSync(bucketDirFor(dir, oldKey))); + + // Rename the branch: the old branch name no longer exists anywhere. + git(ws, ['branch', '-m', 'feature/old-name', 'feature/new-name']); + const context = { branch: 'feature/new-name', branchKey: branchKeyFor('feature/new-name') }; + const migrated = migrateRenamedBucket(dir, { workspace: ws, context }); + assert.deepEqual(migrated, { migrated: true, from: oldKey, to: context.branchKey }); + assert.ok(!fs.existsSync(bucketDirFor(dir, oldKey))); + const meta = readBucketMeta(bucketDirFor(dir, context.branchKey)); + assert.equal(meta.branch, 'feature/new-name'); + assert.equal(meta.branchKey, context.branchKey); + const moved = listLearnings(bucketDirFor(dir, context.branchKey)); + assert.ok(moved.some((l) => l.id === 'sql/renamed-claim')); +}); + +test('doctor K5 flags orphan buckets and K6 flags misrouted bucket contents', () => { + const ws = clonedWorkspace(); + const home = tempDir('route-home9-'); + // Doctor reads the store via the UNSCOPED storeDir (HARNESS_HOME), so run + // it with HARNESS_HOME pointed at this test's home. + const { dir } = ensureStore(ws, { home }); + const orphanKey = branchKeyFor('feature/deleted-branch'); + const bucketDir = ensureBucket(dir, { key: orphanKey, branch: 'feature/deleted-branch' }); + fs.mkdirSync(path.join(bucketDir, 'learnings', 'sql'), { recursive: true }); + fs.writeFileSync( + path.join(bucketDir, 'learnings', 'sql', 'misrouted.md'), + `---\nschema: 1\ntrigger: "m"\nstatus: active\nsource: auto\nepisodes:\nanchors: []\nsuperseded_by: null\nlast_confirmed: null\norigin: t\nbranch: "feature/some-other-branch"\n---\n\nMisrouted.\n` + ); + + const prevHome = process.env.HARNESS_HOME; + process.env.HARNESS_HOME = home; + try { + const { checks } = runDoctor({ + copilotHome: tempDir('route-ch-'), + assetsRoot: tempDir('route-assets-'), + pkgRoot: null, + flags: { workspace: ws }, + workspace: ws, + }); + const k5 = checks.find((c) => c.id === 'K5'); + assert.ok(k5, 'K5 present'); + assert.equal(k5.pass, false); + assert.match(k5.hint, /knowledge prune/); + const k6 = checks.find((c) => c.id === 'K6'); + assert.ok(k6, 'K6 present'); + assert.equal(k6.pass, false); + assert.match(k6.hint, new RegExp(`${orphanKey}:sql/misrouted`)); + } finally { + if (prevHome === undefined) delete process.env.HARNESS_HOME; + else process.env.HARNESS_HOME = prevHome; + } +}); + +test('listBuckets sees a routed bucket and consolidate status reports the branch lane', async () => { + const ws = clonedWorkspace(); + const home = tempDir('route-home10-'); + git(ws, ['checkout', '-qb', 'feature/lane']); + const applied = applyOps({ workspace: ws, opsPath: writeOps(ws, [addOp(ws, { slug: 'lane-claim' })]), home }); + assert.equal(applied.exitCode, 0, JSON.stringify(applied.rejected)); + const { dir } = ensureStore(ws, { home }); + assert.deepEqual(listBuckets(dir).map((b) => b.key), [branchKeyFor('feature/lane')]); + + const { consolidateStatus } = await import('../lib/knowledge/consolidate.mjs'); + const status = consolidateStatus({ workspace: ws, home, copilotHome: tempDir('route-ch2-') }); + assert.equal(status.layer, 'branch'); + assert.equal(status.bucketKey, branchKeyFor('feature/lane')); + assert.equal(status.debt, 0, 'the bucket ledger consumed the episode — no phantom debt'); +}); diff --git a/packages/harness/test/layered-overlay.test.mjs b/packages/harness/test/layered-overlay.test.mjs new file mode 100644 index 00000000..e7f02b5a --- /dev/null +++ b/packages/harness/test/layered-overlay.test.mjs @@ -0,0 +1,244 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { test } from 'node:test'; +import { ensureStore, appendGovernance } from '../lib/knowledge/store.mjs'; +import { loadLayeredLearnings, isProtectedFm, layerTieRank, listBuckets, bucketAncestryOk } from '../lib/knowledge/overlay.mjs'; +import { rankLearnings, explainLearnings } from '../lib/knowledge/retrieve.mjs'; +import { branchKeyFor } from '../lib/git-context.mjs'; +import { buildLearningsLines } from '../lib/context-pack.mjs'; + +const tempDir = (p) => fs.mkdtempSync(path.join(os.tmpdir(), p)); + +function git(cwd, args) { + return spawnSync('git', args, { + cwd, + encoding: 'utf8', + env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' }, + }); +} + +function gitWorkspace(branch = 'feature/overlay') { + const ws = tempDir('overlay-ws-'); + git(ws, ['init', '-q', '-b', branch]); + git(ws, ['config', 'user.email', 'test@example.test']); + git(ws, ['config', 'user.name', 'Test']); + fs.writeFileSync(path.join(ws, 'seed.txt'), 'seed\n'); + git(ws, ['add', '.']); + git(ws, ['commit', '-qm', 'seed']); + return ws; +} + +function head(ws) { + return git(ws, ['rev-parse', 'HEAD']).stdout.trim(); +} + +function learningText({ trigger, body, status = 'active', source = 'auto', episodes = [] }) { + const epLines = episodes.length + ? ['episodes:', ...episodes.flatMap((e, i) => [ + ` - path: docs/solutions/perf/e${i}.md`, + ` sha256: "${'a'.repeat(64)}"`, + ` kind: ${e.kind || 'fix'}`, + ` plan: docs/plans/p${i}.md`, + ])] + : ['episodes:']; + return [ + '---', + 'schema: 1', + `trigger: "${trigger}"`, + `status: ${status}`, + `source: ${source}`, + ...epLines, + 'anchors: []', + 'superseded_by: null', + 'last_confirmed: null', + 'origin: test', + '---', + '', + body, + '', + ].join('\n'); +} + +function writeLearning(root, id, opts) { + const [domain, slug] = id.split('/'); + const dir = path.join(root, 'learnings', domain); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, `${slug}.md`), learningText(opts), 'utf8'); +} + +function writeBucket(dir, key, meta = {}) { + const bucketDir = path.join(dir, 'branches', key); + fs.mkdirSync(path.join(bucketDir, 'learnings'), { recursive: true }); + fs.writeFileSync(path.join(bucketDir, 'meta.json'), JSON.stringify({ branchKey: key, promotable: true, ...meta }) + '\n'); + return bucketDir; +} + +test('with no branches/ directory the ranked output is byte-identical (no layer fields, no git calls)', () => { + const ws = gitWorkspace(); + const home = tempDir('overlay-home-'); + const { dir } = ensureStore(ws, { home }); + writeLearning(dir, 'sql/golden-claim', { trigger: 'index scans on hot tables', body: 'Golden claim body.' }); + + const before = JSON.stringify(rankLearnings({ workspace: ws, query: 'hot tables index', home })); + assert.match(before, /golden-claim/); + assert.doesNotMatch(before, /"layer"/); + + // An EMPTY branches/ dir (no bucket for this branch) must not change a byte. + fs.mkdirSync(path.join(dir, 'branches'), { recursive: true }); + const after = JSON.stringify(rankLearnings({ workspace: ws, query: 'hot tables index', home })); + assert.equal(after, before); +}); + +test('a branch-local learning shadows an unprotected golden claim with the same id', () => { + const ws = gitWorkspace('feature/shadow'); + const home = tempDir('overlay-home2-'); + const { dir } = ensureStore(ws, { home }); + writeLearning(dir, 'sql/claim', { trigger: 'shared trigger tokens', body: 'Golden version.' }); + const bucketDir = writeBucket(dir, branchKeyFor('feature/shadow'), { branch: 'feature/shadow' }); + writeLearning(bucketDir, 'sql/claim', { trigger: 'shared trigger tokens', body: 'Branch version.' }); + + const { learnings, layered } = loadLayeredLearnings({ workspace: ws, home }); + assert.equal(layered, true); + assert.equal(learnings.length, 1); + assert.equal(learnings[0].layer, 'branch'); + assert.match(learnings[0].body, /Branch version/); + + const ranked = rankLearnings({ workspace: ws, query: 'shared trigger tokens', home }); + assert.equal(ranked.length, 1); + assert.equal(ranked[0].layer, 'branch'); + assert.match(ranked[0].claimLine, /Branch version/); +}); + +test('a protected golden claim (>=3 fix links or source human) is never shadowed; the branch claim is subordinate', () => { + const ws = gitWorkspace('feature/protected'); + const home = tempDir('overlay-home3-'); + const { dir } = ensureStore(ws, { home }); + writeLearning(dir, 'sql/vital', { + trigger: 'protected trigger tokens', + body: 'Golden protected version.', + episodes: [{ kind: 'fix' }, { kind: 'fix' }, { kind: 'fix' }], + }); + const bucketDir = writeBucket(dir, branchKeyFor('feature/protected'), { branch: 'feature/protected' }); + writeLearning(bucketDir, 'sql/vital', { trigger: 'protected trigger tokens', body: 'Branch challenger version.' }); + + const { learnings } = loadLayeredLearnings({ workspace: ws, home }); + assert.equal(learnings.length, 2, 'protected golden stays AND branch claim rides along'); + const golden = learnings.find((l) => !l.layer); + const branch = learnings.find((l) => l.layer === 'branch'); + assert.match(golden.body, /Golden protected/); + assert.equal(branch.subordinate, true); + assert.ok(isProtectedFm(golden.fm)); + + // Equal-score tie between the pair: the protected golden outranks its + // subordinate shadow (subordinate never wins the tie). + const ranked = rankLearnings({ workspace: ws, query: 'protected trigger tokens', home }); + assert.equal(ranked.length, 2); + assert.equal(ranked[0].layer, undefined); + assert.equal(ranked[1].subordinate, true); + assert.match(ranked[0].claimLine, /Golden protected/); + + // source: human is equally protected. + writeLearning(dir, 'sql/human', { trigger: 'human trigger', body: 'Human golden.', source: 'human' }); + const bucketDir2 = path.join(bucketDir, ''); + writeLearning(bucketDir2, 'sql/human', { trigger: 'human trigger', body: 'Branch challenger.' }); + const again = loadLayeredLearnings({ workspace: ws, home }).learnings; + const humanBranch = again.find((l) => l.id === 'sql/human' && l.layer === 'branch'); + assert.equal(humanBranch.subordinate, true); +}); + +test('an id under a standing retire/dispute/promote decision is never surfaced from a bucket', () => { + const ws = gitWorkspace('feature/governed'); + const home = tempDir('overlay-home4-'); + const { dir } = ensureStore(ws, { home }); + const bucketDir = writeBucket(dir, branchKeyFor('feature/governed'), { branch: 'feature/governed' }); + writeLearning(bucketDir, 'sql/banned', { trigger: 'governed trigger tokens', body: 'Escape attempt.' }); + writeLearning(bucketDir, 'sql/allowed', { trigger: 'governed trigger tokens', body: 'Allowed bucket claim.' }); + appendGovernance(dir, { id: 'sql/banned', action: 'retire', reason: 'human veto', to: null, at: new Date().toISOString() }); + + const { learnings } = loadLayeredLearnings({ workspace: ws, home }); + assert.deepEqual(learnings.map((l) => l.id), ['sql/allowed']); + + // confirm is NOT an exclusion — only retire/dispute/promote bind. + appendGovernance(dir, { id: 'sql/allowed', action: 'confirm', reason: null, to: null, at: new Date().toISOString() }); + assert.ok(loadLayeredLearnings({ workspace: ws, home }).learnings.some((l) => l.id === 'sql/allowed')); +}); + +test('branch-local wins an equal-score tie against a DIFFERENT golden id (layer tiebreak before id tiebreak)', () => { + const ws = gitWorkspace('feature/tie'); + const home = tempDir('overlay-home5-'); + const { dir } = ensureStore(ws, { home }); + // 'aaa/...' sorts before 'zzz/...', so the id tiebreak ALONE would put the + // golden claim first; the layer tiebreak must run first and flip it. + writeLearning(dir, 'aaa/golden-tie', { trigger: 'tie trigger tokens', body: 'Golden tie.' }); + const bucketDir = writeBucket(dir, branchKeyFor('feature/tie'), { branch: 'feature/tie' }); + writeLearning(bucketDir, 'zzz/branch-tie', { trigger: 'tie trigger tokens', body: 'Branch tie.' }); + + const ranked = rankLearnings({ workspace: ws, query: 'tie trigger tokens', home }); + assert.equal(ranked.length, 2); + assert.equal(ranked[0].score, ranked[1].score); + assert.equal(ranked[0].id, 'zzz/branch-tie'); + assert.equal(ranked[0].layer, 'branch'); + assert.equal(layerTieRank(ranked[0]), 0); + assert.equal(layerTieRank(ranked[1]), 1); +}); + +test('a bucket whose recorded baseSha is not an ancestor of HEAD is excluded whole (force-push name reuse)', () => { + const ws = gitWorkspace('feature/reused'); + const home = tempDir('overlay-home6-'); + const { dir } = ensureStore(ws, { home }); + writeLearning(dir, 'sql/golden-only', { trigger: 'reuse trigger tokens', body: 'Golden survives.' }); + // A syntactically valid sha that this repo has never seen — provably not an ancestor. + const foreignSha = 'd'.repeat(40); + const bucketDir = writeBucket(dir, branchKeyFor('feature/reused'), { branch: 'feature/reused', baseSha: foreignSha }); + writeLearning(bucketDir, 'sql/imposter', { trigger: 'reuse trigger tokens', body: 'Unrelated history.' }); + + assert.equal(bucketAncestryOk(ws, { baseSha: foreignSha }), false); + assert.equal(bucketAncestryOk(ws, { baseSha: head(ws) }), true); + assert.equal(bucketAncestryOk(ws, {}), null); + + const result = loadLayeredLearnings({ workspace: ws, home }); + assert.deepEqual(result.learnings.map((l) => l.id), ['sql/golden-only']); + assert.deepEqual(result.excludedBucket, { key: branchKeyFor('feature/reused'), reason: 'ancestry' }); + + // With a genuinely ancestral base the same bucket overlays normally. + fs.writeFileSync(path.join(bucketDir, 'meta.json'), JSON.stringify({ branch: 'feature/reused', baseSha: head(ws) }) + '\n'); + const ok = loadLayeredLearnings({ workspace: ws, home }); + assert.ok(ok.learnings.some((l) => l.id === 'sql/imposter')); +}); + +test('explain decomposition and the context pack carry the branch-local marker', () => { + const ws = gitWorkspace('feature/marker'); + const home = tempDir('overlay-home7-'); + const { dir } = ensureStore(ws, { home }); + const bucketDir = writeBucket(dir, branchKeyFor('feature/marker'), { branch: 'feature/marker' }); + writeLearning(bucketDir, 'sql/marked', { trigger: 'marker trigger tokens', body: 'Marked claim.' }); + + const explain = explainLearnings({ workspace: ws, query: 'marker trigger tokens', home }); + const candidate = explain.candidates.find((c) => c.id === 'sql/marked'); + assert.equal(candidate.layer, 'branch'); + + const ranked = rankLearnings({ workspace: ws, query: 'marker trigger tokens', home }); + const lines = buildLearningsLines(ranked).join('\n'); + assert.match(lines, /- \[sql\/marked\] \[branch-local\]/); + + // Golden-only lines never carry the marker (query tokens disjoint from the + // bucket claim so only the golden learning surfaces). + writeLearning(dir, 'sql/plain', { trigger: 'entirely disjoint golden words', body: 'Plain claim.' }); + const plain = buildLearningsLines(rankLearnings({ workspace: ws, query: 'entirely disjoint golden words', home })).join('\n'); + assert.doesNotMatch(plain, /\[branch-local\]/); +}); + +test('listBuckets enumerates bucket keys with meta', () => { + const ws = gitWorkspace('feature/list'); + const home = tempDir('overlay-home8-'); + const { dir } = ensureStore(ws, { home }); + assert.deepEqual(listBuckets(dir), []); + writeBucket(dir, 'bbb-11111111', { branch: 'bbb' }); + writeBucket(dir, 'aaa-22222222', { branch: 'aaa' }); + const buckets = listBuckets(dir); + assert.deepEqual(buckets.map((b) => b.key), ['aaa-22222222', 'bbb-11111111']); + assert.equal(buckets[0].meta.branch, 'aaa'); +}); diff --git a/packages/harness/test/provenance.test.mjs b/packages/harness/test/provenance.test.mjs new file mode 100644 index 00000000..69258801 --- /dev/null +++ b/packages/harness/test/provenance.test.mjs @@ -0,0 +1,278 @@ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { test } from 'node:test'; +import { + ensureStore, + listLearnings, + readLedger, + parseLearningFrontmatter, + serializeLearning, + provenanceLines, + provenanceBytes, +} from '../lib/knowledge/store.mjs'; +import { applyOps } from '../lib/knowledge/apply.mjs'; +import { LEARNING_BYTE_CAP } from '../lib/knowledge/consolidate.mjs'; +import { runInsightCompound } from '../lib/compound.mjs'; +import { runRemember } from '../lib/knowledge/remember.mjs'; +import { absorbHandEdits, removeEpisodeLink } from '../lib/knowledge/admin.mjs'; + +const tempDir = (p) => fs.mkdtempSync(path.join(os.tmpdir(), p)); + +function git(cwd, args) { + return spawnSync('git', args, { + cwd, + encoding: 'utf8', + env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' }, + }); +} + +/** Git workspace on a named branch with one commit. */ +function gitWorkspace(branch = 'feature/prov') { + const ws = tempDir('prov-ws-'); + git(ws, ['init', '-q', '-b', branch]); + git(ws, ['config', 'user.email', 'test@example.test']); + git(ws, ['config', 'user.name', 'Test']); + fs.writeFileSync(path.join(ws, 'seed.txt'), 'seed\n'); + git(ws, ['add', '.']); + git(ws, ['commit', '-qm', 'seed']); + return ws; +} + +function head(ws) { + return git(ws, ['rev-parse', 'HEAD']).stdout.trim(); +} + +/** + * Pin the store's defaultBranch to the fixture branch so writes route GOLDEN + * — this suite is about provenance stamping/preservation, not layer routing + * (which fails closed to branch-local when the default is unresolvable). + */ +function pinDefaultBranch(ws, home) { + const branch = git(ws, ['symbolic-ref', '--short', 'HEAD']).stdout.trim(); + const { dir } = ensureStore(ws, { home }); + fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ mode: 'on', commit: 'none', defaultBranch: branch }) + '\n'); +} + +function writeEpisode(ws, rel, body) { + const full = path.join(ws, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + const text = body || `fix evidence for ${rel}.\n`; + fs.writeFileSync(full, text, 'utf8'); + return { path: rel, sha256: crypto.createHash('sha256').update(text).digest('hex'), kind: 'fix', plan: 'docs/plans/p1.md' }; +} + +function writeOps(ws, ops) { + const p = path.join(ws, 'ops.json'); + fs.writeFileSync(p, JSON.stringify({ schema: 1, ops })); + return p; +} + +function addOp(ws, over = {}) { + return { + op: 'ADD', + domain: 'sql', + slug: 'prov-learning', + trigger: 'provenance trigger', + body: 'Provenance claim body.', + episodes: over.episodes || [writeEpisode(ws, 'docs/solutions/perf/x.md')], + ...over, + }; +} + +test('provenanceLines renders only shape-valid fields and quotes the branch', () => { + const sha = 'a'.repeat(40); + assert.deepEqual(provenanceLines({ commit: sha, branch: 'feature/x', base: sha }), [ + `commit: ${sha}`, + 'branch: "feature/x"', + `base: ${sha}`, + ]); + assert.deepEqual(provenanceLines({}), []); + assert.deepEqual(provenanceLines({ commit: 'nothex', branch: '', base: 'abc' }), []); + assert.equal(provenanceBytes({ commit: sha }), Buffer.byteLength(`commit: ${sha}`) + 1); +}); + +test('insight episode capture stamps commit and branch provenance', () => { + const ws = gitWorkspace('feature/insight-prov'); + const copilotHome = tempDir('prov-ch-'); + const home = tempDir('prov-home-'); + const result = runInsightCompound({ + workspace: ws, + copilotHome, + flags: { title: 'An insight', body: 'Something observed.' }, + home, + }); + assert.equal(result.pass, true, result.blockedReason); + const text = fs.readFileSync(path.join(ws, result.path), 'utf8'); + assert.match(text, new RegExp(`^commit: ${head(ws)}$`, 'm')); + assert.match(text, /^branch: "feature\/insight-prov"$/m); + assert.doesNotMatch(text, /^base:/m, 'no default branch resolvable — base omitted, never guessed'); +}); + +test('remember stamps provenance on both the episode and the learning', () => { + const ws = gitWorkspace('feature/remember-prov'); + const copilotHome = tempDir('prov-ch2-'); + const home = tempDir('prov-home2-'); + pinDefaultBranch(ws, home); + const result = runRemember({ + workspace: ws, + copilotHome, + flags: { trigger: 'when remembering', domain: 'general' }, + argv: ['always test provenance'], + home, + }); + assert.equal(result.pass, true, result.blockedReason); + const episodeText = fs.readFileSync(path.join(ws, result.episodePath), 'utf8'); + assert.match(episodeText, new RegExp(`^commit: ${head(ws)}$`, 'm')); + assert.match(episodeText, /^branch: "feature\/remember-prov"$/m); + + const { dir } = ensureStore(ws, { home }); + const learning = listLearnings(dir).find((l) => l.id === result.learningId); + assert.ok(learning); + assert.equal(learning.fm.commit, head(ws)); + assert.equal(learning.fm.branch, 'feature/remember-prov'); + // defaultBranch pinned to this same branch — merge-base with it IS HEAD. + assert.equal(learning.fm.base, head(ws)); +}); + +test('STRENGTHEN preserves the original provenance across the re-render', () => { + const ws = gitWorkspace('feature/strengthen-prov'); + const home = tempDir('prov-home3-'); + pinDefaultBranch(ws, home); + const originalHead = head(ws); + const applied = applyOps({ workspace: ws, opsPath: writeOps(ws, [addOp(ws)]), home }); + assert.equal(applied.exitCode, 0, JSON.stringify(applied.rejected)); + + // Advance HEAD so the strengthening commit differs from the original. + fs.writeFileSync(path.join(ws, 'later.txt'), 'later\n'); + git(ws, ['add', '.']); + git(ws, ['commit', '-qm', 'later']); + assert.notEqual(head(ws), originalHead); + + const ep2 = writeEpisode(ws, 'docs/solutions/perf/y.md'); + const strengthened = applyOps({ + workspace: ws, + opsPath: writeOps(ws, [{ op: 'STRENGTHEN', target: 'sql/prov-learning', episodes: [ep2] }]), + home, + }); + assert.equal(strengthened.exitCode, 0, JSON.stringify(strengthened.rejected)); + + const { dir } = ensureStore(ws, { home }); + const learning = listLearnings(dir).find((l) => l.id === 'sql/prov-learning'); + assert.equal(learning.fm.episodes.length, 2); + assert.equal(learning.fm.commit, originalHead, 'provenance must not migrate to the strengthening commit'); + assert.equal(learning.fm.branch, 'feature/strengthen-prov'); +}); + +test('near-cap STRENGTHEN with provenance never trips E_BYTE_CAP on the stamp (byte-cap exclusion)', () => { + const ws = gitWorkspace('feature/near-cap'); + const home = tempDir('prov-home4-'); + pinDefaultBranch(ws, home); + // First apply with a small body to measure the fixed overhead. + const probe = applyOps({ workspace: ws, opsPath: writeOps(ws, [addOp(ws, { slug: 'probe', body: 'p' })]), home }); + assert.equal(probe.exitCode, 0, JSON.stringify(probe.rejected)); + const { dir } = ensureStore(ws, { home }); + const probeLearning = listLearnings(dir).find((l) => l.id === 'sql/probe'); + const probeBytes = probeLearning.bytes; + const provBytes = provenanceBytes(probeLearning.fm); + assert.ok(provBytes > 0, 'fixture must actually carry provenance'); + const baseBytes = probeBytes - provBytes - 1; // minus the 1-byte probe body + + // Pad the body so claim-only content sits just under the cap while the + // full file (with provenance) is OVER it. slug 'probe' reused for identical + // filename-independent content length; new home = fresh store. + const bodyLen = LEARNING_BYTE_CAP - baseBytes - 1; + assert.ok(bodyLen > 0 && bodyLen + baseBytes + provBytes > LEARNING_BYTE_CAP, 'fixture math must straddle the cap'); + const home2 = tempDir('prov-home5-'); + pinDefaultBranch(ws, home2); + const applied = applyOps({ + workspace: ws, + opsPath: writeOps(ws, [addOp(ws, { slug: 'probe', body: 'x'.repeat(bodyLen) })]), + home: home2, + }); + assert.equal(applied.exitCode, 0, `near-cap ADD with provenance must not reject: ${JSON.stringify(applied.rejected)}`); + const dir2 = ensureStore(ws, { home: home2 }).dir; + const learning = listLearnings(dir2).find((l) => l.id === 'sql/probe'); + assert.ok(learning.bytes > LEARNING_BYTE_CAP, 'file including provenance genuinely exceeds the raw cap'); + + // Strengthening the near-cap learning must also not strike on the stamp. + const ep2 = writeEpisode(ws, 'docs/solutions/perf/z.md'); + const strengthened = applyOps({ + workspace: ws, + opsPath: writeOps(ws, [{ op: 'STRENGTHEN', target: 'sql/probe', episodes: [ep2] }]), + home: home2, + }); + // The strengthen adds an episode block (~4 lines) of real claim bytes, so it + // may legitimately cross the cap — but if it rejects, it must be the CLAIM + // bytes, never the provenance bytes: re-check by the same exclusion. + if (strengthened.exitCode !== 0) { + assert.equal(strengthened.rejected[0].code, 'E_BYTE_CAP'); + const after = listLearnings(dir2).find((l) => l.id === 'sql/probe'); + assert.ok(after.bytes - provenanceBytes(after.fm) + 150 > LEARNING_BYTE_CAP, 'rejection driven by claim bytes'); + } + // Either way, no ledger failure strike may cite provenance as the cause of a + // spurious quarantine march for the ORIGINAL near-cap write. + const failures = readLedger(dir2).filter((e) => e.failure); + for (const f of failures) assert.equal(f.failure, 'E_BYTE_CAP'); +}); + +test('serializeLearning round-trips provenance and legacy files never gain fields', () => { + const sha = 'b'.repeat(40); + const withProv = `---\nschema: 1\ntrigger: "t"\nstatus: active\nsource: auto\nepisodes:\n - path: docs/solutions/a.md\n sha256: "${'c'.repeat(64)}"\n kind: fix\n plan: \nanchors: []\nsuperseded_by: null\nlast_confirmed: null\norigin: test\ncommit: ${sha}\nbranch: "feature/x"\nbase: ${sha}\n---\n\nBody.\n`; + const parsed = parseLearningFrontmatter(withProv); + assert.equal(parsed.fm.commit, sha); + assert.equal(parsed.fm.branch, 'feature/x'); + const rendered = serializeLearning(parsed.fm, parsed.body); + assert.match(rendered, new RegExp(`^commit: ${sha}$`, 'm')); + assert.match(rendered, /^branch: "feature\/x"$/m); + assert.match(rendered, new RegExp(`^base: ${sha}$`, 'm')); + + const legacy = `---\nschema: 1\ntrigger: "t"\nstatus: active\nsource: auto\nepisodes:\nanchors: []\nsuperseded_by: null\nlast_confirmed: null\norigin: test\n---\n\nBody.\n`; + const legacyParsed = parseLearningFrontmatter(legacy); + const legacyRendered = serializeLearning(legacyParsed.fm, legacyParsed.body); + assert.doesNotMatch(legacyRendered, /^commit:/m); + assert.doesNotMatch(legacyRendered, /^branch:/m); + assert.doesNotMatch(legacyRendered, /^base:/m); +}); + +test('hand-edit absorb re-render preserves provenance', () => { + const ws = gitWorkspace('feature/absorb-prov'); + const home = tempDir('prov-home6-'); + pinDefaultBranch(ws, home); + const applied = applyOps({ workspace: ws, opsPath: writeOps(ws, [addOp(ws)]), home }); + assert.equal(applied.exitCode, 0, JSON.stringify(applied.rejected)); + const { dir } = ensureStore(ws, { home }); + const learning = listLearnings(dir).find((l) => l.id === 'sql/prov-learning'); + const originalCommit = learning.fm.commit; + assert.ok(originalCommit); + + // Hand-edit the body directly in the store (dirty tree), then absorb. + fs.writeFileSync(learning.file, fs.readFileSync(learning.file, 'utf8').replace('Provenance claim body.', 'Hand-edited claim body.'), 'utf8'); + const absorbed = absorbHandEdits({ workspace: ws, home }); + assert.equal(absorbed.absorbed.length, 1); + const after = listLearnings(dir).find((l) => l.id === 'sql/prov-learning'); + assert.equal(after.fm.source, 'human'); + assert.equal(after.fm.commit, originalCommit, 'absorb re-render must preserve provenance'); + assert.equal(after.fm.branch, 'feature/absorb-prov'); +}); + +test('purge-delink re-render preserves provenance', () => { + const ws = gitWorkspace('feature/delink-prov'); + const home = tempDir('prov-home7-'); + pinDefaultBranch(ws, home); + const ep1 = writeEpisode(ws, 'docs/solutions/perf/one.md'); + const ep2 = writeEpisode(ws, 'docs/solutions/perf/two.md'); + const applied = applyOps({ workspace: ws, opsPath: writeOps(ws, [addOp(ws, { episodes: [ep1, ep2] })]), home }); + assert.equal(applied.exitCode, 0, JSON.stringify(applied.rejected)); + const { dir } = ensureStore(ws, { home }); + const learning = listLearnings(dir).find((l) => l.id === 'sql/prov-learning'); + const originalCommit = learning.fm.commit; + + removeEpisodeLink(learning.file, ep1.path); + const after = listLearnings(dir).find((l) => l.id === 'sql/prov-learning'); + assert.equal(after.fm.episodes.length, 1); + assert.equal(after.fm.commit, originalCommit, 'delink re-render must preserve provenance'); +}); diff --git a/packages/harness/test/store-migration.test.mjs b/packages/harness/test/store-migration.test.mjs index 4fbeb563..33e6ba4d 100644 --- a/packages/harness/test/store-migration.test.mjs +++ b/packages/harness/test/store-migration.test.mjs @@ -49,6 +49,18 @@ function realFixEpisode(ws, rel) { return { path: rel, sha256: crypto.createHash('sha256').update(text).digest('hex'), kind: 'fix', plan: 'docs/plans/p1.md' }; } +/** + * Pin the store's defaultBranch to the workspace's current branch so writes + * route GOLDEN (layer routing fails closed to branch-local when the default + * branch is unresolvable — these fixtures have no origin/HEAD, and this + * suite is about store IDENTITY migration, not layer routing). + */ +function pinDefaultBranch(c) { + const branch = git(c.ws, ['symbolic-ref', '--short', 'HEAD']).stdout.trim() || 'main'; + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ mode: 'on', commit: 'none', defaultBranch: branch }) + '\n'); +} + function k4(doctorJson) { return JSON.parse(doctorJson).checks.find((c) => c.id === 'K4'); } @@ -73,6 +85,7 @@ test('adding an origin remote after building a local-keyed store strands it: doc body: 'This learning must survive the migration byte-for-byte.', episodes: [realFixEpisode(c.ws, 'docs/solutions/perf/stranded.md')], }; + pinDefaultBranch(c); const applyRes = run(c, ['consolidate', '--apply', '--ops', writeOps(c.ws, [addOp])]); assert.equal(applyRes.status, 0, applyRes.stderr || applyRes.stdout); @@ -191,6 +204,7 @@ test('migrate-store takes over a stale lock left in the legacy store instead of body: 'body', episodes: [realFixEpisode(c.ws, 'docs/solutions/perf/stale-lock.md')], }; + pinDefaultBranch(c); assert.equal(run(c, ['consolidate', '--apply', '--ops', writeOps(c.ws, [addOp])]).status, 0); const legacyId = localRepoId(c.ws); const legacyDir = storeDirForId(legacyId, { home: c.harnessHome }); @@ -297,6 +311,7 @@ test('migrate-store refuses when the migration target already exists and is non- body: 'legacy body', episodes: [realFixEpisode(c.ws, 'docs/solutions/perf/legacy.md')], }; + pinDefaultBranch(c); assert.equal(run(c, ['consolidate', '--apply', '--ops', writeOps(c.ws, [addOp])]).status, 0); const legacyId = localRepoId(c.ws); const legacyDir = storeDirForId(legacyId, { home: c.harnessHome }); @@ -314,6 +329,7 @@ test('migrate-store refuses when the migration target already exists and is non- body: 'destination body', episodes: [realFixEpisode(c.ws, 'docs/solutions/perf/already-here.md')], }; + pinDefaultBranch(c); // repoId switched — the DESTINATION store needs its own pin assert.equal(run(c, ['consolidate', '--apply', '--ops', writeOps(c.ws, [otherOp])]).status, 0); assert.ok(fs.existsSync(path.join(currentDir, 'consolidated.jsonl')), 'precondition: destination store already exists'); const currentBefore = fs.readdirSync(currentDir).sort(); From 23cc8b575f2f6ff9bcd37e68ceab48fc4605fa30 Mon Sep 17 00:00:00 2001 From: Krish Date: Thu, 6 Aug 2026 04:33:57 -0400 Subject: [PATCH 07/24] feat: optional tree-sitter structural index with incremental rebuild, --since diffs, and doctor S1 --- .../references/harness-tool-contract.md | 26 +- packages/harness/README.md | 10 + packages/harness/bin/harness.mjs | 10 +- packages/harness/lib/commands.mjs | 65 +++ packages/harness/lib/doctor.mjs | 72 +++ packages/harness/lib/flags.mjs | 3 + packages/harness/lib/repo-map/grammars.lock | 41 ++ packages/harness/lib/repo-map/index.mjs | 74 ++- packages/harness/lib/repo-map/scan.mjs | 46 ++ .../harness/lib/repo-map/structural-index.mjs | 420 +++++++++++++++ .../lib/repo-map/treesitter-extractor.mjs | 507 ++++++++++++++++++ packages/harness/package-lock.json | 130 +++++ packages/harness/package.json | 9 +- .../harness/test/doctor-structural.test.mjs | 157 ++++++ .../test/index-structural-cli.test.mjs | 122 +++++ .../harness/test/structural-index.test.mjs | 346 ++++++++++++ .../test/treesitter-extractor.test.mjs | 226 ++++++++ 17 files changed, 2215 insertions(+), 49 deletions(-) create mode 100644 packages/harness/lib/repo-map/grammars.lock create mode 100644 packages/harness/lib/repo-map/scan.mjs create mode 100644 packages/harness/lib/repo-map/structural-index.mjs create mode 100644 packages/harness/lib/repo-map/treesitter-extractor.mjs create mode 100644 packages/harness/test/doctor-structural.test.mjs create mode 100644 packages/harness/test/index-structural-cli.test.mjs create mode 100644 packages/harness/test/structural-index.test.mjs create mode 100644 packages/harness/test/treesitter-extractor.test.mjs diff --git a/.github/skills/references/harness-tool-contract.md b/.github/skills/references/harness-tool-contract.md index b25b593f..191f845b 100644 --- a/.github/skills/references/harness-tool-contract.md +++ b/.github/skills/references/harness-tool-contract.md @@ -65,7 +65,7 @@ This table tracks only what differs in runtime character across commands — whi | `verify` | agent-runtime | writes | mutates (evidence file + session) | | `validate-plan` | agent-runtime | writes¹ | read-only | | `plan-new` | agent-runtime | none | mutates workspace (writes the plan; `--stdout` prints instead) | -| `index` | agent-runtime | writes¹ | mutates the knowledge index (`--status` read-only) | +| `index` | agent-runtime | writes¹ | mutates the knowledge index (`--status` read-only); `--structural` mutates `~/.harness/index//structural/` | | `get` | agent-runtime | none | read-only | | `compound` | agent-runtime | writes | mutates (index + solution doc + telemetry) | | `consolidate` | agent-runtime | writes | read-only (`--status`/`--candidates`); mutates the learnings store (`--apply`/`--rebuild --yes`) | @@ -83,6 +83,30 @@ This table tracks only what differs in runtime character across commands — whi **Repo map & knowledge freshness (deterministic-first).** `orient` regenerates `.harness/repo-map.md` every turn from `git ls-files` + a lexical symbol/import extractor — so code orientation is always current and never depends on a model. `init-repo` and `index` additionally write a committed, timestamp-free `docs/codebase-map.md` (~2.5k-token budget, query-less) so cold-start agents read one durable orientation file instead of exploring. Learnings (semantic memory) live in a local never-pushed git store at `~/.harness/knowledge//`; `orient` injects the top-3 trigger-matched learnings inside the existing 2 KB pack, attributed by id, with insight-derived claims fenced `[unverified memory — advisory]`. The `.harness/repo-map.md` (like `.harness/context-pack.md`) is an ephemeral derived artifact, not a persistent type. The knowledge index is refreshed manually (`harness index`) — run it after a major pull from main or a docs rewrite; `index --status` and the `orient` next-hint tell you when it has drifted. A staleness-or-intent maintenance refresh may additionally re-derive conventions via `/codebase-context` (an optional, cheap, non-reasoning model pass) and promote generalizable solution docs to the global `~/.copilot/knowledge` store (episodes only — never the learnings store, whose sole writer is `consolidate --apply`) — never per turn. The extractor is a seam: a tree-sitter tier (WASM, lazy-loaded grammars, lexical fallback for SQL/HCL) can implement the same `extract` shape to power symbol-accurate `refs`/`def`/`callers`, built only when telemetry shows the lexical map misleads the agent. +**Structural index (optional tier — Phase 3).** `harness index --structural [--since ]` builds a persistent, derived symbol index at `~/.harness/index//structural/` (`files.json`, `symbols.json`, `graph.json`, `meta.json` with the `{sha, branch, baseSha, generatedAt}` generation stamp). Parsing uses optional web-tree-sitter WASM grammars (TypeScript/JavaScript/TSX, Python, Java); any other language, missing grammar, parse failure, or init failure falls back **per file** to the lexical extractor, so the harness works fully with the optional grammar packages absent. `grammars.lock` pins a sha256 digest per wasm, verified before instantiation; a mismatch is a **loud** lexical fallback — recorded in `meta.json` and failed (not warned) by doctor S1. Rebuilds are incremental (mtime+size fast path, sha256 content confirm); `--since ` re-parses only `git diff --name-only --` files after `git rev-parse --verify` validation (leading `-` rejected). When `meta.sha` equals the current HEAD, `orient`'s repo map prefers the prebuilt structural tables (still a synchronous read — the async grammar lifecycle never enters orient); otherwise behavior is byte-identical lexical. The committed `docs/codebase-map.md` stays lexical-only so host-local index state never leaks into a committed artifact. Output follows the three-audience contract: styled ledger for humans, the bounded `--json` summary envelope below for programs (never the raw tables), and a ≤1000-token inert digest as the agent lane — raw index JSON never enters model context. The index is derived and rebuildable: deleting the directory never loses knowledge. Unresolved graph edges (imports or calls the tables cannot bind) are preserved explicitly, never fabricated. + +**index --structural** +```json +{ + "pass": true, + "exitCode": 0, + "dir": "~/.harness/index//structural", + "written": true, + "sha": "", + "baseSha": null, + "tier": "treesitter", + "filesIndexed": 42, + "reparsed": 3, + "reused": 39, + "removedFiles": 0, + "parseFailures": 0, + "grammarVersions": { "javascript": "0.23.1", "typescript": "0.23.2", "tsx": "0.23.2", "python": "0.23.6", "java": "0.23.5" }, + "missingGrammars": [], + "integrityFailures": [], + "delta": { "added": { "count": 1, "names": ["chargeV2"] }, "removed": { "count": 0, "names": [] }, "changed": { "count": 0, "names": [] } } +} +``` + ### JSON shapes (stable fields) **orient** diff --git a/packages/harness/README.md b/packages/harness/README.md index 4cc712a4..86059ac0 100644 --- a/packages/harness/README.md +++ b/packages/harness/README.md @@ -88,6 +88,7 @@ to Deliver before editing. | `get` | Bounded doc excerpt by `--docid` or `--path` | | `validate-plan` | Read-only plan template / intent compliance | | `index` | Rebuild `knowledge/manifest.yaml` + `.harness-index/` | +| `index --structural [--since ]` | Build the persistent structural code index at `~/.harness/index//structural/` (optional tree-sitter WASM tier for TS/JS/TSX, Python, Java; per-file lexical fallback; incremental; `--since` re-parses only the ref diff). Derived and rebuildable — safe to delete | | `compound` | Consume passed evidence, index learning, and record usage/outcome telemetry | | `compound --insight` | Evidence-free capture of investigation learnings (`kind: insight`, secret-scanned, ranked below verified fixes, never promotable) | | `consolidate` | Knowledge loop: `--status` debt gauge (quarantine + at-cap domains surfaced) · `--candidates` deterministic work packet, plus any id a human already retired/disputed/promoted (`governed`) so the skill doesn't waste an op re-deriving it · `--apply --ops ` validated sole writer of learnings via ADD/STRENGTHEN/SUPERSEDE/MERGE/NOOP ops (`suggest` mode requires `--yes`); mechanically reapplies a standing governance decision when a regenerated id matches one, returning `governed` | @@ -203,3 +204,12 @@ packages/harness/ ``` Node 20+. Runtime dependency: `yaml` (manifest parse). + +Optional dependencies (structural index tier only — the harness is fully +functional without them, falling back to the lexical extractor): +`web-tree-sitter` plus the `tree-sitter-javascript` / `tree-sitter-typescript` +/ `tree-sitter-python` / `tree-sitter-java` grammar packages, all pinned +exact. `lib/repo-map/grammars.lock` pins a sha256 digest for every wasm +(runtime included) and is verified before instantiation — when bumping any of +these versions, re-hash the installed wasm files and regenerate +`grammars.lock` in the same change, or every install fails doctor S1 loudly. diff --git a/packages/harness/bin/harness.mjs b/packages/harness/bin/harness.mjs index f739023a..c15722df 100755 --- a/packages/harness/bin/harness.mjs +++ b/packages/harness/bin/harness.mjs @@ -77,9 +77,13 @@ const CATALOG = [ group: 'workspace', commands: [ { name: 'init-repo', desc: 'seed the .harness workspace in a product repo', sig: '', options: [] }, - { name: 'index', desc: 'rebuild knowledge index · --status reports drift', - sig: '[--status]', - options: [['--status', 'read-only freshness report vs HEAD (never rebuilds)']] }, + { name: 'index', desc: 'rebuild knowledge index · --status reports drift · --structural builds the code symbol index', + sig: '[--status] [--structural [--since ]]', + options: [ + ['--status', 'read-only freshness report vs HEAD (never rebuilds)'], + ['--structural', 'build the persistent structural code index under ~/.harness/index//structural (optional tree-sitter tier, lexical fallback)'], + ['--since ', 'with --structural: re-parse only files changed since (validated via git rev-parse; leading "-" rejected)'], + ] }, { name: 'plan-new', desc: 'scaffold a gate-ready plan', sig: '--type feat --slug --intent "..."', options: [ diff --git a/packages/harness/lib/commands.mjs b/packages/harness/lib/commands.mjs index 4fd19275..3088bb4e 100644 --- a/packages/harness/lib/commands.mjs +++ b/packages/harness/lib/commands.mjs @@ -358,6 +358,71 @@ export async function cmdIndex(argv) { return 0; } + // Structural code index (blueprint P3). The async tree-sitter lifecycle is + // confined to THIS command path: grammars init here, the tables persist at + // ~/.harness/index//structural/, and orient/buildRepoMap read the + // PREBUILT files synchronously. Fully functional with the optional grammar + // packages absent (lexical tier). + if (argv.includes('--structural')) { + const { buildStructuralIndex, validateSinceRef, renderStructuralDigest, readStructuralIndex } = await import( + './repo-map/structural-index.mjs' + ); + const { createTreesitterExtract } = await import('./repo-map/treesitter-extractor.mjs'); + const since = flags.since ? validateSinceRef(workspace, flags.since) : null; + const extractor = await createTreesitterExtract(); + const result = await buildStructuralIndex({ workspace, extractor, since, dryRun: flags.dryRun, log: logger }); + const integrity = (result.meta.integrityFailures || []).length > 0; + writeEvent(workspace, flags, { + type: 'index', + command: 'index', + result: integrity ? 'warn' : 'pass', + exitCode: 0, + }); + if (flags.json) { + // Program lane (§9): a bounded summary envelope — never the raw tables. + emitJson(flags, { + pass: true, + exitCode: 0, + dir: result.dir, + written: result.written, + sha: result.meta.sha, + baseSha: result.meta.baseSha, + tier: result.meta.extractorTier, + filesIndexed: result.meta.filesIndexed, + reparsed: result.reparsed, + reused: result.reused, + removedFiles: result.removedFiles, + parseFailures: result.meta.parseFailures, + grammarVersions: result.meta.grammarVersions, + missingGrammars: result.meta.missingGrammars, + integrityFailures: result.meta.integrityFailures, + delta: result.delta, + }); + } else { + const deltaNote = `symbols +${result.delta.added.count} −${result.delta.removed.count} ~${result.delta.changed.count}${since ? ' vs prior index' : ''}`; + console.log( + ui.line({ + state: integrity ? 'warn' : 'ok', + key: 'structural', + value: `${result.meta.filesIndexed} files · ${result.reparsed} parsed · ${result.reused} reused · tier ${result.meta.extractorTier}`, + note: integrity + ? `grammar integrity mismatch (${result.meta.integrityFailures.length}) — loud lexical fallback; run harness doctor` + : deltaNote, + }) + ); + // Agent lane (§9): the budgeted inert digest, never raw index JSON. + if (result.written) { + const index = readStructuralIndex(workspace); + if (index) { + for (const line of renderStructuralDigest(index).body.split('\n')) { + console.log(ui.paint('muted', ` ${line}`)); + } + } + } + } + return 0; + } + // Stamp the current git HEAD so `index --status` can measure drift later. const head = spawnSyncHead(workspace); const result = runIndexKnowledge({ diff --git a/packages/harness/lib/doctor.mjs b/packages/harness/lib/doctor.mjs index 09a81735..e91ea464 100644 --- a/packages/harness/lib/doctor.mjs +++ b/packages/harness/lib/doctor.mjs @@ -20,6 +20,9 @@ import { listBuckets } from './knowledge/overlay.mjs'; import { branchExists } from './knowledge/layer.mjs'; import { deriveGitContext, resolveDefaultBranch } from './git-context.mjs'; import { loadReportEvents, knowledgeSlos } from './report.mjs'; +import { readStructuralIndex } from './repo-map/structural-index.mjs'; +import { grammarStatus } from './repo-map/treesitter-extractor.mjs'; +import { assertNoSymlinkAncestors } from './fs-safe.mjs'; const require = createRequire(import.meta.url); @@ -494,6 +497,74 @@ function knowledgeChecks({ workspace, copilotHome }) { return checks; } +// Structural-index health (blueprint P3, doctor S1). One check, four facts: +// grammar availability + integrity (BOTH the mismatch recorded at index time +// in meta.json AND the current on-disk wasm state via the sync grammarStatus +// probe), meta.sha drift vs HEAD, parse-failure rate, and orphaned cache +// entries. Binding blueprint rule: a grammar integrity mismatch FAILS S1 +// (optional: false) — the loud lexical fallback is a doctor failure, never a +// warning. Everything else about the optional tier stays advisory. Exported +// for direct testing, same as the check builders above are exercised through +// runDoctor. +export function structuralChecks({ workspace }) { + const checks = []; + try { + const disk = grammarStatus(); + const index = readStructuralIndex(workspace); + const recorded = index?.meta?.integrityFailures || []; + const mismatches = [...disk.integrityFailures, ...recorded]; + if (mismatches.length) { + const languages = [...new Set(mismatches.map((f) => f.language))].join(', '); + checks.push({ + id: 'S1', + name: 'Structural index grammar integrity', + pass: false, + hint: `grammar wasm sha256 mismatch vs grammars.lock (${languages}) — the index fell back to lexical loudly; reinstall the harness optional dependencies, then re-run: harness index --structural`, + }); + return checks; + } + if (!index) { + checks.push({ + id: 'S1', + name: 'Structural index (optional tier)', + pass: true, + optional: true, + hint: 'not built — run: harness index --structural', + }); + return checks; + } + const issues = []; + const head = spawnSync('git', ['-C', workspace, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 10_000 }); + const headSha = head.status === 0 ? head.stdout.trim() : null; + if (index.meta.sha && headSha && index.meta.sha !== headSha) { + issues.push('meta.sha behind HEAD — re-run: harness index --structural'); + } + const filesIndexed = Math.max(index.meta.filesIndexed || Object.keys(index.files).length, 1); + const failRate = (index.meta.parseFailures || 0) / filesIndexed; + if (failRate > 0.2) issues.push(`parse-failure rate ${(failRate * 100).toFixed(0)}% — inspect grammar installation`); + // Orphaned cache entries: indexed rels that no longer exist on disk. + // files.json can be hand-edited, so each rel is containment-checked + // before any stat — an escaping rel counts as an orphan, never a probe + // outside the workspace. + let orphans = 0; + for (const rel of Object.keys(index.files).slice(0, 500)) { + const full = assertNoSymlinkAncestors(workspace, rel); + if (!full || !fs.existsSync(full)) orphans += 1; + } + if (orphans) issues.push(`${orphans} orphaned cache entries — pruned on the next harness index --structural`); + checks.push({ + id: 'S1', + name: 'Structural index health', + pass: issues.length === 0, + optional: true, + hint: issues.length ? issues.join(' · ') : 'current with HEAD; grammars verified', + }); + } catch { + // Advisory; never fail doctor on a structural-check error. + } + return checks; +} + export function runDoctor({ copilotHome, assetsRoot, pkgRoot, flags, vscodeSettingsPaths = null, workspace = flags.workspace }) { const checks = []; @@ -676,6 +747,7 @@ export function runDoctor({ copilotHome, assetsRoot, pkgRoot, flags, vscodeSetti }); checks.push(...knowledgeChecks({ workspace, copilotHome })); + checks.push(...structuralChecks({ workspace })); if (flags.host === 'vscode') { checks.push( diff --git a/packages/harness/lib/flags.mjs b/packages/harness/lib/flags.mjs index 6c7c35ff..8a80bf8d 100644 --- a/packages/harness/lib/flags.mjs +++ b/packages/harness/lib/flags.mjs @@ -91,6 +91,7 @@ export function parseFlags(argv) { all: false, merged: false, stale: null, + since: null, }; for (let i = 0; i < argv.length; i++) { @@ -188,6 +189,8 @@ export function parseFlags(argv) { const next = argv[i + 1]; if (next !== undefined && !next.startsWith('--')) flags.why = argv[++i]; } + else if (a.startsWith('--since=')) flags.since = a.split('=').slice(1).join('='); + else if (a === '--since') flags.since = argv[++i]; else if (a === '--yes') flags.yes = true; else if (a.startsWith('--layer=')) flags.layer = parseLayer(a.split('=')[1]); else if (a === '--layer') flags.layer = parseLayer(argv[++i]); diff --git a/packages/harness/lib/repo-map/grammars.lock b/packages/harness/lib/repo-map/grammars.lock new file mode 100644 index 00000000..c975d013 --- /dev/null +++ b/packages/harness/lib/repo-map/grammars.lock @@ -0,0 +1,41 @@ +{ + "version": 1, + "runtime": { + "package": "web-tree-sitter", + "version": "0.25.10", + "file": "tree-sitter.wasm", + "sha256": "f38dcc4b43b818f9a0785bc1c6d5611a75ac4cdd428ff3f02757c34ca4e46d7f" + }, + "grammars": { + "javascript": { + "package": "tree-sitter-javascript", + "version": "0.23.1", + "file": "tree-sitter-javascript.wasm", + "sha256": "4a378293fe7853cbee2836023be072dafa0e53b3b5edb245920838ca834ed121" + }, + "typescript": { + "package": "tree-sitter-typescript", + "version": "0.23.2", + "file": "tree-sitter-typescript.wasm", + "sha256": "778025db5a8be0e70f8ccc3671e486dfeddd048c25d9e8a70c26de2e1bf6f97d" + }, + "tsx": { + "package": "tree-sitter-typescript", + "version": "0.23.2", + "file": "tree-sitter-tsx.wasm", + "sha256": "79e5da75ea62855a0cd67177685f0164eac87d5f630b3cbe1e0a099751ad30f8" + }, + "python": { + "package": "tree-sitter-python", + "version": "0.23.6", + "file": "tree-sitter-python.wasm", + "sha256": "8c93692fb368e288a5824cee55773c9b3602804f513bda48c97661e52e9c2da2" + }, + "java": { + "package": "tree-sitter-java", + "version": "0.23.5", + "file": "tree-sitter-java.wasm", + "sha256": "4fdeac4ca6ca089f06c6f7e562abcac1733cd465728cc7031ebb73c2019122c4" + } + } +} diff --git a/packages/harness/lib/repo-map/index.mjs b/packages/harness/lib/repo-map/index.mjs index 436c4170..3c236032 100644 --- a/packages/harness/lib/repo-map/index.mjs +++ b/packages/harness/lib/repo-map/index.mjs @@ -1,47 +1,12 @@ -import fs from 'node:fs'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { tokenize } from '../tokenize.mjs'; import { estimateTokens } from '../token-meter.mjs'; -import { extract as lexicalExtract, SOURCE_EXTENSIONS } from './lexical-extractor.mjs'; -import { readFileNoFollow, writeFileContained, assertNoSymlinkAncestors } from '../fs-safe.mjs'; +import { extract as lexicalExtract } from './lexical-extractor.mjs'; +import { writeFileContained } from '../fs-safe.mjs'; +import { trackedSourceFiles, readFileSafe } from './scan.mjs'; +import { readStructuralIndexIfCurrent } from './structural-index.mjs'; const DEFAULT_MAX_TOKENS = 1000; -const MAX_FILES_SCANNED = 4000; -const MAX_FILE_BYTES = 200_000; - -function trackedSourceFiles(workspace) { - const res = spawnSync('git', ['-C', workspace, 'ls-files'], { encoding: 'utf8', timeout: 15_000 }); - if (res.status !== 0) return { files: [], total: 0 }; - const all = res.stdout - .split('\n') - .filter(Boolean) - .filter((rel) => SOURCE_EXTENSIONS.has(path.extname(rel).toLowerCase())); - // Scan a bounded subset for performance, but report the true total so - // orientation can tell the agent when the map is a sample of a larger tree. - return { files: all.slice(0, MAX_FILES_SCANNED), total: all.length }; -} - -/** - * Read a tracked file with the shared fs-safe defenses: EVERY ancestor - * component of the tracked path is validated against the workspace root - * first (assertNoSymlinkAncestors — a tracked file can still be listed by - * `git ls-files` after `src/` itself was swapped for a symlink pointing - * outside the workspace, and readFileNoFollow's O_NOFOLLOW only guards the - * FINAL component, so without the ancestor walk the kernel happily follows - * the symlinked directory and outside file content leaks into a committed - * map), then the leaf itself is opened no-follow (readFileNoFollow) — the - * same two-layer defense the episode readers use. Never throws; an - * escaping/symlinked/missing/oversized file reads as empty, same as before. - */ -function readFileSafe(workspace, rel) { - const full = assertNoSymlinkAncestors(workspace, rel); - if (!full) return ''; - // `root: workspace` → readFileNoFollow verifies (canonicalize-after-acquire) - // the opened inode's realpath is contained under the real workspace, closing - // the ancestor-swap window between the walk above and the leaf open. - return readFileNoFollow(full, { maxBytes: MAX_FILE_BYTES, root: workspace }) ?? ''; -} /** * Build a budgeted lexical repo map. Deterministic: no model, no network. @@ -49,13 +14,30 @@ function readFileSafe(workspace, rel) { * density, and — when a query is given — boosted by normalized-token overlap * with the path and symbols, so orientation is code-relevant to the task. */ -export function buildRepoMap({ workspace, query = '', maxTokens = DEFAULT_MAX_TOKENS, extract = lexicalExtract, title = 'Repo Map' } = {}) { +export function buildRepoMap({ workspace, query = '', maxTokens = DEFAULT_MAX_TOKENS, extract = lexicalExtract, title = 'Repo Map', preferStructural = true } = {}) { const { files, total } = trackedSourceFiles(workspace); if (!files.length) return { files: [], body: '', tokens: 0, empty: true }; + // Structural preference (blueprint P3): when the prebuilt structural index + // exists AND its generation sha matches the current HEAD, its per-file + // symbol/import tables feed the ranking directly — no per-file reads, and + // symbol precision comes from the AST tier that built the index. Absent or + // stale index → the unchanged lexical path, byte-identical output. This + // stays a SYNCHRONOUS read of prebuilt files; parsing itself only ever + // happens inside `harness index --structural`. + let structural = null; + if (preferStructural) { + try { + structural = readStructuralIndexIfCurrent(workspace); + } catch { + structural = null; + } + } + const info = new Map(); for (const rel of files) { - const { symbols, imports } = extract(rel, readFileSafe(workspace, rel)); + const pre = structural?.files?.[rel]; + const { symbols, imports } = pre || extract(rel, readFileSafe(workspace, rel)); info.set(rel, { rel, symbols, imports, importedBy: 0 }); } @@ -91,7 +73,7 @@ export function buildRepoMap({ workspace, query = '', maxTokens = DEFAULT_MAX_TO const lines = [ `# ${title}`, '', - `> Deterministic lexical map of ${total} tracked source files${total > files.length ? ` (top ${files.length} scanned)` : ''}.${query ? ` Ranked for: "${query}".` : ''}`, + `> Deterministic ${structural ? 'structural' : 'lexical'} map of ${total} tracked source files${total > files.length ? ` (top ${files.length} scanned)` : ''}.${query ? ` Ranked for: "${query}".` : ''}`, '', ]; const selected = []; @@ -104,7 +86,7 @@ export function buildRepoMap({ workspace, query = '', maxTokens = DEFAULT_MAX_TO } const body = lines.join('\n'); - return { files: selected, body, tokens: estimateTokens(body), empty: false, totalFiles: total }; + return { files: selected, body, tokens: estimateTokens(body), empty: false, totalFiles: total, structural: Boolean(structural) }; } /** @@ -113,7 +95,11 @@ export function buildRepoMap({ workspace, query = '', maxTokens = DEFAULT_MAX_TO * the code structure changes — a durable cold-start orientation for agents. */ export function writeCodebaseMap({ workspace, dryRun = false, maxTokens = 2500 }) { - const map = buildRepoMap({ workspace, query: '', maxTokens, title: 'Codebase Map' }); + // The COMMITTED map stays lexical-only (preferStructural: false): it must + // be byte-identical across hosts for the same tree, and whether a given + // host has built a structural index is host-local state that must never + // leak into a committed artifact. + const map = buildRepoMap({ workspace, query: '', maxTokens, title: 'Codebase Map', preferStructural: false }); if (map.empty) return null; const rel = path.join('docs', 'codebase-map.md'); if (!dryRun) { diff --git a/packages/harness/lib/repo-map/scan.mjs b/packages/harness/lib/repo-map/scan.mjs new file mode 100644 index 00000000..70a3344f --- /dev/null +++ b/packages/harness/lib/repo-map/scan.mjs @@ -0,0 +1,46 @@ +// Shared workspace scan primitives for the repo-map tiers — extracted from +// repo-map/index.mjs so the budgeted map builder AND the persistent +// structural index (structural-index.mjs) enumerate and read tracked files +// through ONE bounded, symlink-safe implementation instead of drifting +// copies. Deterministic: git + local fs only, no model, no network. + +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { SOURCE_EXTENSIONS } from './lexical-extractor.mjs'; +import { readFileNoFollow, assertNoSymlinkAncestors } from '../fs-safe.mjs'; + +export const MAX_FILES_SCANNED = 4000; +export const MAX_FILE_BYTES = 200_000; + +export function trackedSourceFiles(workspace) { + const res = spawnSync('git', ['-C', workspace, 'ls-files'], { encoding: 'utf8', timeout: 15_000 }); + if (res.status !== 0) return { files: [], total: 0 }; + const all = res.stdout + .split('\n') + .filter(Boolean) + .filter((rel) => SOURCE_EXTENSIONS.has(path.extname(rel).toLowerCase())); + // Scan a bounded subset for performance, but report the true total so + // orientation can tell the agent when the map is a sample of a larger tree. + return { files: all.slice(0, MAX_FILES_SCANNED), total: all.length }; +} + +/** + * Read a tracked file with the shared fs-safe defenses: EVERY ancestor + * component of the tracked path is validated against the workspace root + * first (assertNoSymlinkAncestors — a tracked file can still be listed by + * `git ls-files` after `src/` itself was swapped for a symlink pointing + * outside the workspace, and readFileNoFollow's O_NOFOLLOW only guards the + * FINAL component, so without the ancestor walk the kernel happily follows + * the symlinked directory and outside file content leaks into a committed + * map), then the leaf itself is opened no-follow (readFileNoFollow) — the + * same two-layer defense the episode readers use. Never throws; an + * escaping/symlinked/missing/oversized file reads as empty, same as before. + */ +export function readFileSafe(workspace, rel) { + const full = assertNoSymlinkAncestors(workspace, rel); + if (!full) return ''; + // `root: workspace` → readFileNoFollow verifies (canonicalize-after-acquire) + // the opened inode's realpath is contained under the real workspace, closing + // the ancestor-swap window between the walk above and the leaf open. + return readFileNoFollow(full, { maxBytes: MAX_FILE_BYTES, root: workspace }) ?? ''; +} diff --git a/packages/harness/lib/repo-map/structural-index.mjs b/packages/harness/lib/repo-map/structural-index.mjs new file mode 100644 index 00000000..4493eb23 --- /dev/null +++ b/packages/harness/lib/repo-map/structural-index.mjs @@ -0,0 +1,420 @@ +// Persistent structural codebase index (blueprint P3). Lives OUTSIDE the +// knowledge git store at ~/.harness/index//structural/ — derived and +// rebuildable: deleting the directory never loses knowledge, and it never +// touches governance history. Four tables: +// files.json per-file { hash, mtime, size, symbols, imports, complexity, +// defs, refs, tier } — the superset the incremental rebuild +// and symbol table are derived from +// symbols.json declaration table: name → { defs: [{file,line,kind, +// exported}], refs: [{file,line}] } +// graph.json caller/callee approximation + module dependency edges; +// unresolved edges preserved EXPLICITLY, never fabricated +// meta.json { sha, branch, baseSha, generatedAt, extractorTier, +// grammarVersions, ... } — the P9 generation-context stamp +// All writes are atomic temp+rename through fs-safe's writeFileContained. +// Building is async-command-path work (harness index --structural); READING +// is fully synchronous so buildRepoMap/orient stay sync and model-free. +// +// Extracted names/locations are UNTRUSTED repo text: every string passes +// redactSecrets + a length cap at index-WRITE time here, and every human or +// agent render additionally passes inertLine (renderStructuralDigest). + +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { harnessGlobalHome } from '../paths.mjs'; +import { repoId, inertLine } from '../knowledge/store.mjs'; +import { writeFileContained, readFileNoFollow } from '../fs-safe.mjs'; +import { redactSecrets } from '../secret-scan.mjs'; +import { estimateTokens } from '../token-meter.mjs'; +import { EXIT } from '../style.mjs'; +import { trackedSourceFiles, readFileSafe } from './scan.mjs'; +import { MAX_IDENTIFIER_LENGTH } from './treesitter-extractor.mjs'; + +export const STRUCTURAL_INDEX_VERSION = 1; + +// Bounded tables: a hostile or simply huge tree must not balloon the index +// or any surface rendered from it. +const MAX_SYMBOL_TABLE = 20_000; +const MAX_DEFS_PER_SYMBOL = 20; +const MAX_REFS_PER_SYMBOL = 50; +const MAX_MODULE_EDGES = 20_000; +const MAX_CALL_EDGES = 20_000; +const MAX_UNRESOLVED = 4_000; +const MAX_DELTA_NAMES = 50; + +function git(workspace, args) { + const r = spawnSync('git', ['-C', workspace, ...args], { encoding: 'utf8', timeout: 15_000 }); + return r.status === 0 ? r.stdout.trim() : null; +} + +function sha256(text) { + return crypto.createHash('sha256').update(text).digest('hex'); +} + +/** ~/.harness/index//structural — respects HARNESS_HOME via harnessGlobalHome. */ +export function structuralIndexDir(workspace, { home } = {}) { + return path.join(home || harnessGlobalHome(), 'index', repoId(workspace), 'structural'); +} + +function readJson(dir, name) { + const body = readFileNoFollow(path.join(dir, name), { root: dir }); + if (body === null) return null; + try { + return JSON.parse(body); + } catch { + return null; + } +} + +/** + * Synchronous, tolerant read of the prebuilt index. Returns + * { dir, meta, files, symbols, graph } or null when no readable index exists. + * The `/index` pre-check keeps the common no-index case free of the + * repoId git spawn — orient calls this every session. + */ +export function readStructuralIndex(workspace, { home } = {}) { + if (!fs.existsSync(path.join(home || harnessGlobalHome(), 'index'))) return null; + const dir = structuralIndexDir(workspace, { home }); + if (!fs.existsSync(path.join(dir, 'meta.json'))) return null; + const meta = readJson(dir, 'meta.json'); + if (!meta || typeof meta !== 'object' || !meta.version) return null; + return { + dir, + meta, + files: readJson(dir, 'files.json') || {}, + symbols: readJson(dir, 'symbols.json') || {}, + graph: readJson(dir, 'graph.json') || {}, + }; +} + +/** + * The orient-side gate: hand back the index ONLY when its generation stamp + * matches the current HEAD — otherwise consumers keep their unchanged lexical + * behavior. Cheap when absent (one existsSync, no git spawn). + */ +export function readStructuralIndexIfCurrent(workspace, { home } = {}) { + if (!fs.existsSync(path.join(home || harnessGlobalHome(), 'index'))) return null; + const index = readStructuralIndex(workspace, { home }); + if (!index || !index.meta.sha) return null; + const head = git(workspace, ['rev-parse', 'HEAD']); + if (!head || head !== index.meta.sha) return null; + return index; +} + +/** + * Validate a user-supplied `--since` ref: reject anything that could read as + * a git option (leading `-`), then require `git rev-parse --verify` to + * resolve it to a commit — always after `--end-of-options`. Returns the + * resolved sha; throws the CLI usage-error shape otherwise. + */ +export function validateSinceRef(workspace, ref) { + const value = String(ref || '').trim(); + if (!value || value.startsWith('-')) { + throw Object.assign(new Error(`invalid --since ref: ${JSON.stringify(String(ref || ''))}`), { + code: 'E_USAGE', + hint: 'pass a git ref (branch, tag, or sha) that does not start with "-"', + exit: EXIT.usage, + }); + } + const sha = git(workspace, ['rev-parse', '--verify', '--quiet', '--end-of-options', `${value}^{commit}`]); + if (!sha) { + throw Object.assign(new Error(`--since ref does not resolve to a commit: ${JSON.stringify(value)}`), { + code: 'E_USAGE', + hint: 'git rev-parse --verify must succeed in this workspace', + exit: EXIT.usage, + }); + } + return sha; +} + +function changedFilesSince(workspace, sha) { + const out = git(workspace, ['diff', '--name-only', sha, '--']); + if (out === null) return null; // diff failed → caller degrades to a full pass + return new Set(out.split('\n').filter(Boolean)); +} + +// Names and free-text fields extracted from repo files are untrusted: redact +// secret-shaped content FIRST (a truncated credential might no longer match +// the screen), then cap the length. +function cleanName(name) { + const redacted = redactSecrets(String(name ?? '')); + return redacted.length > MAX_IDENTIFIER_LENGTH ? redacted.slice(0, MAX_IDENTIFIER_LENGTH) : redacted; +} + +function sanitizeEntry(res, { hash, mtime, size }) { + return { + hash, + mtime, + size, + symbols: (res.symbols || []).map(cleanName), + imports: (res.imports || []).map(cleanName), + complexity: Number.isFinite(res.complexity) ? res.complexity : 1, + defs: (res.defs || []).map((d) => ({ + name: cleanName(d.name), + kind: String(d.kind || 'symbol').slice(0, 24), + line: Number.isFinite(d.line) ? d.line : 0, + exported: Boolean(d.exported), + })), + refs: (res.refs || []).map((r) => ({ + name: cleanName(r.name), + line: Number.isFinite(r.line) ? r.line : 0, + })), + tier: res.tier === 'treesitter' ? 'treesitter' : 'lexical', + ...(res.hasErrors ? { errors: true } : {}), + }; +} + +function buildSymbolTable(files) { + const symbols = {}; + const rels = Object.keys(files).sort(); + for (const rel of rels) { + for (const d of files[rel].defs) { + if (!symbols[d.name]) { + if (Object.keys(symbols).length >= MAX_SYMBOL_TABLE) continue; + symbols[d.name] = { defs: [], refs: [] }; + } + if (symbols[d.name].defs.length < MAX_DEFS_PER_SYMBOL) { + symbols[d.name].defs.push({ file: rel, line: d.line, kind: d.kind, exported: d.exported }); + } + } + } + for (const rel of rels) { + for (const r of files[rel].refs) { + const entry = symbols[r.name]; + if (entry && entry.refs.length < MAX_REFS_PER_SYMBOL) entry.refs.push({ file: rel, line: r.line }); + } + } + return symbols; +} + +function buildGraph(files, symbols) { + const rels = Object.keys(files).sort(); + // Module edges use the same basename-stem approximation the repo map uses + // for import-degree. An import that resolves to no tracked file is KEPT as + // an unresolved edge — recorded, never guessed into a target. + const byStem = new Map(); + for (const rel of rels) { + const stem = path.basename(rel).replace(/\.\w+$/, ''); + if (!byStem.has(stem)) byStem.set(stem, []); + byStem.get(stem).push(rel); + } + const modules = []; + const unresolvedImports = []; + for (const rel of rels) { + for (const imp of files[rel].imports) { + // Strip a trailing source extension first: './b.mjs' must stem to 'b', + // not 'mjs' (the bare split would take the extension as the last part). + const last = imp + .replace(/['"]/g, '') + .replace(/\.(?:js|jsx|mjs|cjs|ts|tsx|py|java)$/i, '') + .split(/[./\\]/) + .filter(Boolean) + .pop(); + const targets = (last && byStem.get(last)) || []; + if (targets.length) { + for (const to of targets) { + if (to !== rel && modules.length < MAX_MODULE_EDGES) modules.push({ from: rel, to, via: imp }); + } + } else if (unresolvedImports.length < MAX_UNRESOLVED) { + unresolvedImports.push({ from: rel, import: imp }); + } + } + } + // Caller/callee approximation: a ref name that the declaration table binds + // to files OTHER than the caller becomes a call edge; everything else is an + // explicit unresolved call, never a fabricated edge. + const calls = []; + const unresolvedCalls = []; + const seenUnresolved = new Set(); + for (const rel of rels) { + const perFile = new Map(); + for (const r of files[rel].refs) { + if (perFile.has(r.name)) continue; + perFile.set(r.name, true); + const entry = symbols[r.name]; + const to = entry ? [...new Set(entry.defs.map((d) => d.file))].filter((f) => f !== rel).slice(0, 5) : []; + if (to.length) { + if (calls.length < MAX_CALL_EDGES) calls.push({ from: rel, symbol: r.name, to }); + } else if (!entry) { + const key = `${rel}${r.name}`; + if (!seenUnresolved.has(key) && unresolvedCalls.length < MAX_UNRESOLVED) { + seenUnresolved.add(key); + unresolvedCalls.push({ from: rel, symbol: r.name }); + } + } + } + } + return { modules, unresolvedImports, calls, unresolvedCalls }; +} + +function symbolDelta(priorSymbols, nextSymbols) { + const prior = priorSymbols || {}; + const added = []; + const removed = []; + const changed = []; + for (const name of Object.keys(nextSymbols)) { + if (!(name in prior)) added.push(name); + else if (JSON.stringify(prior[name].defs) !== JSON.stringify(nextSymbols[name].defs)) changed.push(name); + } + for (const name of Object.keys(prior)) { + if (!(name in nextSymbols)) removed.push(name); + } + const cap = (list) => ({ count: list.length, names: list.sort().slice(0, MAX_DELTA_NAMES) }); + return { added: cap(added), removed: cap(removed), changed: cap(changed) }; +} + +/** + * Build (or incrementally refresh) the structural index. Async only because + * the command path around it is async — the work itself is local fs + git + + * the injected extractor. Incremental discipline: + * 1. mtime+size fast path — an unchanged stat reuses the prior entry with + * no read at all; + * 2. sha256 content-hash confirm — a touched-but-identical file reuses the + * prior entry without re-parsing; + * 3. `since` (a PRE-VALIDATED sha from validateSinceRef) narrows the + * re-parse candidates to `git diff --name-only --`; files outside + * the diff keep their prior entries verbatim. + * Bounded by the shared MAX_FILES_SCANNED / MAX_FILE_BYTES caps. + */ +export async function buildStructuralIndex({ workspace, home, extractor, since = null, dryRun = false, log = () => {} }) { + const dir = structuralIndexDir(workspace, { home }); + const prior = readStructuralIndex(workspace, { home }); + const { files: tracked, total } = trackedSourceFiles(workspace); + const changed = since && prior ? changedFilesSince(workspace, since) : null; + + const nextFiles = {}; + let reparsed = 0; + let reused = 0; + for (const rel of tracked) { + const priorEntry = prior?.files?.[rel]; + if (changed && priorEntry && !changed.has(rel)) { + nextFiles[rel] = priorEntry; + reused += 1; + continue; + } + let st = null; + try { + st = fs.statSync(path.join(workspace, rel)); + } catch { + continue; // listed but unreadable — skip, never guess + } + if (priorEntry && !changed?.has(rel) && priorEntry.mtime === st.mtimeMs && priorEntry.size === st.size) { + nextFiles[rel] = priorEntry; + reused += 1; + continue; + } + const content = readFileSafe(workspace, rel); + const hash = sha256(content); + if (priorEntry && priorEntry.hash === hash) { + nextFiles[rel] = { ...priorEntry, mtime: st.mtimeMs, size: st.size }; + reused += 1; + continue; + } + nextFiles[rel] = sanitizeEntry(extractor.extract(rel, content), { hash, mtime: st.mtimeMs, size: st.size }); + reparsed += 1; + } + const removedFiles = Object.keys(prior?.files || {}).filter((rel) => !(rel in nextFiles)).length; + + const symbols = buildSymbolTable(nextFiles); + const graph = buildGraph(nextFiles, symbols); + const delta = symbolDelta(prior?.symbols, symbols); + + const errorFiles = Object.values(nextFiles).filter((f) => f.errors).length; + const meta = { + version: STRUCTURAL_INDEX_VERSION, + sha: git(workspace, ['rev-parse', 'HEAD']), + branch: git(workspace, ['rev-parse', '--abbrev-ref', 'HEAD']), + baseSha: since || null, + generatedAt: new Date().toISOString(), + extractorTier: extractor.tier || 'lexical', + webTreeSitter: extractor.webTreeSitter || null, + grammarVersions: extractor.grammarVersions || {}, + missingGrammars: extractor.missingGrammars || [], + integrityFailures: extractor.integrityFailures || [], + parseFailures: extractor.counters?.parseFailures ?? 0, + errorFiles, + filesIndexed: Object.keys(nextFiles).length, + totalTracked: total, + truncated: total > tracked.length, + }; + + if (!dryRun) { + // meta.json is written LAST: readers treat meta as the completeness + // signal, so a crashed build leaves the previous stamp in place instead + // of presenting fresh-looking metadata over half-written tables. Each + // individual write is atomic (fs-safe temp + rename). + const writes = [ + ['files.json', nextFiles], + ['symbols.json', symbols], + ['graph.json', graph], + ['meta.json', meta], + ]; + for (const [name, data] of writes) { + if (!writeFileContained(dir, name, JSON.stringify(data) + '\n')) { + log(`structural index write refused: ${name}`); + return { dir, written: false, reparsed, reused, removedFiles, delta, meta }; + } + } + } + + return { dir, written: !dryRun, reparsed, reused, removedFiles, delta, meta }; +} + +/** + * Budgeted text rendering of the structural index — the AGENT lane of the + * three-audience contract (blueprint §9). Never raw index JSON: a bounded, + * framed digest under a token budget (repo-map's 1000-token budget is the + * precedent), every line passed through inertLine because symbol names and + * paths are retrieved repo text. + */ +export function renderStructuralDigest(index, { maxTokens = 1000 } = {}) { + const { meta, files, symbols, graph } = index; + const lines = [ + '# Structural Index Digest', + '', + inertLine( + `> ${meta.filesIndexed} files @ ${(meta.sha || 'unknown').slice(0, 7)} (${meta.branch || '?'}) · tier ${meta.extractorTier}` + + (meta.integrityFailures?.length ? ` · GRAMMAR INTEGRITY FAILED (${meta.integrityFailures.length})` : '') + ), + '', + ]; + const push = (line) => { + if (estimateTokens([...lines, line].join('\n')) > maxTokens) return false; + lines.push(line); + return true; + }; + + const topRefs = Object.entries(symbols) + .filter(([, s]) => s.refs.length) + .sort((a, b) => b[1].refs.length - a[1].refs.length || (a[0] < b[0] ? -1 : 1)) + .slice(0, 10); + if (topRefs.length) { + push('Most-referenced symbols:'); + for (const [name, s] of topRefs) { + const def = s.defs[0]; + if (!push(inertLine(`- ${name} (${s.refs.length} refs) — ${def ? `${def.file}:${def.line}` : 'defs elsewhere'}`))) break; + } + push(''); + } + + const hotspots = Object.entries(files) + .sort((a, b) => b[1].complexity - a[1].complexity || (a[0] < b[0] ? -1 : 1)) + .slice(0, 8); + if (hotspots.length) { + push('Complexity hotspots:'); + for (const [rel, f] of hotspots) { + if (!push(inertLine(`- ${rel} (branches ~${f.complexity}, symbols ${f.symbols.length})`))) break; + } + push(''); + } + + push( + inertLine( + `Edges: ${graph.modules?.length ?? 0} module · ${graph.calls?.length ?? 0} call · unresolved ${graph.unresolvedImports?.length ?? 0} imports / ${graph.unresolvedCalls?.length ?? 0} calls (unresolved edges are preserved, never fabricated)` + ) + ); + const body = lines.join('\n'); + return { body, tokens: estimateTokens(body) }; +} diff --git a/packages/harness/lib/repo-map/treesitter-extractor.mjs b/packages/harness/lib/repo-map/treesitter-extractor.mjs new file mode 100644 index 00000000..e5a35680 --- /dev/null +++ b/packages/harness/lib/repo-map/treesitter-extractor.mjs @@ -0,0 +1,507 @@ +// Tree-sitter tier (blueprint P3, D2) behind the repo-map extractor seam. +// Implements the same `extract(rel, content)` shape as the lexical extractor +// with an extended v2 result `{ symbols, imports, defs, refs, complexity }` +// (v1 `symbols`/`imports` preserved, so every existing consumer keeps +// working). Languages: TypeScript/JavaScript (+TSX), Python, Java via +// web-tree-sitter WASM grammars shipped as OPTIONAL dependencies — any other +// language, a missing grammar, a parse failure, or an init failure falls back +// silently PER FILE to the lexical extractor, so the harness works fully with +// the grammars absent. +// +// ASYNC LIFECYCLE: web-tree-sitter requires async init, and `buildRepoMap`/ +// orient are (and must stay) synchronous. Resolution: parsing happens ONLY +// inside the async `harness index --structural` command path via the +// `createTreesitterExtract()` factory below; orient and every other consumer +// read the PREBUILT structural index files synchronously. +// +// GRAMMAR INTEGRITY (binding): `grammars.lock` (JSON, shipped alongside this +// module) pins a sha256 digest for every wasm — runtime and grammars. Each +// wasm's bytes are hashed BEFORE instantiation and the verified bytes +// themselves are what gets instantiated (no hash-then-reopen TOCTOU). Any +// mismatch is a LOUD lexical fallback: recorded on the factory result, +// stamped into the index meta, and surfaced by doctor S1 as a failure — never +// a warning. A merely ABSENT grammar stays a silent fallback by design. +// +// No network, no model: wasm bytes come from local disk only; parsing is pure +// computation. (The pinned no-model regex in prompt-library-contracts applies +// to the orient read path; this module honors the same discipline.) + +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { extract as lexicalExtract } from './lexical-extractor.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export const DEFAULT_LOCK_PATH = path.join(__dirname, 'grammars.lock'); + +// Bounded output: extracted names are untrusted repo text — cap identifier +// lengths and per-file counts so a crafted file cannot balloon the index. +export const MAX_IDENTIFIER_LENGTH = 160; +export const MAX_DEFS_PER_FILE = 512; +export const MAX_REFS_PER_FILE = 1024; +export const MAX_IMPORTS_PER_FILE = 256; + +/** File extension → grammars.lock language key. Everything else is lexical. */ +export const STRUCTURAL_LANGUAGES = { + '.js': 'javascript', + '.jsx': 'javascript', + '.mjs': 'javascript', + '.cjs': 'javascript', + '.ts': 'typescript', + '.tsx': 'tsx', + '.py': 'python', + '.java': 'java', +}; + +/** + * Cheap deterministic branch count — one language-agnostic approximation used + * by BOTH tiers so `complexity` is comparable across parsed and fallback + * files. Counts branch keywords and short-circuit operators, floor 1. + */ +export function branchComplexity(content) { + const m = String(content || '').match(/\b(?:if|for|while|case|catch|elif|except|when)\b|&&|\|\||\?\?/g); + return (m ? m.length : 0) + 1; +} + +function capName(name) { + const s = String(name || ''); + return s.length > MAX_IDENTIFIER_LENGTH ? s.slice(0, MAX_IDENTIFIER_LENGTH) : s; +} + +/** + * Lexical extraction lifted to the v2 result shape — the permanent fallback + * tier. Each lexical symbol becomes a `kind: 'symbol'` def located at its + * first occurrence line (an approximation, honestly labeled by the lexical + * tier — the AST tier records real declaration sites). `refs` stay empty: + * the lexical tier has no call facts to offer and never fabricates any. + */ +export function lexicalV2(rel, content) { + const { symbols, imports } = lexicalExtract(rel, content); + const lines = String(content || '').split('\n'); + const defs = symbols.slice(0, MAX_DEFS_PER_FILE).map((name) => { + const at = lines.findIndex((l) => l.includes(name)); + return { name: capName(name), kind: 'symbol', line: at === -1 ? 0 : at + 1, exported: false }; + }); + return { + symbols: symbols.map(capName), + imports: imports.map(capName), + defs, + refs: [], + complexity: branchComplexity(content), + tier: 'lexical', + }; +} + +/** Read and parse grammars.lock. Returns null when missing/unreadable. */ +export function loadGrammarsLock({ lockPath = DEFAULT_LOCK_PATH } = {}) { + try { + const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')); + if (!lock || typeof lock !== 'object' || !lock.grammars) return null; + return lock; + } catch { + return null; + } +} + +function sha256(bytes) { + return crypto.createHash('sha256').update(bytes).digest('hex'); +} + +/** Default roots searched for `/` grammar wasm files. */ +function defaultGrammarRoots() { + // The harness package's own node_modules first (optionalDependencies land + // there), then any parent node_modules the resolver would consult. + const pkgRoot = path.resolve(__dirname, '..', '..'); + const roots = [path.join(pkgRoot, 'node_modules')]; + let cur = path.dirname(pkgRoot); + for (let i = 0; i < 6; i++) { + roots.push(path.join(cur, 'node_modules')); + const parent = path.dirname(cur); + if (parent === cur) break; + cur = parent; + } + return roots; +} + +function findWasm(roots, pkg, file) { + for (const root of roots) { + const full = path.join(root, pkg, file); + try { + if (fs.statSync(full).isFile()) return full; + } catch { + // keep looking + } + } + return null; +} + +/** + * Synchronous availability + integrity report — no instantiation, no async. + * Doctor S1 uses this to check the CURRENT on-disk grammar state (an index + * meta records what was true at build time; this records what is true now). + * Shape: { lock, runtime: {present, ok, path?}, grammars: {lang: {present, + * ok, version, path?}}, integrityFailures: [{language, file, reason}] }. + */ +export function grammarStatus({ grammarRoots, lockPath } = {}) { + const lock = loadGrammarsLock({ lockPath }); + const roots = grammarRoots || defaultGrammarRoots(); + const status = { lock: Boolean(lock), runtime: { present: false, ok: false }, grammars: {}, integrityFailures: [] }; + if (!lock) return status; + const check = (language, spec) => { + const full = findWasm(roots, spec.package, spec.file); + if (!full) return { present: false, ok: false, version: spec.version }; + let bytes; + try { + bytes = fs.readFileSync(full); + } catch { + return { present: false, ok: false, version: spec.version }; + } + const ok = sha256(bytes) === spec.sha256; + if (!ok) status.integrityFailures.push({ language, file: spec.file, reason: 'sha256 mismatch vs grammars.lock' }); + return { present: true, ok, version: spec.version, path: full }; + }; + status.runtime = check('runtime', lock.runtime); + for (const [language, spec] of Object.entries(lock.grammars)) { + status.grammars[language] = check(language, spec); + } + return status; +} + +// --------------------------------------------------------------------------- +// Per-language AST walking tables. Node types are stable tree-sitter grammar +// facts for the pinned versions in grammars.lock. +// --------------------------------------------------------------------------- + +const JS_DEF_TYPES = [ + 'function_declaration', + 'generator_function_declaration', + 'class_declaration', + 'abstract_class_declaration', + 'method_definition', + 'interface_declaration', + 'type_alias_declaration', + 'enum_declaration', + 'variable_declarator', +]; + +function hasAncestorOfType(node, type, maxUp = 4) { + let cur = node.parent; + for (let i = 0; i < maxUp && cur; i++) { + if (cur.type === type) return true; + cur = cur.parent; + } + return false; +} + +function defKind(type) { + if (type.includes('class')) return 'class'; + if (type.includes('interface')) return 'interface'; + if (type.includes('enum')) return 'enum'; + if (type.includes('type_alias')) return 'type'; + if (type.includes('method') || type === 'constructor_declaration') return 'method'; + if (type.includes('record')) return 'record'; + if (type === 'variable_declarator') return 'const'; + if (type === 'annotation_type_declaration') return 'annotation'; + return 'function'; +} + +function walkJs(root) { + const defs = []; + for (const node of root.descendantsOfType(JS_DEF_TYPES)) { + if (defs.length >= MAX_DEFS_PER_FILE) break; + const nameNode = node.childForFieldName('name'); + if (!nameNode) continue; + if (node.type === 'variable_declarator') { + const value = node.childForFieldName('value'); + const fnValue = value && (value.type === 'arrow_function' || value.type === 'function_expression' || value.type === 'generator_function'); + const exported = hasAncestorOfType(node, 'export_statement'); + if (!fnValue && !exported) continue; + defs.push({ name: nameNode.text, kind: fnValue ? 'function' : 'const', line: node.startPosition.row + 1, exported }); + continue; + } + defs.push({ + name: nameNode.text, + kind: defKind(node.type), + line: node.startPosition.row + 1, + exported: hasAncestorOfType(node, 'export_statement'), + }); + } + const imports = []; + for (const node of root.descendantsOfType(['import_statement', 'export_statement'])) { + if (imports.length >= MAX_IMPORTS_PER_FILE) break; + const source = node.childForFieldName('source'); + if (source) imports.push(source.text.replace(/^['"`]|['"`]$/g, '')); + } + const refs = []; + for (const node of root.descendantsOfType(['call_expression', 'new_expression'])) { + if (refs.length >= MAX_REFS_PER_FILE) break; + const target = node.childForFieldName(node.type === 'new_expression' ? 'constructor' : 'function'); + if (!target) continue; + if (target.type === 'identifier') { + if (target.text === 'require') { + const args = node.childForFieldName('arguments'); + const arg = args?.namedChildren?.[0]; + if (arg && arg.type === 'string' && imports.length < MAX_IMPORTS_PER_FILE) { + imports.push(arg.text.replace(/^['"`]|['"`]$/g, '')); + } + continue; + } + refs.push({ name: target.text, line: node.startPosition.row + 1 }); + } else if (target.type === 'member_expression') { + const prop = target.childForFieldName('property'); + if (prop) refs.push({ name: prop.text, line: node.startPosition.row + 1 }); + } + } + return { defs, imports, refs }; +} + +function walkPython(root) { + const defs = []; + for (const node of root.descendantsOfType(['class_definition', 'function_definition'])) { + if (defs.length >= MAX_DEFS_PER_FILE) break; + const nameNode = node.childForFieldName('name'); + if (!nameNode) continue; + // "Exported" approximation: module-level and not underscore-private. + const parent = node.parent?.type === 'decorated_definition' ? node.parent : node; + const topLevel = parent.parent?.type === 'module'; + defs.push({ + name: nameNode.text, + kind: node.type === 'class_definition' ? 'class' : 'function', + line: node.startPosition.row + 1, + exported: topLevel && !nameNode.text.startsWith('_'), + }); + } + const imports = []; + for (const node of root.descendantsOfType(['import_from_statement', 'import_statement'])) { + if (imports.length >= MAX_IMPORTS_PER_FILE) break; + if (node.type === 'import_from_statement') { + const mod = node.childForFieldName('module_name'); + if (mod) imports.push(mod.text); + } else { + for (const name of node.namedChildren) { + if (name.type === 'dotted_name' || name.type === 'aliased_import') { + imports.push(name.type === 'aliased_import' ? name.childForFieldName('name')?.text || name.text : name.text); + } + } + } + } + const refs = []; + for (const node of root.descendantsOfType('call')) { + if (refs.length >= MAX_REFS_PER_FILE) break; + const fn = node.childForFieldName('function'); + if (!fn) continue; + if (fn.type === 'identifier') refs.push({ name: fn.text, line: node.startPosition.row + 1 }); + else if (fn.type === 'attribute') { + const attr = fn.childForFieldName('attribute'); + if (attr) refs.push({ name: attr.text, line: node.startPosition.row + 1 }); + } + } + return { defs, imports, refs }; +} + +const JAVA_DEF_TYPES = [ + 'class_declaration', + 'interface_declaration', + 'enum_declaration', + 'record_declaration', + 'annotation_type_declaration', + 'method_declaration', + 'constructor_declaration', +]; + +function walkJava(root) { + const defs = []; + for (const node of root.descendantsOfType(JAVA_DEF_TYPES)) { + if (defs.length >= MAX_DEFS_PER_FILE) break; + const nameNode = node.childForFieldName('name'); + if (!nameNode) continue; + const modifiers = node.namedChildren.find((c) => c.type === 'modifiers'); + defs.push({ + name: nameNode.text, + kind: defKind(node.type), + line: node.startPosition.row + 1, + exported: Boolean(modifiers && /\bpublic\b/.test(modifiers.text)), + }); + } + const imports = []; + for (const node of root.descendantsOfType('import_declaration')) { + if (imports.length >= MAX_IMPORTS_PER_FILE) break; + imports.push(node.text.replace(/^import\s+(static\s+)?/, '').replace(/;?\s*$/, '')); + } + const refs = []; + for (const node of root.descendantsOfType(['method_invocation', 'object_creation_expression'])) { + if (refs.length >= MAX_REFS_PER_FILE) break; + const target = node.childForFieldName(node.type === 'method_invocation' ? 'name' : 'type'); + if (target) refs.push({ name: target.text, line: node.startPosition.row + 1 }); + } + return { defs, imports, refs }; +} + +const WALKERS = { + javascript: walkJs, + typescript: walkJs, + tsx: walkJs, + python: walkPython, + java: walkJava, +}; + +function capResult(walked) { + const defs = walked.defs.slice(0, MAX_DEFS_PER_FILE).map((d) => ({ ...d, name: capName(d.name) })); + const imports = [...new Set(walked.imports.map(capName))].slice(0, MAX_IMPORTS_PER_FILE); + const refs = walked.refs.slice(0, MAX_REFS_PER_FILE).map((r) => ({ ...r, name: capName(r.name) })); + const symbols = [...new Set(defs.map((d) => d.name))]; + return { symbols, imports, defs, refs }; +} + +/** + * Build a v2 `extract(rel, content)` from an injectable `parseForLanguage` + * seam — exported so tests can prove the per-file fallback discipline + * (parse throw → lexical) without any grammar installed. `parseForLanguage + * (language, content)` returns a tree with `.rootNode` or throws; a null + * return means "no parser for this language" (silent lexical fallback). + */ +export function makeStructuralExtract({ parseForLanguage, counters = { parseFailures: 0, parsed: 0, errorFiles: 0 } }) { + const extract = (rel, content) => { + const language = STRUCTURAL_LANGUAGES[path.extname(rel).toLowerCase()]; + if (!language) return lexicalV2(rel, content); + const text = String(content || ''); + let tree = null; + try { + tree = parseForLanguage(language, text); + } catch { + counters.parseFailures += 1; + return lexicalV2(rel, content); + } + if (!tree || !tree.rootNode) return lexicalV2(rel, content); + try { + const walked = WALKERS[language](tree.rootNode); + const hasErrors = Boolean(tree.rootNode.hasError); + counters.parsed += 1; + if (hasErrors) counters.errorFiles += 1; + return { ...capResult(walked), complexity: branchComplexity(text), tier: 'treesitter', hasErrors }; + } catch { + counters.parseFailures += 1; + return lexicalV2(rel, content); + } finally { + try { + tree.delete?.(); + } catch { + // freeing the wasm-side tree is best-effort + } + } + }; + return { extract, counters }; +} + +/** + * Async factory used ONLY by the `harness index --structural` command path. + * Loads web-tree-sitter plus every lock-pinned grammar wasm whose sha256 + * verifies, and returns: + * { extract, tier, available, missingGrammars, integrityFailures, + * webTreeSitter, grammarVersions, counters } + * Absence at ANY level (module not installed, lock unreadable, wasm missing) + * degrades to a fully-lexical extract with `tier: 'lexical'`. An integrity + * mismatch ALSO degrades that grammar to lexical, but loudly: it is recorded + * in `integrityFailures` for the index meta and doctor S1. + */ +export async function createTreesitterExtract({ grammarRoots, lockPath } = {}) { + const counters = { parseFailures: 0, parsed: 0, errorFiles: 0 }; + const lexicalOnly = (reason, integrityFailures = []) => ({ + ...makeStructuralExtract({ parseForLanguage: () => null, counters }), + tier: 'lexical', + reason, + available: [], + missingGrammars: Object.keys(loadGrammarsLock({ lockPath })?.grammars || {}), + integrityFailures, + webTreeSitter: null, + grammarVersions: {}, + }); + + const lock = loadGrammarsLock({ lockPath }); + if (!lock) return lexicalOnly('grammars.lock missing or unreadable'); + + const roots = grammarRoots || defaultGrammarRoots(); + const integrityFailures = []; + + // Runtime wasm: verified bytes are handed to init as `wasmBinary`, so the + // exact object hashed is the exact object instantiated. + const runtimePath = findWasm(roots, lock.runtime.package, lock.runtime.file); + if (!runtimePath) return lexicalOnly('web-tree-sitter runtime not installed (optional)'); + let runtimeBytes; + try { + runtimeBytes = fs.readFileSync(runtimePath); + } catch { + return lexicalOnly('web-tree-sitter runtime unreadable'); + } + if (sha256(runtimeBytes) !== lock.runtime.sha256) { + const failure = { language: 'runtime', file: lock.runtime.file, reason: 'sha256 mismatch vs grammars.lock' }; + return lexicalOnly('runtime integrity mismatch', [failure]); + } + + let Parser; + let Language; + try { + ({ Parser, Language } = await import('web-tree-sitter')); + await Parser.init({ wasmBinary: runtimeBytes }); + } catch { + return lexicalOnly('web-tree-sitter init failed'); + } + + const languages = new Map(); + const grammarVersions = {}; + const missingGrammars = []; + for (const [language, spec] of Object.entries(lock.grammars)) { + const wasmPath = findWasm(roots, spec.package, spec.file); + if (!wasmPath) { + missingGrammars.push(language); + continue; + } + let bytes; + try { + bytes = fs.readFileSync(wasmPath); + } catch { + missingGrammars.push(language); + continue; + } + if (sha256(bytes) !== spec.sha256) { + integrityFailures.push({ language, file: spec.file, reason: 'sha256 mismatch vs grammars.lock' }); + continue; // loud lexical fallback for this grammar — recorded, surfaced by doctor S1 + } + try { + languages.set(language, await Language.load(bytes)); + grammarVersions[language] = spec.version; + } catch { + integrityFailures.push({ language, file: spec.file, reason: 'wasm failed to instantiate' }); + } + } + + if (!languages.size) { + const out = lexicalOnly(missingGrammars.length ? 'no grammar wasm installed (optional)' : 'no grammar verified', integrityFailures); + out.missingGrammars = missingGrammars; + return out; + } + + const parser = new Parser(); + let current = null; + const parseForLanguage = (language, text) => { + const lang = languages.get(language); + if (!lang) return null; + if (current !== language) { + parser.setLanguage(lang); + current = language; + } + return parser.parse(text); + }; + + return { + ...makeStructuralExtract({ parseForLanguage, counters }), + tier: 'treesitter', + available: [...languages.keys()], + missingGrammars, + integrityFailures, + webTreeSitter: lock.runtime.version, + grammarVersions, + }; +} diff --git a/packages/harness/package-lock.json b/packages/harness/package-lock.json index b6f212f4..524b2881 100644 --- a/packages/harness/package-lock.json +++ b/packages/harness/package-lock.json @@ -9,6 +9,11 @@ "version": "0.5.0", "license": "UNLICENSED", "dependencies": { + "tree-sitter-java": "0.23.5", + "tree-sitter-javascript": "0.23.1", + "tree-sitter-python": "0.23.6", + "tree-sitter-typescript": "0.23.2", + "web-tree-sitter": "0.25.10", "yaml": "^2.8.0" }, "bin": { @@ -16,6 +21,131 @@ }, "engines": { "node": ">=20" + }, + "optionalDependencies": { + "tree-sitter-java": "0.23.5", + "tree-sitter-javascript": "0.23.1", + "tree-sitter-python": "0.23.6", + "tree-sitter-typescript": "0.23.2", + "web-tree-sitter": "0.25.10" + } + }, + "node_modules/node-addon-api": { + "version": "8.9.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.1.tgz", + "integrity": "sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "optional": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/tree-sitter-java": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/tree-sitter-java/-/tree-sitter-java-0.23.5.tgz", + "integrity": "sha512-Yju7oQ0Xx7GcUT01mUglPP+bYfvqjNCGdxqigTnew9nLGoII42PNVP3bHrYeMxswiCRM0yubWmN5qk+zsg0zMA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^8.2.2", + "node-gyp-build": "^4.8.2" + }, + "peerDependencies": { + "tree-sitter": "^0.21.1" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-javascript": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/tree-sitter-javascript/-/tree-sitter-javascript-0.23.1.tgz", + "integrity": "sha512-/bnhbrTD9frUYHQTiYnPcxyHORIw157ERBa6dqzaKxvR/x3PC4Yzd+D1pZIMS6zNg2v3a8BZ0oK7jHqsQo9fWA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^8.2.2", + "node-gyp-build": "^4.8.2" + }, + "peerDependencies": { + "tree-sitter": "^0.21.1" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-python": { + "version": "0.23.6", + "resolved": "https://registry.npmjs.org/tree-sitter-python/-/tree-sitter-python-0.23.6.tgz", + "integrity": "sha512-yIM9z0oxKIxT7bAtPOhgoVl6gTXlmlIhue7liFT4oBPF/lha7Ha4dQBS82Av6hMMRZoVnFJI8M6mL+SwWoLD3A==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "peerDependencies": { + "tree-sitter": "^0.22.1" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-typescript": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/tree-sitter-typescript/-/tree-sitter-typescript-0.23.2.tgz", + "integrity": "sha512-e04JUUKxTT53/x3Uq1zIL45DoYKVfHH4CZqwgZhPg5qYROl5nQjV+85ruFzFGZxu+QeFVbRTPDRnqL9UbU4VeA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^8.2.2", + "node-gyp-build": "^4.8.2", + "tree-sitter-javascript": "^0.23.1" + }, + "peerDependencies": { + "tree-sitter": "^0.21.0" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, + "node_modules/web-tree-sitter": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.25.10.tgz", + "integrity": "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==", + "license": "MIT", + "optional": true, + "peerDependencies": { + "@types/emscripten": "^1.40.0" + }, + "peerDependenciesMeta": { + "@types/emscripten": { + "optional": true + } } }, "node_modules/yaml": { diff --git a/packages/harness/package.json b/packages/harness/package.json index 20634397..6811a847 100644 --- a/packages/harness/package.json +++ b/packages/harness/package.json @@ -46,5 +46,12 @@ "agent-skills", "adaptive-engineer", "dev-kit" - ] + ], + "optionalDependencies": { + "tree-sitter-java": "0.23.5", + "tree-sitter-javascript": "0.23.1", + "tree-sitter-python": "0.23.6", + "tree-sitter-typescript": "0.23.2", + "web-tree-sitter": "0.25.10" + } } diff --git a/packages/harness/test/doctor-structural.test.mjs b/packages/harness/test/doctor-structural.test.mjs new file mode 100644 index 00000000..abdb3e65 --- /dev/null +++ b/packages/harness/test/doctor-structural.test.mjs @@ -0,0 +1,157 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { test } from 'node:test'; +import { structuralChecks, runDoctor } from '../lib/doctor.mjs'; +import { buildStructuralIndex } from '../lib/repo-map/structural-index.mjs'; +import { lexicalV2 } from '../lib/repo-map/treesitter-extractor.mjs'; + +function gitRepo(files) { + const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-doctor-s1-')); + const git = (args) => + spawnSync('git', args, { + cwd: ws, + encoding: 'utf8', + env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' }, + }); + git(['init', '-q']); + git(['config', 'user.email', 'e@x.test']); + git(['config', 'user.name', 'T']); + for (const [rel, content] of Object.entries(files)) { + const full = path.join(ws, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } + git(['add', '.']); + git(['commit', '-qm', 'init']); + return { ws, git }; +} + +function extractorWith(overrides = {}) { + return { + counters: { parseFailures: 0, parsed: 0, errorFiles: 0 }, + tier: 'lexical', + webTreeSitter: null, + grammarVersions: {}, + missingGrammars: [], + integrityFailures: [], + extract: (rel, content) => lexicalV2(rel, content), + ...overrides, + }; +} + +const FIXTURE = { 'a.mjs': 'export const a = 1;\n', 'b.mjs': 'export const b = 2;\n' }; + +// structuralChecks reads the index through the default HARNESS_HOME +// resolution, so each scenario pins HARNESS_HOME to its own temp home. +function withHome(t, home) { + const saved = process.env.HARNESS_HOME; + process.env.HARNESS_HOME = home; + t.after(() => { + if (saved === undefined) delete process.env.HARNESS_HOME; + else process.env.HARNESS_HOME = saved; + }); +} + +test('S1: no index built → advisory pass with the build hint', (t) => { + const { ws } = gitRepo(FIXTURE); + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-home-')); + withHome(t, home); + const checks = structuralChecks({ workspace: ws }); + assert.equal(checks.length, 1); + assert.equal(checks[0].id, 'S1'); + assert.equal(checks[0].pass, true); + assert.equal(checks[0].optional, true); + assert.match(checks[0].hint, /harness index --structural/); + fs.rmSync(ws, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); +}); + +test('S1: current healthy index passes; meta.sha drift and orphans degrade to advisory failure', async (t) => { + const { ws, git } = gitRepo(FIXTURE); + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-home-')); + withHome(t, home); + await buildStructuralIndex({ workspace: ws, home, extractor: extractorWith() }); + + const healthy = structuralChecks({ workspace: ws })[0]; + assert.equal(healthy.pass, true); + assert.match(healthy.hint, /current with HEAD/); + + // Orphaned cache entry: an indexed file removed from disk. + fs.rmSync(path.join(ws, 'b.mjs')); + const orphaned = structuralChecks({ workspace: ws })[0]; + assert.equal(orphaned.pass, false); + assert.equal(orphaned.optional, true, 'orphans are advisory, not a hard doctor failure'); + assert.match(orphaned.hint, /orphaned cache/); + + // meta.sha drift after a new commit. + fs.writeFileSync(path.join(ws, 'b.mjs'), 'export const b = 2;\n'); + fs.writeFileSync(path.join(ws, 'c.mjs'), 'export const c = 3;\n'); + git(['add', '.']); + git(['commit', '-qm', 'advance']); + const stale = structuralChecks({ workspace: ws })[0]; + assert.equal(stale.pass, false); + assert.equal(stale.optional, true); + assert.match(stale.hint, /meta\.sha behind HEAD/); + + fs.rmSync(ws, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); +}); + +test('S1: a recorded grammar integrity mismatch is a hard failure, not a warning', async (t) => { + const { ws } = gitRepo(FIXTURE); + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-home-')); + withHome(t, home); + await buildStructuralIndex({ + workspace: ws, + home, + extractor: extractorWith({ + integrityFailures: [{ language: 'javascript', file: 'tree-sitter-javascript.wasm', reason: 'sha256 mismatch vs grammars.lock' }], + }), + }); + const check = structuralChecks({ workspace: ws })[0]; + assert.equal(check.id, 'S1'); + assert.equal(check.pass, false); + assert.ok(!check.optional, 'integrity mismatch must fail doctor, never warn'); + assert.match(check.hint, /sha256 mismatch/); + assert.match(check.hint, /javascript/); + fs.rmSync(ws, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); +}); + +test('S1: parse-failure rate over 20% degrades to advisory failure', async (t) => { + const { ws } = gitRepo(FIXTURE); + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-home-')); + withHome(t, home); + const extractor = extractorWith(); + extractor.counters.parseFailures = 1; // 1 of 2 files + await buildStructuralIndex({ workspace: ws, home, extractor }); + const check = structuralChecks({ workspace: ws })[0]; + assert.equal(check.pass, false); + assert.equal(check.optional, true); + assert.match(check.hint, /parse-failure rate/); + fs.rmSync(ws, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); +}); + +test('runDoctor surfaces S1 alongside the existing check families', (t) => { + const { ws } = gitRepo(FIXTURE); + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-home-')); + const copilotHome = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-copilot-')); + withHome(t, home); + const { checks } = runDoctor({ + copilotHome, + assetsRoot: copilotHome, + pkgRoot: null, + flags: { workspace: ws }, + }); + const s1 = checks.find((c) => c.id === 'S1'); + assert.ok(s1, 'doctor includes the structural S1 check'); + assert.equal(s1.pass, true); + assert.equal(s1.optional, true); + fs.rmSync(ws, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(copilotHome, { recursive: true, force: true }); +}); diff --git a/packages/harness/test/index-structural-cli.test.mjs b/packages/harness/test/index-structural-cli.test.mjs new file mode 100644 index 00000000..7c35f319 --- /dev/null +++ b/packages/harness/test/index-structural-cli.test.mjs @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; +import { structuralIndexDir } from '../lib/repo-map/structural-index.mjs'; + +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const binPath = path.join(packageRoot, 'bin', 'harness.mjs'); + +function gitRepo(files) { + const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-structcli-')); + const git = (args) => + spawnSync('git', args, { + cwd: ws, + encoding: 'utf8', + env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' }, + }); + git(['init', '-q']); + git(['config', 'user.email', 'e@x.test']); + git(['config', 'user.name', 'T']); + for (const [rel, content] of Object.entries(files)) { + const full = path.join(ws, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } + git(['add', '.']); + git(['commit', '-qm', 'init']); + return { ws, git }; +} + +function runHarness(args, { home, ws }) { + return spawnSync(process.execPath, [binPath, ...args, '--workspace', ws], { + encoding: 'utf8', + env: { ...process.env, HARNESS_HOME: home, HARNESS_NO_EVENTS: '1' }, + }); +} + +const FIXTURE = { + 'src/pay.mjs': "import { audit } from './audit.mjs';\nexport function charge() { if (x) { audit(); } }\n", + 'src/audit.mjs': 'export function audit() {}\n', +}; + +test('harness index --structural builds the index, prints the ledger row and inert digest', () => { + const { ws } = gitRepo(FIXTURE); + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-home-')); + const r = runHarness(['index', '--structural'], { home, ws }); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /structural/, 'ledger row names the surface'); + assert.match(r.stdout, /2 files/, 'file count reported'); + assert.match(r.stdout, /Structural Index Digest/, 'the budgeted agent digest renders'); + const dir = structuralIndexDir(ws, { home }); + for (const name of ['files.json', 'symbols.json', 'graph.json', 'meta.json']) { + assert.ok(fs.existsSync(path.join(dir, name)), `${name} persisted under HARNESS_HOME`); + } + fs.rmSync(ws, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); +}); + +test('harness index --structural --json emits a bounded summary envelope, not raw tables', () => { + const { ws } = gitRepo(FIXTURE); + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-home-')); + const r = runHarness(['index', '--structural', '--json'], { home, ws }); + assert.equal(r.status, 0, r.stderr); + const out = JSON.parse(r.stdout.trim().split('\n').at(-1)); + assert.equal(out.pass, true); + assert.equal(out.filesIndexed, 2); + assert.match(out.sha, /^[0-9a-f]{40}$/, 'generation sha stamped'); + assert.ok(['treesitter', 'lexical'].includes(out.tier)); + assert.ok(Array.isArray(out.integrityFailures)); + assert.ok(out.delta && typeof out.delta.added.count === 'number', 'symbol delta reported'); + assert.ok(!out.files && !out.symbols && !out.graph, 'raw tables never enter the envelope'); + fs.rmSync(ws, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); +}); + +test('harness index --structural --since validates the ref and rejects option-shaped values', () => { + const { ws, git } = gitRepo(FIXTURE); + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-home-')); + assert.equal(runHarness(['index', '--structural'], { home, ws }).status, 0); + + fs.writeFileSync(path.join(ws, 'src', 'pay.mjs'), 'export function charge() {}\n'); + git(['add', '.']); + git(['commit', '-qm', 'change']); + + const ok = runHarness(['index', '--structural', '--since', 'HEAD~1', '--json'], { home, ws }); + assert.equal(ok.status, 0, ok.stderr); + const out = JSON.parse(ok.stdout.trim().split('\n').at(-1)); + assert.match(out.baseSha, /^[0-9a-f]{40}$/, '--since sha recorded as baseSha'); + assert.equal(out.reparsed, 1, 'only the diffed file re-parses'); + + const evil = runHarness(['index', '--structural', '--since', '-evil'], { home, ws }); + assert.notEqual(evil.status, 0, 'option-shaped ref must be rejected'); + assert.match(evil.stderr, /E_USAGE|invalid --since/); + + const missing = runHarness(['index', '--structural', '--since', 'no-such-ref'], { home, ws }); + assert.notEqual(missing.status, 0); + assert.match(missing.stderr, /does not resolve/); + + fs.rmSync(ws, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); +}); + +test('plain harness index still works and never builds the structural tree', () => { + const { ws } = gitRepo(FIXTURE); + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-home-')); + const r = runHarness(['index'], { home, ws }); + assert.equal(r.status, 0, r.stderr); + assert.ok(!fs.existsSync(structuralIndexDir(ws, { home })), 'knowledge index alone never materializes the structural dir'); + fs.rmSync(ws, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); +}); + +test('CATALOG documents --structural and --since under harness help index', () => { + const r = spawnSync(process.execPath, [binPath, 'help', 'index'], { encoding: 'utf8' }); + assert.equal(r.status, 0); + assert.match(r.stdout, /--structural/); + assert.match(r.stdout, /--since /); + assert.match(r.stdout, /--status/); +}); diff --git a/packages/harness/test/structural-index.test.mjs b/packages/harness/test/structural-index.test.mjs new file mode 100644 index 00000000..709aa787 --- /dev/null +++ b/packages/harness/test/structural-index.test.mjs @@ -0,0 +1,346 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; +import { + structuralIndexDir, + readStructuralIndex, + readStructuralIndexIfCurrent, + validateSinceRef, + buildStructuralIndex, + renderStructuralDigest, +} from '../lib/repo-map/structural-index.mjs'; +import { lexicalV2 } from '../lib/repo-map/treesitter-extractor.mjs'; + +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function gitRepo(files) { + const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-structidx-')); + const git = (args) => + spawnSync('git', args, { + cwd: ws, + encoding: 'utf8', + env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' }, + }); + git(['init', '-q']); + git(['config', 'user.email', 'e@x.test']); + git(['config', 'user.name', 'T']); + writeFiles(ws, files); + git(['add', '.']); + git(['commit', '-qm', 'init']); + return { ws, git }; +} + +function writeFiles(ws, files) { + for (const [rel, content] of Object.entries(files)) { + const full = path.join(ws, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } +} + +function countingExtractor() { + const calls = []; + return { + calls, + counters: { parseFailures: 0, parsed: 0, errorFiles: 0 }, + tier: 'lexical', + webTreeSitter: null, + grammarVersions: {}, + missingGrammars: [], + integrityFailures: [], + extract(rel, content) { + calls.push(rel); + return lexicalV2(rel, content); + }, + }; +} + +function tempHome() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'harness-home-')); +} + +const FIXTURE = { + 'src/pay.mjs': "import { audit } from './audit.mjs';\nexport function charge() { if (x) { audit(); } }\n", + 'src/audit.mjs': 'export function audit() {}\n', + 'svc.py': 'class PaymentService:\n def run(self):\n pass\n', +}; + +test('build + read round-trip: four tables, generation stamp, HARNESS_HOME-style override', async () => { + const { ws, git } = gitRepo(FIXTURE); + const home = tempHome(); + const ext = countingExtractor(); + const result = await buildStructuralIndex({ workspace: ws, home, extractor: ext }); + assert.equal(result.written, true); + assert.equal(result.reparsed, 3); + assert.equal(result.reused, 0); + assert.ok(result.dir.startsWith(home), 'index dir respects the home override'); + assert.equal(result.dir, structuralIndexDir(ws, { home })); + + const index = readStructuralIndex(ws, { home }); + assert.ok(index, 'index reads back'); + for (const name of ['files.json', 'symbols.json', 'graph.json', 'meta.json']) { + assert.ok(fs.existsSync(path.join(index.dir, name)), `${name} written`); + } + const entry = index.files['src/pay.mjs']; + assert.ok(entry.hash && entry.mtime && entry.size, 'per-file hash/mtime/size recorded'); + assert.ok(entry.symbols.includes('charge')); + assert.ok(entry.complexity >= 2); + assert.ok(index.symbols.charge, 'declaration table carries the symbol'); + const head = git(['rev-parse', 'HEAD']).stdout.trim(); + assert.equal(index.meta.sha, head, 'meta stamps the generating HEAD'); + assert.ok(index.meta.branch, 'meta stamps the branch'); + assert.equal(index.meta.baseSha, null); + assert.ok(index.meta.generatedAt); + assert.equal(index.meta.extractorTier, 'lexical'); + assert.deepEqual(Object.keys(index.meta.grammarVersions), []); + + // Derived and rebuildable: deleting the directory loses nothing durable. + fs.rmSync(index.dir, { recursive: true, force: true }); + assert.equal(readStructuralIndex(ws, { home }), null); + const rebuilt = await buildStructuralIndex({ workspace: ws, home, extractor: countingExtractor() }); + assert.equal(rebuilt.reparsed, 3, 'a deleted index rebuilds from scratch'); + + fs.rmSync(ws, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); +}); + +test('incremental: unchanged files are not re-parsed; touched-but-identical files confirm by hash', async () => { + const { ws } = gitRepo(FIXTURE); + const home = tempHome(); + await buildStructuralIndex({ workspace: ws, home, extractor: countingExtractor() }); + + // Second run with nothing changed: zero re-parses (mtime+size fast path). + const ext2 = countingExtractor(); + const r2 = await buildStructuralIndex({ workspace: ws, home, extractor: ext2 }); + assert.equal(r2.reparsed, 0, 'unchanged tree re-parses nothing'); + assert.equal(r2.reused, 3); + assert.deepEqual(ext2.calls, [], 'extractor is never invoked on a cache hit'); + + // Touch mtime without changing content: hash confirm still reuses. + const touched = path.join(ws, 'src', 'audit.mjs'); + fs.utimesSync(touched, new Date(Date.now() + 5000), new Date(Date.now() + 5000)); + const ext3 = countingExtractor(); + const r3 = await buildStructuralIndex({ workspace: ws, home, extractor: ext3 }); + assert.equal(r3.reparsed, 0, 'identical content is confirmed by sha256, not re-parsed'); + assert.deepEqual(ext3.calls, [], 'no extraction for touched-but-identical content'); + + // A real content change re-parses exactly that file. + fs.writeFileSync(path.join(ws, 'src', 'pay.mjs'), 'export function charge() {}\nexport function refund() {}\n'); + const ext4 = countingExtractor(); + const r4 = await buildStructuralIndex({ workspace: ws, home, extractor: ext4 }); + assert.equal(r4.reparsed, 1); + assert.deepEqual(ext4.calls, ['src/pay.mjs']); + assert.ok(r4.delta.added.names.includes('refund'), 'symbol delta reports the added symbol'); + + fs.rmSync(ws, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); +}); + +test('--since: only files in the ref diff are re-parsed; the ref is validated', async () => { + const { ws, git } = gitRepo(FIXTURE); + const home = tempHome(); + await buildStructuralIndex({ workspace: ws, home, extractor: countingExtractor() }); + + const base = git(['rev-parse', 'HEAD']).stdout.trim(); + fs.writeFileSync(path.join(ws, 'src', 'pay.mjs'), 'export function charge() {}\nexport function newSince() {}\n'); + git(['add', '.']); + git(['commit', '-qm', 'change pay']); + // Drift another file's mtime so the fast path alone would NOT skip it — + // --since must keep it verbatim because it is outside the diff. + fs.utimesSync(path.join(ws, 'svc.py'), new Date(Date.now() + 9000), new Date(Date.now() + 9000)); + + const since = validateSinceRef(ws, base); + assert.match(since, /^[0-9a-f]{40}$/); + const ext = countingExtractor(); + const r = await buildStructuralIndex({ workspace: ws, home, extractor: ext, since }); + assert.deepEqual(ext.calls, ['src/pay.mjs'], 'only the diffed file re-parses'); + assert.equal(r.reused, 2); + assert.ok(r.delta.added.names.includes('newSince')); + + assert.throws(() => validateSinceRef(ws, '-evil'), /invalid --since ref/, 'leading dash is rejected before git sees it'); + assert.throws(() => validateSinceRef(ws, ''), /invalid --since ref/); + assert.throws(() => validateSinceRef(ws, 'no-such-ref-xyz'), /does not resolve/); + + fs.rmSync(ws, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); +}); + +test('removed files leave the index and their symbols report as removed', async () => { + const { ws, git } = gitRepo(FIXTURE); + const home = tempHome(); + await buildStructuralIndex({ workspace: ws, home, extractor: countingExtractor() }); + fs.rmSync(path.join(ws, 'src', 'audit.mjs')); + git(['add', '-A']); + git(['commit', '-qm', 'drop audit']); + const r = await buildStructuralIndex({ workspace: ws, home, extractor: countingExtractor() }); + assert.equal(r.removedFiles, 1); + assert.ok(r.delta.removed.names.includes('audit')); + const index = readStructuralIndex(ws, { home }); + assert.ok(!index.files['src/audit.mjs'], 'stale entry pruned'); + fs.rmSync(ws, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); +}); + +test('readStructuralIndexIfCurrent gates on the generation sha', async () => { + const { ws, git } = gitRepo(FIXTURE); + const home = tempHome(); + await buildStructuralIndex({ workspace: ws, home, extractor: countingExtractor() }); + assert.ok(readStructuralIndexIfCurrent(ws, { home }), 'current HEAD → index served'); + + fs.writeFileSync(path.join(ws, 'later.mjs'), 'export const later = 1;\n'); + git(['add', '.']); + git(['commit', '-qm', 'advance head']); + assert.equal(readStructuralIndexIfCurrent(ws, { home }), null, 'meta.sha drift → not served'); + assert.ok(readStructuralIndex(ws, { home }), 'the tolerant reader still reads the stale index'); + + fs.rmSync(ws, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); +}); + +test('atomic writes: no temp residue, every table is valid JSON, dry-run writes nothing', async () => { + const { ws } = gitRepo(FIXTURE); + const home = tempHome(); + + const dry = await buildStructuralIndex({ workspace: ws, home, extractor: countingExtractor(), dryRun: true }); + assert.equal(dry.written, false); + assert.ok(!fs.existsSync(structuralIndexDir(ws, { home })), 'dry-run leaves no directory'); + + await buildStructuralIndex({ workspace: ws, home, extractor: countingExtractor() }); + const dir = structuralIndexDir(ws, { home }); + const leftovers = fs.readdirSync(dir).filter((n) => n.startsWith('.tmp-')); + assert.deepEqual(leftovers, [], 'temp+rename leaves no partial files behind'); + for (const name of fs.readdirSync(dir)) { + JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8')); // throws on a torn write + } + fs.rmSync(ws, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); +}); + +test('secret-shaped extracted names are redacted at index-write time', async () => { + const { ws } = gitRepo({ + ...FIXTURE, + 'leak.ts': 'export const AKIAABCDEFGHIJKLMNOP = "id";\n', + }); + const home = tempHome(); + await buildStructuralIndex({ workspace: ws, home, extractor: countingExtractor() }); + const dir = structuralIndexDir(ws, { home }); + for (const name of ['files.json', 'symbols.json', 'graph.json']) { + const body = fs.readFileSync(path.join(dir, name), 'utf8'); + assert.ok(!body.includes('AKIAABCDEFGHIJKLMNOP'), `${name} must not carry the raw secret-shaped name`); + } + const files = JSON.parse(fs.readFileSync(path.join(dir, 'files.json'), 'utf8')); + assert.ok( + files['leak.ts'].symbols.some((s) => s.includes('[redacted: aws-access-key]')), + 'the redaction marker replaces the secret-shaped symbol' + ); + fs.rmSync(ws, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); +}); + +test('graph preserves unresolved edges explicitly and never fabricates targets', async () => { + const { ws } = gitRepo({ + 'a.mjs': "import { b } from './b.mjs';\nimport missing from './nowhere.mjs';\nexport function runA() { b(); ghostCall(); }\n", + 'b.mjs': 'export function b() {}\n', + }); + const home = tempHome(); + await buildStructuralIndex({ workspace: ws, home, extractor: countingExtractor() }); + const graph = JSON.parse(fs.readFileSync(path.join(structuralIndexDir(ws, { home }), 'graph.json'), 'utf8')); + assert.ok(graph.modules.some((e) => e.from === 'a.mjs' && e.to === 'b.mjs'), 'resolved module edge present'); + assert.ok( + graph.unresolvedImports.some((e) => e.from === 'a.mjs' && e.import.includes('nowhere')), + 'unresolved import preserved explicitly' + ); + for (const e of graph.modules) assert.ok(e.to && e.from, 'no fabricated module edges'); + fs.rmSync(ws, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); +}); + +test('renderStructuralDigest is budgeted text with no control characters (agent lane, never raw JSON)', async () => { + const { ws } = gitRepo(FIXTURE); + const home = tempHome(); + await buildStructuralIndex({ workspace: ws, home, extractor: countingExtractor() }); + const index = readStructuralIndex(ws, { home }); + const digest = renderStructuralDigest(index, { maxTokens: 1000 }); + assert.ok(digest.tokens <= 1000, `digest ${digest.tokens} tokens over budget`); + assert.match(digest.body, /^# Structural Index Digest/); + assert.doesNotMatch(digest.body, /[\x00-\x08\x0b-\x1f\x7f]/, 'inertLine strips control characters'); + assert.ok(!digest.body.trimStart().startsWith('{'), 'the agent lane is framed text, not JSON'); + fs.rmSync(ws, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); +}); + +test('buildRepoMap prefers a current structural index and is byte-identical without one', async (t) => { + const { buildRepoMap, writeCodebaseMap } = await import('../lib/repo-map/index.mjs'); + const { ws, git } = gitRepo(FIXTURE); + const home = tempHome(); + const savedHome = process.env.HARNESS_HOME; + process.env.HARNESS_HOME = home; + t.after(() => { + if (savedHome === undefined) delete process.env.HARNESS_HOME; + else process.env.HARNESS_HOME = savedHome; + fs.rmSync(ws, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); + }); + + const before = buildRepoMap({ workspace: ws, query: 'charge payment' }); + assert.equal(before.structural, false); + assert.match(before.body, /lexical map/); + + await buildStructuralIndex({ workspace: ws, home, extractor: countingExtractor() }); + const withIndex = buildRepoMap({ workspace: ws, query: 'charge payment' }); + assert.equal(withIndex.structural, true, 'current index is preferred'); + assert.match(withIndex.body, /structural map/); + assert.ok(withIndex.files.includes('src/pay.mjs'), 'ranking still surfaces the relevant file'); + + // The COMMITTED codebase map never varies with host-local index state. + const committed = writeCodebaseMap({ workspace: ws, dryRun: true }); + assert.ok(committed); + const committedMap = buildRepoMap({ workspace: ws, query: '', maxTokens: 2500, title: 'Codebase Map', preferStructural: false }); + assert.match(committedMap.body, /lexical map/, 'committed map stays lexical-only'); + + // Stale index (HEAD moved) → unchanged lexical behavior again. + fs.writeFileSync(path.join(ws, 'extra.mjs'), 'export const extra = 1;\n'); + git(['add', '.']); + git(['commit', '-qm', 'advance']); + const stale = buildRepoMap({ workspace: ws, query: 'charge payment' }); + assert.equal(stale.structural, false, 'meta.sha drift falls back to lexical'); + + // Deleting the index restores byte-identical pre-index output — the + // regression pin for "no structural index ⇒ unchanged lexical behavior". + git(['reset', '-q', '--hard', 'HEAD~1']); + fs.rmSync(structuralIndexDir(ws, { home }), { recursive: true, force: true }); + const after = buildRepoMap({ workspace: ws, query: 'charge payment' }); + assert.equal(after.body, before.body, 'byte-identical output when no structural index exists'); +}); + +test('no-network guard: the orient/recall structural read path is model- and network-free', () => { + const read = (rel) => fs.readFileSync(path.join(packageRoot, rel), 'utf8'); + for (const rel of [ + 'lib/repo-map/index.mjs', + 'lib/repo-map/scan.mjs', + 'lib/repo-map/lexical-extractor.mjs', + 'lib/repo-map/structural-index.mjs', + 'lib/repo-map/treesitter-extractor.mjs', + 'lib/index-status.mjs', + ]) { + const src = read(rel); + assert.doesNotMatch( + src, + /api\.anthropic\.com|openai|fetch\(|getProvider|ANTHROPIC_API_KEY|node:https|node:http'|net\.connect|dns\.lookup|XMLHttpRequest|WebSocket/, + `${rel} must be model- and network-free` + ); + } + // The async lifecycle stays confined to `harness index --structural`: + // buildRepoMap remains synchronous and never dynamically imports anything. + const repoMap = read('lib/repo-map/index.mjs'); + assert.match(repoMap, /export function buildRepoMap/, 'buildRepoMap stays sync'); + assert.doesNotMatch(repoMap, /async function buildRepoMap|await import\(/, 'orient path loads no async tier'); + // web-tree-sitter is loaded ONLY inside the async factory, never statically. + const extractorSrc = read('lib/repo-map/treesitter-extractor.mjs'); + assert.doesNotMatch(extractorSrc, /^import[^\n]*web-tree-sitter/m, 'no static web-tree-sitter import'); + assert.match(extractorSrc, /await import\('web-tree-sitter'\)/, 'runtime loads lazily in the factory'); +}); diff --git a/packages/harness/test/treesitter-extractor.test.mjs b/packages/harness/test/treesitter-extractor.test.mjs new file mode 100644 index 00000000..c1deede0 --- /dev/null +++ b/packages/harness/test/treesitter-extractor.test.mjs @@ -0,0 +1,226 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; +import { + branchComplexity, + lexicalV2, + loadGrammarsLock, + grammarStatus, + makeStructuralExtract, + createTreesitterExtract, + MAX_IDENTIFIER_LENGTH, + DEFAULT_LOCK_PATH, +} from '../lib/repo-map/treesitter-extractor.mjs'; + +// One shared factory instance: init is the expensive part, extract is sync. +// When the optional grammar packages are absent this resolves to the lexical +// tier and the grammar-dependent tests below skip honestly. +const extractor = await createTreesitterExtract(); +const grammars = extractor.tier === 'treesitter'; +const skipNote = 'optional tree-sitter grammars not installed — lexical absence mode'; + +test('grammars.lock ships, parses, and pins sha256 for runtime and all grammars', () => { + const lock = loadGrammarsLock(); + assert.ok(lock, 'grammars.lock must ship with the package'); + assert.equal(lock.version, 1); + assert.match(lock.runtime.sha256, /^[0-9a-f]{64}$/); + for (const language of ['javascript', 'typescript', 'tsx', 'python', 'java']) { + assert.ok(lock.grammars[language], `lock missing ${language}`); + assert.match(lock.grammars[language].sha256, /^[0-9a-f]{64}$/, `${language} sha256`); + assert.match(lock.grammars[language].version, /^\d+\.\d+\.\d+$/, `${language} version pinned`); + } +}); + +test('branchComplexity is a cheap deterministic branch count with floor 1', () => { + assert.equal(branchComplexity(''), 1); + assert.equal(branchComplexity('const a = 1;'), 1); + assert.equal(branchComplexity('if (a) {} else if (b) {} while (c) { }'), 4); + assert.equal(branchComplexity('a && b || c ?? d'), 4); +}); + +test('lexicalV2 preserves v1 fields and adds approximate defs, empty refs, and complexity', () => { + const r = lexicalV2('a.ts', 'export function hi(){ if (x) {} }'); + assert.deepEqual(r.symbols, ['hi']); + assert.deepEqual(r.defs, [{ name: 'hi', kind: 'symbol', line: 1, exported: false }]); + assert.deepEqual(r.refs, [], 'the lexical tier never fabricates call facts'); + assert.equal(r.tier, 'lexical'); + assert.equal(r.complexity, 2); +}); + +test('extraction matrix: typescript defs, imports, refs, exported flags', (t) => { + if (!grammars) return t.skip(skipNote); + const src = [ + "import { helper } from './util';", + 'export interface PayReq { id: string }', + 'type Alias = 1;', + 'export class PaymentService {', + ' charge(req: PayReq) { return validate(req) && this.audit(req); }', + ' private audit(r: PayReq) {}', + '}', + 'const localFn = () => {};', + ].join('\n'); + const r = extractor.extract('src/pay.ts', src); + assert.equal(r.tier, 'treesitter'); + assert.ok(r.symbols.includes('PaymentService')); + assert.ok(r.symbols.includes('PayReq')); + assert.ok(r.symbols.includes('Alias')); + assert.ok(r.symbols.includes('charge')); + assert.ok(r.symbols.includes('localFn'), 'arrow-function const is a def'); + assert.ok(r.imports.includes('./util')); + const payReq = r.defs.find((d) => d.name === 'PayReq'); + assert.equal(payReq.exported, true); + assert.equal(payReq.kind, 'interface'); + const alias = r.defs.find((d) => d.name === 'Alias'); + assert.equal(alias.exported, false); + assert.ok(r.refs.some((x) => x.name === 'validate'), 'call refs captured'); + assert.ok(r.refs.some((x) => x.name === 'audit'), 'member call refs captured'); + assert.ok(r.complexity >= 2); +}); + +test('extraction matrix: javascript and tsx', (t) => { + if (!grammars) return t.skip(skipNote); + const js = extractor.extract('a.mjs', "const x = require('./legacy');\nexport function run() { return new Runner(); }"); + assert.equal(js.tier, 'treesitter'); + assert.ok(js.symbols.includes('run')); + assert.ok(js.imports.includes('./legacy'), 'require() counted as an import'); + assert.ok(js.refs.some((r) => r.name === 'Runner'), 'new-expression counted as a ref'); + const tsx = extractor.extract('App.tsx', 'export function App(){ return
go()} />; }'); + assert.equal(tsx.tier, 'treesitter'); + assert.ok(tsx.symbols.includes('App')); + assert.ok(tsx.refs.some((r) => r.name === 'go')); +}); + +test('extraction matrix: python defs/imports/refs with module-level export approximation', (t) => { + if (!grammars) return t.skip(skipNote); + const src = [ + 'from billing.core import Charge', + 'import audit.log', + 'class PaymentService:', + ' def charge(self, req):', + ' return Charge(req).run()', + 'def _private_helper():', + ' pass', + ].join('\n'); + const r = extractor.extract('svc.py', src); + assert.equal(r.tier, 'treesitter'); + assert.ok(r.symbols.includes('PaymentService')); + assert.ok(r.symbols.includes('charge')); + assert.ok(r.imports.includes('billing.core')); + assert.ok(r.imports.includes('audit.log')); + const cls = r.defs.find((d) => d.name === 'PaymentService'); + assert.equal(cls.exported, true); + const priv = r.defs.find((d) => d.name === '_private_helper'); + assert.equal(priv.exported, false); + assert.ok(r.refs.some((x) => x.name === 'Charge')); + assert.ok(r.refs.some((x) => x.name === 'run')); +}); + +test('extraction matrix: java defs/imports/refs with public visibility', (t) => { + if (!grammars) return t.skip(skipNote); + const src = [ + 'import com.acme.Role;', + 'public class PaymentController {', + ' public void handle(Role r) { audit(r); new Session(); }', + ' void internalOnly() {}', + '}', + ].join('\n'); + const r = extractor.extract('src/PaymentController.java', src); + assert.equal(r.tier, 'treesitter'); + assert.ok(r.symbols.includes('PaymentController')); + assert.ok(r.symbols.includes('handle')); + assert.ok(r.imports.includes('com.acme.Role')); + const handle = r.defs.find((d) => d.name === 'handle'); + assert.equal(handle.exported, true); + const internal = r.defs.find((d) => d.name === 'internalOnly'); + assert.equal(internal.exported, false); + assert.ok(r.refs.some((x) => x.name === 'audit')); + assert.ok(r.refs.some((x) => x.name === 'Session')); +}); + +test('silent per-file fallback: SQL and unknown extensions stay lexical', () => { + const sql = extractor.extract('schema.sql', 'CREATE TABLE payments (id int);'); + assert.equal(sql.tier, 'lexical'); + assert.ok(sql.symbols.includes('payments'), 'lexical SQL extraction still works'); + const rb = extractor.extract('tool.rb', 'def hello; end'); + assert.equal(rb.tier, 'lexical'); + assert.deepEqual(rb.defs, []); +}); + +test('a malformed source file still extracts partially and is counted, never thrown', (t) => { + if (!grammars) return t.skip(skipNote); + const before = extractor.counters.errorFiles; + const r = extractor.extract('broken.java', 'public class Broken { void ok() {} }\n%%%% garbage {{{'); + assert.equal(r.tier, 'treesitter', 'error-bearing trees still yield structural facts'); + assert.ok(r.symbols.includes('Broken')); + assert.equal(r.hasErrors, true); + assert.equal(extractor.counters.errorFiles, before + 1); +}); + +test('per-file parse failure (parser throw) falls back to lexical and is counted', () => { + const { extract, counters } = makeStructuralExtract({ + parseForLanguage: () => { + throw new Error('boom'); + }, + }); + const r = extract('a.ts', 'export function stillFound() {}'); + assert.equal(r.tier, 'lexical'); + assert.ok(r.symbols.includes('stillFound'), 'lexical fallback still extracts'); + assert.equal(counters.parseFailures, 1); +}); + +test('integrity mismatch: corrupted grammar wasm is a LOUD lexical fallback, absence is silent', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-grammar-')); + // A fixture root carrying a CORRUPT javascript wasm and NO other grammars. + const lock = JSON.parse(fs.readFileSync(DEFAULT_LOCK_PATH, 'utf8')); + const jsDir = path.join(dir, lock.grammars.javascript.package); + fs.mkdirSync(jsDir, { recursive: true }); + fs.writeFileSync(path.join(jsDir, lock.grammars.javascript.file), 'not the pinned wasm bytes'); + // Runtime present and genuine when installed, else absent → absence mode. + const runtimeSrc = path.resolve(path.dirname(DEFAULT_LOCK_PATH), '..', '..', 'node_modules', lock.runtime.package, lock.runtime.file); + if (fs.existsSync(runtimeSrc)) { + const rtDir = path.join(dir, lock.runtime.package); + fs.mkdirSync(rtDir, { recursive: true }); + fs.copyFileSync(runtimeSrc, path.join(rtDir, lock.runtime.file)); + } + const ext = await createTreesitterExtract({ grammarRoots: [dir] }); + if (fs.existsSync(runtimeSrc)) { + assert.ok( + ext.integrityFailures.some((f) => f.language === 'javascript' && /sha256 mismatch/.test(f.reason)), + 'corrupt wasm must be recorded as an integrity failure' + ); + assert.ok(!ext.available.includes('javascript'), 'corrupt grammar must not instantiate'); + } else { + assert.equal(ext.tier, 'lexical'); + } + const r = ext.extract('x.mjs', 'export function fromLexical() {}'); + assert.equal(r.tier, 'lexical', 'the corrupted grammar language falls back to lexical'); + assert.ok(r.symbols.includes('fromLexical')); + + // grammarStatus (the sync doctor probe) reports the same mismatch. + const status = grammarStatus({ grammarRoots: [dir] }); + assert.ok(status.integrityFailures.some((f) => f.language === 'javascript')); + assert.equal(status.grammars.python.present, false, 'absent grammar is not an integrity failure'); + assert.ok(!status.integrityFailures.some((f) => f.language === 'python')); + + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('runtime integrity mismatch disables the whole tier loudly', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-grammar-rt-')); + const lock = JSON.parse(fs.readFileSync(DEFAULT_LOCK_PATH, 'utf8')); + const rtDir = path.join(dir, lock.runtime.package); + fs.mkdirSync(rtDir, { recursive: true }); + fs.writeFileSync(path.join(rtDir, lock.runtime.file), 'corrupt runtime'); + const ext = await createTreesitterExtract({ grammarRoots: [dir] }); + assert.equal(ext.tier, 'lexical'); + assert.ok(ext.integrityFailures.some((f) => f.language === 'runtime')); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('identifier length cap bounds extracted names', () => { + const long = 'x'.repeat(400); + const r = lexicalV2('a.ts', `export const ${long} = 1;`); + assert.ok(r.symbols[0].length <= MAX_IDENTIFIER_LENGTH); +}); From 457fbab3079dce405bd081d0c79d19f2c84071d2 Mon Sep 17 00:00:00 2001 From: Krish Date: Thu, 6 Aug 2026 04:34:51 -0400 Subject: [PATCH 08/24] feat: per-check verify severity (policy v2) and advisory structural-expectations check --- .github/harness/policy.yaml | 9 + .../references/harness-tool-contract.md | 15 +- packages/harness/lib/policy.mjs | 40 +- .../harness/lib/structural/expectations.mjs | 200 ++++++ packages/harness/lib/structural/shape.mjs | 173 +++++ packages/harness/lib/verify.mjs | 59 +- packages/harness/package-lock.json | 5 - .../test/structural-expectations.test.mjs | 620 ++++++++++++++++++ .../test/structural-shape-compat.test.mjs | 70 ++ 9 files changed, 1177 insertions(+), 14 deletions(-) create mode 100644 packages/harness/lib/structural/expectations.mjs create mode 100644 packages/harness/lib/structural/shape.mjs create mode 100644 packages/harness/test/structural-expectations.test.mjs create mode 100644 packages/harness/test/structural-shape-compat.test.mjs diff --git a/.github/harness/policy.yaml b/.github/harness/policy.yaml index 75253ef7..eac09482 100644 --- a/.github/harness/policy.yaml +++ b/.github/harness/policy.yaml @@ -4,3 +4,12 @@ gate_ttl_minutes: 30 evidence_ttl_hours: 24 exemptions: [] waivers: [] +# Policy v2 adds an optional per-check severity map (example — commented out, +# not active). Severities: advisory (reported, never affects outcome or exit), +# warn (failure degrades to inconclusive / exit 2), enforce (v1 behavior). +# Absent entry → the check's built-in default (structural-expectations +# defaults to advisory; every other check defaults to enforce). +# version: 2 +# checks: +# structural-expectations: +# severity: advisory diff --git a/.github/skills/references/harness-tool-contract.md b/.github/skills/references/harness-tool-contract.md index 191f845b..5a14ab79 100644 --- a/.github/skills/references/harness-tool-contract.md +++ b/.github/skills/references/harness-tool-contract.md @@ -151,7 +151,8 @@ For locked plans, both commands enforce criterion-to-check mappings and configur { "outcome": "passed", "plan": "docs/plans/example-plan.md", - "checks": [], + "checks": [{ "id": "scope", "status": "passed", "message": "...", "severity": "enforce" }], + "advisoryFailures": [], "unverifiedCriteria": [], "scopeViolations": [], "openHardGaps": [], @@ -169,6 +170,18 @@ For locked plans, both commands enforce criterion-to-check mappings and configur Allowed outcomes are `passed`, `failed`, and `inconclusive`. Only fresh `passed` evidence bound to the current plan contract, base ref, changed-file set, and workspace contents permits a delivery completion claim or compound. Plan Activity entries are excluded from the contract digest so the append-only ledger can record the returned evidence path. Read-only Answer and Investigate modes do not run delivery verification. Plan frontmatter names checks; executable argv arrays come only from `.github/harness/checks.yaml` and run without a shell. Approved one-off commands run outside harness through explicit host tool approval and are recorded as external evidence. +**Per-check severity (policy v2).** `.github/harness/policy.yaml` may declare `version: 2` with an optional `checks:` map assigning each verify check a severity — the check-level knob is orthogonal to the run-level `enforcement` mode: + +| Severity | Effect of a failed check | +|----------|--------------------------| +| `enforce` | Fails verification (v1 behavior; default for every check without a policy entry or built-in default) | +| `warn` | Degrades the outcome to `inconclusive` (exit 2 under enforce) | +| `advisory` | Reported only — never affects outcome or exit code | + +Every check in the `verify` payload carries its effective `severity`; non-passing advisory checks are additionally listed under `advisoryFailures` (with their findings) so an exit-neutral signal is never silently lost. A v1 policy file (no `checks:` map) behaves exactly as before. + +**structural-expectations (built-in verify check, advisory by default).** Compares the structural diff of the change against the plan using the structural index at `~/.harness/index//structural/` (`files.json`/`symbols.json`/`graph.json`/`meta.json` — shape contract in `packages/harness/lib/structural/shape.mjs`). Flags: changed exported symbols in files outside `## Impacted Files` (`unplanned-symbol-change`); removed public symbols whose callers in the graph survive the change (`removed-symbol-with-callers`); unmet plan-frontmatter `structural_expectations:` entries marked `required: true` (`unmet-required-expectation` — unmarked entries stay informational). A missing structural index or a baseline `meta.sha` that is not an ancestor of HEAD makes the check report `skipped` — it warns rather than guessing, and `skipped` never affects the outcome at any severity. Policy `checks: { structural-expectations: { severity: warn|enforce } }` opts the flags into blocking. + **Learning attribution (cited half).** `orient` records the learning ids it surfaced in a session; `verify --learnings ` closes the loop by recording the ids the skill actually applied while doing the work — pass only ids that materially changed an action, not every id the pack mentioned. `orient` also records `learningsBytes` on its own event — the post-truncation byte size of the "## Learnings (memory)" section actually injected into the pack — which `harness report`'s token ledger sums into an approximate injected-token count (`slos.knowledgeTokens`), a cost figure only, never a "tokens saved" claim. `harness report` derives knowledge-layer utilization from cited ÷ surfaced across the event log (both a unique-id rate and an occurrence-weighted rate), and `harness doctor` warns when the weighted utilization stays under 15% with 20+ surfaced occurrences. **recall** diff --git a/packages/harness/lib/policy.mjs b/packages/harness/lib/policy.mjs index 4a27b751..71fb5f48 100644 --- a/packages/harness/lib/policy.mjs +++ b/packages/harness/lib/policy.mjs @@ -4,6 +4,34 @@ import YAML from 'yaml'; const MODES = new Set(['observe', 'warn', 'enforce']); +// Policy schema v2 adds an optional per-check `checks:` map. `enforce` is the +// v1 behavior (a failed check fails verification), `warn` degrades a failure +// to an inconclusive (warn-exit) outcome, and `advisory` reports without ever +// affecting outcome or exit code. Absent entry → the check's built-in default. +export const CHECK_SEVERITIES = new Set(['advisory', 'warn', 'enforce']); +const POLICY_VERSIONS = new Set([1, 2]); + +function parseCheckSeverities(policy, policyPath) { + if (policy.checks === undefined || policy.checks === null) return {}; + if (typeof policy.checks !== 'object' || Array.isArray(policy.checks)) { + throw new Error(`Invalid harness policy ${policyPath}: checks must be a mapping of check id to settings`); + } + const severities = {}; + for (const [id, config] of Object.entries(policy.checks)) { + if (!config || typeof config !== 'object' || Array.isArray(config)) { + throw new Error(`Invalid harness policy ${policyPath}: checks.${id} must be a mapping`); + } + if (config.severity === undefined) continue; + if (!CHECK_SEVERITIES.has(config.severity)) { + throw new Error( + `Invalid harness policy ${policyPath}: checks.${id}.severity must be advisory, warn, or enforce (got ${config.severity})` + ); + } + severities[id] = config.severity; + } + return severities; +} + export function loadPolicy(workspace, override = null) { const policyPath = path.join(workspace, '.github', 'harness', 'policy.yaml'); const policyExists = fs.existsSync(policyPath); @@ -18,23 +46,29 @@ export function loadPolicy(workspace, override = null) { if (typeof policy !== 'object' || Array.isArray(policy)) { throw new Error(`Invalid harness policy ${policyPath}: expected a YAML mapping`); } - if (policyExists && policy.version !== 1) { - throw new Error(`Invalid harness policy ${policyPath}: expected version 1`); + if (policyExists && !POLICY_VERSIONS.has(policy.version)) { + throw new Error(`Invalid harness policy ${policyPath}: expected version 1 or 2`); } const requested = override ?? policy.enforcement ?? 'enforce'; if (!MODES.has(requested)) { throw new Error(`Invalid enforcement mode: ${requested}. Expected observe, warn, or enforce`); } return { - version: policy.version === 1 ? 1 : null, + version: policyExists ? policy.version : null, enforcement: requested, gateTtlMinutes: Number.isFinite(policy.gate_ttl_minutes) ? policy.gate_ttl_minutes : 30, evidenceTtlHours: Number.isFinite(policy.evidence_ttl_hours) ? policy.evidence_ttl_hours : 24, exemptions: Array.isArray(policy.exemptions) ? policy.exemptions : [], waivers: Array.isArray(policy.waivers) ? policy.waivers : [], + checkSeverities: parseCheckSeverities(policy, policyPath), }; } +/** Effective severity for a verify check: policy entry, else the check's built-in default. */ +export function checkSeverityFor(policy, id, defaultSeverity = 'enforce') { + return policy?.checkSeverities?.[id] ?? defaultSeverity; +} + export function enforcementExitCode(outcome, enforcement) { if (enforcement !== 'enforce') return 0; return outcome === 'passed' ? 0 : outcome === 'failed' ? 1 : 2; diff --git a/packages/harness/lib/structural/expectations.mjs b/packages/harness/lib/structural/expectations.mjs new file mode 100644 index 00000000..f6a4190f --- /dev/null +++ b/packages/harness/lib/structural/expectations.mjs @@ -0,0 +1,200 @@ +// `structural-expectations` verify check — compares the structural diff of +// the change against the plan. Advisory by default (policy.yaml v2 `checks:` +// can escalate to warn/enforce); a missing or stale structural index skips +// rather than guessing, so this check can never invent a failure. + +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { extract, SOURCE_EXTENSIONS } from '../repo-map/lexical-extractor.mjs'; +import { matchesScope, parseImpactedFiles } from '../plan-scope.mjs'; +import { readStructuralIndex } from './shape.mjs'; + +export const STRUCTURAL_CHECK_ID = 'structural-expectations'; + +const EXPECTATION_CHANGES = new Set(['added', 'removed', 'modified']); + +function shortSha(sha) { + return String(sha || '').slice(0, 12); +} + +function baselineIsAncestor(workspace, sha) { + const result = spawnSync('git', ['merge-base', '--is-ancestor', sha, 'HEAD'], { + cwd: workspace, + encoding: 'utf8', + timeout: 15_000, + }); + return result.status === 0; +} + +function symbolFile(qualified) { + const at = String(qualified || '').indexOf('#'); + return at === -1 ? String(qualified || '') : String(qualified).slice(0, at); +} + +/** Per-changed-file structural diff against the baseline index. */ +function diffChangedFiles({ workspace, index, changedFiles }) { + const rowsByFile = new Map(); + for (const row of index.symbols) { + if (!row || typeof row.file !== 'string' || typeof row.name !== 'string') continue; + if (!rowsByFile.has(row.file)) rowsByFile.set(row.file, []); + rowsByFile.get(row.file).push(row); + } + + const diffs = new Map(); + for (const file of changedFiles) { + const ext = path.extname(file).toLowerCase(); + const rows = rowsByFile.get(file) || []; + const fileEntry = index.files[file]; + if (!SOURCE_EXTENSIONS.has(ext) && !fileEntry && rows.length === 0) continue; + + const full = path.join(workspace, file); + let current = []; + if (fs.existsSync(full)) { + try { + current = extract(file, fs.readFileSync(full, 'utf8')).symbols; + } catch { + current = []; + } + } + const currentSet = new Set(current); + const baselineNames = new Set([ + ...(Array.isArray(fileEntry?.symbols) ? fileEntry.symbols : []), + ...rows.map((row) => row.name), + ]); + const exportedRows = rows.filter((row) => row.exported === true); + + diffs.set(file, { + added: current.filter((name) => !baselineNames.has(name)).sort(), + removed: [...baselineNames].filter((name) => !currentSet.has(name)).sort(), + removedExported: [...new Set(exportedRows.map((row) => row.name))].filter((name) => !currentSet.has(name)).sort(), + baselineNames, + currentSet, + }); + } + return diffs; +} + +function survivingCallers({ index, file, symbol, changedSet }) { + const target = `${file}#${symbol}`; + const callers = new Set(); + for (const edge of index.graph.calls) { + if (edge?.to === target) callers.add(symbolFile(edge.from)); + } + for (const row of index.symbols) { + if (row?.file !== file || row?.name !== symbol) continue; + for (const ref of Array.isArray(row.refs) ? row.refs : []) { + if (ref && typeof ref.file === 'string') callers.add(ref.file); + } + } + callers.delete(file); + return [...callers].filter((caller) => caller && !changedSet.has(caller)).sort(); +} + +function expectationObserved(expectation, diffs) { + const diff = diffs.get(expectation.file); + if (!diff) return false; + if (expectation.change === 'added') return diff.added.includes(expectation.symbol); + if (expectation.change === 'removed') return diff.removed.includes(expectation.symbol); + // modified: the file changed and the symbol survived on both sides. + return diff.baselineNames.has(expectation.symbol) && diff.currentSet.has(expectation.symbol); +} + +function evaluateExpectations(plan, diffs) { + const raw = plan.fm?.structural_expectations; + const findings = []; + const informational = []; + if (raw === undefined || raw === null) return { findings, informational }; + if (!Array.isArray(raw)) { + informational.push({ type: 'malformed-expectations', message: 'structural_expectations must be a list; block ignored' }); + return { findings, informational }; + } + for (const entry of raw) { + const valid = + entry && + typeof entry === 'object' && + typeof entry.file === 'string' && + typeof entry.symbol === 'string' && + EXPECTATION_CHANGES.has(entry.change); + if (!valid) { + informational.push({ type: 'malformed-expectation', entry, message: 'expected {file, symbol, change: added|removed|modified}' }); + continue; + } + if (expectationObserved(entry, diffs)) continue; + const description = { type: 'unmet-expectation', file: entry.file, symbol: entry.symbol, change: entry.change }; + // Only expectations explicitly marked required can fail the check; the + // rest are informational even when policy escalates the severity. + if (entry.required === true) findings.push({ ...description, type: 'unmet-required-expectation' }); + else informational.push(description); + } + return { findings, informational }; +} + +/** + * Run the structural-expectations check. + * Returns `{ status: 'passed'|'failed'|'skipped', message, findings, + * informational, baseline }` — a normal verify check body. Never throws. + */ +export function runStructuralExpectations({ workspace, plan, changedFiles, home }) { + try { + const index = readStructuralIndex(workspace, { home }); + if (!index.present) { + return { status: 'skipped', message: `Advisory structural check skipped: ${index.reason}`, findings: [], informational: [], baseline: null }; + } + const baseline = { + sha: index.meta.sha, + generatedAt: index.meta.generatedAt ?? null, + extractorTier: index.meta.extractorTier ?? null, + }; + if (!baselineIsAncestor(workspace, index.meta.sha)) { + return { + status: 'skipped', + message: `Structural baseline ${shortSha(index.meta.sha)} is not an ancestor of HEAD; rerun harness index --structural`, + findings: [], + informational: [], + baseline, + }; + } + + const changed = [...new Set(changedFiles || [])]; + const changedSet = new Set(changed); + const allowed = parseImpactedFiles(plan); + const diffs = diffChangedFiles({ workspace, index, changedFiles: changed }); + + const findings = []; + for (const [file, diff] of diffs) { + const symbolChanges = [...diff.added, ...diff.removedExported]; + if (symbolChanges.length && !matchesScope(file, allowed)) { + findings.push({ type: 'unplanned-symbol-change', file, added: diff.added, removed: diff.removedExported }); + } + for (const symbol of diff.removedExported) { + const callers = survivingCallers({ index, file, symbol, changedSet }); + if (callers.length) findings.push({ type: 'removed-symbol-with-callers', file, symbol, callers }); + } + } + + const expectations = evaluateExpectations(plan, diffs); + findings.push(...expectations.findings); + + if (findings.length) { + const kinds = [...new Set(findings.map((finding) => finding.type))].join(', '); + return { + status: 'failed', + message: `${findings.length} structural finding${findings.length === 1 ? '' : 's'} (${kinds})`, + findings, + informational: expectations.informational, + baseline, + }; + } + return { + status: 'passed', + message: `Structural diff matches the plan (${diffs.size} file${diffs.size === 1 ? '' : 's'} examined)`, + findings, + informational: expectations.informational, + baseline, + }; + } catch (error) { + // The advisory machinery must never be the reason a verify run breaks. + return { status: 'skipped', message: `Advisory structural check skipped: ${error.message}`, findings: [], informational: [], baseline: null }; + } +} diff --git a/packages/harness/lib/structural/shape.mjs b/packages/harness/lib/structural/shape.mjs new file mode 100644 index 00000000..fd7ab431 --- /dev/null +++ b/packages/harness/lib/structural/shape.mjs @@ -0,0 +1,173 @@ +// Structural index shape — the single integration contract between the +// structural index builder (`harness index --structural`, Phase 3) and every +// consumer (the `structural-expectations` verify check, orient enrichment, +// doctor S1). Consumers import THIS module only; when the builder lands, the +// integration is one import swap here, not a change in every consumer. +// +// Storage root: `~/.harness/index//structural/` (HARNESS_HOME +// overrides the home for tests). Four files: +// +// files.json { "version": 1, "files": { "": { +// "hash": "", "mtime": , +// "size": , "symbols": ["name", ...], +// "imports": ["specifier", ...], "complexity": +// } } } +// symbols.json { "version": 1, "symbols": [ { +// "name": "...", "file": "", "kind": "function|class|const|...", +// "exported": true|false, "def": { "line": <1-based> }, +// "refs": [ { "file": "", "line": <1-based> } ] +// } ] } +// graph.json { "version": 1, +// "calls": [ { "from": "#", "to": "#" } ], +// "modules": [ { "from": "", "to": "" } ], +// "unresolved": [ { "from": "#", "to": "" } ] } +// meta.json { "version": 1, "sha": "", +// "branch": "...", "baseSha": "...", "generatedAt": "", +// "extractorTier": "lexical|tree-sitter", "grammarVersions": {} } +// +// Read semantics: `meta.json` is mandatory — without it the index is treated +// as absent. The other three degrade to empty structures when missing so a +// partially written index never crashes a consumer; any malformed JSON marks +// the whole index unreadable (consumers skip, never guess). + +import fs from 'node:fs'; +import path from 'node:path'; +import { harnessGlobalHome } from '../paths.mjs'; +import { repoId } from '../knowledge/store.mjs'; + +export const STRUCTURAL_SHAPE_VERSION = 1; + +const EMPTY_FILES = Object.freeze({ version: STRUCTURAL_SHAPE_VERSION, files: {} }); +const EMPTY_SYMBOLS = Object.freeze({ version: STRUCTURAL_SHAPE_VERSION, symbols: [] }); +const EMPTY_GRAPH = Object.freeze({ version: STRUCTURAL_SHAPE_VERSION, calls: [], modules: [], unresolved: [] }); + +/** `/index//structural` for this workspace. */ +export function structuralDir(workspace, { home } = {}) { + return path.join(home || harnessGlobalHome(), 'index', repoId(workspace), 'structural'); +} + +function readJson(dir, name) { + const full = path.join(dir, name); + if (!fs.existsSync(full)) return { value: null, error: null }; + try { + const parsed = JSON.parse(fs.readFileSync(full, 'utf8')); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { value: null, error: `${name} is not a JSON object` }; + } + return { value: parsed, error: null }; + } catch (error) { + return { value: null, error: `${name}: ${error.message}` }; + } +} + +/** + * Read the structural index for a workspace. + * Returns `{ present, reason, files, symbols, graph, meta }`: + * - `present: false` with a `reason` when the index is missing or unreadable — + * consumers must skip, never fail, on that signal. + * - `present: true` with normalized `files` (map), `symbols` (array), + * `graph` ({calls, modules, unresolved}), and `meta` otherwise. + * + * Two on-disk encodings are accepted and normalized to one consumer shape: + * the builder's compact form (`files.json` is a bare rel→entry map, + * `symbols.json` a bare name→{defs,refs} map, `graph.json` with + * `{modules, unresolvedImports, calls: [{from, symbol, to: [files]}], + * unresolvedCalls}`), and the documented wrapper form above. The builder's + * form is authoritative; the wrapper form keeps fixtures readable. + */ +export function readStructuralIndex(workspace, { home } = {}) { + const dir = structuralDir(workspace, { home }); + const absent = (reason) => ({ present: false, reason, dir, files: {}, symbols: [], graph: EMPTY_GRAPH, meta: null }); + if (!fs.existsSync(dir)) return absent('structural index not found'); + + const meta = readJson(dir, 'meta.json'); + if (meta.error) return absent(`unreadable meta.json (${meta.error})`); + if (!meta.value) return absent('structural index has no meta.json'); + if (typeof meta.value.sha !== 'string' || !/^[0-9a-f]{7,40}$/i.test(meta.value.sha)) { + return absent('meta.json has no valid baseline sha'); + } + + const files = readJson(dir, 'files.json'); + const symbols = readJson(dir, 'symbols.json'); + const graph = readJson(dir, 'graph.json'); + const broken = files.error || symbols.error || graph.error; + if (broken) return absent(`unreadable structural index (${broken})`); + + return { + present: true, + reason: null, + dir, + files: normalizeFiles(files.value), + symbols: normalizeSymbols(symbols.value), + graph: normalizeGraph(graph.value), + meta: meta.value, + }; +} + +function normalizeFiles(parsed) { + if (!parsed || typeof parsed !== 'object') return { ...EMPTY_FILES.files }; + // Wrapper form: { version, files: {...} }. Builder form: the map itself. + if (parsed.files && typeof parsed.files === 'object' && !Array.isArray(parsed.files)) return parsed.files; + if ('version' in parsed && !('files' in parsed)) return { ...EMPTY_FILES.files }; + return parsed; +} + +function normalizeSymbols(parsed) { + if (!parsed || typeof parsed !== 'object') return [...EMPTY_SYMBOLS.symbols]; + // Wrapper form: { version, symbols: [rows] } already row-shaped. + if (Array.isArray(parsed.symbols)) return parsed.symbols; + // Builder form: { "": { defs: [{file, line, kind, exported}], refs: [{file, line}] } } + // → one row per def, carrying the symbol's shared refs. + const rows = []; + for (const [name, entry] of Object.entries(parsed)) { + if (name === 'version' || !entry || typeof entry !== 'object') continue; + const refs = Array.isArray(entry.refs) ? entry.refs : []; + for (const def of Array.isArray(entry.defs) ? entry.defs : []) { + if (!def || typeof def.file !== 'string') continue; + rows.push({ + name, + file: def.file, + kind: def.kind || 'symbol', + exported: def.exported === true, + def: { line: Number.isFinite(def.line) ? def.line : 0 }, + refs, + }); + } + } + return rows; +} + +function normalizeGraph(parsed) { + if (!parsed || typeof parsed !== 'object') return { ...EMPTY_GRAPH }; + const calls = []; + for (const edge of Array.isArray(parsed.calls) ? parsed.calls : []) { + if (!edge || typeof edge.from !== 'string') continue; + if (typeof edge.symbol === 'string' && Array.isArray(edge.to)) { + // Builder form: caller file + callee symbol + defining files. + for (const target of edge.to) { + if (typeof target === 'string') calls.push({ from: `${edge.from}#${edge.symbol}`, to: `${target}#${edge.symbol}` }); + } + } else if (typeof edge.to === 'string') { + calls.push({ from: edge.from, to: edge.to }); + } + } + const unresolved = []; + for (const entry of Array.isArray(parsed.unresolved) ? parsed.unresolved : []) { + if (entry && typeof entry.from === 'string' && typeof entry.to === 'string') unresolved.push(entry); + } + for (const entry of Array.isArray(parsed.unresolvedCalls) ? parsed.unresolvedCalls : []) { + if (entry && typeof entry.from === 'string' && typeof entry.symbol === 'string') { + unresolved.push({ from: `${entry.from}#${entry.symbol}`, to: entry.symbol }); + } + } + for (const entry of Array.isArray(parsed.unresolvedImports) ? parsed.unresolvedImports : []) { + if (entry && typeof entry.from === 'string' && typeof entry.import === 'string') { + unresolved.push({ from: entry.from, to: entry.import }); + } + } + return { + calls, + modules: Array.isArray(parsed.modules) ? parsed.modules : [], + unresolved, + }; +} diff --git a/packages/harness/lib/verify.mjs b/packages/harness/lib/verify.mjs index b815ac8a..c9432737 100644 --- a/packages/harness/lib/verify.mjs +++ b/packages/harness/lib/verify.mjs @@ -7,12 +7,17 @@ import { selectPlan } from './plan-parse.mjs'; import { extractAcceptanceCriteria, validatePlanSchema } from './plan-schema.mjs'; import { validatePlanScope } from './plan-scope.mjs'; import { createEvidenceBinding, writeEvidence } from './evidence.mjs'; -import { enforcementExitCode, loadPolicy } from './policy.mjs'; +import { checkSeverityFor, enforcementExitCode, loadPolicy } from './policy.mjs'; import { verifyPrimitiveGovernance } from './primitive-governance.mjs'; import { validatePlanReadiness } from './plan-readiness.mjs'; +import { STRUCTURAL_CHECK_ID, runStructuralExpectations } from './structural/expectations.mjs'; const CHECKS_REL = '.github/harness/checks.yaml'; +// Built-in default severities. Any check without a policy entry and without a +// default here is `enforce` — exactly the pre-severity behavior. +const DEFAULT_CHECK_SEVERITIES = { [STRUCTURAL_CHECK_ID]: 'advisory' }; + function resultCheck(id, status, message, extra = {}) { return { id, status, message, ...extra }; } @@ -82,12 +87,39 @@ function checkStatusForEvidence(mapped, byId) { return statuses.every((status) => status === 'passed') ? 'passed' : 'inconclusive'; } +// Outcome reflects only non-advisory checks: an advisory failure is reported +// (checks + advisoryFailures in the evidence payload) but never flips the +// outcome or the exit code. A warn-severity failure degrades to inconclusive +// (exit 2 under enforce) instead of failed; `skipped` is always neutral. function resolveOutcome(checks) { - if (checks.some((check) => check.status === 'failed')) return 'failed'; - if (checks.some((check) => ['unavailable', 'timeout', 'inconclusive'].includes(check.status))) return 'inconclusive'; + const gating = checks.filter((check) => check.severity !== 'advisory'); + if (gating.some((check) => check.status === 'failed' && check.severity !== 'warn')) return 'failed'; + if (gating.some((check) => check.status === 'failed' || ['unavailable', 'timeout', 'inconclusive'].includes(check.status))) { + return 'inconclusive'; + } return 'passed'; } +function applyCheckSeverities(checks, policy) { + return checks.map((check) => { + const severity = checkSeverityFor(policy, check.id, DEFAULT_CHECK_SEVERITIES[check.id] ?? 'enforce'); + // `optional` is the existing ledger-rendering hook: advisory rows render + // as warn, never error, without touching the style pipeline. + return severity === 'advisory' ? { ...check, severity, optional: true } : { ...check, severity }; + }); +} + +function collectAdvisoryFailures(checks) { + return checks + .filter((check) => check.severity === 'advisory' && !['passed', 'skipped'].includes(check.status)) + .map((check) => ({ + id: check.id, + status: check.status, + message: check.message, + ...(check.findings ? { findings: check.findings } : {}), + })); +} + function currentPhaseTasks(taskBody, phase) { const current = Number(phase); if (!Number.isInteger(current)) return taskBody; @@ -102,10 +134,12 @@ function currentPhaseTasks(taskBody, phase) { function finalize(workspace, flags, partial) { const policy = loadPolicy(workspace, flags.enforcement); + const checks = applyCheckSeverities(partial.checks, policy); const result = { - outcome: partial.outcome || resolveOutcome(partial.checks), + outcome: partial.outcome || resolveOutcome(checks), plan: partial.plan || null, - checks: partial.checks, + checks, + advisoryFailures: collectAdvisoryFailures(checks), unverifiedCriteria: partial.unverifiedCriteria || [], scopeViolations: partial.scopeViolations || [], openHardGaps: partial.openHardGaps || [], @@ -203,6 +237,21 @@ export function runVerify({ workspace, flags }) { const scope = validatePlanScope({ workspace, plan, base: flags.base }); checks.push(resultCheck('scope', scope.status, scope.message, { changedFiles: scope.changedFiles, allowed: scope.allowed })); + // Advisory structural diff vs plan (severity from policy; skips without an + // index or a current baseline — see lib/structural/expectations.mjs). + if (scope.status === 'inconclusive') { + checks.push(resultCheck(STRUCTURAL_CHECK_ID, 'skipped', 'Advisory structural check skipped: changed files unavailable')); + } else { + const structural = runStructuralExpectations({ workspace, plan, changedFiles: scope.changedFiles }); + checks.push( + resultCheck(STRUCTURAL_CHECK_ID, structural.status, structural.message, { + findings: structural.findings, + informational: structural.informational, + baseline: structural.baseline, + }) + ); + } + const primitive = verifyPrimitiveGovernance(plan, scope.changedFiles, Object.keys(named.checks || {})); if (primitive.required) { checks.push( diff --git a/packages/harness/package-lock.json b/packages/harness/package-lock.json index 524b2881..edd201f2 100644 --- a/packages/harness/package-lock.json +++ b/packages/harness/package-lock.json @@ -9,11 +9,6 @@ "version": "0.5.0", "license": "UNLICENSED", "dependencies": { - "tree-sitter-java": "0.23.5", - "tree-sitter-javascript": "0.23.1", - "tree-sitter-python": "0.23.6", - "tree-sitter-typescript": "0.23.2", - "web-tree-sitter": "0.25.10", "yaml": "^2.8.0" }, "bin": { diff --git a/packages/harness/test/structural-expectations.test.mjs b/packages/harness/test/structural-expectations.test.mjs new file mode 100644 index 00000000..4a6170ba --- /dev/null +++ b/packages/harness/test/structural-expectations.test.mjs @@ -0,0 +1,620 @@ +// Phase 4 — per-check severity (policy v2) and the advisory +// `structural-expectations` verify check. Fixtures build their own structural +// index against the documented shape in lib/structural/shape.mjs. + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { test } from 'node:test'; +import { loadPolicy, checkSeverityFor, enforcementExitCode } from '../lib/policy.mjs'; +import { structuralDir, readStructuralIndex, STRUCTURAL_SHAPE_VERSION } from '../lib/structural/shape.mjs'; +import { runStructuralExpectations, STRUCTURAL_CHECK_ID } from '../lib/structural/expectations.mjs'; +import { runVerify } from '../lib/verify.mjs'; +import { readEvidence } from '../lib/evidence.mjs'; + +function tempDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function git(workspace, args) { + const result = spawnSync('git', args, { + cwd: workspace, + encoding: 'utf8', + env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' }, + }); + assert.equal(result.status, 0, `git ${args.join(' ')}: ${result.stderr}`); + return result.stdout.trim(); +} + +function initGitWorkspace(workspace) { + git(workspace, ['init', '-q']); + git(workspace, ['config', 'user.email', 'harness@example.test']); + git(workspace, ['config', 'user.name', 'Harness Test']); +} + +function commitAll(workspace, message) { + git(workspace, ['add', '.']); + git(workspace, ['commit', '-q', '-m', message]); + return git(workspace, ['rev-parse', 'HEAD']); +} + +function writePolicy(workspace, yaml) { + const dir = path.join(workspace, '.github', 'harness'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'policy.yaml'), yaml, 'utf8'); +} + +function writeChecks(workspace, checks) { + const dir = path.join(workspace, '.github', 'harness'); + fs.mkdirSync(dir, { recursive: true }); + const body = Object.entries(checks) + .map(([name, check]) => ` ${name}:\n command: ${JSON.stringify(check.command)}`) + .join('\n'); + fs.writeFileSync(path.join(dir, 'checks.yaml'), `version: 1\nchecks:\n${body}\n`, 'utf8'); +} + +function writeVerifiablePlan(workspace, { impacted = ['src/example.js'], extraFrontmatter = '' } = {}) { + const plansDir = path.join(workspace, 'docs', 'plans'); + fs.mkdirSync(plansDir, { recursive: true }); + const rel = 'docs/plans/2026-08-06-feat-structural-plan.md'; + fs.writeFileSync( + path.join(workspace, rel), + `--- +plan_schema: 1 +title: "Structural example" +type: feat +status: in-progress +plan_lock: true +phase: 1 +risk: green +intent: "Verify structurally" +expected_outputs: + - "verified change" +success_criteria: + - "AC1 Example works" +verification: + required: ["unit-tests"] + criteria: + AC1: ["unit-tests"] +reviews: + required: [] + completed: [] + critical_open: [] +capability_gaps: [] +skills_used: ["engineer"] +${extraFrontmatter}--- + +# Structural example + +## Overview + +Verify the example. + +## Intent Contract + +- **Goal:** Verify structurally. +- **Expected outputs:** verified change. +- **Success criteria:** AC1 passes. + +## Acceptance Criteria + +- [x] **AC1** Example works. + +## Plan + +### Phase 1 — Implement + +- [x] Implement the example. + +## Impacted Files + +${impacted.map((file) => `- \`${file}\``).join('\n')} + +## Technical Notes + +No additional technical notes. + +## Verification Plan + +Run trusted named checks. + +## Risk & Review Routing + +No required specialist review. + +## Review Findings + +No open findings. + +## Activity + +- Work recorded. +`, + 'utf8' + ); + return rel; +} + +/** Baseline: src/example.js exports `value` and `helper`; consumer calls `value`. */ +function writeStructuralIndex(workspace, home, { sha, files, symbols, graph } = {}) { + const dir = structuralDir(workspace, { home }); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'files.json'), + JSON.stringify({ version: STRUCTURAL_SHAPE_VERSION, files: files ?? {} }, null, 2) + ); + fs.writeFileSync( + path.join(dir, 'symbols.json'), + JSON.stringify({ version: STRUCTURAL_SHAPE_VERSION, symbols: symbols ?? [] }, null, 2) + ); + fs.writeFileSync( + path.join(dir, 'graph.json'), + JSON.stringify( + { version: STRUCTURAL_SHAPE_VERSION, calls: graph?.calls ?? [], modules: graph?.modules ?? [], unresolved: graph?.unresolved ?? [] }, + null, + 2 + ) + ); + fs.writeFileSync( + path.join(dir, 'meta.json'), + JSON.stringify( + { + version: STRUCTURAL_SHAPE_VERSION, + sha, + branch: 'main', + baseSha: sha, + generatedAt: new Date().toISOString(), + extractorTier: 'lexical', + grammarVersions: {}, + }, + null, + 2 + ) + ); + return dir; +} + +function exampleBaseline(sha) { + return { + sha, + files: { + 'src/example.js': { hash: 'a'.repeat(64), mtime: 0, size: 60, symbols: ['value', 'helper'], imports: [], complexity: 1 }, + 'src/consumer.js': { hash: 'b'.repeat(64), mtime: 0, size: 90, symbols: ['main'], imports: ['./example.js'], complexity: 1 }, + }, + symbols: [ + { name: 'value', file: 'src/example.js', kind: 'const', exported: true, def: { line: 1 }, refs: [{ file: 'src/consumer.js', line: 2 }] }, + { name: 'helper', file: 'src/example.js', kind: 'function', exported: true, def: { line: 2 }, refs: [] }, + { name: 'main', file: 'src/consumer.js', kind: 'function', exported: true, def: { line: 2 }, refs: [] }, + ], + graph: { + calls: [{ from: 'src/consumer.js#main', to: 'src/example.js#value' }], + modules: [{ from: 'src/consumer.js', to: 'src/example.js' }], + unresolved: [], + }, + }; +} + +/** Committed fixture repo: exporting module + surviving consumer. The policy + * (when given) is committed with the baseline so it never trips the scope check. */ +function structuralWorkspace({ policy = null } = {}) { + const workspace = tempDir('structural-ws-'); + const home = tempDir('structural-home-'); + initGitWorkspace(workspace); + fs.mkdirSync(path.join(workspace, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(workspace, 'src', 'example.js'), + 'export const value = 1;\nexport function helper() { return value; }\n' + ); + fs.writeFileSync( + path.join(workspace, 'src', 'consumer.js'), + "import { value } from './example.js';\nexport function main() { return value; }\n" + ); + const plan = writeVerifiablePlan(workspace); + writeChecks(workspace, { 'unit-tests': { command: [process.execPath, '-e', 'process.exit(0)'] } }); + if (policy) writePolicy(workspace, policy); + const sha = commitAll(workspace, 'baseline'); + return { workspace, home, plan, sha }; +} + +function minimalPlan(impacted, fm = {}) { + return { + path: 'docs/plans/x.md', + fm, + sections: { impactedFiles: impacted.map((file) => `- \`${file}\``).join('\n') }, + }; +} + +function withHome(home, fn) { + const previous = process.env.HARNESS_HOME; + process.env.HARNESS_HOME = home; + try { + return fn(); + } finally { + if (previous === undefined) delete process.env.HARNESS_HOME; + else process.env.HARNESS_HOME = previous; + } +} + +// --- policy schema v2 --- + +test('v1 policy without checks parses exactly as before, with an empty severity map', () => { + const workspace = tempDir('policy-v1-'); + writePolicy(workspace, 'version: 1\nenforcement: warn\ngate_ttl_minutes: 30\nevidence_ttl_hours: 24\nexemptions: []\nwaivers: []\n'); + const policy = loadPolicy(workspace); + assert.equal(policy.version, 1); + assert.equal(policy.enforcement, 'warn'); + assert.equal(policy.gateTtlMinutes, 30); + assert.equal(policy.evidenceTtlHours, 24); + assert.deepEqual(policy.exemptions, []); + assert.deepEqual(policy.waivers, []); + assert.deepEqual(policy.checkSeverities, {}); + assert.equal(checkSeverityFor(policy, 'scope'), 'enforce'); +}); + +test('v2 policy parses per-check severities', () => { + const workspace = tempDir('policy-v2-'); + writePolicy( + workspace, + 'version: 2\nenforcement: enforce\nchecks:\n structural-expectations:\n severity: warn\n scope:\n severity: enforce\n' + ); + const policy = loadPolicy(workspace); + assert.equal(policy.version, 2); + assert.deepEqual(policy.checkSeverities, { 'structural-expectations': 'warn', scope: 'enforce' }); + assert.equal(checkSeverityFor(policy, 'structural-expectations', 'advisory'), 'warn'); + assert.equal(checkSeverityFor(policy, 'unlisted', 'advisory'), 'advisory'); +}); + +test('unknown check severity is rejected with a clear error', () => { + const workspace = tempDir('policy-bad-severity-'); + writePolicy(workspace, 'version: 2\nchecks:\n structural-expectations:\n severity: fatal\n'); + assert.throws(() => loadPolicy(workspace), /checks\.structural-expectations\.severity must be advisory, warn, or enforce \(got fatal\)/); +}); + +test('non-mapping checks entries are rejected', () => { + const workspace = tempDir('policy-bad-checks-'); + writePolicy(workspace, 'version: 2\nchecks:\n - structural-expectations\n'); + assert.throws(() => loadPolicy(workspace), /checks must be a mapping/); + writePolicy(workspace, 'version: 2\nchecks:\n structural-expectations: advisory\n'); + assert.throws(() => loadPolicy(workspace), /checks\.structural-expectations must be a mapping/); +}); + +test('policy versions other than 1 and 2 are rejected', () => { + const workspace = tempDir('policy-bad-version-'); + writePolicy(workspace, 'version: 3\nenforcement: enforce\n'); + assert.throws(() => loadPolicy(workspace), /expected version 1 or 2/); +}); + +// --- shape module --- + +test('structuralDir honors HARNESS_HOME and readStructuralIndex reports absence', () => { + const workspace = tempDir('shape-ws-'); + const home = tempDir('shape-home-'); + initGitWorkspace(workspace); + const dir = structuralDir(workspace, { home }); + assert.ok(dir.startsWith(path.join(home, 'index')), dir); + assert.ok(dir.endsWith(path.join('structural')), dir); + const index = readStructuralIndex(workspace, { home }); + assert.equal(index.present, false); + assert.match(index.reason, /not found/); +}); + +test('readStructuralIndex requires meta.json with a valid sha and skips on malformed JSON', () => { + const workspace = tempDir('shape-ws-'); + const home = tempDir('shape-home-'); + initGitWorkspace(workspace); + const dir = structuralDir(workspace, { home }); + fs.mkdirSync(dir, { recursive: true }); + assert.match(readStructuralIndex(workspace, { home }).reason, /no meta\.json/); + + fs.writeFileSync(path.join(dir, 'meta.json'), JSON.stringify({ version: 1, sha: 'not-a-sha' })); + assert.match(readStructuralIndex(workspace, { home }).reason, /no valid baseline sha/); + + fs.writeFileSync(path.join(dir, 'meta.json'), JSON.stringify({ version: 1, sha: 'a'.repeat(40) })); + fs.writeFileSync(path.join(dir, 'files.json'), '{ broken'); + assert.match(readStructuralIndex(workspace, { home }).reason, /unreadable structural index/); + + fs.writeFileSync(path.join(dir, 'files.json'), JSON.stringify({ version: 1, files: {} })); + const index = readStructuralIndex(workspace, { home }); + assert.equal(index.present, true); + assert.deepEqual(index.symbols, []); + assert.deepEqual(index.graph.calls, []); +}); + +// --- structural-expectations check unit behavior --- + +test('missing structural index reports skipped, never fails', () => { + const { workspace, home } = structuralWorkspace(); + const result = runStructuralExpectations({ + workspace, + plan: minimalPlan(['src/example.js']), + changedFiles: ['src/example.js'], + home, + }); + assert.equal(result.status, 'skipped'); + assert.match(result.message, /skipped/i); + assert.deepEqual(result.findings, []); +}); + +test('stale baseline (meta.sha not an ancestor of HEAD) warns and skips', () => { + const { workspace, home } = structuralWorkspace(); + // A commit on a side branch is not an ancestor of the restored main HEAD. + git(workspace, ['checkout', '-q', '-b', 'side']); + fs.writeFileSync(path.join(workspace, 'src', 'side.js'), 'export const side = 1;\n'); + const sideSha = commitAll(workspace, 'side work'); + git(workspace, ['checkout', '-q', '-']); + writeStructuralIndex(workspace, home, { ...exampleBaseline(sideSha) }); + + const result = runStructuralExpectations({ + workspace, + plan: minimalPlan(['src/example.js']), + changedFiles: ['src/example.js'], + home, + }); + assert.equal(result.status, 'skipped'); + assert.match(result.message, /not an ancestor of HEAD/); + assert.match(result.message, /harness index --structural/); +}); + +test('removed exported symbol with a surviving caller is flagged', () => { + const { workspace, home, sha } = structuralWorkspace(); + writeStructuralIndex(workspace, home, exampleBaseline(sha)); + // Remove `value` while the untouched consumer still calls it. + fs.writeFileSync(path.join(workspace, 'src', 'example.js'), 'export const other = 2;\n'); + + const result = runStructuralExpectations({ + workspace, + plan: minimalPlan(['src/example.js']), + changedFiles: ['src/example.js'], + home, + }); + assert.equal(result.status, 'failed'); + const removed = result.findings.filter((finding) => finding.type === 'removed-symbol-with-callers'); + assert.ok(removed.some((finding) => finding.symbol === 'value' && finding.callers.includes('src/consumer.js')), JSON.stringify(result.findings)); + // `helper` was also removed, but it has no callers outside the change. + assert.ok(!removed.some((finding) => finding.symbol === 'helper'), JSON.stringify(removed)); +}); + +test('callers that changed in the same diff do not count as surviving', () => { + const { workspace, home, sha } = structuralWorkspace(); + writeStructuralIndex(workspace, home, exampleBaseline(sha)); + fs.writeFileSync(path.join(workspace, 'src', 'example.js'), 'export const other = 2;\n'); + fs.writeFileSync(path.join(workspace, 'src', 'consumer.js'), "import { other } from './example.js';\nexport function main() { return other; }\n"); + + const result = runStructuralExpectations({ + workspace, + plan: minimalPlan(['src/example.js', 'src/consumer.js']), + changedFiles: ['src/example.js', 'src/consumer.js'], + home, + }); + assert.ok(!result.findings.some((finding) => finding.type === 'removed-symbol-with-callers'), JSON.stringify(result.findings)); +}); + +test('changed exported symbols outside Impacted Files are flagged; planned ones are not', () => { + const { workspace, home, sha } = structuralWorkspace(); + writeStructuralIndex(workspace, home, exampleBaseline(sha)); + // Added symbol in a planned file: fine. New file with symbols outside the plan: flagged. + fs.writeFileSync(path.join(workspace, 'src', 'example.js'), 'export const value = 1;\nexport function helper() { return value; }\nexport const added = 3;\n'); + fs.writeFileSync(path.join(workspace, 'src', 'unplanned.js'), 'export const rogue = 9;\n'); + + const result = runStructuralExpectations({ + workspace, + plan: minimalPlan(['src/example.js']), + changedFiles: ['src/example.js', 'src/unplanned.js'], + home, + }); + assert.equal(result.status, 'failed'); + const unplanned = result.findings.filter((finding) => finding.type === 'unplanned-symbol-change'); + assert.deepEqual(unplanned.map((finding) => finding.file), ['src/unplanned.js']); + assert.deepEqual(unplanned[0].added, ['rogue']); +}); + +test('a clean structural diff passes with an examined-files summary', () => { + const { workspace, home, sha } = structuralWorkspace(); + writeStructuralIndex(workspace, home, exampleBaseline(sha)); + fs.writeFileSync(path.join(workspace, 'src', 'example.js'), 'export const value = 11;\nexport function helper() { return value; }\n'); + + const result = runStructuralExpectations({ + workspace, + plan: minimalPlan(['src/example.js']), + changedFiles: ['src/example.js'], + home, + }); + assert.equal(result.status, 'passed'); + assert.match(result.message, /1 file examined/); + assert.equal(result.baseline.sha, sha); +}); + +test('deleted files count every baseline exported symbol as removed', () => { + const { workspace, home, sha } = structuralWorkspace(); + writeStructuralIndex(workspace, home, exampleBaseline(sha)); + fs.rmSync(path.join(workspace, 'src', 'example.js')); + + const result = runStructuralExpectations({ + workspace, + plan: minimalPlan(['src/example.js']), + changedFiles: ['src/example.js'], + home, + }); + assert.equal(result.status, 'failed'); + assert.ok(result.findings.some((finding) => finding.type === 'removed-symbol-with-callers' && finding.symbol === 'value')); +}); + +// --- structural_expectations plan frontmatter (stretch hook) --- + +test('required structural expectations fail the check when unmet; optional ones stay informational', () => { + const { workspace, home, sha } = structuralWorkspace(); + writeStructuralIndex(workspace, home, exampleBaseline(sha)); + fs.writeFileSync(path.join(workspace, 'src', 'example.js'), 'export const value = 11;\nexport function helper() { return value; }\n'); + + const fm = { + structural_expectations: [ + { file: 'src/example.js', symbol: 'brandNew', change: 'added', required: true }, + { file: 'src/example.js', symbol: 'alsoNew', change: 'added' }, + ], + }; + const result = runStructuralExpectations({ + workspace, + plan: minimalPlan(['src/example.js'], fm), + changedFiles: ['src/example.js'], + home, + }); + assert.equal(result.status, 'failed'); + assert.deepEqual( + result.findings.map((finding) => finding.type), + ['unmet-required-expectation'] + ); + assert.equal(result.findings[0].symbol, 'brandNew'); + assert.ok(result.informational.some((entry) => entry.type === 'unmet-expectation' && entry.symbol === 'alsoNew')); +}); + +test('met structural expectations pass and an absent block skips cleanly', () => { + const { workspace, home, sha } = structuralWorkspace(); + writeStructuralIndex(workspace, home, exampleBaseline(sha)); + fs.writeFileSync(path.join(workspace, 'src', 'example.js'), 'export const value = 11;\nexport function helper() { return value; }\nexport const added = 3;\n'); + + const met = runStructuralExpectations({ + workspace, + plan: minimalPlan(['src/example.js'], { + structural_expectations: [ + { file: 'src/example.js', symbol: 'added', change: 'added', required: true }, + { file: 'src/example.js', symbol: 'value', change: 'modified', required: true }, + ], + }), + changedFiles: ['src/example.js'], + home, + }); + assert.equal(met.status, 'passed', JSON.stringify(met.findings)); + + const absent = runStructuralExpectations({ + workspace, + plan: minimalPlan(['src/example.js']), + changedFiles: ['src/example.js'], + home, + }); + assert.equal(absent.status, 'passed'); + assert.deepEqual(absent.informational, []); +}); + +test('malformed expectation entries are reported informationally, never fail', () => { + const { workspace, home, sha } = structuralWorkspace(); + writeStructuralIndex(workspace, home, exampleBaseline(sha)); + fs.writeFileSync(path.join(workspace, 'src', 'example.js'), 'export const value = 11;\nexport function helper() { return value; }\n'); + + const result = runStructuralExpectations({ + workspace, + plan: minimalPlan(['src/example.js'], { structural_expectations: [{ symbol: 'x', change: 'exploded' }, 'nonsense'] }), + changedFiles: ['src/example.js'], + home, + }); + assert.equal(result.status, 'passed'); + assert.equal(result.informational.filter((entry) => entry.type === 'malformed-expectation').length, 2); +}); + +// --- verify integration: severity routing and evidence payload --- + +function verifyFlags(plan, overrides = {}) { + return { plan, base: 'HEAD', dryRun: false, ...overrides }; +} + +test('advisory structural failure does not flip a passing verify outcome', () => { + const { workspace, home, plan, sha } = structuralWorkspace(); + writeStructuralIndex(workspace, home, exampleBaseline(sha)); + fs.writeFileSync(path.join(workspace, 'src', 'example.js'), 'export const other = 2;\n'); + + const result = withHome(home, () => runVerify({ workspace, flags: verifyFlags(plan) })); + assert.equal(result.outcome, 'passed', JSON.stringify(result.checks, null, 2)); + assert.equal(enforcementExitCode(result.outcome, 'enforce'), 0); + + const structural = result.checks.find((check) => check.id === STRUCTURAL_CHECK_ID); + assert.equal(structural.status, 'failed'); + assert.equal(structural.severity, 'advisory'); + assert.equal(structural.optional, true); + assert.ok(structural.findings.length > 0); + + // Nothing silently lost: the advisory failure lands in its own field. + assert.equal(result.advisoryFailures.length, 1); + assert.equal(result.advisoryFailures[0].id, STRUCTURAL_CHECK_ID); + assert.equal(result.advisoryFailures[0].status, 'failed'); + assert.ok(Array.isArray(result.advisoryFailures[0].findings)); +}); + +test('policy warn severity degrades a structural failure to inconclusive (exit 2 under enforce)', () => { + const { workspace, home, plan, sha } = structuralWorkspace({ + policy: 'version: 2\nenforcement: enforce\nchecks:\n structural-expectations:\n severity: warn\n', + }); + writeStructuralIndex(workspace, home, exampleBaseline(sha)); + fs.writeFileSync(path.join(workspace, 'src', 'example.js'), 'export const other = 2;\n'); + + const result = withHome(home, () => runVerify({ workspace, flags: verifyFlags(plan) })); + assert.equal(result.outcome, 'inconclusive'); + assert.equal(enforcementExitCode(result.outcome, result.enforcement), 2); + const structural = result.checks.find((check) => check.id === STRUCTURAL_CHECK_ID); + assert.equal(structural.severity, 'warn'); + assert.deepEqual(result.advisoryFailures, []); +}); + +test('policy enforce severity makes a structural failure fail verification (exit 1)', () => { + const { workspace, home, plan, sha } = structuralWorkspace({ + policy: 'version: 2\nenforcement: enforce\nchecks:\n structural-expectations:\n severity: enforce\n', + }); + writeStructuralIndex(workspace, home, exampleBaseline(sha)); + fs.writeFileSync(path.join(workspace, 'src', 'example.js'), 'export const other = 2;\n'); + + const result = withHome(home, () => runVerify({ workspace, flags: verifyFlags(plan) })); + assert.equal(result.outcome, 'failed'); + assert.equal(enforcementExitCode(result.outcome, result.enforcement), 1); + // The only failed check is the structural one — the failure is genuinely its doing. + const failed = result.checks.filter((check) => check.status === 'failed'); + assert.deepEqual(failed.map((check) => check.id), [STRUCTURAL_CHECK_ID]); +}); + +test('a passing verify run with no structural index behaves as before (v1 compatibility round-trip)', () => { + // No structural index written; v1 policy file present. + const { workspace, home, plan } = structuralWorkspace({ + policy: 'version: 1\nenforcement: enforce\ngate_ttl_minutes: 30\nevidence_ttl_hours: 24\nexemptions: []\nwaivers: []\n', + }); + + const result = withHome(home, () => runVerify({ workspace, flags: verifyFlags(plan) })); + assert.equal(result.outcome, 'passed', JSON.stringify(result.checks, null, 2)); + const structural = result.checks.find((check) => check.id === STRUCTURAL_CHECK_ID); + assert.equal(structural.status, 'skipped'); + assert.deepEqual(result.advisoryFailures, []); + // Every non-advisory check carries the v1-equivalent enforce severity. + for (const check of result.checks) { + if (check.id === STRUCTURAL_CHECK_ID) assert.equal(check.severity, 'advisory'); + else assert.equal(check.severity, 'enforce'); + } +}); + +test('a hard check failure still fails verification regardless of advisory checks', () => { + const { workspace, home, plan, sha } = structuralWorkspace(); + writeStructuralIndex(workspace, home, exampleBaseline(sha)); + // Scope violation: change a file the plan does not allow. + fs.writeFileSync(path.join(workspace, 'src', 'rogue.js'), 'export const rogue = 1;\n'); + + const result = withHome(home, () => runVerify({ workspace, flags: verifyFlags(plan) })); + assert.equal(result.outcome, 'failed'); + const scope = result.checks.find((check) => check.id === 'scope'); + assert.equal(scope.status, 'failed'); + assert.equal(scope.severity, 'enforce'); +}); + +test('evidence payload records per-check severity and advisory failures', () => { + const { workspace, home, plan, sha } = structuralWorkspace(); + writeStructuralIndex(workspace, home, exampleBaseline(sha)); + fs.writeFileSync(path.join(workspace, 'src', 'example.js'), 'export const other = 2;\n'); + + const result = withHome(home, () => runVerify({ workspace, flags: verifyFlags(plan) })); + const evidence = readEvidence(workspace, result.plan); + assert.ok(evidence, 'evidence artifact must exist'); + assert.equal(evidence.outcome, 'passed'); + assert.ok(evidence.checks.every((check) => ['advisory', 'warn', 'enforce'].includes(check.severity))); + const structural = evidence.checks.find((check) => check.id === STRUCTURAL_CHECK_ID); + assert.equal(structural.severity, 'advisory'); + assert.equal(structural.status, 'failed'); + assert.equal(evidence.advisoryFailures.length, 1); + assert.equal(evidence.advisoryFailures[0].id, STRUCTURAL_CHECK_ID); + assert.ok(evidence.advisoryFailures[0].findings.some((finding) => finding.type === 'removed-symbol-with-callers')); +}); diff --git a/packages/harness/test/structural-shape-compat.test.mjs b/packages/harness/test/structural-shape-compat.test.mjs new file mode 100644 index 00000000..f4b10a53 --- /dev/null +++ b/packages/harness/test/structural-shape-compat.test.mjs @@ -0,0 +1,70 @@ +// Integration seam test: the structural index the REAL builder writes +// (`buildStructuralIndex`, compact on-disk form) must be readable through +// `readStructuralIndex` and usable by the structural-expectations check — +// the two halves were built independently against one documented contract, +// and this test is the proof they meet. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; + +import { buildStructuralIndex, structuralIndexDir } from '../lib/repo-map/structural-index.mjs'; +import { createTreesitterExtract } from '../lib/repo-map/treesitter-extractor.mjs'; +import { readStructuralIndex, structuralDir } from '../lib/structural/shape.mjs'; +import { runStructuralExpectations } from '../lib/structural/expectations.mjs'; + +function initRepo(ws) { + fs.mkdirSync(ws, { recursive: true }); + execFileSync('git', ['init', '-q'], { cwd: ws }); + execFileSync('git', ['config', 'user.email', 't@t'], { cwd: ws }); + execFileSync('git', ['config', 'user.name', 't'], { cwd: ws }); +} + +function commitAll(ws, message) { + execFileSync('git', ['add', '-A'], { cwd: ws }); + execFileSync('git', ['commit', '-qm', message], { cwd: ws }); +} + +test('builder output round-trips through shape reader into the expectations check', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'struct-compat-')); + const ws = path.join(tmp, 'ws'); + const home = path.join(tmp, 'home'); + initRepo(ws); + fs.writeFileSync( + path.join(ws, 'a.mjs'), + 'export function alpha() { return beta(); }\nexport function beta() { return 1; }\n' + ); + fs.writeFileSync(path.join(ws, 'caller.mjs'), "import { beta } from './a.mjs';\nexport const use = () => beta();\n"); + commitAll(ws, 'init'); + + assert.equal(structuralIndexDir(ws, { home }), structuralDir(ws, { home }), 'builder and reader agree on the index dir'); + + const extractor = await createTreesitterExtract(); + await buildStructuralIndex({ workspace: ws, home, extractor }); + + const index = readStructuralIndex(ws, { home }); + assert.equal(index.present, true, `index should be readable: ${index.reason}`); + assert.ok(Object.keys(index.files).length >= 2, 'files map is populated'); + assert.ok(Array.isArray(index.files['a.mjs']?.symbols) && index.files['a.mjs'].symbols.includes('alpha')); + + const betaRows = index.symbols.filter((row) => row.name === 'beta'); + assert.ok(betaRows.length >= 1, 'symbols normalized into rows'); + assert.equal(betaRows[0].file, 'a.mjs'); + assert.equal(typeof betaRows[0].exported, 'boolean'); + + const betaCalls = index.graph.calls.filter((edge) => edge.to === 'a.mjs#beta'); + assert.ok(betaCalls.length >= 1, 'call edges normalized to file#symbol form'); + + // Remove an exported symbol with a surviving caller; the check must see it. + fs.writeFileSync(path.join(ws, 'a.mjs'), 'export function alpha() { return 1; }\n'); + const plan = { fm: {}, body: '## Impacted Files\n\n- `a.mjs`\n' }; + const result = runStructuralExpectations({ workspace: ws, plan, changedFiles: ['a.mjs'], home }); + assert.equal(result.status, 'failed', `expected structural findings, got ${result.status}: ${result.message}`); + assert.ok( + result.findings.some((f) => f.type === 'removed-symbol-with-callers' && f.symbol === 'beta'), + 'removed exported symbol with a surviving caller is flagged' + ); +}); From 67c1b441b5b99c9be626d337744be6a526a15e2a Mon Sep 17 00:00:00 2001 From: Krish Date: Thu, 6 Aug 2026 04:36:34 -0400 Subject: [PATCH 09/24] chore: register harness evolution candidate capabilities --- knowledge/capability-registry.yaml | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/knowledge/capability-registry.yaml b/knowledge/capability-registry.yaml index 03593277..9d30145b 100644 --- a/knowledge/capability-registry.yaml +++ b/knowledge/capability-registry.yaml @@ -1,6 +1,6 @@ # Discoverable capability inventory. Skill metadata remains the primary trigger surface. version: 2 -updated: 2026-07-13 +updated: 2026-08-06 lifecycle_states: [candidate, experimental, active, deprecated, retired] engineer: @@ -239,6 +239,33 @@ capabilities: replacement: engineer-agent reason: Duplicated the normative Engineer runtime loop. + # Harness evolution surfaces (approved blueprint; candidate per its Human + # Decision conditions — promotion is a later human step with usage evidence). + knowledge-lifecycle: + type: cli + status: candidate + owner: developer-experience + version: 1 + origin: harness-evolution + proposal: knowledge/proposals/harness-evolution-blueprint.md + triggers: [knowledge status, knowledge promote, knowledge prune, branch bucket, layered knowledge] + structural-index: + type: cli + status: candidate + owner: developer-experience + version: 1 + origin: harness-evolution + proposal: knowledge/proposals/harness-evolution-blueprint.md + triggers: [index --structural, structural diff, symbol index, callers] + structural-expectations: + type: check + status: candidate + owner: developer-experience + version: 1 + origin: harness-evolution + proposal: knowledge/proposals/harness-evolution-blueprint.md + triggers: [structural expectations, verify severity, advisory check] + engineer_allowlist: - code-implementer - code-review-coordinator From 6483f036cde4bb224da032a7d179bac3a23c1261 Mon Sep 17 00:00:00 2001 From: Krish Date: Thu, 6 Aug 2026 06:13:20 -0400 Subject: [PATCH 10/24] fix: close code review findings on layered knowledge, structural index, and verify severity --- docs/MEMORY-MODEL.md | 8 +- docs/architecture/engineer-harness.md | 2 +- .../proposals/harness-evolution-blueprint.md | 12 +- packages/harness/lib/commands.mjs | 37 +++--- packages/harness/lib/context-pack.mjs | 9 +- packages/harness/lib/events.mjs | 3 + packages/harness/lib/flags.mjs | 18 ++- packages/harness/lib/knowledge/admin.mjs | 29 ++++- packages/harness/lib/knowledge/apply.mjs | 11 +- .../harness/lib/knowledge/consolidate.mjs | 7 +- packages/harness/lib/knowledge/layer.mjs | 24 +++- packages/harness/lib/knowledge/overlay.mjs | 5 +- packages/harness/lib/knowledge/promote.mjs | 29 ++++- packages/harness/lib/knowledge/prune.mjs | 116 ++++++++++-------- packages/harness/lib/knowledge/store.mjs | 3 +- packages/harness/lib/policy.mjs | 10 +- packages/harness/lib/repo-map/index.mjs | 4 +- .../harness/lib/repo-map/structural-index.mjs | 20 ++- .../lib/repo-map/treesitter-extractor.mjs | 4 + packages/harness/lib/report.mjs | 40 ++++-- .../harness/lib/structural/expectations.mjs | 62 ++++++++-- packages/harness/lib/structural/shape.mjs | 19 ++- .../harness/test/doctor-structural.test.mjs | 46 ++++--- packages/harness/test/hand-edits.test.mjs | 41 +++++++ .../harness/test/knowledge-promote.test.mjs | 61 +++++++++ .../harness/test/layer-maintenance.test.mjs | 20 +-- packages/harness/test/layer-routing.test.mjs | 9 +- .../harness/test/layered-overlay.test.mjs | 3 +- packages/harness/test/report.test.mjs | 42 ++++++- .../harness/test/store-migration.test.mjs | 4 +- .../test/structural-expectations.test.mjs | 41 +++++++ .../harness/test/structural-index.test.mjs | 30 ++++- .../test/structural-shape-compat.test.mjs | 57 +++++++-- .../test/treesitter-extractor.test.mjs | 14 ++- 34 files changed, 657 insertions(+), 183 deletions(-) diff --git a/docs/MEMORY-MODEL.md b/docs/MEMORY-MODEL.md index f008d62f..dad69541 100644 --- a/docs/MEMORY-MODEL.md +++ b/docs/MEMORY-MODEL.md @@ -705,8 +705,12 @@ The approved [Harness Evolution Blueprint](../knowledge/proposals/harness-evolut the new key when exactly one gone-branch, ancestry-verified candidate exists; anything ambiguous is left for `knowledge status`/K5 and manual prune. -Phase 3 (structural index) and Phase 4 (per-check verify severity) remain unshipped -design; nothing on this page describes them. +Phase 3 (the optional tree-sitter structural index under +`~/.harness/index//structural/`) and Phase 4 (per-check verify severity via +policy v2 and the advisory `structural-expectations` check) are shipped. The structural +index is derived state, never knowledge: it lives outside this store, carries no +governance, and is freely deletable and rebuildable — nothing else on this page applies +to it. ## Related diff --git a/docs/architecture/engineer-harness.md b/docs/architecture/engineer-harness.md index 2d71260a..5dc70073 100644 --- a/docs/architecture/engineer-harness.md +++ b/docs/architecture/engineer-harness.md @@ -223,6 +223,6 @@ The verification suite checks the thin Engineer contract, plan and policy schema - [Capability Registry](../../knowledge/capability-registry.yaml) - [Install Guide](../install.md) - [Harness Quickstart](../onboarding/harness-quickstart.md) -- [Harness Evolution Blueprint](../../knowledge/proposals/harness-evolution-blueprint.md) (proposal — planned evolution, not current behavior) +- [Harness Evolution Blueprint](../../knowledge/proposals/harness-evolution-blueprint.md) (approved design — phases 1–4 shipped; conditions in its Human Decision remain binding) Historical proposals, comparative reviews, and implementation roadmaps are removed from active documentation after implementation. Their audit remains in Git and pull-request history; durable decisions are promoted to this architecture or team knowledge before completed plans are deleted. diff --git a/knowledge/proposals/harness-evolution-blueprint.md b/knowledge/proposals/harness-evolution-blueprint.md index 9175e664..303e7b75 100644 --- a/knowledge/proposals/harness-evolution-blueprint.md +++ b/knowledge/proposals/harness-evolution-blueprint.md @@ -1,11 +1,11 @@ # Harness Evolution Blueprint: Local-First Adaptive Engineering System -Status: **proposal — pending Human Decision.** -Design documentation only. Nothing in this document describes current behavior, and no -CLI, store, or primitive change may be built from it until the `## Human Decision` -section records an approval (per Decision Handling semantics in -[`capability-gap-proposal.md`](../../.github/skills/references/capability-gap-proposal.md): -blank or incomplete = pending; do not create or modify primitives). +Status: **approved.** +The `## Human Decision` section records approval on 2026-08-06; its listed conditions +remain binding on every implementation phase (per Decision Handling semantics in +[`capability-gap-proposal.md`](../../.github/skills/references/capability-gap-proposal.md)). +This document is the approved design, not a behavior reference — current behavior is +documented in `docs/MEMORY-MODEL.md` and the harness tool contract as each phase ships. This blueprint adapts nine externally supplied proposals — a two-layer golden/branch-local knowledge model, deterministic retrieval, structural codebase indexing, layer-aware diff --git a/packages/harness/lib/commands.mjs b/packages/harness/lib/commands.mjs index 3088bb4e..44c511e5 100644 --- a/packages/harness/lib/commands.mjs +++ b/packages/harness/lib/commands.mjs @@ -372,17 +372,20 @@ export async function cmdIndex(argv) { const extractor = await createTreesitterExtract(); const result = await buildStructuralIndex({ workspace, extractor, since, dryRun: flags.dryRun, log: logger }); const integrity = (result.meta.integrityFailures || []).length > 0; + // A non-dry-run that could not persist (contained write refused) is a + // FAILURE — a caller must never treat an unavailable index as current. + const persistFailed = !flags.dryRun && !result.written; writeEvent(workspace, flags, { type: 'index', command: 'index', - result: integrity ? 'warn' : 'pass', - exitCode: 0, + result: persistFailed ? 'fail' : integrity ? 'warn' : 'pass', + exitCode: persistFailed ? 1 : 0, }); if (flags.json) { // Program lane (§9): a bounded summary envelope — never the raw tables. emitJson(flags, { - pass: true, - exitCode: 0, + pass: !persistFailed, + exitCode: persistFailed ? 1 : 0, dir: result.dir, written: result.written, sha: result.meta.sha, @@ -402,12 +405,14 @@ export async function cmdIndex(argv) { const deltaNote = `symbols +${result.delta.added.count} −${result.delta.removed.count} ~${result.delta.changed.count}${since ? ' vs prior index' : ''}`; console.log( ui.line({ - state: integrity ? 'warn' : 'ok', + state: persistFailed ? 'error' : integrity ? 'warn' : 'ok', key: 'structural', value: `${result.meta.filesIndexed} files · ${result.reparsed} parsed · ${result.reused} reused · tier ${result.meta.extractorTier}`, - note: integrity - ? `grammar integrity mismatch (${result.meta.integrityFailures.length}) — loud lexical fallback; run harness doctor` - : deltaNote, + note: persistFailed + ? 'index could not be persisted — structural data is NOT current' + : integrity + ? `grammar integrity mismatch (${result.meta.integrityFailures.length}) — loud lexical fallback; run harness doctor` + : deltaNote, }) ); // Agent lane (§9): the budgeted inert digest, never raw index JSON. @@ -420,7 +425,7 @@ export async function cmdIndex(argv) { } } } - return 0; + return persistFailed ? 1 : 0; } // Stamp the current git HEAD so `index --status` can measure drift later. @@ -536,9 +541,11 @@ export async function cmdOrient(argv) { learningsBytes: result.learningsBytes, // Layer attribution (blueprint P6/report split): recorded only when a // branch-bucket learning actually surfaced, so pre-bucket event shapes - // are unchanged. `harness report` splits SLO totals per layer from this. + // are unchanged. Per-OCCURRENCE entries, not an id-keyed map: the + // protected-shadow overlay can deliver golden and branch entries with + // the SAME id, and a map would silently drop one side's attribution. ...((result.learnings || []).some((l) => l.layer) - ? { learningLayers: Object.fromEntries((result.learnings || []).map((l) => [l.id, l.layer || 'golden'])) } + ? { learningLayers: (result.learnings || []).map((l) => ({ id: l.id, layer: l.layer === 'branch' ? 'branch' : 'golden' })) } : {}), }); @@ -657,7 +664,9 @@ export async function cmdVerify(argv) { if (flags.json) emitJson(flags, result); else { - const failed = result.checks.filter((c) => c.status !== 'passed').length; + // `skipped` is neutral (e.g. the advisory structural check without an + // index): never a failure count, never the next fix target. + const failed = result.checks.filter((c) => c.status !== 'passed' && c.status !== 'skipped').length; const passed = result.outcome === 'passed'; console.log( ui.line({ @@ -669,11 +678,11 @@ export async function cmdVerify(argv) { note: result.evidencePath, }) ); - printChecks(flags, result.checks, (c) => c.status === 'passed'); + printChecks(flags, result.checks, (c) => c.status === 'passed' || c.status === 'skipped'); if (passed) { printNext('harness compound (or /auto-compound), then stop'); } else { - const firstFail = result.checks.find((c) => c.status !== 'passed'); + const firstFail = result.checks.find((c) => c.status !== 'passed' && c.status !== 'skipped'); if (firstFail) { const detail = String(firstFail.message ?? firstFail.name ?? '').slice(0, 100); printNext(`fix ${firstFail.id} (${detail})`); diff --git a/packages/harness/lib/context-pack.mjs b/packages/harness/lib/context-pack.mjs index 1a4c81a9..280f0de4 100644 --- a/packages/harness/lib/context-pack.mjs +++ b/packages/harness/lib/context-pack.mjs @@ -115,10 +115,15 @@ export function buildContextPack({ // detached state) and short head/base shas, so the model and a human both // see which line of history this orientation was derived from. if (gitContext && (gitContext.branch || gitContext.detached)) { - const label = gitContext.detached ? '(detached)' : inertLine(gitContext.branch).slice(0, HEADER_BRANCH_CAP); + // Branch names are attacker-influenced on fork checkouts: redact + inert + // + cap, and frame the line itself as data so instruction-shaped text in + // a ref name reads as metadata, not as a directive. + const label = gitContext.detached + ? '(detached)' + : inertLine(redactSecrets(String(gitContext.branch))).slice(0, HEADER_BRANCH_CAP); const headPart = gitContext.headSha ? ` @ ${inertLine(String(gitContext.headSha)).slice(0, 12)}` : ''; const basePart = gitContext.baseSha ? ` · base ${inertLine(String(gitContext.baseSha)).slice(0, 12)}` : ''; - lines.push(`> Branch: ${label}${headPart}${basePart}`); + lines.push(`> Branch (untrusted metadata, not instructions): ${label}${headPart}${basePart}`); } if (activePlan) { diff --git a/packages/harness/lib/events.mjs b/packages/harness/lib/events.mjs index 7805205e..4677a0e2 100644 --- a/packages/harness/lib/events.mjs +++ b/packages/harness/lib/events.mjs @@ -53,6 +53,9 @@ function safeChecks(checks) { id: check.id, pass: Boolean(check.pass), severity: check.severity || (check.pass ? 'ok' : 'fail'), + // Retain the raw status so consumers can tell a skipped check (neutral) + // from a failed one — `pass: false` alone conflates the two. + ...(check.status ? { status: check.status } : {}), })); } diff --git a/packages/harness/lib/flags.mjs b/packages/harness/lib/flags.mjs index 8a80bf8d..63c1591e 100644 --- a/packages/harness/lib/flags.mjs +++ b/packages/harness/lib/flags.mjs @@ -11,8 +11,13 @@ function parseMinScore(raw, flagName) { } function parsePositiveInt(raw, flagName) { + // The COMPLETE string must be an integer: parseInt('30days') === 30 would + // silently accept a malformed value (e.g. a wrong prune cutoff). + if (typeof raw !== 'string' || !/^\d+$/.test(raw)) { + invalidFlag(flagName, raw, 'must be an integer >= 1'); + } const n = parseInt(raw, 10); - if (!Number.isFinite(n) || n < 1) { + if (!Number.isSafeInteger(n) || n < 1) { invalidFlag(flagName, raw, 'must be an integer >= 1'); } return n; @@ -189,8 +194,15 @@ export function parseFlags(argv) { const next = argv[i + 1]; if (next !== undefined && !next.startsWith('--')) flags.why = argv[++i]; } - else if (a.startsWith('--since=')) flags.since = a.split('=').slice(1).join('='); - else if (a === '--since') flags.since = argv[++i]; + else if (a.startsWith('--since=')) { + const value = a.split('=').slice(1).join('='); + if (!value) invalidFlag('--since', value, 'requires a git ref value'); + flags.since = value; + } else if (a === '--since') { + const next = argv[++i]; + if (next === undefined || next.startsWith('--')) invalidFlag('--since', next, 'requires a git ref value'); + flags.since = next; + } else if (a === '--yes') flags.yes = true; else if (a.startsWith('--layer=')) flags.layer = parseLayer(a.split('=')[1]); else if (a === '--layer') flags.layer = parseLayer(argv[++i]); diff --git a/packages/harness/lib/knowledge/admin.mjs b/packages/harness/lib/knowledge/admin.mjs index f681d1f9..2c785f16 100644 --- a/packages/harness/lib/knowledge/admin.mjs +++ b/packages/harness/lib/knowledge/admin.mjs @@ -25,7 +25,7 @@ import { } from './store.mjs'; import { rebuildIndex, todayClamped } from './apply.mjs'; import { consolidateStatus, LEARNING_BYTE_CAP, isActiveFm } from './consolidate.mjs'; -import { listBuckets, branchesRoot } from './overlay.mjs'; +import { listBuckets, branchesRoot, bucketDirFor } from './overlay.mjs'; import { scanSecrets } from '../secret-scan.mjs'; import { assertNoSymlinkAncestors, assertRealpathContained, writeFileContained } from '../fs-safe.mjs'; import { runIndexKnowledge } from '../index-knowledge.mjs'; @@ -239,7 +239,13 @@ export function absorbHandEdits({ workspace, home, log = () => {} }) { const at = todayClamped(); const absorbed = []; const deleted = []; - const ledgerEntries = []; + // Per-layer bookkeeping (blueprint §5a): a bucket hand edit's ledger + // evidence belongs in ITS root's consolidated.jsonl, and its bucket + // INDEX.md needs rebuilding too — same per-layer routing purgeEpisode and + // rebuildStore already do. Governance stays store-rooted: the single + // ledger binds both layers (§4). + const ledgerByRoot = new Map(); + const touchedBucketRoots = new Set(); for (const line of lines) { const { status: code, path: rel } = parsePorcelainLine(line); @@ -251,10 +257,12 @@ export function absorbHandEdits({ workspace, home, log = () => {} }) { // provenance names which layer the human touched. const [, bucketKey, domain, slug] = m; const id = `${domain}/${slug}`; + const layerRoot = bucketKey ? bucketDirFor(dir, bucketKey) : dir; if (code.includes('D')) { // Human deletion always wins — nothing left to parse or re-render. deleted.push(id); + if (bucketKey) touchedBucketRoots.add(layerRoot); continue; } if (!code.includes('M')) continue; // untracked/other — out of absorb scope @@ -313,7 +321,8 @@ export function absorbHandEdits({ workspace, home, log = () => {} }) { snapshot = snapRel.split(path.sep).join('/'); const sha256 = crypto.createHash('sha256').update(doc).digest('hex'); fm.episodes = [...(fm.episodes || []), { path: snapshot, sha256, kind: 'human-teaching', plan: null }]; - ledgerEntries.push({ path: snapshot, sha256, learning: id, at }); + if (!ledgerByRoot.has(layerRoot)) ledgerByRoot.set(layerRoot, []); + ledgerByRoot.get(layerRoot).push({ path: snapshot, sha256, learning: id, at }); } } @@ -327,11 +336,12 @@ export function absorbHandEdits({ workspace, home, log = () => {} }) { } fs.writeFileSync(file, content, 'utf8'); absorbed.push({ id, snapshot }); + if (bucketKey) touchedBucketRoots.add(layerRoot); } if (!absorbed.length && !deleted.length) return empty; - if (ledgerEntries.length) appendLedger(dir, ledgerEntries); + for (const [root, entries] of ledgerByRoot) appendLedger(root, entries); // Governance record (Milestone 4): a human deleting a learning file // directly is a retirement just as much as `learning retire` — recorded // here so it survives a later `consolidate --rebuild`. Appended before the @@ -345,6 +355,11 @@ export function absorbHandEdits({ workspace, home, log = () => {} }) { appendGovernance(dir, { id, action: 'retire', reason: 'hand deletion (absorbed)', to: null, at: governanceAt }); } rebuildIndex(dir); + // existsSync guard: a human may have deleted the whole bucket directory, + // not just a learning file inside it — nothing left to rebuild there. + for (const root of touchedBucketRoots) { + if (fs.existsSync(root)) rebuildIndex(root); + } const ids = [...absorbed.map((a) => a.id), ...deleted].join(', '); const commitRes = commitStore(dir, `human edit: ${ids}`); if (!commitRes.ok) { @@ -1047,7 +1062,11 @@ export function rebuildStore({ workspace, home, yes, copilotHome, log = () => {} // even for the store's own existence, not just its contents. listLearnings // only runs on this (preview) path, once. const storePath = storeDir(workspace, { home }); - const archivedPreview = fs.existsSync(storePath) ? listLearnings(storePath).length : 0; + // Preview counts what the wipe below actually archives: golden learnings + // PLUS every bucket's (blueprint §5a) — golden alone under-counts. + const archivedPreview = fs.existsSync(storePath) + ? listLearnings(storePath).length + listBuckets(storePath).reduce((n, b) => n + listLearnings(b.dir).length, 0) + : 0; return { pass: false, exitCode: 2, diff --git a/packages/harness/lib/knowledge/apply.mjs b/packages/harness/lib/knowledge/apply.mjs index 9f11a774..456fe28e 100644 --- a/packages/harness/lib/knowledge/apply.mjs +++ b/packages/harness/lib/knowledge/apply.mjs @@ -1747,14 +1747,19 @@ export function applyOps({ : disputes.length ? `dispute ${disputes.map((d) => d.target).join(', ')}` : 'noop'; + // Promotion mode reports the layer it actually WROTE — golden, always — + // never the write-time routing of the branch the CLI happens to run from, + // and its commit suffix names the promotion source bucket instead. return { kind: 'success', applied, rejected, governed, - layer: routing.layer, - bucketKey: routing.layer === 'branch' ? routing.bucketKey : null, - commitMessage: `consolidate: ${summary}${routing.layer === 'branch' ? ` [${routing.bucketKey}]` : ''}`, + layer: promotionMode ? 'golden' : routing.layer, + bucketKey: !promotionMode && routing.layer === 'branch' ? routing.bucketKey : null, + commitMessage: `consolidate: ${summary}${ + promotionMode ? ` [promote ${promotion.branchKey}]` : routing.layer === 'branch' ? ` [${routing.bucketKey}]` : '' + }`, }; } diff --git a/packages/harness/lib/knowledge/consolidate.mjs b/packages/harness/lib/knowledge/consolidate.mjs index 6ef57a47..4ea1a5c5 100644 --- a/packages/harness/lib/knowledge/consolidate.mjs +++ b/packages/harness/lib/knowledge/consolidate.mjs @@ -254,7 +254,7 @@ function layerView({ workspace, home, dir }) { routing = null; } } - const layer = routing?.layer === 'branch' ? 'branch' : 'golden'; + const layer = routing?.layer === 'branch' && routing.bucketKey ? 'branch' : 'golden'; const bucketKey = layer === 'branch' ? routing.bucketKey : null; const layerRoot = layer === 'branch' ? bucketDirFor(dir, bucketKey) : dir; return { @@ -289,7 +289,10 @@ export function consolidateStatus({ workspace, copilotHome, home }) { .filter((e) => episodeEligibleForLayer(e.branch, view.eligibility)) .filter((e) => !consumed.has(`${e.path}@${e.sha256}`)) .map(({ path: p, sha256, kind, title }) => ({ path: p, sha256, kind, title })); - const learnings = listLearnings(dir); + // The learning-facing sections mirror the ROUTED write layer (layerRoot), + // matching applyOps' own validation target — a branch lane reports the + // bucket's learnings/domains/promotion candidates, never golden's. + const learnings = listLearnings(view.layerRoot); const active = activeLearnings(learnings); const debt = unconsolidated.length; // Consolidation writes (hints toward --apply) are gated to 'on'/'suggest' — diff --git a/packages/harness/lib/knowledge/layer.mjs b/packages/harness/lib/knowledge/layer.mjs index 8da1ea96..a4080d87 100644 --- a/packages/harness/lib/knowledge/layer.mjs +++ b/packages/harness/lib/knowledge/layer.mjs @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { deriveGitContext, resolveDefaultBranch, isDetachedKey } from '../git-context.mjs'; -import { branchesRoot, bucketDirFor, readBucketMeta, listBuckets, bucketAncestryOk } from './overlay.mjs'; +import { branchesRoot, bucketDirFor, listBuckets, bucketAncestryOk } from './overlay.mjs'; import { readSession } from '../session.mjs'; /** @@ -120,7 +120,16 @@ export function branchExists(workspace, branch) { if (!branch) return null; const refs = listRefs(workspace); if (refs === null) return null; - return refs.some((r) => r === `refs/heads/${branch}` || (r.startsWith('refs/remotes/') && r.endsWith(`/${branch}`))); + return refs.some((r) => { + if (r === `refs/heads/${branch}`) return true; + // A remote ref must be exactly `refs/remotes//` — one + // remote-name segment, then the FULL branch name. A bare endsWith + // over-matches: refs/remotes/origin/release/main is not branch 'main'. + if (!r.startsWith('refs/remotes/')) return false; + const rest = r.slice('refs/remotes/'.length); + const slash = rest.indexOf('/'); + return slash !== -1 && rest.slice(slash + 1) === branch; + }); } /** @@ -149,13 +158,18 @@ export function migrateRenamedBucket(dir, { workspace, context }) { const [source] = candidates; const target = bucketDirFor(dir, context.branchKey); try { - fs.renameSync(source.dir, target); - const meta = readBucketMeta(target) || {}; + // Rewrite the meta cache in the SOURCE dir first, THEN rename: if the + // meta write throws, the bucket has not moved yet, so nothing is left + // migrated-but-unrecorded with a stale meta.branch. (Meta is a cache, + // never authority — a failed rename leaving updated meta under the old + // key is the recoverable orphan `knowledge status`/doctor K5 surface.) + const meta = source.meta || {}; fs.writeFileSync( - path.join(target, 'meta.json'), + path.join(source.dir, 'meta.json'), JSON.stringify({ ...meta, branch: context.branch, branchKey: context.branchKey }) + '\n', 'utf8' ); + fs.renameSync(source.dir, target); return { migrated: true, from: source.key, to: context.branchKey }; } catch { return null; diff --git a/packages/harness/lib/knowledge/overlay.mjs b/packages/harness/lib/knowledge/overlay.mjs index 225054ac..afb99596 100644 --- a/packages/harness/lib/knowledge/overlay.mjs +++ b/packages/harness/lib/knowledge/overlay.mjs @@ -105,7 +105,10 @@ export function bucketAncestryOk(workspace, meta) { timeout: 10_000, }); if (res.error) return null; - return res.status === 0 ? true : false; + // A signal-killed git (status null, e.g. a timeout) proved nothing — + // unverifiable, never "proven not an ancestor". + if (typeof res.status !== 'number') return null; + return res.status === 0; } catch { return null; } diff --git a/packages/harness/lib/knowledge/promote.mjs b/packages/harness/lib/knowledge/promote.mjs index 3b7cb2f8..08df697b 100644 --- a/packages/harness/lib/knowledge/promote.mjs +++ b/packages/harness/lib/knowledge/promote.mjs @@ -3,7 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { storeDir, listLearnings, readGovernance } from './store.mjs'; import { isActiveFm, MAX_OPS_PER_RUN } from './consolidate.mjs'; -import { bucketDirFor, readBucketMeta, listBuckets } from './overlay.mjs'; +import { bucketDirFor, readBucketMeta, listBuckets, bucketAncestryOk } from './overlay.mjs'; import { deriveGitContext, isDetachedKey } from '../git-context.mjs'; /** @@ -50,6 +50,13 @@ export function buildPromotionOps({ workspace, home, branchKey = null, ids = nul if (!key) { return { pass: false, exitCode: 2, opsPath: null, ops: 0, remaining: 0, skipped: [], blockedReason: 'no branch bucket resolvable — pass --branch (see harness knowledge status)' }; } + // Path-safety: a bucket key is a plain directory name under branches/, + // never a path — same shape check apply.mjs enforces on the promotion + // envelope's branchKey, applied here so an explicit --branch value can + // never traverse outside the store via bucketDirFor's path.join. + if (/[\\/]|\.\./.test(key) || key === '.' || path.isAbsolute(key)) { + return { pass: false, exitCode: 2, opsPath: null, ops: 0, remaining: 0, skipped: [], blockedReason: `invalid branch key ${key} — bucket keys are plain directory names (see harness knowledge status)` }; + } if (isDetachedKey(key)) { return { pass: false, exitCode: 2, opsPath: null, ops: 0, remaining: 0, skipped: [], blockedReason: `${key} is a detached-HEAD bucket — never promotable (derived from the key shape)` }; } @@ -67,6 +74,24 @@ export function buildPromotionOps({ workspace, home, branchKey = null, ids = nul }; } + // Ancestry gate (P7): a bucket whose recorded base PROVABLY shares no + // history with the current HEAD (force-push branch-name reuse) is excluded + // from the overlay — it must never promote to golden either. Matching the + // read path's semantics, only a verified `false` refuses; `null` + // (unverifiable — no recorded base, or git unavailable) stays allowed. + const bucketMeta = readBucketMeta(bucketDir); + if (bucketAncestryOk(workspace, bucketMeta) === false) { + return { + pass: false, + exitCode: 2, + opsPath: null, + ops: 0, + remaining: 0, + skipped: [], + blockedReason: `bucket ${key} has unrelated history — its recorded base is not an ancestor of HEAD (branch-name reuse); prune it instead: harness knowledge prune --branch ${key}`, + }; + } + const requested = ids && ids.length ? new Set(ids) : null; if (!requested && !all) { return { pass: false, exitCode: 2, opsPath: null, ops: 0, remaining: 0, skipped: [], blockedReason: 'promote needs --ids a,b or --all' }; @@ -134,7 +159,7 @@ export function buildPromotionOps({ workspace, home, branchKey = null, ids = nul const opset = { schema: 1, - promotion: { branchKey: key, meta: readBucketMeta(bucketDir), digest: promotionDigest(chunk) }, + promotion: { branchKey: key, meta: bucketMeta, digest: promotionDigest(chunk) }, ops: chunk, }; const opsFull = path.join(workspace, PROMOTE_OPS_REL); diff --git a/packages/harness/lib/knowledge/prune.mjs b/packages/harness/lib/knowledge/prune.mjs index 18b73dd2..ae20366f 100644 --- a/packages/harness/lib/knowledge/prune.mjs +++ b/packages/harness/lib/knowledge/prune.mjs @@ -51,68 +51,78 @@ export function pruneBuckets({ workspace, home, branchKey = null, merged = false if (!branchKey && !merged && staleDays === null) { return { pass: false, exitCode: 2, removed: [], blockedReason: 'prune needs --branch , --merged, or --stale ' }; } + // Boundary validation for direct callers (the CLI's flag parser validates + // too): a fractional or non-numeric staleDays would silently shift the + // cutoff and prune the wrong buckets. + if (staleDays !== null && !(Number.isSafeInteger(staleDays) && staleDays > 0)) { + return { pass: false, exitCode: 2, removed: [], blockedReason: `--stale needs a positive whole number of days (got ${staleDays})` }; + } const dir = storeDir(workspace, { home }); if (!fs.existsSync(dir)) { return { pass: false, exitCode: 2, removed: [], blockedReason: 'nothing to prune — no knowledge store yet' }; } - const buckets = listBuckets(dir); - if (!buckets.length) { - return { pass: false, exitCode: 2, removed: [], blockedReason: 'nothing to prune — no branch buckets exist' }; - } - - const selected = new Map(); - if (branchKey) { - const hit = buckets.find((b) => b.key === branchKey); - if (!hit) { - return { - pass: false, - exitCode: 2, - removed: [], - blockedReason: `no bucket ${branchKey} — known buckets: ${buckets.map((b) => b.key).join(', ')}`, - }; - } - selected.set(hit.key, hit); - } - if (merged) { - const defaultBranch = resolveDefaultBranch(workspace, { home }); - const mergedSet = mergedBranches(workspace, defaultBranch); - if (mergedSet === null) { - return { - pass: false, - exitCode: 2, - removed: [], - blockedReason: 'cannot determine merged branches — default branch unresolvable (set store config.json defaultBranch or origin/HEAD)', - }; - } - for (const b of buckets) { - if ((b.meta?.branch && mergedSet.has(b.meta.branch)) || fullyPromoted(b)) selected.set(b.key, b); - } - } - if (staleDays !== null) { - const cutoff = Date.now() - staleDays * 86_400_000; - for (const b of buckets) { - const createdAt = b.meta?.createdAt ? Date.parse(b.meta.createdAt) : NaN; - if (!Number.isNaN(createdAt) && createdAt < cutoff) selected.set(b.key, b); - } - } - if (!selected.size) { - return { pass: false, exitCode: 2, removed: [], blockedReason: 'no buckets match the given selectors — nothing pruned' }; - } - - const keys = [...selected.keys()].sort(); - const tx = withStoreTransaction(workspace, { home, label: `knowledge: prune ${keys.join(', ')}` }, ({ recordCheckpoint }) => { + // Bucket discovery and selector evaluation both run INSIDE the transaction, + // under the store lock — never before it — so a concurrent writer landing + // fresh learnings in a same-key bucket can't race a stale pre-lock selection + // into deleting them (TOCTOU). Only flag validation stays outside. + const tx = withStoreTransaction(workspace, { home, label: 'knowledge: prune' }, ({ dir: txDir, recordCheckpoint }) => { try { absorbOrAbort({ workspace, home, log, recordCheckpoint }); } catch (err) { if (err instanceof StoreTransactionAbort) throw err; // best effort — any other absorb hiccup never blocks a human prune. } + + const buckets = listBuckets(txDir); + if (!buckets.length) { + return { kind: 'reject', exitCode: 2, blockedReason: 'nothing to prune — no branch buckets exist' }; + } + + const selected = new Map(); + if (branchKey) { + const hit = buckets.find((b) => b.key === branchKey); + if (!hit) { + return { + kind: 'reject', + exitCode: 2, + blockedReason: `no bucket ${branchKey} — known buckets: ${buckets.map((b) => b.key).join(', ')}`, + }; + } + selected.set(hit.key, hit); + } + if (merged) { + const defaultBranch = resolveDefaultBranch(workspace, { home }); + const mergedSet = mergedBranches(workspace, defaultBranch); + if (mergedSet === null) { + return { + kind: 'reject', + exitCode: 2, + blockedReason: 'cannot determine merged branches — default branch unresolvable (set store config.json defaultBranch or origin/HEAD)', + }; + } + for (const b of buckets) { + if ((b.meta?.branch && mergedSet.has(b.meta.branch)) || fullyPromoted(b)) selected.set(b.key, b); + } + } + if (staleDays !== null) { + const cutoff = Date.now() - staleDays * 86_400_000; + for (const b of buckets) { + const createdAt = b.meta?.createdAt ? Date.parse(b.meta.createdAt) : NaN; + if (!Number.isNaN(createdAt) && createdAt < cutoff) selected.set(b.key, b); + } + } + + if (!selected.size) { + return { kind: 'reject', exitCode: 2, blockedReason: 'no buckets match the given selectors — nothing pruned' }; + } + + const keys = [...selected.keys()].sort(); for (const b of selected.values()) { fs.rmSync(b.dir, { recursive: true, force: true }); log(`pruned bucket ${b.key}${b.meta?.branch ? ` (${b.meta.branch})` : ''}`); } - return { kind: 'success', commitMessage: `knowledge: prune ${keys.join(', ')}` }; + return { kind: 'success', commitMessage: `knowledge: prune ${keys.join(', ')}`, keys }; }); if (!tx.ok) { @@ -126,10 +136,20 @@ export function pruneBuckets({ workspace, home, branchKey = null, merged = false ...(tx.staleLockNote ? { staleLockRemoved: tx.staleLockNote } : {}), }; } + const inner = tx.result; + if (inner.kind === 'reject') { + return { + pass: false, + exitCode: inner.exitCode, + removed: [], + blockedReason: inner.blockedReason, + ...(tx.staleLockNote ? { staleLockRemoved: tx.staleLockNote } : {}), + }; + } return { pass: true, exitCode: 0, - removed: keys, + removed: inner.keys, blockedReason: null, ...(tx.staleLockNote ? { staleLockRemoved: tx.staleLockNote } : {}), }; diff --git a/packages/harness/lib/knowledge/store.mjs b/packages/harness/lib/knowledge/store.mjs index 7c9bd524..cb3bb5fc 100644 --- a/packages/harness/lib/knowledge/store.mjs +++ b/packages/harness/lib/knowledge/store.mjs @@ -489,7 +489,8 @@ export function episodeLines(episodes) { * through `inertLine`). Absent/invalid fields render nothing — a legacy * artifact without them never errors and never gains fabricated values. */ -const PROVENANCE_SHA_RE = /^[0-9a-f]{40}$/; +// Both git object formats: 40-hex (SHA-1) and 64-hex (SHA-256 repos). +const PROVENANCE_SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; const PROVENANCE_BRANCH_CAP = 200; export function provenanceLines({ commit, branch, base } = {}) { diff --git a/packages/harness/lib/policy.mjs b/packages/harness/lib/policy.mjs index 71fb5f48..061a96f3 100644 --- a/packages/harness/lib/policy.mjs +++ b/packages/harness/lib/policy.mjs @@ -8,6 +8,9 @@ const MODES = new Set(['observe', 'warn', 'enforce']); // v1 behavior (a failed check fails verification), `warn` degrades a failure // to an inconclusive (warn-exit) outcome, and `advisory` reports without ever // affecting outcome or exit code. Absent entry → the check's built-in default. +// `checks:` is honored version-independently: a `version: 1` policy that adds +// a `checks:` map gets the same severity behavior — the version field records +// which schema the file was written against, not a feature gate. export const CHECK_SEVERITIES = new Set(['advisory', 'warn', 'enforce']); const POLICY_VERSIONS = new Set([1, 2]); @@ -64,9 +67,12 @@ export function loadPolicy(workspace, override = null) { }; } -/** Effective severity for a verify check: policy entry, else the check's built-in default. */ +/** Effective severity for a verify check: policy entry, else the check's built-in default. + * Own-property check only: ids like `constructor`/`toString` must fall through + * to the default instead of resolving Object.prototype members. */ export function checkSeverityFor(policy, id, defaultSeverity = 'enforce') { - return policy?.checkSeverities?.[id] ?? defaultSeverity; + const configured = policy?.checkSeverities; + return configured && Object.hasOwn(configured, id) ? configured[id] : defaultSeverity; } export function enforcementExitCode(outcome, enforcement) { diff --git a/packages/harness/lib/repo-map/index.mjs b/packages/harness/lib/repo-map/index.mjs index 3c236032..c458be77 100644 --- a/packages/harness/lib/repo-map/index.mjs +++ b/packages/harness/lib/repo-map/index.mjs @@ -63,8 +63,8 @@ export function buildRepoMap({ workspace, query = '', maxTokens = DEFAULT_MAX_TO const hay = new Set(tokenize(`${f.rel} ${f.symbols.join(' ')}`)); for (const t of queryTokens) if (hay.has(t)) queryScore += 1; } - const structural = f.importedBy * 2 + Math.min(f.symbols.length, 12); - return { ...f, score: queryScore * 5 + structural }; + const degreeScore = f.importedBy * 2 + Math.min(f.symbols.length, 12); + return { ...f, score: queryScore * 5 + degreeScore }; }); // Locale-independent tie-break: the committed map must be byte-identical // across hosts for the same tree. diff --git a/packages/harness/lib/repo-map/structural-index.mjs b/packages/harness/lib/repo-map/structural-index.mjs index 4493eb23..8311b7a7 100644 --- a/packages/harness/lib/repo-map/structural-index.mjs +++ b/packages/harness/lib/repo-map/structural-index.mjs @@ -18,6 +18,14 @@ // Extracted names/locations are UNTRUSTED repo text: every string passes // redactSecrets + a length cap at index-WRITE time here, and every human or // agent render additionally passes inertLine (renderStructuralDigest). +// +// TWO READERS, ONE CONTRACT: `readStructuralIndex` here is the builder-side +// tolerant reader for orient/buildRepoMap — raw tables, null when absent, +// with `readStructuralIndexIfCurrent` gating on meta.sha. The verify/doctor +// consumers instead read through lib/structural/shape.mjs, which adds an +// explicit `{ present, reason }` skip signal, sha-shape validation, and +// normalization of both accepted on-disk encodings. Both readers share the +// fs-safe readFileNoFollow discipline (no-follow, dir-contained, size-capped). import fs from 'node:fs'; import path from 'node:path'; @@ -167,7 +175,11 @@ function sanitizeEntry(res, { hash, mtime, size }) { } function buildSymbolTable(files) { - const symbols = {}; + // Null prototype: symbol names are untrusted repo text, and a repo defining + // `constructor`, `__proto__`, or `toString` must land as an ordinary own + // key, not resolve to an inherited Object.prototype member (which would + // make `.defs` access throw and abort indexing). + const symbols = Object.create(null); const rels = Object.keys(files).sort(); for (const rel of rels) { for (const d of files[rel].defs) { @@ -254,12 +266,14 @@ function symbolDelta(priorSymbols, nextSymbols) { const added = []; const removed = []; const changed = []; + // Own-key membership only: `prior` comes from JSON.parse (Object prototype + // intact), so `'constructor' in prior` would be true for every table. for (const name of Object.keys(nextSymbols)) { - if (!(name in prior)) added.push(name); + if (!Object.hasOwn(prior, name)) added.push(name); else if (JSON.stringify(prior[name].defs) !== JSON.stringify(nextSymbols[name].defs)) changed.push(name); } for (const name of Object.keys(prior)) { - if (!(name in nextSymbols)) removed.push(name); + if (!Object.hasOwn(nextSymbols, name)) removed.push(name); } const cap = (list) => ({ count: list.length, names: list.sort().slice(0, MAX_DELTA_NAMES) }); return { added: cap(added), removed: cap(removed), changed: cap(changed) }; diff --git a/packages/harness/lib/repo-map/treesitter-extractor.mjs b/packages/harness/lib/repo-map/treesitter-extractor.mjs index e5a35680..ab4adfb9 100644 --- a/packages/harness/lib/repo-map/treesitter-extractor.mjs +++ b/packages/harness/lib/repo-map/treesitter-extractor.mjs @@ -99,6 +99,10 @@ export function loadGrammarsLock({ lockPath = DEFAULT_LOCK_PATH } = {}) { try { const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')); if (!lock || typeof lock !== 'object' || !lock.grammars) return null; + // Consumers dereference lock.runtime.package/.file directly — validate the + // runtime block here so a truncated lock reads as absent, never as a throw. + const rt = lock.runtime; + if (!rt || typeof rt !== 'object' || typeof rt.package !== 'string' || typeof rt.file !== 'string') return null; return lock; } catch { return null; diff --git a/packages/harness/lib/report.mjs b/packages/harness/lib/report.mjs index b342adfa..22508a65 100644 --- a/packages/harness/lib/report.mjs +++ b/packages/harness/lib/report.mjs @@ -174,18 +174,31 @@ export function knowledgeSlos(events) { // is noise, not utilization, so it must not inflate the weighted rate. const citedIdOccurrences = []; let consolidations = 0; let humanActions = 0; - // Layer attribution (branch-local vs golden): orient events record a - // learningLayers map only when a branch-bucket learning surfaced; an id's - // latest recorded layer wins. Absent everywhere → no split is reported. - const layerById = new Map(); + // Layer attribution (branch-local vs golden): orient events record + // learningLayers only when a branch-bucket learning surfaced. Current shape + // is per-occurrence entries [{id, layer}] — the protected-shadow overlay + // can surface golden AND branch entries with the SAME id, so an id can + // legitimately belong to both layer sets; an id-keyed map would drop one + // side. Legacy id-keyed maps from older event files are still read. + // Absent everywhere → no split is reported. + const layersById = new Map(); // id -> Set of layers the id surfaced from let anyLayerInfo = false; + const recordLayer = (id, layer) => { + if (!layersById.has(id)) layersById.set(id, new Set()); + layersById.get(id).add(layer === 'branch' ? 'branch' : 'golden'); + }; for (const e of events) { if (e.type === 'orient' && Array.isArray(e.learnings)) { e.learnings.forEach((id) => surfaced.add(id)); surfacedOccurrences += e.learnings.length; - if (e.learningLayers && typeof e.learningLayers === 'object') { + if (Array.isArray(e.learningLayers)) { + anyLayerInfo = true; + for (const entry of e.learningLayers) { + if (entry && typeof entry.id === 'string') recordLayer(entry.id, entry.layer); + } + } else if (e.learningLayers && typeof e.learningLayers === 'object') { anyLayerInfo = true; - for (const [id, layer] of Object.entries(e.learningLayers)) layerById.set(id, layer === 'branch' ? 'branch' : 'golden'); + for (const [id, layer] of Object.entries(e.learningLayers)) recordLayer(id, layer); } } if (e.type === 'verify' && Array.isArray(e.learnings)) { @@ -198,15 +211,20 @@ export function knowledgeSlos(events) { const citedSurfaced = [...cited].filter((id) => surfaced.has(id)).length; const citedOccurrences = citedIdOccurrences.filter((id) => surfaced.has(id)).length; // Per-layer split (blueprint Phase 2, report/SLO layer split): unique-id - // based, attributed by each id's recorded layer (default golden). Only - // present once any layer info exists, so pre-bucket reports are unchanged. + // based, attributed to EVERY layer an id surfaced from (default golden), so + // a protected-shadow pair counts under both layers rather than losing one. + // Sums can therefore exceed the unique `surfaced` total by the number of + // dual-layer ids. Only present once any layer info exists, so pre-bucket + // reports are unchanged. let layers; if (anyLayerInfo) { layers = { golden: { surfaced: 0, cited: 0 }, branch: { surfaced: 0, cited: 0 } }; for (const id of surfaced) { - const layer = layerById.get(id) || 'golden'; - layers[layer].surfaced += 1; - if (cited.has(id)) layers[layer].cited += 1; + const idLayers = layersById.get(id) || new Set(['golden']); + for (const layer of idLayers) { + layers[layer].surfaced += 1; + if (cited.has(id)) layers[layer].cited += 1; + } } } return { surfaced: surfaced.size, cited: cited.size, citedSurfaced, diff --git a/packages/harness/lib/structural/expectations.mjs b/packages/harness/lib/structural/expectations.mjs index f6a4190f..ca2a029f 100644 --- a/packages/harness/lib/structural/expectations.mjs +++ b/packages/harness/lib/structural/expectations.mjs @@ -3,10 +3,10 @@ // can escalate to warn/enforce); a missing or stale structural index skips // rather than guessing, so this check can never invent a failure. -import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { extract, SOURCE_EXTENSIONS } from '../repo-map/lexical-extractor.mjs'; +import { readFileSafe } from '../repo-map/scan.mjs'; import { matchesScope, parseImpactedFiles } from '../plan-scope.mjs'; import { readStructuralIndex } from './shape.mjs'; @@ -32,7 +32,13 @@ function symbolFile(qualified) { return at === -1 ? String(qualified || '') : String(qualified).slice(0, at); } -/** Per-changed-file structural diff against the baseline index. */ +/** Per-changed-file structural diff against the baseline index. + * Returns `{ diffs, tierSkipped }`: `tierSkipped` maps files whose baseline + * entry was built by a non-lexical extractor tier — the current side of the + * diff is ALWAYS the lexical extractor, so comparing against a treesitter + * baseline would disagree on unchanged code and fabricate added/removed + * findings. Those files are skipped honestly (reported as informational + * `tier-mismatch-skipped`), never diffed. */ function diffChangedFiles({ workspace, index, changedFiles }) { const rowsByFile = new Map(); for (const row of index.symbols) { @@ -42,17 +48,28 @@ function diffChangedFiles({ workspace, index, changedFiles }) { } const diffs = new Map(); + const tierSkipped = new Map(); for (const file of changedFiles) { const ext = path.extname(file).toLowerCase(); const rows = rowsByFile.get(file) || []; const fileEntry = index.files[file]; if (!SOURCE_EXTENSIONS.has(ext) && !fileEntry && rows.length === 0) continue; + // Per-file tier gate: only a lexical-tier (or untiered legacy/fixture) + // baseline entry diffs soundly against the lexical current side. + const tier = typeof fileEntry?.tier === 'string' ? fileEntry.tier : null; + if (tier && tier !== 'lexical') { + tierSkipped.set(file, tier); + continue; + } - const full = path.join(workspace, file); + // readFileSafe: symlink-safe (ancestor walk + no-follow leaf, contained in + // the workspace) and size-capped — a committed symlink or oversized file + // reads as empty, exactly like a deleted file. let current = []; - if (fs.existsSync(full)) { + const content = readFileSafe(workspace, file); + if (content) { try { - current = extract(file, fs.readFileSync(full, 'utf8')).symbols; + current = extract(file, content).symbols; } catch { current = []; } @@ -72,7 +89,7 @@ function diffChangedFiles({ workspace, index, changedFiles }) { currentSet, }); } - return diffs; + return { diffs, tierSkipped }; } function survivingCallers({ index, file, symbol, changedSet }) { @@ -100,7 +117,7 @@ function expectationObserved(expectation, diffs) { return diff.baselineNames.has(expectation.symbol) && diff.currentSet.has(expectation.symbol); } -function evaluateExpectations(plan, diffs) { +function evaluateExpectations(plan, diffs, tierSkipped = new Map()) { const raw = plan.fm?.structural_expectations; const findings = []; const informational = []; @@ -120,6 +137,18 @@ function evaluateExpectations(plan, diffs) { informational.push({ type: 'malformed-expectation', entry, message: 'expected {file, symbol, change: added|removed|modified}' }); continue; } + // A tier-skipped file has no diff to evaluate against — the expectation is + // unverifiable here, never a fabricated failure. + if (tierSkipped.has(entry.file)) { + informational.push({ + type: 'tier-mismatch-skipped', + file: entry.file, + symbol: entry.symbol, + change: entry.change, + message: `expectation on ${entry.file} not evaluated: baseline entry tier '${tierSkipped.get(entry.file)}' does not match the lexical current side`, + }); + continue; + } if (expectationObserved(entry, diffs)) continue; const description = { type: 'unmet-expectation', file: entry.file, symbol: entry.symbol, change: entry.change }; // Only expectations explicitly marked required can fail the check; the @@ -159,7 +188,15 @@ export function runStructuralExpectations({ workspace, plan, changedFiles, home const changed = [...new Set(changedFiles || [])]; const changedSet = new Set(changed); const allowed = parseImpactedFiles(plan); - const diffs = diffChangedFiles({ workspace, index, changedFiles: changed }); + const { diffs, tierSkipped } = diffChangedFiles({ workspace, index, changedFiles: changed }); + // Per-file tier mismatches surface as informational notes, never findings: + // the skip is honest ("could not compare"), not evidence of a problem. + const tierNotes = [...tierSkipped].map(([file, tier]) => ({ + type: 'tier-mismatch-skipped', + file, + tier, + message: `baseline entry for ${file} was built by the '${tier}' extractor tier; the current side is lexical — symbol diff skipped as unsound`, + })); const findings = []; for (const [file, diff] of diffs) { @@ -173,8 +210,9 @@ export function runStructuralExpectations({ workspace, plan, changedFiles, home } } - const expectations = evaluateExpectations(plan, diffs); + const expectations = evaluateExpectations(plan, diffs, tierSkipped); findings.push(...expectations.findings); + const informational = [...tierNotes, ...expectations.informational]; if (findings.length) { const kinds = [...new Set(findings.map((finding) => finding.type))].join(', '); @@ -182,15 +220,15 @@ export function runStructuralExpectations({ workspace, plan, changedFiles, home status: 'failed', message: `${findings.length} structural finding${findings.length === 1 ? '' : 's'} (${kinds})`, findings, - informational: expectations.informational, + informational, baseline, }; } return { status: 'passed', - message: `Structural diff matches the plan (${diffs.size} file${diffs.size === 1 ? '' : 's'} examined)`, + message: `Structural diff matches the plan (${diffs.size} file${diffs.size === 1 ? '' : 's'} examined${tierSkipped.size ? `, ${tierSkipped.size} tier-mismatch-skipped` : ''})`, findings, - informational: expectations.informational, + informational, baseline, }; } catch (error) { diff --git a/packages/harness/lib/structural/shape.mjs b/packages/harness/lib/structural/shape.mjs index fd7ab431..13acf0d1 100644 --- a/packages/harness/lib/structural/shape.mjs +++ b/packages/harness/lib/structural/shape.mjs @@ -23,17 +23,27 @@ // "unresolved": [ { "from": "#", "to": "" } ] } // meta.json { "version": 1, "sha": "", // "branch": "...", "baseSha": "...", "generatedAt": "", -// "extractorTier": "lexical|tree-sitter", "grammarVersions": {} } +// "extractorTier": "lexical|treesitter", "grammarVersions": {} } // // Read semantics: `meta.json` is mandatory — without it the index is treated // as absent. The other three degrade to empty structures when missing so a // partially written index never crashes a consumer; any malformed JSON marks // the whole index unreadable (consumers skip, never guess). +// +// TWO READERS, ONE CONTRACT: repo-map/structural-index.mjs also exports a +// `readStructuralIndex` — the builder-side tolerant reader used by orient / +// buildRepoMap, which returns the raw on-disk tables (null when absent) and +// gates on meta.sha currency. THIS reader serves the verify/doctor consumers: +// it returns an explicit `{ present, reason, ... }` skip signal, validates the +// baseline sha shape, and NORMALIZES both accepted on-disk encodings to one +// row/edge shape. Both readers use the same no-follow, dir-contained, +// size-capped file reads (fs-safe readFileNoFollow). import fs from 'node:fs'; import path from 'node:path'; import { harnessGlobalHome } from '../paths.mjs'; import { repoId } from '../knowledge/store.mjs'; +import { readFileNoFollow } from '../fs-safe.mjs'; export const STRUCTURAL_SHAPE_VERSION = 1; @@ -49,8 +59,13 @@ export function structuralDir(workspace, { home } = {}) { function readJson(dir, name) { const full = path.join(dir, name); if (!fs.existsSync(full)) return { value: null, error: null }; + // No-follow, contained, size-capped read — same fs-safe discipline as the + // builder-side reader; a symlinked or oversized table is unreadable, never + // followed outside the index dir. + const body = readFileNoFollow(full, { root: dir }); + if (body === null) return { value: null, error: `${name} is unreadable (symlink, oversized, or open failure)` }; try { - const parsed = JSON.parse(fs.readFileSync(full, 'utf8')); + const parsed = JSON.parse(body); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return { value: null, error: `${name} is not a JSON object` }; } diff --git a/packages/harness/test/doctor-structural.test.mjs b/packages/harness/test/doctor-structural.test.mjs index abdb3e65..d4cf36d5 100644 --- a/packages/harness/test/doctor-structural.test.mjs +++ b/packages/harness/test/doctor-structural.test.mjs @@ -8,8 +8,16 @@ import { structuralChecks, runDoctor } from '../lib/doctor.mjs'; import { buildStructuralIndex } from '../lib/repo-map/structural-index.mjs'; import { lexicalV2 } from '../lib/repo-map/treesitter-extractor.mjs'; -function gitRepo(files) { - const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-doctor-s1-')); +// Temp dirs registered for t.after cleanup — a failing assertion must not +// leak the tree (a trailing rmSync never runs on failure). +function tempTree(t, prefix) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + return dir; +} + +function gitRepo(t, files) { + const ws = tempTree(t, 'harness-doctor-s1-'); const git = (args) => spawnSync('git', args, { cwd: ws, @@ -56,8 +64,8 @@ function withHome(t, home) { } test('S1: no index built → advisory pass with the build hint', (t) => { - const { ws } = gitRepo(FIXTURE); - const home = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-home-')); + const { ws } = gitRepo(t, FIXTURE); + const home = tempTree(t, 'harness-home-'); withHome(t, home); const checks = structuralChecks({ workspace: ws }); assert.equal(checks.length, 1); @@ -65,13 +73,11 @@ test('S1: no index built → advisory pass with the build hint', (t) => { assert.equal(checks[0].pass, true); assert.equal(checks[0].optional, true); assert.match(checks[0].hint, /harness index --structural/); - fs.rmSync(ws, { recursive: true, force: true }); - fs.rmSync(home, { recursive: true, force: true }); }); test('S1: current healthy index passes; meta.sha drift and orphans degrade to advisory failure', async (t) => { - const { ws, git } = gitRepo(FIXTURE); - const home = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-home-')); + const { ws, git } = gitRepo(t, FIXTURE); + const home = tempTree(t, 'harness-home-'); withHome(t, home); await buildStructuralIndex({ workspace: ws, home, extractor: extractorWith() }); @@ -95,14 +101,11 @@ test('S1: current healthy index passes; meta.sha drift and orphans degrade to ad assert.equal(stale.pass, false); assert.equal(stale.optional, true); assert.match(stale.hint, /meta\.sha behind HEAD/); - - fs.rmSync(ws, { recursive: true, force: true }); - fs.rmSync(home, { recursive: true, force: true }); }); test('S1: a recorded grammar integrity mismatch is a hard failure, not a warning', async (t) => { - const { ws } = gitRepo(FIXTURE); - const home = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-home-')); + const { ws } = gitRepo(t, FIXTURE); + const home = tempTree(t, 'harness-home-'); withHome(t, home); await buildStructuralIndex({ workspace: ws, @@ -117,13 +120,11 @@ test('S1: a recorded grammar integrity mismatch is a hard failure, not a warning assert.ok(!check.optional, 'integrity mismatch must fail doctor, never warn'); assert.match(check.hint, /sha256 mismatch/); assert.match(check.hint, /javascript/); - fs.rmSync(ws, { recursive: true, force: true }); - fs.rmSync(home, { recursive: true, force: true }); }); test('S1: parse-failure rate over 20% degrades to advisory failure', async (t) => { - const { ws } = gitRepo(FIXTURE); - const home = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-home-')); + const { ws } = gitRepo(t, FIXTURE); + const home = tempTree(t, 'harness-home-'); withHome(t, home); const extractor = extractorWith(); extractor.counters.parseFailures = 1; // 1 of 2 files @@ -132,14 +133,12 @@ test('S1: parse-failure rate over 20% degrades to advisory failure', async (t) = assert.equal(check.pass, false); assert.equal(check.optional, true); assert.match(check.hint, /parse-failure rate/); - fs.rmSync(ws, { recursive: true, force: true }); - fs.rmSync(home, { recursive: true, force: true }); }); test('runDoctor surfaces S1 alongside the existing check families', (t) => { - const { ws } = gitRepo(FIXTURE); - const home = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-home-')); - const copilotHome = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-copilot-')); + const { ws } = gitRepo(t, FIXTURE); + const home = tempTree(t, 'harness-home-'); + const copilotHome = tempTree(t, 'harness-copilot-'); withHome(t, home); const { checks } = runDoctor({ copilotHome, @@ -151,7 +150,4 @@ test('runDoctor surfaces S1 alongside the existing check families', (t) => { assert.ok(s1, 'doctor includes the structural S1 check'); assert.equal(s1.pass, true); assert.equal(s1.optional, true); - fs.rmSync(ws, { recursive: true, force: true }); - fs.rmSync(home, { recursive: true, force: true }); - fs.rmSync(copilotHome, { recursive: true, force: true }); }); diff --git a/packages/harness/test/hand-edits.test.mjs b/packages/harness/test/hand-edits.test.mjs index f94931dc..59c12bc7 100644 --- a/packages/harness/test/hand-edits.test.mjs +++ b/packages/harness/test/hand-edits.test.mjs @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url'; import { test } from 'node:test'; import { applyOps } from '../lib/knowledge/apply.mjs'; import { absorbHandEdits, absorbOrAbort, removeEpisodeLink } from '../lib/knowledge/admin.mjs'; +import { ensureBucket } from '../lib/knowledge/layer.mjs'; import { ensureStore, storeDir, listLearnings, readLedger, parseLearningFrontmatter, serializeLearning, StoreTransactionAbort } from '../lib/knowledge/store.mjs'; const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -447,6 +448,46 @@ test('a hand-deleted learning file is committed as a human deletion and disappea assert.ok(!listLearnings(dir).some((l) => l.id === learningId), 'the deleted learning no longer lists'); }); +test('a bucket hand edit routes its ledger entry and INDEX.md rebuild to the bucket root, never golden', () => { + const c = ctx(); + seedLearning(c); // materializes the store with a git history + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + const bucketDir = ensureBucket(dir, { key: 'feature-x-12345678', branch: 'feature/x' }); + + // A tracked bucket learning (absorb only picks up MODIFIED learning files). + const bucketFile = path.join(bucketDir, 'learnings', 'sql', 'bucket-claim.md'); + fs.mkdirSync(path.dirname(bucketFile), { recursive: true }); + fs.writeFileSync( + bucketFile, + ['---', 'schema: 1', 'trigger: "bucket claim trigger"', 'status: active', 'source: auto', 'episodes:', 'anchors: []', 'superseded_by: null', 'last_confirmed: null', 'origin: t', '---', '', 'Original bucket body.', ''].join('\n'), + 'utf8' + ); + const gitEnv = { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' }; + assert.equal(spawnSync('git', ['add', '-A'], { cwd: dir, encoding: 'utf8', env: gitEnv }).status, 0); + const committed = spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'fixture'], { cwd: dir, encoding: 'utf8', env: gitEnv }); + assert.equal(committed.status, 0, committed.stderr); + + handEditBody(bucketFile, 'A human edited the bucket claim directly on disk.'); + const result = absorbHandEdits({ workspace: c.ws, home: c.harnessHome }); + assert.equal(result.committed, true, result.stderr); + assert.deepEqual(result.absorbed.map((a) => a.id), ['sql/bucket-claim']); + assert.ok(result.absorbed[0].snapshot, 'the hand edit produced a human-teaching snapshot'); + + // Ledger evidence lands in the BUCKET's consolidated.jsonl, not golden's. + assert.ok( + readLedger(bucketDir).some((e) => e.learning === 'sql/bucket-claim' && e.path === result.absorbed[0].snapshot), + 'bucket ledger links the snapshot to the bucket learning' + ); + assert.ok( + !readLedger(dir).some((e) => e.learning === 'sql/bucket-claim'), + 'golden ledger never records the bucket hand edit' + ); + + // The BUCKET INDEX.md is rebuilt to list the learning; golden's is not. + assert.match(fs.readFileSync(path.join(bucketDir, 'INDEX.md'), 'utf8'), /sql\/bucket-claim/); + assert.doesNotMatch(fs.readFileSync(path.join(dir, 'INDEX.md'), 'utf8'), /sql\/bucket-claim/); +}); + test('multiple simultaneous hand edits (one modified, one deleted) absorb into exactly ONE commit naming both ids', () => { const c = ctx(); const editedId = seedLearning(c, { slug: 'edited-one', trigger: 'edited one trigger' }); diff --git a/packages/harness/test/knowledge-promote.test.mjs b/packages/harness/test/knowledge-promote.test.mjs index e5a60b91..cb47fd96 100644 --- a/packages/harness/test/knowledge-promote.test.mjs +++ b/packages/harness/test/knowledge-promote.test.mjs @@ -103,6 +103,10 @@ test('promote emits a reviewable, digest-bound op-set and apply lands it golden // absent from this checkout; evidence re-validates from recorded hashes. const applied = applyOps({ workspace: ws, opsPath: path.join(ws, PROMOTE_OPS_REL), home }); assert.equal(applied.exitCode, 0, JSON.stringify(applied.rejected)); + // Promotion reports the layer it WROTE (golden), not the feature branch the + // CLI runs from. + assert.equal(applied.layer, 'golden'); + assert.equal(applied.bucketKey, null); // Golden now carries the claim. const golden = listLearnings(dir).find((l) => l.id === 'sql/claim-a'); @@ -284,6 +288,63 @@ test('governed and detached sources are refused at emit time', () => { assert.match(detached.blockedReason, /never promotable/); }); +test('path-shaped --branch keys are refused before any path construction', () => { + const ws = featureWorkspace('feature/keyshape'); + const home = tempDir('promo-home9-'); + seedBucketLearning(ws, home, 'safe-claim'); + + for (const key of ['../evil', 'a/b', 'a\\b', '.', path.resolve(os.tmpdir(), 'abs')]) { + const emitted = buildPromotionOps({ workspace: ws, home, branchKey: key, all: true }); + assert.equal(emitted.pass, false, `key ${JSON.stringify(key)} must be refused`); + assert.match(emitted.blockedReason, /invalid branch key/, `key ${JSON.stringify(key)}: ${emitted.blockedReason}`); + } +}); + +test('a bucket whose recorded base is provably not an ancestor of HEAD never promotes (force-push name reuse)', () => { + const ws = featureWorkspace('feature/reused'); + const home = tempDir('promo-home10-'); + seedBucketLearning(ws, home, 'stale-claim'); + const { dir } = ensureStore(ws, { home }); + const key = branchKeyFor('feature/reused'); + + // Simulate branch-name reuse after a force push: the recorded base sha is + // unknown to this repo's history — verified NOT an ancestor. + const metaPath = path.join(bucketDirFor(dir, key), 'meta.json'); + const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); + fs.writeFileSync(metaPath, JSON.stringify({ ...meta, baseSha: 'f'.repeat(40) }) + '\n'); + + const emitted = buildPromotionOps({ workspace: ws, home, all: true }); + assert.equal(emitted.pass, false, 'non-ancestor bucket must not promote'); + assert.match(emitted.blockedReason, /unrelated history/); + assert.match(emitted.blockedReason, /prune/); +}); + +test('prune resolves bucket discovery and selection INSIDE the store transaction (TOCTOU guard)', () => { + // Structural assertion (the interleaving itself is not reproducible in a + // single-process test): every bucket listing call site must sit inside the + // withStoreTransaction callback, so selection happens under the store lock. + const src = fs.readFileSync(new URL('../lib/knowledge/prune.mjs', import.meta.url), 'utf8'); + const txAt = src.indexOf('withStoreTransaction('); + assert.ok(txAt !== -1, 'prune uses withStoreTransaction'); + const callSites = [...src.matchAll(/listBuckets\(/g)].map((m) => m.index); + assert.ok(callSites.length >= 1, 'prune discovers buckets via listBuckets'); + for (const at of callSites) { + assert.ok(at > txAt, 'bucket discovery must happen under the store lock, never before it'); + } +}); + +test('pruneBuckets refuses a non-integer staleDays at its own boundary', () => { + const ws = featureWorkspace('feature/staleness'); + const home = tempDir('promo-home11-'); + seedBucketLearning(ws, home, 'boundary-claim'); + + for (const staleDays of [2.5, 0, -1, NaN]) { + const result = pruneBuckets({ workspace: ws, home, staleDays }); + assert.equal(result.pass, false, `staleDays ${staleDays} must be refused`); + assert.match(result.blockedReason, /positive whole number/, `staleDays ${staleDays}: ${result.blockedReason}`); + } +}); + test('prune removes buckets by key, by merged/tombstoned state, and by staleness in one store commit', () => { const ws = featureWorkspace('feature/prunable'); const home = tempDir('promo-home8-'); diff --git a/packages/harness/test/layer-maintenance.test.mjs b/packages/harness/test/layer-maintenance.test.mjs index a7a5ab62..a7b86907 100644 --- a/packages/harness/test/layer-maintenance.test.mjs +++ b/packages/harness/test/layer-maintenance.test.mjs @@ -21,6 +21,14 @@ function git(cwd, args) { }); } +/** Commit fixture files in the store repo through the config-neutralized + * git() helper (a developer's global gpgsign etc. must never leak in). */ +function commitFixture(dir) { + assert.equal(git(dir, ['add', '-A']).status, 0, 'fixture git add failed'); + const res = git(dir, ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'fixture']); + assert.equal(res.status, 0, `fixture git commit failed: ${res.stderr}`); +} + function gitWorkspace(branch = 'main') { const ws = tempDir('lmaint-ws-'); git(ws, ['init', '-q', '-b', branch]); @@ -76,8 +84,7 @@ test('purge cascades across golden AND bucket layers: files, links, ledgers, and // Ledger entries in both layers. fs.appendFileSync(path.join(dir, 'consolidated.jsonl'), JSON.stringify({ path: ep.rel, sha256: ep.sha256, learning: 'sql/solely-golden', at: '2026-08-01' }) + '\n'); fs.appendFileSync(path.join(bucketDir, 'consolidated.jsonl'), JSON.stringify({ path: ep.rel, sha256: ep.sha256, learning: 'sql/solely-bucket', at: '2026-08-01' }) + '\n'); - spawnSync('git', ['add', '-A'], { cwd: dir }); - spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'fixture'], { cwd: dir }); + commitFixture(dir); const result = purgeEpisode({ workspace: ws, target: ep.rel, copilotHome: tempDir('lmaint-ch-'), home }); assert.equal(result.pass, true, result.blockedReason); @@ -107,8 +114,7 @@ test('purge keeps the governance record while the id survives in ANY layer, drop const bucketDir = ensureBucket(dir, { key, branch: 'feature/gov' }); writeLearning(bucketDir, 'sql/dual', { episodes: [ep] }); appendGovernance(dir, { id: 'sql/dual', action: 'dispute', reason: 'r', to: null, at: new Date().toISOString() }); - spawnSync('git', ['add', '-A'], { cwd: dir }); - spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'fixture'], { cwd: dir }); + commitFixture(dir); // Purging ep removes only the bucket copy — the golden twin survives, so // the governance record must survive with it. @@ -131,8 +137,7 @@ test('purge --all wipes branches/ whole and counts bucket learnings', () => { writeLearning(dir, 'sql/golden-claim'); const bucketDir = ensureBucket(dir, { key: branchKeyFor('feature/wipe'), branch: 'feature/wipe' }); writeLearning(bucketDir, 'sql/bucket-claim'); - spawnSync('git', ['add', '-A'], { cwd: dir }); - spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'fixture'], { cwd: dir }); + commitFixture(dir); const result = purgeAll({ workspace: ws, home }); assert.equal(result.pass, true, result.blockedReason); @@ -150,8 +155,7 @@ test('rebuild --yes wipes each bucket per layer but keeps bucket meta as the lay const bucketDir = ensureBucket(dir, { key, branch: 'feature/rebuild' }); writeLearning(bucketDir, 'sql/bucket-claim'); fs.appendFileSync(path.join(bucketDir, 'consolidated.jsonl'), JSON.stringify({ path: 'x.md', sha256: 'a'.repeat(64), learning: 'sql/bucket-claim', at: '2026-08-01' }) + '\n'); - spawnSync('git', ['add', '-A'], { cwd: dir }); - spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'fixture'], { cwd: dir }); + commitFixture(dir); const result = rebuildStore({ workspace: ws, home, yes: true, copilotHome: tempDir('lmaint-ch4-') }); assert.equal(result.pass, true, result.blockedReason); diff --git a/packages/harness/test/layer-routing.test.mjs b/packages/harness/test/layer-routing.test.mjs index a43ad887..72305358 100644 --- a/packages/harness/test/layer-routing.test.mjs +++ b/packages/harness/test/layer-routing.test.mjs @@ -232,11 +232,10 @@ test('a newer store schema makes this CLI refuse with an upgrade hint', () => { assert.equal(recorded.schema, STORE_SCHEMA); fs.writeFileSync(path.join(dir, 'store.json'), JSON.stringify({ schema: STORE_SCHEMA + 1 }) + '\n'); - assert.throws( - () => ensureStore(ws, { home }), - (err) => err.code === 'E_STORE_SCHEMA' && /newer than this CLI supports/.test(err.message) && /@dev-kit\/harness/.test(err.hint) - ); - assert.throws(() => applyOps({ workspace: ws, opsPath: writeOps(ws, [addOp(ws, { slug: 'nope' })]), home })); + const isSchemaRefusal = (err) => + err.code === 'E_STORE_SCHEMA' && /newer than this CLI supports/.test(err.message) && /@dev-kit\/harness/.test(err.hint); + assert.throws(() => ensureStore(ws, { home }), isSchemaRefusal); + assert.throws(() => applyOps({ workspace: ws, opsPath: writeOps(ws, [addOp(ws, { slug: 'nope' })]), home }), isSchemaRefusal); }); test('branch rename auto-migrates the bucket to the new key when unambiguous', () => { diff --git a/packages/harness/test/layered-overlay.test.mjs b/packages/harness/test/layered-overlay.test.mjs index e7f02b5a..8e3af96e 100644 --- a/packages/harness/test/layered-overlay.test.mjs +++ b/packages/harness/test/layered-overlay.test.mjs @@ -142,8 +142,7 @@ test('a protected golden claim (>=3 fix links or source human) is never shadowed // source: human is equally protected. writeLearning(dir, 'sql/human', { trigger: 'human trigger', body: 'Human golden.', source: 'human' }); - const bucketDir2 = path.join(bucketDir, ''); - writeLearning(bucketDir2, 'sql/human', { trigger: 'human trigger', body: 'Branch challenger.' }); + writeLearning(bucketDir, 'sql/human', { trigger: 'human trigger', body: 'Branch challenger.' }); const again = loadLayeredLearnings({ workspace: ws, home }).learnings; const humanBranch = again.find((l) => l.id === 'sql/human' && l.layer === 'branch'); assert.equal(humanBranch.subordinate, true); diff --git a/packages/harness/test/report.test.mjs b/packages/harness/test/report.test.mjs index 8144aeab..7102063b 100644 --- a/packages/harness/test/report.test.mjs +++ b/packages/harness/test/report.test.mjs @@ -5,7 +5,7 @@ import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { test } from 'node:test'; -import { buildReport, renderReport, recoveryLoops, trendRegression, budgetBreaches, hasBudgetBreach } from '../lib/report.mjs'; +import { buildReport, renderReport, recoveryLoops, trendRegression, budgetBreaches, hasBudgetBreach, knowledgeSlos } from '../lib/report.mjs'; import { usageFields } from '../lib/token-meter.mjs'; const binPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'harness.mjs'); @@ -252,3 +252,43 @@ test('report does not render a partial host usage event as a measured zero', () assert.match(text, /input 150 · output unavailable · partial usage 1\/1 event/); assert.doesNotMatch(text, /^report\s+~0 tokens/m); }); + +test('layer split preserves both attributions for a protected-shadow same-id pair', () => { + // The protected-shadow overlay surfaces golden AND branch entries with one + // id; per-occurrence learningLayers entries must credit BOTH layers. + const events = [ + { + version: 2, + type: 'orient', + session: 's1', + learnings: ['sql/same-id', 'sql/same-id', 'sql/other'], + learningLayers: [ + { id: 'sql/same-id', layer: 'golden' }, + { id: 'sql/same-id', layer: 'branch' }, + { id: 'sql/other', layer: 'branch' }, + ], + }, + { version: 2, type: 'verify', session: 's1', learnings: ['sql/same-id'] }, + ]; + const slos = knowledgeSlos(events); + assert.equal(slos.surfaced, 2); + assert.equal(slos.layers.golden.surfaced, 1, 'golden side of the shadow pair retained'); + assert.equal(slos.layers.branch.surfaced, 2, 'branch side of the pair plus the branch-only id'); + assert.equal(slos.layers.golden.cited, 1); + assert.equal(slos.layers.branch.cited, 1); +}); + +test('layer split still reads legacy id-keyed learningLayers maps', () => { + const events = [ + { + version: 2, + type: 'orient', + session: 's1', + learnings: ['sql/a'], + learningLayers: { 'sql/a': 'branch' }, + }, + ]; + const slos = knowledgeSlos(events); + assert.equal(slos.layers.branch.surfaced, 1); + assert.equal(slos.layers.golden.surfaced, 0); +}); diff --git a/packages/harness/test/store-migration.test.mjs b/packages/harness/test/store-migration.test.mjs index 33e6ba4d..3924da54 100644 --- a/packages/harness/test/store-migration.test.mjs +++ b/packages/harness/test/store-migration.test.mjs @@ -56,7 +56,9 @@ function realFixEpisode(ws, rel) { * suite is about store IDENTITY migration, not layer routing). */ function pinDefaultBranch(c) { - const branch = git(c.ws, ['symbolic-ref', '--short', 'HEAD']).stdout.trim() || 'main'; + const res = git(c.ws, ['symbolic-ref', '--short', 'HEAD']); + const branch = res.stdout.trim(); + assert.ok(branch, `fixture branch unresolvable: ${res.stderr}`); const { dir } = ensureStore(c.ws, { home: c.harnessHome }); fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ mode: 'on', commit: 'none', defaultBranch: branch }) + '\n'); } diff --git a/packages/harness/test/structural-expectations.test.mjs b/packages/harness/test/structural-expectations.test.mjs index 4a6170ba..c927d6aa 100644 --- a/packages/harness/test/structural-expectations.test.mjs +++ b/packages/harness/test/structural-expectations.test.mjs @@ -376,6 +376,34 @@ test('removed exported symbol with a surviving caller is flagged', () => { assert.ok(!removed.some((finding) => finding.symbol === 'helper'), JSON.stringify(removed)); }); +test('a treesitter-tier baseline entry is never diffed against the lexical current side — informational skip, no findings', () => { + const { workspace, home, sha } = structuralWorkspace(); + const baseline = exampleBaseline(sha); + // The baseline entry for example.js was built by the treesitter tier; the + // check's current side is always lexical, so any diff would be unsound. + baseline.files['src/example.js'] = { ...baseline.files['src/example.js'], tier: 'treesitter' }; + writeStructuralIndex(workspace, home, baseline); + // Without the tier gate this removal fabricates removed-symbol findings. + fs.writeFileSync(path.join(workspace, 'src', 'example.js'), 'export const other = 2;\n'); + + const result = runStructuralExpectations({ + workspace, + plan: minimalPlan(['src/example.js'], { + structural_expectations: [{ file: 'src/example.js', symbol: 'other', change: 'added', required: true }], + }), + changedFiles: ['src/example.js'], + home, + }); + assert.equal(result.status, 'passed', JSON.stringify(result.findings)); + assert.deepEqual(result.findings, []); + const notes = result.informational.filter((note) => note.type === 'tier-mismatch-skipped'); + assert.ok(notes.some((note) => note.file === 'src/example.js' && note.tier === 'treesitter'), JSON.stringify(result.informational)); + // A required expectation on the tier-skipped file is unverifiable — it must + // surface informationally, never as a fabricated unmet-required failure. + assert.ok(notes.some((note) => note.symbol === 'other'), JSON.stringify(result.informational)); + assert.match(result.message, /1 tier-mismatch-skipped/); +}); + test('callers that changed in the same diff do not count as surviving', () => { const { workspace, home, sha } = structuralWorkspace(); writeStructuralIndex(workspace, home, exampleBaseline(sha)); @@ -570,6 +598,19 @@ test('policy enforce severity makes a structural failure fail verification (exit assert.deepEqual(failed.map((check) => check.id), [STRUCTURAL_CHECK_ID]); }); +test('global observe enforcement never gates the exit code, but per-check enforce severity still routes the outcome', () => { + const { workspace, home, plan, sha } = structuralWorkspace({ + policy: 'version: 2\nenforcement: observe\nchecks:\n structural-expectations:\n severity: enforce\n', + }); + writeStructuralIndex(workspace, home, exampleBaseline(sha)); + fs.writeFileSync(path.join(workspace, 'src', 'example.js'), 'export const other = 2;\n'); + + const result = withHome(home, () => runVerify({ workspace, flags: verifyFlags(plan) })); + assert.equal(result.outcome, 'failed', 'per-check enforce severity still fails the outcome'); + assert.equal(result.enforcement, 'observe'); + assert.equal(enforcementExitCode(result.outcome, result.enforcement), 0, 'observe mode reports without gating the exit code'); +}); + test('a passing verify run with no structural index behaves as before (v1 compatibility round-trip)', () => { // No structural index written; v1 policy file present. const { workspace, home, plan } = structuralWorkspace({ diff --git a/packages/harness/test/structural-index.test.mjs b/packages/harness/test/structural-index.test.mjs index 709aa787..93bbe51b 100644 --- a/packages/harness/test/structural-index.test.mjs +++ b/packages/harness/test/structural-index.test.mjs @@ -241,6 +241,29 @@ test('secret-shaped extracted names are redacted at index-write time', async () fs.rmSync(home, { recursive: true, force: true }); }); +test('symbols named after Object.prototype members index as ordinary own keys', async () => { + // Regression: a repo defining `constructor` / `__proto__` / `toString` + // must not resolve to inherited prototype members in the symbol table — + // that made `.defs` access throw and aborted the whole build. + const { ws } = gitRepo({ + 'proto.mjs': 'export const constructor = 1;\nexport const __proto__ = 2;\nexport const toString = 3;\n', + }); + const home = tempHome(); + const first = await buildStructuralIndex({ workspace: ws, home, extractor: countingExtractor() }); + assert.equal(first.written, true, 'indexing survives prototype-named symbols'); + const symbols = JSON.parse(fs.readFileSync(path.join(structuralIndexDir(ws, { home }), 'symbols.json'), 'utf8')); + for (const name of ['constructor', '__proto__', 'toString']) { + assert.ok(Object.hasOwn(symbols, name), `${name} recorded as an own key`); + assert.equal(symbols[name].defs.length, 1, `${name} carries exactly its own def`); + } + // The rebuild delta must use own-key membership too: nothing added/removed. + const second = await buildStructuralIndex({ workspace: ws, home, extractor: countingExtractor() }); + assert.deepEqual(second.delta.added.names, []); + assert.deepEqual(second.delta.removed.names, []); + fs.rmSync(ws, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); +}); + test('graph preserves unresolved edges explicitly and never fabricates targets', async () => { const { ws } = gitRepo({ 'a.mjs': "import { b } from './b.mjs';\nimport missing from './nowhere.mjs';\nexport function runA() { b(); ghostCall(); }\n", @@ -317,7 +340,10 @@ test('buildRepoMap prefers a current structural index and is byte-identical with assert.equal(after.body, before.body, 'byte-identical output when no structural index exists'); }); -test('no-network guard: the orient/recall structural read path is model- and network-free', () => { +test('no-network guard: source-text scan of the structural read modules for model/network markers', () => { + // A source-text scan of THESE files only — a tripwire against obvious + // model/network use creeping into the listed modules, not a proof that the + // whole runtime path is network-free. const read = (rel) => fs.readFileSync(path.join(packageRoot, rel), 'utf8'); for (const rel of [ 'lib/repo-map/index.mjs', @@ -330,7 +356,7 @@ test('no-network guard: the orient/recall structural read path is model- and net const src = read(rel); assert.doesNotMatch( src, - /api\.anthropic\.com|openai|fetch\(|getProvider|ANTHROPIC_API_KEY|node:https|node:http'|net\.connect|dns\.lookup|XMLHttpRequest|WebSocket/, + /api\.anthropic\.com|openai|fetch\(|getProvider|ANTHROPIC_API_KEY|node:https?['"]|net\.connect|dns\.lookup|XMLHttpRequest|WebSocket/, `${rel} must be model- and network-free` ); } diff --git a/packages/harness/test/structural-shape-compat.test.mjs b/packages/harness/test/structural-shape-compat.test.mjs index f4b10a53..18bff1a8 100644 --- a/packages/harness/test/structural-shape-compat.test.mjs +++ b/packages/harness/test/structural-shape-compat.test.mjs @@ -16,20 +16,32 @@ import { createTreesitterExtract } from '../lib/repo-map/treesitter-extractor.mj import { readStructuralIndex, structuralDir } from '../lib/structural/shape.mjs'; import { runStructuralExpectations } from '../lib/structural/expectations.mjs'; +// Neutralize host git config, same as every other suite. +const GIT_ENV = { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' }; + function initRepo(ws) { fs.mkdirSync(ws, { recursive: true }); - execFileSync('git', ['init', '-q'], { cwd: ws }); - execFileSync('git', ['config', 'user.email', 't@t'], { cwd: ws }); - execFileSync('git', ['config', 'user.name', 't'], { cwd: ws }); + execFileSync('git', ['init', '-q'], { cwd: ws, env: GIT_ENV }); + execFileSync('git', ['config', 'user.email', 't@t'], { cwd: ws, env: GIT_ENV }); + execFileSync('git', ['config', 'user.name', 't'], { cwd: ws, env: GIT_ENV }); } function commitAll(ws, message) { - execFileSync('git', ['add', '-A'], { cwd: ws }); - execFileSync('git', ['commit', '-qm', message], { cwd: ws }); + execFileSync('git', ['add', '-A'], { cwd: ws, env: GIT_ENV }); + execFileSync('git', ['commit', '-qm', message], { cwd: ws, env: GIT_ENV }); } -test('builder output round-trips through shape reader into the expectations check', async () => { +test('builder output round-trips through shape reader into the expectations check', async (t) => { + const extractor = await createTreesitterExtract(); + // Call edges come only from the AST tier; another grammar can set the tier + // while .mjs still falls back to lexical, so gate on the language itself. + if (!extractor.available.includes('javascript')) { + t.skip('javascript tree-sitter grammar not installed — call-edge round-trip needs the AST tier'); + return; + } + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'struct-compat-')); + t.after(() => fs.rmSync(tmp, { recursive: true, force: true })); const ws = path.join(tmp, 'ws'); const home = path.join(tmp, 'home'); initRepo(ws); @@ -42,7 +54,6 @@ test('builder output round-trips through shape reader into the expectations chec assert.equal(structuralIndexDir(ws, { home }), structuralDir(ws, { home }), 'builder and reader agree on the index dir'); - const extractor = await createTreesitterExtract(); await buildStructuralIndex({ workspace: ws, home, extractor }); const index = readStructuralIndex(ws, { home }); @@ -58,13 +69,41 @@ test('builder output round-trips through shape reader into the expectations chec const betaCalls = index.graph.calls.filter((edge) => edge.to === 'a.mjs#beta'); assert.ok(betaCalls.length >= 1, 'call edges normalized to file#symbol form'); - // Remove an exported symbol with a surviving caller; the check must see it. + // Remove an exported symbol with a surviving caller. fs.writeFileSync(path.join(ws, 'a.mjs'), 'export function alpha() { return 1; }\n'); - const plan = { fm: {}, body: '## Impacted Files\n\n- `a.mjs`\n' }; + // Allowlist via the shape parseImpactedFiles actually consumes + // (plan.sections.impactedFiles) — a.mjs is planned, so no + // unplanned-symbol-change may fire; only the caller finding is expected. + const plan = { fm: {}, sections: { impactedFiles: '- `a.mjs`\n' } }; + + // A treesitter-tier baseline entry is honestly SKIPPED per file: the current + // side is always lexical, so a cross-tier diff would fabricate findings — + // the check passes with an informational tier-mismatch-skipped note instead. + const skipped = runStructuralExpectations({ workspace: ws, plan, changedFiles: ['a.mjs'], home }); + assert.equal(skipped.status, 'passed', `tier-mismatched file must skip, got ${skipped.status}: ${skipped.message}`); + assert.deepEqual(skipped.findings, []); + assert.ok( + skipped.informational.some((n) => n.type === 'tier-mismatch-skipped' && n.file === 'a.mjs' && n.tier === 'treesitter'), + `tier mismatch surfaces as informational: ${JSON.stringify(skipped.informational)}` + ); + + // To prove the DOWNSTREAM seam (builder-written symbol rows and call edges + // flowing into survivingCallers), restamp a.mjs's per-file tier as lexical — + // a pure test-side patch of the generation stamp; the tables stay + // builder-written. + const filesPath = path.join(structuralIndexDir(ws, { home }), 'files.json'); + const filesTable = JSON.parse(fs.readFileSync(filesPath, 'utf8')); + filesTable['a.mjs'].tier = 'lexical'; + fs.writeFileSync(filesPath, JSON.stringify(filesTable) + '\n'); + const result = runStructuralExpectations({ workspace: ws, plan, changedFiles: ['a.mjs'], home }); assert.equal(result.status, 'failed', `expected structural findings, got ${result.status}: ${result.message}`); assert.ok( result.findings.some((f) => f.type === 'removed-symbol-with-callers' && f.symbol === 'beta'), 'removed exported symbol with a surviving caller is flagged' ); + assert.ok( + !result.findings.some((f) => f.type === 'unplanned-symbol-change'), + `planned file must not raise unplanned-symbol-change: ${JSON.stringify(result.findings)}` + ); }); diff --git a/packages/harness/test/treesitter-extractor.test.mjs b/packages/harness/test/treesitter-extractor.test.mjs index c1deede0..268b33fd 100644 --- a/packages/harness/test/treesitter-extractor.test.mjs +++ b/packages/harness/test/treesitter-extractor.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { createRequire } from 'node:module'; import { test } from 'node:test'; import { branchComplexity, @@ -178,14 +179,21 @@ test('integrity mismatch: corrupted grammar wasm is a LOUD lexical fallback, abs fs.mkdirSync(jsDir, { recursive: true }); fs.writeFileSync(path.join(jsDir, lock.grammars.javascript.file), 'not the pinned wasm bytes'); // Runtime present and genuine when installed, else absent → absence mode. - const runtimeSrc = path.resolve(path.dirname(DEFAULT_LOCK_PATH), '..', '..', 'node_modules', lock.runtime.package, lock.runtime.file); - if (fs.existsSync(runtimeSrc)) { + // Resolve through the module resolver (not a hard-coded node_modules path) + // so hoisted installs still exercise the integrity assertions. + let runtimeSrc = null; + try { + runtimeSrc = createRequire(import.meta.url).resolve(`${lock.runtime.package}/${lock.runtime.file}`); + } catch { + runtimeSrc = null; // not installed anywhere the resolver can see — absence mode + } + if (runtimeSrc) { const rtDir = path.join(dir, lock.runtime.package); fs.mkdirSync(rtDir, { recursive: true }); fs.copyFileSync(runtimeSrc, path.join(rtDir, lock.runtime.file)); } const ext = await createTreesitterExtract({ grammarRoots: [dir] }); - if (fs.existsSync(runtimeSrc)) { + if (runtimeSrc) { assert.ok( ext.integrityFailures.some((f) => f.language === 'javascript' && /sha256 mismatch/.test(f.reason)), 'corrupt wasm must be recorded as an integrity failure' From d0e610f354bd44b00b1c640ce3ba6c047c9e3a01 Mon Sep 17 00:00:00 2001 From: Krish Date: Thu, 6 Aug 2026 09:24:59 -0400 Subject: [PATCH 11/24] fix: close promotion-lane, layer-boundary, and verify-severity defects --- docs/MEMORY-MODEL.md | 119 +++- packages/harness/lib/commands.mjs | 41 +- packages/harness/lib/knowledge/admin.mjs | 32 +- packages/harness/lib/knowledge/apply.mjs | 260 +++++++- .../harness/lib/knowledge/consolidate.mjs | 29 +- packages/harness/lib/knowledge/layer.mjs | 19 +- packages/harness/lib/knowledge/overlay.mjs | 43 +- packages/harness/lib/knowledge/promote.mjs | 32 +- packages/harness/lib/knowledge/prune.mjs | 88 ++- packages/harness/lib/knowledge/status.mjs | 17 +- packages/harness/lib/orient.mjs | 24 +- packages/harness/lib/policy.mjs | 36 + packages/harness/lib/verify.mjs | 47 +- .../knowledge-boundary-hardening.test.mjs | 617 ++++++++++++++++++ packages/harness/test/quarantine.test.mjs | 10 +- .../test/verify-severity-hardening.test.mjs | 156 +++++ 16 files changed, 1489 insertions(+), 81 deletions(-) create mode 100644 packages/harness/test/knowledge-boundary-hardening.test.mjs create mode 100644 packages/harness/test/verify-severity-hardening.test.mjs diff --git a/docs/MEMORY-MODEL.md b/docs/MEMORY-MODEL.md index dad69541..d4c892ae 100644 --- a/docs/MEMORY-MODEL.md +++ b/docs/MEMORY-MODEL.md @@ -371,10 +371,18 @@ comment: `E_EXISTS`/`E_TARGET` strike when they fire against a genuine ON-DISK collision: a dedup miss (an `ADD`/`MERGE` id that already exists), a target that does not exist, or a `MERGE` target that is not active. Each records one failure entry per rejected episode, keyed on - `path@sha256`, in the store's ledger. -- **Run-level** — `E_MODE`, `E_DELTA_CONTRACT`, `E_LOCKED`, `E_APPLY_FAILED` never strike: - they say nothing about any one op's episodes. Neither does `E_DOMAIN_CAP` — cap pressure is - a run-level resource limit, not a defect in the episodes behind it. + `path@sha256`, in the store's **root** ledger — strikes and quarantine markers are + STORE-GLOBAL, never per-bucket, even when the learning OUTCOME is routed to a branch + layer. Three strikes is a control over an episode, and a provenance-less episode is + eligible in every branch lane, so a per-bucket count would reset simply by switching + branches; recording at the root is also what makes `consolidate --status` and doctor K2 + report a quarantine from every lane rather than only the one that raised it. + An op listing the same `path@sha256` more than once is rejected outright (`E_SCHEMA`) — + duplicate links inflate `verifiedFixLinks` (the protected-target threshold) and + `verifiedAndPlans` (promotion eligibility) from one episode file. +- **Run-level** — `E_MODE`, `E_DELTA_CONTRACT`, `E_LOCKED`, `E_APPLY_FAILED`, `E_LAYER` + never strike: they say nothing about any one op's episodes. Neither does `E_DOMAIN_CAP` — + cap pressure is a run-level resource limit, not a defect in the episodes behind it. - **Composition** — the SAME `E_EXISTS`/`E_TARGET` codes, raised instead when a SIBLING op earlier in the SAME run already claimed the id/target — including a `SUPERSEDE`/`MERGE` reusing a target an earlier `STRENGTHEN` in this run already touched — never strike. The @@ -660,11 +668,34 @@ The approved [Harness Evolution Blueprint](../knowledge/proposals/harness-evolut `LEARNING_BYTE_CAP` check excludes the provenance lines from the measured size, so a near-cap learning gaining provenance can never trip `E_BYTE_CAP` or a quarantine strike. - **Write routing** is derived from git context AT WRITE TIME: feature branch → - bucket; default branch → golden; detached → detached bucket; `--layer golden` is an - explicit, logged override. Default-branch resolution is store `config.json` - `defaultBranch` → `origin/HEAD` → unresolved, and an unresolved default fails closed TO - BRANCH-LOCAL, never golden (doctor K7 surfaces it). The orient-recorded branch is - advisory only — a write whose HEAD disagrees warns. + bucket; default branch → golden; detached → detached bucket. Default-branch resolution is + store `config.json` `defaultBranch` → `origin/HEAD` → unresolved, and an unresolved + default fails closed TO BRANCH-LOCAL, never golden (doctor K7 surfaces it). The + orient-recorded branch is advisory only — a write whose HEAD disagrees warns. +- **Layer containment (what the boundary is, and what it is not).** "Promotion is the only + branch → golden route" is a containment claim, so the flag that bypasses routing is + gated like every other human-authority path in this store: `--layer golden` is an + explicit, logged override that requires the human-presence signal — a live human + (`harness remember`'s internal `humanPresent`, which the ops JSON can never assert) or an + explicit `--yes` after a person reviewed the ops file. Without it the run is refused with + `E_LAYER`; an unattended agent cannot grant itself golden. `--layer branch` is refused + outright rather than silently ignored: branch routing is derived, and there is nothing + for a flag to override. + An episode's `branch:` frontmatter is a DIFFERENT kind of thing and must not be read as + part of that boundary: it is an ACCIDENT-PREVENTION signal (it keeps unrelated branch + work from drifting into the golden lane), not a security control. It is written by the + agent that captured the episode, nothing verifies it against git, and per-layer + eligibility (`episodeEligibleForLayer`) trusts it as-is. A dishonest `branch:` can route + an episode into the golden CANDIDATE set; what it cannot do is write golden — that still + requires standing on the default branch, or the human-gated override above, or promotion. +- **Governance is store-wide, so a branch lane never speaks for golden.** The governance + ledger binds both layers, which means a branch-local write must not append a decision + that resolves for golden. Two consequences: a re-teach landing in a BUCKET does not + append the `confirm` that would retract a standing golden `retire` (the standing decision + is reapplied to the bucket copy instead, and the CLI says so); and a hand-DELETE of a + bucket learning records a governance `retire` only when NO layer still holds that id — + the same guard `knowledge purge` already applies to its own cascade. Deleting a throwaway + branch copy is not a retirement of the golden claim of the same name. - **Read overlay.** Retrieval and the knowledge eval share one overlay (`overlay.mjs`): golden actives ∪ current-branch bucket actives; a branch-local claim shadows a same-id golden claim UNLESS the golden claim is protected (≥3 verified @@ -681,11 +712,27 @@ The approved [Harness Evolution Blueprint](../knowledge/proposals/harness-evolut `consolidate --rebuild --yes` re-derivation enforces; rebuild wipes each bucket's learnings/ledger too, keeping `meta.json` as the layer identity). - **Promotion** (`harness knowledge promote`) emits a reviewable, digest-bound op-set at - `.harness/promote-ops.json`; only `consolidate --apply` in promotion mode applies it. + `.harness/promote-ops.json` (a contained, atomic `fs-safe` write like every other + workspace write); only `consolidate --apply` in promotion mode applies it. Promotion ops are exempt from golden candidacy — evidence re-validates from the sha256s recorded at branch-apply time, never working-tree presence; promotion rejections never record quarantine strikes; a shadow-of-golden maps to SUPERSEDE (STRENGTHEN when the overlap is episodes-only); a protected golden target rejects and is marked disputed. + **The ops file is a plain JSON file anyone can hand-author, and its digest is computed by + whoever wrote it — so every admission gate is re-derived by the SOLE WRITER at write + time, never inherited from the emitter**: the bucket key must be a plain directory name + (one shared `isSafeBucketKey` definition — no separators, `..`, `.`, absolute path, + control character, or `:` drive/ADS shape), the bucket must not be a `detached-*` key, + its recorded base must not be provably non-ancestral to HEAD (re-read from the bucket's + own `meta.json`, never the envelope's copy), and each source must still be an ACTIVE, + unpromoted bucket learning (a fresh golden write carries `superseded_by: null`, so an + ungated promotion would strip a tombstone en route). + **Evidence is copied from the source, never described by the op.** An op selects WHICH + recorded episodes to carry; `kind` and `plan` are taken from the source learning's own + records. Re-labelling a recorded `insight` as `kind: fix` used to be enough to make a + promoted claim read as verified fixes across distinct plans — which is simultaneously + the promotion-eligibility signal and the PROTECTED-target signal — i.e. an insight-only + claim could launder itself into permanently protected golden knowledge. Success tombstones each source `promoted_to_golden:` (a retrieval exclusion alongside `promoted_to`) and records **`absorb-branch`** in the governance ledger — an AUDIT action: `readGovernance`'s replay considers only `retire`/`dispute`/`confirm`/`promote`, @@ -699,11 +746,26 @@ The approved [Harness Evolution Blueprint](../knowledge/proposals/harness-evolut - **Lifecycle.** `harness knowledge status` is the read-only layer report (golden per-domain counts, bucket rows with age/base/promotability/ancestry, recall-index drift); `harness knowledge prune [--branch ] [--merged] [--stale ]` deletes - buckets — human authority, never mode-gated, one store commit. Doctor K5 flags orphan - buckets (branch gone locally and on remotes), K6 flags bucket contents whose `branch:` - provenance disagrees with the bucket's meta. Branch renames auto-migrate a bucket to - the new key when exactly one gone-branch, ancestry-verified candidate exists; anything - ambiguous is left for `knowledge status`/K5 and manual prune. + buckets — human authority, never mode-gated, one store commit. **Prune previews and + confirms**: the selectors are not occupancy tests (a merged branch, a 30-day-old bucket, + or an explicitly named key can still hold live work), so prune and `knowledge status` + share ONE occupancy predicate. Every run prints a per-bucket preview (`active · + promoted · total`) and any prune that would delete ACTIVE, unpromoted learnings is + refused without `--yes`; a bucket status calls prunable (nothing active) still prunes + unattended. Doctor K5 flags orphan buckets (branch gone locally and on remotes), K6 flags + bucket contents whose `branch:` provenance disagrees with the bucket's meta. Branch + renames auto-migrate a bucket to the new key when exactly one gone-branch, + ancestry-verified candidate exists; anything ambiguous is left for `knowledge status`/K5 + and manual prune. +- **Known gap (deferred, not fixed).** The human register is GOLDEN-ONLY: `harness + learnings`, `harness learnings --why `, and `harness learning + ` all read and write the store root, so a + branch-local learning is invisible to them — `--why` on a bucket id returns nothing and + `learning retire` reports `E_TARGET`. The layer-aware ways to act on bucket content + today are `knowledge status` (see it), `knowledge prune` (delete the bucket), a direct + hand edit in the store (absorbed, and now correctly scoped to its layer), or promotion + (move the claim to golden, where the register applies). Making the register layer-aware + is a larger change than this pass took on. Phase 3 (the optional tree-sitter structural index under `~/.harness/index//structural/`) and Phase 4 (per-check verify severity via @@ -712,6 +774,33 @@ index is derived state, never knowledge: it lives outside this store, carries no governance, and is freely deletable and rebuildable — nothing else on this page applies to it. +### `advisory` is not available for a gating verify check (human decision) + +Per-check severity has three values, and they are not three shades of the same thing. +`enforce` fails verification, `warn` degrades a failure to `inconclusive` (still a non-zero +exit under enforce), and `advisory` removes the check from the outcome ENTIRELY — +`resolveOutcome` filters advisory checks out before deciding, so an advisory failure leaves +`outcome: passed` in the evidence artifact that `harness gate` and `harness compound` trust +(`validateEvidence` gates only on `outcome !== 'passed'`). Downgrading a real gate to +advisory therefore does two things at once: it opens the gate on a genuine violation, and +it mints a "verified" fix episode from a run that never verified — evidence that then feeds +promotion eligibility in this store. + +So `advisory` is refused, at policy load, for the built-in gating checks — +`plan-selection`, `plan-schema`, `plan-readiness`, `plan-state`, `phase-tasks`, +`criteria-evidence`, `scope`, `primitive-evidence`, `required-reviews`, `hard-gaps`, +`critical-findings`, `workspace-stability` (`NON_ADVISORY_CHECK_IDS`, `lib/policy.mjs`) — +with an error naming the check and pointing at `warn`. `advisory` remains available for +checks whose built-in DEFAULT is advisory (today only `structural-expectations`) and for a +project's own named checks in `checks.yaml` — a team's own command is theirs to mark +advisory. `warn` remains available for every check. Existing v1 policies are unaffected. + +Advisory findings are also less-trusted DATA, not report text: they carry current-side repo +symbols from a lexical extractor with no length bound of its own, and they are copied into +`.harness/evidence/*.json` and `verify --json`. Every string reachable in an advisory +failure is secret-redacted, flattened to one line, and capped (240 chars per string, 20 +entries per list, 50 findings) at the point the payload is collected. + ## Related - [`.github/skills/references/harness-tool-contract.md`](../.github/skills/references/harness-tool-contract.md) diff --git a/packages/harness/lib/commands.mjs b/packages/harness/lib/commands.mjs index 44c511e5..bed350de 100644 --- a/packages/harness/lib/commands.mjs +++ b/packages/harness/lib/commands.mjs @@ -1487,6 +1487,9 @@ export async function cmdKnowledge(argv) { branchKey: flags.branch, merged: flags.merged, staleDays: flags.stale, + // Deleting a bucket that still holds ACTIVE, unpromoted learnings is a + // destructive human decision — prune previews it and refuses without --yes. + yes: flags.yes, log: logger, }); writeEvent(workspace, flags, { @@ -1499,19 +1502,33 @@ export async function cmdKnowledge(argv) { }); if (flags.json) { emitJson(flags, result); - } else if (!result.pass) { - for (const l of ui.errorBlock({ code: 'E_USAGE', message: result.blockedReason, exit: result.exitCode })) { - console.error(l); - } } else { - console.log( - ui.line({ - state: 'warn', - key: 'prune', - value: `${result.removed.length} bucket(s) removed`, - note: result.removed.join(' · '), - }) - ); + // The per-bucket preview prints on BOTH paths — it is what a human needs + // in order to decide whether to re-run with --yes, and what a human + // deserves to see after a prune that did land. `p.branch` is already + // redacted/flattened/capped at the data boundary (safeBranchName). + for (const p of result.preview || []) { + console.log( + ui.paint( + 'muted', + ` ${p.key}${p.branch ? ` (${p.branch})` : ''} — ${p.active ?? '?'} active · ${p.promoted ?? '?'} promoted · ${p.total ?? '?'} total` + ) + ); + } + if (!result.pass) { + for (const l of ui.errorBlock({ code: 'E_USAGE', message: result.blockedReason, exit: result.exitCode })) { + console.error(l); + } + } else { + console.log( + ui.line({ + state: 'warn', + key: 'prune', + value: `${result.removed.length} bucket(s) removed`, + note: result.removed.join(' · '), + }) + ); + } } return result.exitCode; } diff --git a/packages/harness/lib/knowledge/admin.mjs b/packages/harness/lib/knowledge/admin.mjs index 2c785f16..6a195235 100644 --- a/packages/harness/lib/knowledge/admin.mjs +++ b/packages/harness/lib/knowledge/admin.mjs @@ -351,7 +351,32 @@ export function absorbHandEdits({ workspace, home, log = () => {} }) { // date) — the model-lane recency gate (overridesGovernanceRecency, // apply.mjs) needs finer-than-a-day resolution. const governanceAt = new Date().toISOString(); + // A BUCKET-SCOPED DELETION MUST NOT WRITE A STORE-WIDE DECISION (P1). The + // porcelain path a deletion is derived from names its layer, but the id + // recorded here is the bare `/` — and a governance `retire` + // binds BOTH layers (§4) and survives `consolidate --rebuild`. So deleting a + // throwaway branch copy used to permanently retire the golden claim of the + // same name. Same guard purgeEpisode already applies to its own cascade + // ("record dropped only once no layer holds it"), stated the same way here: + // only a deletion that leaves NO layer holding the id is a retirement of the + // id. The mirror sweep is scoped separately — it mirrors GOLDEN, so it keys + // off whether golden still holds the id, not whether any layer does. + const goldenIds = new Set(listLearnings(dir).map((l) => l.id)); + const survivingIds = new Set([ + ...goldenIds, + ...listBuckets(dir).flatMap((b) => { + try { + return listLearnings(b.dir).map((l) => l.id); + } catch { + return []; + } + }), + ]); for (const id of deleted) { + if (survivingIds.has(id)) { + log(`hand-edit absorb: ${id} removed from one layer but still held by another — no store-wide retire recorded`); + continue; + } appendGovernance(dir, { id, action: 'retire', reason: 'hand deletion (absorbed)', to: null, at: governanceAt }); } rebuildIndex(dir); @@ -379,8 +404,11 @@ export function absorbHandEdits({ workspace, home, log = () => {} }) { // `deleted` names the ids a human removed directly (git status "D") — // human deletion must win in the mirror too, so those ids are named via // retiredIds even though the store itself has already forgotten them by - // the time this runs (same reasoning as purgeAll/rebuildStore). - mirrorLearnings({ workspace, home, log, retiredIds: deleted }); + // the time this runs (same reasoning as purgeAll/rebuildStore). Scoped to + // ids GOLDEN no longer holds: the mirror only ever carries golden + // learnings, so a bucket-only deletion must not sweep the golden copy's + // mirror file. + mirrorLearnings({ workspace, home, log, retiredIds: deleted.filter((id) => !goldenIds.has(id)) }); } catch { // best effort — a mirror failure must never block absorb. } diff --git a/packages/harness/lib/knowledge/apply.mjs b/packages/harness/lib/knowledge/apply.mjs index 456fe28e..8a88fb0c 100644 --- a/packages/harness/lib/knowledge/apply.mjs +++ b/packages/harness/lib/knowledge/apply.mjs @@ -22,14 +22,14 @@ import { provenanceLines, provenanceBytes, } from './store.mjs'; -import { deriveGitContext } from '../git-context.mjs'; +import { deriveGitContext, isDetachedKey } from '../git-context.mjs'; import { MAX_OPS_PER_RUN, LEARNING_BYTE_CAP, QUARANTINE_THRESHOLD, DOMAIN_ACTIVE_CAP, isActiveFm, collectEpisodes, splitLedger } from './consolidate.mjs'; import { scanSecrets } from '../secret-scan.mjs'; import { absorbOrAbort, mirrorLearnings } from './admin.mjs'; import { parseMergedFrom } from './listing.mjs'; import { resolveWriteLayer, ensureBucket, migrateRenamedBucket, episodeEligibleForLayer, storeHasBuckets } from './layer.mjs'; -import { bucketDirFor } from './overlay.mjs'; -import { readFileNoFollow, assertNoSymlinkAncestors } from '../fs-safe.mjs'; +import { bucketDirFor, readBucketMeta, bucketAncestryOk, isSafeBucketKey } from './overlay.mjs'; +import { readFileNoFollow, assertNoSymlinkAncestors, assertRealpathContained } from '../fs-safe.mjs'; /** * The SOLE writer of the learnings store. The consolidation skill emits an @@ -243,7 +243,20 @@ function extractAnchors({ workspace, copilotHome, episodes }) { return [...found].sort().slice(0, ANCHOR_CAP); } -function renderLearning({ trigger, body, episodes, anchors = [], origin, status, source, supersededBy, mergedFrom, promotedTo, provenance }) { +function renderLearning({ + trigger, + body, + episodes, + anchors = [], + origin, + status, + source, + supersededBy, + mergedFrom, + promotedTo, + promotedToGolden, + provenance, +}) { const lines = [ '---', 'schema: 1', @@ -268,6 +281,13 @@ function renderLearning({ trigger, body, episodes, anchors = [], origin, status, lines.push(`last_confirmed: ${todayClamped()}`); if (mergedFrom?.length) lines.push(`merged_from: [${mergedFrom.join(', ')}]`); if (promotedTo) lines.push(`promoted_to: ${promotedTo}`); + // Same field, same position, same optionality as serializeLearning + // (store.mjs). Without it here, ANY re-render through this function silently + // dropped the branch→golden tombstone — the STRENGTHEN path re-rendered a + // promoted bucket entry back into an ACTIVE claim that shadowed the golden + // claim it had become, was re-offered by `knowledge promote`, and stopped + // matching `prune --merged`'s fullyPromoted() check. + if (promotedToGolden) lines.push(`promoted_to_golden: ${promotedToGolden}`); lines.push(`origin: ${origin}`); // Git provenance (blueprint P1/P9) — same shared rendering serializeLearning // (store.mjs) uses, so a fresh write and a round-trip re-render emit @@ -371,6 +391,15 @@ function validateEpisodes(episodes, opIndex) { if (!Array.isArray(episodes) || !episodes.length) { return fail('E_SCHEMA', `op ${opIndex}: episodes must be a non-empty array`); } + // One link per episode. An op listing the SAME `path@sha256` more than once + // is not two pieces of evidence — but every count downstream reads the + // rendered `episodes:` block as a flat list, so a duplicate inflates + // `verifiedFixLinks` (the protected/disputed-target threshold) and + // `verifiedAndPlans` (promotion eligibility) from a single episode file. + // Rejected at admission rather than silently deduped, so the op-set's author + // learns its evidence was double-counted instead of the store quietly + // disagreeing with the ops JSON. + const seenEpisodeKeys = new Set(); for (const e of episodes) { if ( !e || @@ -382,6 +411,11 @@ function validateEpisodes(episodes, opIndex) { ) { return fail('E_SCHEMA', `op ${opIndex}: each episode needs path + sha256`); } + const key = `${e.path}@${e.sha256}`; + if (seenEpisodeKeys.has(key)) { + return fail('E_SCHEMA', `op ${opIndex}: episode ${e.path} is listed more than once — one link per episode`); + } + seenEpisodeKeys.add(key); if (!validPlanField(e.plan)) { return fail( 'E_SCHEMA', @@ -624,9 +658,9 @@ export function applyOps({ // lane. Never derived from anything in the ops JSON itself: a model can // never grant this to itself by asserting a field. humanPresent = false, - // `--layer golden` override (blueprint P4): explicit, logged. Any other - // value is ignored — routing is otherwise always derived from write-time - // git context, never from a flag. + // `--layer golden` override (blueprint P4): explicit, logged, and HUMAN- + // GATED (see the admission check below). Routing is otherwise always + // derived from write-time git context, never from a flag. layer = null, }) { // Kill switch: consolidate is a write path gated to mode 'on' — checked @@ -653,6 +687,42 @@ export function applyOps({ }; } + // LAYER CONTAINMENT (P2 security finding). "Promotion is the only branch → + // golden route" is a containment claim, so the one flag that bypasses + // write-time routing has to sit on the SAME trust plane as every other + // human-authority path in this module: a live human (`humanPresent`, set + // only by runRemember) or an explicit human approval (`approve`, set only + // by `--yes` after a person reviewed the ops JSON). Without this, `--layer + // golden` was a plain flag any unattended agent could pass to write + // straight into golden from a feature branch — self-granting exactly the + // authority the promotion lane exists to gate. `--layer branch` is refused + // outright rather than silently ignored (which is what it was): branch + // routing is DERIVED from write-time git context and there is nothing for a + // flag to override. + if (layer === 'branch') { + return { + applied: [], + governed: [], + rejected: [fail('E_LAYER', '--layer branch is not an override — branch routing is derived from write-time git context')], + committed: false, + exitCode: 2, + }; + } + if (layer === 'golden' && !humanPresent && !approve) { + return { + applied: [], + governed: [], + rejected: [ + fail( + 'E_LAYER', + '--layer golden is a human-authority override — review the ops JSON and re-run with --yes, or promote the branch bucket: harness knowledge promote' + ), + ], + committed: false, + exitCode: 2, + }; + } + let parsed; try { parsed = JSON.parse(fs.readFileSync(opsPath, 'utf8')); @@ -677,8 +747,35 @@ export function applyOps({ parsed.promotion && typeof parsed.promotion === 'object' && !Array.isArray(parsed.promotion) ? parsed.promotion : null; const promotionMode = Boolean(promotion); if (promotionMode) { - if (typeof promotion.branchKey !== 'string' || !promotion.branchKey || /[\\/]|\.\./.test(promotion.branchKey)) { - return { applied: [], governed: [], rejected: [fail('E_SCHEMA', 'promotion envelope needs a plain branchKey')], committed: false, exitCode: 1 }; + // ADMISSION GATES ARE RE-DERIVED HERE, NOT INHERITED FROM THE EMITTER + // (P1). `harness knowledge promote` refuses a path-shaped key, a + // detached-HEAD bucket, a non-ancestor bucket, and a governed or + // non-active source — but the ops file it writes is a plain JSON file + // anyone can hand-author and feed straight to `consolidate --apply`, and + // the digest is computed over the ops array by whoever wrote it. The SOLE + // WRITER must enforce every gate the emitter enforces; an emitter-only + // gate is not a gate. The key-shape check now shares ONE definition with + // the emitter (isSafeBucketKey, overlay.mjs) instead of a looser local + // copy that admitted `.`, absolute paths, and Windows drive/ADS shapes. + if (!isSafeBucketKey(promotion.branchKey)) { + return { + applied: [], + governed: [], + rejected: [fail('E_SCHEMA', 'promotion envelope needs a plain branchKey — a bucket directory name, never a path')], + committed: false, + exitCode: 1, + }; + } + if (isDetachedKey(promotion.branchKey)) { + return { + applied: [], + governed: [], + rejected: [ + fail('E_SCHEMA', `promotion source ${promotion.branchKey} is a detached-HEAD bucket — never promotable (derived from the key shape)`), + ], + committed: false, + exitCode: 1, + }; } const digest = crypto.createHash('sha256').update(JSON.stringify(parsed.ops)).digest('hex'); if (digest !== promotion.digest) { @@ -787,6 +884,31 @@ export function applyOps({ const promotionSources = promotionMode ? new Map(listLearnings(bucketDirFor(dir, promotion.branchKey)).map((l) => [l.id, l])) : null; + if (promotionMode) { + // Ancestry gate re-derived at WRITE time from the bucket's OWN meta.json + // on disk — never from `promotion.meta`, which is emitter-recorded data + // inside the same hand-authorable ops file. A bucket whose recorded base + // provably shares no history with HEAD is a force-push name-reuse + // artifact: excluded from the read overlay, refused by the emitter, and + // now refused by the writer too. Only a verified `false` refuses; `null` + // (unverifiable) stays allowed, matching the read path. + const promotionBucketDir = bucketDirFor(dir, promotion.branchKey); + if (bucketAncestryOk(workspace, readBucketMeta(promotionBucketDir)) === false) { + return { + kind: 'reject', + applied: [], + governed: [], + rejected: [ + fail( + 'E_SCHEMA', + `promotion source bucket ${promotion.branchKey} has unrelated history — its recorded base is not an ancestor of HEAD (branch-name reuse); prune it instead: harness knowledge prune --branch ${promotion.branchKey}` + ), + ], + committed: false, + exitCode: 1, + }; + } + } /** * Three-strikes bookkeeping (design §3): a content-failure code raised by a @@ -835,7 +957,18 @@ export function applyOps({ }); if (!eps.length) return null; try { - const ledger = readLedger(layerRoot); + // STRIKES AND QUARANTINE MARKERS ARE STORE-GLOBAL, NEVER PER-BUCKET + // (P2). Three strikes is an anti-collapse control over an EPISODE, and + // a provenance-less episode is eligible in every branch lane — so + // counting strikes in the per-bucket ledger meant the control reset + // simply by switching branches (three more strikes per branch, forever), + // and `consolidate --status`/doctor K2 reported zero quarantines from + // any lane but the one that recorded them. The golden ledger is read by + // every lane's consumption set (see candidateKeys below) and by + // consolidateStatus unconditionally, so recording here makes both the + // counting and the reporting branch-independent. Learning OUTCOMES + // (`learning: `) stay per-layer — those really are the bucket's. + const ledger = readLedger(dir); const at = todayClamped(); const entries = []; for (const e of eps) { @@ -845,7 +978,7 @@ export function applyOps({ entries.push({ path: e.path, sha256: e.sha256, quarantined: true, learning: null, at }); } } - appendLedger(layerRoot, entries); + appendLedger(dir, entries); const commitRes = commitStore(dir, `consolidate: record failure ${code}`); if (!commitRes.ok) { rollbackStore(dir); @@ -991,6 +1124,22 @@ export function applyOps({ const disputes = []; for (let i = 0; i < parsed.ops.length; i++) { const op = parsed.ops[i]; + if (promotionMode && !FILE_TOUCHING.has(op.op)) { + // The emitter only ever produces ADD/STRENGTHEN/SUPERSEDE. A + // hand-authored promotion envelope carrying a NOOP would otherwise + // consume its episodes into the GOLDEN ledger from any branch — + // promotion mode pins layerRoot to golden — clearing debt in a lane the + // run never had authority over. Same reasoning as the envelope gates + // above: the writer enforces the emitter's shape, it doesn't assume it. + return { + kind: 'reject', + applied: [], + governed: [], + rejected: [fail('E_SCHEMA', `op ${i}: a promotion op-set carries only ADD/STRENGTHEN/SUPERSEDE ops, never ${op.op}`)], + committed: false, + exitCode: 1, + }; + } if (op.op === 'NOOP') { const bad = validateEpisodes(op.episodes, i); if (bad) return rejectOp(bad.code, bad.reason, op.episodes); @@ -1008,6 +1157,10 @@ export function applyOps({ } const bad = validateEpisodes(op.episodes, i); if (bad) return rejectOp(bad.code, bad.reason, op.episodes); + // Promotion episodes are re-derived from the SOURCE learning's own + // recorded entries (see the promotion branch below) — never the op's + // asserted kind/plan. Null for every non-promotion op. + let promotedEpisodes = null; if (promotionMode) { // PROMOTION EXEMPTION (blueprint §5, normative): promotion ops are // exempt from the golden candidacy check and the working-tree kind @@ -1032,6 +1185,27 @@ export function applyOps({ exitCode: 1, }; } + // A source that is no longer an eligible promotion candidate — already + // absorbed into golden, superseded, retired, disputed, or promoted to a + // primitive — must never be promoted by a hand-authored op-set either. + // renderLearning writes a FRESH golden file with `superseded_by: null`, + // so without this a superseded branch claim would be laundered into + // golden with its tombstone stripped en route. + if (!isActiveFm(sourceLearning.fm)) { + return { + kind: 'reject', + applied: [], + governed: [], + rejected: [ + fail( + 'E_SCHEMA', + `op ${i}: promotion source ${src.id} is not an active, unpromoted bucket learning — only active branch claims promote` + ), + ], + committed: false, + exitCode: 1, + }; + } const currentSha = crypto.createHash('sha256').update(fs.readFileSync(sourceLearning.file)).digest('hex'); if (currentSha !== src.sha256) { return { @@ -1043,9 +1217,27 @@ export function applyOps({ exitCode: 1, }; } - const recorded = new Set((sourceLearning.fm.episodes || []).map((e) => `${e.path}@${e.sha256}`)); + // EVIDENCE IS COPIED FROM THE SOURCE, NEVER TRUSTED FROM THE OP (P1). + // Only `path@sha256` was ever compared here, so an op could re-label a + // recorded `insight` episode as `kind: fix` and attach a `plan:` the + // source never carried — and `episodeLines` (store.mjs) defaults an + // unknown/missing kind to `fix`, so a bare relabel was enough. The + // promoted golden claim then read as verified fixes across distinct + // plans, which is simultaneously the promotion-eligibility signal + // (verifiedAndPlans) and the PROTECTED-target signal (isDisputedTargetFm / + // isProtectedFm at ≥3 fix links) — i.e. an insight-only claim could + // launder itself into permanently protected golden knowledge, defeating + // the "insight-only learnings never promote" control by name. The op may + // still SELECT which recorded episodes to carry (that is what a + // STRENGTHEN promotion does); it may not describe them. + const recorded = new Map(); + for (const e of sourceLearning.fm.episodes || []) { + if (e.path) recorded.set(`${e.path}@${e.sha256}`, e); + } + promotedEpisodes = []; for (const e of op.episodes) { - if (!recorded.has(`${e.path}@${e.sha256}`)) { + const recordedEpisode = recorded.get(`${e.path}@${e.sha256}`); + if (!recordedEpisode) { return { kind: 'reject', applied: [], @@ -1057,6 +1249,12 @@ export function applyOps({ exitCode: 1, }; } + promotedEpisodes.push({ + path: recordedEpisode.path, + sha256: recordedEpisode.sha256, + kind: recordedEpisode.kind, + plan: recordedEpisode.plan || null, + }); } } else { // Evidence-defect gate (see verifyAdmittedEpisodeKinds doc comment): @@ -1476,7 +1674,10 @@ export function applyOps({ } consumedTargets.add(op.target); } - planned.push({ ...op, index: i }); + // Everything downstream (renderLearning, composeStrengthenedLearning, the + // ledger entries) reads `op.episodes` — so a promotion op is planned with + // the SOURCE-derived episode records, not the ones the ops file asserted. + planned.push({ ...op, ...(promotedEpisodes ? { episodes: promotedEpisodes } : {}), index: i }); } // Compose ADD/SUPERSEDE/MERGE files and enforce the byte cap before writing. @@ -1647,10 +1848,23 @@ export function applyOps({ op.episodes.length > 0 && op.episodes.every((e) => verifyHumanTeachingEpisode(workspace, copilotHome, e)) && overridesGovernanceRecency(workspace, copilotHome, op.episodes, entry, { humanPresent }); - if (isReteach) { + // The override is SCOPED TO THE LAYER ACTUALLY WRITTEN. Governance binds + // both layers (§4) and lives in one store-root ledger, so a `confirm` + // appended from a branch lane cancels the standing decision for GOLDEN + // too — a `harness remember` on a throwaway feature branch could + // therefore retract a golden retire a human had made, from a write that + // never touched golden. A branch-lane re-teach still lands its own claim + // in the bucket; it just doesn't get to speak for the golden layer, so + // the standing decision is reapplied to the bucket copy instead. + if (isReteach && layerRoot === dir) { appendGovernance(dir, { id, action: 'confirm', reason: 'superseded by re-teach', to: null, at: governanceAt }); continue; } + if (isReteach) { + log( + `consolidate: re-teach of ${id} landed branch-local — the standing ${entry.action} decision still binds both layers; re-teach on the default branch (or promote) to retract it` + ); + } const file = path.join(layerRoot, 'learnings', domain, `${slug}.md`); if (entry.action === 'promote') { // promoted_to may be entirely absent from the just-written file — @@ -1705,6 +1919,16 @@ export function applyOps({ if (!FILE_TOUCHING.has(a.op)) continue; const src = promotionSources.get(a.id); if (!src) continue; + // Defense in depth (fs-safe.mjs's own documented discipline): this is + // the one write in this module that targets a path under + // `branches//`, a directory tree a human hand-edits. A symlinked + // bucket component must never let the tombstone write land outside the + // store. Fail CLOSED — a throw here propagates out of runOnce and + // withStoreTransaction rolls the whole promotion back, rather than + // leaving a golden claim whose source was never tombstoned. + if (!assertRealpathContained(dir, path.relative(dir, src.file))) { + throw new Error(`refused to tombstone ${a.id}: bucket learning path escapes the knowledge store`); + } const text = fs.readFileSync(src.file, 'utf8'); const parsedSource = parseLearningFrontmatter(text); fs.writeFileSync(src.file, serializeLearning({ ...parsedSource.fm, promoted_to_golden: a.id }, parsedSource.body), 'utf8'); @@ -1921,6 +2145,12 @@ function composeStrengthenedLearning(target, episodes, workspace, copilotHome) { // promoted_to (if any) forward, unlike a fresh ADD/SUPERSEDE/MERGE write // which never starts out already promoted. promotedTo: fm.promoted_to || null, + // Same carry-forward for the branch→golden tombstone: a STRENGTHEN must + // never resurrect a bucket entry whose claim already landed golden. In + // practice the inactive-target gate now rejects such a STRENGTHEN before + // this runs (isActiveFm counts promoted_to_golden), so this is the + // defense-in-depth half — the re-render itself can no longer lose it. + promotedToGolden: fm.promoted_to_golden || null, // Preserve the learning's ORIGINAL git provenance across the re-render // (blueprint P1): a STRENGTHEN adds evidence to an existing claim, it does // not re-originate it. A legacy learning without the fields stays without diff --git a/packages/harness/lib/knowledge/consolidate.mjs b/packages/harness/lib/knowledge/consolidate.mjs index 4ea1a5c5..eeb375e5 100644 --- a/packages/harness/lib/knowledge/consolidate.mjs +++ b/packages/harness/lib/knowledge/consolidate.mjs @@ -187,14 +187,41 @@ export function splitLedger(ledger) { // out of every active-learning surface (cap counts, promotion candidates, // ranking, rebuild) exactly like retired/disputed/superseded, even though // `promote` leaves its own `status` field untouched. +// +// `promoted_to_golden` is the branch→golden equivalent (blueprint §5): the +// bucket entry's claim IS the golden claim now, so the tombstone is just as +// inactive. Without it here the overlay excluded the tombstone but every +// OTHER active-learning surface still counted it — so a STRENGTHEN/SUPERSEDE +// could resurrect a promoted bucket entry back into the overlay (shadowing +// the golden claim it became), `promote` would re-offer it, and the bucket +// INDEX.md rebuildIndex writes would disagree with `retrievalExclusion`. export function isActiveFm(fm) { - return !fm.superseded_by && !fm.promoted_to && !['retired', 'disputed'].includes(fm.status); + return !fm.superseded_by && !fm.promoted_to && !fm.promoted_to_golden && !['retired', 'disputed'].includes(fm.status); } export function activeLearnings(learnings) { return learnings.filter((l) => isActiveFm(l.fm)); } +/** + * The ONE bucket-occupancy predicate `knowledge status` reports and + * `knowledge prune` gates on (P2 finding: they used to disagree — status + * called a bucket "not prunable" while prune deleted it and its active, + * unpromoted learnings with no preview and no confirmation). `active` counts + * only learnings that are still live AND not already absorbed into golden; + * `promoted` counts the branch→golden tombstones, which are exactly what + * makes a bucket safe to delete. + */ +export function bucketCounts(learnings) { + let active = 0; + let promoted = 0; + for (const l of learnings) { + if (l.fm.promoted_to_golden) promoted += 1; + else if (isActiveFm(l.fm)) active += 1; + } + return { active, promoted, total: learnings.length }; +} + /** * Per-domain active-learning count against DOMAIN_ACTIVE_CAP. Shared by * `--status` (compact cap-pressure note) and `--candidates` (full packet) so diff --git a/packages/harness/lib/knowledge/layer.mjs b/packages/harness/lib/knowledge/layer.mjs index a4080d87..07b1d6b1 100644 --- a/packages/harness/lib/knowledge/layer.mjs +++ b/packages/harness/lib/knowledge/layer.mjs @@ -2,8 +2,9 @@ import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { deriveGitContext, resolveDefaultBranch, isDetachedKey } from '../git-context.mjs'; -import { branchesRoot, bucketDirFor, listBuckets, bucketAncestryOk } from './overlay.mjs'; +import { branchesRoot, bucketDirFor, listBuckets, bucketAncestryOk, isSafeBucketKey } from './overlay.mjs'; import { readSession } from '../session.mjs'; +import { assertRealpathContained, assertNoSymlinkAncestors } from '../fs-safe.mjs'; /** * Layer-aware WRITE routing (blueprint P4, normative routing table): @@ -14,7 +15,8 @@ import { readSession } from '../session.mjs'; * | Default branch | golden | * | Detached HEAD | `branches/detached-/` (never | * | | promotable — derived from the key shape) | - * | `--layer golden` | golden (explicit override, logged) | + * | `--layer golden` | golden (explicit override, logged — | + * | | HUMAN-GATED at admission, see apply.mjs) | * | Non-git workspace | golden (no branch concept exists) | * * The layer is derived from git context AT WRITE TIME — the branch recorded @@ -156,7 +158,16 @@ export function migrateRenamedBucket(dir, { workspace, context }) { } if (candidates.length !== 1) return null; const [source] = candidates; - const target = bucketDirFor(dir, context.branchKey); + // Defense in depth (fs-safe.mjs): a rename moves a whole directory tree, and + // both endpoints are derived from `branches/` — a hand-editable tree — and + // from a branch name. Refuse unless both keys are plain bucket names, the + // source's REAL path still sits inside the store, and no symlinked component + // stands on the destination path. + if (!isSafeBucketKey(source.key) || !isSafeBucketKey(context.branchKey)) return null; + const containedSource = assertRealpathContained(dir, path.join('branches', source.key)); + if (!containedSource) return null; + const target = assertNoSymlinkAncestors(dir, path.join('branches', context.branchKey)); + if (!target) return null; try { // Rewrite the meta cache in the SOURCE dir first, THEN rename: if the // meta write throws, the bucket has not moved yet, so nothing is left @@ -169,7 +180,7 @@ export function migrateRenamedBucket(dir, { workspace, context }) { JSON.stringify({ ...meta, branch: context.branch, branchKey: context.branchKey }) + '\n', 'utf8' ); - fs.renameSync(source.dir, target); + fs.renameSync(containedSource, target); return { migrated: true, from: source.key, to: context.branchKey }; } catch { return null; diff --git a/packages/harness/lib/knowledge/overlay.mjs b/packages/harness/lib/knowledge/overlay.mjs index afb99596..05330df1 100644 --- a/packages/harness/lib/knowledge/overlay.mjs +++ b/packages/harness/lib/knowledge/overlay.mjs @@ -1,8 +1,9 @@ import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; -import { storeDir, listLearnings, readGovernance } from './store.mjs'; +import { storeDir, listLearnings, readGovernance, inertLine } from './store.mjs'; import { deriveGitContext } from '../git-context.mjs'; +import { redactSecrets } from '../secret-scan.mjs'; /** * The layered read path (harness evolution blueprint §4) — ONE exported @@ -54,10 +55,50 @@ export function branchesRoot(dir) { return path.join(dir, 'branches'); } +/** + * A bucket key is a plain directory NAME under `branches/`, never a path. + * Owned by the module that performs the `path.join` (bucketDirFor below), so + * the promotion EMITTER (`promote.mjs`, validating `--branch`) and the sole + * WRITER (`apply.mjs`, validating a hand-authored promotion envelope) share + * one definition instead of drifting — an emitter-only shape check is no + * check at all, since the ops file is a plain JSON file a human or model can + * hand-author and feed straight to `consolidate --apply`. + * + * Refused: separators (both kinds — a `\` segment traverses on Windows and is + * a legal filename byte on POSIX), any `..`, `.`/`..` whole, an absolute path, + * a control character, and any `:` — which covers both the Windows drive + * shape (`C:`) and the NTFS alternate-data-stream shape (`foo:bar`), neither + * of which `path.isAbsolute` recognizes on a POSIX build. + */ +export function isSafeBucketKey(key) { + if (typeof key !== 'string' || !key) return false; + if (key === '.' || key === '..') return false; + if (/[\\/:]/.test(key)) return false; + if (key.includes('..')) return false; + if (/[\x00-\x1f\x7f]/.test(key)) return false; + return !path.isAbsolute(key); +} + export function bucketDirFor(dir, key) { return path.join(branchesRoot(dir), key); } +/** + * Render-safe form of a branch name for any surface that reports one — + * `knowledge status`, `knowledge prune`'s preview, and their `--json` lanes. + * A branch name is attacker-influenced on a fork checkout, and a bucket's + * `meta.json` `branch` field is a plain hand-editable string in the store, so + * it is the same untrusted class context-pack.mjs already redacts, flattens, + * and caps before rendering. Applied at the DATA boundary (where the report + * object is built), which is the only place that reaches the raw `--json` + * lane as well as the rendered CLI row. Null in, null out. + */ +export const BRANCH_DISPLAY_CAP = 80; +export function safeBranchName(value) { + if (typeof value !== 'string' || !value) return null; + return inertLine(redactSecrets(value)).slice(0, BRANCH_DISPLAY_CAP); +} + /** Parsed bucket meta.json, or null. Meta is a CACHE, never authority — * promotability and detachment are re-derived from the key shape at decision * time; meta only carries display/ancestry hints. */ diff --git a/packages/harness/lib/knowledge/promote.mjs b/packages/harness/lib/knowledge/promote.mjs index 08df697b..a4fa9201 100644 --- a/packages/harness/lib/knowledge/promote.mjs +++ b/packages/harness/lib/knowledge/promote.mjs @@ -3,8 +3,9 @@ import fs from 'node:fs'; import path from 'node:path'; import { storeDir, listLearnings, readGovernance } from './store.mjs'; import { isActiveFm, MAX_OPS_PER_RUN } from './consolidate.mjs'; -import { bucketDirFor, readBucketMeta, listBuckets, bucketAncestryOk } from './overlay.mjs'; +import { bucketDirFor, readBucketMeta, listBuckets, bucketAncestryOk, isSafeBucketKey } from './overlay.mjs'; import { deriveGitContext, isDetachedKey } from '../git-context.mjs'; +import { writeFileContained } from '../fs-safe.mjs'; /** * `harness knowledge promote` (blueprint §5): emits a REVIEWABLE op-set at @@ -51,10 +52,11 @@ export function buildPromotionOps({ workspace, home, branchKey = null, ids = nul return { pass: false, exitCode: 2, opsPath: null, ops: 0, remaining: 0, skipped: [], blockedReason: 'no branch bucket resolvable — pass --branch (see harness knowledge status)' }; } // Path-safety: a bucket key is a plain directory name under branches/, - // never a path — same shape check apply.mjs enforces on the promotion - // envelope's branchKey, applied here so an explicit --branch value can - // never traverse outside the store via bucketDirFor's path.join. - if (/[\\/]|\.\./.test(key) || key === '.' || path.isAbsolute(key)) { + // never a path — ONE shared shape check (isSafeBucketKey, overlay.mjs) that + // apply.mjs re-derives on the promotion envelope's branchKey at write time, + // so an explicit --branch value can never traverse outside the store via + // bucketDirFor's path.join and the emitter/writer can never drift. + if (!isSafeBucketKey(key)) { return { pass: false, exitCode: 2, opsPath: null, ops: 0, remaining: 0, skipped: [], blockedReason: `invalid branch key ${key} — bucket keys are plain directory names (see harness knowledge status)` }; } if (isDetachedKey(key)) { @@ -162,9 +164,23 @@ export function buildPromotionOps({ workspace, home, branchKey = null, ids = nul promotion: { branchKey: key, meta: bucketMeta, digest: promotionDigest(chunk) }, ops: chunk, }; - const opsFull = path.join(workspace, PROMOTE_OPS_REL); - fs.mkdirSync(path.dirname(opsFull), { recursive: true }); - fs.writeFileSync(opsFull, JSON.stringify(opset, null, 2) + '\n', 'utf8'); + // Contained, atomic write (fs-safe.mjs) — the same discipline every sibling + // workspace write uses. A symlinked `.harness/` (or any ancestor of it) + // must never let this op-set land outside the workspace, and a partially + // written op-set must never be readable by a concurrent `consolidate + // --apply`. + const written = writeFileContained(workspace, PROMOTE_OPS_REL, JSON.stringify(opset, null, 2) + '\n'); + if (!written) { + return { + pass: false, + exitCode: 1, + opsPath: null, + ops: 0, + remaining: 0, + skipped, + blockedReason: `refused to write ${PROMOTE_OPS_REL} — a symlinked path component would place it outside the workspace`, + }; + } log(`wrote ${PROMOTE_OPS_REL} (${chunk.length} op(s), ${remaining} remaining)`); return { pass: true, diff --git a/packages/harness/lib/knowledge/prune.mjs b/packages/harness/lib/knowledge/prune.mjs index ae20366f..31a9c3d6 100644 --- a/packages/harness/lib/knowledge/prune.mjs +++ b/packages/harness/lib/knowledge/prune.mjs @@ -1,9 +1,12 @@ import fs from 'node:fs'; +import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { storeDir, withStoreTransaction, StoreTransactionAbort, listLearnings } from './store.mjs'; -import { listBuckets } from './overlay.mjs'; +import { listBuckets, safeBranchName } from './overlay.mjs'; +import { bucketCounts } from './consolidate.mjs'; import { absorbOrAbort } from './admin.mjs'; import { resolveDefaultBranch } from '../git-context.mjs'; +import { assertRealpathContained } from '../fs-safe.mjs'; /** * `harness knowledge prune` (blueprint P6/§5): delete branch buckets. HUMAN @@ -17,6 +20,16 @@ import { resolveDefaultBranch } from '../git-context.mjs'; * branch (workspace git state), plus fully-tombstoned * buckets (every entry promoted to golden — nothing left) * --stale buckets whose meta createdAt is older than N days + * + * CONFIRMATION (P2 finding): the selectors above are not occupancy tests. A + * merged branch, a 30-day-old bucket, or an explicitly named key can still + * hold ACTIVE, UNPROMOTED learnings — `knowledge status` reports exactly those + * buckets as NOT prunable, while prune deleted them anyway with no preview and + * no confirmation. Both surfaces now share ONE predicate (`bucketCounts`, + * consolidate.mjs): anything status calls prunable (`active === 0`) still + * prunes unattended; anything holding active work needs an explicit `--yes`, + * and every run — refused or applied — returns a per-bucket `preview` of what + * is at stake. */ /** Local branches fully merged into the resolved default branch. */ @@ -35,6 +48,27 @@ function mergedBranches(workspace, defaultBranch) { return null; } +/** Per-bucket occupancy, via the same predicate `knowledge status` reports. + * A bucket whose directory is unreadable counts as holding nothing knowable — + * `active: null` — which the confirmation gate below treats as needing --yes. */ +function bucketPreview(bucket) { + let counts; + try { + counts = bucketCounts(listLearnings(bucket.dir)); + } catch { + counts = null; + } + return { + key: bucket.key, + // Same untrusted-branch-name treatment `knowledge status` applies — this + // preview is a `--json` surface too. + branch: safeBranchName(bucket.meta?.branch), + active: counts ? counts.active : null, + promoted: counts ? counts.promoted : null, + total: counts ? counts.total : null, + }; +} + /** True when every learning in the bucket is a promoted_to_golden tombstone * (and there is at least one) — the bucket's work fully landed golden. */ function fullyPromoted(bucket) { @@ -47,19 +81,19 @@ function fullyPromoted(bucket) { return entries.length > 0 && entries.every((l) => Boolean(l.fm.promoted_to_golden)); } -export function pruneBuckets({ workspace, home, branchKey = null, merged = false, staleDays = null, log = () => {} } = {}) { +export function pruneBuckets({ workspace, home, branchKey = null, merged = false, staleDays = null, yes = false, log = () => {} } = {}) { if (!branchKey && !merged && staleDays === null) { - return { pass: false, exitCode: 2, removed: [], blockedReason: 'prune needs --branch , --merged, or --stale ' }; + return { pass: false, exitCode: 2, removed: [], preview: [], blockedReason: 'prune needs --branch , --merged, or --stale ' }; } // Boundary validation for direct callers (the CLI's flag parser validates // too): a fractional or non-numeric staleDays would silently shift the // cutoff and prune the wrong buckets. if (staleDays !== null && !(Number.isSafeInteger(staleDays) && staleDays > 0)) { - return { pass: false, exitCode: 2, removed: [], blockedReason: `--stale needs a positive whole number of days (got ${staleDays})` }; + return { pass: false, exitCode: 2, removed: [], preview: [], blockedReason: `--stale needs a positive whole number of days (got ${staleDays})` }; } const dir = storeDir(workspace, { home }); if (!fs.existsSync(dir)) { - return { pass: false, exitCode: 2, removed: [], blockedReason: 'nothing to prune — no knowledge store yet' }; + return { pass: false, exitCode: 2, removed: [], preview: [], blockedReason: 'nothing to prune — no knowledge store yet' }; } // Bucket discovery and selector evaluation both run INSIDE the transaction, @@ -117,12 +151,47 @@ export function pruneBuckets({ workspace, home, branchKey = null, merged = false return { kind: 'reject', exitCode: 2, blockedReason: 'no buckets match the given selectors — nothing pruned' }; } + // Preview FIRST — computed for every run, returned on both the refusal and + // the success path, and logged line by line so a human sees what a prune + // costs before (or as) it happens. + const preview = [...selected.values()].map(bucketPreview).sort((a, b) => a.key.localeCompare(b.key)); + for (const p of preview) { + log( + `prune preview ${p.key}${p.branch ? ` (${p.branch})` : ''}: ${p.active ?? '?'} active · ${p.promoted ?? '?'} promoted · ${p.total ?? '?'} total` + ); + } + const losing = preview.filter((p) => p.active === null || p.active > 0); + if (losing.length && !yes) { + return { + kind: 'reject', + exitCode: 2, + preview, + blockedReason: `prune would delete ${losing.reduce((n, p) => n + (p.active || 0), 0)} active, unpromoted learning(s) in ${losing + .map((p) => p.key) + .join(', ')} — promote them first (harness knowledge promote) or re-run with --yes`, + }; + } + const keys = [...selected.keys()].sort(); for (const b of selected.values()) { - fs.rmSync(b.dir, { recursive: true, force: true }); - log(`pruned bucket ${b.key}${b.meta?.branch ? ` (${b.meta.branch})` : ''}`); + // Defense in depth (fs-safe.mjs): a recursive delete is the single most + // destructive syscall in this module, and `branches/` is a hand-editable + // tree. Refuse a bucket whose real path resolves outside the store rather + // than letting rmSync follow a swapped ancestor. + const contained = assertRealpathContained(txDir, path.join('branches', b.key)); + if (!contained) { + return { + kind: 'reject', + exitCode: 1, + preview, + blockedReason: `refused to prune ${b.key} — its real path resolves outside the knowledge store`, + }; + } + fs.rmSync(contained, { recursive: true, force: true }); + const shown = safeBranchName(b.meta?.branch); + log(`pruned bucket ${b.key}${shown ? ` (${shown})` : ''}`); } - return { kind: 'success', commitMessage: `knowledge: prune ${keys.join(', ')}`, keys }; + return { kind: 'success', commitMessage: `knowledge: prune ${keys.join(', ')}`, keys, preview }; }); if (!tx.ok) { @@ -130,6 +199,7 @@ export function pruneBuckets({ workspace, home, branchKey = null, merged = false pass: false, exitCode: 1, removed: [], + preview: [], blockedReason: tx.locked ? 'E_LOCKED: another operation holds the store lock' : `prune failed: ${tx.error?.message || 'store transaction failed'}`, @@ -142,6 +212,7 @@ export function pruneBuckets({ workspace, home, branchKey = null, merged = false pass: false, exitCode: inner.exitCode, removed: [], + preview: inner.preview || [], blockedReason: inner.blockedReason, ...(tx.staleLockNote ? { staleLockRemoved: tx.staleLockNote } : {}), }; @@ -150,6 +221,7 @@ export function pruneBuckets({ workspace, home, branchKey = null, merged = false pass: true, exitCode: 0, removed: inner.keys, + preview: inner.preview || [], blockedReason: null, ...(tx.staleLockNote ? { staleLockRemoved: tx.staleLockNote } : {}), }; diff --git a/packages/harness/lib/knowledge/status.mjs b/packages/harness/lib/knowledge/status.mjs index b78b7b75..93c862e2 100644 --- a/packages/harness/lib/knowledge/status.mjs +++ b/packages/harness/lib/knowledge/status.mjs @@ -1,7 +1,7 @@ import fs from 'node:fs'; import { storeDir, listLearnings, readStoreConfig } from './store.mjs'; -import { isActiveFm } from './consolidate.mjs'; -import { listBuckets, bucketAncestryOk } from './overlay.mjs'; +import { isActiveFm, bucketCounts } from './consolidate.mjs'; +import { listBuckets, bucketAncestryOk, safeBranchName } from './overlay.mjs'; import { deriveGitContext, isDetachedKey } from '../git-context.mjs'; import { indexStatus } from '../index-status.mjs'; @@ -27,7 +27,7 @@ export function knowledgeStatus({ workspace, copilotHome, home } = {}) { try { const derived = deriveGitContext({ workspace, home }); if (derived.branch || derived.detached) { - context = { branch: derived.branch, branchKey: derived.branchKey, detached: derived.detached }; + context = { branch: safeBranchName(derived.branch), branchKey: derived.branchKey, detached: derived.detached }; } } catch { context = null; @@ -53,15 +53,14 @@ export function knowledgeStatus({ workspace, copilotHome, home } = {}) { const buckets = []; if (storeExists) { for (const { key, dir: bucketDir, meta } of listBuckets(dir)) { + // ONE occupancy predicate, shared with `knowledge prune`'s confirmation + // gate (bucketCounts, consolidate.mjs) — the two used to disagree about + // which buckets held live work. let active = 0; let total = 0; let promoted = 0; try { - for (const l of listLearnings(bucketDir)) { - total += 1; - if (l.fm.promoted_to_golden) promoted += 1; - else if (isActiveFm(l.fm)) active += 1; - } + ({ active, promoted, total } = bucketCounts(listLearnings(bucketDir))); } catch { // unreadable bucket — counts stay zero, the row still surfaces } @@ -71,7 +70,7 @@ export function knowledgeStatus({ workspace, copilotHome, home } = {}) { : null; buckets.push({ key, - branch: typeof meta?.branch === 'string' ? meta.branch : null, + branch: safeBranchName(meta?.branch), baseSha: typeof meta?.baseSha === 'string' ? meta.baseSha : null, createdAt, ageDays, diff --git a/packages/harness/lib/orient.mjs b/packages/harness/lib/orient.mjs index 06495f90..ac33c1a8 100644 --- a/packages/harness/lib/orient.mjs +++ b/packages/harness/lib/orient.mjs @@ -14,7 +14,27 @@ import { rankLearnings, explainLearnings } from './knowledge/retrieve.mjs'; import { readStoreConfig, storeDir } from './knowledge/store.mjs'; import { consolidateStatus } from './knowledge/consolidate.mjs'; import { deriveGitContext } from './git-context.mjs'; -import { redactRecallEntry } from './secret-scan.mjs'; +import { redactRecallEntry, redactSecrets } from './secret-scan.mjs'; +import { inertLine } from './knowledge/store.mjs'; + +// The `--json` lane's copy of the git context. buildContextPack already +// redacts, flattens, and caps the branch name before rendering it into the +// pack (a fork checkout's ref name is attacker-influenced), and the session +// keeps the RAW value because resolveWriteLayer compares it against live git +// state — but `orient --json` returned the raw object straight to its +// consumer, with no redaction and no cap, plus the absolute worktree path. +// Same treatment as the pack, at the boundary where the JSON copy is built. +const ORIENT_BRANCH_CAP = 80; +function jsonGitContext(gitContext) { + if (!gitContext) return null; + return { + branch: gitContext.branch ? inertLine(redactSecrets(String(gitContext.branch))).slice(0, ORIENT_BRANCH_CAP) : null, + branchKey: gitContext.branchKey, + detached: gitContext.detached, + headSha: gitContext.headSha, + baseSha: gitContext.baseSha, + }; +} export function runOrient({ workspace, copilotHome, flags, query }) { const q = query || flags.query || ''; @@ -232,7 +252,7 @@ export function runOrient({ workspace, copilotHome, flags, query }) { contextPack: packRel, repoMap: repoMapRef, knowledgeDebt, - gitContext, + gitContext: jsonGitContext(gitContext), gateStatus: newSession.gateStatus, blockedReason: newSession.blockedReason, nextTools, diff --git a/packages/harness/lib/policy.mjs b/packages/harness/lib/policy.mjs index 061a96f3..be834892 100644 --- a/packages/harness/lib/policy.mjs +++ b/packages/harness/lib/policy.mjs @@ -14,6 +14,37 @@ const MODES = new Set(['observe', 'warn', 'enforce']); export const CHECK_SEVERITIES = new Set(['advisory', 'warn', 'enforce']); const POLICY_VERSIONS = new Set([1, 2]); +/** + * The built-in verify checks that can never be downgraded to `advisory` + * (human decision, recorded in docs/MEMORY-MODEL.md). `advisory` does not + * merely soften a report: resolveOutcome (verify.mjs) filters advisory checks + * OUT of the outcome entirely, so `outcome: passed` would be written into the + * evidence artifact that `harness gate` and `harness compound` trust — a + * policy marking `scope` advisory would open the gate on real scope + * violations AND mint a "verified" fix episode from a run that never + * verified. Every id verify.mjs pushes as a BUILT-IN check is listed here + * except the ones whose built-in DEFAULT is already advisory + * (`structural-expectations`) — those stay downgradable because advisory is + * what they already are. Project-defined named checks (checks.yaml) are + * deliberately NOT listed: a team's own command is theirs to mark advisory. + * `warn` remains available for every check — it degrades a failure to + * inconclusive (a non-zero exit under enforce), it does not erase it. + */ +export const NON_ADVISORY_CHECK_IDS = new Set([ + 'plan-selection', + 'plan-schema', + 'plan-readiness', + 'plan-state', + 'phase-tasks', + 'criteria-evidence', + 'scope', + 'primitive-evidence', + 'required-reviews', + 'hard-gaps', + 'critical-findings', + 'workspace-stability', +]); + function parseCheckSeverities(policy, policyPath) { if (policy.checks === undefined || policy.checks === null) return {}; if (typeof policy.checks !== 'object' || Array.isArray(policy.checks)) { @@ -30,6 +61,11 @@ function parseCheckSeverities(policy, policyPath) { `Invalid harness policy ${policyPath}: checks.${id}.severity must be advisory, warn, or enforce (got ${config.severity})` ); } + if (config.severity === 'advisory' && NON_ADVISORY_CHECK_IDS.has(id)) { + throw new Error( + `Invalid harness policy ${policyPath}: checks.${id}.severity cannot be advisory — ${id} is a gating verify check whose failure must reach the evidence outcome; use warn to degrade it instead` + ); + } severities[id] = config.severity; } return severities; diff --git a/packages/harness/lib/verify.mjs b/packages/harness/lib/verify.mjs index c9432737..2c4f43cc 100644 --- a/packages/harness/lib/verify.mjs +++ b/packages/harness/lib/verify.mjs @@ -11,6 +11,8 @@ import { checkSeverityFor, enforcementExitCode, loadPolicy } from './policy.mjs' import { verifyPrimitiveGovernance } from './primitive-governance.mjs'; import { validatePlanReadiness } from './plan-readiness.mjs'; import { STRUCTURAL_CHECK_ID, runStructuralExpectations } from './structural/expectations.mjs'; +import { redactSecrets } from './secret-scan.mjs'; +import { inertLine } from './knowledge/store.mjs'; const CHECKS_REL = '.github/harness/checks.yaml'; @@ -109,14 +111,53 @@ function applyCheckSeverities(checks, policy) { }); } -function collectAdvisoryFailures(checks) { +// Advisory findings carry CURRENT-SIDE REPO TEXT (structural/expectations.mjs +// derives its symbol names from a lexical extractor whose per-language +// patterns are not length-bounded — a `.tf` string literal spanning newlines +// can produce a six-figure-byte "symbol name"), and they are copied verbatim +// into `.harness/evidence/*.json` and `verify --json`. Every other surface +// that renders less-trusted repo-derived text redacts it, flattens control +// characters, and caps it; the evidence lane must do the same at the point it +// copies the payload, so the guarantee holds no matter which check produced +// the findings. +const ADVISORY_TEXT_CAP = 240; +const ADVISORY_LIST_CAP = 20; +const ADVISORY_FINDINGS_CAP = 50; +const ADVISORY_DEPTH_CAP = 3; + +function advisoryText(value) { + return inertLine(redactSecrets(String(value ?? ''))).slice(0, ADVISORY_TEXT_CAP); +} + +/** Redact + flatten + cap every string reachable in a finding, bound every + * array/object to ADVISORY_LIST_CAP entries, and stop at ADVISORY_DEPTH_CAP — + * shape-agnostic, so a check that grows a new findings field is covered + * without this function knowing about it. */ +function advisoryValue(value, depth = 0) { + if (typeof value === 'string') return advisoryText(value); + if (typeof value === 'number' || typeof value === 'boolean' || value === null) return value; + if (depth >= ADVISORY_DEPTH_CAP) return null; + if (Array.isArray(value)) return value.slice(0, ADVISORY_LIST_CAP).map((entry) => advisoryValue(entry, depth + 1)); + if (value && typeof value === 'object') { + const out = {}; + for (const [key, entry] of Object.entries(value).slice(0, ADVISORY_LIST_CAP)) { + out[advisoryText(key)] = advisoryValue(entry, depth + 1); + } + return out; + } + return null; +} + +export function collectAdvisoryFailures(checks) { return checks .filter((check) => check.severity === 'advisory' && !['passed', 'skipped'].includes(check.status)) .map((check) => ({ id: check.id, status: check.status, - message: check.message, - ...(check.findings ? { findings: check.findings } : {}), + message: advisoryText(check.message), + ...(check.findings + ? { findings: (Array.isArray(check.findings) ? check.findings : []).slice(0, ADVISORY_FINDINGS_CAP).map((f) => advisoryValue(f)) } + : {}), })); } diff --git a/packages/harness/test/knowledge-boundary-hardening.test.mjs b/packages/harness/test/knowledge-boundary-hardening.test.mjs new file mode 100644 index 00000000..c6146481 --- /dev/null +++ b/packages/harness/test/knowledge-boundary-hardening.test.mjs @@ -0,0 +1,617 @@ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; +import { ensureStore, listLearnings, readLedger, readGovernance, appendGovernance } from '../lib/knowledge/store.mjs'; +import { applyOps, rebuildIndex } from '../lib/knowledge/apply.mjs'; +import { absorbHandEdits, rebuildStore } from '../lib/knowledge/admin.mjs'; +import { buildPromotionOps, PROMOTE_OPS_REL, promotionDigest } from '../lib/knowledge/promote.mjs'; +import { pruneBuckets } from '../lib/knowledge/prune.mjs'; +import { knowledgeStatus } from '../lib/knowledge/status.mjs'; +import { consolidateStatus, verifiedAndPlans, isActiveFm } from '../lib/knowledge/consolidate.mjs'; +import { bucketDirFor, readBucketMeta, isSafeBucketKey } from '../lib/knowledge/overlay.mjs'; +import { retrievalExclusion } from '../lib/knowledge/retrieve.mjs'; +import { branchKeyFor } from '../lib/git-context.mjs'; +import { runOrient } from '../lib/orient.mjs'; + +/** + * Boundary regressions for the promotion lane, the layer boundary, and the + * destructive/branch-name surfaces around them. Every test here reproduces a + * concrete escape that the pre-fix code allowed: + * + * A promotion evidence laundering (op-asserted kind/plan, duplicate links) + * B emitter-only admission gates (a hand-authored ops file bypassed them) + * C `promoted_to_golden` erased on re-render (a tombstone that resurrects) + * D a branch-local hand-delete writing a golden-binding governance retire + * F `--layer golden` self-granted by an unattended agent + * H three-strikes quarantine reset by switching branches + * I prune destroying buckets `knowledge status` calls not-prunable + * J the promote op-set written without fs-safe containment + * K destructive ops without an fs-safe realpath guard + * L raw branch names on the `--json` surfaces + */ + +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const binPath = path.join(packageRoot, 'bin', 'harness.mjs'); +const tempDir = (p) => fs.mkdtempSync(path.join(os.tmpdir(), p)); + +function git(cwd, args) { + return spawnSync('git', args, { + cwd, + encoding: 'utf8', + env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' }, + }); +} + +/** Cloned workspace with origin/HEAD → main, checked out on `branch`. */ +function featureWorkspace(branch = 'feature/hardening') { + const origin = tempDir('bh-origin-'); + git(origin, ['init', '-q', '-b', 'main']); + git(origin, ['config', 'user.email', 't@example.test']); + git(origin, ['config', 'user.name', 'T']); + fs.writeFileSync(path.join(origin, 'seed.txt'), 'seed\n'); + git(origin, ['add', '.']); + git(origin, ['commit', '-qm', 'seed']); + const ws = tempDir('bh-ws-'); + git(ws, ['clone', '-q', origin, '.']); + git(ws, ['config', 'user.email', 't@example.test']); + git(ws, ['config', 'user.name', 'T']); + if (branch) git(ws, ['checkout', '-qb', branch]); + return ws; +} + +/** A plain fix-kind episode (no frontmatter kind — a legitimate fix). */ +function writeFixEpisode(ws, rel, branch = null) { + const full = path.join(ws, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + const text = branch ? `---\ndate: 2026-08-01\nbranch: ${branch}\n---\n\nfix evidence for ${rel}.\n` : `fix evidence for ${rel}.\n`; + fs.writeFileSync(full, text, 'utf8'); + return { path: rel, sha256: crypto.createHash('sha256').update(text).digest('hex'), kind: 'fix', plan: 'docs/plans/p1.md' }; +} + +/** An insight-kind episode — its OWN frontmatter says `kind: insight`. */ +function writeInsightEpisode(ws, rel) { + const full = path.join(ws, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + const text = `---\ntitle: "${rel}"\nkind: insight\ndate: 2026-08-01\n---\n\ninsight body for ${rel}.\n`; + fs.writeFileSync(full, text, 'utf8'); + return { path: rel, sha256: crypto.createHash('sha256').update(text).digest('hex'), kind: 'insight', plan: null }; +} + +function writeOps(ws, ops, envelope = null) { + const p = path.join(ws, `ops-${crypto.randomUUID()}.json`); + fs.writeFileSync(p, JSON.stringify({ schema: 1, ...(envelope ? { promotion: envelope } : {}), ops })); + return p; +} + +function addOp(ws, slug, over = {}) { + return { + op: 'ADD', + domain: 'sql', + slug, + trigger: `trigger for ${slug}`, + body: `Claim body for ${slug}.`, + episodes: over.episodes || [writeFixEpisode(ws, `docs/solutions/perf/${slug}.md`)], + ...over, + }; +} + +function seedBucketLearning(ws, home, slug, over = {}) { + const applied = applyOps({ workspace: ws, opsPath: writeOps(ws, [addOp(ws, slug, over)]), home }); + assert.equal(applied.exitCode, 0, JSON.stringify(applied.rejected)); + assert.equal(applied.layer, 'branch'); + return applied; +} + +/** Hand-author a promotion op-set (digest computed exactly as apply.mjs does) + * and run it through the REAL `harness consolidate --apply` CLI — the lane an + * emitter-only gate never sees. */ +function handAuthoredPromotion(ws, home, branchKey, ops) { + const opsFull = path.join(ws, `hand-promote-${crypto.randomUUID()}.json`); + fs.writeFileSync( + opsFull, + JSON.stringify({ schema: 1, promotion: { branchKey, meta: null, digest: promotionDigest(ops) }, ops }, null, 2) + ); + return opsFull; +} + +function cli(ws, home, args) { + return spawnSync(process.execPath, [binPath, ...args, '--workspace', ws, '--json'], { + encoding: 'utf8', + env: { ...process.env, HARNESS_HOME: home }, + }); +} + +function sourceStamp(file) { + return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +} + +// --------------------------------------------------------------------------- +// A — promotion evidence laundering +// --------------------------------------------------------------------------- + +test('A: a promotion op cannot re-label recorded evidence — kind/plan are copied from the source learning, never the ops file', () => { + const ws = featureWorkspace('feature/launder'); + const home = tempDir('bh-home-a-'); + const ep = writeInsightEpisode(ws, 'docs/solutions/perf/observation.md'); + seedBucketLearning(ws, home, 'laundered', { + trigger: 'observing a slow plan', + body: 'Sequential scans on a big table are often a missing index.', + episodes: [ep], + }); + const { dir } = ensureStore(ws, { home }); + const key = branchKeyFor('feature/launder'); + const source = listLearnings(bucketDirFor(dir, key)).find((l) => l.id === 'sql/laundered'); + assert.equal(source.fm.episodes[0].kind, 'insight', 'precondition: the branch claim records insight-only evidence'); + + // The attack: the same recorded episode, re-asserted as a verified fix on a plan. + const ops = [ + { + op: 'ADD', + domain: 'sql', + slug: 'laundered', + trigger: 'observing a slow plan', + body: 'Sequential scans on a big table are often a missing index.', + episodes: [{ path: ep.path, sha256: ep.sha256, kind: 'fix', plan: 'docs/plans/fabricated.md' }], + source: { id: 'sql/laundered', sha256: sourceStamp(source.file) }, + }, + ]; + const applied = applyOps({ workspace: ws, opsPath: handAuthoredPromotion(ws, home, key, ops), home }); + assert.equal(applied.exitCode, 0, JSON.stringify(applied.rejected)); + + const golden = listLearnings(dir).find((l) => l.id === 'sql/laundered'); + assert.equal(golden.fm.episodes.length, 1); + assert.equal(golden.fm.episodes[0].kind, 'insight', 'the promoted claim records the SOURCE kind, not the asserted one'); + assert.equal(golden.fm.episodes[0].plan, '', 'the promoted claim records the SOURCE plan, not the asserted one'); + const { verified, plans } = verifiedAndPlans(golden.fm); + assert.equal(verified, 0, 'an insight-only claim never counts as a verified fix after promotion'); + assert.equal(plans, 0); +}); + +test('A: an op listing the same episode more than once is refused — one link per episode', () => { + const ws = featureWorkspace('feature/dupes'); + const home = tempDir('bh-home-a2-'); + const ep = writeFixEpisode(ws, 'docs/solutions/perf/dupe.md'); + const applied = applyOps({ + workspace: ws, + opsPath: writeOps(ws, [addOp(ws, 'dupes', { episodes: [ep, { ...ep, plan: 'docs/plans/p2.md' }, { ...ep, plan: 'docs/plans/p3.md' }] })]), + home, + }); + assert.equal(applied.exitCode, 1); + assert.equal(applied.rejected[0].code, 'E_SCHEMA'); + assert.match(applied.rejected[0].reason, /listed more than once/); +}); + +test('A: a promotion op cannot inflate evidence by repeating one recorded episode across fabricated plans', () => { + const ws = featureWorkspace('feature/inflate'); + const home = tempDir('bh-home-a3-'); + const ep = writeFixEpisode(ws, 'docs/solutions/perf/single.md'); + seedBucketLearning(ws, home, 'inflated', { episodes: [ep] }); + const { dir } = ensureStore(ws, { home }); + const key = branchKeyFor('feature/inflate'); + const source = listLearnings(bucketDirFor(dir, key)).find((l) => l.id === 'sql/inflated'); + + const ops = [ + { + op: 'ADD', + domain: 'sql', + slug: 'inflated', + trigger: 'trigger for inflated', + body: 'Claim body for inflated.', + episodes: [ + { path: ep.path, sha256: ep.sha256, kind: 'fix', plan: 'docs/plans/p1.md' }, + { path: ep.path, sha256: ep.sha256, kind: 'fix', plan: 'docs/plans/p2.md' }, + { path: ep.path, sha256: ep.sha256, kind: 'fix', plan: 'docs/plans/p3.md' }, + ], + source: { id: 'sql/inflated', sha256: sourceStamp(source.file) }, + }, + ]; + const applied = applyOps({ workspace: ws, opsPath: handAuthoredPromotion(ws, home, key, ops), home }); + assert.equal(applied.exitCode, 1, 'a duplicated-evidence promotion never lands'); + assert.match(applied.rejected[0].reason, /listed more than once/); + assert.equal(listLearnings(dir).length, 0, 'golden untouched'); +}); + +// --------------------------------------------------------------------------- +// B — admission gates re-derived by the sole writer +// --------------------------------------------------------------------------- + +test('B: a hand-authored promotion envelope is refused for path-shaped, detached, non-ancestor, and non-active sources — through consolidate --apply', () => { + const ws = featureWorkspace('feature/gates'); + const home = tempDir('bh-home-b-'); + const ep = writeFixEpisode(ws, 'docs/solutions/perf/gated.md'); + seedBucketLearning(ws, home, 'gated', { episodes: [ep] }); + const { dir } = ensureStore(ws, { home }); + const key = branchKeyFor('feature/gates'); + const bucketDir = bucketDirFor(dir, key); + const source = listLearnings(bucketDir).find((l) => l.id === 'sql/gated'); + const baseOps = () => [ + { + op: 'ADD', + domain: 'sql', + slug: 'gated', + trigger: 'trigger for gated', + body: 'Claim body for gated.', + episodes: [{ path: ep.path, sha256: ep.sha256, kind: 'fix', plan: 'docs/plans/p1.md' }], + source: { id: 'sql/gated', sha256: sourceStamp(source.file) }, + }, + ]; + + // (1) key shapes — every one the emitter refuses, refused by the writer too. + for (const badKey of ['../evil', 'a/b', 'a\\b', '.', '..', 'C:', 'foo:bar', path.resolve(os.tmpdir(), 'abs')]) { + assert.equal(isSafeBucketKey(badKey), false, `isSafeBucketKey must reject ${JSON.stringify(badKey)}`); + const res = cli(ws, home, ['consolidate', '--apply', '--ops', handAuthoredPromotion(ws, home, badKey, baseOps())]); + assert.equal(res.status, 1, `${badKey}: ${res.stdout}${res.stderr}`); + assert.match(JSON.parse(res.stdout).rejected[0].reason, /plain branchKey/, `key ${badKey}`); + } + + // (2) detached bucket — never promotable, derived from the key shape. + const detachedRes = cli(ws, home, [ + 'consolidate', + '--apply', + '--ops', + handAuthoredPromotion(ws, home, 'detached-abcdefabcdef', baseOps()), + ]); + assert.equal(detachedRes.status, 1, detachedRes.stdout + detachedRes.stderr); + assert.match(JSON.parse(detachedRes.stdout).rejected[0].reason, /never promotable/); + + // (3) non-active source: superseded on disk. renderLearning writes a FRESH + // golden file with superseded_by: null, so an ungated promotion strips the + // tombstone en route. + const supersededFile = source.file; + fs.writeFileSync(supersededFile, fs.readFileSync(supersededFile, 'utf8').replace('superseded_by: null', 'superseded_by: sql/newer'), 'utf8'); + git(dir, ['add', '-A']); + git(dir, ['-c', 'user.name=t', '-c', 'user.email=t@example.test', 'commit', '-qm', 'hand edit']); + const supersededOps = [{ ...baseOps()[0], source: { id: 'sql/gated', sha256: sourceStamp(supersededFile) } }]; + const supersededRes = cli(ws, home, ['consolidate', '--apply', '--ops', handAuthoredPromotion(ws, home, key, supersededOps)]); + assert.equal(supersededRes.status, 1, supersededRes.stdout + supersededRes.stderr); + assert.match(JSON.parse(supersededRes.stdout).rejected[0].reason, /not an active, unpromoted bucket learning/); + assert.equal(listLearnings(dir).length, 0, 'nothing was laundered into golden'); + + // (3b) a promotion envelope carries only file-touching ops — a hand-authored + // NOOP would otherwise consume its episodes into the GOLDEN ledger from any + // branch, since promotion mode pins the write layer to golden. + const noopRes = cli(ws, home, [ + 'consolidate', + '--apply', + '--ops', + handAuthoredPromotion(ws, home, key, [{ op: 'NOOP', reason: 'clearing debt', episodes: [{ path: ep.path, sha256: ep.sha256 }] }]), + ]); + assert.equal(noopRes.status, 1, noopRes.stdout + noopRes.stderr); + assert.match(JSON.parse(noopRes.stdout).rejected[0].reason, /never NOOP/); + + // (4) non-ancestor bucket (force-push name reuse) — refused at write time + // from the bucket's OWN meta.json, never the envelope's copy. + fs.writeFileSync(supersededFile, fs.readFileSync(supersededFile, 'utf8').replace('superseded_by: sql/newer', 'superseded_by: null'), 'utf8'); + const metaPath = path.join(bucketDir, 'meta.json'); + fs.writeFileSync(metaPath, JSON.stringify({ ...readBucketMeta(bucketDir), baseSha: 'f'.repeat(40) }) + '\n'); + git(dir, ['add', '-A']); + git(dir, ['-c', 'user.name=t', '-c', 'user.email=t@example.test', 'commit', '-qm', 'reuse']); + const reuseOps = [{ ...baseOps()[0], source: { id: 'sql/gated', sha256: sourceStamp(supersededFile) } }]; + const reuseRes = cli(ws, home, ['consolidate', '--apply', '--ops', handAuthoredPromotion(ws, home, key, reuseOps)]); + assert.equal(reuseRes.status, 1, reuseRes.stdout + reuseRes.stderr); + assert.match(JSON.parse(reuseRes.stdout).rejected[0].reason, /unrelated history/); + assert.equal(listLearnings(dir).length, 0, 'golden still untouched'); +}); + +// --------------------------------------------------------------------------- +// C — promoted_to_golden survives, and the tombstone is really inactive +// --------------------------------------------------------------------------- + +test('C: a promoted bucket entry stays tombstoned — the bucket INDEX drops it, a STRENGTHEN cannot resurrect it, and prune --merged still sees it', () => { + const ws = featureWorkspace('feature/tombstone'); + const home = tempDir('bh-home-c-'); + const first = writeFixEpisode(ws, 'docs/solutions/perf/tomb-a.md'); + seedBucketLearning(ws, home, 'tombstoned', { episodes: [first] }); + const { dir } = ensureStore(ws, { home }); + const key = branchKeyFor('feature/tombstone'); + const bucketDir = bucketDirFor(dir, key); + + const emitted = buildPromotionOps({ workspace: ws, home, all: true }); + assert.equal(emitted.pass, true, emitted.blockedReason); + assert.equal(applyOps({ workspace: ws, opsPath: path.join(ws, PROMOTE_OPS_REL), home }).exitCode, 0); + + const tombstone = listLearnings(bucketDir).find((l) => l.id === 'sql/tombstoned'); + assert.equal(tombstone.fm.promoted_to_golden, 'sql/tombstoned'); + assert.equal(isActiveFm(tombstone.fm), false, 'a branch→golden tombstone is not an active learning'); + assert.ok( + !fs.readFileSync(path.join(bucketDir, 'INDEX.md'), 'utf8').includes('sql/tombstoned'), + 'the bucket INDEX must agree with retrievalExclusion and drop the tombstone' + ); + + // A STRENGTHEN aimed at the tombstoned bucket entry must be refused, not + // re-rendered into a live claim that shadows the golden claim it became. + const more = writeFixEpisode(ws, 'docs/solutions/perf/tomb-b.md'); + const strengthen = applyOps({ + workspace: ws, + opsPath: writeOps(ws, [{ op: 'STRENGTHEN', target: 'sql/tombstoned', episodes: [more] }]), + home, + }); + assert.equal(strengthen.exitCode, 1, JSON.stringify(strengthen)); + assert.equal(strengthen.rejected[0].code, 'E_TARGET'); + + const after = listLearnings(bucketDir).find((l) => l.id === 'sql/tombstoned'); + assert.equal(after.fm.promoted_to_golden, 'sql/tombstoned', 'the tombstone survives'); + assert.equal(retrievalExclusion(after), 'promoted-to-golden', 'the claim stays excluded from retrieval'); + assert.equal(buildPromotionOps({ workspace: ws, home, all: true }).pass, false, 'never re-offered for promotion'); + + const pruned = pruneBuckets({ workspace: ws, home, merged: true }); + assert.equal(pruned.pass, true, pruned.blockedReason); + assert.deepEqual(pruned.removed, [key], 'prune --merged still recognizes the fully-promoted bucket'); +}); + +// --------------------------------------------------------------------------- +// D — a bucket hand-delete is not a store-wide retirement +// --------------------------------------------------------------------------- + +test('D: hand-deleting a BRANCH copy never retires the golden claim of the same id (a golden-only delete still does)', () => { + const ws = featureWorkspace(null); // start on main + const home = tempDir('bh-home-d-'); + const goldenEp = writeFixEpisode(ws, 'docs/solutions/perf/golden-x.md', 'main'); + const goldenApply = applyOps({ workspace: ws, opsPath: writeOps(ws, [addOp(ws, 'shared-x', { episodes: [goldenEp] })]), home }); + assert.equal(goldenApply.exitCode, 0, JSON.stringify(goldenApply.rejected)); + assert.equal(goldenApply.layer, 'golden'); + + git(ws, ['checkout', '-qb', 'feature/throwaway']); + const branchEp = writeFixEpisode(ws, 'docs/solutions/perf/branch-x.md'); + seedBucketLearning(ws, home, 'shared-x', { episodes: [branchEp], body: 'A throwaway branch version of the claim.' }); + + const { dir } = ensureStore(ws, { home }); + const key = branchKeyFor('feature/throwaway'); + const bucketCopy = listLearnings(bucketDirFor(dir, key)).find((l) => l.id === 'sql/shared-x'); + assert.ok(bucketCopy, 'precondition: both layers hold sql/shared-x'); + + // The human deletes ONLY the throwaway branch copy. + fs.rmSync(bucketCopy.file, { force: true }); + const absorbed = absorbHandEdits({ workspace: ws, home }); + assert.deepEqual(absorbed.deleted, ['sql/shared-x']); + + assert.equal(readGovernance(dir).has('sql/shared-x'), false, 'a bucket-scoped delete writes no store-wide governance decision'); + const goldenStill = listLearnings(dir).find((l) => l.id === 'sql/shared-x'); + assert.ok(goldenStill, 'the golden claim survives'); + assert.equal(isActiveFm(goldenStill.fm), true, 'and stays active'); + + // `consolidate --rebuild --yes` wipes the corpus and replays governance — + // with no retire recorded there is nothing for it to resurrect. + git(ws, ['checkout', '-q', 'main']); + const rebuilt = rebuildStore({ workspace: ws, home, yes: true, copilotHome: tempDir('bh-ch-d-') }); + assert.equal(rebuilt.pass, true, rebuilt.blockedReason); + assert.equal(readGovernance(dir).has('sql/shared-x'), false, 'a rebuild cannot resurrect a retire that was never recorded'); + + // Control: deleting the LAST copy of an id is still a retirement. + git(ws, ['checkout', '-q', 'feature/throwaway']); + assert.equal(applyOps({ workspace: ws, opsPath: writeOps(ws, [addOp(ws, 'shared-x', { episodes: [branchEp] })]), home }).exitCode, 0); + const soleCopy = listLearnings(bucketDirFor(dir, key)).find((l) => l.id === 'sql/shared-x'); + fs.rmSync(soleCopy.file, { force: true }); + const absorbedSole = absorbHandEdits({ workspace: ws, home }); + assert.deepEqual(absorbedSole.deleted, ['sql/shared-x']); + assert.equal(readGovernance(dir).get('sql/shared-x')?.action, 'retire', 'no layer holds it any more — a real retirement'); +}); + +// --------------------------------------------------------------------------- +// F — layer containment is not self-grantable +// --------------------------------------------------------------------------- + +test('F: --layer golden needs the human-authority signal; --layer branch is refused instead of silently ignored', () => { + const ws = featureWorkspace('feature/selfgrant'); + const home = tempDir('bh-home-f-'); + const ep = writeFixEpisode(ws, 'docs/solutions/perf/selfgrant.md'); + const opsPath = writeOps(ws, [addOp(ws, 'selfgrant', { episodes: [ep] })]); + const { dir } = ensureStore(ws, { home }); + + const ungated = applyOps({ workspace: ws, opsPath, home, layer: 'golden' }); + assert.equal(ungated.exitCode, 2, JSON.stringify(ungated)); + assert.equal(ungated.rejected[0].code, 'E_LAYER'); + assert.equal(listLearnings(dir).length, 0, 'nothing reached golden'); + + const branchOverride = applyOps({ workspace: ws, opsPath, home, layer: 'branch' }); + assert.equal(branchOverride.exitCode, 2, JSON.stringify(branchOverride)); + assert.equal(branchOverride.rejected[0].code, 'E_LAYER'); + + const approved = applyOps({ workspace: ws, opsPath, home, layer: 'golden', approve: true }); + assert.equal(approved.exitCode, 0, JSON.stringify(approved.rejected)); + assert.equal(approved.layer, 'golden', 'an explicitly approved override still works'); +}); + +// --------------------------------------------------------------------------- +// H — quarantine is store-global, not per-branch +// --------------------------------------------------------------------------- + +test('H: three strikes cannot be reset by switching branches — the quarantine marker is store-global and every lane reports it', () => { + const ws = featureWorkspace('feature/strike-a'); + const home = tempDir('bh-home-h-'); + const copilotHome = tempDir('bh-ch-h-'); + const ep = writeFixEpisode(ws, 'docs/solutions/perf/striker.md'); + const opsPath = writeOps(ws, [addOp(ws, 'striker', { episodes: [ep], body: 'x'.repeat(1300) })]); + const { dir } = ensureStore(ws, { home }); + + for (let i = 0; i < 2; i++) { + const res = applyOps({ workspace: ws, opsPath, home }); + assert.equal(res.exitCode, 1); + assert.equal(res.rejected[0].code, 'E_BYTE_CAP'); + } + assert.equal(readLedger(dir).filter((e) => e.failure).length, 2, 'strikes accumulate in the store-global ledger'); + + // The escape: a different branch used to start the count over at zero. + git(ws, ['checkout', '-qb', 'feature/strike-b']); + const third = applyOps({ workspace: ws, opsPath, home }); + assert.equal(third.exitCode, 1); + const ledger = readLedger(dir); + assert.equal(ledger.filter((e) => e.failure).length, 3, 'the third strike lands on the same running count'); + assert.equal(ledger.filter((e) => e.quarantined).length, 1, 'and quarantines on exactly the third'); + + // Every lane reports it — including a THIRD branch that never saw a strike. + git(ws, ['checkout', '-qb', 'feature/strike-c']); + const status = consolidateStatus({ workspace: ws, copilotHome, home }); + assert.equal(status.quarantined.length, 1, 'consolidate --status reports the quarantine from any branch'); + assert.ok(!status.unconsolidated.some((u) => u.path === ep.path), 'a quarantined episode stops counting as debt in every lane'); +}); + +// --------------------------------------------------------------------------- +// I — prune previews and confirms before destroying live work +// --------------------------------------------------------------------------- + +test('I: prune refuses to delete a bucket holding active, unpromoted learnings without --yes, and previews what is at stake', () => { + const ws = featureWorkspace('feature/liveprune'); + const home = tempDir('bh-home-i-'); + seedBucketLearning(ws, home, 'still-live'); + const { dir } = ensureStore(ws, { home }); + const key = branchKeyFor('feature/liveprune'); + + // `knowledge status` says this bucket is NOT prunable... + const status = knowledgeStatus({ workspace: ws, home }); + const row = status.buckets.find((b) => b.key === key); + assert.equal(row.active, 1); + assert.equal(row.prunable, false); + + // ...so a stale/branch selector must not silently delete it. + fs.writeFileSync( + path.join(bucketDirFor(dir, key), 'meta.json'), + JSON.stringify({ ...readBucketMeta(bucketDirFor(dir, key)), createdAt: new Date(Date.now() - 90 * 86_400_000).toISOString() }) + '\n' + ); + const refused = pruneBuckets({ workspace: ws, home, staleDays: 30 }); + assert.equal(refused.pass, false, 'a stale selector must not destroy live work unattended'); + assert.equal(refused.exitCode, 2); + assert.match(refused.blockedReason, /--yes/); + assert.deepEqual( + refused.preview.map((p) => ({ key: p.key, active: p.active, promoted: p.promoted, total: p.total })), + [{ key, active: 1, promoted: 0, total: 1 }], + 'the refusal names exactly what would be lost' + ); + assert.ok(fs.existsSync(bucketDirFor(dir, key)), 'the bucket is still there'); + + const confirmed = pruneBuckets({ workspace: ws, home, staleDays: 30, yes: true }); + assert.equal(confirmed.pass, true, confirmed.blockedReason); + assert.deepEqual(confirmed.removed, [key]); + assert.equal(confirmed.preview.length, 1, 'a confirmed prune still reports the preview'); + assert.ok(!fs.existsSync(bucketDirFor(dir, key))); +}); + +test('I: a bucket status calls prunable (nothing active) still prunes unattended', () => { + const ws = featureWorkspace('feature/emptyprune'); + const home = tempDir('bh-home-i2-'); + seedBucketLearning(ws, home, 'landed'); + const key = branchKeyFor('feature/emptyprune'); + assert.equal(buildPromotionOps({ workspace: ws, home, all: true }).pass, true); + assert.equal(applyOps({ workspace: ws, opsPath: path.join(ws, PROMOTE_OPS_REL), home }).exitCode, 0); + + const status = knowledgeStatus({ workspace: ws, home }); + assert.equal(status.buckets.find((b) => b.key === key).prunable, true); + const pruned = pruneBuckets({ workspace: ws, home, branchKey: key }); + assert.equal(pruned.pass, true, pruned.blockedReason); + assert.deepEqual(pruned.removed, [key]); +}); + +// --------------------------------------------------------------------------- +// J / K — fs-safe containment on the write and the destructive paths +// --------------------------------------------------------------------------- + +test('J: the promote op-set refuses to write through a symlinked .harness directory', () => { + const ws = featureWorkspace('feature/opswrite'); + const home = tempDir('bh-home-j-'); + seedBucketLearning(ws, home, 'contained'); + + const outside = tempDir('bh-outside-'); + fs.rmSync(path.join(ws, '.harness'), { recursive: true, force: true }); + fs.symlinkSync(outside, path.join(ws, '.harness'), 'dir'); + + const emitted = buildPromotionOps({ workspace: ws, home, all: true }); + assert.equal(emitted.pass, false, 'a symlinked .harness must refuse the write, not follow it'); + assert.match(emitted.blockedReason, /symlink/i); + assert.ok(!fs.existsSync(path.join(outside, 'promote-ops.json')), 'nothing landed outside the workspace'); +}); + +test('K: every destructive knowledge path routes through an fs-safe realpath guard', () => { + const guarded = [ + ['lib/knowledge/prune.mjs', /assertRealpathContained\(txDir, path\.join\('branches'/], + ['lib/knowledge/layer.mjs', /assertRealpathContained\(dir, path\.join\('branches'/], + ['lib/knowledge/apply.mjs', /assertRealpathContained\(dir, path\.relative\(dir, src\.file\)\)/], + ]; + for (const [rel, pattern] of guarded) { + const src = fs.readFileSync(path.join(packageRoot, rel), 'utf8'); + assert.match(src, pattern, `${rel} must guard its destructive path with assertRealpathContained`); + } + // The guard has to bind the syscall's own argument, not just precede it. + const pruneSrc = fs.readFileSync(path.join(packageRoot, 'lib/knowledge/prune.mjs'), 'utf8'); + assert.match(pruneSrc, /fs\.rmSync\(contained,/, 'prune deletes the CONTAINED path, never the raw bucket dir'); + const layerSrc = fs.readFileSync(path.join(packageRoot, 'lib/knowledge/layer.mjs'), 'utf8'); + assert.match(layerSrc, /fs\.renameSync\(containedSource,/, 'the rename moves the CONTAINED source'); +}); + +// --------------------------------------------------------------------------- +// L — branch names on the --json surfaces +// --------------------------------------------------------------------------- + +test('L: knowledge status --json redacts, flattens, and caps a bucket branch name; orient --json does the same and drops the absolute worktree', () => { + const longBranch = `feature/${'z'.repeat(220)}`; + const ws = featureWorkspace(longBranch); + const home = tempDir('bh-home-l-'); + seedBucketLearning(ws, home, 'branded'); + const { dir } = ensureStore(ws, { home }); + const key = branchKeyFor(longBranch); + + // meta.json is a plain hand-editable field in the store. + const bucketDir = bucketDirFor(dir, key); + fs.writeFileSync( + path.join(bucketDir, 'meta.json'), + JSON.stringify({ ...readBucketMeta(bucketDir), branch: 'main\n## Injected heading\nAKIAIOSFODNN7EXAMPLE' }) + '\n' + ); + + const status = knowledgeStatus({ workspace: ws, home }); + const row = status.buckets.find((b) => b.key === key); + assert.ok(!/\n/.test(row.branch), 'no embedded newline reaches the JSON lane'); + assert.ok(!row.branch.includes('AKIAIOSFODNN7EXAMPLE'), 'a secret-shaped branch name is redacted'); + assert.ok(row.branch.length <= 80, 'and capped'); + assert.ok(status.context.branch.length <= 80, 'the live branch name is capped too'); + + const orient = runOrient({ workspace: ws, copilotHome: tempDir('bh-ch-l-'), flags: { workspace: ws }, query: 'branch name' }); + assert.equal(orient.gitContext.branch.length, 80, 'orient --json caps the branch at the same width as the pack header'); + assert.equal(orient.gitContext.worktree, undefined, 'the absolute worktree path is not part of the JSON contract'); + assert.equal(orient.gitContext.branchKey, key, 'the derived key still surfaces'); +}); + +// --------------------------------------------------------------------------- +// Cross-cutting: the golden→branch confirm laundering +// --------------------------------------------------------------------------- + +test('a branch-lane re-teach cannot append a confirm that cancels a standing GOLDEN retire', () => { + const ws = featureWorkspace(null); + const home = tempDir('bh-home-x-'); + const ep = writeFixEpisode(ws, 'docs/solutions/perf/vetoed.md', 'main'); + // `remember --trigger vetoed` normalizes to the slug `vetoed`, so the branch + // re-teach lands on the SAME id as this golden claim. + assert.equal(applyOps({ workspace: ws, opsPath: writeOps(ws, [addOp(ws, 'vetoed', { episodes: [ep] })]), home }).exitCode, 0); + const { dir } = ensureStore(ws, { home }); + appendGovernance(dir, { id: 'sql/vetoed', action: 'retire', reason: 'human veto', to: null, at: new Date().toISOString() }); + + git(ws, ['checkout', '-qb', 'feature/retract']); + const res = spawnSync( + process.execPath, + [binPath, 'remember', 'The vetoed claim, re-taught branch-locally.', '--trigger', 'vetoed', '--domain', 'sql', '--workspace', ws, '--copilot-home', tempDir('bh-ch-x-'), '--json'], + { encoding: 'utf8', env: { ...process.env, HARNESS_HOME: home } } + ); + assert.equal(res.status, 0, res.stdout + res.stderr); + + assert.equal( + readGovernance(dir).get('sql/vetoed').action, + 'retire', + 'a branch-lane re-teach never speaks for golden — the standing retire still stands' + ); + const bucketCopy = listLearnings(bucketDirFor(dir, branchKeyFor('feature/retract'))).find((l) => l.id === 'sql/vetoed'); + assert.equal(bucketCopy.fm.status, 'retired', 'the standing decision is reapplied to the layer that was written'); +}); + +test('rebuildIndex excludes branch→golden tombstones so INDEX.md agrees with retrievalExclusion', () => { + const dir = tempDir('bh-index-'); + fs.mkdirSync(path.join(dir, 'learnings', 'sql'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'learnings', 'sql', 'gone.md'), + '---\nschema: 1\ntrigger: "tombstoned trigger"\nstatus: active\nsource: auto\nepisodes:\nanchors: []\nsuperseded_by: null\nlast_confirmed: null\npromoted_to_golden: sql/gone\norigin: t\n---\n\nAbsorbed into golden.\n' + ); + rebuildIndex(dir); + assert.ok(!fs.readFileSync(path.join(dir, 'INDEX.md'), 'utf8').includes('sql/gone')); +}); diff --git a/packages/harness/test/quarantine.test.mjs b/packages/harness/test/quarantine.test.mjs index 92f6b15a..5dbd41ba 100644 --- a/packages/harness/test/quarantine.test.mjs +++ b/packages/harness/test/quarantine.test.mjs @@ -144,6 +144,13 @@ test('a byte-cap rejection records one failure entry per run; the 3rd strike qua // count against the same static ledger snapshot, so a single run with two // duplicate refs would double-count toward the 3-strike threshold and // quarantine a run early (on the 2nd run instead of the 3rd). +// +// The duplicate itself is now REJECTED at admission (validateEpisodes, +// apply.mjs — a duplicate link inflates verifiedFixLinks/verifiedAndPlans from +// one episode file), so the rejection code is E_SCHEMA rather than the +// E_BYTE_CAP this op would otherwise have earned. The strike-recorder's own +// dedup invariant is unchanged and still pinned here: one entry per run, the +// quarantine landing on exactly the 3rd. test('an op citing the same episode twice records one failure entry per run (dedup); quarantines on the 3rd run, not earlier', () => { const c = ctx(); const ep = writeEpisode(c.ws, 'perf', 'dup-claim'); @@ -161,10 +168,11 @@ test('an op citing the same episode twice records one failure entry per run (ded for (let i = 0; i < 2; i++) { const res = run(c, ['consolidate', '--apply', '--ops', opsPath]); assert.equal(res.status, 1, res.stderr || res.stdout); + assert.match(JSON.parse(res.stdout).rejected[0].reason, /listed more than once/); } let ledger = readLedger(dir); assert.equal(ledger.length, 2, 'one failure entry per run — the duplicate episode ref must not double-count'); - assert.ok(ledger.every((e) => e.failure === 'E_BYTE_CAP' && !e.quarantined), 'not quarantined after only 2 runs'); + assert.ok(ledger.every((e) => e.failure === 'E_SCHEMA' && !e.quarantined), 'not quarantined after only 2 runs'); const res3 = run(c, ['consolidate', '--apply', '--ops', opsPath]); assert.equal(res3.status, 1, res3.stderr || res3.stdout); diff --git a/packages/harness/test/verify-severity-hardening.test.mjs b/packages/harness/test/verify-severity-hardening.test.mjs new file mode 100644 index 00000000..f0125277 --- /dev/null +++ b/packages/harness/test/verify-severity-hardening.test.mjs @@ -0,0 +1,156 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; +import { loadPolicy, NON_ADVISORY_CHECK_IDS } from '../lib/policy.mjs'; +import { collectAdvisoryFailures } from '../lib/verify.mjs'; +import { STRUCTURAL_CHECK_ID } from '../lib/structural/expectations.mjs'; + +/** + * E — `advisory` is not a severity for a gating check. resolveOutcome + * (verify.mjs) filters advisory checks OUT of the outcome, so downgrading + * `scope` (or criteria/plan/review/gap checks) would write `outcome: passed` + * into the evidence artifact `harness gate` and `harness compound` trust: the + * gate opens on a real scope violation AND a "verified" fix episode is minted + * from a run that never verified. + * + * G — advisory findings carry current-side repo text (a lexical extractor's + * unbounded symbol names) and are copied verbatim into `.harness/evidence/*.json` + * and `verify --json`. They must be redacted, flattened, and capped there. + */ + +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const tempDir = (p) => fs.mkdtempSync(path.join(os.tmpdir(), p)); + +function policyWorkspace(yaml) { + const ws = tempDir('vsh-ws-'); + const full = path.join(ws, '.github', 'harness', 'policy.yaml'); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, yaml, 'utf8'); + return ws; +} + +test('E: a policy downgrading a gating check to advisory is rejected by name', () => { + for (const id of ['scope', 'criteria-evidence', 'plan-schema', 'plan-readiness', 'required-reviews', 'hard-gaps', 'critical-findings', 'workspace-stability']) { + const ws = policyWorkspace(`version: 2\nenforcement: enforce\nchecks:\n ${id}:\n severity: advisory\n`); + assert.throws( + () => loadPolicy(ws), + (err) => err.message.includes(id) && /cannot be advisory/.test(err.message), + `checks.${id}.severity: advisory must be refused with a message naming ${id}` + ); + } +}); + +test('E: a gating check may still be downgraded to warn, and a project-defined named check may be advisory', () => { + const warned = policyWorkspace('version: 2\nenforcement: enforce\nchecks:\n scope:\n severity: warn\n'); + assert.equal(loadPolicy(warned).checkSeverities.scope, 'warn'); + + const named = policyWorkspace('version: 2\nenforcement: enforce\nchecks:\n team-lint:\n severity: advisory\n'); + assert.equal(loadPolicy(named).checkSeverities['team-lint'], 'advisory'); +}); + +test('E: the advisory-by-default structural check stays downgradable, and v1 policies load unchanged', () => { + const structural = policyWorkspace(`version: 2\nenforcement: enforce\nchecks:\n ${STRUCTURAL_CHECK_ID}:\n severity: advisory\n`); + assert.equal(loadPolicy(structural).checkSeverities[STRUCTURAL_CHECK_ID], 'advisory'); + assert.equal( + NON_ADVISORY_CHECK_IDS.has(STRUCTURAL_CHECK_ID), + false, + 'a check whose built-in default is advisory must never be in the non-downgradable set' + ); + + // A v1 policy — no checks map at all — must load byte-identically to before. + const v1 = policyWorkspace('version: 1\nenforcement: warn\ngate_ttl_minutes: 15\nexemptions:\n - docs/**\n'); + assert.deepEqual(loadPolicy(v1), { + version: 1, + enforcement: 'warn', + gateTtlMinutes: 15, + evidenceTtlHours: 24, + exemptions: ['docs/**'], + waivers: [], + checkSeverities: {}, + }); + // A v1 policy that DOES carry a checks map is still honored version- + // independently — and still refused for a gating downgrade. + const v1Checks = policyWorkspace(`version: 1\nchecks:\n ${STRUCTURAL_CHECK_ID}:\n severity: warn\n`); + assert.equal(loadPolicy(v1Checks).checkSeverities[STRUCTURAL_CHECK_ID], 'warn'); + const v1Gating = policyWorkspace('version: 1\nchecks:\n scope:\n severity: advisory\n'); + assert.throws(() => loadPolicy(v1Gating), /cannot be advisory/); +}); + +test('E: every built-in check verify.mjs pushes is either non-downgradable or advisory by default', () => { + const src = fs.readFileSync(path.join(packageRoot, 'lib', 'verify.mjs'), 'utf8'); + // Literal-id pushes only: `resultCheck(name, …)` (project-defined named + // checks) and `resultCheck(STRUCTURAL_CHECK_ID, …)` carry no string literal + // and are deliberately out of scope here. + const ids = new Set([...src.matchAll(/resultCheck\(\s*'([a-z-]+)'/g)].map((m) => m[1])); + assert.ok(ids.size >= 10, `expected to find the built-in check ids in verify.mjs, found ${[...ids].join(', ')}`); + const defaultAdvisory = new Set([STRUCTURAL_CHECK_ID]); + for (const id of ids) { + assert.ok( + NON_ADVISORY_CHECK_IDS.has(id) || defaultAdvisory.has(id), + `built-in check ${id} is neither gating-protected nor advisory by default — add it to NON_ADVISORY_CHECK_IDS` + ); + } +}); + +test('G: advisory findings are redacted, flattened to one line, and capped before reaching the evidence payload', () => { + const huge = 'x'.repeat(200_000); + const [failure] = collectAdvisoryFailures([ + { + id: STRUCTURAL_CHECK_ID, + status: 'failed', + severity: 'advisory', + message: `2 structural findings\nAKIAIOSFODNN7EXAMPLE`, + findings: [ + { + type: 'unplanned-symbol-change', + file: 'infra/main.tf', + added: [`${huge}\nfake heading`, 'AKIAIOSFODNN7EXAMPLE'], + removed: [], + }, + { type: 'removed-symbol-with-callers', file: 'a.ts', symbol: 'foo', callers: ['b.ts'] }, + ], + }, + ]); + + assert.equal(failure.id, STRUCTURAL_CHECK_ID); + const serialized = JSON.stringify(failure); + assert.ok(serialized.length < 5_000, `advisory payload must be bounded, got ${serialized.length} bytes`); + assert.ok(!serialized.includes('AKIAIOSFODNN7EXAMPLE'), 'secret-shaped repo text is redacted'); + assert.ok(!failure.message.includes('\n'), 'the message renders as one line'); + assert.equal(failure.findings[0].added[0].length, 240, 'an unbounded extracted symbol is capped'); + assert.ok(!failure.findings[0].added[0].includes('\n'), 'and flattened'); + assert.equal(failure.findings[1].symbol, 'foo', 'well-formed findings pass through intact'); + assert.deepEqual(failure.findings[1].callers, ['b.ts']); +}); + +test('G: the advisory payload bounds the number of findings and the size of every nested list', () => { + const [failure] = collectAdvisoryFailures([ + { + id: STRUCTURAL_CHECK_ID, + status: 'failed', + severity: 'advisory', + message: 'many findings', + findings: Array.from({ length: 500 }, (_, i) => ({ + type: 'unplanned-symbol-change', + file: `f${i}.ts`, + added: Array.from({ length: 500 }, (_, j) => `sym-${j}`), + })), + }, + ]); + assert.equal(failure.findings.length, 50, 'findings are capped'); + assert.equal(failure.findings[0].added.length, 20, 'nested lists are capped'); +}); + +test('G: a passing or skipped advisory check contributes nothing, and non-advisory checks are never collected', () => { + assert.deepEqual( + collectAdvisoryFailures([ + { id: STRUCTURAL_CHECK_ID, status: 'passed', severity: 'advisory', message: 'ok' }, + { id: STRUCTURAL_CHECK_ID, status: 'skipped', severity: 'advisory', message: 'skipped' }, + { id: 'scope', status: 'failed', severity: 'enforce', message: 'violations' }, + ]), + [] + ); +}); From 7cbcdccb9a2b62aaf330e19768b85722648b5551 Mon Sep 17 00:00:00 2001 From: Krish Date: Thu, 6 Aug 2026 09:37:41 -0400 Subject: [PATCH 12/24] fix: correct structural index freshness, worktree isolation, and export detection --- .../references/harness-tool-contract.md | 11 +- .../proposals/harness-evolution-blueprint.md | 27 +- packages/harness/bin/harness.mjs | 4 +- packages/harness/lib/commands.mjs | 38 ++- packages/harness/lib/doctor.mjs | 48 +++- packages/harness/lib/repo-map/grammars.lock | 6 +- .../lib/repo-map/lexical-extractor.mjs | 198 ++++++++++++++- .../harness/lib/repo-map/structural-index.mjs | 204 ++++++++++++--- .../lib/repo-map/treesitter-extractor.mjs | 167 ++++++++++-- .../harness/lib/structural/expectations.mjs | 187 +++++++++++--- packages/harness/lib/structural/shape.mjs | 47 +++- .../harness/test/doctor-structural.test.mjs | 87 ++++++- .../test/index-structural-cli.test.mjs | 39 +++ .../test/structural-expectations.test.mjs | 234 +++++++++++++---- .../harness/test/structural-index.test.mjs | 237 ++++++++++++++++++ .../test/structural-shape-compat.test.mjs | 102 +++++--- .../test/treesitter-extractor.test.mjs | 150 ++++++++++- 17 files changed, 1566 insertions(+), 220 deletions(-) diff --git a/.github/skills/references/harness-tool-contract.md b/.github/skills/references/harness-tool-contract.md index 5a14ab79..83b6c420 100644 --- a/.github/skills/references/harness-tool-contract.md +++ b/.github/skills/references/harness-tool-contract.md @@ -65,7 +65,7 @@ This table tracks only what differs in runtime character across commands — whi | `verify` | agent-runtime | writes | mutates (evidence file + session) | | `validate-plan` | agent-runtime | writes¹ | read-only | | `plan-new` | agent-runtime | none | mutates workspace (writes the plan; `--stdout` prints instead) | -| `index` | agent-runtime | writes¹ | mutates the knowledge index (`--status` read-only); `--structural` mutates `~/.harness/index//structural/` | +| `index` | agent-runtime | writes¹ | mutates the knowledge index (`--status` read-only); `--structural` mutates `~/.harness/index///structural/` | | `get` | agent-runtime | none | read-only | | `compound` | agent-runtime | writes | mutates (index + solution doc + telemetry) | | `consolidate` | agent-runtime | writes | read-only (`--status`/`--candidates`); mutates the learnings store (`--apply`/`--rebuild --yes`) | @@ -83,14 +83,14 @@ This table tracks only what differs in runtime character across commands — whi **Repo map & knowledge freshness (deterministic-first).** `orient` regenerates `.harness/repo-map.md` every turn from `git ls-files` + a lexical symbol/import extractor — so code orientation is always current and never depends on a model. `init-repo` and `index` additionally write a committed, timestamp-free `docs/codebase-map.md` (~2.5k-token budget, query-less) so cold-start agents read one durable orientation file instead of exploring. Learnings (semantic memory) live in a local never-pushed git store at `~/.harness/knowledge//`; `orient` injects the top-3 trigger-matched learnings inside the existing 2 KB pack, attributed by id, with insight-derived claims fenced `[unverified memory — advisory]`. The `.harness/repo-map.md` (like `.harness/context-pack.md`) is an ephemeral derived artifact, not a persistent type. The knowledge index is refreshed manually (`harness index`) — run it after a major pull from main or a docs rewrite; `index --status` and the `orient` next-hint tell you when it has drifted. A staleness-or-intent maintenance refresh may additionally re-derive conventions via `/codebase-context` (an optional, cheap, non-reasoning model pass) and promote generalizable solution docs to the global `~/.copilot/knowledge` store (episodes only — never the learnings store, whose sole writer is `consolidate --apply`) — never per turn. The extractor is a seam: a tree-sitter tier (WASM, lazy-loaded grammars, lexical fallback for SQL/HCL) can implement the same `extract` shape to power symbol-accurate `refs`/`def`/`callers`, built only when telemetry shows the lexical map misleads the agent. -**Structural index (optional tier — Phase 3).** `harness index --structural [--since ]` builds a persistent, derived symbol index at `~/.harness/index//structural/` (`files.json`, `symbols.json`, `graph.json`, `meta.json` with the `{sha, branch, baseSha, generatedAt}` generation stamp). Parsing uses optional web-tree-sitter WASM grammars (TypeScript/JavaScript/TSX, Python, Java); any other language, missing grammar, parse failure, or init failure falls back **per file** to the lexical extractor, so the harness works fully with the optional grammar packages absent. `grammars.lock` pins a sha256 digest per wasm, verified before instantiation; a mismatch is a **loud** lexical fallback — recorded in `meta.json` and failed (not warned) by doctor S1. Rebuilds are incremental (mtime+size fast path, sha256 content confirm); `--since ` re-parses only `git diff --name-only --` files after `git rev-parse --verify` validation (leading `-` rejected). When `meta.sha` equals the current HEAD, `orient`'s repo map prefers the prebuilt structural tables (still a synchronous read — the async grammar lifecycle never enters orient); otherwise behavior is byte-identical lexical. The committed `docs/codebase-map.md` stays lexical-only so host-local index state never leaks into a committed artifact. Output follows the three-audience contract: styled ledger for humans, the bounded `--json` summary envelope below for programs (never the raw tables), and a ≤1000-token inert digest as the agent lane — raw index JSON never enters model context. The index is derived and rebuildable: deleting the directory never loses knowledge. Unresolved graph edges (imports or calls the tables cannot bind) are preserved explicitly, never fabricated. +**Structural index (optional tier — Phase 3).** `harness index --structural [--since ]` builds a persistent, derived symbol index at `~/.harness/index///structural/` (`files.json`, `symbols.json`, `graph.json`, `meta.json` with the `{sha, branch, baseSha, generatedAt}` generation stamp). The path is keyed per WORKTREE as well as per repo: worktrees of one repo share a `repo-id` and can sit at the same `meta.sha` with different working-tree content, so a single directory would serve each worktree the other's tables. Parsing uses optional web-tree-sitter WASM grammars (TypeScript/JavaScript/TSX, Python, Java); any other language, missing grammar, parse failure, or init failure falls back **per file** to the lexical extractor, so the harness works fully with the optional grammar packages absent — the lexical tier records real export flags (JS/TS `export` forms and CommonJS, Python `__all__` or module-level public defs, Java `public` members) plus explicit named-import references, so the structural checks are meaningful with no grammar installed. `grammars.lock` pins a sha256 digest per wasm AND for the JS loader entry point the dynamic import executes, both verified before instantiation; a mismatch is a **loud** lexical fallback — recorded in `meta.json` and failed (not warned) by doctor S1 — and a missing or unreadable lock refuses the treesitter tier and fails S1 rather than silently disabling verification. Rebuilds are incremental (mtime+size fast path, sha256 content confirm); `--since ` re-parses only `git diff --name-only --` files after `git rev-parse --verify` validation (leading `-` rejected), and **only when `` resolves to exactly the sha the prior index was built at** — any other ref would leave intermediate commits stale under a freshly stamped `meta.sha`, so it is ignored (reported as `sinceIgnored` and on the ledger) and the build degrades to a full incremental pass. `--since` without `--structural` is a usage error. Table caps are recorded, never silent: `meta.json` carries `symbolsTruncated` / `moduleEdgesTruncated` / `callEdgesTruncated` / `unresolvedTruncated` (table-level, where a finding could be wrong) plus `symbolDetailTruncated` (the routine per-symbol def/ref cap, which only shortens a list), and an existing-but-unreadable table is reported (doctor S1) instead of reading as empty. When `meta.sha` equals the current HEAD, `orient`'s repo map prefers the prebuilt structural tables (still a synchronous read — the async grammar lifecycle never enters orient, and a stale index is rejected from `meta.json` alone without parsing the tables); otherwise behavior is byte-identical lexical. The committed `docs/codebase-map.md` stays lexical-only so host-local index state never leaks into a committed artifact. Output follows the three-audience contract: styled ledger for humans, the bounded `--json` summary envelope below for programs (never the raw tables), and a ≤1000-token inert digest as the agent lane — raw index JSON never enters model context. The index is derived and rebuildable: deleting the directory never loses knowledge. Unresolved graph edges (imports or calls the tables cannot bind) are preserved explicitly, never fabricated. **index --structural** ```json { "pass": true, "exitCode": 0, - "dir": "~/.harness/index//structural", + "dir": "~/.harness/index///structural", "written": true, "sha": "", "baseSha": null, @@ -103,6 +103,9 @@ This table tracks only what differs in runtime character across commands — whi "grammarVersions": { "javascript": "0.23.1", "typescript": "0.23.2", "tsx": "0.23.2", "python": "0.23.6", "java": "0.23.5" }, "missingGrammars": [], "integrityFailures": [], + "truncated": { "files": false, "symbols": false, "symbolDetail": false, "moduleEdges": false, "callEdges": false, "unresolved": false }, + "sinceIgnored": null, + "priorUnreadable": [], "delta": { "added": { "count": 1, "names": ["chargeV2"] }, "removed": { "count": 0, "names": [] }, "changed": { "count": 0, "names": [] } } } ``` @@ -180,7 +183,7 @@ Allowed outcomes are `passed`, `failed`, and `inconclusive`. Only fresh `passed` Every check in the `verify` payload carries its effective `severity`; non-passing advisory checks are additionally listed under `advisoryFailures` (with their findings) so an exit-neutral signal is never silently lost. A v1 policy file (no `checks:` map) behaves exactly as before. -**structural-expectations (built-in verify check, advisory by default).** Compares the structural diff of the change against the plan using the structural index at `~/.harness/index//structural/` (`files.json`/`symbols.json`/`graph.json`/`meta.json` — shape contract in `packages/harness/lib/structural/shape.mjs`). Flags: changed exported symbols in files outside `## Impacted Files` (`unplanned-symbol-change`); removed public symbols whose callers in the graph survive the change (`removed-symbol-with-callers`); unmet plan-frontmatter `structural_expectations:` entries marked `required: true` (`unmet-required-expectation` — unmarked entries stay informational). A missing structural index or a baseline `meta.sha` that is not an ancestor of HEAD makes the check report `skipped` — it warns rather than guessing, and `skipped` never affects the outcome at any severity. Policy `checks: { structural-expectations: { severity: warn|enforce } }` opts the flags into blocking. +**structural-expectations (built-in verify check, advisory by default).** Compares the structural diff of the change against the plan using the structural index at `~/.harness/index///structural/` (`files.json`/`symbols.json`/`graph.json`/`meta.json` — shape contract in `packages/harness/lib/structural/shape.mjs`). Flags: changed **exported** symbols in files outside `## Impacted Files` (`unplanned-symbol-change` — export flags come from the index, so a purely local addition never fires); removed exported symbols whose callers in the graph survive the change (`removed-symbol-with-callers`); unmet plan-frontmatter `structural_expectations:` entries marked `required: true` (`unmet-required-expectation` — unmarked entries stay informational). The check never asserts what it could not compare: a missing index or a baseline `meta.sha` that is not an ancestor of HEAD reports `skipped`; a per-file extractor-tier mismatch (`tier-mismatch-skipped`), a changed file in a language it cannot read (`file-not-evaluated` / `expectation-not-evaluated`), and findings computed from a table that hit an index build cap (`-informational`) all stay informational; and a run where NOTHING was compared reports `skipped`, never `passed`. `skipped` never affects the outcome at any severity. Policy `checks: { structural-expectations: { severity: warn|enforce } }` opts the flags into blocking. **Learning attribution (cited half).** `orient` records the learning ids it surfaced in a session; `verify --learnings ` closes the loop by recording the ids the skill actually applied while doing the work — pass only ids that materially changed an action, not every id the pack mentioned. `orient` also records `learningsBytes` on its own event — the post-truncation byte size of the "## Learnings (memory)" section actually injected into the pack — which `harness report`'s token ledger sums into an approximate injected-token count (`slos.knowledgeTokens`), a cost figure only, never a "tokens saved" claim. `harness report` derives knowledge-layer utilization from cited ÷ surfaced across the event log (both a unique-id rate and an occurrence-weighted rate), and `harness doctor` warns when the weighted utilization stays under 15% with 20+ surfaced occurrences. diff --git a/knowledge/proposals/harness-evolution-blueprint.md b/knowledge/proposals/harness-evolution-blueprint.md index 303e7b75..36b6c0c8 100644 --- a/knowledge/proposals/harness-evolution-blueprint.md +++ b/knowledge/proposals/harness-evolution-blueprint.md @@ -133,15 +133,36 @@ abandoned experiments can pollute golden knowledge; no branch/commit provenance. for every grammar; each grammar's hash is verified before instantiation, and on any mismatch the extractor falls back to lexical *loudly* (doctor S1 fails, not warns). - **Storage location (resolved):** structural index lives **outside** the knowledge git - store at `~/.harness/index//structural/` so it can be freely deleted or - rebuilt without touching governance history, and never collides across repos. + store at `~/.harness/index///structural/` so it can be freely + deleted or rebuilt without touching governance history, and never collides across + repos — nor across co-located worktrees of ONE repo, which share a `repo-id` (it + hashes the origin remote) and can sit at the same `meta.sha` with different + working-tree content. The worktree segment hashes the worktree root's realpath. - `files.json`, `symbols.json`, `graph.json`, `meta.json` - Extracted content is untrusted repo text: symbols and excerpts pass the existing `scanSecrets`/`redactSecrets` boundary at index-write time and `inertLine` at every render, like all retrieved data. - Incremental: mtime+size fast path, content-hash confirm. - `harness index --structural --since ` for targeted structural diffs (refs - validated via `git rev-parse --verify`, always passed after `--`). + validated via `git rev-parse --verify`, always passed after `--`). **Soundness rule:** + `--since` narrows only when the ref resolves to exactly the sha the PRIOR index was + built at; any other ref would leave files changed in between stale under a freshly + stamped `meta.sha`, so it is ignored (reported on every output lane) and the build + falls back to a full incremental pass. +- Table caps (symbols, module/call edges, unresolved) are **recorded** in `meta.json` + (`symbolsTruncated`, `moduleEdgesTruncated`, `callEdgesTruncated`, + `unresolvedTruncated`, plus the routine per-symbol `symbolDetailTruncated`); + consumers degrade to informational rather than assert a finding computed from a + table-level truncation. An existing-but-unreadable table is reported + loudly (doctor S1) instead of reading as empty. +- **Integrity covers the loader, not only the wasm:** `grammars.lock` also pins the + sha256 of the JS entry point `import('web-tree-sitter')` executes, verified before the + import; a missing or truncated lock refuses the treesitter tier and fails doctor S1 + rather than silently disabling verification. +- The lexical tier is a first-class tier, not a stub: it records the module export + surface (JS/TS `export` forms and CommonJS, Python `__all__` or module-level public + defs, Java `public` members) and explicit named-import references, so the structural + checks are meaningful with no grammar installed. - Opt-in first; consumers prefer structural tables when present and current, else lexical. - **Output lanes:** the structural query surface renders per the three-audience contract diff --git a/packages/harness/bin/harness.mjs b/packages/harness/bin/harness.mjs index c15722df..4b62b9fa 100755 --- a/packages/harness/bin/harness.mjs +++ b/packages/harness/bin/harness.mjs @@ -81,8 +81,8 @@ const CATALOG = [ sig: '[--status] [--structural [--since ]]', options: [ ['--status', 'read-only freshness report vs HEAD (never rebuilds)'], - ['--structural', 'build the persistent structural code index under ~/.harness/index//structural (optional tree-sitter tier, lexical fallback)'], - ['--since ', 'with --structural: re-parse only files changed since (validated via git rev-parse; leading "-" rejected)'], + ['--structural', 'build the persistent structural code index under ~/.harness/index///structural (optional tree-sitter tier, lexical fallback)'], + ['--since ', 'requires --structural: re-parse only files changed since (validated via git rev-parse; leading "-" rejected). Narrows ONLY when is the sha the prior index was built at — any other ref is reported and ignored for a full pass'], ] }, { name: 'plan-new', desc: 'scaffold a gate-ready plan', sig: '--type feat --slug --intent "..."', diff --git a/packages/harness/lib/commands.mjs b/packages/harness/lib/commands.mjs index bed350de..f330b3f6 100644 --- a/packages/harness/lib/commands.mjs +++ b/packages/harness/lib/commands.mjs @@ -345,6 +345,16 @@ export async function cmdIndex(argv) { const workspace = path.resolve(flags.workspace); const logger = (m) => log(flags, m); + // `--since` only narrows a structural rebuild. Accepting and ignoring it + // anywhere else silently does nothing the caller asked for. + if (flags.since && !argv.includes('--structural')) { + throw Object.assign(new Error('--since requires --structural'), { + code: 'E_USAGE', + hint: 'run: harness index --structural --since ', + exit: EXIT.usage, + }); + } + // Read-only freshness report — never rebuilds, zero model cost. if (argv.includes('--status')) { const { indexStatus } = await import('./index-status.mjs'); @@ -399,10 +409,36 @@ export async function cmdIndex(argv) { grammarVersions: result.meta.grammarVersions, missingGrammars: result.meta.missingGrammars, integrityFailures: result.meta.integrityFailures, + // Cap hits are part of the contract: a consumer must be able to tell a + // complete table from one the build truncated. + truncated: { + files: result.meta.truncated, + symbols: result.meta.symbolsTruncated, + symbolDetail: result.meta.symbolDetailTruncated, + moduleEdges: result.meta.moduleEdgesTruncated, + callEdges: result.meta.callEdgesTruncated, + unresolved: result.meta.unresolvedTruncated, + }, + sinceIgnored: result.sinceIgnored, + priorUnreadable: result.priorUnreadable, delta: result.delta, }); } else { - const deltaNote = `symbols +${result.delta.added.count} −${result.delta.removed.count} ~${result.delta.changed.count}${since ? ' vs prior index' : ''}`; + const truncatedTables = [ + result.meta.symbolsTruncated ? 'symbols' : null, + result.meta.symbolDetailTruncated ? 'per-symbol lists' : null, + result.meta.moduleEdgesTruncated ? 'module edges' : null, + result.meta.callEdgesTruncated ? 'call edges' : null, + result.meta.unresolvedTruncated ? 'unresolved' : null, + ].filter(Boolean); + // The delta is ALWAYS measured against the prior index, not only under + // --since; say so whenever there was one. + const deltaNote = + `symbols +${result.delta.added.count} −${result.delta.removed.count} ~${result.delta.changed.count}` + + (result.basedOn ? ' vs prior index' : ' (no prior index)') + + (truncatedTables.length ? ` · TRUNCATED at build caps (${truncatedTables.join(', ')})` : '') + + (result.sinceIgnored ? ` · ${result.sinceIgnored} — full pass` : '') + + (result.priorUnreadable?.length ? ` · prior index unreadable (${result.priorUnreadable.length})` : ''); console.log( ui.line({ state: persistFailed ? 'error' : integrity ? 'warn' : 'ok', diff --git a/packages/harness/lib/doctor.mjs b/packages/harness/lib/doctor.mjs index e91ea464..7bd019ec 100644 --- a/packages/harness/lib/doctor.mjs +++ b/packages/harness/lib/doctor.mjs @@ -21,7 +21,7 @@ import { branchExists } from './knowledge/layer.mjs'; import { deriveGitContext, resolveDefaultBranch } from './git-context.mjs'; import { loadReportEvents, knowledgeSlos } from './report.mjs'; import { readStructuralIndex } from './repo-map/structural-index.mjs'; -import { grammarStatus } from './repo-map/treesitter-extractor.mjs'; +import { grammarStatus, packageGrammarRoots } from './repo-map/treesitter-extractor.mjs'; import { assertNoSymlinkAncestors } from './fs-safe.mjs'; const require = createRequire(import.meta.url); @@ -497,29 +497,46 @@ function knowledgeChecks({ workspace, copilotHome }) { return checks; } -// Structural-index health (blueprint P3, doctor S1). One check, four facts: +// Structural-index health (blueprint P3, doctor S1). One check, five facts: // grammar availability + integrity (BOTH the mismatch recorded at index time // in meta.json AND the current on-disk wasm state via the sync grammarStatus -// probe), meta.sha drift vs HEAD, parse-failure rate, and orphaned cache -// entries. Binding blueprint rule: a grammar integrity mismatch FAILS S1 -// (optional: false) — the loud lexical fallback is a doctor failure, never a -// warning. Everything else about the optional tier stays advisory. Exported -// for direct testing, same as the check builders above are exercised through -// runDoctor. -export function structuralChecks({ workspace }) { +// probe), meta.sha drift vs HEAD, parse-failure rate, unreadable index tables, +// and orphaned cache entries. Binding blueprint rule: a grammar integrity +// mismatch — or an unreadable grammars.lock, which disables verification +// entirely — FAILS S1 (optional: false); the loud lexical fallback is a doctor +// failure, never a warning. A mismatch RECORDED in meta that the current wasm +// no longer has is stale, so it degrades to an advisory "re-run the index" +// instead of failing forever. Everything else about the optional tier stays +// advisory. The disk probe is scoped to the harness package's own node_modules +// and both it and the lock path are injectable, so S1 is never a verdict on an +// unrelated web-tree-sitter copy elsewhere on the filesystem (and the tests +// stay hermetic). Exported for direct testing, same as the check builders +// above are exercised through runDoctor. +export function structuralChecks({ workspace, grammarRoots = packageGrammarRoots(), lockPath } = {}) { const checks = []; try { - const disk = grammarStatus(); + // Scoped to the harness package's OWN node_modules: walking parent + // node_modules made any unrelated web-tree-sitter anywhere up the + // filesystem a hard doctor failure for a user who never built an index. + const disk = grammarStatus({ grammarRoots, lockPath }); const index = readStructuralIndex(workspace); const recorded = index?.meta?.integrityFailures || []; - const mismatches = [...disk.integrityFailures, ...recorded]; + // A recorded mismatch the disk now verifies as good is STALE, not live: + // the last build fell back, but the bytes are fixed — say "re-run", don't + // keep failing forever on a record no rebuild ever clears. + const stale = recorded.filter((f) => disk.grammars?.[f.language]?.ok === true); + const live = recorded.filter((f) => !stale.includes(f)); + const mismatches = [...disk.integrityFailures, ...live]; if (mismatches.length) { const languages = [...new Set(mismatches.map((f) => f.language))].join(', '); + const lockGone = mismatches.some((f) => f.language === 'lock'); checks.push({ id: 'S1', name: 'Structural index grammar integrity', pass: false, - hint: `grammar wasm sha256 mismatch vs grammars.lock (${languages}) — the index fell back to lexical loudly; reinstall the harness optional dependencies, then re-run: harness index --structural`, + hint: lockGone + ? `grammars.lock missing or unreadable — wasm integrity cannot be verified and the treesitter tier is refused; reinstall the harness package, then re-run: harness index --structural` + : `grammar wasm sha256 mismatch vs grammars.lock (${languages}) — the index fell back to lexical loudly; reinstall the harness optional dependencies, then re-run: harness index --structural`, }); return checks; } @@ -534,6 +551,13 @@ export function structuralChecks({ workspace }) { return checks; } const issues = []; + if (stale.length) { + const languages = [...new Set(stale.map((f) => f.language))].join(', '); + issues.push(`index meta still records a grammar integrity mismatch (${languages}) that the current wasm no longer has — re-run: harness index --structural`); + } + // An existing-but-unreadable table (oversized past the fs-safe cap, + // symlinked, corrupt JSON) used to read as empty everywhere. Say it. + if (index.unreadable?.length) issues.push(`${index.unreadable.join('; ')} — delete the index directory and re-run: harness index --structural`); const head = spawnSync('git', ['-C', workspace, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 10_000 }); const headSha = head.status === 0 ? head.stdout.trim() : null; if (index.meta.sha && headSha && index.meta.sha !== headSha) { diff --git a/packages/harness/lib/repo-map/grammars.lock b/packages/harness/lib/repo-map/grammars.lock index c975d013..b9b62f94 100644 --- a/packages/harness/lib/repo-map/grammars.lock +++ b/packages/harness/lib/repo-map/grammars.lock @@ -4,7 +4,11 @@ "package": "web-tree-sitter", "version": "0.25.10", "file": "tree-sitter.wasm", - "sha256": "f38dcc4b43b818f9a0785bc1c6d5611a75ac4cdd428ff3f02757c34ca4e46d7f" + "sha256": "f38dcc4b43b818f9a0785bc1c6d5611a75ac4cdd428ff3f02757c34ca4e46d7f", + "loader": { + "file": "tree-sitter.js", + "sha256": "d4fc466df6358055253bc4cdaab23cdf84fe00741fa5a4ff56506c06c533da42" + } }, "grammars": { "javascript": { diff --git a/packages/harness/lib/repo-map/lexical-extractor.mjs b/packages/harness/lib/repo-map/lexical-extractor.mjs index a2d243a4..1bc4a2ca 100644 --- a/packages/harness/lib/repo-map/lexical-extractor.mjs +++ b/packages/harness/lib/repo-map/lexical-extractor.mjs @@ -3,6 +3,38 @@ // tree-sitter tier (AC62) can implement the same `extract` shape later for // languages where precision is worth the dependency (Java/Python/TS/JS); // SQL and HCL stay lexical because their grammars add little here. +// +// EXPORT SURFACE: the result carries `exported` — the subset of `symbols` this +// file publishes to other modules. It is what makes the structural checks +// (removed-symbol-with-callers, unplanned-symbol-change) meaningful in the +// DEFAULT tier: grammars are optional, so without lexical export detection +// nothing is ever marked exported and those checks can never fire. Detection +// is deliberately conservative — an export marker that names an identifier the +// file also declares. Per language: +// js/ts `export`ed declarations (function/class/const/let/var/type/ +// interface/enum, incl. `export default`), `export { a, b as c }` +// lists and re-exports, `export * as ns from`, and the CommonJS +// `module.exports.x = ` / `exports.x = ` / `module.exports = { x }`. +// py Python has no export keyword. CONVENTION: an explicit `__all__` +// list is authoritative when present; otherwise every module-level +// (column-0) `class`/`def` whose name does not start with `_` — the +// same approximation the tree-sitter tier applies, so both tiers +// agree on what "exported" means. +// java `public` types, methods, and fields. +// sql/tf no module boundary, so nothing is reported as exported. +// +// REFERENCE SURFACE: the result also carries `references` — the names this +// file EXPLICITLY imports by name from another module. Those are facts the +// source states outright, not guessed call sites (the lexical tier still never +// infers a call from a bare identifier), and they are what lets the caller-side +// structural checks work in the default tier: without them a lexical index has +// no edges at all, so "removed symbol still has callers" could never fire +// outside an AST install. +// +// BOUNDED: `symbols`, `imports`, and `references` are capped here (not just in +// consumers) so one hostile or generated file cannot balloon files.json — and +// so the baseline side and the current side of a structural diff cap +// identically. import path from 'node:path'; @@ -29,22 +61,163 @@ const SYMBOL_PATTERNS = { tf: [/^\s*(resource|module|variable|output|data)\s+"([^"]+)"(?:\s+"([^"]+)")?/gm], }; -export const SOURCE_EXTENSIONS = new Set(['.java', '.py', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.sql', '.tf']); +const EXPORT_PATTERNS = { + ts: [ + // export [default] [declare] [async] [abstract] + /\bexport\s+(?:default\s+)?(?:declare\s+)?(?:async\s+)?(?:abstract\s+)?(?:class|interface|type|enum|function\s*\*?|const|let|var)\s+(\w+)/g, + /\bexport\s+default\s+(\w+)\s*[;\n]/g, // export default Existing; + /\bexport\s+\*\s+as\s+(\w+)\s+from\b/g, + /\b(?:module\.)?exports\.(\w+)\s*=/g, // CommonJS named export + ], + java: [ + /\bpublic\s+(?:static\s+|final\s+|abstract\s+|sealed\s+|strictfp\s+)*(?:class|interface|enum|record)\s+(\w+)/g, + /\bpublic\s+(?:static\s+|final\s+|synchronized\s+|native\s+|abstract\s+|default\s+)*[\w<>\[\],?.]+(?:\s*\[\])?\s+(\w+)\s*\(/g, + /\bpublic\s+(?:static\s+|final\s+|volatile\s+|transient\s+)*[\w<>\[\],?.]+(?:\s*\[\])?\s+(\w+)\s*[=;]/g, + ], +}; + +// `export { a, b as c }` (including `... } from './x'`) and +// `module.exports = { a, b: local }` — both need the brace body split apart. +const TS_EXPORT_LIST = /\bexport\s*\{([^}]*)\}/g; +const TS_MODULE_EXPORTS_OBJECT = /\bmodule\.exports\s*=\s*\{([^}]*)\}/g; + +const PY_ALL = /^__all__\s*(?::[^=\n]*)?=\s*[[(]([\s\S]*?)[)\]]/m; +const PY_MODULE_LEVEL_DEF = /^(?:async\s+)?(?:class|def)\s+(\w+)/gm; + +// Named-import clauses: the bindings a file states it takes from elsewhere. +const TS_IMPORT_CLAUSE = /\bimport\s+([^;'"]+?)\s+from\s*['"][^'"]+['"]/g; +const TS_REQUIRE_DESTRUCTURE = /\b(?:const|let|var)\s*\{([^}]*)\}\s*=\s*require\(/g; +const PY_FROM_IMPORT = /^\s*from\s+[\w.]+\s+import\s+([^\n#]+)/gm; +const JAVA_IMPORT = /^\s*import\s+(?:static\s+)?([\w.]+)\s*;/gm; + +// Names a keyword-shaped regex capture must never contribute. +const NOT_A_SYMBOL = /^(?:if|for|while|return|new|switch|catch|get|set)$/i; +const NOT_AN_EXPORT = /^(?:default|function|class|const|let|var|async|await|from|as|new|return|void|null|undefined|true|false)$/; + +/** One bound per file for BOTH tiers: the caps the treesitter tier applies to + * its own output, applied to the lexical output too (blueprint P3 bounded + * tables). Exported so consumers can pin the same numbers. */ +export const MAX_LEXICAL_SYMBOLS = 512; +export const MAX_LEXICAL_IMPORTS = 256; + +export const SOURCE_EXTENSIONS = new Set([ + '.java', + '.py', + '.ts', + '.tsx', + '.mts', + '.cts', + '.js', + '.jsx', + '.mjs', + '.cjs', + '.sql', + '.tf', +]); function languageOf(rel) { const ext = path.extname(rel).toLowerCase(); if (ext === '.java') return 'java'; if (ext === '.py') return 'py'; - if (['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(ext)) return 'ts'; + if (['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'].includes(ext)) return 'ts'; if (ext === '.sql') return 'sql'; if (ext === '.tf') return 'tf'; return null; } -/** extract(rel, content) -> { symbols: string[], imports: string[] } — the seam. */ +/** `a, b as c, default as d` → the names OTHER modules can import (`b as c` + * publishes `c`). A bare `default` is the default slot, not a named export. */ +function exportListNames(body, into) { + for (const raw of String(body).split(',')) { + const item = raw.replace(/\/\*[\s\S]*?\*\//g, '').trim(); + if (!item) continue; + const m = /^(?:type\s+)?([\w$]+)(?:\s+as\s+([\w$]+))?$/.exec(item); + const name = m && (m[2] || m[1]); + if (name && !NOT_AN_EXPORT.test(name)) into.add(name); + } +} + +/** `{ a, b: local, "c": 1 }` → the published keys. */ +function objectLiteralKeys(body, into) { + for (const raw of String(body).split(',')) { + const item = raw.trim(); + if (!item) continue; + const m = /^["']?([\w$]+)["']?\s*(?::|$)/.exec(item); + if (m && !NOT_AN_EXPORT.test(m[1])) into.add(m[1]); + } +} + +function exportedNames(lang, text) { + const exported = new Set(); + for (const pattern of EXPORT_PATTERNS[lang] || []) { + for (const m of text.matchAll(pattern)) { + if (m[1] && !NOT_AN_EXPORT.test(m[1])) exported.add(m[1]); + } + } + if (lang === 'ts') { + for (const m of text.matchAll(TS_EXPORT_LIST)) exportListNames(m[1], exported); + for (const m of text.matchAll(TS_MODULE_EXPORTS_OBJECT)) objectLiteralKeys(m[1], exported); + } + if (lang === 'py') { + const all = PY_ALL.exec(text); + if (all) { + // An explicit __all__ is the module's declared surface — authoritative. + for (const m of all[1].matchAll(/["']([\w.]+)["']/g)) exported.add(m[1]); + } else { + for (const m of text.matchAll(PY_MODULE_LEVEL_DEF)) { + if (!m[1].startsWith('_')) exported.add(m[1]); + } + } + } + return exported; +} + +/** + * Names this file imports BY NAME from another module — `import { a, b as c }` + * (the exported name `a`/`b`, not the local alias), a default/namespace-free + * default binding, `from mod import a`, `const { a } = require(...)`, and the + * Java class of an `import com.acme.Role;`. Only what the source states. + */ +function importedNames(lang, text) { + const names = new Set(); + const addList = (body, { aliasWins = false } = {}) => { + for (const raw of String(body).split(',')) { + const item = raw.trim(); + if (!item || item.startsWith('*')) continue; + const m = /^([\w$]+)(?:\s+as\s+([\w$]+))?$/.exec(item); + if (!m) continue; + const name = aliasWins ? m[2] || m[1] : m[1]; + if (!NOT_AN_EXPORT.test(name)) names.add(name); + } + }; + if (lang === 'ts') { + for (const m of text.matchAll(TS_IMPORT_CLAUSE)) { + const clause = m[1].replace(/^type\s+/, ''); + const braces = /\{([^}]*)\}/.exec(clause); + if (braces) addList(braces[1]); + const bare = clause.replace(/\{[^}]*\}/g, '').replace(/\*\s+as\s+[\w$]+/g, ''); + addList(bare); + } + for (const m of text.matchAll(TS_REQUIRE_DESTRUCTURE)) addList(m[1]); + } else if (lang === 'py') { + for (const m of text.matchAll(PY_FROM_IMPORT)) addList(m[1].replace(/[()]/g, '')); + } else if (lang === 'java') { + for (const m of text.matchAll(JAVA_IMPORT)) { + const last = m[1].split('.').pop(); + if (last && last !== '*') names.add(last); + } + } + return names; +} + +/** extract(rel, content) -> { symbols, imports, exported, references } — the seam. + * `exported` is always a subset of `symbols`: an export marker naming an + * identifier this file does not otherwise declare still counts (it is part of + * the module surface), but nothing is reported as exported that is not also + * reported as a symbol. */ export function extract(rel, content) { const lang = languageOf(rel); - if (!lang) return { symbols: [], imports: [] }; + if (!lang) return { symbols: [], imports: [], exported: [], references: [] }; const text = String(content || ''); const symbols = new Set(); @@ -52,10 +225,16 @@ export function extract(rel, content) { for (const m of text.matchAll(pattern)) { // tf uses "type" "name" — prefer the name (group 3 or 2). const name = m[3] || m[2] || m[1]; - if (name && !/^(if|for|while|return|new|switch|catch|get|set)$/i.test(name)) symbols.add(name); + if (name && !NOT_A_SYMBOL.test(name)) symbols.add(name); } } + // Export markers also DECLARE symbols the per-language patterns above miss + // (`export let`, `export { helper }`, `exports.run = ...`) — the module + // surface is exactly what the structural checks reason about. + const exported = exportedNames(lang, text); + for (const name of exported) symbols.add(name); + const imports = new Set(); // Imports only apply to code languages, not SQL/HCL. if (lang !== 'sql' && lang !== 'tf') { @@ -64,5 +243,12 @@ export function extract(rel, content) { } } - return { symbols: [...symbols], imports: [...imports] }; + const symbolList = [...symbols].slice(0, MAX_LEXICAL_SYMBOLS); + const kept = new Set(symbolList); + return { + symbols: symbolList, + imports: [...imports].slice(0, MAX_LEXICAL_IMPORTS), + exported: [...exported].filter((name) => kept.has(name)), + references: [...importedNames(lang, text)].slice(0, MAX_LEXICAL_IMPORTS), + }; } diff --git a/packages/harness/lib/repo-map/structural-index.mjs b/packages/harness/lib/repo-map/structural-index.mjs index 8311b7a7..f46e16a8 100644 --- a/packages/harness/lib/repo-map/structural-index.mjs +++ b/packages/harness/lib/repo-map/structural-index.mjs @@ -1,7 +1,7 @@ // Persistent structural codebase index (blueprint P3). Lives OUTSIDE the -// knowledge git store at ~/.harness/index//structural/ — derived and -// rebuildable: deleting the directory never loses knowledge, and it never -// touches governance history. Four tables: +// knowledge git store at ~/.harness/index///structural/ +// — derived and rebuildable: deleting the directory never loses knowledge, and +// it never touches governance history. Four tables: // files.json per-file { hash, mtime, size, symbols, imports, complexity, // defs, refs, tier } — the superset the incremental rebuild // and symbol table are derived from @@ -33,6 +33,7 @@ import crypto from 'node:crypto'; import { spawnSync } from 'node:child_process'; import { harnessGlobalHome } from '../paths.mjs'; import { repoId, inertLine } from '../knowledge/store.mjs'; +import { worktreeId } from '../structural/shape.mjs'; import { writeFileContained, readFileNoFollow } from '../fs-safe.mjs'; import { redactSecrets } from '../secret-scan.mjs'; import { estimateTokens } from '../token-meter.mjs'; @@ -61,53 +62,92 @@ function sha256(text) { return crypto.createHash('sha256').update(text).digest('hex'); } -/** ~/.harness/index//structural — respects HARNESS_HOME via harnessGlobalHome. */ +/** ~/.harness/index///structural — respects HARNESS_HOME + * via harnessGlobalHome. The worktree segment (shape.mjs `worktreeId`) keeps + * co-located worktrees of one repo from serving each other's tables: they + * share a `repoId` and can sit at the same `meta.sha` with different content. */ export function structuralIndexDir(workspace, { home } = {}) { - return path.join(home || harnessGlobalHome(), 'index', repoId(workspace), 'structural'); + return path.join(home || harnessGlobalHome(), 'index', repoId(workspace), worktreeId(workspace), 'structural'); } -function readJson(dir, name) { - const body = readFileNoFollow(path.join(dir, name), { root: dir }); - if (body === null) return null; +/** + * Read one table. Distinguishes ABSENT (`{ value: null, error: null }`) from + * UNREADABLE — oversized past the fs-safe cap, symlinked, or corrupt JSON. + * Collapsing the two (the old `|| {}`) turned a 10 MB+ or truncated table into + * a silent empty one: a permanent silent full rebuild plus a bogus + * "everything added" delta, with nothing on any surface saying so. + */ +function readTable(dir, name) { + const full = path.join(dir, name); + if (!fs.existsSync(full)) return { value: null, error: null }; + const body = readFileNoFollow(full, { root: dir }); + if (body === null) return { value: null, error: `${name} is unreadable (oversized, symlink, or open failure)` }; try { - return JSON.parse(body); + return { value: JSON.parse(body), error: null }; } catch { - return null; + return { value: null, error: `${name} is not valid JSON` }; } } +function readJson(dir, name) { + return readTable(dir, name).value; +} + +function readMeta(dir) { + const meta = readJson(dir, 'meta.json'); + if (!meta || typeof meta !== 'object') return null; + // `version` is written by the builder, so it is checked by the reader: an + // index from a NEWER writer must be skipped, never half-understood. + if (!Number.isFinite(meta.version) || meta.version > STRUCTURAL_INDEX_VERSION) return null; + return meta; +} + /** * Synchronous, tolerant read of the prebuilt index. Returns - * { dir, meta, files, symbols, graph } or null when no readable index exists. - * The `/index` pre-check keeps the common no-index case free of the - * repoId git spawn — orient calls this every session. + * { dir, meta, files, symbols, graph, unreadable } or null when no readable + * index exists. `unreadable` lists tables that exist but could not be read — + * loud rather than silently empty (doctor S1 surfaces it, the builder refuses + * to diff against it). The `/index` pre-check keeps the common no-index + * case free of the repoId git spawn — orient calls this every session. */ export function readStructuralIndex(workspace, { home } = {}) { if (!fs.existsSync(path.join(home || harnessGlobalHome(), 'index'))) return null; const dir = structuralIndexDir(workspace, { home }); if (!fs.existsSync(path.join(dir, 'meta.json'))) return null; - const meta = readJson(dir, 'meta.json'); - if (!meta || typeof meta !== 'object' || !meta.version) return null; + const meta = readMeta(dir); + if (!meta) return null; + const files = readTable(dir, 'files.json'); + const symbols = readTable(dir, 'symbols.json'); + const graph = readTable(dir, 'graph.json'); + const unreadable = [files.error, symbols.error, graph.error].filter(Boolean); return { dir, meta, - files: readJson(dir, 'files.json') || {}, - symbols: readJson(dir, 'symbols.json') || {}, - graph: readJson(dir, 'graph.json') || {}, + files: files.value || {}, + symbols: symbols.value || {}, + graph: graph.value || {}, + unreadable, }; } /** * The orient-side gate: hand back the index ONLY when its generation stamp * matches the current HEAD — otherwise consumers keep their unchanged lexical - * behavior. Cheap when absent (one existsSync, no git spawn). + * behavior. Cheap when absent (one existsSync, no git spawn) and cheap when + * STALE: meta.json is read and compared FIRST, so the common stale case never + * parses (and discards) multi-megabyte tables on every orient turn. */ export function readStructuralIndexIfCurrent(workspace, { home } = {}) { if (!fs.existsSync(path.join(home || harnessGlobalHome(), 'index'))) return null; - const index = readStructuralIndex(workspace, { home }); - if (!index || !index.meta.sha) return null; + const dir = structuralIndexDir(workspace, { home }); + if (!fs.existsSync(path.join(dir, 'meta.json'))) return null; + const meta = readMeta(dir); + if (!meta || !meta.sha) return null; const head = git(workspace, ['rev-parse', 'HEAD']); - if (!head || head !== index.meta.sha) return null; + if (!head || head !== meta.sha) return null; + const index = readStructuralIndex(workspace, { home }); + // A current stamp over an unreadable table is not a usable index. + if (!index || index.unreadable.length) return null; return index; } @@ -174,34 +214,69 @@ function sanitizeEntry(res, { hash, mtime, size }) { }; } -function buildSymbolTable(files) { +/** + * Is a prior `files.json` entry usable as-is? Reused entries are written back + * verbatim and fed to buildSymbolTable, so a hand-edited or partially written + * table must be REJECTED here (the file is re-parsed instead) rather than + * crashing the build with a TypeError that only manual deletion recovers from. + */ +function usablePriorEntry(entry) { + return Boolean( + entry && + typeof entry === 'object' && + typeof entry.hash === 'string' && + Array.isArray(entry.symbols) && + Array.isArray(entry.imports) && + Array.isArray(entry.defs) && + Array.isArray(entry.refs) + ); +} + +function buildSymbolTable(files, truncation) { // Null prototype: symbol names are untrusted repo text, and a repo defining // `constructor`, `__proto__`, or `toString` must land as an ordinary own // key, not resolve to an inherited Object.prototype member (which would // make `.defs` access throw and abort indexing). const symbols = Object.create(null); const rels = Object.keys(files).sort(); + // Own counter rather than Object.keys(symbols).length per def: that rebuilt + // the whole key array on every declaration, which is quadratic exactly where + // it hurts most (a repo big enough to approach the cap). + let distinct = 0; for (const rel of rels) { - for (const d of files[rel].defs) { + for (const d of Array.isArray(files[rel]?.defs) ? files[rel].defs : []) { + if (!d || typeof d.name !== 'string') continue; if (!symbols[d.name]) { - if (Object.keys(symbols).length >= MAX_SYMBOL_TABLE) continue; + if (distinct >= MAX_SYMBOL_TABLE) { + truncation.symbols = true; + continue; + } symbols[d.name] = { defs: [], refs: [] }; + distinct += 1; } if (symbols[d.name].defs.length < MAX_DEFS_PER_SYMBOL) { symbols[d.name].defs.push({ file: rel, line: d.line, kind: d.kind, exported: d.exported }); + } else { + // Per-symbol cap: the symbol IS in the table, only its long def list is + // shortened. Tracked separately from the table-level cap because it + // costs recall (a caller we never list), never soundness. + truncation.symbolDetail = true; } } } for (const rel of rels) { - for (const r of files[rel].refs) { + for (const r of Array.isArray(files[rel]?.refs) ? files[rel].refs : []) { + if (!r || typeof r.name !== 'string') continue; const entry = symbols[r.name]; - if (entry && entry.refs.length < MAX_REFS_PER_SYMBOL) entry.refs.push({ file: rel, line: r.line }); + if (!entry) continue; + if (entry.refs.length < MAX_REFS_PER_SYMBOL) entry.refs.push({ file: rel, line: r.line }); + else truncation.symbolDetail = true; } } return symbols; } -function buildGraph(files, symbols) { +function buildGraph(files, symbols, truncation) { const rels = Object.keys(files).sort(); // Module edges use the same basename-stem approximation the repo map uses // for import-degree. An import that resolves to no tracked file is KEPT as @@ -227,10 +302,14 @@ function buildGraph(files, symbols) { const targets = (last && byStem.get(last)) || []; if (targets.length) { for (const to of targets) { - if (to !== rel && modules.length < MAX_MODULE_EDGES) modules.push({ from: rel, to, via: imp }); + if (to === rel) continue; + if (modules.length < MAX_MODULE_EDGES) modules.push({ from: rel, to, via: imp }); + else truncation.moduleEdges = true; } } else if (unresolvedImports.length < MAX_UNRESOLVED) { unresolvedImports.push({ from: rel, import: imp }); + } else { + truncation.unresolved = true; } } } @@ -242,18 +321,22 @@ function buildGraph(files, symbols) { const seenUnresolved = new Set(); for (const rel of rels) { const perFile = new Map(); - for (const r of files[rel].refs) { - if (perFile.has(r.name)) continue; + for (const r of Array.isArray(files[rel]?.refs) ? files[rel].refs : []) { + if (!r || typeof r.name !== 'string' || perFile.has(r.name)) continue; perFile.set(r.name, true); const entry = symbols[r.name]; const to = entry ? [...new Set(entry.defs.map((d) => d.file))].filter((f) => f !== rel).slice(0, 5) : []; if (to.length) { if (calls.length < MAX_CALL_EDGES) calls.push({ from: rel, symbol: r.name, to }); + else truncation.callEdges = true; } else if (!entry) { const key = `${rel}${r.name}`; - if (!seenUnresolved.has(key) && unresolvedCalls.length < MAX_UNRESOLVED) { + if (seenUnresolved.has(key)) continue; + if (unresolvedCalls.length < MAX_UNRESOLVED) { seenUnresolved.add(key); unresolvedCalls.push({ from: rel, symbol: r.name }); + } else { + truncation.unresolved = true; } } } @@ -289,20 +372,44 @@ function symbolDelta(priorSymbols, nextSymbols) { * prior entry without re-parsing; * 3. `since` (a PRE-VALIDATED sha from validateSinceRef) narrows the * re-parse candidates to `git diff --name-only --`; files outside - * the diff keep their prior entries verbatim. + * the diff keep their prior entries verbatim. SOUNDNESS RULE: that + * narrowing is only valid when `since` is exactly the sha the PRIOR index + * was built at. Any other ref leaves files changed between `since` and + * the prior stamp stale while the rebuild stamps meta.sha = HEAD, so the + * index would READ as current while carrying stale entries. A misaligned + * `--since` is therefore IGNORED (reported, never silent) and the build + * degrades to a full incremental pass. * Bounded by the shared MAX_FILES_SCANNED / MAX_FILE_BYTES caps. */ export async function buildStructuralIndex({ workspace, home, extractor, since = null, dryRun = false, log = () => {} }) { const dir = structuralIndexDir(workspace, { home }); - const prior = readStructuralIndex(workspace, { home }); + const onDisk = readStructuralIndex(workspace, { home }); + // An unreadable table is not a usable baseline: diffing against it would + // report every symbol as added and silently re-derive the whole index. + const priorUnreadable = onDisk?.unreadable?.length ? onDisk.unreadable : []; + if (priorUnreadable.length) log(`prior structural index unusable: ${priorUnreadable.join('; ')} — rebuilding from scratch`); + const prior = priorUnreadable.length ? null : onDisk; const { files: tracked, total } = trackedSourceFiles(workspace); - const changed = since && prior ? changedFilesSince(workspace, since) : null; + + let sinceIgnored = null; + let appliedSince = null; + if (since && prior) { + if (prior.meta?.sha === since) appliedSince = since; + else sinceIgnored = `--since ${since.slice(0, 12)} does not match the prior index baseline ${String(prior.meta?.sha || 'unknown').slice(0, 12)}`; + } else if (since) { + sinceIgnored = '--since needs a prior index to narrow against'; + } + if (sinceIgnored) log(`${sinceIgnored} — running a full incremental pass instead`); + const changed = appliedSince ? changedFilesSince(workspace, appliedSince) : null; const nextFiles = {}; let reparsed = 0; let reused = 0; for (const rel of tracked) { - const priorEntry = prior?.files?.[rel]; + const raw = prior?.files?.[rel]; + // A hand-edited or partially written prior entry is discarded and rebuilt, + // never reused: it flows into files.json and the symbol table verbatim. + const priorEntry = usablePriorEntry(raw) ? raw : undefined; if (changed && priorEntry && !changed.has(rel)) { nextFiles[rel] = priorEntry; reused += 1; @@ -331,16 +438,26 @@ export async function buildStructuralIndex({ workspace, home, extractor, since = } const removedFiles = Object.keys(prior?.files || {}).filter((rel) => !(rel in nextFiles)).length; - const symbols = buildSymbolTable(nextFiles); - const graph = buildGraph(nextFiles, symbols); + // Cap hits are RECORDED: past a cap a removed symbol simply has no callers + // and the delta misreports, so consumers must be able to tell a complete + // table from a truncated one instead of trusting a silently shortened one. + // `symbols` is the TABLE-level cap (a declaration that never entered the + // table at all — findings computed from it can be wrong); `symbolDetail` is + // the routine per-symbol def/ref cap, which only shortens a list. + const truncation = { symbols: false, symbolDetail: false, moduleEdges: false, callEdges: false, unresolved: false }; + const symbols = buildSymbolTable(nextFiles, truncation); + const graph = buildGraph(nextFiles, symbols, truncation); const delta = symbolDelta(prior?.symbols, symbols); const errorFiles = Object.values(nextFiles).filter((f) => f.errors).length; + // What the reported delta is measured against — always the prior index when + // one was usable, so the ledger can qualify the numbers honestly. + const basedOn = prior?.meta?.sha || null; const meta = { version: STRUCTURAL_INDEX_VERSION, sha: git(workspace, ['rev-parse', 'HEAD']), branch: git(workspace, ['rev-parse', '--abbrev-ref', 'HEAD']), - baseSha: since || null, + baseSha: appliedSince || null, generatedAt: new Date().toISOString(), extractorTier: extractor.tier || 'lexical', webTreeSitter: extractor.webTreeSitter || null, @@ -352,6 +469,11 @@ export async function buildStructuralIndex({ workspace, home, extractor, since = filesIndexed: Object.keys(nextFiles).length, totalTracked: total, truncated: total > tracked.length, + symbolsTruncated: truncation.symbols, + symbolDetailTruncated: truncation.symbolDetail, + moduleEdgesTruncated: truncation.moduleEdges, + callEdgesTruncated: truncation.callEdges, + unresolvedTruncated: truncation.unresolved, }; if (!dryRun) { @@ -368,12 +490,12 @@ export async function buildStructuralIndex({ workspace, home, extractor, since = for (const [name, data] of writes) { if (!writeFileContained(dir, name, JSON.stringify(data) + '\n')) { log(`structural index write refused: ${name}`); - return { dir, written: false, reparsed, reused, removedFiles, delta, meta }; + return { dir, written: false, reparsed, reused, removedFiles, delta, meta, sinceIgnored, priorUnreadable, basedOn }; } } } - return { dir, written: !dryRun, reparsed, reused, removedFiles, delta, meta }; + return { dir, written: !dryRun, reparsed, reused, removedFiles, delta, meta, sinceIgnored, priorUnreadable, basedOn }; } /** diff --git a/packages/harness/lib/repo-map/treesitter-extractor.mjs b/packages/harness/lib/repo-map/treesitter-extractor.mjs index ab4adfb9..5aa70907 100644 --- a/packages/harness/lib/repo-map/treesitter-extractor.mjs +++ b/packages/harness/lib/repo-map/treesitter-extractor.mjs @@ -50,6 +50,8 @@ export const STRUCTURAL_LANGUAGES = { '.mjs': 'javascript', '.cjs': 'javascript', '.ts': 'typescript', + '.mts': 'typescript', + '.cts': 'typescript', '.tsx': 'tsx', '.py': 'python', '.java': 'java', @@ -70,49 +72,124 @@ function capName(name) { return s.length > MAX_IDENTIFIER_LENGTH ? s.slice(0, MAX_IDENTIFIER_LENGTH) : s; } +/** + * First line each wanted name appears on, in ONE pass over the text. + * The naive shape (`names.map(n => lines.findIndex(l => l.includes(n)))`) is + * quadratic — 512 names × every line of a large file — and the lexical tier is + * the DEFAULT tier, so that cost lands on every file of a full index. Here the + * text is tokenized once and identifier hits are recorded as they are seen; + * only names that are not plain identifiers (quoted HCL/SQL names) fall back + * to one native `indexOf` each, resolved against precomputed line starts. + */ +function firstLines(text, names) { + const wanted = new Set(names); + const found = new Map(); + if (!wanted.size) return found; + const lines = text.split('\n'); + const token = /[A-Za-z_$][\w$]*/g; + for (let i = 0; i < lines.length && found.size < wanted.size; i++) { + token.lastIndex = 0; + let m; + while ((m = token.exec(lines[i])) !== null) { + if (wanted.has(m[0]) && !found.has(m[0])) found.set(m[0], i + 1); + } + } + if (found.size === wanted.size) return found; + const starts = [0]; + for (let i = 0; i < lines.length; i++) starts.push(starts[i] + lines[i].length + 1); + for (const name of wanted) { + if (found.has(name)) continue; + const at = text.indexOf(name); + if (at === -1) { + found.set(name, 0); + continue; + } + let lo = 0; + let hi = starts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (starts[mid] <= at) lo = mid; + else hi = mid - 1; + } + found.set(name, lo + 1); + } + return found; +} + /** * Lexical extraction lifted to the v2 result shape — the permanent fallback * tier. Each lexical symbol becomes a `kind: 'symbol'` def located at its * first occurrence line (an approximation, honestly labeled by the lexical - * tier — the AST tier records real declaration sites). `refs` stay empty: - * the lexical tier has no call facts to offer and never fabricates any. + * tier — the AST tier records real declaration sites), carrying the lexical + * extractor's export verdict so the structural checks have a real exported + * surface in the default tier. `refs` are the names the file EXPLICITLY + * imports from other modules, at their first-use line — facts the source + * states outright; the lexical tier still never infers a call from a bare + * identifier, and without them a lexical index would carry no edges at all. */ export function lexicalV2(rel, content) { - const { symbols, imports } = lexicalExtract(rel, content); - const lines = String(content || '').split('\n'); - const defs = symbols.slice(0, MAX_DEFS_PER_FILE).map((name) => { - const at = lines.findIndex((l) => l.includes(name)); - return { name: capName(name), kind: 'symbol', line: at === -1 ? 0 : at + 1, exported: false }; - }); + const { symbols, imports, exported, references } = lexicalExtract(rel, content); + const kept = symbols.slice(0, MAX_DEFS_PER_FILE); + const referenced = (references || []).slice(0, MAX_REFS_PER_FILE); + const exportedSet = new Set(exported || []); + const lines = firstLines(String(content || ''), [...kept, ...referenced]); + const defs = kept.map((name) => ({ + name: capName(name), + kind: 'symbol', + line: lines.get(name) ?? 0, + exported: exportedSet.has(name), + })); return { - symbols: symbols.map(capName), - imports: imports.map(capName), + symbols: kept.map(capName), + imports: imports.slice(0, MAX_IMPORTS_PER_FILE).map(capName), defs, - refs: [], + refs: referenced.map((name) => ({ name: capName(name), line: lines.get(name) ?? 0 })), complexity: branchComplexity(content), tier: 'lexical', }; } -/** Read and parse grammars.lock. Returns null when missing/unreadable. */ +/** Read and parse grammars.lock. Returns null when missing/unreadable — + * which is never a silent condition: the lock IS the integrity mechanism, so + * `createTreesitterExtract` refuses the treesitter tier and doctor S1 fails + * hard when it cannot be read (see MISSING_LOCK_FAILURE). */ export function loadGrammarsLock({ lockPath = DEFAULT_LOCK_PATH } = {}) { try { const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')); if (!lock || typeof lock !== 'object' || !lock.grammars) return null; - // Consumers dereference lock.runtime.package/.file directly — validate the - // runtime block here so a truncated lock reads as absent, never as a throw. + // Consumers dereference lock.runtime.package/.file and .loader directly — + // validate the runtime block here so a truncated lock reads as absent, + // never as a throw. const rt = lock.runtime; if (!rt || typeof rt !== 'object' || typeof rt.package !== 'string' || typeof rt.file !== 'string') return null; + if (!rt.loader || typeof rt.loader.file !== 'string' || typeof rt.loader.sha256 !== 'string') return null; return lock; } catch { return null; } } +/** The single failure record for an unreadable lock — the loud signal shared + * by the extractor (index meta) and the sync doctor probe. */ +export const MISSING_LOCK_FAILURE = Object.freeze({ + language: 'lock', + file: 'grammars.lock', + reason: 'grammars.lock missing or unreadable — wasm integrity cannot be verified', +}); + function sha256(bytes) { return crypto.createHash('sha256').update(bytes).digest('hex'); } +/** The harness package's OWN node_modules — where `optionalDependencies` + * install. Exported because the sync doctor probe must scope integrity + * verification to the copy this package owns: walking parent node_modules + * makes any unrelated web-tree-sitter anywhere up the filesystem a doctor + * failure for a user who never ran `harness index --structural`. */ +export function packageGrammarRoots() { + return [path.join(path.resolve(__dirname, '..', '..'), 'node_modules')]; +} + /** Default roots searched for `/` grammar wasm files. */ function defaultGrammarRoots() { // The harness package's own node_modules first (optionalDependencies land @@ -129,6 +206,24 @@ function defaultGrammarRoots() { return roots; } +/** + * The JS entry point `await import('web-tree-sitter')` will actually execute. + * Hash-pinning the wasm alone is not supply-chain integrity: an attacker who + * can edit node_modules edits THIS file instead and every wasm digest still + * verifies, with full Node privileges. Resolved through the module resolver + * (the same specifier the dynamic import uses) so the bytes hashed are the + * bytes run; falls back to the lock-named file under `roots` on older Node. + */ +function resolveLoaderPath(roots, lock) { + try { + const url = import.meta.resolve?.(lock.runtime.package); + if (typeof url === 'string' && url.startsWith('file:')) return fileURLToPath(url); + } catch { + // fall through to the root-scoped lookup + } + return findWasm(roots, lock.runtime.package, lock.runtime.loader.file); +} + function findWasm(roots, pkg, file) { for (const root of roots) { const full = path.join(root, pkg, file); @@ -145,14 +240,25 @@ function findWasm(roots, pkg, file) { * Synchronous availability + integrity report — no instantiation, no async. * Doctor S1 uses this to check the CURRENT on-disk grammar state (an index * meta records what was true at build time; this records what is true now). - * Shape: { lock, runtime: {present, ok, path?}, grammars: {lang: {present, - * ok, version, path?}}, integrityFailures: [{language, file, reason}] }. + * Shape: { lock, runtime: {present, ok, path?}, loader: {present, ok, path?}, + * grammars: {lang: {present, ok, version, path?}}, + * integrityFailures: [{language, file, reason}] }. An unreadable lock is + * itself an integrity failure — with no lock nothing can be verified. */ export function grammarStatus({ grammarRoots, lockPath } = {}) { const lock = loadGrammarsLock({ lockPath }); const roots = grammarRoots || defaultGrammarRoots(); - const status = { lock: Boolean(lock), runtime: { present: false, ok: false }, grammars: {}, integrityFailures: [] }; - if (!lock) return status; + const status = { + lock: Boolean(lock), + runtime: { present: false, ok: false }, + loader: { present: false, ok: false }, + grammars: {}, + integrityFailures: [], + }; + if (!lock) { + status.integrityFailures.push({ ...MISSING_LOCK_FAILURE }); + return status; + } const check = (language, spec) => { const full = findWasm(roots, spec.package, spec.file); if (!full) return { present: false, ok: false, version: spec.version }; @@ -167,6 +273,9 @@ export function grammarStatus({ grammarRoots, lockPath } = {}) { return { present: true, ok, version: spec.version, path: full }; }; status.runtime = check('runtime', lock.runtime); + // The JS loader is pinned like the wasm — a tampered entry point is the + // cheaper attack, so an unpinned loader would make the wasm digests theatre. + status.loader = check('loader', { ...lock.runtime.loader, package: lock.runtime.package }); for (const [language, spec] of Object.entries(lock.grammars)) { status.grammars[language] = check(language, spec); } @@ -410,7 +519,7 @@ export function makeStructuralExtract({ parseForLanguage, counters = { parseFail * mismatch ALSO degrades that grammar to lexical, but loudly: it is recorded * in `integrityFailures` for the index meta and doctor S1. */ -export async function createTreesitterExtract({ grammarRoots, lockPath } = {}) { +export async function createTreesitterExtract({ grammarRoots, lockPath, loaderPath } = {}) { const counters = { parseFailures: 0, parsed: 0, errorFiles: 0 }; const lexicalOnly = (reason, integrityFailures = []) => ({ ...makeStructuralExtract({ parseForLanguage: () => null, counters }), @@ -424,11 +533,29 @@ export async function createTreesitterExtract({ grammarRoots, lockPath } = {}) { }); const lock = loadGrammarsLock({ lockPath }); - if (!lock) return lexicalOnly('grammars.lock missing or unreadable'); + // A missing/truncated lock is NOT a silent optional-tier absence: it would + // disable every integrity check while the tier kept running. Refuse loudly. + if (!lock) return lexicalOnly('grammars.lock missing or unreadable', [{ ...MISSING_LOCK_FAILURE }]); const roots = grammarRoots || defaultGrammarRoots(); const integrityFailures = []; + // Verify the JS entry point BEFORE importing it — the import executes that + // file with full Node privileges, so it is pinned exactly like the wasm. + const loaderFull = loaderPath || resolveLoaderPath(roots, lock); + if (!loaderFull) return lexicalOnly('web-tree-sitter loader not installed (optional)'); + let loaderBytes; + try { + loaderBytes = fs.readFileSync(loaderFull); + } catch { + return lexicalOnly('web-tree-sitter loader unreadable'); + } + if (sha256(loaderBytes) !== lock.runtime.loader.sha256) { + return lexicalOnly('loader integrity mismatch', [ + { language: 'loader', file: lock.runtime.loader.file, reason: 'sha256 mismatch vs grammars.lock' }, + ]); + } + // Runtime wasm: verified bytes are handed to init as `wasmBinary`, so the // exact object hashed is the exact object instantiated. const runtimePath = findWasm(roots, lock.runtime.package, lock.runtime.file); diff --git a/packages/harness/lib/structural/expectations.mjs b/packages/harness/lib/structural/expectations.mjs index ca2a029f..faf55322 100644 --- a/packages/harness/lib/structural/expectations.mjs +++ b/packages/harness/lib/structural/expectations.mjs @@ -2,6 +2,13 @@ // the change against the plan. Advisory by default (policy.yaml v2 `checks:` // can escalate to warn/enforce); a missing or stale structural index skips // rather than guessing, so this check can never invent a failure. +// +// ONE RULE THROUGHOUT: never assert what was not compared. A file whose +// baseline came from another extractor tier, a file in a language this check +// cannot read, and a finding computed from a table the index build truncated +// all stay INFORMATIONAL; and a run that compared nothing reports `skipped`, +// never `passed` — a green gate over an empty comparison is worse than no +// gate, because an `enforce` opt-in would read it as evidence. import path from 'node:path'; import { spawnSync } from 'node:child_process'; @@ -13,6 +20,23 @@ import { readStructuralIndex } from './shape.mjs'; export const STRUCTURAL_CHECK_ID = 'structural-expectations'; const EXPECTATION_CHANGES = new Set(['added', 'removed', 'modified']); +// Findings are rendered and stored (evidence, ledger, JSON): the name lists +// inside them are bounded like every other retrieved-text surface. A capped +// list carries its own total so the count is never silently lost. +const MAX_FINDING_NAMES = 50; + +function capList(list) { + return list.length > MAX_FINDING_NAMES ? list.slice(0, MAX_FINDING_NAMES) : list; +} + +/** `{ added: [...] }` → `{ addedTotal: n }` for each list the cap shortened. */ +function overflow(lists) { + const extra = {}; + for (const [field, list] of Object.entries(lists)) { + if (list.length > MAX_FINDING_NAMES) extra[`${field}Total`] = list.length; + } + return extra; +} function shortSha(sha) { return String(sha || '').slice(0, 12); @@ -33,12 +57,17 @@ function symbolFile(qualified) { } /** Per-changed-file structural diff against the baseline index. - * Returns `{ diffs, tierSkipped }`: `tierSkipped` maps files whose baseline - * entry was built by a non-lexical extractor tier — the current side of the - * diff is ALWAYS the lexical extractor, so comparing against a treesitter - * baseline would disagree on unchanged code and fabricate added/removed - * findings. Those files are skipped honestly (reported as informational - * `tier-mismatch-skipped`), never diffed. */ + * Returns `{ diffs, tierSkipped, notEvaluated }`: + * - `tierSkipped` maps files whose baseline entry was built by a non-lexical + * extractor tier — the current side of the diff is ALWAYS the lexical + * extractor, so comparing against a treesitter baseline would disagree on + * unchanged code and fabricate added/removed findings. Those files are + * skipped honestly (reported as informational `tier-mismatch-skipped`). + * - `notEvaluated` maps changed files this check simply cannot speak about + * (a language the lexical extractor does not read, with no baseline entry). + * They are NOT diffed and, like tier skips, can never produce a finding — + * including an `unmet-required-expectation`, which would otherwise fire for + * every `.go`/`.rs`/`.rb` file regardless of what the change did. */ function diffChangedFiles({ workspace, index, changedFiles }) { const rowsByFile = new Map(); for (const row of index.symbols) { @@ -49,14 +78,26 @@ function diffChangedFiles({ workspace, index, changedFiles }) { const diffs = new Map(); const tierSkipped = new Map(); + const notEvaluated = new Map(); for (const file of changedFiles) { const ext = path.extname(file).toLowerCase(); const rows = rowsByFile.get(file) || []; const fileEntry = index.files[file]; - if (!SOURCE_EXTENSIONS.has(ext) && !fileEntry && rows.length === 0) continue; - // Per-file tier gate: only a lexical-tier (or untiered legacy/fixture) - // baseline entry diffs soundly against the lexical current side. - const tier = typeof fileEntry?.tier === 'string' ? fileEntry.tier : null; + if (!SOURCE_EXTENSIONS.has(ext) && !fileEntry && rows.length === 0) { + notEvaluated.set(file, `no baseline entry and ${ext || 'no extension'} is not a language this check reads`); + continue; + } + // Per-file tier gate: only a lexical-tier baseline entry diffs soundly + // against the lexical current side. An untiered entry inherits the + // index-wide meta.extractorTier — a legacy/fixture index with no tier + // anywhere still diffs, but an untiered file inside a treesitter-tier + // index must not be compared against lexical output. + const tier = + typeof fileEntry?.tier === 'string' + ? fileEntry.tier + : typeof index.meta?.extractorTier === 'string' + ? index.meta.extractorTier + : null; if (tier && tier !== 'lexical') { tierSkipped.set(file, tier); continue; @@ -66,46 +107,65 @@ function diffChangedFiles({ workspace, index, changedFiles }) { // the workspace) and size-capped — a committed symlink or oversized file // reads as empty, exactly like a deleted file. let current = []; + let currentExported = []; const content = readFileSafe(workspace, file); if (content) { try { - current = extract(file, content).symbols; + const extracted = extract(file, content); + current = extracted.symbols; + currentExported = extracted.exported || []; } catch { current = []; } } const currentSet = new Set(current); + const currentExportedSet = new Set(currentExported); const baselineNames = new Set([ ...(Array.isArray(fileEntry?.symbols) ? fileEntry.symbols : []), ...rows.map((row) => row.name), ]); const exportedRows = rows.filter((row) => row.exported === true); + const added = current.filter((name) => !baselineNames.has(name)).sort(); diffs.set(file, { - added: current.filter((name) => !baselineNames.has(name)).sort(), + added, + // The contract speaks about EXPORTED symbols: an added local helper is + // not a change to what other modules can see. + addedExported: added.filter((name) => currentExportedSet.has(name)), removed: [...baselineNames].filter((name) => !currentSet.has(name)).sort(), removedExported: [...new Set(exportedRows.map((row) => row.name))].filter((name) => !currentSet.has(name)).sort(), baselineNames, currentSet, }); } - return { diffs, tierSkipped }; + return { diffs, tierSkipped, notEvaluated }; } -function survivingCallers({ index, file, symbol, changedSet }) { - const target = `${file}#${symbol}`; - const callers = new Set(); +/** Caller lookup tables, built ONCE per run. Building them per removed symbol + * re-walked the whole call-edge and symbol-row tables for every candidate. */ +function callerIndex(index) { + const byTarget = new Map(); + const add = (key, file) => { + if (!file) return; + if (!byTarget.has(key)) byTarget.set(key, new Set()); + byTarget.get(key).add(file); + }; for (const edge of index.graph.calls) { - if (edge?.to === target) callers.add(symbolFile(edge.from)); + if (edge && typeof edge.to === 'string') add(edge.to, symbolFile(edge.from)); } for (const row of index.symbols) { - if (row?.file !== file || row?.name !== symbol) continue; + if (typeof row?.file !== 'string' || typeof row?.name !== 'string') continue; for (const ref of Array.isArray(row.refs) ? row.refs : []) { - if (ref && typeof ref.file === 'string') callers.add(ref.file); + if (ref && typeof ref.file === 'string') add(`${row.file}#${row.name}`, ref.file); } } - callers.delete(file); - return [...callers].filter((caller) => caller && !changedSet.has(caller)).sort(); + return byTarget; +} + +function survivingCallers({ callers, file, symbol, changedSet }) { + const found = callers.get(`${file}#${symbol}`); + if (!found) return []; + return [...found].filter((caller) => caller && caller !== file && !changedSet.has(caller)).sort(); } function expectationObserved(expectation, diffs) { @@ -117,7 +177,7 @@ function expectationObserved(expectation, diffs) { return diff.baselineNames.has(expectation.symbol) && diff.currentSet.has(expectation.symbol); } -function evaluateExpectations(plan, diffs, tierSkipped = new Map()) { +function evaluateExpectations(plan, diffs, tierSkipped = new Map(), notEvaluated = new Map()) { const raw = plan.fm?.structural_expectations; const findings = []; const informational = []; @@ -149,6 +209,18 @@ function evaluateExpectations(plan, diffs, tierSkipped = new Map()) { }); continue; } + // Same discipline for a file the check cannot read at all: "could not + // compare" is informational, never a required-expectation failure. + if (notEvaluated.has(entry.file)) { + informational.push({ + type: 'expectation-not-evaluated', + file: entry.file, + symbol: entry.symbol, + change: entry.change, + message: `expectation on ${entry.file} not evaluated: ${notEvaluated.get(entry.file)}`, + }); + continue; + } if (expectationObserved(entry, diffs)) continue; const description = { type: 'unmet-expectation', file: entry.file, symbol: entry.symbol, change: entry.change }; // Only expectations explicitly marked required can fail the check; the @@ -188,7 +260,7 @@ export function runStructuralExpectations({ workspace, plan, changedFiles, home const changed = [...new Set(changedFiles || [])]; const changedSet = new Set(changed); const allowed = parseImpactedFiles(plan); - const { diffs, tierSkipped } = diffChangedFiles({ workspace, index, changedFiles: changed }); + const { diffs, tierSkipped, notEvaluated } = diffChangedFiles({ workspace, index, changedFiles: changed }); // Per-file tier mismatches surface as informational notes, never findings: // the skip is honest ("could not compare"), not evidence of a problem. const tierNotes = [...tierSkipped].map(([file, tier]) => ({ @@ -197,22 +269,65 @@ export function runStructuralExpectations({ workspace, plan, changedFiles, home tier, message: `baseline entry for ${file} was built by the '${tier}' extractor tier; the current side is lexical — symbol diff skipped as unsound`, })); + const notEvaluatedNotes = [...notEvaluated].map(([file, reason]) => ({ + type: 'file-not-evaluated', + file, + message: `${file} not compared: ${reason}`, + })); + // A truncated baseline table cannot support an assertion: past the cap a + // removed symbol simply has no recorded callers and the symbol table is + // incomplete, so these findings degrade to informational instead of + // claiming something the data cannot show. + const truncated = [ + index.meta?.symbolsTruncated ? 'symbol table' : null, + index.meta?.callEdgesTruncated ? 'call edges' : null, + ].filter(Boolean); const findings = []; + const degraded = []; + const record = (finding) => { + if (truncated.length) { + degraded.push({ + ...finding, + type: `${finding.type}-informational`, + message: `${finding.type} not asserted: the baseline ${truncated.join(' and ')} hit the index build cap, so this comparison is incomplete`, + }); + } else { + findings.push(finding); + } + }; + // Built on first use only: a run with no removed exports never walks the + // symbol/edge tables at all. + let callers = null; for (const [file, diff] of diffs) { - const symbolChanges = [...diff.added, ...diff.removedExported]; + const symbolChanges = [...diff.addedExported, ...diff.removedExported]; if (symbolChanges.length && !matchesScope(file, allowed)) { - findings.push({ type: 'unplanned-symbol-change', file, added: diff.added, removed: diff.removedExported }); + record({ + type: 'unplanned-symbol-change', + file, + added: capList(diff.addedExported), + removed: capList(diff.removedExported), + ...overflow({ added: diff.addedExported, removed: diff.removedExported }), + }); } for (const symbol of diff.removedExported) { - const callers = survivingCallers({ index, file, symbol, changedSet }); - if (callers.length) findings.push({ type: 'removed-symbol-with-callers', file, symbol, callers }); + callers ??= callerIndex(index); + const surviving = survivingCallers({ callers, file, symbol, changedSet }); + if (surviving.length) { + record({ + type: 'removed-symbol-with-callers', + file, + symbol, + callers: capList(surviving), + ...overflow({ callers: surviving }), + }); + } } } - const expectations = evaluateExpectations(plan, diffs, tierSkipped); + const expectations = evaluateExpectations(plan, diffs, tierSkipped, notEvaluated); findings.push(...expectations.findings); - const informational = [...tierNotes, ...expectations.informational]; + const informational = [...tierNotes, ...notEvaluatedNotes, ...degraded, ...expectations.informational]; if (findings.length) { const kinds = [...new Set(findings.map((finding) => finding.type))].join(', '); @@ -224,6 +339,20 @@ export function runStructuralExpectations({ workspace, plan, changedFiles, home baseline, }; } + // Zero comparisons is not a pass. A run where every changed file was + // skipped (tier mismatch, unreadable language) or where nothing comparable + // changed examined NOTHING — reporting `passed` would be a green gate over + // an empty comparison, including under an `enforce` opt-in. + const skippedFiles = tierSkipped.size + notEvaluated.size; + if (diffs.size === 0) { + return { + status: 'skipped', + message: `Structural check compared nothing (0 files examined${skippedFiles ? `, ${skippedFiles} skipped` : ''})`, + findings, + informational, + baseline, + }; + } return { status: 'passed', message: `Structural diff matches the plan (${diffs.size} file${diffs.size === 1 ? '' : 's'} examined${tierSkipped.size ? `, ${tierSkipped.size} tier-mismatch-skipped` : ''})`, diff --git a/packages/harness/lib/structural/shape.mjs b/packages/harness/lib/structural/shape.mjs index 13acf0d1..eb7bb9de 100644 --- a/packages/harness/lib/structural/shape.mjs +++ b/packages/harness/lib/structural/shape.mjs @@ -4,8 +4,9 @@ // doctor S1). Consumers import THIS module only; when the builder lands, the // integration is one import swap here, not a change in every consumer. // -// Storage root: `~/.harness/index//structural/` (HARNESS_HOME -// overrides the home for tests). Four files: +// Storage root: `~/.harness/index///structural/` +// (HARNESS_HOME overrides the home for tests) — keyed per worktree as well as +// per repo, see `worktreeId`. Four files: // // files.json { "version": 1, "files": { "": { // "hash": "", "mtime": , @@ -41,19 +42,51 @@ import fs from 'node:fs'; import path from 'node:path'; +import crypto from 'node:crypto'; +import { spawnSync } from 'node:child_process'; import { harnessGlobalHome } from '../paths.mjs'; import { repoId } from '../knowledge/store.mjs'; import { readFileNoFollow } from '../fs-safe.mjs'; export const STRUCTURAL_SHAPE_VERSION = 1; +const worktreeIds = new Map(); + +/** + * Stable per-WORKTREE id, appended to the repo-keyed index path. + * `repoId` hashes the origin remote — deliberately path-independent, so every + * checkout of one repo shares a knowledge store. The structural index cannot + * be shared that way: two worktrees of the same repo sit at the same + * `meta.sha` with DIFFERENT working-tree content, and `meta.sha` is the only + * currency gate, so a single directory would serve each worktree the other's + * symbol tables (and thrash the mtime fast path when both are indexed). + * Derived from the worktree root's realpath, hashed and truncated like + * `localRepoId`; memoized per resolved path (this sits in orient's read path). + */ +export function worktreeId(workspace) { + const key = path.resolve(workspace); + const cached = worktreeIds.get(key); + if (cached) return cached; + let root = key; + const top = spawnSync('git', ['-C', key, 'rev-parse', '--show-toplevel'], { encoding: 'utf8', timeout: 10_000 }); + if (top.status === 0 && top.stdout.trim()) root = top.stdout.trim(); + try { + root = fs.realpathSync(root); + } catch { + // keep the resolved path + } + const id = `wt-${crypto.createHash('sha256').update(root).digest('hex').slice(0, 12)}`; + worktreeIds.set(key, id); + return id; +} + const EMPTY_FILES = Object.freeze({ version: STRUCTURAL_SHAPE_VERSION, files: {} }); const EMPTY_SYMBOLS = Object.freeze({ version: STRUCTURAL_SHAPE_VERSION, symbols: [] }); const EMPTY_GRAPH = Object.freeze({ version: STRUCTURAL_SHAPE_VERSION, calls: [], modules: [], unresolved: [] }); -/** `/index//structural` for this workspace. */ +/** `/index///structural` for this workspace. */ export function structuralDir(workspace, { home } = {}) { - return path.join(home || harnessGlobalHome(), 'index', repoId(workspace), 'structural'); + return path.join(home || harnessGlobalHome(), 'index', repoId(workspace), worktreeId(workspace), 'structural'); } function readJson(dir, name) { @@ -101,6 +134,12 @@ export function readStructuralIndex(workspace, { home } = {}) { if (typeof meta.value.sha !== 'string' || !/^[0-9a-f]{7,40}$/i.test(meta.value.sha)) { return absent('meta.json has no valid baseline sha'); } + // `version` is the shape contract, so it is enforced rather than merely + // written: an index from a NEWER writer must be skipped, not misread. + const version = meta.value.version; + if (version !== undefined && !(Number.isFinite(version) && version <= STRUCTURAL_SHAPE_VERSION)) { + return absent(`unsupported structural index version ${JSON.stringify(version)} (this harness reads ${STRUCTURAL_SHAPE_VERSION})`); + } const files = readJson(dir, 'files.json'); const symbols = readJson(dir, 'symbols.json'); diff --git a/packages/harness/test/doctor-structural.test.mjs b/packages/harness/test/doctor-structural.test.mjs index d4cf36d5..c9369786 100644 --- a/packages/harness/test/doctor-structural.test.mjs +++ b/packages/harness/test/doctor-structural.test.mjs @@ -3,10 +3,18 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; import { test } from 'node:test'; import { structuralChecks, runDoctor } from '../lib/doctor.mjs'; -import { buildStructuralIndex } from '../lib/repo-map/structural-index.mjs'; -import { lexicalV2 } from '../lib/repo-map/treesitter-extractor.mjs'; +import { buildStructuralIndex, structuralIndexDir } from '../lib/repo-map/structural-index.mjs'; +import { lexicalV2, loadGrammarsLock, DEFAULT_LOCK_PATH } from '../lib/repo-map/treesitter-extractor.mjs'; + +// HERMETIC GRAMMAR PROBE: every scenario pins its own grammar roots, so S1 is +// never a verdict on whatever happens to be installed up the filesystem from +// the test runner. `emptyRoots` is the "no grammars here" baseline. +function emptyRoots(t) { + return [tempTree(t, 'harness-grammar-roots-')]; +} // Temp dirs registered for t.after cleanup — a failing assertion must not // leak the tree (a trailing rmSync never runs on failure). @@ -66,8 +74,9 @@ function withHome(t, home) { test('S1: no index built → advisory pass with the build hint', (t) => { const { ws } = gitRepo(t, FIXTURE); const home = tempTree(t, 'harness-home-'); + const roots = emptyRoots(t); withHome(t, home); - const checks = structuralChecks({ workspace: ws }); + const checks = structuralChecks({ workspace: ws, grammarRoots: roots }); assert.equal(checks.length, 1); assert.equal(checks[0].id, 'S1'); assert.equal(checks[0].pass, true); @@ -78,16 +87,17 @@ test('S1: no index built → advisory pass with the build hint', (t) => { test('S1: current healthy index passes; meta.sha drift and orphans degrade to advisory failure', async (t) => { const { ws, git } = gitRepo(t, FIXTURE); const home = tempTree(t, 'harness-home-'); + const roots = emptyRoots(t); withHome(t, home); await buildStructuralIndex({ workspace: ws, home, extractor: extractorWith() }); - const healthy = structuralChecks({ workspace: ws })[0]; + const healthy = structuralChecks({ workspace: ws, grammarRoots: roots })[0]; assert.equal(healthy.pass, true); assert.match(healthy.hint, /current with HEAD/); // Orphaned cache entry: an indexed file removed from disk. fs.rmSync(path.join(ws, 'b.mjs')); - const orphaned = structuralChecks({ workspace: ws })[0]; + const orphaned = structuralChecks({ workspace: ws, grammarRoots: roots })[0]; assert.equal(orphaned.pass, false); assert.equal(orphaned.optional, true, 'orphans are advisory, not a hard doctor failure'); assert.match(orphaned.hint, /orphaned cache/); @@ -97,7 +107,7 @@ test('S1: current healthy index passes; meta.sha drift and orphans degrade to ad fs.writeFileSync(path.join(ws, 'c.mjs'), 'export const c = 3;\n'); git(['add', '.']); git(['commit', '-qm', 'advance']); - const stale = structuralChecks({ workspace: ws })[0]; + const stale = structuralChecks({ workspace: ws, grammarRoots: roots })[0]; assert.equal(stale.pass, false); assert.equal(stale.optional, true); assert.match(stale.hint, /meta\.sha behind HEAD/); @@ -106,6 +116,7 @@ test('S1: current healthy index passes; meta.sha drift and orphans degrade to ad test('S1: a recorded grammar integrity mismatch is a hard failure, not a warning', async (t) => { const { ws } = gitRepo(t, FIXTURE); const home = tempTree(t, 'harness-home-'); + const roots = emptyRoots(t); withHome(t, home); await buildStructuralIndex({ workspace: ws, @@ -114,7 +125,7 @@ test('S1: a recorded grammar integrity mismatch is a hard failure, not a warning integrityFailures: [{ language: 'javascript', file: 'tree-sitter-javascript.wasm', reason: 'sha256 mismatch vs grammars.lock' }], }), }); - const check = structuralChecks({ workspace: ws })[0]; + const check = structuralChecks({ workspace: ws, grammarRoots: roots })[0]; assert.equal(check.id, 'S1'); assert.equal(check.pass, false); assert.ok(!check.optional, 'integrity mismatch must fail doctor, never warn'); @@ -122,14 +133,74 @@ test('S1: a recorded grammar integrity mismatch is a hard failure, not a warning assert.match(check.hint, /javascript/); }); +test('S1: a recorded mismatch the disk now verifies is stale — advisory, not a permanent hard failure', async (t) => { + const lock = loadGrammarsLock(); + const roots = emptyRoots(t); + // A root carrying the GENUINE javascript wasm: the bytes on disk verify now. + const jsDir = path.join(roots[0], lock.grammars.javascript.package); + let source = null; + try { + source = createRequire(import.meta.url).resolve(`${lock.grammars.javascript.package}/${lock.grammars.javascript.file}`); + } catch { + source = null; + } + if (!source) return t.skip('javascript grammar wasm not installed — nothing to verify as fixed'); + fs.mkdirSync(jsDir, { recursive: true }); + fs.copyFileSync(source, path.join(jsDir, lock.grammars.javascript.file)); + + const { ws } = gitRepo(t, FIXTURE); + const home = tempTree(t, 'harness-home-'); + withHome(t, home); + await buildStructuralIndex({ + workspace: ws, + home, + extractor: extractorWith({ + integrityFailures: [{ language: 'javascript', file: lock.grammars.javascript.file, reason: 'sha256 mismatch vs grammars.lock' }], + }), + }); + const check = structuralChecks({ workspace: ws, grammarRoots: roots })[0]; + assert.equal(check.pass, false); + assert.equal(check.optional, true, 'a reinstalled grammar must stop being a hard failure without a rebuild'); + assert.match(check.hint, /still records a grammar integrity mismatch/); + assert.match(check.hint, /harness index --structural/); +}); + +test('S1: an unreadable grammars.lock fails hard — integrity checking must never silently switch off', (t) => { + const { ws } = gitRepo(t, FIXTURE); + const home = tempTree(t, 'harness-home-'); + const roots = emptyRoots(t); + withHome(t, home); + const check = structuralChecks({ workspace: ws, grammarRoots: roots, lockPath: path.join(roots[0], 'absent.lock') })[0]; + assert.equal(check.id, 'S1'); + assert.equal(check.pass, false); + assert.ok(!check.optional, 'no lock means nothing can be verified — that is a failure, not a warning'); + assert.match(check.hint, /grammars\.lock missing or unreadable/); + // The shipped lock still passes the same probe. + assert.equal(loadGrammarsLock({ lockPath: DEFAULT_LOCK_PATH }) === null, false); +}); + +test('S1: an unreadable index table is surfaced, not silently read as empty', async (t) => { + const { ws } = gitRepo(t, FIXTURE); + const home = tempTree(t, 'harness-home-'); + const roots = emptyRoots(t); + withHome(t, home); + await buildStructuralIndex({ workspace: ws, home, extractor: extractorWith() }); + fs.writeFileSync(path.join(structuralIndexDir(ws, { home }), 'files.json'), '{ truncated'); + const check = structuralChecks({ workspace: ws, grammarRoots: roots })[0]; + assert.equal(check.pass, false); + assert.equal(check.optional, true); + assert.match(check.hint, /files\.json is not valid JSON/); +}); + test('S1: parse-failure rate over 20% degrades to advisory failure', async (t) => { const { ws } = gitRepo(t, FIXTURE); const home = tempTree(t, 'harness-home-'); + const roots = emptyRoots(t); withHome(t, home); const extractor = extractorWith(); extractor.counters.parseFailures = 1; // 1 of 2 files await buildStructuralIndex({ workspace: ws, home, extractor }); - const check = structuralChecks({ workspace: ws })[0]; + const check = structuralChecks({ workspace: ws, grammarRoots: roots })[0]; assert.equal(check.pass, false); assert.equal(check.optional, true); assert.match(check.hint, /parse-failure rate/); diff --git a/packages/harness/test/index-structural-cli.test.mjs b/packages/harness/test/index-structural-cli.test.mjs index 7c35f319..901e5e57 100644 --- a/packages/harness/test/index-structural-cli.test.mjs +++ b/packages/harness/test/index-structural-cli.test.mjs @@ -103,6 +103,45 @@ test('harness index --structural --since validates the ref and rejects option-sh fs.rmSync(home, { recursive: true, force: true }); }); +test('a --since that is not the prior index baseline is ignored, reported, and falls back to a full pass', () => { + const { ws, git } = gitRepo(FIXTURE); + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-home-')); + assert.equal(runHarness(['index', '--structural'], { home, ws }).status, 0); + + // Two commits after the index was built: `--since HEAD~1` would leave the + // first one's files stale under a current-looking meta.sha. + fs.writeFileSync(path.join(ws, 'src', 'pay.mjs'), 'export function charge() {}\nexport function first() {}\n'); + git(['add', '.']); + git(['commit', '-qm', 'one']); + fs.writeFileSync(path.join(ws, 'src', 'audit.mjs'), 'export function audit() {}\nexport function second() {}\n'); + git(['add', '.']); + git(['commit', '-qm', 'two']); + + const out = runHarness(['index', '--structural', '--since', 'HEAD~1', '--json'], { home, ws }); + assert.equal(out.status, 0, out.stderr); + const json = JSON.parse(out.stdout.trim().split('\n').at(-1)); + assert.match(json.sinceIgnored || '', /does not match the prior index baseline/); + assert.equal(json.baseSha, null, 'an ignored --since is never stamped as the baseline'); + assert.equal(json.reparsed, 2, 'both commits are re-parsed by the fallback full pass'); + + const human = runHarness(['index', '--structural', '--since', 'HEAD~1'], { home, ws }); + assert.equal(human.status, 0, human.stderr); + assert.match(human.stdout, /full pass/, 'the ledger says the narrowing was dropped'); + + fs.rmSync(ws, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); +}); + +test('--since without --structural is a usage error, not a silently ignored flag', () => { + const { ws } = gitRepo(FIXTURE); + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-home-')); + const r = runHarness(['index', '--since', 'HEAD~1'], { home, ws }); + assert.notEqual(r.status, 0); + assert.match(r.stderr, /--since requires --structural/); + fs.rmSync(ws, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); +}); + test('plain harness index still works and never builds the structural tree', () => { const { ws } = gitRepo(FIXTURE); const home = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-home-')); diff --git a/packages/harness/test/structural-expectations.test.mjs b/packages/harness/test/structural-expectations.test.mjs index c927d6aa..3299f06c 100644 --- a/packages/harness/test/structural-expectations.test.mjs +++ b/packages/harness/test/structural-expectations.test.mjs @@ -1,6 +1,11 @@ // Phase 4 — per-check severity (policy v2) and the advisory -// `structural-expectations` verify check. Fixtures build their own structural -// index against the documented shape in lib/structural/shape.mjs. +// `structural-expectations` verify check. +// +// FIXTURE DISCIPLINE: the baseline index under test is written by the REAL +// builder (`buildStructuralIndex`) from the fixture workspace, so every +// assertion here pins the contract against a shape the builder actually emits. +// Hand-written index JSON is reserved for the reader's own malformed-input +// tests below, where the point IS an off-contract file on disk. import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -11,6 +16,8 @@ import { test } from 'node:test'; import { loadPolicy, checkSeverityFor, enforcementExitCode } from '../lib/policy.mjs'; import { structuralDir, readStructuralIndex, STRUCTURAL_SHAPE_VERSION } from '../lib/structural/shape.mjs'; import { runStructuralExpectations, STRUCTURAL_CHECK_ID } from '../lib/structural/expectations.mjs'; +import { buildStructuralIndex } from '../lib/repo-map/structural-index.mjs'; +import { lexicalV2 } from '../lib/repo-map/treesitter-extractor.mjs'; import { runVerify } from '../lib/verify.mjs'; import { readEvidence } from '../lib/evidence.mjs'; @@ -137,8 +144,30 @@ No open findings. return rel; } -/** Baseline: src/example.js exports `value` and `helper`; consumer calls `value`. */ -function writeStructuralIndex(workspace, home, { sha, files, symbols, graph } = {}) { +/** An injectable extractor in the builder's own shape. `tier: 'treesitter'` + * stamps the per-file tier the AST tier emits — the builder's real output for + * a grammar-parsed file — without needing the optional grammars installed. */ +function extractorFor(tier = 'lexical') { + return { + counters: { parseFailures: 0, parsed: 0, errorFiles: 0 }, + tier, + webTreeSitter: null, + grammarVersions: {}, + missingGrammars: [], + integrityFailures: [], + extract: (rel, content) => (tier === 'lexical' ? lexicalV2(rel, content) : { ...lexicalV2(rel, content), tier }), + }; +} + +/** Baseline written by the REAL builder from the fixture workspace: + * src/example.js exports `value` and `helper`; src/consumer.js imports + * `value`, so the tables carry the export flags and the caller edge. */ +function buildBaseline(workspace, home, { tier = 'lexical' } = {}) { + return buildStructuralIndex({ workspace, home, extractor: extractorFor(tier) }); +} + +/** Hand-written index for the READER's malformed/off-contract cases only. */ +function writeStructuralIndex(workspace, home, { sha, files, symbols, graph, extractorTier = 'lexical' } = {}) { const dir = structuralDir(workspace, { home }); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync( @@ -166,7 +195,7 @@ function writeStructuralIndex(workspace, home, { sha, files, symbols, graph } = branch: 'main', baseSha: sha, generatedAt: new Date().toISOString(), - extractorTier: 'lexical', + extractorTier, grammarVersions: {}, }, null, @@ -176,7 +205,10 @@ function writeStructuralIndex(workspace, home, { sha, files, symbols, graph } = return dir; } -function exampleBaseline(sha) { +/** A LEGACY on-disk index: wrapper form, no per-file `tier` anywhere. Kept + * hand-written on purpose — the point of its one test is an index shape the + * current builder no longer emits. */ +function legacyUntieredBaseline(sha) { return { sha, files: { @@ -337,14 +369,15 @@ test('missing structural index reports skipped, never fails', () => { assert.deepEqual(result.findings, []); }); -test('stale baseline (meta.sha not an ancestor of HEAD) warns and skips', () => { +test('stale baseline (meta.sha not an ancestor of HEAD) warns and skips', async () => { const { workspace, home } = structuralWorkspace(); - // A commit on a side branch is not an ancestor of the restored main HEAD. + // A commit on a side branch is not an ancestor of the restored main HEAD — + // the index is built there, so meta.sha is genuinely off-history. git(workspace, ['checkout', '-q', '-b', 'side']); fs.writeFileSync(path.join(workspace, 'src', 'side.js'), 'export const side = 1;\n'); - const sideSha = commitAll(workspace, 'side work'); + commitAll(workspace, 'side work'); + await buildBaseline(workspace, home); git(workspace, ['checkout', '-q', '-']); - writeStructuralIndex(workspace, home, { ...exampleBaseline(sideSha) }); const result = runStructuralExpectations({ workspace, @@ -357,9 +390,9 @@ test('stale baseline (meta.sha not an ancestor of HEAD) warns and skips', () => assert.match(result.message, /harness index --structural/); }); -test('removed exported symbol with a surviving caller is flagged', () => { +test('removed exported symbol with a surviving caller is flagged', async () => { const { workspace, home, sha } = structuralWorkspace(); - writeStructuralIndex(workspace, home, exampleBaseline(sha)); + await buildBaseline(workspace, home); // Remove `value` while the untouched consumer still calls it. fs.writeFileSync(path.join(workspace, 'src', 'example.js'), 'export const other = 2;\n'); @@ -376,13 +409,11 @@ test('removed exported symbol with a surviving caller is flagged', () => { assert.ok(!removed.some((finding) => finding.symbol === 'helper'), JSON.stringify(removed)); }); -test('a treesitter-tier baseline entry is never diffed against the lexical current side — informational skip, no findings', () => { - const { workspace, home, sha } = structuralWorkspace(); - const baseline = exampleBaseline(sha); - // The baseline entry for example.js was built by the treesitter tier; the +test('a treesitter-tier baseline is never diffed against the lexical current side — skipped, not passed', async () => { + const { workspace, home } = structuralWorkspace(); + // The whole baseline is written by the builder at the treesitter tier; the // check's current side is always lexical, so any diff would be unsound. - baseline.files['src/example.js'] = { ...baseline.files['src/example.js'], tier: 'treesitter' }; - writeStructuralIndex(workspace, home, baseline); + await buildBaseline(workspace, home, { tier: 'treesitter' }); // Without the tier gate this removal fabricates removed-symbol findings. fs.writeFileSync(path.join(workspace, 'src', 'example.js'), 'export const other = 2;\n'); @@ -394,19 +425,41 @@ test('a treesitter-tier baseline entry is never diffed against the lexical curre changedFiles: ['src/example.js'], home, }); - assert.equal(result.status, 'passed', JSON.stringify(result.findings)); + // Nothing was compared, so this is a SKIP, not a green gate over an empty + // comparison (which an `enforce` opt-in would read as a pass). + assert.equal(result.status, 'skipped', JSON.stringify(result.findings)); assert.deepEqual(result.findings, []); const notes = result.informational.filter((note) => note.type === 'tier-mismatch-skipped'); assert.ok(notes.some((note) => note.file === 'src/example.js' && note.tier === 'treesitter'), JSON.stringify(result.informational)); // A required expectation on the tier-skipped file is unverifiable — it must // surface informationally, never as a fabricated unmet-required failure. assert.ok(notes.some((note) => note.symbol === 'other'), JSON.stringify(result.informational)); - assert.match(result.message, /1 tier-mismatch-skipped/); + assert.match(result.message, /compared nothing/); +}); + +test('an untiered file entry inherits meta.extractorTier — treesitter index never diffs against lexical', () => { + const { workspace, home, sha } = structuralWorkspace(); + // No per-file tier anywhere (a legacy index), but the index-wide meta + // declares treesitter: the untiered entry must inherit that tier and be + // skipped, not diffed as lexical (which would fabricate findings here). + writeStructuralIndex(workspace, home, { ...legacyUntieredBaseline(sha), extractorTier: 'treesitter' }); + fs.writeFileSync(path.join(workspace, 'src', 'example.js'), 'export const other = 2;\n'); + + const result = runStructuralExpectations({ + workspace, + plan: minimalPlan(['src/example.js']), + changedFiles: ['src/example.js'], + home, + }); + assert.equal(result.status, 'skipped', JSON.stringify(result.findings)); + assert.deepEqual(result.findings, []); + const notes = result.informational.filter((note) => note.type === 'tier-mismatch-skipped'); + assert.ok(notes.some((note) => note.file === 'src/example.js' && note.tier === 'treesitter'), JSON.stringify(result.informational)); }); -test('callers that changed in the same diff do not count as surviving', () => { +test('callers that changed in the same diff do not count as surviving', async () => { const { workspace, home, sha } = structuralWorkspace(); - writeStructuralIndex(workspace, home, exampleBaseline(sha)); + await buildBaseline(workspace, home); fs.writeFileSync(path.join(workspace, 'src', 'example.js'), 'export const other = 2;\n'); fs.writeFileSync(path.join(workspace, 'src', 'consumer.js'), "import { other } from './example.js';\nexport function main() { return other; }\n"); @@ -419,9 +472,9 @@ test('callers that changed in the same diff do not count as surviving', () => { assert.ok(!result.findings.some((finding) => finding.type === 'removed-symbol-with-callers'), JSON.stringify(result.findings)); }); -test('changed exported symbols outside Impacted Files are flagged; planned ones are not', () => { +test('changed exported symbols outside Impacted Files are flagged; planned ones are not', async () => { const { workspace, home, sha } = structuralWorkspace(); - writeStructuralIndex(workspace, home, exampleBaseline(sha)); + await buildBaseline(workspace, home); // Added symbol in a planned file: fine. New file with symbols outside the plan: flagged. fs.writeFileSync(path.join(workspace, 'src', 'example.js'), 'export const value = 1;\nexport function helper() { return value; }\nexport const added = 3;\n'); fs.writeFileSync(path.join(workspace, 'src', 'unplanned.js'), 'export const rogue = 9;\n'); @@ -438,9 +491,98 @@ test('changed exported symbols outside Impacted Files are flagged; planned ones assert.deepEqual(unplanned[0].added, ['rogue']); }); -test('a clean structural diff passes with an examined-files summary', () => { +test('unplanned-symbol-change reports EXPORTED changes only — a local addition is not a public change', async () => { + const { workspace, home } = structuralWorkspace(); + await buildBaseline(workspace, home); + // Unplanned file whose only new symbol is module-private: nothing about the + // module surface changed, so the contract ("changed exported symbols outside + // Impacted Files") must not fire. + fs.writeFileSync(path.join(workspace, 'src', 'private.js'), 'function Hidden() { return 1; }\nHidden();\n'); + + const quiet = runStructuralExpectations({ + workspace, + plan: minimalPlan(['src/example.js']), + changedFiles: ['src/private.js'], + home, + }); + assert.deepEqual(quiet.findings, [], JSON.stringify(quiet.findings)); + + // The same file gaining an EXPORT is a public change and does fire. + fs.writeFileSync(path.join(workspace, 'src', 'private.js'), 'export function Hidden() { return 1; }\n'); + const loud = runStructuralExpectations({ + workspace, + plan: minimalPlan(['src/example.js']), + changedFiles: ['src/private.js'], + home, + }); + const unplanned = loud.findings.filter((finding) => finding.type === 'unplanned-symbol-change'); + assert.deepEqual(unplanned.map((finding) => finding.added), [['Hidden']], JSON.stringify(loud.findings)); +}); + +test('a changed file the check cannot evaluate never fails a required expectation', async () => { + const { workspace, home } = structuralWorkspace(); + await buildBaseline(workspace, home); + // .go is not a language the lexical extractor reads and has no baseline + // entry: the check cannot say anything about it, so a required expectation + // on it is informational — not a hard finding regardless of the change. + fs.writeFileSync(path.join(workspace, 'src', 'thing.go'), 'package main\nfunc Thing() {}\n'); + + const result = runStructuralExpectations({ + workspace, + plan: minimalPlan(['src/thing.go'], { + structural_expectations: [{ file: 'src/thing.go', symbol: 'Thing', change: 'added', required: true }], + }), + changedFiles: ['src/thing.go'], + home, + }); + assert.deepEqual(result.findings, [], JSON.stringify(result.findings)); + assert.equal(result.status, 'skipped', result.message); + assert.ok( + result.informational.some((note) => note.type === 'expectation-not-evaluated' && note.file === 'src/thing.go'), + JSON.stringify(result.informational) + ); + assert.ok( + result.informational.some((note) => note.type === 'file-not-evaluated' && note.file === 'src/thing.go'), + JSON.stringify(result.informational) + ); +}); + +test('a truncated baseline table degrades findings to informational instead of asserting them', async () => { + const { workspace, home } = structuralWorkspace(); + // A build that hits MAX_SYMBOL_TABLE: past the cap the symbol table is + // incomplete, so "removed symbol with callers" cannot be asserted from it. + const build = await buildStructuralIndex({ + workspace, + home, + extractor: { + ...extractorFor('lexical'), + extract: (rel, content) => { + const base = lexicalV2(rel, content); + if (rel !== 'src/example.js') return base; + const filler = Array.from({ length: 20_000 }, (_, i) => ({ name: `filler${i}`, kind: 'symbol', line: 1, exported: false })); + return { ...base, defs: [...base.defs, ...filler] }; + }, + }, + }); + assert.equal(build.meta.symbolsTruncated, true, 'the build must record that it hit the cap'); + + fs.writeFileSync(path.join(workspace, 'src', 'example.js'), 'export const other = 2;\n'); + const result = runStructuralExpectations({ + workspace, + plan: minimalPlan(['src/example.js']), + changedFiles: ['src/example.js'], + home, + }); + assert.deepEqual(result.findings, [], JSON.stringify(result.findings)); + assert.ok( + result.informational.some((note) => note.type === 'removed-symbol-with-callers-informational' && /build cap/.test(note.message)), + JSON.stringify(result.informational) + ); +}); + +test('a clean structural diff passes with an examined-files summary', async () => { const { workspace, home, sha } = structuralWorkspace(); - writeStructuralIndex(workspace, home, exampleBaseline(sha)); + await buildBaseline(workspace, home); fs.writeFileSync(path.join(workspace, 'src', 'example.js'), 'export const value = 11;\nexport function helper() { return value; }\n'); const result = runStructuralExpectations({ @@ -454,9 +596,9 @@ test('a clean structural diff passes with an examined-files summary', () => { assert.equal(result.baseline.sha, sha); }); -test('deleted files count every baseline exported symbol as removed', () => { +test('deleted files count every baseline exported symbol as removed', async () => { const { workspace, home, sha } = structuralWorkspace(); - writeStructuralIndex(workspace, home, exampleBaseline(sha)); + await buildBaseline(workspace, home); fs.rmSync(path.join(workspace, 'src', 'example.js')); const result = runStructuralExpectations({ @@ -471,9 +613,9 @@ test('deleted files count every baseline exported symbol as removed', () => { // --- structural_expectations plan frontmatter (stretch hook) --- -test('required structural expectations fail the check when unmet; optional ones stay informational', () => { +test('required structural expectations fail the check when unmet; optional ones stay informational', async () => { const { workspace, home, sha } = structuralWorkspace(); - writeStructuralIndex(workspace, home, exampleBaseline(sha)); + await buildBaseline(workspace, home); fs.writeFileSync(path.join(workspace, 'src', 'example.js'), 'export const value = 11;\nexport function helper() { return value; }\n'); const fm = { @@ -497,9 +639,9 @@ test('required structural expectations fail the check when unmet; optional ones assert.ok(result.informational.some((entry) => entry.type === 'unmet-expectation' && entry.symbol === 'alsoNew')); }); -test('met structural expectations pass and an absent block skips cleanly', () => { +test('met structural expectations pass and an absent block skips cleanly', async () => { const { workspace, home, sha } = structuralWorkspace(); - writeStructuralIndex(workspace, home, exampleBaseline(sha)); + await buildBaseline(workspace, home); fs.writeFileSync(path.join(workspace, 'src', 'example.js'), 'export const value = 11;\nexport function helper() { return value; }\nexport const added = 3;\n'); const met = runStructuralExpectations({ @@ -525,9 +667,9 @@ test('met structural expectations pass and an absent block skips cleanly', () => assert.deepEqual(absent.informational, []); }); -test('malformed expectation entries are reported informationally, never fail', () => { +test('malformed expectation entries are reported informationally, never fail', async () => { const { workspace, home, sha } = structuralWorkspace(); - writeStructuralIndex(workspace, home, exampleBaseline(sha)); + await buildBaseline(workspace, home); fs.writeFileSync(path.join(workspace, 'src', 'example.js'), 'export const value = 11;\nexport function helper() { return value; }\n'); const result = runStructuralExpectations({ @@ -546,9 +688,9 @@ function verifyFlags(plan, overrides = {}) { return { plan, base: 'HEAD', dryRun: false, ...overrides }; } -test('advisory structural failure does not flip a passing verify outcome', () => { +test('advisory structural failure does not flip a passing verify outcome', async () => { const { workspace, home, plan, sha } = structuralWorkspace(); - writeStructuralIndex(workspace, home, exampleBaseline(sha)); + await buildBaseline(workspace, home); fs.writeFileSync(path.join(workspace, 'src', 'example.js'), 'export const other = 2;\n'); const result = withHome(home, () => runVerify({ workspace, flags: verifyFlags(plan) })); @@ -568,11 +710,11 @@ test('advisory structural failure does not flip a passing verify outcome', () => assert.ok(Array.isArray(result.advisoryFailures[0].findings)); }); -test('policy warn severity degrades a structural failure to inconclusive (exit 2 under enforce)', () => { +test('policy warn severity degrades a structural failure to inconclusive (exit 2 under enforce)', async () => { const { workspace, home, plan, sha } = structuralWorkspace({ policy: 'version: 2\nenforcement: enforce\nchecks:\n structural-expectations:\n severity: warn\n', }); - writeStructuralIndex(workspace, home, exampleBaseline(sha)); + await buildBaseline(workspace, home); fs.writeFileSync(path.join(workspace, 'src', 'example.js'), 'export const other = 2;\n'); const result = withHome(home, () => runVerify({ workspace, flags: verifyFlags(plan) })); @@ -583,11 +725,11 @@ test('policy warn severity degrades a structural failure to inconclusive (exit 2 assert.deepEqual(result.advisoryFailures, []); }); -test('policy enforce severity makes a structural failure fail verification (exit 1)', () => { +test('policy enforce severity makes a structural failure fail verification (exit 1)', async () => { const { workspace, home, plan, sha } = structuralWorkspace({ policy: 'version: 2\nenforcement: enforce\nchecks:\n structural-expectations:\n severity: enforce\n', }); - writeStructuralIndex(workspace, home, exampleBaseline(sha)); + await buildBaseline(workspace, home); fs.writeFileSync(path.join(workspace, 'src', 'example.js'), 'export const other = 2;\n'); const result = withHome(home, () => runVerify({ workspace, flags: verifyFlags(plan) })); @@ -598,11 +740,11 @@ test('policy enforce severity makes a structural failure fail verification (exit assert.deepEqual(failed.map((check) => check.id), [STRUCTURAL_CHECK_ID]); }); -test('global observe enforcement never gates the exit code, but per-check enforce severity still routes the outcome', () => { +test('global observe enforcement never gates the exit code, but per-check enforce severity still routes the outcome', async () => { const { workspace, home, plan, sha } = structuralWorkspace({ policy: 'version: 2\nenforcement: observe\nchecks:\n structural-expectations:\n severity: enforce\n', }); - writeStructuralIndex(workspace, home, exampleBaseline(sha)); + await buildBaseline(workspace, home); fs.writeFileSync(path.join(workspace, 'src', 'example.js'), 'export const other = 2;\n'); const result = withHome(home, () => runVerify({ workspace, flags: verifyFlags(plan) })); @@ -629,9 +771,9 @@ test('a passing verify run with no structural index behaves as before (v1 compat } }); -test('a hard check failure still fails verification regardless of advisory checks', () => { +test('a hard check failure still fails verification regardless of advisory checks', async () => { const { workspace, home, plan, sha } = structuralWorkspace(); - writeStructuralIndex(workspace, home, exampleBaseline(sha)); + await buildBaseline(workspace, home); // Scope violation: change a file the plan does not allow. fs.writeFileSync(path.join(workspace, 'src', 'rogue.js'), 'export const rogue = 1;\n'); @@ -642,9 +784,9 @@ test('a hard check failure still fails verification regardless of advisory check assert.equal(scope.severity, 'enforce'); }); -test('evidence payload records per-check severity and advisory failures', () => { +test('evidence payload records per-check severity and advisory failures', async () => { const { workspace, home, plan, sha } = structuralWorkspace(); - writeStructuralIndex(workspace, home, exampleBaseline(sha)); + await buildBaseline(workspace, home); fs.writeFileSync(path.join(workspace, 'src', 'example.js'), 'export const other = 2;\n'); const result = withHome(home, () => runVerify({ workspace, flags: verifyFlags(plan) })); diff --git a/packages/harness/test/structural-index.test.mjs b/packages/harness/test/structural-index.test.mjs index 93bbe51b..02c560ca 100644 --- a/packages/harness/test/structural-index.test.mjs +++ b/packages/harness/test/structural-index.test.mjs @@ -14,6 +14,7 @@ import { renderStructuralDigest, } from '../lib/repo-map/structural-index.mjs'; import { lexicalV2 } from '../lib/repo-map/treesitter-extractor.mjs'; +import { readStructuralIndex as readShapeIndex } from '../lib/structural/shape.mjs'; const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -140,6 +141,93 @@ test('incremental: unchanged files are not re-parsed; touched-but-identical file fs.rmSync(home, { recursive: true, force: true }); }); +test('--since is refused as a narrowing unless it matches the prior index baseline', async () => { + // The stale-index trap: an index built at A, three commits landing, then + // `--since HEAD~1`. Files changed in commits 1-2 are outside that diff, so + // they would keep their A-era entries verbatim while meta.sha is stamped to + // the new HEAD — an index that READS as current while carrying stale data. + const { ws, git } = gitRepo(FIXTURE); + const home = tempHome(); + await buildStructuralIndex({ workspace: ws, home, extractor: countingExtractor() }); + const builtAt = git(['rev-parse', 'HEAD']).stdout.trim(); + + fs.writeFileSync(path.join(ws, 'src', 'pay.mjs'), 'export function charge() {}\nexport function commitOne() {}\n'); + git(['add', '.']); + git(['commit', '-qm', 'one']); + fs.writeFileSync(path.join(ws, 'src', 'audit.mjs'), 'export function audit() {}\nexport function commitTwo() {}\n'); + git(['add', '.']); + git(['commit', '-qm', 'two']); + fs.writeFileSync(path.join(ws, 'svc.py'), 'class PaymentService:\n def three(self):\n pass\n'); + git(['add', '.']); + git(['commit', '-qm', 'three']); + + const misaligned = validateSinceRef(ws, 'HEAD~1'); + assert.notEqual(misaligned, builtAt); + const ext = countingExtractor(); + const r = await buildStructuralIndex({ workspace: ws, home, extractor: ext, since: misaligned }); + assert.match(r.sinceIgnored || '', /does not match the prior index baseline/); + assert.equal(r.meta.baseSha, null, 'an ignored --since is never recorded as the baseline'); + + const index = readStructuralIndex(ws, { home }); + assert.equal(index.meta.sha, git(['rev-parse', 'HEAD']).stdout.trim()); + // The commits the misaligned --since would have skipped are indexed. + assert.ok(index.files['src/pay.mjs'].symbols.includes('commitOne'), 'commit 1 must not stay stale under a current stamp'); + assert.ok(index.files['src/audit.mjs'].symbols.includes('commitTwo'), 'commit 2 must not stay stale under a current stamp'); + assert.ok(index.files['svc.py'].symbols.includes('three')); + + // Aligned: `since` IS the prior baseline, so narrowing is sound and applied. + const aligned = git(['rev-parse', 'HEAD']).stdout.trim(); + fs.writeFileSync(path.join(ws, 'src', 'pay.mjs'), 'export function charge() {}\nexport function afterAligned() {}\n'); + git(['add', '.']); + git(['commit', '-qm', 'four']); + const ext2 = countingExtractor(); + const aligned2 = await buildStructuralIndex({ workspace: ws, home, extractor: ext2, since: aligned }); + assert.equal(aligned2.sinceIgnored, null); + assert.equal(aligned2.meta.baseSha, aligned); + assert.deepEqual(ext2.calls, ['src/pay.mjs'], 'an aligned --since still narrows to the ref diff'); + + fs.rmSync(ws, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); +}); + +test('co-located worktrees of one repo never serve each other structural tables', async () => { + // repoId hashes the ORIGIN REMOTE by design, so every worktree of a repo + // shares it; meta.sha is the only currency gate, and two worktrees sit at + // the same sha with different working-tree content. Without a per-worktree + // path segment each would read the other's symbol tables as its own. + const { ws, git } = gitRepo(FIXTURE); + git(['remote', 'add', 'origin', 'https://example.test/shared-repo.git']); + const linked = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-worktree-')); + const target = path.join(linked, 'checkout'); + assert.equal(git(['worktree', 'add', '-q', '-b', 'side', target]).status, 0); + const home = tempHome(); + + const dirMain = structuralIndexDir(ws, { home }); + const dirSide = structuralIndexDir(target, { home }); + assert.notEqual(dirMain, dirSide, 'each worktree gets its own index directory'); + assert.equal(path.dirname(path.dirname(dirMain)), path.dirname(path.dirname(dirSide)), 'both still live under one repo id'); + assert.equal( + spawnSync('git', ['-C', ws, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).stdout.trim(), + spawnSync('git', ['-C', target, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).stdout.trim(), + 'the two worktrees are at the same sha — the exact collision case' + ); + + fs.writeFileSync(path.join(target, 'src', 'pay.mjs'), 'export function onlyInTheWorktree() {}\n'); + await buildStructuralIndex({ workspace: ws, home, extractor: countingExtractor() }); + await buildStructuralIndex({ workspace: target, home, extractor: countingExtractor() }); + + const main = readStructuralIndex(ws, { home }); + const side = readStructuralIndex(target, { home }); + assert.ok(main.files['src/pay.mjs'].symbols.includes('charge')); + assert.ok(!main.files['src/pay.mjs'].symbols.includes('onlyInTheWorktree'), 'the main worktree keeps its own table'); + assert.ok(side.files['src/pay.mjs'].symbols.includes('onlyInTheWorktree'), 'the linked worktree keeps its own table'); + + spawnSync('git', ['-C', ws, 'worktree', 'remove', '--force', target], { encoding: 'utf8' }); + fs.rmSync(linked, { recursive: true, force: true }); + fs.rmSync(ws, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); +}); + test('--since: only files in the ref diff are re-parsed; the ref is validated', async () => { const { ws, git } = gitRepo(FIXTURE); const home = tempHome(); @@ -201,6 +289,155 @@ test('readStructuralIndexIfCurrent gates on the generation sha', async () => { fs.rmSync(home, { recursive: true, force: true }); }); +test('a stale index is rejected from meta.json alone — the tables are never parsed', async (t) => { + const { ws, git } = gitRepo(FIXTURE); + const home = tempHome(); + t.after(() => { + fs.rmSync(ws, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); + }); + await buildStructuralIndex({ workspace: ws, home, extractor: countingExtractor() }); + fs.writeFileSync(path.join(ws, 'later.mjs'), 'export const later = 1;\n'); + git(['add', '.']); + git(['commit', '-qm', 'advance head']); + + // orient runs this every turn; on a stale index the old order read and + // parsed all three tables (multi-MB in a real repo) only to discard them. + const opened = []; + const realOpen = fs.openSync; + fs.openSync = (target, ...rest) => { + opened.push(String(target)); + return realOpen(target, ...rest); + }; + try { + assert.equal(readStructuralIndexIfCurrent(ws, { home }), null, 'stale index is not served'); + } finally { + fs.openSync = realOpen; + } + assert.ok( + opened.some((name) => name.endsWith('meta.json')), + 'the generation stamp is read' + ); + for (const table of ['files.json', 'symbols.json', 'graph.json']) { + assert.ok(!opened.some((name) => name.endsWith(table)), `${table} must not be parsed for a stale index: ${opened}`); + } +}); + +test('an existing-but-unreadable table is loud, never silently empty', async (t) => { + const { ws } = gitRepo(FIXTURE); + const home = tempHome(); + t.after(() => { + fs.rmSync(ws, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); + }); + await buildStructuralIndex({ workspace: ws, home, extractor: countingExtractor() }); + const dir = structuralIndexDir(ws, { home }); + fs.writeFileSync(path.join(dir, 'symbols.json'), '{ "truncated": '); + + const index = readStructuralIndex(ws, { home }); + assert.deepEqual( + index.unreadable.map((reason) => reason.split(' ')[0]), + ['symbols.json'], + JSON.stringify(index.unreadable) + ); + assert.equal(readStructuralIndexIfCurrent(ws, { home }), null, 'a current stamp over a broken table is not usable'); + + const logs = []; + const rebuilt = await buildStructuralIndex({ workspace: ws, home, extractor: countingExtractor(), log: (m) => logs.push(m) }); + assert.deepEqual(rebuilt.priorUnreadable.length, 1); + assert.ok(logs.some((line) => /prior structural index unusable/.test(line)), JSON.stringify(logs)); + assert.equal(rebuilt.reparsed, 3, 'an unusable prior forces an honest full rebuild'); + assert.equal(readStructuralIndex(ws, { home }).unreadable.length, 0, 'the rebuild repairs the table'); +}); + +test('a hand-edited or partial prior entry is discarded and rebuilt, never a crash', async (t) => { + const { ws } = gitRepo(FIXTURE); + const home = tempHome(); + t.after(() => { + fs.rmSync(ws, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); + }); + await buildStructuralIndex({ workspace: ws, home, extractor: countingExtractor() }); + const dir = structuralIndexDir(ws, { home }); + const files = JSON.parse(fs.readFileSync(path.join(dir, 'files.json'), 'utf8')); + const stat = fs.statSync(path.join(ws, 'svc.py')); + // Same mtime+size as on disk, so the fast path WILL reuse it, but with no + // defs/refs arrays — the shape that used to abort the whole build. + files['svc.py'] = { hash: 'c'.repeat(64), mtime: stat.mtimeMs, size: stat.size }; + fs.writeFileSync(path.join(dir, 'files.json'), JSON.stringify(files)); + + const ext = countingExtractor(); + const rebuilt = await buildStructuralIndex({ workspace: ws, home, extractor: ext }); + assert.equal(rebuilt.written, true); + assert.deepEqual(ext.calls, ['svc.py'], 'only the unusable entry is re-parsed'); + const index = readStructuralIndex(ws, { home }); + assert.ok(index.files['svc.py'].symbols.includes('PaymentService')); +}); + +test('table caps are recorded in meta, not silently applied', async (t) => { + const { ws } = gitRepo(FIXTURE); + const home = tempHome(); + t.after(() => { + fs.rmSync(ws, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); + }); + const clean = await buildStructuralIndex({ workspace: ws, home, extractor: countingExtractor() }); + assert.equal(clean.meta.symbolsTruncated, false); + assert.equal(clean.meta.callEdgesTruncated, false); + assert.equal(clean.meta.moduleEdgesTruncated, false); + assert.equal(clean.meta.unresolvedTruncated, false); + + // A fresh home so every file is genuinely re-parsed by the bloated extractor. + const bloatedHome = tempHome(); + t.after(() => fs.rmSync(bloatedHome, { recursive: true, force: true })); + const bloated = countingExtractor(); + const inner = bloated.extract.bind(bloated); + bloated.extract = (rel, content) => { + const base = inner(rel, content); + if (rel !== 'svc.py') return base; + return { ...base, defs: [...base.defs, ...Array.from({ length: 20_000 }, (_, i) => ({ name: `f${i}`, kind: 'symbol', line: 1, exported: false }))] }; + }; + const capped = await buildStructuralIndex({ workspace: ws, home: bloatedHome, extractor: bloated }); + assert.equal(capped.meta.symbolsTruncated, true, 'a dropped symbol must be recorded, not silent'); + assert.equal(readStructuralIndex(ws, { home: bloatedHome }).meta.symbolsTruncated, true); +}); + +test('an index written by a NEWER version is skipped by both readers', async (t) => { + const { ws } = gitRepo(FIXTURE); + const home = tempHome(); + t.after(() => { + fs.rmSync(ws, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); + }); + await buildStructuralIndex({ workspace: ws, home, extractor: countingExtractor() }); + const dir = structuralIndexDir(ws, { home }); + const meta = JSON.parse(fs.readFileSync(path.join(dir, 'meta.json'), 'utf8')); + assert.equal(meta.version, 1, 'meta.version is written'); + fs.writeFileSync(path.join(dir, 'meta.json'), JSON.stringify({ ...meta, version: 99 })); + + assert.equal(readStructuralIndex(ws, { home }), null, 'a future index shape is not half-read'); + assert.equal(readStructuralIndexIfCurrent(ws, { home }), null); + const shape = readShapeIndex(ws, { home }); + assert.equal(shape.present, false); + assert.match(shape.reason, /unsupported structural index version/); +}); + +test('.mts and .cts files are indexed like the other TypeScript extensions', async (t) => { + const { ws } = gitRepo({ + 'a.mts': 'export function fromMts() {}\n', + 'b.cts': 'export function fromCts() {}\n', + }); + const home = tempHome(); + t.after(() => { + fs.rmSync(ws, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); + }); + await buildStructuralIndex({ workspace: ws, home, extractor: countingExtractor() }); + const index = readStructuralIndex(ws, { home }); + assert.ok(index.files['a.mts']?.symbols.includes('fromMts'), JSON.stringify(Object.keys(index.files))); + assert.ok(index.files['b.cts']?.symbols.includes('fromCts')); +}); + test('atomic writes: no temp residue, every table is valid JSON, dry-run writes nothing', async () => { const { ws } = gitRepo(FIXTURE); const home = tempHome(); diff --git a/packages/harness/test/structural-shape-compat.test.mjs b/packages/harness/test/structural-shape-compat.test.mjs index 18bff1a8..9e2df635 100644 --- a/packages/harness/test/structural-shape-compat.test.mjs +++ b/packages/harness/test/structural-shape-compat.test.mjs @@ -2,7 +2,8 @@ // (`buildStructuralIndex`, compact on-disk form) must be readable through // `readStructuralIndex` and usable by the structural-expectations check — // the two halves were built independently against one documented contract, -// and this test is the proof they meet. +// and this test is the proof they meet. Nothing here hand-edits the tables: +// every assertion is against bytes the builder itself wrote. import test from 'node:test'; import assert from 'node:assert/strict'; @@ -12,7 +13,7 @@ import path from 'node:path'; import { execFileSync } from 'node:child_process'; import { buildStructuralIndex, structuralIndexDir } from '../lib/repo-map/structural-index.mjs'; -import { createTreesitterExtract } from '../lib/repo-map/treesitter-extractor.mjs'; +import { createTreesitterExtract, lexicalV2, packageGrammarRoots } from '../lib/repo-map/treesitter-extractor.mjs'; import { readStructuralIndex, structuralDir } from '../lib/structural/shape.mjs'; import { runStructuralExpectations } from '../lib/structural/expectations.mjs'; @@ -31,16 +32,21 @@ function commitAll(ws, message) { execFileSync('git', ['commit', '-qm', message], { cwd: ws, env: GIT_ENV }); } -test('builder output round-trips through shape reader into the expectations check', async (t) => { - const extractor = await createTreesitterExtract(); - // Call edges come only from the AST tier; another grammar can set the tier - // while .mjs still falls back to lexical, so gate on the language itself. - if (!extractor.available.includes('javascript')) { - t.skip('javascript tree-sitter grammar not installed — call-edge round-trip needs the AST tier'); - return; - } +/** The DEFAULT tier: what every install without the optional grammars uses. */ +function lexicalExtractor() { + return { + counters: { parseFailures: 0, parsed: 0, errorFiles: 0 }, + tier: 'lexical', + webTreeSitter: null, + grammarVersions: {}, + missingGrammars: [], + integrityFailures: [], + extract: lexicalV2, + }; +} - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'struct-compat-')); +function fixtureRepo(t, prefix) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); t.after(() => fs.rmSync(tmp, { recursive: true, force: true })); const ws = path.join(tmp, 'ws'); const home = path.join(tmp, 'home'); @@ -51,56 +57,82 @@ test('builder output round-trips through shape reader into the expectations chec ); fs.writeFileSync(path.join(ws, 'caller.mjs'), "import { beta } from './a.mjs';\nexport const use = () => beta();\n"); commitAll(ws, 'init'); + return { ws, home }; +} - assert.equal(structuralIndexDir(ws, { home }), structuralDir(ws, { home }), 'builder and reader agree on the index dir'); +// Allowlist via the shape parseImpactedFiles actually consumes +// (plan.sections.impactedFiles) — a.mjs is planned, so no +// unplanned-symbol-change may fire; only the caller finding is expected. +const PLAN = { fm: {}, sections: { impactedFiles: '- `a.mjs`\n' } }; + +test('builder output round-trips through the shape reader (AST tier, grammars installed)', async (t) => { + // Roots scoped to the harness package's own node_modules: hermetic against + // whatever else lives up the filesystem. + const extractor = await createTreesitterExtract({ grammarRoots: packageGrammarRoots() }); + // Call edges come only from the AST tier; another grammar can set the tier + // while .mjs still falls back to lexical, so gate on the language itself. + if (!extractor.available.includes('javascript')) { + t.skip('javascript tree-sitter grammar not installed — AST-tier round-trip needs it'); + return; + } + const { ws, home } = fixtureRepo(t, 'struct-compat-ast-'); + assert.equal(structuralIndexDir(ws, { home }), structuralDir(ws, { home }), 'builder and reader agree on the index dir'); await buildStructuralIndex({ workspace: ws, home, extractor }); const index = readStructuralIndex(ws, { home }); assert.equal(index.present, true, `index should be readable: ${index.reason}`); assert.ok(Object.keys(index.files).length >= 2, 'files map is populated'); assert.ok(Array.isArray(index.files['a.mjs']?.symbols) && index.files['a.mjs'].symbols.includes('alpha')); + assert.equal(index.files['a.mjs'].tier, 'treesitter', 'the builder stamps the per-file tier it used'); const betaRows = index.symbols.filter((row) => row.name === 'beta'); assert.ok(betaRows.length >= 1, 'symbols normalized into rows'); assert.equal(betaRows[0].file, 'a.mjs'); - assert.equal(typeof betaRows[0].exported, 'boolean'); + assert.equal(betaRows[0].exported, true, 'AST tier records the real export flag'); const betaCalls = index.graph.calls.filter((edge) => edge.to === 'a.mjs#beta'); assert.ok(betaCalls.length >= 1, 'call edges normalized to file#symbol form'); - // Remove an exported symbol with a surviving caller. + // Remove an exported symbol with a surviving caller. A treesitter-tier + // baseline entry is honestly SKIPPED per file: the current side is always + // lexical, so a cross-tier diff would fabricate findings. Nothing was + // compared, so the check reports `skipped` — never a green `passed`. fs.writeFileSync(path.join(ws, 'a.mjs'), 'export function alpha() { return 1; }\n'); - // Allowlist via the shape parseImpactedFiles actually consumes - // (plan.sections.impactedFiles) — a.mjs is planned, so no - // unplanned-symbol-change may fire; only the caller finding is expected. - const plan = { fm: {}, sections: { impactedFiles: '- `a.mjs`\n' } }; - - // A treesitter-tier baseline entry is honestly SKIPPED per file: the current - // side is always lexical, so a cross-tier diff would fabricate findings — - // the check passes with an informational tier-mismatch-skipped note instead. - const skipped = runStructuralExpectations({ workspace: ws, plan, changedFiles: ['a.mjs'], home }); - assert.equal(skipped.status, 'passed', `tier-mismatched file must skip, got ${skipped.status}: ${skipped.message}`); + const skipped = runStructuralExpectations({ workspace: ws, plan: PLAN, changedFiles: ['a.mjs'], home }); + assert.equal(skipped.status, 'skipped', `tier-mismatched file must skip, got ${skipped.status}: ${skipped.message}`); assert.deepEqual(skipped.findings, []); assert.ok( skipped.informational.some((n) => n.type === 'tier-mismatch-skipped' && n.file === 'a.mjs' && n.tier === 'treesitter'), `tier mismatch surfaces as informational: ${JSON.stringify(skipped.informational)}` ); +}); - // To prove the DOWNSTREAM seam (builder-written symbol rows and call edges - // flowing into survivingCallers), restamp a.mjs's per-file tier as lexical — - // a pure test-side patch of the generation stamp; the tables stay - // builder-written. - const filesPath = path.join(structuralIndexDir(ws, { home }), 'files.json'); - const filesTable = JSON.parse(fs.readFileSync(filesPath, 'utf8')); - filesTable['a.mjs'].tier = 'lexical'; - fs.writeFileSync(filesPath, JSON.stringify(filesTable) + '\n'); +test('removed-symbol-with-callers fires end to end from real builder output in the default lexical tier', async (t) => { + const { ws, home } = fixtureRepo(t, 'struct-compat-lex-'); + // No grammars needed and no table patched: this is the shape a stock install + // (optional grammars absent) writes. The lexical tier now records real + // export flags and explicit named-import references, which is exactly what + // the caller-side finding is computed from. + await buildStructuralIndex({ workspace: ws, home, extractor: lexicalExtractor() }); - const result = runStructuralExpectations({ workspace: ws, plan, changedFiles: ['a.mjs'], home }); + const index = readStructuralIndex(ws, { home }); + assert.equal(index.present, true, `index should be readable: ${index.reason}`); + assert.equal(index.files['a.mjs'].tier, 'lexical'); + const beta = index.symbols.find((row) => row.name === 'beta' && row.file === 'a.mjs'); + assert.ok(beta, `beta must be in the symbol rows: ${JSON.stringify(index.symbols)}`); + assert.equal(beta.exported, true, 'the lexical tier records the export surface'); + assert.ok( + index.graph.calls.some((edge) => edge.to === 'a.mjs#beta' && edge.from.startsWith('caller.mjs')), + `the named import is a recorded edge: ${JSON.stringify(index.graph.calls)}` + ); + + fs.writeFileSync(path.join(ws, 'a.mjs'), 'export function alpha() { return 1; }\n'); + const result = runStructuralExpectations({ workspace: ws, plan: PLAN, changedFiles: ['a.mjs'], home }); assert.equal(result.status, 'failed', `expected structural findings, got ${result.status}: ${result.message}`); assert.ok( - result.findings.some((f) => f.type === 'removed-symbol-with-callers' && f.symbol === 'beta'), - 'removed exported symbol with a surviving caller is flagged' + result.findings.some((f) => f.type === 'removed-symbol-with-callers' && f.symbol === 'beta' && f.callers.includes('caller.mjs')), + `removed exported symbol with a surviving caller is flagged: ${JSON.stringify(result.findings)}` ); assert.ok( !result.findings.some((f) => f.type === 'unplanned-symbol-change'), diff --git a/packages/harness/test/treesitter-extractor.test.mjs b/packages/harness/test/treesitter-extractor.test.mjs index 268b33fd..9b1eacc0 100644 --- a/packages/harness/test/treesitter-extractor.test.mjs +++ b/packages/harness/test/treesitter-extractor.test.mjs @@ -11,14 +11,18 @@ import { grammarStatus, makeStructuralExtract, createTreesitterExtract, + packageGrammarRoots, MAX_IDENTIFIER_LENGTH, + MAX_DEFS_PER_FILE, DEFAULT_LOCK_PATH, } from '../lib/repo-map/treesitter-extractor.mjs'; // One shared factory instance: init is the expensive part, extract is sync. -// When the optional grammar packages are absent this resolves to the lexical -// tier and the grammar-dependent tests below skip honestly. -const extractor = await createTreesitterExtract(); +// Roots are pinned to the harness package's OWN node_modules so the suite +// never depends on what happens to live in a parent directory; when the +// optional grammar packages are absent this resolves to the lexical tier and +// the grammar-dependent tests below skip honestly. +const extractor = await createTreesitterExtract({ grammarRoots: packageGrammarRoots() }); const grammars = extractor.tier === 'treesitter'; const skipNote = 'optional tree-sitter grammars not installed — lexical absence mode'; @@ -41,15 +45,101 @@ test('branchComplexity is a cheap deterministic branch count with floor 1', () = assert.equal(branchComplexity('a && b || c ?? d'), 4); }); -test('lexicalV2 preserves v1 fields and adds approximate defs, empty refs, and complexity', () => { - const r = lexicalV2('a.ts', 'export function hi(){ if (x) {} }'); - assert.deepEqual(r.symbols, ['hi']); - assert.deepEqual(r.defs, [{ name: 'hi', kind: 'symbol', line: 1, exported: false }]); - assert.deepEqual(r.refs, [], 'the lexical tier never fabricates call facts'); +test('lexicalV2 preserves v1 fields and adds approximate defs, real export flags, and complexity', () => { + const r = lexicalV2('a.ts', 'export function hi(){ if (x) {} }\nfunction Local(){}\n'); + assert.deepEqual(r.symbols, ['hi', 'Local']); + assert.deepEqual(r.defs, [ + { name: 'hi', kind: 'symbol', line: 1, exported: true }, + { name: 'Local', kind: 'symbol', line: 2, exported: false }, + ]); + assert.deepEqual(r.refs, [], 'no imports means no references — never a guessed call'); assert.equal(r.tier, 'lexical'); assert.equal(r.complexity, 2); }); +test('lexical export detection: the DEFAULT tier reports a real module surface', () => { + const ts = lexicalV2( + 'mod.ts', + [ + 'const hidden = 1;', + 'export const shown = 2;', + 'export let mutable = 3;', + 'function helper() {}', + 'class Widget {}', + 'export { helper, Widget as Gadget };', + 'export * as ns from "./other";', + 'export default hidden;', + ].join('\n') + ); + const exported = new Set(ts.defs.filter((d) => d.exported).map((d) => d.name)); + for (const name of ['shown', 'mutable', 'helper', 'Gadget', 'ns', 'hidden']) { + assert.ok(exported.has(name), `${name} must read as exported: ${JSON.stringify(ts.defs)}`); + } + + const cjs = lexicalV2('legacy.cjs', 'function run() {}\nmodule.exports.run = run;\nexports.other = 1;\n'); + const cjsExported = new Set(cjs.defs.filter((d) => d.exported).map((d) => d.name)); + assert.ok(cjsExported.has('run') && cjsExported.has('other'), JSON.stringify(cjs.defs)); + + // Python has no export keyword: __all__ wins when present, otherwise + // module-level non-underscore defs — the same rule the AST tier applies. + const withAll = lexicalV2('svc.py', '__all__ = ["public_one"]\ndef public_one():\n pass\ndef also_public():\n pass\n'); + const pyAll = new Map(withAll.defs.map((d) => [d.name, d.exported])); + assert.equal(pyAll.get('public_one'), true); + assert.equal(pyAll.get('also_public'), false, '__all__ is authoritative when present'); + const noAll = lexicalV2('svc2.py', 'def public_one():\n pass\ndef _private():\n pass\nclass Inner:\n def method(self):\n pass\n'); + const py = new Map(noAll.defs.map((d) => [d.name, d.exported])); + assert.equal(py.get('public_one'), true); + assert.equal(py.get('_private'), false); + assert.equal(py.get('method'), false, 'a nested method is not a module-level export'); + + const java = lexicalV2('A.java', 'public class A {\n public void open() {}\n private void shut() {}\n}\n'); + const jv = new Map(java.defs.map((d) => [d.name, d.exported])); + assert.equal(jv.get('A'), true); + assert.equal(jv.get('open'), true); + assert.equal(jv.get('shut'), false); + + // SQL/HCL have no module boundary — nothing is claimed as exported. + const sql = lexicalV2('schema.sql', 'CREATE TABLE payments (id int);'); + assert.deepEqual(sql.defs.map((d) => d.exported), [false]); +}); + +test('lexical references are stated named imports, never inferred call sites', () => { + const js = lexicalV2('caller.mjs', "import { beta, gamma as g } from './a.mjs';\nexport const use = () => beta() + delta();\n"); + assert.deepEqual(js.refs.map((r) => r.name).sort(), ['beta', 'gamma'], 'the imported names, not the local alias'); + assert.ok(!js.refs.some((r) => r.name === 'delta'), 'a bare call is never invented as a reference'); + const py = lexicalV2('svc.py', 'from billing.core import Charge\ndef run():\n return Charge()\n'); + assert.deepEqual(py.refs.map((r) => r.name), ['Charge']); + const java = lexicalV2('A.java', 'import com.acme.Role;\npublic class A {}\n'); + assert.deepEqual(java.refs.map((r) => r.name), ['Role']); +}); + +test('lexicalV2 is linear in file size — the default tier must not rescan per name', () => { + // A crafted file: 20k long near-matching lines, then 512 declarations. The + // old shape ran one `lines.findIndex(l => l.includes(name))` PER NAME, so + // every declaration rescanned the whole prefix — ~1.3s here, on the DEFAULT + // tier, for one file of a full index. A single tokenizing pass is ~20ms. + const pad = `// ${'a'.repeat(200)}`; + const lines = []; + for (let i = 0; i < 20_000; i++) lines.push(pad); + for (let i = 0; i < 512; i++) lines.push(`export const ${'a'.repeat(40)}b${i} = ${i};`); + const content = lines.join('\n'); + const started = Date.now(); + const r = lexicalV2('big.ts', content); + const elapsed = Date.now() - started; + assert.equal(r.defs.length, 512); + assert.equal(r.defs[0].line, 20_001, 'declaration lines are still resolved exactly'); + assert.ok(elapsed < 300, `lexicalV2 took ${elapsed}ms on one ${Math.round(content.length / 1024)}KB file — the per-name rescan is back`); +}); + +test('the lexical path caps symbols and imports like the AST path does', () => { + const symbols = Array.from({ length: MAX_DEFS_PER_FILE + 50 }, (_, i) => `export const s${i} = ${i};`).join('\n'); + const capped = lexicalV2('many.ts', symbols); + assert.equal(capped.symbols.length, MAX_DEFS_PER_FILE, 'unbounded symbol lists would grow files.json without limit'); + assert.equal(capped.defs.length, MAX_DEFS_PER_FILE); + const imports = Array.from({ length: 400 }, (_, i) => `import { n${i} } from './m${i}';`).join('\n'); + assert.ok(lexicalV2('imports.ts', imports).imports.length <= 256); +}); + test('extraction matrix: typescript defs, imports, refs, exported flags', (t) => { if (!grammars) return t.skip(skipNote); const src = [ @@ -227,6 +317,50 @@ test('runtime integrity mismatch disables the whole tier loudly', async () => { fs.rmSync(dir, { recursive: true, force: true }); }); +test('the JS loader entry point is hash-pinned, not just the wasm', async (t) => { + const lock = loadGrammarsLock(); + assert.ok(lock.runtime.loader, 'grammars.lock must pin the loader entry point'); + assert.match(lock.runtime.loader.sha256, /^[0-9a-f]{64}$/); + assert.match(lock.runtime.loader.file, /\.(?:js|cjs|mjs)$/, 'the pinned loader is the JS entry the import executes'); + + // A tampered loader is the cheaper attack than a tampered wasm: it runs with + // full Node privileges. Point the factory at a forged entry point and the + // tier must refuse LOUDLY, never import it. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-loader-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const forged = path.join(dir, lock.runtime.loader.file); + fs.writeFileSync(forged, 'module.exports = { Parser: {}, Language: {} }; // not the pinned loader\n'); + const ext = await createTreesitterExtract({ grammarRoots: packageGrammarRoots(), loaderPath: forged }); + assert.equal(ext.tier, 'lexical', 'a loader mismatch disables the tier'); + assert.ok( + ext.integrityFailures.some((f) => f.language === 'loader' && /sha256 mismatch/.test(f.reason)), + `loader mismatch must be recorded: ${JSON.stringify(ext.integrityFailures)}` + ); + assert.deepEqual(ext.available, []); +}); + +test('a missing or truncated grammars.lock is a LOUD refusal, never a silent disable', async (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-nolock-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const missing = path.join(dir, 'absent.lock'); + const truncated = path.join(dir, 'truncated.lock'); + // A lock without the runtime block cannot verify anything either. + fs.writeFileSync(truncated, JSON.stringify({ version: 1, grammars: {} })); + + for (const lockPath of [missing, truncated]) { + assert.equal(loadGrammarsLock({ lockPath }), null, `${lockPath} must not parse as a usable lock`); + const ext = await createTreesitterExtract({ lockPath }); + assert.equal(ext.tier, 'lexical'); + assert.ok( + ext.integrityFailures.some((f) => f.language === 'lock'), + `an unverifiable lock must be recorded, not silently ignored: ${JSON.stringify(ext.integrityFailures)}` + ); + const status = grammarStatus({ lockPath, grammarRoots: [dir] }); + assert.equal(status.lock, false); + assert.ok(status.integrityFailures.some((f) => f.language === 'lock'), JSON.stringify(status.integrityFailures)); + } +}); + test('identifier length cap bounds extracted names', () => { const long = 'x'.repeat(400); const r = lexicalV2('a.ts', `export const ${long} = 1;`); From 67100ef3a2763027d63ed61a80030b99766ae9c5 Mon Sep 17 00:00:00 2001 From: Krish Date: Thu, 6 Aug 2026 09:40:02 -0400 Subject: [PATCH 13/24] fix: exclude advisory checks from verify failure counts and next-action hints --- packages/harness/lib/commands.mjs | 11 +++-- .../test/verify-advisory-hint.test.mjs | 43 +++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 packages/harness/test/verify-advisory-hint.test.mjs diff --git a/packages/harness/lib/commands.mjs b/packages/harness/lib/commands.mjs index f330b3f6..47ae976b 100644 --- a/packages/harness/lib/commands.mjs +++ b/packages/harness/lib/commands.mjs @@ -701,8 +701,13 @@ export async function cmdVerify(argv) { if (flags.json) emitJson(flags, result); else { // `skipped` is neutral (e.g. the advisory structural check without an - // index): never a failure count, never the next fix target. - const failed = result.checks.filter((c) => c.status !== 'passed' && c.status !== 'skipped').length; + // index): never a failure count, never the next fix target. An `advisory` + // check is neutral for the same reason — it cannot move the outcome + // (resolveOutcome excludes it), so counting it here or pointing the agent + // at it would route attention to the one check that can never unblock the + // run. Both stay visible as rows and in `advisoryFailures`. + const gating = (c) => c.status !== 'passed' && c.status !== 'skipped' && c.severity !== 'advisory'; + const failed = result.checks.filter(gating).length; const passed = result.outcome === 'passed'; console.log( ui.line({ @@ -718,7 +723,7 @@ export async function cmdVerify(argv) { if (passed) { printNext('harness compound (or /auto-compound), then stop'); } else { - const firstFail = result.checks.find((c) => c.status !== 'passed' && c.status !== 'skipped'); + const firstFail = result.checks.find(gating); if (firstFail) { const detail = String(firstFail.message ?? firstFail.name ?? '').slice(0, 100); printNext(`fix ${firstFail.id} (${detail})`); diff --git a/packages/harness/test/verify-advisory-hint.test.mjs b/packages/harness/test/verify-advisory-hint.test.mjs new file mode 100644 index 00000000..627e35b7 --- /dev/null +++ b/packages/harness/test/verify-advisory-hint.test.mjs @@ -0,0 +1,43 @@ +// An advisory check cannot move verify's outcome, so it must not be counted +// as a gating failure or offered as the next fix target — otherwise a run that +// genuinely failed on a gating check points the agent at the one check that +// can never unblock it. Advisory failures stay visible as rows and in the +// evidence payload's `advisoryFailures`. + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +const gating = (c) => c.status !== 'passed' && c.status !== 'skipped' && c.severity !== 'advisory'; + +test('advisory and skipped checks are neither counted nor offered as the next fix', () => { + const checks = [ + { id: 'structural-expectations', status: 'failed', severity: 'advisory', message: '2 structural findings' }, + { id: 'plan-schema', status: 'skipped', severity: 'enforce', message: 'no schema output planned' }, + { id: 'required-reviews', status: 'failed', severity: 'enforce', message: 'security-sentinel not completed' }, + { id: 'scope', status: 'failed', severity: 'enforce', message: 'changed file outside Impacted Files' }, + ]; + + const failed = checks.filter(gating); + assert.equal(failed.length, 2, 'only the two enforce-severity failures count'); + assert.deepEqual( + failed.map((c) => c.id), + ['required-reviews', 'scope'], + 'advisory and skipped are excluded from the failure count' + ); + assert.equal(checks.find(gating).id, 'required-reviews', 'the next fix target is the first gating failure'); +}); + +test('a run whose only failure is advisory offers no fix target', () => { + const checks = [ + { id: 'structural-expectations', status: 'failed', severity: 'advisory', message: 'advisory finding' }, + { id: 'scope', status: 'passed', severity: 'enforce', message: 'ok' }, + ]; + assert.equal(checks.filter(gating).length, 0); + assert.equal(checks.find(gating), undefined); +}); + +test('a check with no severity field is treated as gating', () => { + // Callers that predate policy v2 pass no severity; those must keep counting. + const checks = [{ id: 'harness-tests', status: 'failed', message: 'suite failed' }]; + assert.equal(checks.filter(gating).length, 1); +}); From 58b7a7fb07f5b010e7a1d757b3d6429e90c5d9c9 Mon Sep 17 00:00:00 2001 From: Krish Date: Thu, 6 Aug 2026 13:57:15 -0400 Subject: [PATCH 14/24] fix: bind promotion to source identity and close store residue, untracked-file, and routing gaps --- docs/MEMORY-MODEL.md | 33 +++- packages/harness/lib/knowledge/admin.mjs | 38 ++++- packages/harness/lib/knowledge/apply.mjs | 151 +++++++++++++++--- packages/harness/lib/knowledge/store.mjs | 131 ++++++++++++++- .../harness/test/domain-cap-merge.test.mjs | 36 +++-- packages/harness/test/hand-edits.test.mjs | 105 ++++++++++++ .../knowledge-boundary-hardening.test.mjs | 44 +++++ .../harness/test/knowledge-promote.test.mjs | 126 +++++++++++++-- packages/harness/test/layer-routing.test.mjs | 52 ++++++ .../harness/test/learning-promote.test.mjs | 11 +- .../harness/test/store-transaction.test.mjs | 79 ++++++++- 11 files changed, 747 insertions(+), 59 deletions(-) diff --git a/docs/MEMORY-MODEL.md b/docs/MEMORY-MODEL.md index d4c892ae..ac8459ee 100644 --- a/docs/MEMORY-MODEL.md +++ b/docs/MEMORY-MODEL.md @@ -613,7 +613,7 @@ bucket key is recorded in the snapshot's frontmatter) — is absorbed automatically — every mutation entry point (`consolidate --apply`, `remember`, `learning retire|dispute|confirm|promote`, `knowledge purge`, `knowledge prune`, `consolidate --rebuild --yes`) -runs `git status --porcelain` in the store first and commits any dirty edit as its own +runs `git status --porcelain -uall` in the store first and commits any dirty edit as its own `human edit: ` commit, landing before that entry point's own commit. - **A modified learning file** is snapshotted verbatim as a `kind: human-teaching` episode @@ -628,6 +628,14 @@ runs `git status --porcelain` in the store first and commits any dirty edit as i dispute, run `harness learning confirm ` or re-teach it (`harness remember`, same trigger/domain, at least as recent as the record) — either records a fresh governance entry; a hand edit alone never does. +- **A planted (never-tracked) learning file** absorbs exactly like a modified one. A file + dropped straight into `learnings//.md` — or the bucket equivalent — is live, + retrievable content the moment it lands, so it gets the same treatment: secret scan, + byte-cap check, `docs/solutions/teachings/` snapshot, re-serialization by the canonical + writer, `source: human`, and its own `human edit: ` commit. It is never adopted + silently by a later transaction's `git add -A`, and a run whose op set is REJECTED still + records it as an absorbed hand edit rather than laundering it into store history + unvalidated. - **A hand-deleted learning file** is absorbed as a governance `retire`, not a purge: the working file, `INDEX.md` entry, and (under `knowledge commit repo`) the mirrored product-repo copy are removed immediately — human deletion always wins, same immediacy as @@ -635,10 +643,20 @@ runs `git status --porcelain` in the store first and commits any dirty edit as i rather than being erased. If the backing episodes ever regenerate this id again (a `consolidate --rebuild --yes` later re-derives it fresh from T1), the governance ledger reapplies retire instead of silently resurrecting it. Use `knowledge purge` instead when - the episodes themselves — not just this one learning — must stop existing. + the episodes themselves — not just this one learning — must stop existing. The + "another layer still holds this id" exemption that suppresses the retire record counts + only **active** learnings: an inactive `promoted_to_golden` bucket tombstone is not a + surviving holder, so deleting a promoted golden claim still records the retire. - The absorbed content may exceed the 1,200-byte learning cap — human authority overrides the cap for hand edits (logged, not rejected; the cap binds only the sole writer's own ops). +- **Crash residue is not a hand edit.** Every store transaction writes an intent journal + under the store's `.git/` before its first mutation and clears it on commit or rollback, + so a writer killed mid-transaction leaves uncommitted state the next transaction can + positively identify as CLI-authored: it is rolled back to the dead writer's last + checkpoint (any intra-transaction commit it did land, such as an absorbed hand edit, + survives) instead of being absorbed as human authority. Dirt found with no journal behind + it is a genuine hand edit and absorbs exactly as described above. Use `harness remember` to add a new claim and `harness learning retire|dispute|confirm` to change a learning's status when a CLI command is more convenient than a direct edit — both @@ -733,6 +751,17 @@ The approved [Harness Evolution Blueprint](../knowledge/proposals/harness-evolut promoted claim read as verified fixes across distinct plans — which is simultaneously the promotion-eligibility signal and the PROTECTED-target signal — i.e. an insight-only claim could launder itself into permanently protected golden knowledge. + **A promotion op is bound to its source IDENTITY, not just its evidence.** Promotion + moves a claim between layers; it is not an authoring operation. The destination — an + ADD/SUPERSEDE's `domain/slug`, a STRENGTHEN's `target` — must equal the cited source id, + and the promoted claim's `trigger` and `body` are read from the verified source learning + rather than from the op. Without that binding a hand-authored, correctly-re-digested op + could cite one claim's verified identity (including its `source: human` standing, which + the promoted claim inherits) while writing an entirely different, attacker-authored one, + or graft one claim's verified-fix episodes onto an unrelated golden claim. A + rename/re-slug during promotion would have to be its own explicitly gated operation; it + is never implicit. The tombstone below likewise follows the SOURCE id, so a mis-bound or + refused run can never leave the cited source unconsumed and reusable. Success tombstones each source `promoted_to_golden:` (a retrieval exclusion alongside `promoted_to`) and records **`absorb-branch`** in the governance ledger — an AUDIT action: `readGovernance`'s replay considers only `retire`/`dispute`/`confirm`/`promote`, diff --git a/packages/harness/lib/knowledge/admin.mjs b/packages/harness/lib/knowledge/admin.mjs index 6a195235..35c24d0b 100644 --- a/packages/harness/lib/knowledge/admin.mjs +++ b/packages/harness/lib/knowledge/admin.mjs @@ -215,13 +215,29 @@ export function mirrorLearnings({ workspace, home, log = () => {}, retiredIds = * absorbed; untracked/modified non-learning store files (config.json, * stale.json, INDEX.md) are left alone for the next normal commit's own * `git add -A` to pick up. + * + * UNTRACKED LEARNING FILES ARE HAND EDITS TOO (P1). A learning file planted + * directly in the store — `?? learnings//.md`, or the bucket + * equivalent — is ACTIVE, retrievable content the moment it lands + * (listLearnings reads the tree, not the git index), yet it was skipped here + * as "untracked/other" and then swept wholesale into store history by the + * next transaction's `git add -A` — INCLUDING a transaction whose own op set + * was rejected. Never validated, never secret-scanned, never rendered by the + * sole writer, and recorded with whatever provenance its author typed. + * Untracked learning paths are therefore absorbed through this exact same + * path as a modified one: snapshot-evidenced, secret-scanned, byte-cap + * logged, re-serialized by `serializeLearning`, and stamped `source: human` + * — the honest provenance for a file a person put in the store by hand. + * `-uall` is required for that: the default `-unormal` collapses a brand-new + * `learnings//` into a single directory entry that matches no + * learning path shape. */ export function absorbHandEdits({ workspace, home, log = () => {} }) { const empty = { absorbed: [], deleted: [], committed: false }; const dir = storeDir(workspace, { home }); if (!fs.existsSync(dir) || !fs.existsSync(path.join(dir, '.git'))) return empty; - const status = spawnSync('git', ['status', '--porcelain'], { cwd: dir, encoding: 'utf8' }); + const status = spawnSync('git', ['status', '--porcelain', '-uall'], { cwd: dir, encoding: 'utf8' }); // Fail CLOSED (P2): a spawn error or a non-zero `git status` exit used to be // coerced to an empty string — read as "tree is clean" — so a later // transaction rollback (git reset --hard + clean -fd) could silently destroy @@ -265,7 +281,10 @@ export function absorbHandEdits({ workspace, home, log = () => {} }) { if (bucketKey) touchedBucketRoots.add(layerRoot); continue; } - if (!code.includes('M')) continue; // untracked/other — out of absorb scope + // `??` (planted, never tracked) absorbs exactly like `M` (see the doc + // comment above) — anything else (staged-only, renamed-into, conflicted) + // stays out of absorb scope. + if (code !== '??' && !code.includes('M')) continue; const file = path.join(dir, rel); let text; @@ -361,12 +380,23 @@ export function absorbHandEdits({ workspace, home, log = () => {} }) { // only a deletion that leaves NO layer holding the id is a retirement of the // id. The mirror sweep is scoped separately — it mirrors GOLDEN, so it keys // off whether golden still holds the id, not whether any layer does. + // + // "HOLDING" MEANS ACTIVE, NOT MERELY PRESENT (P1). `listLearnings` returns + // every physical file, including INACTIVE ones — most importantly the + // `promoted_to_golden` tombstone a promotion leaves in the source bucket. + // Counting that tombstone as a surviving holder meant a human deleting the + // PROMOTED GOLDEN claim recorded no `retire` at all: a later + // `consolidate --rebuild` drops the tombstone, re-consolidation of the + // still-present branch episode recreates the id, and the human's deletion + // is silently undone with no governance veto to stop it. Only an ACTIVE + // learning can suppress the retirement record. const goldenIds = new Set(listLearnings(dir).map((l) => l.id)); + const activeIds = (root) => listLearnings(root).filter((l) => isActiveFm(l.fm)).map((l) => l.id); const survivingIds = new Set([ - ...goldenIds, + ...activeIds(dir), ...listBuckets(dir).flatMap((b) => { try { - return listLearnings(b.dir).map((l) => l.id); + return activeIds(b.dir); } catch { return []; } diff --git a/packages/harness/lib/knowledge/apply.mjs b/packages/harness/lib/knowledge/apply.mjs index 8a88fb0c..aff52d96 100644 --- a/packages/harness/lib/knowledge/apply.mjs +++ b/packages/harness/lib/knowledge/apply.mjs @@ -815,22 +815,63 @@ export function applyOps({ const origin = repoId(workspace); - // Write-time git provenance (blueprint P1/P9): derived ONCE per run from the - // CURRENT workspace HEAD — never from anything the ops JSON asserts — and - // stamped on every fresh ADD/SUPERSEDE/MERGE write. STRENGTHEN re-renders - // preserve the target's ORIGINAL provenance instead (composeStrengthenedLearning), - // so a claim's recorded origin never silently migrates to the strengthening - // commit. All fields optional: a non-git workspace stamps nothing. - const gitContext = deriveGitContext({ workspace, home }); - const writeProvenance = { commit: gitContext.headSha, branch: gitContext.branch, base: gitContext.baseSha }; - - // Write-layer routing (blueprint P4): derived from git context AT WRITE - // TIME — feature branch → bucket, default branch → golden, detached HEAD → - // non-promotable detached bucket, `--layer golden` explicit override - // (logged), unresolvable default branch fails closed to branch-local. The - // orient-recorded branch is advisory; resolveWriteLayer logs a warning when - // write-time HEAD disagrees. - const routing = resolveWriteLayer({ workspace, home, layerOverride: layer === 'golden' ? 'golden' : null, log }); + // ONE GIT SNAPSHOT PER RUN, TAKEN INSIDE THE TRANSACTION (P1). Routing and + // provenance describe the same thing — the HEAD this write belongs to — so + // they must come from the SAME read. They used to be TWO separate + // `deriveGitContext` calls, both taken BEFORE the store lock existed: a + // checkout landing between them could stamp provenance for branch A while + // routing the write to branch B's layer, and a checkout landing after both + // could cache golden routing from the default branch and then write it out + // from a feature branch — bypassing branch isolation outright. Now + // `resolveWriteLayer`'s own context IS the provenance source (one read), it + // is derived under the lock, and `assertHeadUnmoved` below re-validates it + // at write time and fails closed if HEAD moved mid-transaction. + // + // STRENGTHEN re-renders preserve the target's ORIGINAL provenance instead + // (composeStrengthenedLearning), so a claim's recorded origin never silently + // migrates to the strengthening commit. All fields optional: a non-git + // workspace stamps nothing. + // + // Routing itself follows blueprint P4: feature branch → bucket, default + // branch → golden, detached HEAD → non-promotable detached bucket, + // `--layer golden` explicit override (logged), unresolvable default branch + // fails closed to branch-local. The orient-recorded branch is advisory; + // resolveWriteLayer logs a warning when write-time HEAD disagrees. + let routing = null; + let writeProvenance = null; + function deriveRouting() { + routing = resolveWriteLayer({ workspace, home, layerOverride: layer === 'golden' ? 'golden' : null, log }); + writeProvenance = { commit: routing.context.headSha, branch: routing.context.branch, base: routing.context.baseSha }; + } + + /** + * Fail-closed write-time re-validation of the snapshot above: a `git + * checkout` in the workspace between routing derivation and the mutation + * phase would leave this run writing a claim into a layer that no longer + * matches the HEAD it stamped. The store lock cannot serialize the + * WORKSPACE's git operations, so the only safe answer is to notice and + * abort — never to silently write to the stale layer. Returns a rejection + * (nothing has been written yet at the point it is called) or null. + */ + function assertHeadUnmoved() { + const now = deriveGitContext({ workspace, home }); + const before = routing.context; + if (now.headSha === before.headSha && now.branch === before.branch && now.detached === before.detached) return null; + const nameOf = (c) => c.branch || (c.detached ? `detached ${String(c.headSha).slice(0, 12)}` : c.headSha || 'unknown'); + return { + kind: 'reject', + applied: [], + governed: [], + rejected: [ + fail( + 'E_HEAD_MOVED', + `workspace HEAD moved mid-transaction (${nameOf(before)} → ${nameOf(now)}) — nothing was written; re-run the apply from a settled checkout` + ), + ], + committed: false, + exitCode: 1, + }; + } /** * Everything from the store-state snapshot through the mutation phase, @@ -1123,7 +1164,11 @@ export function applyOps({ const planned = []; const disputes = []; for (let i = 0; i < parsed.ops.length; i++) { - const op = parsed.ops[i]; + // Rebindable: a promotion op's claim CONTENT is replaced below with the + // verified source learning's own trigger/body, so everything downstream + // (secret scan, imperative lint, renderLearning) reads what the source + // actually says rather than what the ops file asserts. + let op = parsed.ops[i]; if (promotionMode && !FILE_TOUCHING.has(op.op)) { // The emitter only ever produces ADD/STRENGTHEN/SUPERSEDE. A // hand-authored promotion envelope carrying a NOOP would otherwise @@ -1217,6 +1262,44 @@ export function applyOps({ exitCode: 1, }; } + // PROMOTION IS A LAYER MOVE, NOT AN AUTHORING OPERATION (P1). Until + // this gate, only the SOURCE side was bound: `src.id` was looked up + // and its file hashed, but the op's own DESTINATION (`domain`/`slug` + // for ADD/SUPERSEDE, `target` for STRENGTHEN) and its `trigger`/`body` + // were taken verbatim. The digest is computed over the ops array by + // whoever wrote the file, so it binds nothing an attacker doesn't also + // control — a hand-authored ADD could cite human-sourced claim `A`, + // name destination `B`, carry arbitrary content, and mint a golden `B` + // stamped `source: human` (the promoted claim inherits the SOURCE's + // derived source/status below), i.e. exactly the authority the normal + // lane reserves for disk-verified human teaching. A STRENGTHEN could + // likewise graft `A`'s verified-fix episodes onto an unrelated golden + // `B`, inflating the protected/promotion-eligibility counts. So the + // destination MUST equal the source id, and the claim text comes from + // the verified source learning, never from the op. A genuine + // rename/re-slug during promotion would have to be its own explicitly + // gated operation — it is never implicit here. The emitter already + // only ever produces this shape (promote.mjs derives domain/slug/ + // target from the source), so nothing legitimate changes. + const destId = op.op === 'STRENGTHEN' ? op.target : newIdFor(op); + if (destId !== src.id) { + return { + kind: 'reject', + applied: [], + governed: [], + rejected: [ + fail( + 'E_SCHEMA', + `op ${i}: promotion destination ${destId || '(none)'} does not match source ${src.id} — promotion moves a claim between layers, it never renames or re-authors it` + ), + ], + committed: false, + exitCode: 1, + }; + } + if (op.op !== 'STRENGTHEN') { + op = { ...op, trigger: sourceLearning.fm.trigger || '', body: sourceLearning.body }; + } // EVIDENCE IS COPIED FROM THE SOURCE, NEVER TRUSTED FROM THE OP (P1). // Only `path@sha256` was ever compared here, so an op could re-label a // recorded `insight` episode as `kind: fix` and attach a `plan:` the @@ -1777,6 +1860,12 @@ export function applyOps({ }; } + // Write-time HEAD re-validation (see assertHeadUnmoved): the last point + // before anything is written is the last point a stale routing/provenance + // snapshot can still be caught for free. + const moved = assertHeadUnmoved(); + if (moved) return moved; + // Mutation phase. No manual try/catch + git reset here any more — a // throw from anywhere below propagates straight out of runOnce, out of // withStoreTransaction's own fn callback, where the SAME rollback @@ -1915,9 +2004,15 @@ export function applyOps({ if (promotionMode && !dryRun) { const bucketRoot = bucketDirFor(dir, promotion.branchKey); let touchedBucket = false; - for (const a of applied) { - if (!FILE_TOUCHING.has(a.op)) continue; - const src = promotionSources.get(a.id); + // THE TOMBSTONE FOLLOWS THE SOURCE, NOT THE DESTINATION (P1). This loop + // used to look the bucket entry up by the id the op WROTE, so an op + // naming a destination other than its source left the source untouched + // — still active, still promotable, reusable for repeat runs. Walking + // the planned writes gives each one its own `op.source.id` directly; + // the destination-binding gate above already makes the two equal, so + // this is the belt to that gate's braces. + for (const entry of [...writes, ...strengthenWrites]) { + const src = entry.op.source?.id ? promotionSources.get(entry.op.source.id) : null; if (!src) continue; // Defense in depth (fs-safe.mjs's own documented discipline): this is // the one write in this module that targets a path under @@ -1927,13 +2022,13 @@ export function applyOps({ // withStoreTransaction rolls the whole promotion back, rather than // leaving a golden claim whose source was never tombstoned. if (!assertRealpathContained(dir, path.relative(dir, src.file))) { - throw new Error(`refused to tombstone ${a.id}: bucket learning path escapes the knowledge store`); + throw new Error(`refused to tombstone ${src.id}: bucket learning path escapes the knowledge store`); } const text = fs.readFileSync(src.file, 'utf8'); const parsedSource = parseLearningFrontmatter(text); - fs.writeFileSync(src.file, serializeLearning({ ...parsedSource.fm, promoted_to_golden: a.id }, parsedSource.body), 'utf8'); + fs.writeFileSync(src.file, serializeLearning({ ...parsedSource.fm, promoted_to_golden: src.id }, parsedSource.body), 'utf8'); appendGovernance(dir, { - id: a.id, + id: src.id, action: 'absorb-branch', reason: `promoted from ${promotion.branchKey}`, to: null, @@ -1988,6 +2083,10 @@ export function applyOps({ } if (dryRun) { + // A preview writes nothing and takes no lock, so one snapshot is all it + // can meaningfully have; the write-time re-validation above never runs + // (runOnce returns its preview before reaching it). + deriveRouting(); const { dir, git } = ensureStore(workspace, { home, dryRun: true }); const result = runOnce({ dir, git }); if (result.kind === 'reject') { @@ -2018,6 +2117,10 @@ export function applyOps({ }, }, ({ dir, git, recordCheckpoint }) => { + // The run's ONE git snapshot (routing + provenance), taken under the store + // lock rather than before it, and re-validated at write time + // (assertHeadUnmoved) — see deriveRouting's doc comment above. + deriveRouting(); // Absorb any hand edit sitting in the store BEFORE anything else reads or // mutates it — still its own self-contained commit (absorbHandEdits calls // commitStore itself), now made WHILE the lock is held instead of before @@ -2087,7 +2190,7 @@ export function applyOps({ governed: inner.governed, layer: inner.layer, bucketKey: inner.bucketKey ?? null, - ...(routing.branchWarning ? { branchWarning: routing.branchWarning } : {}), + ...(routing?.branchWarning ? { branchWarning: routing.branchWarning } : {}), ...staleExtra, }; } diff --git a/packages/harness/lib/knowledge/store.mjs b/packages/harness/lib/knowledge/store.mjs index cb3bb5fc..07210c51 100644 --- a/packages/harness/lib/knowledge/store.mjs +++ b/packages/harness/lib/knowledge/store.mjs @@ -757,6 +757,109 @@ function currentHeadSha(dir) { return res.status === 0 ? res.stdout.trim() : null; } +/** True when the store's working tree has ANY uncommitted change. Fails + * CLOSED (dirty) on an unreadable `git status`, so the journal below never + * records "clean at start" for a tree it could not actually inspect — the + * recovery path only ever discards residue it is certain nobody else authored. */ +function treeIsDirty(dir) { + const res = spawnSync('git', ['status', '--porcelain'], { cwd: dir, encoding: 'utf8' }); + if (res.error || res.status !== 0) return true; + return Boolean(res.stdout.trim()); +} + +/** + * IN-TRANSACTION INTENT JOURNAL (P1 — crash residue is not human authority). + * + * A writer that dies between its first mutation and its commit/rollback + * leaves CLI-authored dirt in the store working tree. The stale-lock takeover + * path cannot tell that dirt apart from a genuine hand edit, so the next + * transaction's `absorbHandEdits` (admin.mjs) used to absorb it as + * `kind: human-teaching` and stamp `source: human` — laundering a + * model-authored partial write into the one authority tier the store reserves + * for a person editing the file themselves. + * + * The journal makes the two distinguishable. It is written immediately after + * the lock is acquired and BEFORE `fn` runs, refreshed whenever an + * intra-transaction commit lands (recordCheckpoint — the tree is clean again + * at that instant, so everything dirty from there on is this transaction's + * own work), and removed once the transaction has committed or rolled back. + * `dirtyAtStart` records whether anything was ALREADY uncommitted when the + * journal was written: false means every uncommitted byte found later is + * necessarily CLI residue; true means an unabsorbed human edit was already + * sitting there, so recovery keeps its hands off and leaves it for absorb + * exactly as before. + * + * It lives under `.git/` deliberately: that is the one path inside the store + * `git add -A` can never stage and `git clean -fd` never sweeps, so the + * journal can neither leak into store history nor be destroyed by the very + * rollback it drives. A store with no git has neither commits nor rollbacks, + * so it is never journaled. + */ +const TXN_JOURNAL_REL = path.join('.git', 'harness-txn.json'); + +function writeTxnJournal(dir, data) { + try { + fs.writeFileSync(path.join(dir, TXN_JOURNAL_REL), JSON.stringify(data) + '\n', 'utf8'); + } catch { + // best effort — a journal write failure degrades to pre-journal behavior + } +} + +function readTxnJournal(dir) { + try { + const parsed = JSON.parse(fs.readFileSync(path.join(dir, TXN_JOURNAL_REL), 'utf8')); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed; + } catch { + // absent or corrupt — treated as "no interrupted transaction" + } + return null; +} + +function clearTxnJournal(dir) { + try { + fs.rmSync(path.join(dir, TXN_JOURNAL_REL), { force: true }); + } catch { + // ignored — a stranded journal only ever costs one extra recovery pass + } +} + +/** + * Crash recovery, run under the freshly-acquired lock: a journal still on + * disk means the previous holder never reached its commit or rollback. When + * that journal recorded a CLEAN tree at its start (see above), every + * uncommitted byte in the store right now is that dead writer's residue — + * discarded back to its last recorded checkpoint (so any intra-transaction + * commit it DID land, e.g. an absorbed hand edit, survives) instead of being + * inherited by the next transaction's absorb as human authority. A journal + * that recorded pre-existing dirt is left alone: that dirt may be a real hand + * edit the dead writer never got to absorb, and absorbing it is exactly the + * behavior to preserve. Returns a human-readable note, or null when nothing + * was recovered. + */ +function recoverInterruptedTransaction(dir, git, lockPath) { + const journal = readTxnJournal(dir); + if (!journal) return null; + clearTxnJournal(dir); + if (!git || journal.dirtyAtStart !== false) return null; + if (!treeIsDirty(dir)) return null; + const checkpoint = typeof journal.checkpoint === 'string' && /^[0-9a-f]{40,64}$/.test(journal.checkpoint) ? journal.checkpoint : null; + rollbackStore(dir, checkpoint); + // A recorded checkpoint can be unreachable (a store rewritten under the + // dead writer's feet) — `git reset --hard ` then fails silently and + // leaves the residue in place. Fail closed to the store's plain + // "discard everything uncommitted" reset rather than let it through. + if (treeIsDirty(dir)) rollbackStore(dir); + // rollbackStore's `git clean -fd` sweeps untracked directories — including + // the `.lock` this transaction is holding right now. Re-assert it before + // anything else runs. + try { + fs.mkdirSync(lockPath); + } catch { + // still there — nothing to re-assert + } + return 'discarded interrupted write residue'; +} + /** * Thrown by a withStoreTransaction `fn` to signal a failure that must NOT * trigger the standard rollback (git reset --hard + clean -fd). Reserved for @@ -813,8 +916,12 @@ export class StoreTransactionAbort extends Error { * concurrent writer's dirty-then-rolled-back mutation (P2). * * Acquires `.lock` (mkdir + stale-takeover-via-rename — moved here from - * apply.mjs so there is exactly one implementation) BEFORE calling - * `fn({ dir, git, recordCheckpoint })`. `fn` is expected to mutate the store + * apply.mjs so there is exactly one implementation), then runs crash recovery + * against the intent journal and writes a fresh one (see + * recoverInterruptedTransaction above — a dead writer's uncommitted residue is + * discarded rather than absorbed as human authority by the next transaction), + * all BEFORE calling `fn({ dir, git, recordCheckpoint })`. `fn` is expected + * to mutate the store * directly and return a plain result value describing what happened; it may * perform its OWN sub-commits when it needs more than one checkpoint inside * this same lock (e.g. absorbHandEdits's self-contained "human edit: " @@ -870,7 +977,13 @@ export function withStoreTransaction(workspace, { home, label, afterCommit } = { if (!lock.acquired) { return { ok: false, locked: true, rolledBack: false, error: null, committed: false, result: null, dir, git, staleLockNote: null }; } - const staleLockNote = lock.staleLockNote; + // Crash recovery BEFORE anything reads the tree (see + // recoverInterruptedTransaction): a dead writer's uncommitted residue is + // discarded here rather than inherited by the absorb step below as human + // authority. Both notes ride the one existing recovery channel callers + // already surface as `staleLockRemoved`. + const residueNote = recoverInterruptedTransaction(dir, git, lockPath); + const staleLockNote = [lock.staleLockNote, residueNote].filter(Boolean).join('; ') || null; // The rollback floor: entry HEAD, advanced by recordCheckpoint() whenever // an intra-transaction commit lands. Re-queried from git (not a @@ -879,8 +992,15 @@ export function withStoreTransaction(workspace, { home, label, afterCommit } = { // catches up — it can never make checkpointSha wrong the way a // hand-maintained value could. let checkpointSha = git ? currentHeadSha(dir) : null; + const journalBase = { pid: process.pid, at: new Date().toISOString(), label: label || null }; + if (git) writeTxnJournal(dir, { ...journalBase, checkpoint: checkpointSha, dirtyAtStart: treeIsDirty(dir) }); function recordCheckpoint() { - if (git) checkpointSha = currentHeadSha(dir); + if (!git) return; + checkpointSha = currentHeadSha(dir); + // The intra-transaction commit just cleaned the tree, so whatever is + // dirty from here on is unambiguously this transaction's own work — even + // if a hand edit WAS pending when the journal was first written. + writeTxnJournal(dir, { ...journalBase, checkpoint: checkpointSha, dirtyAtStart: false }); } function guardedRollback() { @@ -933,6 +1053,9 @@ export function withStoreTransaction(workspace, { home, label, afterCommit } = { } return { ok: true, locked: false, rolledBack: false, error: null, committed: commitRes.committed, result, dir, git, staleLockNote }; } finally { + // Cleared only once the commit or rollback above has finished: while it + // exists, a crash at any point leaves the residue classifiable. + clearTxnJournal(dir); // The rollback above may have already removed the untracked .lock // directory via `git clean -fd` — tolerate that instead of throwing. fs.rmSync(lockPath, { recursive: true, force: true }); diff --git a/packages/harness/test/domain-cap-merge.test.mjs b/packages/harness/test/domain-cap-merge.test.mjs index 3827efa2..690cd68a 100644 --- a/packages/harness/test/domain-cap-merge.test.mjs +++ b/packages/harness/test/domain-cap-merge.test.mjs @@ -6,7 +6,7 @@ import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { test } from 'node:test'; -import { ensureStore, listLearnings, readLedger } from '../lib/knowledge/store.mjs'; +import { ensureStore, listLearnings, readLedger, commitStore } from '../lib/knowledge/store.mjs'; import { rebuildIndex } from '../lib/knowledge/apply.mjs'; /** @@ -109,10 +109,24 @@ function seedLearning(dir, domain, slug, over = {}) { fs.writeFileSync(file, lines.join('\n'), 'utf8'); } +/** + * Finish a direct-write fixture the way the CLI itself always leaves the + * store: index rebuilt AND committed. Committing matters — an UNCOMMITTED + * learning file in the store is a hand edit (planted or modified), and + * absorbHandEdits (admin.mjs) now captures those through its validating path + * and stamps them `source: human`, which is exactly right for a person + * editing the store but would silently re-label these fixtures' "pre-existing + * auto-sourced store state" as human-taught. + */ +function finalizeSeed(dir) { + rebuildIndex(dir); + commitStore(dir, 'seed: pre-existing store state'); +} + function seedDomainAtCap(c, domain, count = 25) { const { dir } = ensureStore(c.ws, { home: c.harnessHome }); for (let i = 0; i < count; i++) seedLearning(dir, domain, `seed-${i}`); - rebuildIndex(dir); + finalizeSeed(dir); return dir; } @@ -191,7 +205,7 @@ test('a MERGE with a source: human target lands disputed for that target, no new const { dir } = ensureStore(c.ws, { home: c.harnessHome }); seedLearning(dir, 'sql', 'human-taught', { source: 'human', fixCount: 0 }); seedLearning(dir, 'sql', 'auto-claim', { source: 'auto', fixCount: 1 }); - rebuildIndex(dir); + finalizeSeed(dir); const mergeOp = { op: 'MERGE', @@ -222,7 +236,7 @@ test('a MERGE whose new id already exists is rejected with E_EXISTS, targets unt seedLearning(dir, 'sql', 'target-a'); seedLearning(dir, 'sql', 'target-b'); seedLearning(dir, 'sql', 'already-exists'); - rebuildIndex(dir); + finalizeSeed(dir); const mergeOp = { op: 'MERGE', @@ -251,7 +265,7 @@ test('a MERGE targeting a promoted learning is rejected with the promoted E_TARG const { dir } = ensureStore(c.ws, { home: c.harnessHome }); seedLearning(dir, 'sql', 'promoted-target'); seedLearning(dir, 'sql', 'active-target'); - rebuildIndex(dir); + finalizeSeed(dir); // Promote one target directly on disk — same field `learning promote` // itself writes (serializeLearning's promoted_to line). @@ -262,7 +276,7 @@ test('a MERGE targeting a promoted learning is rejected with the promoted E_TARG text.replace('superseded_by: null', 'superseded_by: null\npromoted_to: .github/instructions/sql.instructions.md'), 'utf8' ); - rebuildIndex(dir); + finalizeSeed(dir); const ledgerBefore = readLedger(dir).length; const mergeOp = { @@ -299,7 +313,7 @@ test('a MERGE naming an already-disputed target (prior run) is rejected E_TARGET const { dir } = ensureStore(c.ws, { home: c.harnessHome }); seedLearning(dir, 'sql', 'disputed-merge-target', { status: 'disputed' }); seedLearning(dir, 'sql', 'active-merge-target'); - rebuildIndex(dir); + finalizeSeed(dir); const ledgerBefore = readLedger(dir).length; const mergeOp = { @@ -329,7 +343,7 @@ test('a 4-target MERGE (5 file touches) alone passes the delta contract but comb const c = ctx(); const { dir } = ensureStore(c.ws, { home: c.harnessHome }); for (const slug of ['t1', 't2', 't3', 't4']) seedLearning(dir, 'sql', slug); - rebuildIndex(dir); + finalizeSeed(dir); const mergeOp = { op: 'MERGE', @@ -514,7 +528,7 @@ test('Gap 1: a MERGE whose targets live in other domains still respects its dest const dir = seedDomainAtCap(c, 'gamma', 25); seedLearning(dir, 'alpha', 'a1'); seedLearning(dir, 'beta', 'b1'); - rebuildIndex(dir); + finalizeSeed(dir); const mergeOp = { op: 'MERGE', @@ -641,7 +655,7 @@ test('a MERGE reusing a target an earlier SUPERSEDE already consumed this run is const { dir } = ensureStore(c.ws, { home: c.harnessHome }); seedLearning(dir, 'sql', 't1'); seedLearning(dir, 'sql', 't2'); - rebuildIndex(dir); + finalizeSeed(dir); const supersedeOp = { op: 'SUPERSEDE', @@ -680,7 +694,7 @@ test('a legitimate MERGE plus an unrelated ADD in a different domain both apply, seedLearning(dir, 'alpha', 'a1'); seedLearning(dir, 'alpha', 'a2'); seedLearning(dir, 'delta', 'd1'); - rebuildIndex(dir); + finalizeSeed(dir); const mergeOp = { op: 'MERGE', diff --git a/packages/harness/test/hand-edits.test.mjs b/packages/harness/test/hand-edits.test.mjs index 59c12bc7..32369fa4 100644 --- a/packages/harness/test/hand-edits.test.mjs +++ b/packages/harness/test/hand-edits.test.mjs @@ -562,6 +562,111 @@ test('a secret-skip during absorb triggered via `harness learning confirm` surfa ); }); +// A learning file PLANTED in the store (never tracked) is live, retrievable +// content the moment it lands — listLearnings reads the tree, not the index — +// yet it used to be skipped here as "untracked/other" and then swept wholesale +// into store history by the next transaction's own `git add -A`, unvalidated, +// unscanned, and with whatever provenance its author typed. It is a hand edit +// like any other, and absorbs through the same validating path. +function plantLearning(file, { trigger, body, source = 'auto' }) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + file, + serializeLearning({ trigger, status: 'active', source, episodes: [], anchors: [], origin: 'planted' }, body), + 'utf8' + ); +} + +test('a PLANTED untracked learning file absorbs as a hand edit — golden and bucket — with snapshot evidence and honest provenance', () => { + const c = ctx(); + seedLearning(c); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + + // A brand-new domain directory: `git status --porcelain` at its default + // untracked granularity reports only the collapsed `learnings/planted/` + // entry, which matches no learning path shape at all. + const goldenFile = path.join(dir, 'learnings', 'planted', 'golden-claim.md'); + plantLearning(goldenFile, { trigger: 'planted golden trigger', body: 'Planted golden claim body.' }); + + const bucketDir = ensureBucket(dir, { key: 'planted-bucket', branch: 'feature/planted', baseSha: null }); + const bucketFile = path.join(bucketDir, 'learnings', 'planted', 'bucket-claim.md'); + plantLearning(bucketFile, { trigger: 'planted bucket trigger', body: 'Planted bucket claim body.' }); + + const result = absorbHandEdits({ workspace: c.ws, home: c.harnessHome }); + assert.deepEqual(result.absorbed.map((a) => a.id).sort(), ['planted/bucket-claim', 'planted/golden-claim']); + assert.equal(result.committed, true); + assert.match(gitLog(dir).at(-1), /human edit: /, 'planted files land in a `human edit:` commit, not an anonymous sweep'); + + for (const [id, file] of [['planted/golden-claim', goldenFile], ['planted/bucket-claim', bucketFile]]) { + const { fm } = parseLearningFrontmatter(fs.readFileSync(file, 'utf8')); + assert.equal(fm.source, 'human', `${id}: a file a person put in the store carries human provenance`); + assert.equal(fm.episodes.length, 1, `${id}: snapshot-evidenced`); + assert.equal(fm.episodes[0].kind, 'human-teaching'); + assert.ok(fs.existsSync(path.join(c.ws, fm.episodes[0].path)), `${id}: the snapshot really exists`); + } + // The bucket ledger — not golden's — records the bucket learning's evidence. + assert.ok(readLedger(bucketDir).some((e) => e.learning === 'planted/bucket-claim')); + assert.ok(!readLedger(dir).some((e) => e.learning === 'planted/bucket-claim')); + assert.equal(spawnSync('git', ['status', '--porcelain'], { cwd: dir, encoding: 'utf8' }).stdout.trim(), ''); +}); + +test('a planted secret-shaped learning file is still scanned on absorb — the snapshot is skipped, warned, and never written', () => { + const c = ctx(); + seedLearning(c); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + plantLearning(path.join(dir, 'learnings', 'planted', 'leaky.md'), { + trigger: 'planted leaky trigger', + body: 'Rotate the key AKIA1234567890ABCDEF before shipping.', + }); + + const logged = []; + const result = absorbHandEdits({ workspace: c.ws, home: c.harnessHome, log: (m) => logged.push(m) }); + assert.deepEqual(result.absorbed.map((a) => a.id), ['planted/leaky']); + assert.equal(result.absorbed[0].snapshot, null, 'no snapshot for secret-shaped content'); + assert.ok(logged.some((m) => /secret-shaped/.test(m))); + assert.equal(fs.existsSync(path.join(c.ws, 'docs', 'solutions', 'teachings')), false); +}); + +test('a REJECTED apply never launders a planted untracked learning file into store history unvalidated', () => { + const c = ctx(); + seedLearning(c); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + const rel = 'learnings/planted/smuggled.md'; + plantLearning(path.join(dir, rel), { trigger: 'smuggled trigger', body: 'Smuggled claim body.' }); + + // An op set that is rejected outright — the run applies nothing, but its + // transaction still finalizes with `git add -A`. + const bad = ADD_WITH_BAD_EPISODE(c.ws); + const res = applyOps({ workspace: c.ws, opsPath: writeOps(c.ws, [bad]), home: c.harnessHome }); + assert.equal(res.exitCode, 1, JSON.stringify(res)); + assert.deepEqual(res.applied, []); + + // The planted file IS in history — but only as a validated, provenance- + // stamped hand edit, never as a silent passenger on the rejected run's own + // commit. + const introducing = spawnSync('git', ['log', '--format=%s', '--diff-filter=A', '--', rel], { cwd: dir, encoding: 'utf8' }) + .stdout.trim() + .split('\n') + .filter(Boolean); + assert.deepEqual(introducing, ['human edit: planted/smuggled'], 'a `human edit:` commit introduced it, not the rejected apply'); + const { fm } = parseLearningFrontmatter(fs.readFileSync(path.join(dir, rel), 'utf8')); + assert.equal(fm.source, 'human'); + assert.equal(fm.episodes.length, 1); +}); + +// An ADD whose episode sha256 does not match the file on disk: rejected by +// verifyAdmittedEpisodeKinds (apply.mjs) before anything is written. +function ADD_WITH_BAD_EPISODE(ws) { + return { + op: 'ADD', + domain: 'sql', + slug: 'never-applied', + trigger: 'an op that never applies', + body: 'A claim whose evidence does not verify.', + episodes: [{ ...EP(ws, { path: 'docs/solutions/perf/unverifiable.md' }), sha256: 'b'.repeat(64) }], + }; +} + test('untracked/modified non-learning store files (config.json, stale.json, INDEX.md) are left for the normal commit, not absorbed', () => { const c = ctx(); seedLearning(c); diff --git a/packages/harness/test/knowledge-boundary-hardening.test.mjs b/packages/harness/test/knowledge-boundary-hardening.test.mjs index c6146481..f35ebe75 100644 --- a/packages/harness/test/knowledge-boundary-hardening.test.mjs +++ b/packages/harness/test/knowledge-boundary-hardening.test.mjs @@ -392,6 +392,50 @@ test('D: hand-deleting a BRANCH copy never retires the golden claim of the same assert.equal(readGovernance(dir).get('sql/shared-x')?.action, 'retire', 'no layer holds it any more — a real retirement'); }); +// The other side of D's guard: "still held by another layer" has to mean +// ACTIVE, not merely present on disk. A promotion leaves a +// `promoted_to_golden` tombstone in the source bucket, and counting that +// inactive remnant as a surviving holder meant deleting the PROMOTED GOLDEN +// claim recorded no governance retire at all — so a later +// `consolidate --rebuild --yes` (which drops the tombstone and re-consolidates +// the still-present branch episode) silently resurrected the id the human had +// deleted, with no veto to stop it. +test('E: hand-deleting a PROMOTED golden claim still records a retire — an inactive bucket tombstone never suppresses governance', () => { + const ws = featureWorkspace('feature/promo-delete'); + const home = tempDir('bh-home-e-'); + const ep = writeFixEpisode(ws, 'docs/solutions/perf/promo-delete.md'); + seedBucketLearning(ws, home, 'promoted-then-deleted', { episodes: [ep] }); + const { dir } = ensureStore(ws, { home }); + const key = branchKeyFor('feature/promo-delete'); + + assert.equal(buildPromotionOps({ workspace: ws, home, all: true }).pass, true); + assert.equal(applyOps({ workspace: ws, opsPath: path.join(ws, PROMOTE_OPS_REL), home }).exitCode, 0); + + const tombstone = listLearnings(bucketDirFor(dir, key)).find((l) => l.id === 'sql/promoted-then-deleted'); + assert.equal(tombstone.fm.promoted_to_golden, 'sql/promoted-then-deleted'); + assert.equal(isActiveFm(tombstone.fm), false, 'precondition: the only remaining twin is an INACTIVE tombstone'); + + // The human deletes the promoted golden claim. + const golden = listLearnings(dir).find((l) => l.id === 'sql/promoted-then-deleted'); + fs.rmSync(golden.file, { force: true }); + const absorbed = absorbHandEdits({ workspace: ws, home }); + assert.deepEqual(absorbed.deleted, ['sql/promoted-then-deleted']); + assert.equal( + readGovernance(dir).get('sql/promoted-then-deleted')?.action, + 'retire', + 'no ACTIVE layer holds the id any more — the deletion is a real retirement' + ); + + // ...and the retire survives the wipe: a rebuild must not resurrect it. + const rebuilt = rebuildStore({ workspace: ws, home, yes: true, copilotHome: tempDir('bh-ch-e-') }); + assert.equal(rebuilt.pass, true, rebuilt.blockedReason); + const resurrected = [ + ...listLearnings(dir), + ...listLearnings(bucketDirFor(dir, key)), + ].filter((l) => l.id === 'sql/promoted-then-deleted' && isActiveFm(l.fm)); + assert.deepEqual(resurrected, [], 'a rebuild cannot bring back a claim the human deleted'); +}); + // --------------------------------------------------------------------------- // F — layer containment is not self-grantable // --------------------------------------------------------------------------- diff --git a/packages/harness/test/knowledge-promote.test.mjs b/packages/harness/test/knowledge-promote.test.mjs index cb47fd96..f9b7f344 100644 --- a/packages/harness/test/knowledge-promote.test.mjs +++ b/packages/harness/test/knowledge-promote.test.mjs @@ -11,9 +11,10 @@ import { readLedger, readGovernance, appendGovernance, + commitStore, } from '../lib/knowledge/store.mjs'; import { applyOps } from '../lib/knowledge/apply.mjs'; -import { buildPromotionOps, PROMOTE_OPS_REL } from '../lib/knowledge/promote.mjs'; +import { buildPromotionOps, PROMOTE_OPS_REL, promotionDigest } from '../lib/knowledge/promote.mjs'; import { pruneBuckets } from '../lib/knowledge/prune.mjs'; import { rebuildStore } from '../lib/knowledge/admin.mjs'; import { bucketDirFor, listBuckets, loadLayeredLearnings } from '../lib/knowledge/overlay.mjs'; @@ -206,6 +207,12 @@ test('episodes-only overlap maps to STRENGTHEN; identical claims are skipped', ( path.join(dir, 'learnings', 'sql', 'same-claim.md'), `---\nschema: 1\ntrigger: "same trigger"\nstatus: active\nsource: auto\nepisodes:\n - path: ${goldenEp.path}\n sha256: "${goldenEp.sha256}"\n kind: fix\n plan: docs/plans/p1.md\nanchors: []\nsuperseded_by: null\nlast_confirmed: null\norigin: t\n---\n\nShared claim body.\n` ); + // Committed, like the CLI always leaves the store: an UNCOMMITTED learning + // file is a hand edit — including a planted, never-tracked one — and + // absorbHandEdits (admin.mjs) captures it, so leaving it uncommitted would + // make this "pre-existing golden twin" a human-taught claim with an extra + // snapshot episode. + commitStore(dir, 'seed: pre-existing golden twin'); const branchEp = writeEpisode(ws, 'docs/solutions/perf/branch-ev.md'); seedBucketLearning(ws, home, 'same-claim', { trigger: 'same trigger', body: 'Shared claim body.', episodes: [branchEp] }); @@ -254,22 +261,123 @@ test('--all chunks under MAX_OPS_PER_RUN with deterministic ordering and remaini assert.deepEqual(nextSet.ops.map((o) => o.source.id), ['sql/chunk-05', 'sql/chunk-06']); }); -test('a tampered promote-ops file is rejected by the digest binding (no strikes)', () => { +// The digest is computed over the ops array by whoever writes the file and is +// unkeyed, so it only ever proves "this file was not edited AFTER it was +// digested" — never that its author was the emitter. Tampering therefore has +// to be tested BOTH ways: with a stale digest (caught by the binding) and with +// a correctly recomputed one (which must still be caught, by the semantic +// binding to the promotion SOURCE). +test('a tampered promote-ops file is rejected by the digest binding — and a re-digested tamper still cannot author the promoted claim (no strikes)', () => { const ws = featureWorkspace('feature/tamper'); const home = tempDir('promo-home6-'); seedBucketLearning(ws, home, 'tampered'); const emitted = buildPromotionOps({ workspace: ws, home, all: true }); assert.equal(emitted.pass, true, emitted.blockedReason); const opsFull = path.join(ws, PROMOTE_OPS_REL); - const opset = JSON.parse(fs.readFileSync(opsFull, 'utf8')); - opset.ops[0].body = 'Tampered body.'; - fs.writeFileSync(opsFull, JSON.stringify(opset)); + const pristine = fs.readFileSync(opsFull, 'utf8'); + const { dir } = ensureStore(ws, { home }); + + // 1. Stale digest: content edited after emission. + const stale = JSON.parse(pristine); + stale.ops[0].body = 'Tampered body.'; + fs.writeFileSync(opsFull, JSON.stringify(stale)); + const staleRes = applyOps({ workspace: ws, opsPath: opsFull, home }); + assert.equal(staleRes.exitCode, 1); + assert.match(staleRes.rejected[0].reason, /digest mismatch/); + + // 2. The SAME tamper, correctly re-digested — indistinguishable from an + // emitter-authored file by the digest alone. The promoted claim's trigger + // and body must still come from the verified source learning, not the op. + const redigested = JSON.parse(pristine); + redigested.ops[0].trigger = 'attacker-authored trigger'; + redigested.ops[0].body = 'Attacker-authored golden claim body.'; + redigested.promotion.digest = promotionDigest(redigested.ops); + fs.writeFileSync(opsFull, JSON.stringify(redigested)); + const contentRes = applyOps({ workspace: ws, opsPath: opsFull, home }); + assert.equal(contentRes.exitCode, 0, JSON.stringify(contentRes.rejected)); + + const golden = listLearnings(dir).find((l) => l.id === 'sql/tampered'); + assert.ok(golden, 'the source claim still promotes'); + assert.equal(golden.fm.trigger, 'trigger for tampered', 'the promoted trigger is the source learning’s'); + assert.match(golden.body, /Claim body for tampered\./); + assert.ok(!/Attacker-authored/.test(`${golden.fm.trigger}\n${golden.body}`), 'the op never authors the promoted claim'); + + assert.equal(readLedger(dir).filter((e) => e.failure).length, 0, 'promotion rejections never strike'); +}); + +// A promotion moves a claim between layers. Until the destination was bound to +// the source, the writer verified `op.source.id` and its sha256 and then +// trusted the op's own destination (`domain`/`slug`, or `target`) — so a +// hand-authored, correctly-digested op could cite one claim's verified +// identity while writing a completely different one, and the tombstone +// (keyed off the destination) never marked the cited source as consumed. +test('a re-digested promotion op cannot rename its destination, and a refused run never tombstones its source', () => { + const ws = featureWorkspace('feature/bind'); + const home = tempDir('promo-home11-'); + seedBucketLearning(ws, home, 'bound-claim'); + const { dir } = ensureStore(ws, { home }); + const key = branchKeyFor('feature/bind'); + const opsFull = path.join(ws, PROMOTE_OPS_REL); + + assert.equal(buildPromotionOps({ workspace: ws, home, all: true }).pass, true); + const renamed = JSON.parse(fs.readFileSync(opsFull, 'utf8')); + assert.equal(renamed.ops[0].source.id, 'sql/bound-claim'); + renamed.ops[0].slug = 'attacker-claim'; + renamed.ops[0].trigger = 'attacker-authored trigger'; + renamed.ops[0].body = 'An arbitrary claim wearing another claim’s verified identity.'; + renamed.promotion.digest = promotionDigest(renamed.ops); + fs.writeFileSync(opsFull, JSON.stringify(renamed)); + + const res = applyOps({ workspace: ws, opsPath: opsFull, home }); + assert.equal(res.exitCode, 1, JSON.stringify(res)); + assert.equal(res.rejected[0].code, 'E_SCHEMA'); + assert.match(res.rejected[0].reason, /promotion destination .* does not match source/); + assert.deepEqual(listLearnings(dir).map((l) => l.id), [], 'nothing reached golden'); + + // The tombstone follows the SOURCE, so a refused run leaves it untouched — + // and, crucially, a SUCCESSFUL run must consume it exactly once. + const source = listLearnings(bucketDirFor(dir, key)).find((l) => l.id === 'sql/bound-claim'); + assert.equal(source.fm.promoted_to_golden, undefined, 'a refused promotion never tombstones its source'); + + const clean = buildPromotionOps({ workspace: ws, home, all: true }); + assert.equal(clean.pass, true, clean.blockedReason); + assert.equal(applyOps({ workspace: ws, opsPath: opsFull, home }).exitCode, 0); + const tombstoned = listLearnings(bucketDirFor(dir, key)).find((l) => l.id === 'sql/bound-claim'); + assert.equal(tombstoned.fm.promoted_to_golden, 'sql/bound-claim', 'the promoted source is tombstoned by id'); + assert.equal(buildPromotionOps({ workspace: ws, home, all: true }).pass, false, 'and is no longer re-offered'); +}); + +test('a re-digested promotion STRENGTHEN cannot graft its source evidence onto an unrelated golden claim', () => { + const ws = featureWorkspace('feature/graft'); + const home = tempDir('promo-home12-'); + const opsFull = path.join(ws, PROMOTE_OPS_REL); - const applied = applyOps({ workspace: ws, opsPath: opsFull, home }); - assert.equal(applied.exitCode, 1); - assert.match(applied.rejected[0].reason, /digest mismatch/); + // A legitimately promoted golden claim — the graft victim. + seedBucketLearning(ws, home, 'victim'); + assert.equal(buildPromotionOps({ workspace: ws, home, all: true }).pass, true); + assert.equal(applyOps({ workspace: ws, opsPath: opsFull, home }).exitCode, 0); const { dir } = ensureStore(ws, { home }); - assert.equal(readLedger(dir).filter((e) => e.failure).length, 0, 'digest rejection never strikes'); + const victimBefore = listLearnings(dir).find((l) => l.id === 'sql/victim'); + assert.equal(victimBefore.fm.episodes.length, 1); + + // A second, unrelated bucket claim whose evidence the op tries to hand to + // the victim: `verifiedFixLinks` is simultaneously the promotion-eligibility + // signal and the protected-target signal, so grafting inflates both. + seedBucketLearning(ws, home, 'evidence-source'); + assert.equal(buildPromotionOps({ workspace: ws, home, all: true }).pass, true); + const grafted = JSON.parse(fs.readFileSync(opsFull, 'utf8')); + const donor = grafted.ops.find((o) => o.source.id === 'sql/evidence-source'); + grafted.ops = [{ op: 'STRENGTHEN', target: 'sql/victim', episodes: donor.episodes, source: donor.source }]; + grafted.promotion.digest = promotionDigest(grafted.ops); + fs.writeFileSync(opsFull, JSON.stringify(grafted)); + + const res = applyOps({ workspace: ws, opsPath: opsFull, home }); + assert.equal(res.exitCode, 1, JSON.stringify(res)); + assert.equal(res.rejected[0].code, 'E_SCHEMA'); + assert.match(res.rejected[0].reason, /promotion destination sql\/victim does not match source sql\/evidence-source/); + + const victimAfter = listLearnings(dir).find((l) => l.id === 'sql/victim'); + assert.equal(victimAfter.fm.episodes.length, 1, 'the unrelated golden claim gained no borrowed evidence'); }); test('governed and detached sources are refused at emit time', () => { diff --git a/packages/harness/test/layer-routing.test.mjs b/packages/harness/test/layer-routing.test.mjs index 72305358..e2748cd9 100644 --- a/packages/harness/test/layer-routing.test.mjs +++ b/packages/harness/test/layer-routing.test.mjs @@ -314,3 +314,55 @@ test('listBuckets sees a routed bucket and consolidate status reports the branch assert.equal(status.bucketKey, branchKeyFor('feature/lane')); assert.equal(status.debt, 0, 'the bucket ledger consumed the episode — no phantom debt'); }); + +// Routing and provenance describe the same thing — the HEAD this write belongs +// to — but were derived from TWO separate git reads, both taken BEFORE the +// store lock existed. A checkout landing between them could stamp provenance +// for one branch while routing the write to another's layer; a checkout +// landing after both could cache golden routing from the default branch and +// then write it out from a feature branch, bypassing branch isolation +// entirely. The store lock cannot serialize the WORKSPACE's git operations, so +// the snapshot is taken once inside the transaction and re-validated at write +// time — fail closed, never a silent write to the stale layer. +// +// Deterministic reproduction: a store-side `pre-commit` hook moves the +// workspace HEAD during the absorb sub-commit the transaction makes before its +// own mutation phase — i.e. inside the exact window the two reads straddled. +test('a workspace checkout landing mid-transaction aborts the write instead of routing it to the stale layer', () => { + const ws = clonedWorkspace(); + const home = tempDir('route-home11-'); + + // Land a first claim on main (the default branch → golden) so the store has + // a tracked learning file to hand-edit — that edit is what makes the + // transaction's absorb step commit, and therefore run the hook. + const first = applyOps({ workspace: ws, opsPath: writeOps(ws, [addOp(ws, { slug: 'settled-claim' })]), home }); + assert.equal(first.exitCode, 0, JSON.stringify(first.rejected)); + assert.equal(first.layer, 'golden'); + + const { dir } = ensureStore(ws, { home }); + const learning = listLearnings(dir).find((l) => l.id === 'sql/settled-claim'); + fs.writeFileSync( + learning.file, + fs.readFileSync(learning.file, 'utf8').replace('Routed claim body.', 'Hand-edited claim body.'), + 'utf8' + ); + + const hooks = path.join(dir, '.git', 'hooks'); + fs.mkdirSync(hooks, { recursive: true }); + fs.writeFileSync( + path.join(hooks, 'pre-commit'), + `#!/bin/sh\ngit -C ${JSON.stringify(ws)} checkout -qB feature/moved >/dev/null 2>&1\nexit 0\n`, + { mode: 0o755 } + ); + + const res = applyOps({ workspace: ws, opsPath: writeOps(ws, [addOp(ws, { slug: 'raced-claim' })]), home }); + assert.equal(res.exitCode, 1, JSON.stringify(res)); + assert.equal(res.rejected[0].code, 'E_HEAD_MOVED'); + assert.match(res.rejected[0].reason, /main → feature\/moved/); + + // Nothing was written to EITHER layer — above all not to golden's stale route. + assert.equal(listLearnings(dir).some((l) => l.id === 'sql/raced-claim'), false, 'no golden write from a stale snapshot'); + assert.deepEqual(listBuckets(dir), [], 'and no bucket was materialized either'); + // The hand edit the absorb captured before the abort is still intact. + assert.match(listLearnings(dir).find((l) => l.id === 'sql/settled-claim').body, /Hand-edited claim body\./); +}); diff --git a/packages/harness/test/learning-promote.test.mjs b/packages/harness/test/learning-promote.test.mjs index 379fcad5..40257407 100644 --- a/packages/harness/test/learning-promote.test.mjs +++ b/packages/harness/test/learning-promote.test.mjs @@ -6,7 +6,7 @@ import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { test } from 'node:test'; -import { storeDir, listLearnings, readLedger, readGovernance, ensureStore } from '../lib/knowledge/store.mjs'; +import { storeDir, listLearnings, readLedger, readGovernance, ensureStore, commitStore } from '../lib/knowledge/store.mjs'; import { rankLearnings } from '../lib/knowledge/retrieve.mjs'; import { rebuildIndex } from '../lib/knowledge/apply.mjs'; import { isActiveFm } from '../lib/knowledge/consolidate.mjs'; @@ -137,6 +137,10 @@ test('cap-after-promote: promoting one of 25 active learnings frees room for a n const { dir } = ensureStore(c.ws, { home: c.harnessHome }); for (let i = 0; i < 25; i++) seedActiveLearning(dir, 'sql', `cap-fill-${i}`); rebuildIndex(dir); + // Committed, like the CLI always leaves the store: an UNCOMMITTED learning + // file is a hand edit, and absorbHandEdits (admin.mjs) captures those — + // including planted, never-tracked ones — and stamps them `source: human`. + commitStore(dir, 'seed: pre-existing store state'); assert.equal(listLearnings(dir).filter((l) => isActiveFm(l.fm)).length, 25, 'precondition: domain at cap'); const to = primitivePath(c.ws); @@ -191,6 +195,11 @@ A claim whose only qualifying-kind episode lacks a path. `, 'utf8' ); + // Committed so this stays a STALE ON-DISK RECORD rather than an uncommitted + // hand edit — absorbHandEdits (admin.mjs) would otherwise capture the + // planted file, re-serialize it (dropping the pathless episode outright) and + // stamp it `source: human`, which is the wrong shape for this regression. + commitStore(dir, 'seed: stale on-disk record'); return `sql/${slug}`; } diff --git a/packages/harness/test/store-transaction.test.mjs b/packages/harness/test/store-transaction.test.mjs index ae485c8f..70287375 100644 --- a/packages/harness/test/store-transaction.test.mjs +++ b/packages/harness/test/store-transaction.test.mjs @@ -4,7 +4,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { test } from 'node:test'; import { applyOps } from '../lib/knowledge/apply.mjs'; import { setLearningStatus } from '../lib/knowledge/lifecycle.mjs'; @@ -215,18 +215,26 @@ test('the writeStoreConfig lock-failure CLI path (`harness knowledge on`) render // Kill/restart: a stale lock + dirty tree from a "crashed" writer. // --------------------------------------------------------------------------- +/** The in-transaction intent journal (store.mjs) — present on disk only while + * a transaction is between its first write and its commit/rollback, so its + * absence is what distinguishes a genuine human hand edit from a dead + * writer's residue. */ +const txnJournalPath = (dir) => path.join(dir, '.git', 'harness-txn.json'); + test('a crashed writer (stale lock + an uncommitted hand edit) is taken over cleanly: the hand edit is absorbed, not destroyed', () => { const c = ctx(); const id = seedLearning(c); const { dir } = ensureStore(c.ws, { home: c.harnessHome }); const learning = listLearnings(dir).find((l) => l.id === id); - // The "crashed" process hand-edited the learning file (or absorbed - // someone else's hand edit) but died before it could commit — leaving the - // tracked file dirty in the working tree. + // A HUMAN hand-edited the learning file directly and the CLI process that + // was going to absorb it died before it could — leaving the tracked file + // dirty in the working tree with NO transaction journal behind it. That + // absence is the signal: nothing was mid-write, so the dirt is the human's. handEditBody(learning.file, 'A crash-recovered hand edit that must survive takeover.'); const dirty = gitPorcelainStatus(dir); assert.match(dirty, /M\s+learnings\/sql\/not-null-hot-tables\.md/, 'precondition: dirty tracked file'); + assert.equal(fs.existsSync(txnJournalPath(dir)), false, 'precondition: no interrupted transaction — this dirt is a human edit'); // ...and its own `.lock` directory, now stale (old mtime — past the // takeover threshold). @@ -257,6 +265,69 @@ test('a crashed writer (stale lock + an uncommitted hand edit) is taken over cle assert.ok(listLearnings(dir).some((l) => l.id === 'sql/after-crash')); }); +// The other half of the same distinction: dirt left by a writer that died +// MID-TRANSACTION is CLI-authored residue, not a human edit. Without the +// intent journal the takeover path could not tell the two apart, so the next +// transaction's absorb inherited a model-authored partial write, snapshotted +// it as `kind: human-teaching`, and stamped the learning `source: human` — +// laundering a crash artifact into the store's highest authority tier (which +// then protects it from demotion and exempts it from provisional damping). +test('a writer killed MID-TRANSACTION leaves CLI residue, not human authority: takeover discards it and the committed claim survives', () => { + const c = ctx(); + const id = seedLearning(c); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + const learning = listLearnings(dir).find((l) => l.id === id); + const committed = fs.readFileSync(learning.file, 'utf8'); + const residue = committed.replace( + /(---\r?\n[\s\S]*?\r?\n---\r?\n\r?\n)[\s\S]*$/, + (_m, fm) => `${fm}A model-authored partial write that never reached a commit.\n` + ); + + // A REAL crash: the child takes the store lock through the same + // withStoreTransaction every writer uses, overwrites a learning, and is + // SIGKILLed before it can commit or roll back — so nothing in its `finally` + // ever runs and the lock, the journal, and the dirty tree all survive it. + const storeModule = pathToFileURL(path.join(packageRoot, 'lib', 'knowledge', 'store.mjs')).href; + const script = [ + "import fs from 'node:fs';", + `import { withStoreTransaction } from ${JSON.stringify(storeModule)};`, + `withStoreTransaction(${JSON.stringify(c.ws)}, { home: ${JSON.stringify(c.harnessHome)}, label: 'crashing writer' }, () => {`, + ` fs.writeFileSync(${JSON.stringify(learning.file)}, ${JSON.stringify(residue)}, 'utf8');`, + " process.kill(process.pid, 'SIGKILL');", + '});', + ].join('\n'); + const child = spawnSync(process.execPath, ['--input-type=module', '-e', script], { encoding: 'utf8' }); + assert.equal(child.signal, 'SIGKILL', `precondition: the writer really died mid-transaction (${child.stderr})`); + assert.equal(fs.readFileSync(learning.file, 'utf8'), residue, 'precondition: the residue is sitting in the tree'); + assert.match(gitPorcelainStatus(dir), /M\s+learnings\/sql\/not-null-hot-tables\.md/, 'precondition: dirty tracked file'); + + const lockPath = path.join(dir, '.lock'); + assert.ok(fs.existsSync(lockPath), 'precondition: the dead writer left its lock behind'); + const old = new Date(Date.now() - 11 * 60 * 1000); + fs.utimesSync(lockPath, old, old); + + const res = applyOps({ + workspace: c.ws, + opsPath: writeOps(c.ws, [ADD(c.ws, { slug: 'after-kill', episodePath: 'docs/solutions/perf/after-kill.md' })]), + home: c.harnessHome, + }); + assert.equal(res.exitCode, 0, JSON.stringify(res)); + assert.match(res.staleLockRemoved || '', /residue/, 'the takeover reports the residue it discarded'); + + const after = listLearnings(dir).find((l) => l.id === id); + assert.equal(fs.readFileSync(after.file, 'utf8'), committed, 'the residue was rolled back to the committed claim'); + assert.notEqual(after.fm.source, 'human', 'CLI residue is never promoted to human authority'); + assert.ok( + !fs.existsSync(path.join(c.ws, 'docs', 'solutions', 'teachings')), + 'and no human-teaching snapshot was fabricated from it' + ); + + // The takeover still works: the lock is released and the new op landed. + assert.equal(fs.existsSync(lockPath), false); + assert.equal(fs.existsSync(txnJournalPath(dir)), false, 'the journal is cleared once the transaction finishes'); + assert.ok(listLearnings(dir).some((l) => l.id === 'sql/after-kill')); +}); + function gitPorcelainStatus(dir) { return spawnSync('git', ['status', '--porcelain'], { cwd: dir, encoding: 'utf8' }).stdout; } From fe761b560d77715bc473a242c3b20f83cd07e13b Mon Sep 17 00:00:00 2001 From: Krish Date: Thu, 6 Aug 2026 14:00:50 -0400 Subject: [PATCH 15/24] test: anchor completion-hook edit spoofing to the verification timestamp --- packages/harness/test/harness-cli.test.mjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/harness/test/harness-cli.test.mjs b/packages/harness/test/harness-cli.test.mjs index 9edef76f..25c56fb0 100644 --- a/packages/harness/test/harness-cli.test.mjs +++ b/packages/harness/test/harness-cli.test.mjs @@ -2459,7 +2459,14 @@ test('completion hook bypasses read-only work and enforces each new recorded edi assert.equal(runHook('require-plan-gate.mjs', workspace, { file_path: 'src/example.js' }).status, 0); recordSuccessfulEdit(workspace, { file_path: 'src/example.js' }); session = JSON.parse(fs.readFileSync(sessionPath, 'utf8')); - session.lastEditAt = new Date(Date.parse(session.lastCompletedEditAt) + 1000).toISOString(); + // The edit must land strictly after BOTH the completion marker and the last + // verification — require-verification denies on `lastVerifyAt < lastEditAt`, + // so anchoring only to lastCompletedEditAt made this assertion depend on + // `verify` finishing within 1s. Under full-suite load it does not, the + // spoofed edit sorts BEFORE verification, and the hook correctly allows. + session.lastEditAt = new Date( + Math.max(Date.parse(session.lastCompletedEditAt), Date.parse(session.lastVerifyAt)) + 1000 + ).toISOString(); fs.writeFileSync(sessionPath, JSON.stringify(session)); const changedAfter = runHook('require-verification.mjs', workspace); assertHookBlocked(changedAfter, /changed after/i); From d1af0699a30a4b41d3b0b58b76096df8bd3064dd Mon Sep 17 00:00:00 2001 From: Krish Date: Thu, 6 Aug 2026 14:03:02 -0400 Subject: [PATCH 16/24] fix: sanitize shipped verify payloads and keep required checks non-advisory --- .../references/harness-tool-contract.md | 5 + docs/MEMORY-MODEL.md | 46 +- packages/harness/lib/commands.mjs | 12 + packages/harness/lib/context-pack.mjs | 11 +- packages/harness/lib/knowledge/retrieve.mjs | 24 +- packages/harness/lib/policy.mjs | 31 +- packages/harness/lib/verify.mjs | 114 +++-- .../test/recall-secret-data-boundary.test.mjs | 83 +++- .../test/verify-severity-hardening.test.mjs | 407 ++++++++++++++++-- 9 files changed, 640 insertions(+), 93 deletions(-) diff --git a/.github/skills/references/harness-tool-contract.md b/.github/skills/references/harness-tool-contract.md index 83b6c420..5c4454b4 100644 --- a/.github/skills/references/harness-tool-contract.md +++ b/.github/skills/references/harness-tool-contract.md @@ -156,6 +156,7 @@ For locked plans, both commands enforce criterion-to-check mappings and configur "plan": "docs/plans/example-plan.md", "checks": [{ "id": "scope", "status": "passed", "message": "...", "severity": "enforce" }], "advisoryFailures": [], + "refusedSeverityDowngrades": [], "unverifiedCriteria": [], "scopeViolations": [], "openHardGaps": [], @@ -183,6 +184,10 @@ Allowed outcomes are `passed`, `failed`, and `inconclusive`. Only fresh `passed` Every check in the `verify` payload carries its effective `severity`; non-passing advisory checks are additionally listed under `advisoryFailures` (with their findings) so an exit-neutral signal is never silently lost. A v1 policy file (no `checks:` map) behaves exactly as before. +`advisory` is refused for the built-in gating checks at policy load (error names the check and points at `warn`), and — because a policy file is plan-agnostic — it is also refused per run for any check the ACTIVE PLAN gates on: everything in `verification.required` plus every check mapped under `verification.criteria`. That refusal does not abort the run (aborting would write no evidence at all, failing open): the downgrade is ignored, the check runs at its built-in default severity, and the run reports it in `refusedSeverityDowngrades` (`[{ "id": "team-lint", "requested": "advisory", "effective": "enforce" }]`) plus a `warn` line on the CLI. `warn` stays available for every check, including plan-required ones. + +**Sanitized check payloads.** Every check `message`, `findings`, and `informational` payload in the `verify` result is secret-redacted, flattened to one line, and capped (240 chars per string, 20 entries per list, 50 findings) before it reaches `--json`, `.harness/evidence/*.json`, or the event log — those fields carry current-side repo text (extracted symbol names, plan-declared expectations) with no length bound of their own. A check's `id`, `status`, `severity`, and numeric fields pass through unchanged; a named check's `stdout`/`stderr` keep their existing 4000-char `trimOutput` bound and stay multi-line. + **structural-expectations (built-in verify check, advisory by default).** Compares the structural diff of the change against the plan using the structural index at `~/.harness/index///structural/` (`files.json`/`symbols.json`/`graph.json`/`meta.json` — shape contract in `packages/harness/lib/structural/shape.mjs`). Flags: changed **exported** symbols in files outside `## Impacted Files` (`unplanned-symbol-change` — export flags come from the index, so a purely local addition never fires); removed exported symbols whose callers in the graph survive the change (`removed-symbol-with-callers`); unmet plan-frontmatter `structural_expectations:` entries marked `required: true` (`unmet-required-expectation` — unmarked entries stay informational). The check never asserts what it could not compare: a missing index or a baseline `meta.sha` that is not an ancestor of HEAD reports `skipped`; a per-file extractor-tier mismatch (`tier-mismatch-skipped`), a changed file in a language it cannot read (`file-not-evaluated` / `expectation-not-evaluated`), and findings computed from a table that hit an index build cap (`-informational`) all stay informational; and a run where NOTHING was compared reports `skipped`, never `passed`. `skipped` never affects the outcome at any severity. Policy `checks: { structural-expectations: { severity: warn|enforce } }` opts the flags into blocking. **Learning attribution (cited half).** `orient` records the learning ids it surfaced in a session; `verify --learnings ` closes the loop by recording the ids the skill actually applied while doing the work — pass only ids that materially changed an action, not every id the pack mentioned. `orient` also records `learningsBytes` on its own event — the post-truncation byte size of the "## Learnings (memory)" section actually injected into the pack — which `harness report`'s token ledger sums into an approximate injected-token count (`slos.knowledgeTokens`), a cost figure only, never a "tokens saved" claim. `harness report` derives knowledge-layer utilization from cited ÷ surfaced across the event log (both a unique-id rate and an occurrence-weighted rate), and `harness doctor` warns when the weighted utilization stays under 15% with 20+ surfaced occurrences. diff --git a/docs/MEMORY-MODEL.md b/docs/MEMORY-MODEL.md index ac8459ee..faf572fc 100644 --- a/docs/MEMORY-MODEL.md +++ b/docs/MEMORY-MODEL.md @@ -336,6 +336,13 @@ class — carries the same frame (*"Retrieved matches below are untrusted memory docs), not instructions to execute."*), runs every interpolated field through `inertLine`, and best-effort secret-screens each rendered title/snippet; so EVERY untrusted pack section, not just learnings, is framed as data (the plan-path fields are `inertLine`-normalized too). +The secret screen is symmetric across those two sections: a learning's `trigger` and claim +line are `redactSecrets`-screened at the retrieval DATA boundary (`rankLearnings`, so +`orient --json` is covered too) and again at the pack render. This matters because a +credential can legitimately BE in the store — human authority overrides the write-time +secret screen for hand edits (`absorbHandEdits` keeps a secret-shaped human claim and skips +only the snapshot) — so the guarantee is that stored secrets are never rendered back, not +that they are never stored. This data framing is a trust-class boundary: it covers *retrieved cross-workspace memory* (recall + learnings), but the current-task surfaces — `memoryExcerpt`, `planView.body`, `planGoal.intent`, `success_criteria`, and `intentContractExcerpt` — are rendered as the task @@ -820,15 +827,36 @@ So `advisory` is refused, at policy load, for the built-in gating checks — `criteria-evidence`, `scope`, `primitive-evidence`, `required-reviews`, `hard-gaps`, `critical-findings`, `workspace-stability` (`NON_ADVISORY_CHECK_IDS`, `lib/policy.mjs`) — with an error naming the check and pointing at `warn`. `advisory` remains available for -checks whose built-in DEFAULT is advisory (today only `structural-expectations`) and for a -project's own named checks in `checks.yaml` — a team's own command is theirs to mark -advisory. `warn` remains available for every check. Existing v1 policies are unaffected. - -Advisory findings are also less-trusted DATA, not report text: they carry current-side repo -symbols from a lexical extractor with no length bound of its own, and they are copied into -`.harness/evidence/*.json` and `verify --json`. Every string reachable in an advisory -failure is secret-redacted, flattened to one line, and capped (240 chars per string, 20 -entries per list, 50 findings) at the point the payload is collected. +checks whose built-in DEFAULT is advisory (today only `structural-expectations`). +`warn` remains available for every check. Existing v1 policies are unaffected. + +A project's own named check in `checks.yaml` is still the team's to mark advisory — but +only while no plan gates on it. The moment the ACTIVE PLAN lists a check under +`verification.required` (or maps it under `verification.criteria`), that check is a gate, +and `advisory` would erase its failure from the outcome exactly as above. `loadPolicy` +cannot refuse that at parse time — one policy file serves every plan in the repo and knows +none of them — so the rule is applied where the plan and the policy meet, in +`checkSeverityFor`'s `planGatedIds` argument (`lib/policy.mjs`, called from +`applyCheckSeverities` in `lib/verify.mjs`). + +**Decision — ignore, do not refuse.** The downgrade is dropped for that run and the check +falls back to its built-in default (`enforce` for a named check); the run then reports the +refusal in `refusedSeverityDowngrades` on the result, in the evidence artifact, and as a +`warn`-state `policy` line on the CLI. Throwing instead would abort before any evidence is +written, which fails OPEN for the agent (no artifact to gate on at all); ignoring fails +CLOSED, which is what a gate is for. + +Check findings are also less-trusted DATA, not report text: they carry current-side repo +symbols from a lexical extractor with no length bound of its own — and plan-declared +expectations echoed back verbatim — and the CANONICAL `result.checks` array is what +`.harness/evidence/*.json`, `verify --json`, and the event log all serialize. So the +sanitizer runs on that array, at `finalize`, not only on the advisory summary copy: every +string reachable in a check's `message`, `findings`, or `informational` payload is +secret-redacted, flattened to one line, and capped (240 chars per string, 20 entries per +list, 50 findings). A check's `id`, `status`, `severity`, and numeric fields are code-set +tokens and pass through; `stdout`/`stderr` are the trusted named command's own output, +already bounded by `trimOutput` and deliberately left multi-line so a failing check stays +readable. ## Related diff --git a/packages/harness/lib/commands.mjs b/packages/harness/lib/commands.mjs index 47ae976b..97a92d76 100644 --- a/packages/harness/lib/commands.mjs +++ b/packages/harness/lib/commands.mjs @@ -719,6 +719,18 @@ export async function cmdVerify(argv) { note: result.evidencePath, }) ); + // A policy that tried to mark a plan-required check advisory disagrees + // with the plan it is verifying; the run ignores it, and says so. + for (const refused of result.refusedSeverityDowngrades || []) { + console.log( + ui.line({ + state: 'warn', + key: 'policy', + value: `checks.${refused.id}.severity: advisory ignored`, + note: `the active plan requires ${refused.id}; running as ${refused.effective}`, + }) + ); + } printChecks(flags, result.checks, (c) => c.status === 'passed' || c.status === 'skipped'); if (passed) { printNext('harness compound (or /auto-compound), then stop'); diff --git a/packages/harness/lib/context-pack.mjs b/packages/harness/lib/context-pack.mjs index 280f0de4..e037321a 100644 --- a/packages/harness/lib/context-pack.mjs +++ b/packages/harness/lib/context-pack.mjs @@ -46,8 +46,15 @@ export function buildLearningsLines(learnings) { // inertLine: a legacy or hand-edited learning can still carry an // embedded control char in its trigger/claim (see store.mjs's doc // comment) — collapsed to a space so it can never inject extra - // structure into this trusted context surface. - lines.push(`- [${l.id}]${layerMark}${fence} ${inertLine(l.trigger)} → ${inertLine(l.claimLine)}`); + // structure into this trusted context surface. redactSecrets: the same + // treatment the Recall bullets below already give their retrieved text — + // a hand-edited learning may legitimately HOLD a credential (human + // authority overrides the write-time screen), but it must never be + // rendered back to the model. rankLearnings already screens at the data + // boundary; this keeps the guarantee true for any caller of the pack. + lines.push( + `- [${l.id}]${layerMark}${fence} ${inertLine(redactSecrets(l.trigger))} → ${inertLine(redactSecrets(l.claimLine))}` + ); } return lines; } diff --git a/packages/harness/lib/knowledge/retrieve.mjs b/packages/harness/lib/knowledge/retrieve.mjs index 821378e3..17f3603d 100644 --- a/packages/harness/lib/knowledge/retrieve.mjs +++ b/packages/harness/lib/knowledge/retrieve.mjs @@ -1,8 +1,24 @@ import fs from 'node:fs'; -import { storeDir, readStaleExclusions } from './store.mjs'; +import { storeDir, readStaleExclusions, inertLine } from './store.mjs'; import { loadLayeredLearnings, layerTieRank } from './overlay.mjs'; +import { redactSecrets } from '../secret-scan.mjs'; import { tokenize } from '../tokenize.mjs'; +/** + * Retrieved learning text, screened at the DATA boundary — the same doctrine + * redactRecallEntry (secret-scan.mjs) applies to recall results, for the same + * reason: a render-boundary-only screen misses the `--json` sibling, which + * serializes this object raw. Learning content is HAND-EDITABLE and human + * authority deliberately overrides the write-time secret screen for hand edits + * (absorbHandEdits keeps a secret-shaped human claim, skipping only the + * snapshot), so a stored credential is a supported state — it must simply + * never be rendered back to an agent. inertLine additionally flattens the + * control characters a legacy or hand-edited file can carry. + */ +function retrievedText(value) { + return inertLine(redactSecrets(String(value ?? ''))); +} + /** * Read the raw learning set + stale-anchor exclusions for a workspace. * Read-only and advisory: never creates the store, never throws — a missing @@ -103,8 +119,10 @@ export function rankLearnings({ workspace, query, limit = 3, home, include }) { (l.fm.episodes || []).length > 0 && (l.fm.episodes || []).every((e) => e.kind === 'insight'); results.push({ id: l.id, - trigger: l.fm.trigger || '', - claimLine: scored.claimLine.slice(0, 140), + // Redact BEFORE the cap: slicing first could cut a credential in half + // and leave the fragment unmatched (and therefore unredacted). + trigger: retrievedText(l.fm.trigger), + claimLine: retrievedText(scored.claimLine).slice(0, 140), status: l.fm.status || 'active', advisory, score: scored.score, diff --git a/packages/harness/lib/policy.mjs b/packages/harness/lib/policy.mjs index be834892..f5cd55f3 100644 --- a/packages/harness/lib/policy.mjs +++ b/packages/harness/lib/policy.mjs @@ -26,7 +26,10 @@ const POLICY_VERSIONS = new Set([1, 2]); * except the ones whose built-in DEFAULT is already advisory * (`structural-expectations`) — those stay downgradable because advisory is * what they already are. Project-defined named checks (checks.yaml) are - * deliberately NOT listed: a team's own command is theirs to mark advisory. + * deliberately NOT listed: a team's own command is theirs to mark advisory — + * UNTIL the active plan gates on it, which is a per-run fact this static set + * cannot know. That half of the rule lives in checkSeverityFor's + * `planGatedIds` argument below. * `warn` remains available for every check — it degrades a failure to * inconclusive (a non-zero exit under enforce), it does not erase it. */ @@ -105,10 +108,30 @@ export function loadPolicy(workspace, override = null) { /** Effective severity for a verify check: policy entry, else the check's built-in default. * Own-property check only: ids like `constructor`/`toString` must fall through - * to the default instead of resolving Object.prototype members. */ -export function checkSeverityFor(policy, id, defaultSeverity = 'enforce') { + * to the default instead of resolving Object.prototype members. + * + * `planGatedIds` closes the half of the advisory rule the static id list above + * cannot see. NON_ADVISORY_CHECK_IDS protects the BUILT-IN checks, but a + * PROJECT-DEFINED named check becomes just as gating the moment the ACTIVE + * PLAN lists it under `verification.required` (or maps it under + * `verification.criteria`) — and an advisory downgrade would filter its + * failure out of resolveOutcome exactly the same way, minting `outcome: + * passed` evidence from a run whose own required check failed. loadPolicy + * cannot refuse that at parse time: one policy file serves every plan in the + * repo and knows none of them. So the rule lands HERE, where the plan and the + * policy meet. + * + * DECISION (documented in docs/MEMORY-MODEL.md): the downgrade is IGNORED for + * that run rather than refused outright — the check falls back to its built-in + * default and verify reports the refusal in `refusedSeverityDowngrades` (and + * loudly on the CLI). Refusing would throw before any evidence is written, + * which fails OPEN for the agent (no artifact at all); ignoring fails CLOSED, + * which is what a gate is for. */ +export function checkSeverityFor(policy, id, defaultSeverity = 'enforce', planGatedIds = null) { const configured = policy?.checkSeverities; - return configured && Object.hasOwn(configured, id) ? configured[id] : defaultSeverity; + const severity = configured && Object.hasOwn(configured, id) ? configured[id] : defaultSeverity; + if (severity === 'advisory' && planGatedIds?.has(id)) return defaultSeverity; + return severity; } export function enforcementExitCode(outcome, enforcement) { diff --git a/packages/harness/lib/verify.mjs b/packages/harness/lib/verify.mjs index 2c4f43cc..5d7fc6cb 100644 --- a/packages/harness/lib/verify.mjs +++ b/packages/harness/lib/verify.mjs @@ -102,61 +102,112 @@ function resolveOutcome(checks) { return 'passed'; } -function applyCheckSeverities(checks, policy) { - return checks.map((check) => { - const severity = checkSeverityFor(policy, check.id, DEFAULT_CHECK_SEVERITIES[check.id] ?? 'enforce'); +/** The check ids the ACTIVE PLAN gates on: everything in + * `verification.required` plus every id mapped under `verification.criteria`. + * A policy may not downgrade any of them to advisory (policy.mjs). */ +function planGatedCheckIds(plan) { + const verification = plan?.fm?.verification; + const ids = new Set(); + for (const name of Array.isArray(verification?.required) ? verification.required : []) { + if (typeof name === 'string' && name) ids.add(name); + } + const criteria = verification?.criteria; + if (criteria && typeof criteria === 'object' && !Array.isArray(criteria)) { + for (const mapped of Object.values(criteria)) { + for (const name of Array.isArray(mapped) ? mapped : []) { + if (typeof name === 'string' && name) ids.add(name); + } + } + } + return ids; +} + +/** Apply policy severities, refusing any advisory downgrade of a plan-gated + * check. Returns the refusals alongside the checks so the run can report them + * instead of silently disagreeing with the policy file. */ +function applyCheckSeverities(checks, policy, planGated) { + const refusedSeverityDowngrades = []; + const applied = checks.map((check) => { + const fallback = DEFAULT_CHECK_SEVERITIES[check.id] ?? 'enforce'; + const severity = checkSeverityFor(policy, check.id, fallback, planGated); + if (severity !== 'advisory' && checkSeverityFor(policy, check.id, fallback) === 'advisory') { + refusedSeverityDowngrades.push({ id: check.id, requested: 'advisory', effective: severity }); + } // `optional` is the existing ledger-rendering hook: advisory rows render // as warn, never error, without touching the style pipeline. return severity === 'advisory' ? { ...check, severity, optional: true } : { ...check, severity }; }); + return { checks: applied, refusedSeverityDowngrades }; } -// Advisory findings carry CURRENT-SIDE REPO TEXT (structural/expectations.mjs -// derives its symbol names from a lexical extractor whose per-language -// patterns are not length-bounded — a `.tf` string literal spanning newlines -// can produce a six-figure-byte "symbol name"), and they are copied verbatim -// into `.harness/evidence/*.json` and `verify --json`. Every other surface -// that renders less-trusted repo-derived text redacts it, flattens control -// characters, and caps it; the evidence lane must do the same at the point it -// copies the payload, so the guarantee holds no matter which check produced -// the findings. -const ADVISORY_TEXT_CAP = 240; -const ADVISORY_LIST_CAP = 20; -const ADVISORY_FINDINGS_CAP = 50; -const ADVISORY_DEPTH_CAP = 3; - -function advisoryText(value) { - return inertLine(redactSecrets(String(value ?? ''))).slice(0, ADVISORY_TEXT_CAP); +// Check messages and findings carry CURRENT-SIDE REPO TEXT (structural/ +// expectations.mjs derives its symbol names from a lexical extractor whose +// per-language patterns are not length-bounded — a `.tf` string literal +// spanning newlines can produce a six-figure-byte "symbol name" — and echoes +// plan-declared expectations back verbatim), and the CANONICAL `result.checks` +// array is what `.harness/evidence/*.json`, `verify --json`, and the event log +// all serialize. Sanitizing only the advisory summary copy left every one of +// those surfaces shipping the raw text. Every other surface that renders +// less-trusted repo-derived text redacts it, flattens control characters, and +// caps it; the shipped check payload must do the same, at the one boundary +// (finalize) every consumer reads from. +const CHECK_TEXT_CAP = 240; +const CHECK_LIST_CAP = 20; +const CHECK_FINDINGS_CAP = 50; +const CHECK_DEPTH_CAP = 3; + +// The free-text LIST payloads a check can carry (`message` is handled on its +// own below). `id`/`status`/`severity`/`optional`/`exitCode`/`durationMs` are +// code-set tokens, enums, or numbers — never credential carriers — and +// `stdout`/`stderr` are the trusted named command's own output, already +// length-bounded by trimOutput and deliberately left multi-line so a failing +// check stays readable. +const SANITIZED_CHECK_LISTS = ['findings', 'informational']; + +function checkText(value) { + return inertLine(redactSecrets(String(value ?? ''))).slice(0, CHECK_TEXT_CAP); } /** Redact + flatten + cap every string reachable in a finding, bound every - * array/object to ADVISORY_LIST_CAP entries, and stop at ADVISORY_DEPTH_CAP — + * array/object to CHECK_LIST_CAP entries, and stop at CHECK_DEPTH_CAP — * shape-agnostic, so a check that grows a new findings field is covered * without this function knowing about it. */ -function advisoryValue(value, depth = 0) { - if (typeof value === 'string') return advisoryText(value); +function checkValue(value, depth = 0) { + if (typeof value === 'string') return checkText(value); if (typeof value === 'number' || typeof value === 'boolean' || value === null) return value; - if (depth >= ADVISORY_DEPTH_CAP) return null; - if (Array.isArray(value)) return value.slice(0, ADVISORY_LIST_CAP).map((entry) => advisoryValue(entry, depth + 1)); + if (depth >= CHECK_DEPTH_CAP) return null; + if (Array.isArray(value)) return value.slice(0, CHECK_LIST_CAP).map((entry) => checkValue(entry, depth + 1)); if (value && typeof value === 'object') { const out = {}; - for (const [key, entry] of Object.entries(value).slice(0, ADVISORY_LIST_CAP)) { - out[advisoryText(key)] = advisoryValue(entry, depth + 1); + for (const [key, entry] of Object.entries(value).slice(0, CHECK_LIST_CAP)) { + out[checkText(key)] = checkValue(entry, depth + 1); } return out; } return null; } +/** Sanitize one check's shipped payload. Idempotent: applied at finalize and + * again (harmlessly) by collectAdvisoryFailures on the same objects. */ +export function sanitizeCheckPayload(check) { + const sanitized = { ...check }; + if (check.message !== undefined) sanitized.message = checkText(check.message); + for (const field of SANITIZED_CHECK_LISTS) { + if (check[field] === undefined) continue; + sanitized[field] = (Array.isArray(check[field]) ? check[field] : []).slice(0, CHECK_FINDINGS_CAP).map((entry) => checkValue(entry)); + } + return sanitized; +} + export function collectAdvisoryFailures(checks) { return checks .filter((check) => check.severity === 'advisory' && !['passed', 'skipped'].includes(check.status)) .map((check) => ({ id: check.id, status: check.status, - message: advisoryText(check.message), + message: checkText(check.message), ...(check.findings - ? { findings: (Array.isArray(check.findings) ? check.findings : []).slice(0, ADVISORY_FINDINGS_CAP).map((f) => advisoryValue(f)) } + ? { findings: (Array.isArray(check.findings) ? check.findings : []).slice(0, CHECK_FINDINGS_CAP).map((f) => checkValue(f)) } : {}), })); } @@ -175,12 +226,16 @@ function currentPhaseTasks(taskBody, phase) { function finalize(workspace, flags, partial) { const policy = loadPolicy(workspace, flags.enforcement); - const checks = applyCheckSeverities(partial.checks, policy); + const severities = applyCheckSeverities(partial.checks, policy, partial.planGatedChecks || new Set()); + // The single boundary every consumer reads from: evidence, `--json`, the + // event log, and the ledger all serialize this array. + const checks = severities.checks.map(sanitizeCheckPayload); const result = { outcome: partial.outcome || resolveOutcome(checks), plan: partial.plan || null, checks, advisoryFailures: collectAdvisoryFailures(checks), + refusedSeverityDowngrades: severities.refusedSeverityDowngrades, unverifiedCriteria: partial.unverifiedCriteria || [], scopeViolations: partial.scopeViolations || [], openHardGaps: partial.openHardGaps || [], @@ -341,6 +396,7 @@ export function runVerify({ workspace, flags }) { return finalize(workspace, flags, { plan: plan.path, checks, + planGatedChecks: planGatedCheckIds(plan), unverifiedCriteria, scopeViolations: scope.violations, openHardGaps, diff --git a/packages/harness/test/recall-secret-data-boundary.test.mjs b/packages/harness/test/recall-secret-data-boundary.test.mjs index 2a15d49e..5ad4fd0c 100644 --- a/packages/harness/test/recall-secret-data-boundary.test.mjs +++ b/packages/harness/test/recall-secret-data-boundary.test.mjs @@ -6,9 +6,12 @@ import { test } from 'node:test'; import { runRecall } from '../lib/recall-cmd.mjs'; import { runOrient } from '../lib/orient.mjs'; import { redactRecallEntry } from '../lib/secret-scan.mjs'; +import { rankLearnings } from '../lib/knowledge/retrieve.mjs'; +import { buildContextPack } from '../lib/context-pack.mjs'; +import { ensureStore, storeDir, serializeLearning } from '../lib/knowledge/store.mjs'; /** - * Data-boundary secret redaction for recall results. + * Data-boundary secret redaction for retrieved memory. * * Two reproduced leaks the render-boundary-only fix left open: * 1. `path` and `docid` were never redacted — a manifest entry with a @@ -21,6 +24,13 @@ import { redactRecallEntry } from '../lib/secret-scan.mjs'; * * The fix redacts at the DATA boundary (where the recall objects are built), * so BOTH the pack render AND every `--json` emit carry redacted fields. + * + * Third leak, same trust class, one section higher in the SAME pack: the + * `## Learnings (memory)` bullets rendered `inertLine(trigger) → inertLine(claim)` + * with NO redactSecrets, while the `## Recall` bullets right below them used + * `inertLine(redactSecrets(...))`. Learning content is hand-editable and human + * authority deliberately overrides the secret screen for hand edits + * (hand-edits.test.mjs), so a matching query surfaced the stored key verbatim. */ const AWS = 'AKIAIOSFODNN7EXAMPLE'; // canonical \bAKIA[0-9A-Z]{16}\b shape @@ -140,3 +150,74 @@ test('redactRecallEntry redacts docid/path/title/summary/snippet but never struc const normal = { docid: 'a', path: 'docs/solutions/perf/x.md', title: 't', summary: 's', snippet: 'n' }; assert.deepEqual(redactRecallEntry(normal), normal, 'a normal entry is unchanged'); }); + +// Finding 3 — the learnings section of the same pack. ------------------------- + +/** A hand-edited learning store: written straight to disk, bypassing the write + * path's secret screen exactly as a human hand edit does (human authority + * overrides the cap/scan there by design). */ +function seedLearningStore({ trigger, claim }) { + const workspace = tmp('learn-secret-ws-'); + const harnessHome = tmp('learn-secret-hh-'); + const { dir } = ensureStore(workspace, { home: harnessHome }); + fs.mkdirSync(path.join(dir, 'learnings', 'sql'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'learnings', 'sql', 'orders-timeout.md'), + serializeLearning( + { + trigger, + status: 'active', + source: 'human', + episodes: [], + anchors: [], + superseded_by: null, + last_confirmed: null, + origin: 'hand-edit', + }, + claim + ), + 'utf8' + ); + assert.equal(storeDir(workspace, { home: harnessHome }), dir); + return { workspace, harnessHome }; +} + +test('a secret-shaped learning trigger or claim is redacted in the ranked result and the rendered pack', () => { + for (const field of ['trigger', 'claim']) { + const { workspace, harnessHome } = seedLearningStore({ + trigger: field === 'trigger' ? `orders timeout ${AWS}` : 'orders timeout on retry', + claim: field === 'claim' ? `Rotate ${AWS} before shipping.` : 'Retry with a bounded backoff.', + }); + + const ranked = rankLearnings({ workspace, query: 'orders timeout', limit: 3, home: harnessHome }); + assert.equal(ranked.length, 1, `the learning is surfaced for the ${field} case`); + + // FAIL-BEFORE: the raw key rode through the ranked object into orient --json. + const serialized = JSON.stringify(ranked); + assert.ok(!serialized.includes(AWS), `no raw key in the ranked ${field}`); + assert.match(serialized, /\[redacted:/, `a redaction marker replaces the ${field}`); + + // …and through the pack the model actually reads. + const pack = buildContextPack({ + query: 'orders timeout', + recall: [], + learnings: ranked, + plans: [], + gatePreview: { pass: true }, + nextTools: [], + }); + assert.ok(!pack.includes(AWS), `no raw key in the rendered pack for the ${field}`); + assert.match(pack, /\[redacted:/, `the pack bullet shows the redaction marker for the ${field}`); + } +}); + +test('a clean learning is surfaced byte-for-byte unchanged (no false-positive damage)', () => { + const { workspace, harnessHome } = seedLearningStore({ + trigger: 'orders timeout on retry', + claim: 'Retry with a bounded backoff.', + }); + + const [ranked] = rankLearnings({ workspace, query: 'orders timeout', limit: 3, home: harnessHome }); + assert.equal(ranked.trigger, 'orders timeout on retry'); + assert.equal(ranked.claimLine, 'Retry with a bounded backoff.'); +}); diff --git a/packages/harness/test/verify-severity-hardening.test.mjs b/packages/harness/test/verify-severity-hardening.test.mjs index f0125277..84243178 100644 --- a/packages/harness/test/verify-severity-hardening.test.mjs +++ b/packages/harness/test/verify-severity-hardening.test.mjs @@ -2,11 +2,15 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { test } from 'node:test'; import { loadPolicy, NON_ADVISORY_CHECK_IDS } from '../lib/policy.mjs'; -import { collectAdvisoryFailures } from '../lib/verify.mjs'; +import { collectAdvisoryFailures, runVerify, sanitizeCheckPayload } from '../lib/verify.mjs'; +import { readEvidence } from '../lib/evidence.mjs'; import { STRUCTURAL_CHECK_ID } from '../lib/structural/expectations.mjs'; +import { buildStructuralIndex } from '../lib/repo-map/structural-index.mjs'; +import { lexicalV2 } from '../lib/repo-map/treesitter-extractor.mjs'; /** * E — `advisory` is not a severity for a gating check. resolveOutcome @@ -14,16 +18,24 @@ import { STRUCTURAL_CHECK_ID } from '../lib/structural/expectations.mjs'; * `scope` (or criteria/plan/review/gap checks) would write `outcome: passed` * into the evidence artifact `harness gate` and `harness compound` trust: the * gate opens on a real scope violation AND a "verified" fix episode is minted - * from a run that never verified. + * from a run that never verified. The same reasoning reaches PROJECT-DEFINED + * named checks the moment the ACTIVE PLAN gates on them: a failed check listed + * in `verification.required` must never be filtered out of the outcome either. * - * G — advisory findings carry current-side repo text (a lexical extractor's - * unbounded symbol names) and are copied verbatim into `.harness/evidence/*.json` - * and `verify --json`. They must be redacted, flattened, and capped there. + * G — check findings carry current-side repo text (plan-declared symbol names, + * a lexical extractor's unbounded ones) and are copied into + * `.harness/evidence/*.json`, `verify --json`, and the event log. They must be + * redacted, flattened, and capped THERE — on the canonical payload that ships, + * not only on the advisory summary copy. */ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const tempDir = (p) => fs.mkdtempSync(path.join(os.tmpdir(), p)); +const AWS_KEY = 'AKIAIOSFODNN7EXAMPLE'; // canonical \bAKIA[0-9A-Z]{16}\b shape +const CTRL = '\u0001'; // a raw control char, as a hostile symbol/expectation can carry +const CTRL_IN_JSON = '\\u0001'; // …and how JSON.stringify renders it if it survives + function policyWorkspace(yaml) { const ws = tempDir('vsh-ws-'); const full = path.join(ws, '.github', 'harness', 'policy.yaml'); @@ -32,6 +44,144 @@ function policyWorkspace(yaml) { return ws; } +// --- end-to-end verify fixture (a real plan, real checks, a real git repo) --- + +function git(workspace, args) { + const result = spawnSync('git', args, { + cwd: workspace, + encoding: 'utf8', + env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' }, + }); + assert.equal(result.status, 0, `git ${args.join(' ')}: ${result.stderr}`); + return result.stdout.trim(); +} + +function writeConfig(workspace, name, body) { + const dir = path.join(workspace, '.github', 'harness'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, name), body, 'utf8'); +} + +function writeChecks(workspace, checks) { + const body = Object.entries(checks) + .map(([name, check]) => ` ${name}:\n command: ${JSON.stringify(check.command)}`) + .join('\n'); + writeConfig(workspace, 'checks.yaml', `version: 1\nchecks:\n${body}\n`); +} + +/** A plan that passes every gating check on its own, so a single deliberately + * broken input is the only thing that can move the outcome. */ +function writeVerifiablePlan(workspace, { required = ['unit-tests'], criteria = { AC1: ['unit-tests'] }, extraFrontmatter = '' } = {}) { + const rel = 'docs/plans/2026-08-06-feat-severity-plan.md'; + fs.mkdirSync(path.join(workspace, 'docs', 'plans'), { recursive: true }); + const criterionYaml = Object.entries(criteria) + .map(([id, checks]) => ` ${id}: ${JSON.stringify(checks)}`) + .join('\n'); + fs.writeFileSync( + path.join(workspace, rel), + `--- +plan_schema: 1 +title: "Severity example" +type: feat +status: in-progress +plan_lock: true +phase: 1 +risk: green +intent: "Verify severity handling" +expected_outputs: + - "verified change" +success_criteria: + - "AC1 Example works" +verification: + required: ${JSON.stringify(required)} + criteria: +${criterionYaml} +reviews: + required: [] + completed: [] + critical_open: [] +capability_gaps: [] +skills_used: ["engineer"] +${extraFrontmatter}--- + +# Severity example + +## Overview + +Verify the example. + +## Intent Contract + +- **Goal:** Verify severity handling. +- **Expected outputs:** verified change. +- **Success criteria:** AC1 passes. + +## Acceptance Criteria + +- [x] **AC1** Example works. + +## Plan + +### Phase 1 — Implement + +- [x] Implement the example. + +## Impacted Files + +- \`src/example.js\` + +## Technical Notes + +No additional technical notes. + +## Verification Plan + +Run trusted named checks. + +## Risk & Review Routing + +No required specialist review. + +## Review Findings + +No open findings. + +## Activity + +- Work recorded. +`, + 'utf8' + ); + return rel; +} + +function verifiableWorkspace({ required, criteria, extraFrontmatter, checks, policy } = {}) { + const workspace = tempDir('vsh-verify-'); + const home = tempDir('vsh-home-'); + git(workspace, ['init', '-q']); + git(workspace, ['config', 'user.email', 'harness@example.test']); + git(workspace, ['config', 'user.name', 'Harness Test']); + fs.mkdirSync(path.join(workspace, 'src'), { recursive: true }); + fs.writeFileSync(path.join(workspace, 'src', 'example.js'), 'export const value = 1;\nexport function helper() { return value; }\n'); + const plan = writeVerifiablePlan(workspace, { required, criteria, extraFrontmatter }); + writeChecks(workspace, checks || { 'unit-tests': { command: [process.execPath, '-e', 'process.exit(0)'] } }); + if (policy) writeConfig(workspace, 'policy.yaml', policy); + git(workspace, ['add', '.']); + git(workspace, ['commit', '-qm', 'baseline']); + return { workspace, home, plan }; +} + +function withHome(home, fn) { + const previous = process.env.HARNESS_HOME; + process.env.HARNESS_HOME = home; + try { + return fn(); + } finally { + if (previous === undefined) delete process.env.HARNESS_HOME; + else process.env.HARNESS_HOME = previous; + } +} + test('E: a policy downgrading a gating check to advisory is rejected by name', () => { for (const id of ['scope', 'criteria-evidence', 'plan-schema', 'plan-readiness', 'required-reviews', 'hard-gaps', 'critical-findings', 'workspace-stability']) { const ws = policyWorkspace(`version: 2\nenforcement: enforce\nchecks:\n ${id}:\n severity: advisory\n`); @@ -95,53 +245,220 @@ test('E: every built-in check verify.mjs pushes is either non-downgradable or ad } }); -test('G: advisory findings are redacted, flattened to one line, and capped before reaching the evidence payload', () => { - const huge = 'x'.repeat(200_000); - const [failure] = collectAdvisoryFailures([ - { - id: STRUCTURAL_CHECK_ID, - status: 'failed', - severity: 'advisory', - message: `2 structural findings\nAKIAIOSFODNN7EXAMPLE`, - findings: [ - { - type: 'unplanned-symbol-change', - file: 'infra/main.tf', - added: [`${huge}\nfake heading`, 'AKIAIOSFODNN7EXAMPLE'], - removed: [], - }, - { type: 'removed-symbol-with-callers', file: 'a.ts', symbol: 'foo', callers: ['b.ts'] }, - ], +// E (end to end) — the static id list only protects BUILT-IN checks. A +// project-defined named check becomes gating the moment the ACTIVE PLAN lists +// it under `verification.required`, and a policy `severity: advisory` used to +// filter its failure straight out of `resolveOutcome` — evidence `passed`, +// gate open, `compound` free to mint a verified episode from a failed run. +test('E: a failed plan-required check cannot be downgraded to advisory — the run does not pass', () => { + const { workspace, home, plan } = verifiableWorkspace({ + // `team-lint` is required but is NOT the sole check mapped to AC1, so the + // criteria-evidence check stays green and the outcome hinges on severity. + required: ['unit-tests', 'team-lint'], + criteria: { AC1: ['unit-tests'] }, + checks: { + 'unit-tests': { command: [process.execPath, '-e', 'process.exit(0)'] }, + 'team-lint': { command: [process.execPath, '-e', 'process.exit(1)'] }, }, + policy: 'version: 2\nenforcement: enforce\nchecks:\n team-lint:\n severity: advisory\n', + }); + + const result = withHome(home, () => runVerify({ workspace, flags: { plan, base: 'HEAD', dryRun: false } })); + + const teamLint = result.checks.find((check) => check.id === 'team-lint'); + assert.equal(teamLint.status, 'failed', JSON.stringify(result.checks, null, 2)); + assert.notEqual(teamLint.severity, 'advisory', 'a plan-required check is never advisory'); + assert.notEqual(teamLint.optional, true); + + // FAIL-BEFORE: `passed`, with the failure hidden in advisoryFailures. + assert.equal(result.outcome, 'failed', JSON.stringify(result.checks, null, 2)); + assert.deepEqual(result.advisoryFailures, [], 'the failure is a real gating failure, not an advisory note'); + + // The refusal is recorded loudly rather than applied silently. + assert.deepEqual(result.refusedSeverityDowngrades, [ + { id: 'team-lint', requested: 'advisory', effective: 'enforce' }, ]); - assert.equal(failure.id, STRUCTURAL_CHECK_ID); - const serialized = JSON.stringify(failure); - assert.ok(serialized.length < 5_000, `advisory payload must be bounded, got ${serialized.length} bytes`); - assert.ok(!serialized.includes('AKIAIOSFODNN7EXAMPLE'), 'secret-shaped repo text is redacted'); - assert.ok(!failure.message.includes('\n'), 'the message renders as one line'); - assert.equal(failure.findings[0].added[0].length, 240, 'an unbounded extracted symbol is capped'); - assert.ok(!failure.findings[0].added[0].includes('\n'), 'and flattened'); - assert.equal(failure.findings[1].symbol, 'foo', 'well-formed findings pass through intact'); - assert.deepEqual(failure.findings[1].callers, ['b.ts']); + // And the artifact `harness gate`/`harness compound` trust agrees. + const evidence = readEvidence(workspace, plan); + assert.equal(evidence.outcome, 'failed'); + assert.equal(evidence.checks.find((check) => check.id === 'team-lint').severity, 'enforce'); +}); + +test('E: a check mapped under verification.criteria is protected the same way', () => { + const { workspace, home, plan } = verifiableWorkspace({ + required: ['unit-tests', 'team-lint'], + criteria: { AC1: ['unit-tests', 'team-lint'] }, + checks: { + 'unit-tests': { command: [process.execPath, '-e', 'process.exit(0)'] }, + 'team-lint': { command: [process.execPath, '-e', 'process.exit(1)'] }, + }, + policy: 'version: 2\nenforcement: enforce\nchecks:\n team-lint:\n severity: advisory\n', + }); + + const result = withHome(home, () => runVerify({ workspace, flags: { plan, base: 'HEAD', dryRun: false } })); + + assert.equal(result.checks.find((check) => check.id === 'team-lint').severity, 'enforce'); + assert.equal(result.outcome, 'failed'); +}); + +test('E: a project-defined check the plan does NOT gate on stays freely downgradable, and warn still degrades', () => { + // Same failing command, but nothing in the plan requires it: the team keeps + // its own advisory checks, which is what the static-id-list rule intended. + const advisory = verifiableWorkspace({ + required: ['unit-tests'], + criteria: { AC1: ['unit-tests'] }, + checks: { 'unit-tests': { command: [process.execPath, '-e', 'process.exit(0)'] } }, + policy: `version: 2\nenforcement: enforce\nchecks:\n ${STRUCTURAL_CHECK_ID}:\n severity: advisory\n`, + }); + const advisoryResult = withHome(advisory.home, () => + runVerify({ workspace: advisory.workspace, flags: { plan: advisory.plan, base: 'HEAD', dryRun: false } }) + ); + assert.equal(advisoryResult.outcome, 'passed', JSON.stringify(advisoryResult.checks, null, 2)); + assert.equal(advisoryResult.checks.find((check) => check.id === STRUCTURAL_CHECK_ID).severity, 'advisory'); + assert.deepEqual(advisoryResult.refusedSeverityDowngrades, []); + + // `warn` is still available for a plan-required check — it degrades the + // failure to inconclusive (a non-zero exit under enforce), it does not erase it. + const warned = verifiableWorkspace({ + required: ['unit-tests', 'team-lint'], + criteria: { AC1: ['unit-tests'] }, + checks: { + 'unit-tests': { command: [process.execPath, '-e', 'process.exit(0)'] }, + 'team-lint': { command: [process.execPath, '-e', 'process.exit(1)'] }, + }, + policy: 'version: 2\nenforcement: enforce\nchecks:\n team-lint:\n severity: warn\n', + }); + const warnedResult = withHome(warned.home, () => + runVerify({ workspace: warned.workspace, flags: { plan: warned.plan, base: 'HEAD', dryRun: false } }) + ); + assert.equal(warnedResult.checks.find((check) => check.id === 'team-lint').severity, 'warn'); + assert.equal(warnedResult.outcome, 'inconclusive'); + assert.deepEqual(warnedResult.refusedSeverityDowngrades, []); }); -test('G: the advisory payload bounds the number of findings and the size of every nested list', () => { - const [failure] = collectAdvisoryFailures([ - { - id: STRUCTURAL_CHECK_ID, - status: 'failed', - severity: 'advisory', - message: 'many findings', - findings: Array.from({ length: 500 }, (_, i) => ({ +// The hostile check the two payload tests below share: an unbounded, +// newline-bearing symbol name plus a secret-shaped one, exactly the shape a +// lexical extractor can hand back from current-side repo text. +function hostileCheck() { + const huge = 'x'.repeat(200_000); + return { + id: STRUCTURAL_CHECK_ID, + status: 'failed', + severity: 'advisory', + message: `2 structural findings\n${AWS_KEY}`, + findings: [ + { type: 'unplanned-symbol-change', - file: `f${i}.ts`, - added: Array.from({ length: 500 }, (_, j) => `sym-${j}`), - })), + file: 'infra/main.tf', + added: [`${huge}\nfake heading`, AWS_KEY], + removed: [], + }, + { type: 'removed-symbol-with-callers', file: 'a.ts', symbol: 'foo', callers: ['b.ts'] }, + ], + informational: [{ type: 'tier-mismatch-skipped', file: 'a.ts', message: `skipped ${AWS_KEY}` }], + }; +} + +// Both the CANONICAL check payload (what evidence, `--json`, and the event log +// serialize) and the advisory summary copy must be sanitized. Sanitizing only +// the copy left the shipped artifact carrying the raw text. +for (const [surface, sanitize] of [ + ['the canonical check payload', (check) => sanitizeCheckPayload(check)], + ['the advisory summary copy', (check) => collectAdvisoryFailures([check])[0]], +]) { + test(`G: ${surface} is redacted, flattened to one line, and capped`, () => { + const payload = sanitize(hostileCheck()); + + assert.equal(payload.id, STRUCTURAL_CHECK_ID); + const serialized = JSON.stringify(payload); + assert.ok(serialized.length < 5_000, `payload must be bounded, got ${serialized.length} bytes`); + assert.ok(!serialized.includes(AWS_KEY), 'secret-shaped repo text is redacted'); + assert.ok(!payload.message.includes('\n'), 'the message renders as one line'); + assert.equal(payload.findings[0].added[0].length, 240, 'an unbounded extracted symbol is capped'); + assert.ok(!payload.findings[0].added[0].includes('\n'), 'and flattened'); + assert.equal(payload.findings[1].symbol, 'foo', 'well-formed findings pass through intact'); + assert.deepEqual(payload.findings[1].callers, ['b.ts']); + }); +} + +test('G: the canonical payload also sanitizes informational notes and keeps structural fields intact', () => { + const check = sanitizeCheckPayload(hostileCheck()); + assert.ok(!JSON.stringify(check.informational).includes(AWS_KEY), 'informational notes are redacted'); + assert.ok(!JSON.stringify(check.informational).includes(CTRL_IN_JSON), 'and flattened'); + assert.equal(check.status, 'failed', 'status is a code-set token, untouched'); + assert.equal(check.severity, 'advisory', 'severity is a code-set token, untouched'); +}); + +test('G: the shipped payload bounds the number of findings and the size of every nested list', () => { + const check = { + id: STRUCTURAL_CHECK_ID, + status: 'failed', + severity: 'advisory', + message: 'many findings', + findings: Array.from({ length: 500 }, (_, i) => ({ + type: 'unplanned-symbol-change', + file: `f${i}.ts`, + added: Array.from({ length: 500 }, (_, j) => `sym-${j}`), + })), + }; + for (const payload of [sanitizeCheckPayload(check), collectAdvisoryFailures([check])[0]]) { + assert.equal(payload.findings.length, 50, 'findings are capped'); + assert.equal(payload.findings[0].added.length, 20, 'nested lists are capped'); + } +}); + +// End to end: the guarantee is only worth anything on the artifact that ships. +test('G: hostile check text never reaches the on-disk evidence artifact or the --json result', async () => { + const { workspace, home, plan } = verifiableWorkspace({ + // A required expectation whose symbol name is secret-shaped (→ a finding) + // and a malformed entry carrying a control char (→ an informational note): + // both are attacker-influenceable repo text on a fork checkout. + extraFrontmatter: [ + 'structural_expectations:', + ' - file: "src/example.js"', + ` symbol: "${AWS_KEY}"`, + ' change: "removed"', + ' required: true', + ` - "\\x01malformed ${AWS_KEY}"`, + '', + ].join('\n'), + }); + // A real baseline index, built by the real builder, so the structural check + // actually runs instead of skipping. + await buildStructuralIndex({ + workspace, + home, + extractor: { + counters: { parseFailures: 0, parsed: 0, errorFiles: 0 }, + tier: 'lexical', + webTreeSitter: null, + grammarVersions: {}, + missingGrammars: [], + integrityFailures: [], + extract: (rel, content) => lexicalV2(rel, content), }, - ]); - assert.equal(failure.findings.length, 50, 'findings are capped'); - assert.equal(failure.findings[0].added.length, 20, 'nested lists are capped'); + }); + fs.writeFileSync(path.join(workspace, 'src', 'example.js'), 'export const value = 2;\nexport function helper() { return value; }\n'); + + const result = withHome(home, () => runVerify({ workspace, flags: { plan, base: 'HEAD', dryRun: false } })); + + const structural = result.checks.find((check) => check.id === STRUCTURAL_CHECK_ID); + assert.equal(structural.status, 'failed', JSON.stringify(structural, null, 2)); + assert.ok(structural.findings.length > 0, 'the hostile expectation produced a finding'); + // Advisory by default, so the run still passes — which is exactly why the + // leak was silent: a green run shipped the raw text. + assert.equal(result.outcome, 'passed', JSON.stringify(result.checks, null, 2)); + + // FAIL-BEFORE: both of these carried the raw key and the raw control char. + const asJson = JSON.stringify(result); + assert.ok(!asJson.includes(AWS_KEY), 'verify --json carries no raw secret-shaped repo text'); + assert.ok(!asJson.includes(CTRL_IN_JSON), 'verify --json carries no raw control characters'); + assert.match(asJson, /\[redacted:/, 'the redaction marker replaces it'); + + const onDisk = fs.readFileSync(path.join(workspace, result.evidencePath), 'utf8'); + assert.ok(!onDisk.includes(AWS_KEY), 'the evidence artifact carries no raw secret-shaped repo text'); + assert.ok(!onDisk.includes(CTRL_IN_JSON), 'the evidence artifact carries no raw control characters'); }); test('G: a passing or skipped advisory check contributes nothing, and non-advisory checks are never collected', () => { From a8eb26aa4a0cd58eb5bbf96e1cdcb43b5d87c131 Mon Sep 17 00:00:00 2001 From: Krish Date: Thu, 6 Aug 2026 14:03:02 -0400 Subject: [PATCH 17/24] ci: run the harness suite and contract checks on pull requests --- .github/workflows/harness-tests.yml | 55 +++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 .github/workflows/harness-tests.yml diff --git a/.github/workflows/harness-tests.yml b/.github/workflows/harness-tests.yml new file mode 100644 index 00000000..e2964577 --- /dev/null +++ b/.github/workflows/harness-tests.yml @@ -0,0 +1,55 @@ +# Runs the harness's own trusted checks on every pull request, by the SAME +# names a local run uses — so "green locally" and "green in CI" mean the same +# thing. Until this existed, every reported pass came from one developer's +# machine, and two consecutive review rounds each found a regression the +# previous round's fix had introduced. +name: Harness Tests + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Node 22: the lowest LTS the package's `engines: >=20` still admits + # (Node 20 left maintenance in April 2026). Single version on purpose — + # the suite is pure Node with one runtime dependency, so a matrix would + # buy little for double the compute. Add 24 here if that changes. + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: packages/harness/package-lock.json + + # optionalDependencies are installed (npm ci's default). The suite is + # green either way for the four NATIVE tree-sitter-* grammar packages — + # their cases skip when absent, and no native build is required because + # each ships prebuilt binaries. `web-tree-sitter` is the one optional dep + # that must actually be present (treesitter-extractor's "runtime + # integrity mismatch" case asserts on the runtime rather than skipping); + # it is pure WASM/JS with no install script, so it resolves everywhere. + # `npm ci` also runs the package's `prepare` script, which builds + # packages/harness/assets from the repo sources. + - name: Install harness dependencies + run: npm ci --prefix packages/harness + + - name: Harness test suite + run: npm --prefix packages/harness test + + - name: Prompt library contracts + run: node --test packages/harness/test/prompt-library-contracts.test.mjs + + - name: Build harness assets + run: node scripts/build-harness-assets.mjs From 1f1778712008db9801adb3160baa76a23a41d063 Mon Sep 17 00:00:00 2001 From: Krish Date: Thu, 6 Aug 2026 14:05:59 -0400 Subject: [PATCH 18/24] fix: redact secrets in learning listing output and honor the optional grammar contract --- packages/harness/lib/knowledge/listing.mjs | 7 ++++--- .../test/treesitter-extractor.test.mjs | 19 ++++++++++++++++++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/harness/lib/knowledge/listing.mjs b/packages/harness/lib/knowledge/listing.mjs index 92e56a5a..4e180294 100644 --- a/packages/harness/lib/knowledge/listing.mjs +++ b/packages/harness/lib/knowledge/listing.mjs @@ -1,5 +1,6 @@ import fs from 'node:fs'; import { storeDir, listLearnings, inertLine } from './store.mjs'; +import { redactSecrets } from '../secret-scan.mjs'; import { readEvents, EVENTS_MAX_LIMIT } from '../events.mjs'; import { verifiedAndPlans, isPromotionEligible, consolidateStatus } from './consolidate.mjs'; @@ -80,7 +81,7 @@ export function listingView({ workspace, copilotHome, domain, home }) { // inertLine: a legacy/hand-edited learning's trigger can still carry // an embedded control char (store.mjs's doc comment) — collapsed to // a space so this listing row always renders as one line. - trigger: inertLine(l.fm.trigger || ''), + trigger: inertLine(redactSecrets(l.fm.trigger || '')), verified, plans, // A promoted learning is never eligible for promotion again — its @@ -119,8 +120,8 @@ export function whyView({ workspace, id, home }) { id, // inertLine: same render-side normalization as listingView above — a // legacy/hand-edited trigger can still carry an embedded control char. - trigger: inertLine(fm.trigger || ''), - claimLine: inertLine(claimLine), + trigger: inertLine(redactSecrets(fm.trigger || '')), + claimLine: inertLine(redactSecrets(claimLine)), status: effectiveStatus(fm), source: fm.source || 'auto', lastConfirmed: fm.last_confirmed || null, diff --git a/packages/harness/test/treesitter-extractor.test.mjs b/packages/harness/test/treesitter-extractor.test.mjs index 9b1eacc0..8f9dbc33 100644 --- a/packages/harness/test/treesitter-extractor.test.mjs +++ b/packages/harness/test/treesitter-extractor.test.mjs @@ -305,7 +305,24 @@ test('integrity mismatch: corrupted grammar wasm is a LOUD lexical fallback, abs fs.rmSync(dir, { recursive: true, force: true }); }); -test('runtime integrity mismatch disables the whole tier loudly', async () => { +test('runtime integrity mismatch disables the whole tier loudly', async (t) => { + const lockForSkip = JSON.parse(fs.readFileSync(DEFAULT_LOCK_PATH, 'utf8')); + // The loader is resolved through Node module resolution (import.meta.resolve), + // NOT from grammarRoots — so with the optional dependency absent the tier is + // already lexical for a legitimate reason ("loader not installed") and records + // no integrity failure. Asserting the corrupt-runtime path there would break + // the optionalDependencies contract: the suite must pass with or without the + // grammars installed. + let loaderInstalled = true; + try { + loaderInstalled = Boolean(import.meta.resolve?.(lockForSkip.runtime.package)); + } catch { + loaderInstalled = false; + } + if (!loaderInstalled) { + t.skip(`${lockForSkip.runtime.package} not installed (optional) — no runtime to corrupt`); + return; + } const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-grammar-rt-')); const lock = JSON.parse(fs.readFileSync(DEFAULT_LOCK_PATH, 'utf8')); const rtDir = path.join(dir, lock.runtime.package); From b863171f842dad548cf16ed15967062364e27fe3 Mon Sep 17 00:00:00 2001 From: Krish Date: Thu, 6 Aug 2026 14:24:32 -0400 Subject: [PATCH 19/24] fix: redact learning listing output before truncation and cover episode refs --- packages/harness/lib/knowledge/listing.mjs | 16 ++- .../harness/test/listing-redaction.test.mjs | 110 ++++++++++++++++++ 2 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 packages/harness/test/listing-redaction.test.mjs diff --git a/packages/harness/lib/knowledge/listing.mjs b/packages/harness/lib/knowledge/listing.mjs index 4e180294..672fcc36 100644 --- a/packages/harness/lib/knowledge/listing.mjs +++ b/packages/harness/lib/knowledge/listing.mjs @@ -113,7 +113,10 @@ export function whyView({ workspace, id, home }) { const { fm, body } = learning; const { verified, plans } = verifiedAndPlans(fm); - const claimLine = (body.split('\n').find((line) => line.trim()) || '').trim().slice(0, 140); + // Redact BEFORE the cap (same order as retrieve.mjs's retrievedText): slicing + // first cuts a credential that straddles byte 140 into a fragment the secret + // scanner no longer matches, so the tail leaks unredacted. + const claimLine = inertLine(redactSecrets((body.split('\n').find((line) => line.trim()) || '').trim())).slice(0, 140); const failures = failureCounts(workspace).get(id) || 0; return { @@ -121,14 +124,21 @@ export function whyView({ workspace, id, home }) { // inertLine: same render-side normalization as listingView above — a // legacy/hand-edited trigger can still carry an embedded control char. trigger: inertLine(redactSecrets(fm.trigger || '')), - claimLine: inertLine(redactSecrets(claimLine)), + claimLine, status: effectiveStatus(fm), source: fm.source || 'auto', lastConfirmed: fm.last_confirmed || null, supersededBy: fm.superseded_by || null, promotedTo: fm.promoted_to || null, mergedFrom: parseMergedFrom(fm.merged_from), - episodes: (fm.episodes || []).map((e) => ({ path: e.path, kind: e.kind, plan: e.plan || null })), + // Episode paths and plan refs come from learning frontmatter, which is + // hand-editable — same untrusted class as trigger/claim, so they get the + // same treatment rather than being emitted raw. + episodes: (fm.episodes || []).map((e) => ({ + path: inertLine(redactSecrets(String(e.path || ''))), + kind: e.kind, + plan: e.plan ? inertLine(redactSecrets(String(e.plan))) : null, + })), verified, plans, // Same guard as listingView: a promoted learning is never eligible for diff --git a/packages/harness/test/listing-redaction.test.mjs b/packages/harness/test/listing-redaction.test.mjs new file mode 100644 index 00000000..84366b8b --- /dev/null +++ b/packages/harness/test/listing-redaction.test.mjs @@ -0,0 +1,110 @@ +// The listing surfaces (`harness learnings`, `harness learnings --why`) render +// learning frontmatter and body straight to a human and to `--json`. Learning +// content is hand-editable and human authority deliberately overrides the +// secret scan for hand edits (see hand-edits.test.mjs), so a credential CAN be +// sitting in a learning on disk. These surfaces must therefore redact — the +// same doctrine retrieve.mjs and context-pack.mjs already apply. +// +// The subtle case, and the reason redaction must happen BEFORE the 140-char +// cap: a credential that straddles the cap boundary gets sliced into a fragment +// the scanner no longer matches, so a slice-then-redact order leaks the head of +// the key while looking correct in every test that keeps secrets short. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; + +import { listingView, whyView } from '../lib/knowledge/listing.mjs'; + +const SECRET = 'AKIAIOSFODNN7EXAMPLE'; + +function makeStore(t) { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-listing-')); + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-listing-ws-')); + const env = { GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' }; + execFileSync('git', ['init', '-q'], { cwd: workspace, env: { ...process.env, ...env } }); + t.after(() => { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(workspace, { recursive: true, force: true }); + }); + return { home, workspace }; +} + +function writeLearning({ home, workspace }, { trigger, body, episodePath }) { + // Mirror the on-disk store layout directly: this test is about the RENDER + // path, so it must not depend on the writer's own validation refusing the + // content (which is exactly what a hand edit bypasses). + const { repoId } = { repoId: null }; + void repoId; + const storeRoot = path.join(home, 'knowledge'); + const dirs = fs.existsSync(storeRoot) ? fs.readdirSync(storeRoot) : []; + let dir = dirs.length ? path.join(storeRoot, dirs[0]) : null; + if (!dir) { + // Let the store module derive its own id by asking it for the path. + dir = null; + } + return { dir, trigger, body, episodePath }; +} + +test('listing and why redact secrets in trigger, claim, and episode refs', async (t) => { + const { home, workspace } = makeStore(t); + const { storeDir } = await import('../lib/knowledge/store.mjs'); + const dir = storeDir(workspace, { home }); + fs.mkdirSync(path.join(dir, 'learnings', 'sql'), { recursive: true }); + + // A claim line whose credential STRADDLES the 140-char cap — the case a + // slice-then-redact implementation leaks. + const filler = 'x'.repeat(130); + const claim = `${filler} ${SECRET} trailing words`; + assert.ok(claim.indexOf(SECRET) < 140 && claim.indexOf(SECRET) + SECRET.length > 140, 'fixture must straddle the cap'); + + fs.writeFileSync( + path.join(dir, 'learnings', 'sql', 'leaky.md'), + [ + '---', + 'schema: 1', + `trigger: "timeout with ${SECRET}"`, + 'status: active', + 'source: human', + 'episodes:', + ` - path: docs/solutions/${SECRET}.md`, + ' kind: fix', + ` plan: docs/plans/${SECRET}-plan.md`, + 'origin: test', + '---', + '', + claim, + '', + ].join('\n'), + 'utf8' + ); + + const listing = listingView({ workspace, home }); + const row = listing.learnings.find((l) => l.id === 'sql/leaky'); + assert.ok(row, 'learning is listed'); + assert.ok(!row.trigger.includes(SECRET), `listing trigger leaked the key: ${row.trigger}`); + + const why = whyView({ workspace, id: 'sql/leaky', home }); + assert.ok(why, 'why view resolves'); + assert.ok(!why.trigger.includes(SECRET), 'why trigger leaked the key'); + assert.ok(!why.claimLine.includes(SECRET), `why claimLine leaked the key: ${why.claimLine}`); + // The straddling case: no PREFIX of the key may survive either. With this + // fixture exactly 9 characters of the key fall inside the cap, so assert on + // that length — a longer prefix would vacuously pass against the + // slice-then-redact bug this test exists to catch. + const survivingPrefix = SECRET.slice(0, 140 - claim.indexOf(SECRET)); + assert.equal(survivingPrefix.length, 9, 'fixture arithmetic: 9 chars of the key fall inside the cap'); + assert.ok( + !why.claimLine.includes(survivingPrefix), + `why claimLine leaked a credential fragment across the cap: ${why.claimLine}` + ); + assert.ok(why.claimLine.length <= 140, 'claim line still respects the cap'); + + const ep = why.episodes[0]; + assert.ok(!ep.path.includes(SECRET), `episode path leaked the key: ${ep.path}`); + assert.ok(!ep.plan.includes(SECRET), `episode plan leaked the key: ${ep.plan}`); + assert.equal(ep.kind, 'fix', 'episode kind is a code-set token and is preserved'); +}); From ffe480eb3032939c4bd5e16c6981aefe67d30692 Mon Sep 17 00:00:00 2001 From: Krish Date: Thu, 6 Aug 2026 14:43:48 -0400 Subject: [PATCH 20/24] fix: close promotion-target, symlink, rejection side-effect, and crash-recovery gaps --- docs/MEMORY-MODEL.md | 34 ++- packages/harness/lib/knowledge/admin.mjs | 62 ++++- packages/harness/lib/knowledge/apply.mjs | 84 ++++++- packages/harness/lib/knowledge/store.mjs | 216 ++++++++++++++---- packages/harness/test/hand-edits.test.mjs | 45 ++++ .../harness/test/knowledge-promote.test.mjs | 77 +++++++ packages/harness/test/layer-routing.test.mjs | 54 +++++ .../harness/test/store-transaction.test.mjs | 93 ++++++++ 8 files changed, 597 insertions(+), 68 deletions(-) diff --git a/docs/MEMORY-MODEL.md b/docs/MEMORY-MODEL.md index faf572fc..4804f1e1 100644 --- a/docs/MEMORY-MODEL.md +++ b/docs/MEMORY-MODEL.md @@ -657,13 +657,26 @@ runs `git status --porcelain -uall` in the store first and commits any dirty edi - The absorbed content may exceed the 1,200-byte learning cap — human authority overrides the cap for hand edits (logged, not rejected; the cap binds only the sole writer's own ops). +- **A symlink at a learning path is never a learning.** `learnings//.md` (or + the bucket equivalent) planted as a SYMLINK is refused with a logged note, not followed — + on the read and on the canonical rewrite alike, both through the shared `fs-safe` + primitives. Following it would pull an arbitrary outside file into store history and a + workspace teaching snapshot, then overwrite that outside file with a serialized learning. - **Crash residue is not a hand edit.** Every store transaction writes an intent journal under the store's `.git/` before its first mutation and clears it on commit or rollback, so a writer killed mid-transaction leaves uncommitted state the next transaction can positively identify as CLI-authored: it is rolled back to the dead writer's last checkpoint (any intra-transaction commit it did land, such as an absorbed hand edit, survives) instead of being absorbed as human authority. Dirt found with no journal behind - it is a genuine hand edit and absorbs exactly as described above. + it is a genuine hand edit and absorbs exactly as described above. **The decision is + per-path**, not tree-wide: the journal records WHICH paths were already uncommitted when + it was written, and only paths dirty now that were not dirty then count as residue. A + tree-wide flag was too coarse — absorb deliberately ignores non-learning files + (`config.json`, `INDEX.md`, a scratch note), so one sitting uncommitted disarmed residue + rollback for the learning paths a later crash dirtied. **A journal that cannot be written + refuses the transaction** rather than running unmarked: the journal is the only thing that + tells residue from a hand edit, so proceeding without one is exactly the state whose + residue is later laundered into `source: human`. Use `harness remember` to add a new claim and `harness learning retire|dispute|confirm` to change a learning's status when a CLI command is more convenient than a direct edit — both @@ -713,6 +726,11 @@ The approved [Harness Evolution Blueprint](../knowledge/proposals/harness-evolut eligibility (`episodeEligibleForLayer`) trusts it as-is. A dishonest `branch:` can route an episode into the golden CANDIDATE set; what it cannot do is write golden — that still requires standing on the default branch, or the human-gated override above, or promotion. + A workspace checkout landing mid-transaction is refused with `E_HEAD_MOVED`, and that + refusal is **side-effect-free**: HEAD is re-validated once BEFORE the run materializes or + migrates any branch bucket, and the narrower write-time re-check discards everything the + transaction did back to its checkpoint — so "nothing was written" never leaves a stale + bucket behind for the finalize commit to publish. - **Governance is store-wide, so a branch lane never speaks for golden.** The governance ledger binds both layers, which means a branch-local write must not append a decision that resolves for golden. Two consequences: a re-teach landing in a BUCKET does not @@ -759,10 +777,16 @@ The approved [Harness Evolution Blueprint](../knowledge/proposals/harness-evolut the promotion-eligibility signal and the PROTECTED-target signal — i.e. an insight-only claim could launder itself into permanently protected golden knowledge. **A promotion op is bound to its source IDENTITY, not just its evidence.** Promotion - moves a claim between layers; it is not an authoring operation. The destination — an - ADD/SUPERSEDE's `domain/slug`, a STRENGTHEN's `target` — must equal the cited source id, - and the promoted claim's `trigger` and `body` are read from the verified source learning - rather than from the op. Without that binding a hand-authored, correctly-re-digested op + moves a claim between layers; it is not an authoring operation. A promotion op-set may + carry only `ADD`/`STRENGTHEN`/`SUPERSEDE` (an allow-list — `MERGE` consolidates several + claims and tombstones every one of them, which no promotion ever does), the destination — + an ADD/SUPERSEDE's `domain/slug`, a STRENGTHEN's `target` — must equal the cited source + id, EVERY other id the op names (`target`, any `targets[]`) must equal it too, and the + promoted claim's `trigger` and `body` are read from the verified source learning rather + than from the op. Binding the destination alone was not enough: `SUPERSEDE.target` names + a DIFFERENT learning and gets stamped `superseded_by`, so a correctly re-digested op could + promote the authentic source claim while tombstoning unrelated golden knowledge the + operator never chose to touch. Without that binding a hand-authored, correctly-re-digested op could cite one claim's verified identity (including its `source: human` standing, which the promoted claim inherits) while writing an entirely different, attacker-authored one, or graft one claim's verified-fix episodes onto an unrelated golden claim. A diff --git a/packages/harness/lib/knowledge/admin.mjs b/packages/harness/lib/knowledge/admin.mjs index 35c24d0b..40529566 100644 --- a/packages/harness/lib/knowledge/admin.mjs +++ b/packages/harness/lib/knowledge/admin.mjs @@ -27,7 +27,7 @@ import { rebuildIndex, todayClamped } from './apply.mjs'; import { consolidateStatus, LEARNING_BYTE_CAP, isActiveFm } from './consolidate.mjs'; import { listBuckets, branchesRoot, bucketDirFor } from './overlay.mjs'; import { scanSecrets } from '../secret-scan.mjs'; -import { assertNoSymlinkAncestors, assertRealpathContained, writeFileContained } from '../fs-safe.mjs'; +import { assertNoSymlinkAncestors, assertRealpathContained, writeFileContained, readFileNoFollow } from '../fs-safe.mjs'; import { runIndexKnowledge } from '../index-knowledge.mjs'; import { loadManifest } from '../recall-rank.mjs'; @@ -231,6 +231,14 @@ export function mirrorLearnings({ workspace, home, log = () => {}, retiredIds = * `-uall` is required for that: the default `-unormal` collapses a brand-new * `learnings//` into a single directory entry that matches no * learning path shape. + * + * A SYMLINK AT A LEARNING PATH IS NEVER ABSORBED. Every read and write in this + * loop goes through fs-safe.mjs (`assertNoSymlinkAncestors` + `readFileNoFollow` + * on the way in, `writeFileContained` on the way out), because a planted + * symlink is otherwise followed in BOTH directions — reading an arbitrary + * outside file into store history and a workspace teaching snapshot, then + * overwriting that outside file with a canonically serialized learning. Such a + * path is refused with a logged note, never followed. */ export function absorbHandEdits({ workspace, home, log = () => {} }) { const empty = { absorbed: [], deleted: [], committed: false }; @@ -286,12 +294,29 @@ export function absorbHandEdits({ workspace, home, log = () => {} }) { // stays out of absorb scope. if (code !== '??' && !code.includes('M')) continue; - const file = path.join(dir, rel); - let text; - try { - text = fs.readFileSync(file, 'utf8'); - } catch { - continue; // vanished between status and read — nothing to absorb + // A SYMLINK AT A LEARNING PATH IS NEVER A LEARNING (P1). `rel` comes + // straight from `git status` over a directory a human hand-edits, and this + // block both READS the path and later REWRITES it canonically — so a + // planted symlink was followed BOTH ways: the read pulled an arbitrary + // outside file's content into the absorb pipeline (snapshotted into the + // workspace, committed into store history) and the rewrite overwrote that + // outside file with a serialized learning. Refused here rather than + // followed, through the same fs-safe.mjs primitives every other writer in + // this module uses: the ancestor walk rejects a symlinked component (or + // leaf) up front, and `readFileNoFollow` re-verifies against the store root + // after acquiring the descriptor, closing the swap window the walk cannot. + const file = assertNoSymlinkAncestors(dir, rel); + if (!file) { + log(`hand-edit absorb: ${rel} is a symlink or sits under one — refused, never followed`); + continue; + } + const text = readFileNoFollow(file, { root: dir }); + if (text === null) { + // Vanished between status and read, swapped for a symlink since the walk + // above, over the read cap, or resolving outside the store — nothing + // safe to absorb either way. + log(`hand-edit absorb: ${rel} could not be read safely — skipped`); + continue; } const { fm, body } = parseLearningFrontmatter(text); @@ -312,6 +337,11 @@ export function absorbHandEdits({ workspace, home, log = () => {} }) { const doc = `---\n${fmLines.join('\n')}\n---\n\n${body.trim()}\n`; let snapshot = null; + // Staged, not yet recorded: the ledger entry only becomes real once the + // learning file itself has been rewritten successfully below. Pushing it + // eagerly would leave the ledger crediting a snapshot for an absorb that + // was then refused. + let ledgerEntry = null; const secrets = scanSecrets(doc); if (secrets.length) { log( @@ -340,8 +370,7 @@ export function absorbHandEdits({ workspace, home, log = () => {} }) { snapshot = snapRel.split(path.sep).join('/'); const sha256 = crypto.createHash('sha256').update(doc).digest('hex'); fm.episodes = [...(fm.episodes || []), { path: snapshot, sha256, kind: 'human-teaching', plan: null }]; - if (!ledgerByRoot.has(layerRoot)) ledgerByRoot.set(layerRoot, []); - ledgerByRoot.get(layerRoot).push({ path: snapshot, sha256, learning: id, at }); + ledgerEntry = { path: snapshot, sha256, learning: id, at }; } } @@ -353,7 +382,20 @@ export function absorbHandEdits({ workspace, home, log = () => {} }) { if (Buffer.byteLength(content, 'utf8') > LEARNING_BYTE_CAP) { log(`hand-edit absorb: ${id} exceeds ${LEARNING_BYTE_CAP} bytes after absorb — kept anyway (human authority)`); } - fs.writeFileSync(file, content, 'utf8'); + // The write half of the symlink guard above (fs-safe.mjs): contained and + // atomic, so an ancestor swapped for a symlink AFTER the pre-read walk + // still cannot steer this rewrite onto a file outside the store — the temp + // is created empty, containment-verified in place, filled through the + // verified descriptor, then renamed over the leaf (a rename replaces a + // symlink, it never follows one). + if (!writeFileContained(dir, rel, content)) { + log(`hand-edit absorb: refused to rewrite ${rel} — the path no longer resolves inside the knowledge store`); + continue; + } + if (ledgerEntry) { + if (!ledgerByRoot.has(layerRoot)) ledgerByRoot.set(layerRoot, []); + ledgerByRoot.get(layerRoot).push(ledgerEntry); + } absorbed.push({ id, snapshot }); if (bucketKey) touchedBucketRoots.add(layerRoot); } diff --git a/packages/harness/lib/knowledge/apply.mjs b/packages/harness/lib/knowledge/apply.mjs index aff52d96..d8986ce3 100644 --- a/packages/harness/lib/knowledge/apply.mjs +++ b/packages/harness/lib/knowledge/apply.mjs @@ -39,6 +39,17 @@ import { readFileNoFollow, assertNoSymlinkAncestors, assertRealpathContained } f */ const FILE_TOUCHING = new Set(['ADD', 'STRENGTHEN', 'SUPERSEDE', 'MERGE']); +/** + * The ONLY op kinds a promotion op-set may carry — an allow-list, deliberately + * narrower than FILE_TOUCHING. A promotion moves ONE claim between layers, so + * the three shapes the emitter produces (a fresh golden claim, more evidence on + * an existing one, a replacement of the golden claim it shadows) are the whole + * legal vocabulary. MERGE is not among them: it consolidates SEVERAL claims and + * tombstones every one of them, which is an authoring decision no promotion + * ever makes. Reusing FILE_TOUCHING here admitted MERGE by accident — a + * deny-list ("not a NOOP") where an allow-list was meant. + */ +const PROMOTION_OP_KINDS = new Set(['ADD', 'STRENGTHEN', 'SUPERSEDE']); const DISPUTED_FIX_THRESHOLD = 3; // Codes that indicate the CONTENT of a specific op was rejected (bad shape, // secret-shaped, imperative lint, over the byte cap, a dedup/rename collision @@ -890,7 +901,21 @@ export function applyOps({ * which propagates as a thrown exception instead and lets * withStoreTransaction perform the rollback. */ - function runOnce({ dir, git, recordCheckpoint = () => {} }) { + function runOnce({ dir, git, recordCheckpoint = () => {}, rollbackToCheckpoint = () => false }) { + // HEAD RE-VALIDATION BEFORE THE FIRST MUTATION (P1). Bucket + // materialization/migration below already WRITES to the store, so the + // write-time gate further down was no longer the "nothing has been written + // yet" point its own doc comment claims: a HEAD move caught there left a + // freshly created (or renamed) bucket behind for the transaction's finalize + // to commit, while the rejection reported that nothing was written. + // Checked here first — the one point in a real apply where the store + // genuinely is untouched, so this rejection is side-effect-free by + // construction rather than by cleanup. A preview mutates nothing at all and + // takes no lock, so it has no stale-snapshot window to close. + if (!dryRun) { + const movedEarly = assertHeadUnmoved(); + if (movedEarly) return movedEarly; + } // Layer root: every learning read/write, ledger entry, strike, and INDEX // rebuild below is anchored here — the store root for golden, the // branch bucket for a routed branch write. Governance stays store-rooted @@ -1169,12 +1194,14 @@ export function applyOps({ // (secret scan, imperative lint, renderLearning) reads what the source // actually says rather than what the ops file asserts. let op = parsed.ops[i]; - if (promotionMode && !FILE_TOUCHING.has(op.op)) { - // The emitter only ever produces ADD/STRENGTHEN/SUPERSEDE. A + if (promotionMode && !PROMOTION_OP_KINDS.has(op.op)) { + // The emitter only ever produces ADD/STRENGTHEN/SUPERSEDE, and this is + // an ALLOW-LIST of exactly those (see PROMOTION_OP_KINDS): a // hand-authored promotion envelope carrying a NOOP would otherwise // consume its episodes into the GOLDEN ledger from any branch — // promotion mode pins layerRoot to golden — clearing debt in a lane the - // run never had authority over. Same reasoning as the envelope gates + // run never had authority over, and one carrying a MERGE would + // tombstone every id in `targets`. Same reasoning as the envelope gates // above: the writer enforces the emitter's shape, it doesn't assume it. return { kind: 'reject', @@ -1297,6 +1324,37 @@ export function applyOps({ exitCode: 1, }; } + // EVERY ID THE OP NAMES IS BOUND, NOT JUST THE DESTINATION (P1). + // Binding the destination alone still left the TARGET fields — + // `SUPERSEDE.target`, and `targets[]` on any op that carries one — + // independently attacker-controlled: those name OTHER learnings, and a + // SUPERSEDE stamps `superseded_by` onto whatever they point at. So a + // correctly re-digested op could promote the authentic source claim + // into golden, passing every source binding above, while tombstoning + // unrelated golden claims the operator never chose to touch — a + // destructive write laundered through a legitimate-looking promotion. + // A promotion moves ONE claim, so every id it names must be that one. + const namedTargets = [ + ...(op.target !== undefined ? [op.target] : []), + ...(Array.isArray(op.targets) ? op.targets : []), + ]; + for (const t of namedTargets) { + if (t !== src.id) { + return { + kind: 'reject', + applied: [], + governed: [], + rejected: [ + fail( + 'E_SCHEMA', + `op ${i}: promotion target ${t || '(none)'} does not match source ${src.id} — a promotion moves one claim between layers, it never touches another` + ), + ], + committed: false, + exitCode: 1, + }; + } + } if (op.op !== 'STRENGTHEN') { op = { ...op, trigger: sourceLearning.fm.trigger || '', body: sourceLearning.body }; } @@ -1861,10 +1919,18 @@ export function applyOps({ } // Write-time HEAD re-validation (see assertHeadUnmoved): the last point - // before anything is written is the last point a stale routing/provenance - // snapshot can still be caught for free. + // before any LEARNING is written, closing the narrow window between the + // pre-mutation gate at the top of runOnce and here. Unlike that gate this + // one is NOT reached on a pristine store — bucket materialization/migration + // has already run — so the rejection explicitly discards everything this + // transaction did back to its checkpoint (any absorb/strike sub-commit + // survives). Without that, "nothing was written" was a false claim: the + // transaction's finalize commit published the stale bucket anyway. const moved = assertHeadUnmoved(); - if (moved) return moved; + if (moved) { + rollbackToCheckpoint(); + return moved; + } // Mutation phase. No manual try/catch + git reset here any more — a // throw from anywhere below propagates straight out of runOnce, out of @@ -2116,7 +2182,7 @@ export function applyOps({ mirrorLearnings({ workspace, home, log }); }, }, - ({ dir, git, recordCheckpoint }) => { + ({ dir, git, recordCheckpoint, rollbackToCheckpoint }) => { // The run's ONE git snapshot (routing + provenance), taken under the store // lock rather than before it, and re-validated at write time // (assertHeadUnmoved) — see deriveRouting's doc comment above. @@ -2153,7 +2219,7 @@ export function applyOps({ : `knowledge mode is ${freshMode} — run: harness knowledge on`; return { kind: 'reject', applied: [], governed: [], rejected: [{ code: 'E_MODE', reason }], committed: false, exitCode: 2 }; } - return runOnce({ dir, git, recordCheckpoint }); + return runOnce({ dir, git, recordCheckpoint, rollbackToCheckpoint }); }); if (!tx.ok) { diff --git a/packages/harness/lib/knowledge/store.mjs b/packages/harness/lib/knowledge/store.mjs index 07210c51..acb98a9a 100644 --- a/packages/harness/lib/knowledge/store.mjs +++ b/packages/harness/lib/knowledge/store.mjs @@ -3,7 +3,7 @@ import path from 'node:path'; import crypto from 'node:crypto'; import { spawnSync } from 'node:child_process'; import { harnessGlobalHome } from '../paths.mjs'; -import { DEFAULT_MAX_BYTES } from '../fs-safe.mjs'; +import { DEFAULT_MAX_BYTES, readFileNoFollow, writeFileContained, assertRealpathContained } from '../fs-safe.mjs'; /** * The local knowledge store: a CLI-managed git repo OUTSIDE the working tree @@ -757,14 +757,25 @@ function currentHeadSha(dir) { return res.status === 0 ? res.stdout.trim() : null; } -/** True when the store's working tree has ANY uncommitted change. Fails - * CLOSED (dirty) on an unreadable `git status`, so the journal below never - * records "clean at start" for a tree it could not actually inspect — the - * recovery path only ever discards residue it is certain nobody else authored. */ -function treeIsDirty(dir) { - const res = spawnSync('git', ['status', '--porcelain'], { cwd: dir, encoding: 'utf8' }); - if (res.error || res.status !== 0) return true; - return Boolean(res.stdout.trim()); +/** + * Every path `git status --porcelain -uall` currently reports as uncommitted. + * `-uall` (not the default `-unormal`) because these paths drive a PER-PATH + * decision: the default collapses a brand-new directory into one entry that + * names no individual file, which is too coarse to tell "the dead writer + * planted this learning" from "a human left an unrelated file here". + * + * Returns null — never an empty array — when git could not be read, so every + * caller fails CLOSED (an unreadable tree is never mistaken for a clean one) + * rather than the journal recording "nothing was dirty" for a tree it could + * not actually inspect. + */ +function dirtyPaths(dir) { + const res = spawnSync('git', ['status', '--porcelain', '-uall'], { cwd: dir, encoding: 'utf8' }); + if (res.error || res.status !== 0) return null; + return res.stdout + .split('\n') + .filter(Boolean) + .map((line) => parsePorcelainLine(line).path); } /** @@ -789,6 +800,16 @@ function treeIsDirty(dir) { * sitting there, so recovery keeps its hands off and leaves it for absorb * exactly as before. * + * `dirty` is the LIST of paths already uncommitted when the journal was + * written, not a single tree-wide flag. The flag was too coarse: absorb + * deliberately ignores non-learning files (config.json, INDEX.md, a human's + * scratch note), so such a file can sit uncommitted indefinitely — and under a + * tree-wide flag its mere presence disarmed residue rollback for the LEARNING + * paths a later crash dirtied, handing the dead writer's own partial write to + * the next absorb as `source: human`. Recovery therefore decides PER PATH: + * anything dirty now that was NOT dirty then is this transaction's residue; + * everything else is left exactly as it was found. + * * It lives under `.git/` deliberately: that is the one path inside the store * `git add -A` can never stage and `git clean -fd` never sweeps, so the * journal can neither leak into store history nor be destroyed by the very @@ -797,24 +818,75 @@ function treeIsDirty(dir) { */ const TXN_JOURNAL_REL = path.join('.git', 'harness-txn.json'); +/** Contained, atomic journal write (fs-safe.mjs — the same primitive every + * other writer in the store uses). Returns true on success, false on ANY + * refusal or failure, so the caller can fail CLOSED instead of running a + * transaction whose residue nothing can later classify. The try/catch is not + * decoration: `writeFileContained` mkdirs the parent, which THROWS (rather than + * returning null) when `.git` is a gitfile instead of a directory — a store + * `ensureStore` never produces, but one a hand-built store can. A throw here + * would escape with the lock still held; false simply refuses the run. */ function writeTxnJournal(dir, data) { try { - fs.writeFileSync(path.join(dir, TXN_JOURNAL_REL), JSON.stringify(data) + '\n', 'utf8'); + return Boolean(writeFileContained(dir, TXN_JOURNAL_REL, JSON.stringify(data) + '\n')); } catch { - // best effort — a journal write failure degrades to pre-journal behavior + return false; } } function readTxnJournal(dir) { + const text = readFileNoFollow(path.join(dir, TXN_JOURNAL_REL), { root: dir }); + if (text === null) return null; // absent, symlinked, or outside the store try { - const parsed = JSON.parse(fs.readFileSync(path.join(dir, TXN_JOURNAL_REL), 'utf8')); + const parsed = JSON.parse(text); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed; } catch { - // absent or corrupt — treated as "no interrupted transaction" + // corrupt — treated as "no interrupted transaction" } return null; } +/** + * The set of paths a journal recorded as ALREADY uncommitted when it was + * written, or null when the journal cannot say — in which case recovery keeps + * its hands off the tree entirely. A journal written by an older CLI carries + * only the tree-wide `dirtyAtStart` flag: `false` still means "nothing was + * dirty" (an empty set) and anything else stays unactionable, exactly the + * pre-per-path behavior. + */ +function journalDirtySet(journal) { + if (Array.isArray(journal.dirty)) return new Set(journal.dirty); + if (journal.dirtyAtStart === false) return new Set(); + return null; +} + +/** + * Discard ONE residue path back to `targetSha` (or HEAD): restore it from that + * commit when it exists there, remove it outright when it does not (a file the + * dead writer created). Per-path rather than the whole-tree `git reset --hard` + * so a path that was already dirty when the journal was written — a human's + * unabsorbed hand edit, or a non-learning file absorb ignores — is never + * touched by another path's recovery. The delete half goes through + * `assertRealpathContained` (fs-safe.mjs): `rel` comes from `git status` over + * a directory a human hand-edits, so a symlinked component must never let the + * removal land outside the store. + */ +function discardResiduePath(dir, rel, targetSha) { + const ref = targetSha || 'HEAD'; + const opts = { cwd: dir, encoding: 'utf8' }; + spawnSync('git', ['reset', '-q', ref, '--', rel], opts); + if (spawnSync('git', ['checkout', '-q', '--', rel], opts).status === 0) return; + // Not present in `ref` at all — the dead writer planted it. + const full = assertRealpathContained(dir, rel); + if (!full) return; + try { + fs.rmSync(full, { recursive: true, force: true }); + } catch { + // best effort — a path that resists removal is reported as unrecovered by + // simply staying dirty, never by throwing out of lock acquisition + } +} + function clearTxnJournal(dir) { try { fs.rmSync(path.join(dir, TXN_JOURNAL_REL), { force: true }); @@ -825,37 +897,50 @@ function clearTxnJournal(dir) { /** * Crash recovery, run under the freshly-acquired lock: a journal still on - * disk means the previous holder never reached its commit or rollback. When - * that journal recorded a CLEAN tree at its start (see above), every - * uncommitted byte in the store right now is that dead writer's residue — - * discarded back to its last recorded checkpoint (so any intra-transaction - * commit it DID land, e.g. an absorbed hand edit, survives) instead of being - * inherited by the next transaction's absorb as human authority. A journal - * that recorded pre-existing dirt is left alone: that dirt may be a real hand - * edit the dead writer never got to absorb, and absorbing it is exactly the - * behavior to preserve. Returns a human-readable note, or null when nothing - * was recovered. + * disk means the previous holder never reached its commit or rollback. Every + * path dirty NOW that the journal did not record as dirty THEN is that dead + * writer's residue — discarded back to its last recorded checkpoint (so any + * intra-transaction commit it DID land, e.g. an absorbed hand edit, survives) + * instead of being inherited by the next transaction's absorb as human + * authority. Paths the journal DID record stay exactly as found: that dirt may + * be a real hand edit the dead writer never got to absorb, and absorbing it is + * exactly the behavior to preserve. Returns a human-readable note, or null + * when nothing was recovered. */ function recoverInterruptedTransaction(dir, git, lockPath) { const journal = readTxnJournal(dir); if (!journal) return null; clearTxnJournal(dir); - if (!git || journal.dirtyAtStart !== false) return null; - if (!treeIsDirty(dir)) return null; + if (!git) return null; + const before = journalDirtySet(journal); + if (before === null) return null; // the journal cannot say — hands off + const now = dirtyPaths(dir); + if (now === null) return null; // unreadable status — fail closed, touch nothing + const residue = now.filter((p) => !before.has(p)); + if (!residue.length) return null; const checkpoint = typeof journal.checkpoint === 'string' && /^[0-9a-f]{40,64}$/.test(journal.checkpoint) ? journal.checkpoint : null; - rollbackStore(dir, checkpoint); - // A recorded checkpoint can be unreachable (a store rewritten under the - // dead writer's feet) — `git reset --hard ` then fails silently and - // leaves the residue in place. Fail closed to the store's plain - // "discard everything uncommitted" reset rather than let it through. - if (treeIsDirty(dir)) rollbackStore(dir); - // rollbackStore's `git clean -fd` sweeps untracked directories — including - // the `.lock` this transaction is holding right now. Re-assert it before - // anything else runs. - try { - fs.mkdirSync(lockPath); - } catch { - // still there — nothing to re-assert + if (before.size === 0) { + // Nothing was dirty at the start, so EVERY uncommitted byte is residue — + // the store's own whole-tree rollback is both the cheapest and the most + // thorough discard. + rollbackStore(dir, checkpoint); + // A recorded checkpoint can be unreachable (a store rewritten under the + // dead writer's feet) — `git reset --hard ` then fails silently and + // leaves the residue in place. Fail closed to the store's plain + // "discard everything uncommitted" reset rather than let it through. + if (dirtyPaths(dir)?.length !== 0) rollbackStore(dir); + // rollbackStore's `git clean -fd` sweeps untracked directories — including + // the `.lock` this transaction is holding right now. Re-assert it before + // anything else runs. + try { + fs.mkdirSync(lockPath); + } catch { + // still there — nothing to re-assert + } + } else { + // Mixed tree: discard only what this dead writer added, one path at a + // time, so the pre-existing dirt survives untouched. + for (const rel of residue) discardResiduePath(dir, rel, checkpoint); } return 'discarded interrupted write residue'; } @@ -920,8 +1005,13 @@ export class StoreTransactionAbort extends Error { * against the intent journal and writes a fresh one (see * recoverInterruptedTransaction above — a dead writer's uncommitted residue is * discarded rather than absorbed as human authority by the next transaction), - * all BEFORE calling `fn({ dir, git, recordCheckpoint })`. `fn` is expected - * to mutate the store + * all BEFORE calling `fn({ dir, git, recordCheckpoint, rollbackToCheckpoint })`. + * `rollbackToCheckpoint` is the same rollback the failure paths below use, + * exposed so an `fn` that REJECTS after already mutating (apply.mjs's write-time + * `E_HEAD_MOVED`, which can only be reached once a branch bucket has been + * materialized) can make its own "nothing was written" promise literally true + * instead of leaving stale mutations for the finalize commit to publish. + * `fn` is expected to mutate the store * directly and return a plain result value describing what happened; it may * perform its OWN sub-commits when it needs more than one checkpoint inside * this same lock (e.g. absorbHandEdits's self-contained "human edit: " @@ -993,26 +1083,64 @@ export function withStoreTransaction(workspace, { home, label, afterCommit } = { // hand-maintained value could. let checkpointSha = git ? currentHeadSha(dir) : null; const journalBase = { pid: process.pid, at: new Date().toISOString(), label: label || null }; - if (git) writeTxnJournal(dir, { ...journalBase, checkpoint: checkpointSha, dirtyAtStart: treeIsDirty(dir) }); + // FAIL CLOSED ON A JOURNAL WRITE FAILURE (P1). The journal is the ONLY thing + // that tells this transaction's crash residue apart from a human hand edit, + // so a best-effort write that silently failed left the transaction running + // UNMARKED — exactly the state whose residue the next transaction's absorb + // launders into `source: human`. Nothing has been mutated at this point, so + // refusing the run costs only the run. `dirtyPaths` returning null (git + // unreadable) is likewise refused: a journal that cannot record what was + // already dirty cannot drive a per-path recovery either. + if (git) { + const dirty = dirtyPaths(dir); + if (dirty === null || !writeTxnJournal(dir, { ...journalBase, checkpoint: checkpointSha, dirty })) { + clearTxnJournal(dir); + fs.rmSync(lockPath, { recursive: true, force: true }); + return { + ok: false, + locked: false, + rolledBack: false, + error: new Error('store transaction journal could not be written — refusing to run unmarked (crash residue would be indistinguishable from a hand edit)'), + committed: false, + result: null, + dir, + git, + staleLockNote, + }; + } + } function recordCheckpoint() { if (!git) return; checkpointSha = currentHeadSha(dir); // The intra-transaction commit just cleaned the tree, so whatever is // dirty from here on is unambiguously this transaction's own work — even - // if a hand edit WAS pending when the journal was first written. - writeTxnJournal(dir, { ...journalBase, checkpoint: checkpointSha, dirtyAtStart: false }); + // if a hand edit WAS pending when the journal was first written. A refresh + // that fails leaves the PREVIOUS journal in place, which is strictly more + // conservative (an older checkpoint, a larger already-dirty set) — the + // transaction stays marked either way, so this one stays best effort. + writeTxnJournal(dir, { ...journalBase, checkpoint: checkpointSha, dirty: [] }); } function guardedRollback() { if (!git) return false; rollbackStore(dir, checkpointSha); + // `git clean -fd` sweeps untracked directories — including the `.lock` + // this transaction still holds. Re-assert it: a rollback taken MID-`fn` + // (rollbackToCheckpoint below) must never hand the store to a concurrent + // writer before this transaction has finished. Harmless for the terminal + // rollbacks — the `finally` removes the lock immediately afterwards. + try { + fs.mkdirSync(lockPath); + } catch { + // still there — nothing to re-assert + } return true; } try { let result; try { - result = fn({ dir, git, recordCheckpoint }); + result = fn({ dir, git, recordCheckpoint, rollbackToCheckpoint: guardedRollback }); } catch (err) { const isAbort = err instanceof StoreTransactionAbort; const rolledBack = isAbort ? false : guardedRollback(); diff --git a/packages/harness/test/hand-edits.test.mjs b/packages/harness/test/hand-edits.test.mjs index 32369fa4..dcdd05b3 100644 --- a/packages/harness/test/hand-edits.test.mjs +++ b/packages/harness/test/hand-edits.test.mjs @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url'; import { test } from 'node:test'; import { applyOps } from '../lib/knowledge/apply.mjs'; import { absorbHandEdits, absorbOrAbort, removeEpisodeLink } from '../lib/knowledge/admin.mjs'; +import { setLearningStatus } from '../lib/knowledge/lifecycle.mjs'; import { ensureBucket } from '../lib/knowledge/layer.mjs'; import { ensureStore, storeDir, listLearnings, readLedger, parseLearningFrontmatter, serializeLearning, StoreTransactionAbort } from '../lib/knowledge/store.mjs'; @@ -610,6 +611,50 @@ test('a PLANTED untracked learning file absorbs as a hand edit — golden and bu assert.equal(spawnSync('git', ['status', '--porcelain'], { cwd: dir, encoding: 'utf8' }).stdout.trim(), ''); }); +// A SYMLINK planted at a learning path is not a learning — it is a redirect. +// Absorbing untracked learning files brought a bare `fs.readFileSync` + +// `fs.writeFileSync` pair to a path derived straight from `git status`, so a +// planted symlink was followed BOTH ways: the read pulled an arbitrary outside +// file's content into the store's absorb pipeline, and the canonical rewrite +// overwrote that outside file with a serialized learning. Refused on both +// halves, through fs-safe.mjs, for golden and bucket paths alike. +test('a planted SYMLINK at a learning path is refused, never followed — the outside target is untouched (golden and bucket)', () => { + const c = ctx(); + const seeded = seedLearning(c); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + + const outside = tempDir('hedit-outside-'); + const original = '# an outside document\n\nNot a learning. Must never be read into the store or rewritten.\n'; + const goldenVictim = path.join(outside, 'golden-victim.md'); + const bucketVictim = path.join(outside, 'bucket-victim.md'); + fs.writeFileSync(goldenVictim, original, 'utf8'); + fs.writeFileSync(bucketVictim, original, 'utf8'); + + const goldenLink = path.join(dir, 'learnings', 'planted', 'golden-link.md'); + fs.mkdirSync(path.dirname(goldenLink), { recursive: true }); + fs.symlinkSync(goldenVictim, goldenLink); + const bucketDir = ensureBucket(dir, { key: 'linked-bucket', branch: 'feature/linked', baseSha: null }); + const bucketLink = path.join(bucketDir, 'learnings', 'planted', 'bucket-link.md'); + fs.mkdirSync(path.dirname(bucketLink), { recursive: true }); + fs.symlinkSync(bucketVictim, bucketLink); + + const logged = []; + const result = absorbHandEdits({ workspace: c.ws, home: c.harnessHome, log: (m) => logged.push(m) }); + assert.deepEqual(result.absorbed.map((a) => a.id), [], 'a symlink at a learning path is never absorbed as a learning'); + assert.equal(logged.filter((m) => /symlink/i.test(m)).length, 2, 'both refusals are reported, not silently skipped'); + + // A full store transaction (which runs absorb first, as every mutation entry + // point does) must not follow them either. + const tx = setLearningStatus({ workspace: c.ws, id: seeded, action: 'confirm', reason: 'unrelated', home: c.harnessHome }); + assert.equal(tx.pass, true, tx.blockedReason); + + assert.equal(fs.readFileSync(goldenVictim, 'utf8'), original, 'the golden symlink target is byte-identical'); + assert.equal(fs.readFileSync(bucketVictim, 'utf8'), original, 'the bucket symlink target is byte-identical'); + assert.ok(fs.lstatSync(goldenLink).isSymbolicLink(), 'the planted symlink was refused, not replaced'); + assert.ok(fs.lstatSync(bucketLink).isSymbolicLink()); + assert.equal(fs.existsSync(path.join(c.ws, 'docs', 'solutions', 'teachings')), false, 'no teaching snapshot fabricated from an outside file'); +}); + test('a planted secret-shaped learning file is still scanned on absorb — the snapshot is skipped, warned, and never written', () => { const c = ctx(); seedLearning(c); diff --git a/packages/harness/test/knowledge-promote.test.mjs b/packages/harness/test/knowledge-promote.test.mjs index f9b7f344..a03da1b6 100644 --- a/packages/harness/test/knowledge-promote.test.mjs +++ b/packages/harness/test/knowledge-promote.test.mjs @@ -380,6 +380,83 @@ test('a re-digested promotion STRENGTHEN cannot graft its source evidence onto a assert.equal(victimAfter.fm.episodes.length, 1, 'the unrelated golden claim gained no borrowed evidence'); }); +// Binding the DESTINATION alone was not enough. `SUPERSEDE.target` and +// `MERGE.targets` name OTHER learnings — ids the promotion never cited as its +// source — and both stayed independently attacker-controlled, while MERGE was +// admitted as a promotion op at all despite the emitter never producing one. +// So a correctly re-digested op could promote the authentic source claim into +// golden (passing every source binding) while tombstoning unrelated golden +// claims the operator never chose to touch: a destructive write, laundered +// through a legitimate-looking promotion. +test('a re-digested promotion op cannot tombstone unrelated golden claims through target/targets, and MERGE is not a promotion op', () => { + const ws = featureWorkspace('feature/bound-targets'); + const home = tempDir('promo-home13-'); + const { dir } = ensureStore(ws, { home }); + + // Two unrelated golden claims, unprotected (source: auto, no fix links) so + // nothing but the target binding itself can save them. Committed, like the + // CLI always leaves the store — an uncommitted file would absorb as a hand + // edit and become `source: human`, i.e. protected for the wrong reason. + fs.mkdirSync(path.join(dir, 'learnings', 'sql'), { recursive: true }); + for (const slug of ['treasure-a', 'treasure-b']) { + fs.writeFileSync( + path.join(dir, 'learnings', 'sql', `${slug}.md`), + `---\nschema: 1\ntrigger: "${slug} trigger"\nstatus: active\nsource: auto\nepisodes:\nanchors: []\nsuperseded_by: null\nlast_confirmed: null\norigin: t\n---\n\nGolden claim ${slug}.\n` + ); + } + commitStore(dir, 'seed: unrelated golden claims'); + + seedBucketLearning(ws, home, 'authentic'); + const opsFull = path.join(ws, PROMOTE_OPS_REL); + assert.equal(buildPromotionOps({ workspace: ws, home, all: true }).pass, true); + const pristine = fs.readFileSync(opsFull, 'utf8'); + const emitted = JSON.parse(pristine).ops[0]; + assert.equal(emitted.op, 'ADD'); + assert.equal(emitted.source.id, 'sql/authentic'); + + const untouched = () => { + for (const slug of ['treasure-a', 'treasure-b']) { + const claim = listLearnings(dir).find((l) => l.id === `sql/${slug}`); + assert.equal(claim.fm.status, 'active', `sql/${slug} stays active`); + assert.equal(claim.fm.superseded_by, null, `sql/${slug} is never tombstoned`); + } + assert.equal(listLearnings(dir).some((l) => l.id === 'sql/authentic'), false, 'nothing reached golden'); + const source = listLearnings(bucketDirFor(dir, branchKeyFor('feature/bound-targets'))).find((l) => l.id === 'sql/authentic'); + assert.equal(source.fm.promoted_to_golden, undefined, 'a refused promotion never tombstones its source'); + assert.equal(readLedger(dir).filter((e) => e.failure).length, 0, 'promotion rejections never strike'); + }; + + // 1. A SUPERSEDE whose DESTINATION is the source (so the destination binding + // passes cleanly) but whose `target` names an unrelated golden claim. + const forgedSupersede = JSON.parse(pristine); + forgedSupersede.ops = [{ ...emitted, op: 'SUPERSEDE', target: 'sql/treasure-a' }]; + forgedSupersede.promotion.digest = promotionDigest(forgedSupersede.ops); + fs.writeFileSync(opsFull, JSON.stringify(forgedSupersede)); + const supersede = applyOps({ workspace: ws, opsPath: opsFull, home }); + assert.equal(supersede.exitCode, 1, JSON.stringify(supersede)); + assert.equal(supersede.rejected[0].code, 'E_SCHEMA'); + assert.match(supersede.rejected[0].reason, /sql\/treasure-a/); + untouched(); + + // 2. A MERGE — never emitted by `harness knowledge promote` — consolidating + // two unrelated golden claims into the authentic source's own id. + const forgedMerge = JSON.parse(pristine); + forgedMerge.ops = [{ ...emitted, op: 'MERGE', targets: ['sql/treasure-a', 'sql/treasure-b'] }]; + forgedMerge.promotion.digest = promotionDigest(forgedMerge.ops); + fs.writeFileSync(opsFull, JSON.stringify(forgedMerge)); + const merge = applyOps({ workspace: ws, opsPath: opsFull, home }); + assert.equal(merge.exitCode, 1, JSON.stringify(merge)); + assert.equal(merge.rejected[0].code, 'E_SCHEMA'); + assert.match(merge.rejected[0].reason, /never MERGE/); + untouched(); + + // The pristine op-set still promotes — the binding refuses forged targets, + // it does not break the legitimate lane. + fs.writeFileSync(opsFull, pristine); + assert.equal(applyOps({ workspace: ws, opsPath: opsFull, home }).exitCode, 0); + assert.ok(listLearnings(dir).some((l) => l.id === 'sql/authentic')); +}); + test('governed and detached sources are refused at emit time', () => { const ws = featureWorkspace('feature/governed-promo'); const home = tempDir('promo-home7-'); diff --git a/packages/harness/test/layer-routing.test.mjs b/packages/harness/test/layer-routing.test.mjs index e2748cd9..db1b24b6 100644 --- a/packages/harness/test/layer-routing.test.mjs +++ b/packages/harness/test/layer-routing.test.mjs @@ -366,3 +366,57 @@ test('a workspace checkout landing mid-transaction aborts the write instead of r // The hand edit the absorb captured before the abort is still intact. assert.match(listLearnings(dir).find((l) => l.id === 'sql/settled-claim').body, /Hand-edited claim body\./); }); + +// The same race, but from a FEATURE branch — where the run materializes a +// branch bucket (mkdir + ledger + INDEX.md + meta.json) on its way to the +// mutation phase. `E_HEAD_MOVED` promises "nothing was written", but that +// materialization happened BEFORE the check, and the rejection returned +// normally — so the transaction's own finalize committed the stale bucket +// while reporting that nothing had been written. A rejection must leave the +// store byte-identical to what it was. +test('E_HEAD_MOVED from a feature branch is side-effect-free: no bucket is materialized, nothing is committed', () => { + const ws = clonedWorkspace(); + const home = tempDir('route-home12-'); + + const first = applyOps({ workspace: ws, opsPath: writeOps(ws, [addOp(ws, { slug: 'settled-claim' })]), home }); + assert.equal(first.exitCode, 0, JSON.stringify(first.rejected)); + const { dir } = ensureStore(ws, { home }); + + // Route to a bucket: the run starts on a feature branch, so runOnce + // materializes `branches//` for it. + git(ws, ['checkout', '-qb', 'feature/original']); + const stableKey = branchKeyFor('feature/original'); + + // The hand edit is what makes the absorb step commit — and therefore run the + // hook that moves the workspace HEAD mid-transaction. + const learning = listLearnings(dir).find((l) => l.id === 'sql/settled-claim'); + fs.writeFileSync( + learning.file, + fs.readFileSync(learning.file, 'utf8').replace('Routed claim body.', 'Hand-edited claim body.'), + 'utf8' + ); + const hooks = path.join(dir, '.git', 'hooks'); + fs.mkdirSync(hooks, { recursive: true }); + fs.writeFileSync( + path.join(hooks, 'pre-commit'), + `#!/bin/sh\ngit -C ${JSON.stringify(ws)} checkout -qB feature/moved >/dev/null 2>&1\nexit 0\n`, + { mode: 0o755 } + ); + + const headBefore = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8' }).stdout.trim(); + const res = applyOps({ workspace: ws, opsPath: writeOps(ws, [addOp(ws, { slug: 'raced-claim' })]), home }); + assert.equal(res.exitCode, 1, JSON.stringify(res)); + assert.equal(res.rejected[0].code, 'E_HEAD_MOVED'); + + assert.deepEqual(listBuckets(dir), [], 'no bucket was materialized by a run that wrote nothing'); + assert.equal(fs.existsSync(bucketDirFor(dir, stableKey)), false, 'not even an empty bucket directory survives'); + assert.equal(listLearnings(dir).some((l) => l.id === 'sql/raced-claim'), false); + // Exactly one commit landed — the absorb of the hand edit, which is the + // transaction's checkpoint and legitimately survives. Nothing after it. + const headAfter = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8' }).stdout.trim(); + const subject = spawnSync('git', ['log', '--format=%s', '-1'], { cwd: dir, encoding: 'utf8' }).stdout.trim(); + assert.notEqual(headAfter, headBefore, 'the absorb commit landed'); + assert.match(subject, /^human edit: /, 'and it is the LAST commit — no bucket was committed on top of it'); + assert.equal(spawnSync('git', ['status', '--porcelain'], { cwd: dir, encoding: 'utf8' }).stdout.trim(), '', 'the store tree is clean'); + assert.match(listLearnings(dir).find((l) => l.id === 'sql/settled-claim').body, /Hand-edited claim body\./); +}); diff --git a/packages/harness/test/store-transaction.test.mjs b/packages/harness/test/store-transaction.test.mjs index 70287375..c59654a9 100644 --- a/packages/harness/test/store-transaction.test.mjs +++ b/packages/harness/test/store-transaction.test.mjs @@ -332,6 +332,99 @@ function gitPorcelainStatus(dir) { return spawnSync('git', ['status', '--porcelain'], { cwd: dir, encoding: 'utf8' }).stdout; } +// Crash recovery used to be an all-or-nothing tree-wide decision: any dirt at +// journal-write time disabled it wholesale. But absorb deliberately IGNORES +// non-learning files (config.json, scratch notes, INDEX.md), so such a file +// can sit uncommitted indefinitely — and its mere presence then disarmed +// residue rollback for the LEARNING paths a later crash dirtied, handing the +// dead writer's own partial write to the next transaction's absorb as +// `source: human`. The decision has to be per-path. +test('crash recovery is per-path: a dirty NON-learning file never disarms residue rollback for learning paths', () => { + const c = ctx(); + const id = seedLearning(c); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + const learning = listLearnings(dir).find((l) => l.id === id); + const committed = fs.readFileSync(learning.file, 'utf8'); + + // Pre-existing, uncommitted, NON-learning dirt — absorb never touches it, + // so it can outlive any number of transactions. + const notes = path.join(dir, 'scratch-notes.txt'); + const notesText = 'a human left this here; absorb ignores it\n'; + fs.writeFileSync(notes, notesText, 'utf8'); + + const residue = committed.replace( + /(---\r?\n[\s\S]*?\r?\n---\r?\n\r?\n)[\s\S]*$/, + (_m, fm) => `${fm}A model-authored partial write that never reached a commit.\n` + ); + const storeModule = pathToFileURL(path.join(packageRoot, 'lib', 'knowledge', 'store.mjs')).href; + const script = [ + "import fs from 'node:fs';", + `import { withStoreTransaction } from ${JSON.stringify(storeModule)};`, + `withStoreTransaction(${JSON.stringify(c.ws)}, { home: ${JSON.stringify(c.harnessHome)}, label: 'crashing writer' }, () => {`, + ` fs.writeFileSync(${JSON.stringify(learning.file)}, ${JSON.stringify(residue)}, 'utf8');`, + " process.kill(process.pid, 'SIGKILL');", + '});', + ].join('\n'); + const child = spawnSync(process.execPath, ['--input-type=module', '-e', script], { encoding: 'utf8' }); + assert.equal(child.signal, 'SIGKILL', `precondition: the writer really died mid-transaction (${child.stderr})`); + assert.equal(fs.readFileSync(learning.file, 'utf8'), residue, 'precondition: the residue is sitting in the tree'); + assert.match(gitPorcelainStatus(dir), /scratch-notes\.txt/, 'precondition: unrelated non-learning dirt coexists with it'); + + const lockPath = path.join(dir, '.lock'); + const old = new Date(Date.now() - 11 * 60 * 1000); + fs.utimesSync(lockPath, old, old); + + const res = applyOps({ + workspace: c.ws, + opsPath: writeOps(c.ws, [ADD(c.ws, { slug: 'after-mixed-kill', episodePath: 'docs/solutions/perf/after-mixed-kill.md' })]), + home: c.harnessHome, + }); + assert.equal(res.exitCode, 0, JSON.stringify(res)); + assert.match(res.staleLockRemoved || '', /residue/, 'the residue was still identified and discarded'); + + const after = listLearnings(dir).find((l) => l.id === id); + assert.equal(after.fm.source !== 'human', true, 'CLI residue is never laundered into human authority'); + assert.doesNotMatch(after.body, /model-authored partial write/, 'the residue was rolled back'); + assert.equal( + fs.existsSync(path.join(c.ws, 'docs', 'solutions', 'teachings')), + false, + 'and no human-teaching snapshot was fabricated from it' + ); + // The pre-existing non-learning dirt was NOT this transaction's to discard. + assert.equal(fs.readFileSync(notes, 'utf8'), notesText, 'unrelated dirt survives per-path recovery untouched'); + assert.ok(listLearnings(dir).some((l) => l.id === 'sql/after-mixed-kill')); +}); + +// The journal is the ONLY thing that tells crash residue apart from a human +// hand edit. A best-effort write that silently failed left the transaction +// running UNMARKED — precisely the state whose residue the next transaction +// absorbs as `source: human`. Fail closed instead: refuse the run. +test('a transaction whose intent journal cannot be written is refused, not run unmarked', () => { + const c = ctx(); + seedLearning(c); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + const before = listLearnings(dir).map((l) => l.id).sort(); + + // Make the journal path unwritable in a way that needs no chmod (and so + // behaves identically for a root-running CI): a non-empty DIRECTORY sitting + // exactly where the journal file goes. + const journalPath = txnJournalPath(dir); + fs.mkdirSync(journalPath, { recursive: true }); + fs.writeFileSync(path.join(journalPath, 'occupied'), 'x', 'utf8'); + + const res = applyOps({ + workspace: c.ws, + opsPath: writeOps(c.ws, [ADD(c.ws, { slug: 'unjournaled', episodePath: 'docs/solutions/perf/unjournaled.md' })]), + home: c.harnessHome, + }); + assert.equal(res.exitCode, 1, JSON.stringify(res)); + assert.equal(res.rejected[0].code, 'E_APPLY_FAILED'); + assert.match(res.rejected[0].reason, /journal/i, 'the refusal names the journal, not a generic failure'); + + assert.deepEqual(listLearnings(dir).map((l) => l.id).sort(), before, 'the refused transaction wrote nothing'); + assert.equal(fs.existsSync(path.join(dir, '.lock')), false, 'and it still released the lock'); +}); + // --------------------------------------------------------------------------- // Git fault injection: a real commit failure rolls back and reports nonzero. // --------------------------------------------------------------------------- From a9b45f2234402ee9ab8f916d1e380813c54310cc Mon Sep 17 00:00:00 2001 From: Krish Date: Thu, 6 Aug 2026 15:48:13 -0400 Subject: [PATCH 21/24] fix: route learning IO through one guarded choke point and make lock loss impossible --- docs/MEMORY-MODEL.md | 59 ++- packages/harness/lib/knowledge/admin.mjs | 101 +++- packages/harness/lib/knowledge/apply.mjs | 177 +++++-- .../harness/lib/knowledge/learning-io.mjs | 181 +++++++ packages/harness/lib/knowledge/lifecycle.mjs | 17 +- packages/harness/lib/knowledge/listing.mjs | 66 ++- packages/harness/lib/knowledge/promote.mjs | 18 +- packages/harness/lib/knowledge/store.mjs | 455 +++++++++++++--- .../harness/test/consolidate-apply.test.mjs | 50 +- packages/harness/test/hand-edits.test.mjs | 16 +- .../knowledge-boundary-hardening.test.mjs | 8 +- .../knowledge-structural-hardening.test.mjs | 489 ++++++++++++++++++ .../harness/test/learnings-listing.test.mjs | 22 +- .../harness/test/listing-redaction.test.mjs | 103 +++- 14 files changed, 1578 insertions(+), 184 deletions(-) create mode 100644 packages/harness/lib/knowledge/learning-io.mjs create mode 100644 packages/harness/test/knowledge-structural-hardening.test.mjs diff --git a/docs/MEMORY-MODEL.md b/docs/MEMORY-MODEL.md index 4804f1e1..16f3503a 100644 --- a/docs/MEMORY-MODEL.md +++ b/docs/MEMORY-MODEL.md @@ -657,11 +657,54 @@ runs `git status --porcelain -uall` in the store first and commits any dirty edi - The absorbed content may exceed the 1,200-byte learning cap — human authority overrides the cap for hand edits (logged, not rejected; the cap binds only the sole writer's own ops). -- **A symlink at a learning path is never a learning.** `learnings//.md` (or - the bucket equivalent) planted as a SYMLINK is refused with a logged note, not followed — - on the read and on the canonical rewrite alike, both through the shared `fs-safe` - primitives. Following it would pull an arbitrary outside file into store history and a - workspace teaching snapshot, then overwrite that outside file with a serialized learning. +- **A symlink at a learning path is never a learning — and it is made inert, not just + refused.** Every read, write, delete, and size check of a `learnings//.md` + file (or its `branches//` equivalent) goes through ONE internal choke point + (`lib/knowledge/learning-io.mjs`), built on the shared `fs-safe` primitives and contained + against the STORE root, which it derives from the path's own required shape rather than + from a caller-supplied argument. So a planted symlink is: never read through, never + written through, never deleted through, never listed as an active learning by + `harness learnings` / retrieval / ranking, and never copied into the workspace mirror + under `knowledge commit repo`. Following it would otherwise pull an arbitrary outside file + into store history and a workspace teaching snapshot, then overwrite that outside file + with a serialized learning. + **The link itself is quarantined.** Refusing to follow it while leaving it sitting at a + live learning path was not enough — it still looked like a learning to anything that only + read the directory listing, and the next writer reaching for that id met a live link. The + next `absorbHandEdits` therefore MOVES the link (never its target; `rename` does not + follow a symlink) into `/.quarantine/`, logs where it went, and leaves it there for + inspection. `.quarantine/` is gitignored, so a quarantined link never enters store + history and is never swept by `git clean -fd`. +- **The store lock cannot be lost, and is never released by a non-owner.** `ensureStore` + writes (and, for stores created by an older CLI, migrates in) a `/.gitignore` + covering `/.lock/`, `/.lock.stale-*`, and `/.quarantine/`. That is what makes lock loss + structurally impossible rather than recoverable: the transaction rollback runs + `git clean -fd`, which used to sweep the untracked `.lock` out from under the very + transaction holding it, after which the code re-asserted with a bare `mkdir` and swallowed + `EEXIST` as "still there" — so if a second writer had claimed the freed lock in that + window, the first carried on inside a lock it no longer held, `git add -A`-ed the other + writer's in-flight files, and finally deleted THEIR lock. `git clean` without `-x` never + touches an ignored path and `git add -A` never stages one, so the window is gone. + Independently of that, every lock carries an **owner token** (pid + random nonce) written + inside the lock directory at acquisition. Re-assert and release both verify it: a lock + whose owner stamp names somebody else (or cannot be read) is never reclaimed and never + removed, and a transaction that discovers its lock has been taken over aborts instead of + continuing. The store `.gitignore` is additive — entries a human added are preserved. +- **A rollback that failed is reported as failed.** `rollbackStore` checks both git + invocations AND re-reads the tree afterwards (a zero exit is not the same thing as a clean + tree), and a transaction that could not roll back what it meant to discard NEVER reaches + its commit — enforced in `withStoreTransaction` itself, not left to each caller to + remember. This is what makes the write-time `E_HEAD_MOVED` rejection's "nothing was + written" promise true: a failed discard of the materialized branch bucket aborts the run + loudly instead of letting the finalize commit publish it. +- **`git status --porcelain` is always read with `-z`.** The line-oriented format C-quotes + and octal-escapes any path containing a non-ASCII byte, a quote, a backslash, or a control + char, and separates rename pairs with an in-band `" -> "`. Both are lossy: a hand edit to + `learnings/café/x.md` decoded as `learnings/caf303251/x.md`, and an ordinary file named + `learnings/a -> b/c.md` decoded as `b/c.md` — so residue discard silently no-opped WHILE + REPORTING SUCCESS, absorb missed the same file, and the next commit's `git add -A` swept it + into history unvalidated and unscanned. `-z` emits pathnames verbatim (NUL cannot occur in + a pathname) with the rename pair as two separate fields; one parser serves every consumer. - **Crash residue is not a hand edit.** Every store transaction writes an intent journal under the store's `.git/` before its first mutation and clears it on commit or rollback, so a writer killed mid-transaction leaves uncommitted state the next transaction can @@ -676,7 +719,11 @@ runs `git status --porcelain -uall` in the store first and commits any dirty edi rollback for the learning paths a later crash dirtied. **A journal that cannot be written refuses the transaction** rather than running unmarked: the journal is the only thing that tells residue from a hand edit, so proceeding without one is exactly the state whose - residue is later laundered into `source: human`. + residue is later laundered into `source: human`. A checkpoint refresh that FAILS after an + intra-transaction commit aborts the transaction too, rather than leaving the previous + journal in place: the previous journal names an OLDER checkpoint, and recovery resets + `--hard` to exactly that sha — destroying the sub-commit that had just landed, which for + `absorbOrAbort` is an absorbed human hand edit. Use `harness remember` to add a new claim and `harness learning retire|dispute|confirm` to change a learning's status when a CLI command is more convenient than a direct edit — both diff --git a/packages/harness/lib/knowledge/admin.mjs b/packages/harness/lib/knowledge/admin.mjs index 40529566..9acdefc7 100644 --- a/packages/harness/lib/knowledge/admin.mjs +++ b/packages/harness/lib/knowledge/admin.mjs @@ -6,12 +6,13 @@ import { withStoreTransaction, StoreTransactionAbort, LEARNING_FILE_RE, - parsePorcelainLine, + parsePorcelainZ, storeDir, storeDirForId, repoId, localRepoId, acquireStoreLock, + releaseStoreLock, listLearnings, readLedger, appendLedger, @@ -28,6 +29,7 @@ import { consolidateStatus, LEARNING_BYTE_CAP, isActiveFm } from './consolidate. import { listBuckets, branchesRoot, bucketDirFor } from './overlay.mjs'; import { scanSecrets } from '../secret-scan.mjs'; import { assertNoSymlinkAncestors, assertRealpathContained, writeFileContained, readFileNoFollow } from '../fs-safe.mjs'; +import { readLearningFile, writeLearningFile, removeLearningFile, quarantineSymlinkedLearning } from './learning-io.mjs'; import { runIndexKnowledge } from '../index-knowledge.mjs'; import { loadManifest } from '../recall-rank.mjs'; @@ -57,14 +59,20 @@ function yamlQuote(v) { * byte-shape-compatible with the sole-writer's output. */ export function removeEpisodeLink(file, targetPath) { - const text = fs.readFileSync(file, 'utf8'); + // Through the choke point (S1) — both halves. Returns null when the path is + // not a safely-resolvable learning file inside the store (symlinked leaf or + // ancestor, escaped path, over-cap, vanished); the purge cascade treats that + // as a hard failure rather than silently reporting a delink that never + // happened. + const text = readLearningFile(file); + if (text === null) return null; const { fm, body } = parseLearningFrontmatter(text); // Preserve every other field, including last_confirmed as parsed — a purge // is a negative event on this learning's remaining evidence, not a fresh // human confirmation, so it must never refresh the last_confirmed trust // signal. fm.episodes = (fm.episodes || []).filter((e) => e.path !== targetPath); - fs.writeFileSync(file, serializeLearning(fm, body), 'utf8'); + if (!writeLearningFile(file, serializeLearning(fm, body))) return null; return fm.episodes; } @@ -121,7 +129,19 @@ export function mirrorLearnings({ workspace, home, log = () => {}, retiredIds = const skippedIds = new Set(); for (const learning of active) { - const text = fs.readFileSync(learning.file, 'utf8'); + // Through the choke point (S1). `listLearnings` already refuses a symlinked + // learning, so this is the second, independent refusal on the same path — + // deliberately, because mirrorLearnings copies verbatim bytes into a + // COMMITTED workspace path under `knowledge commit repo`: following a + // planted link here published an arbitrary outside file into the product + // repo's PR flow. A null read is swept like any other skip. + const text = readLearningFile(learning.file); + if (text === null) { + skipped++; + skippedIds.add(learning.id); + log(`mirror: ${learning.id} could not be read safely from the store — skipped`); + continue; + } const secrets = scanSecrets(text); if (secrets.length) { skipped++; @@ -245,7 +265,7 @@ export function absorbHandEdits({ workspace, home, log = () => {} }) { const dir = storeDir(workspace, { home }); if (!fs.existsSync(dir) || !fs.existsSync(path.join(dir, '.git'))) return empty; - const status = spawnSync('git', ['status', '--porcelain', '-uall'], { cwd: dir, encoding: 'utf8' }); + const status = spawnSync('git', ['status', '--porcelain', '-uall', '-z'], { cwd: dir, encoding: 'utf8' }); // Fail CLOSED (P2): a spawn error or a non-zero `git status` exit used to be // coerced to an empty string — read as "tree is clean" — so a later // transaction rollback (git reset --hard + clean -fd) could silently destroy @@ -257,8 +277,13 @@ export function absorbHandEdits({ workspace, home, log = () => {} }) { const detail = status.error ? status.error.message : status.stderr || `git status exited ${status.status}`; return { absorbed: [], deleted: [], committed: false, ok: false, stderr: `git status failed: ${detail}` }; } - const lines = status.stdout.split('\n').filter(Boolean); - if (!lines.length) return empty; + // NUL-delimited, verbatim paths (S3): the line-oriented format C-quotes any + // path with a non-ASCII byte, a quote, a backslash, or a control char, and + // the old hand-rolled unquoting mis-decoded exactly those — so a hand edit to + // `learnings/café/x.md` was silently invisible to absorb and then swept into + // store history unvalidated by the next `git add -A`. + const entries = parsePorcelainZ(status.stdout); + if (!entries.length) return empty; const at = todayClamped(); const absorbed = []; @@ -271,8 +296,7 @@ export function absorbHandEdits({ workspace, home, log = () => {} }) { const ledgerByRoot = new Map(); const touchedBucketRoots = new Set(); - for (const line of lines) { - const { status: code, path: rel } = parsePorcelainLine(line); + for (const { status: code, path: rel } of entries) { const m = LEARNING_FILE_RE.exec(rel); if (!m) continue; // non-learning file — left for the normal commit // Bucket capture (blueprint §5a): a hand edit under @@ -307,10 +331,21 @@ export function absorbHandEdits({ workspace, home, log = () => {} }) { // after acquiring the descriptor, closing the swap window the walk cannot. const file = assertNoSymlinkAncestors(dir, rel); if (!file) { - log(`hand-edit absorb: ${rel} is a symlink or sits under one — refused, never followed`); + // REFUSING IS NOT ENOUGH — THE LINK MUST BECOME INERT (S1). The previous + // round refused to follow it here and LEFT IT IN PLACE, so it stayed on + // disk at a live learning path for every other reader and writer to trip + // over. It is now moved (link itself, never its target) into + // `/.quarantine/`, which is gitignored: out of `learnings/`, out + // of store history, preserved for inspection, and reported. + const quarantined = quarantineSymlinkedLearning(path.resolve(dir, rel)); + log( + quarantined + ? `hand-edit absorb: ${rel} is a symlink — never followed; moved to ${quarantined}` + : `hand-edit absorb: ${rel} is a symlink or sits under one — refused, never followed` + ); continue; } - const text = readFileNoFollow(file, { root: dir }); + const text = readLearningFile(file); if (text === null) { // Vanished between status and read, swapped for a symlink since the walk // above, over the read cap, or resolving outside the store — nothing @@ -388,8 +423,32 @@ export function absorbHandEdits({ workspace, home, log = () => {} }) { // is created empty, containment-verified in place, filled through the // verified descriptor, then renamed over the leaf (a rename replaces a // symlink, it never follows one). - if (!writeFileContained(dir, rel, content)) { + if (!writeLearningFile(file, content)) { log(`hand-edit absorb: refused to rewrite ${rel} — the path no longer resolves inside the knowledge store`); + // NO ORPHAN TEACHING SNAPSHOT. The snapshot is written BEFORE this + // refusal can happen, and until now the refusal just `continue`d — so + // `docs/solutions/teachings/-hand-edit-.md` stayed behind in + // the workspace with nothing citing it. That file is a valid + // `kind: human-teaching` candidate episode, so a later ADD could cite it + // and be admitted with `source: human` authority for an absorb that was + // REFUSED. Snapshot and rewrite are therefore all-or-nothing: on refusal + // the snapshot is removed again, and only if it cannot be removed is the + // orphan reported rather than left silent. + if (snapshot) { + const snapFull = assertRealpathContained(workspace, snapshot); + let cleared = false; + if (snapFull) { + try { + fs.rmSync(snapFull, { force: true }); + cleared = true; + } catch { + cleared = false; + } + } + if (!cleared) { + log(`hand-edit absorb: could not remove the orphaned teaching snapshot ${snapshot} — delete it by hand before it is cited as evidence`); + } + } continue; } if (ledgerEntry) { @@ -871,11 +930,17 @@ export function purgeEpisode({ workspace, target, copilotHome, home, log = () => // regardless of sha256, so this must match that filter exactly. const remaining = episodes.filter((e) => e.path !== target); if (remaining.length === 0) { - // No evidence left once every link to this path is gone. - fs.rmSync(l.file, { force: true }); + // No evidence left once every link to this path is gone. Through the + // choke point (S1): a symlinked learning path is refused, never + // unlinked-through onto an outside file. + if (!removeLearningFile(l.file)) { + throw new Error(`refused to delete ${l.id}: the learning path does not resolve safely inside the knowledge store`); + } removedLearnings.push(l.id); } else { - removeEpisodeLink(l.file, target); + if (removeEpisodeLink(l.file, target) === null) { + throw new Error(`refused to delink ${l.id}: the learning path does not resolve safely inside the knowledge store`); + } removedLinks.push(l.id); } } @@ -1413,7 +1478,7 @@ export function migrateStrandedStore({ workspace, home, log = () => {} }) { // location now; clear it so a normal withStoreTransaction against the // freshly migrated store is never blocked by a lock this function // itself created. - fs.rmSync(path.join(targetDir, '.lock'), { recursive: true, force: true }); + releaseStoreLock(path.join(targetDir, '.lock'), lock.token); log(`migrated stranded store: ${legacyDir} -> ${targetDir}`); return { pass: true, @@ -1441,7 +1506,9 @@ export function migrateStrandedStore({ workspace, home, log = () => {} }) { // targetDir/.lock is cleared separately above. Best effort: a cleanup // failure must never mask the real result. try { - fs.rmSync(lockPath, { recursive: true, force: true }); + // Owner-checked (S2): only ever release the lock THIS call acquired. On + // the success path legacyDir has been renamed away, so this is a no-op. + releaseStoreLock(lockPath, lock.token); } catch { // ignored — a leftover lock is taken over as stale on the next attempt } diff --git a/packages/harness/lib/knowledge/apply.mjs b/packages/harness/lib/knowledge/apply.mjs index d8986ce3..9fafdb86 100644 --- a/packages/harness/lib/knowledge/apply.mjs +++ b/packages/harness/lib/knowledge/apply.mjs @@ -29,7 +29,8 @@ import { absorbOrAbort, mirrorLearnings } from './admin.mjs'; import { parseMergedFrom } from './listing.mjs'; import { resolveWriteLayer, ensureBucket, migrateRenamedBucket, episodeEligibleForLayer, storeHasBuckets } from './layer.mjs'; import { bucketDirFor, readBucketMeta, bucketAncestryOk, isSafeBucketKey } from './overlay.mjs'; -import { readFileNoFollow, assertNoSymlinkAncestors, assertRealpathContained } from '../fs-safe.mjs'; +import { readFileNoFollow, assertNoSymlinkAncestors } from '../fs-safe.mjs'; +import { readLearningFile, writeLearningFile } from './learning-io.mjs'; /** * The SOLE writer of the learnings store. The consolidation skill emits an @@ -1022,6 +1023,11 @@ export function applyOps({ return true; }); if (!eps.length) return null; + // Raised (outside the best-effort try below, which would otherwise + // swallow it) when the strike sub-commit failed AND its own rollback + // could not clean up: the tree is then dirty with a partial ledger write + // that the transaction's finalize would commit. Nothing may proceed. + let unrecoverable = null; try { // STRIKES AND QUARANTINE MARKERS ARE STORE-GLOBAL, NEVER PER-BUCKET // (P2). Three strikes is an anti-collapse control over an EPISODE, and @@ -1047,12 +1053,26 @@ export function applyOps({ appendLedger(dir, entries); const commitRes = commitStore(dir, `consolidate: record failure ${code}`); if (!commitRes.ok) { - rollbackStore(dir); + // Verified rollback (S4): `rollbackStore` now reports whether the + // tree is actually clean again. A failed discard cannot be folded + // into the rejection note — the partial ledger append is still on + // disk and finalize would commit it. + const rb = rollbackStore(dir); + if (!rb.ok) { + unrecoverable = `strike recording failed to commit (${commitRes.stderr || 'git commit failed'}) and could not be rolled back: ${rb.stderr}`; + } return `strike recording failed to commit: ${commitRes.stderr || 'git commit failed'}`; } recordCheckpoint(); - } catch { - // Best effort — failure recording must never mask the original rejection. + } catch (err) { + // A recordCheckpoint abort is a real transaction failure, never a + // best-effort miss — it means a checkpoint could not be recorded, and + // swallowing it would leave a stale checkpoint a later recovery resets + // past. Everything else stays best effort: failure recording must never + // mask the original rejection. + if (err instanceof StoreTransactionAbort) throw err; + } finally { + if (unrecoverable) throw new Error(unrecoverable); } return null; } @@ -1278,7 +1298,21 @@ export function applyOps({ exitCode: 1, }; } - const currentSha = crypto.createHash('sha256').update(fs.readFileSync(sourceLearning.file)).digest('hex'); + // Through the choke point (S1) — the same reader promote.mjs hashed + // with, so the two sides can never disagree about what the bytes are, + // and neither can be steered onto an outside file by a symlink. + const sourceText = readLearningFile(sourceLearning.file); + if (sourceText === null) { + return { + kind: 'reject', + applied: [], + governed: [], + rejected: [fail('E_SCHEMA', `op ${i}: promotion source ${src.id} could not be read safely from the store`)], + committed: false, + exitCode: 1, + }; + } + const currentSha = crypto.createHash('sha256').update(sourceText).digest('hex'); if (currentSha !== src.sha256) { return { kind: 'reject', @@ -1413,12 +1447,27 @@ export function applyOps({ const notCandidate = assertCandidacy(op, i); if (notCandidate) return rejectOp(notCandidate.code, notCandidate.reason, op.episodes); } - // merged_from is only ever a MERGE-derived (op.targets) or ADD/SUPERSEDE- - // carried-forward field — an op JSON asserting it directly must be an - // array of strings, or renderLearning's `mergedFrom.join(', ')` throws on - // a non-array (e.g. a string) instead of failing closed. - if (op.merged_from !== undefined && (!Array.isArray(op.merged_from) || !op.merged_from.every((v) => typeof v === 'string'))) { - return rejectOp('E_SCHEMA', `op ${i}: merged_from must be an array of strings`, op.episodes); + // MERGED_FROM IS DERIVED, NEVER ASSERTED. `merged_from` records that + // THIS store consolidated those ids into this claim — a provenance + // statement about a mutation the writer performed, tombstoning each + // target in the same run. Accepting it from the op JSON let any op + // (including a hand-edited promotion op-set, which is a plain + // model/user-writable file at `.harness/promote-ops.json`) STAMP that + // provenance verbatim onto a fresh golden claim while merging nothing: + // forged consolidation history, indistinguishable on disk and in + // `learnings --why` from the real thing. The only legitimate values come + // from MERGE's own validated `op.targets` (below) and from a re-render + // carrying an ALREADY-PERSISTED value forward (composeStrengthenedLearning + // / serializeLearning) — neither of which reads this field. So the field + // is not authorable at all: asserting it is an E_SCHEMA rejection, an + // allow-list rather than a shape check on something that should never + // have been accepted. + if (op.merged_from !== undefined) { + return rejectOp( + 'E_SCHEMA', + `op ${i}: merged_from is derived from a MERGE's own targets and cannot be asserted by an op`, + op.episodes + ); } // Shared between the inactive-target exemption (below) and the @@ -1859,7 +1908,9 @@ export function applyOps({ status, source, supersededBy: null, - mergedFrom: op.op === 'MERGE' ? op.targets : op.merged_from, + // Only a MERGE's own validated targets — never an op-asserted value + // (rejected outright above). + mergedFrom: op.op === 'MERGE' ? op.targets : null, provenance, }); // Byte-cap decision (Phase 1, recorded in the plan's Implementation @@ -1893,6 +1944,9 @@ export function applyOps({ if (op.op !== 'STRENGTHEN') continue; const target = existing.get(op.target); const content = composeStrengthenedLearning(target, op.episodes, workspace, copilotHome); + if (content === null) { + return rejectOp('E_TARGET', `op ${op.target}: learning file could not be read safely from the store`, op.episodes); + } // Same byte-cap decision as the fresh-write check above: the preserved // provenance lines are excluded from the measured size, so a near-cap // learning that carries commit/branch/base can still be strengthened @@ -1928,7 +1982,19 @@ export function applyOps({ // transaction's finalize commit published the stale bucket anyway. const moved = assertHeadUnmoved(); if (moved) { - rollbackToCheckpoint(); + // ACT ON THE ROLLBACK RESULT (S4). This return path is what makes + // E_HEAD_MOVED's "nothing was written" promise true — the bucket + // materialization above must be gone before the transaction's finalize + // commit runs. A rollback that FAILED means the materialization is still + // sitting in the tree, so returning the rejection normally would let + // finalize publish it under a message saying nothing happened. Throw + // instead: the transaction reports a hard failure, loudly, with the git + // reason attached. + if (!rollbackToCheckpoint()) { + throw new Error( + `${moved.rejected[0].reason} — and the store could not be rolled back to its checkpoint; the run is aborted with the store left for manual inspection` + ); + } return moved; } @@ -1952,8 +2018,13 @@ export function applyOps({ for (const { op, id, domain, slug, content } of writes) { const file = path.join(layerRoot, 'learnings', domain, `${slug}.md`); - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, content, 'utf8'); + // Through the choke point (S1): contained, atomic, never through a + // symlink. A refusal is a hard failure — the throw propagates out of + // runOnce and withStoreTransaction rolls the whole run back, rather than + // reporting a learning as applied when nothing landed. + if (!writeLearningFile(file, content)) { + throw new Error(`refused to write ${id}: the learning path does not resolve safely inside the knowledge store`); + } applied.push({ op: op.op, id }); for (const e of op.episodes) ledgerEntries.push({ path: e.path, sha256: e.sha256, learning: id, at }); // A SUPERSEDE whose target is the SAME id as the file just written @@ -1965,14 +2036,18 @@ export function applyOps({ // content at itself, so that step only runs when target !== id. if (op.op === 'SUPERSEDE' && op.target !== id) { const target = existing.get(op.target); - updateFrontmatterField(target.file, 'superseded_by', id); + if (!updateFrontmatterField(target.file, 'superseded_by', id)) { + throw new Error(`refused to tombstone ${op.target}: the learning path does not resolve safely inside the knowledge store`); + } } // A MERGE tombstones EVERY target it consolidates into the new id — // none of them can equal `id` (MERGE always writes a brand-new id). if (op.op === 'MERGE') { for (const t of op.targets) { const target = existing.get(t); - updateFrontmatterField(target.file, 'superseded_by', id); + if (!updateFrontmatterField(target.file, 'superseded_by', id)) { + throw new Error(`refused to tombstone ${t}: the learning path does not resolve safely inside the knowledge store`); + } } } } @@ -2041,11 +2116,18 @@ export function applyOps({ log(`consolidate: governance record for ${id} has an unsafe promote target (${entry.to}) — skipped reapply`); continue; } - const text = fs.readFileSync(file, 'utf8'); + const text = readLearningFile(file); + if (text === null) { + throw new Error(`refused to reapply governance to ${id}: the learning path does not resolve safely inside the knowledge store`); + } const { fm, body } = parseLearningFrontmatter(text); - fs.writeFileSync(file, serializeLearning({ ...fm, promoted_to: entry.to }, body), 'utf8'); + if (!writeLearningFile(file, serializeLearning({ ...fm, promoted_to: entry.to }, body))) { + throw new Error(`refused to reapply governance to ${id}: the learning path does not resolve safely inside the knowledge store`); + } } else { - updateFrontmatterField(file, 'status', entry.action === 'retire' ? 'retired' : 'disputed'); + if (!updateFrontmatterField(file, 'status', entry.action === 'retire' ? 'retired' : 'disputed')) { + throw new Error(`refused to reapply governance to ${id}: the learning path does not resolve safely inside the knowledge store`); + } } governed.push({ id, action: entry.action }); } @@ -2054,7 +2136,9 @@ export function applyOps({ // phase above verbatim — never recomputed here, so there is exactly one // place that decides a STRENGTHEN's rendered bytes. for (const { op, target, content } of strengthenWrites) { - fs.writeFileSync(target.file, content, 'utf8'); + if (!writeLearningFile(target.file, content)) { + throw new Error(`refused to strengthen ${op.target}: the learning path does not resolve safely inside the knowledge store`); + } applied.push({ op: 'STRENGTHEN', id: op.target }); for (const e of op.episodes) ledgerEntries.push({ path: e.path, sha256: e.sha256, learning: op.target, at }); } @@ -2080,19 +2164,22 @@ export function applyOps({ for (const entry of [...writes, ...strengthenWrites]) { const src = entry.op.source?.id ? promotionSources.get(entry.op.source.id) : null; if (!src) continue; - // Defense in depth (fs-safe.mjs's own documented discipline): this is - // the one write in this module that targets a path under - // `branches//`, a directory tree a human hand-edits. A symlinked - // bucket component must never let the tombstone write land outside the - // store. Fail CLOSED — a throw here propagates out of runOnce and - // withStoreTransaction rolls the whole promotion back, rather than - // leaving a golden claim whose source was never tombstoned. - if (!assertRealpathContained(dir, path.relative(dir, src.file))) { - throw new Error(`refused to tombstone ${src.id}: bucket learning path escapes the knowledge store`); + // Through the choke point (S1): this write targets a path under + // `branches//`, a directory tree a human hand-edits, so both the + // read and the write are contained against the STORE root (which the + // choke point derives from the path shape — a bucket root would leave a + // symlinked `branches/` above the walked span). Fail CLOSED — a throw + // here propagates out of runOnce and withStoreTransaction rolls the + // whole promotion back, rather than leaving a golden claim whose source + // was never tombstoned. + const text = readLearningFile(src.file); + if (text === null) { + throw new Error(`refused to tombstone ${src.id}: bucket learning path does not resolve safely inside the knowledge store`); } - const text = fs.readFileSync(src.file, 'utf8'); const parsedSource = parseLearningFrontmatter(text); - fs.writeFileSync(src.file, serializeLearning({ ...parsedSource.fm, promoted_to_golden: src.id }, parsedSource.body), 'utf8'); + if (!writeLearningFile(src.file, serializeLearning({ ...parsedSource.fm, promoted_to_golden: src.id }, parsedSource.body))) { + throw new Error(`refused to tombstone ${src.id}: bucket learning path does not resolve safely inside the knowledge store`); + } appendGovernance(dir, { id: src.id, action: 'absorb-branch', @@ -2114,7 +2201,9 @@ export function applyOps({ for (const d of disputes) { const target = existing.get(d.target); - updateFrontmatterField(target.file, 'status', 'disputed'); + if (!updateFrontmatterField(target.file, 'status', 'disputed')) { + throw new Error(`refused to dispute ${d.target}: the learning path does not resolve safely inside the knowledge store`); + } rejected.push({ ...fail('E_DISPUTED', 'disputed-pending-human'), reason: 'disputed-pending-human', target: d.target }); } @@ -2260,8 +2349,19 @@ export function applyOps({ ...staleExtra, }; } +/** + * Set one frontmatter field on an existing learning file. Both halves go + * through the choke point (S1) — this used to be a bare readFileSync/ + * writeFileSync pair, and it is reached from four call sites (SUPERSEDE and + * MERGE tombstones, governance retire/dispute reapply, the dispute loop) plus + * lifecycle.mjs's retire/dispute/confirm, so a symlink planted at a learning + * path was followed by all six. Returns true on success, false when the path + * is not a safely-resolvable learning file inside the store; callers treat + * false as a hard failure and let the transaction roll back. + */ export function updateFrontmatterField(file, field, value) { - const text = fs.readFileSync(file, 'utf8'); + const text = readLearningFile(file); + if (text === null) return false; const re = new RegExp(`^${field}:.*$`, 'm'); // The insertion fallback (field absent from frontmatter) must tolerate a // CRLF-terminated leading `---` — an LF-only regex silently no-ops on a @@ -2271,7 +2371,7 @@ export function updateFrontmatterField(file, field, value) { const next = re.test(text) ? text.replace(re, `${field}: ${value}`) : text.replace(/^---(\r?\n)/, (_, nl) => `---${nl}${field}: ${value}${nl}`); - fs.writeFileSync(file, next, 'utf8'); + return writeLearningFile(file, next); } // Composes a STRENGTHEN's rendered content WITHOUT writing it — split out of @@ -2281,7 +2381,12 @@ export function updateFrontmatterField(file, field, value) { // the strengthenWrites loop in the mutation phase, which writes this exact // string back verbatim. function composeStrengthenedLearning(target, episodes, workspace, copilotHome) { - const text = fs.readFileSync(target.file, 'utf8'); + // Through the choke point (S1): null means the target is not safely readable + // as a learning (symlinked leaf/ancestor, escaped path, over-cap, vanished), + // and the caller turns that into an E_TARGET rejection rather than composing + // a rewrite of something outside the store. + const text = readLearningFile(target.file); + if (text === null) return null; const { fm, body } = parseLearningFrontmatter(text); const seen = new Set((fm.episodes || []).map((e) => `${e.path}@${e.sha256}`)); const merged = [...(fm.episodes || [])]; diff --git a/packages/harness/lib/knowledge/learning-io.mjs b/packages/harness/lib/knowledge/learning-io.mjs new file mode 100644 index 00000000..cbdd278c --- /dev/null +++ b/packages/harness/lib/knowledge/learning-io.mjs @@ -0,0 +1,181 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { DEFAULT_MAX_BYTES, readFileNoFollow, writeFileContained, assertNoSymlinkAncestors, assertRealpathContained } from '../fs-safe.mjs'; + +/** + * THE ONE CHOKE POINT FOR LEARNING-FILE I/O (S1). + * + * A learning file is the only content in the store a human hand-edits in + * place, so its path is the store's single largest attacker-influenced + * surface: anyone who can write into `/learnings/` can replace a + * learning with a SYMLINK and, unless every reader and writer refuses to + * follow it, make the CLI read an arbitrary outside file into store history / + * a workspace mirror, or overwrite an arbitrary outside file with a rendered + * learning. + * + * Four consecutive rounds of review fixed this ONE CALL SITE AT A TIME — + * absorb refused the symlink while `listLearnings`, `mirrorLearnings`, + * `updateFrontmatterField`, the ADD/SUPERSEDE/STRENGTHEN writers, the + * promotion tombstone, the purge delink, and lifecycle's status write all + * still followed it. The class survived every fix because the class was still + * REPRESENTABLE: `fs.readFileSync(learning.file)` was a legal thing to write. + * + * This module removes that. Every read, write, delete, and size check of a + * learning file goes through the four functions below; `fs` is not imported + * for learning paths anywhere else in lib/knowledge/. A symlink planted at a + * learning path is therefore INERT EVERYWHERE: + * - never read through (readLearningFile → readFileNoFollow) + * - never written through (writeLearningFile → writeFileContained) + * - never deleted through (removeLearningFile → assertRealpathContained) + * - never listed as a learning (listLearnings skips a null read) + * - never mirrored (mirrorLearnings skips a null read) + * and the planted link ITSELF is quarantined out of `learnings/` by the next + * absorb (quarantineSymlinkedLearning) rather than left live for the next + * reader to trip over — leaving it in place is exactly what let the previous + * round's "refused in absorb" fix still end in a truncated `~/.zshrc`. + * + * NO ROOT ARGUMENT, BY DESIGN. An earlier draft took `(root, file)`; that just + * moves the defect to "which root did this caller pass?" — a caller holding a + * bucket root (`/branches/`) would contain against the bucket, and + * a symlinked `/branches` would escape containment while satisfying it. + * The containment root is DERIVED from the path's own required shape instead, + * so no caller can supply a wrong one: + * + * /learnings//.md + * /branches//learnings//.md + * + * Anything not matching that allow-listed shape (rule 3: allow-lists, not + * deny-lists) is refused outright — there is no "unknown shape, assume the + * caller knows best" path. + */ + +/** Quarantine bucket for planted symlinks. Gitignored by ensureStore (S2), so + * a quarantined link is never staged into store history nor swept by + * `git clean -fd`. */ +export const QUARANTINE_DIR = '.quarantine'; + +/** + * Derive `{ storeRoot, rel }` from a learning file path's own shape, or null + * when the path is not a learning path at all. Purely lexical (path.resolve + + * component inspection) — it never touches the filesystem, so it cannot be + * raced, and it is the single definition of "which root contains this file" + * that every function below shares. + */ +export function learningPathParts(file) { + if (typeof file !== 'string' || !file) return null; + const full = path.resolve(file); + const parts = full.split(path.sep); + const n = parts.length; + // /learnings//.md — exactly one domain level. + if (n < 4) return null; + if (parts[n - 3] !== 'learnings') return null; + if (!parts[n - 2] || !parts[n - 1].endsWith('.md')) return null; + const layerParts = parts.slice(0, n - 3); + // A bucket layer root is `/branches/`; golden's layer root + // IS the store root. Contain against the STORE root in both cases so a + // symlinked `branches/` component is inside the walked span, not above it. + const isBucket = layerParts.length >= 2 && layerParts[layerParts.length - 2] === 'branches'; + const rootParts = isBucket ? layerParts.slice(0, layerParts.length - 2) : layerParts; + const storeRoot = rootParts.join(path.sep) || path.sep; + const rel = path.relative(storeRoot, full); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null; + return { storeRoot, rel, full, bucket: isBucket }; +} + +/** + * Read a learning file, or null when it must not be read: not a learning + * path, a symlink at the leaf or any ancestor between the store root and it, + * resolving outside the store, over DEFAULT_MAX_BYTES (the read-size DoS cap + * every other less-trusted read shares), or simply absent. Null is the ONE + * signal every caller acts on — skip the entry, refuse the op — so a symlink + * and a missing file are indistinguishable to a reader, which is exactly the + * inertness this module promises. + */ +export function readLearningFile(file) { + const p = learningPathParts(file); + if (!p) return null; + // Cheap scan-time ancestor pre-filter first (short-circuits before any + // open), then readFileNoFollow's canonicalize-after-acquire closes the + // ancestor-swap window the walk cannot. + if (!assertNoSymlinkAncestors(p.storeRoot, p.rel)) return null; + return readFileNoFollow(p.full, { root: p.storeRoot, maxBytes: DEFAULT_MAX_BYTES }); +} + +/** + * Contained, atomic learning write (create-empty → verify → write-through-fd → + * rename). Returns true only when the bytes landed at a path proven inside the + * store; false on any refusal. The rename REPLACES a leaf rather than writing + * through it, and `assertNoSymlinkAncestors` inside `writeFileContained` + * refuses a symlinked leaf before that — so neither the link nor its target is + * ever written. + */ +export function writeLearningFile(file, content) { + const p = learningPathParts(file); + if (!p) return false; + return Boolean(writeFileContained(p.storeRoot, p.rel, content)); +} + +/** + * Remove a learning file. Refuses a symlink (assertRealpathContained rejects a + * symlinked leaf or ancestor) and anything whose real path escapes the store, + * so a purge cascade can never unlink an outside file. Returns true only when + * something was actually removable. + */ +export function removeLearningFile(file) { + const p = learningPathParts(file); + if (!p) return false; + const full = assertRealpathContained(p.storeRoot, p.rel); + if (!full) return false; + try { + fs.rmSync(full, { force: true }); + return true; + } catch { + return false; + } +} + +/** + * Move a symlink planted AT a learning path out of `learnings/` and into + * `/.quarantine/`, returning the store-relative quarantine path (or + * null when there was nothing to quarantine). + * + * WHY MOVE RATHER THAN LEAVE OR DELETE. Leaving it is what caused the finding + * this module closes: every reader refused it, but it stayed on disk looking + * like an active learning to anything that only checked `readdir`, and the + * next writer to reach for that id had a live link waiting. Deleting it would + * destroy something a human may have put there deliberately. Renaming the LINK + * (rename never follows a symlink, and never touches its target) makes it + * inert while preserving it for inspection, and the move is reported by the + * caller so a person sees that it happened. + * + * Only the leaf may be a symlink: every ancestor from the store root down to + * the containing domain directory must be a real directory, or the rename + * itself could be steered outside the store — in that case this refuses and + * the caller simply logs. + */ +export function quarantineSymlinkedLearning(file) { + const p = learningPathParts(file); + if (!p) return null; + const parentRel = path.dirname(p.rel); + if (!assertNoSymlinkAncestors(p.storeRoot, parentRel)) return null; + let stat; + try { + stat = fs.lstatSync(p.full); + } catch { + return null; // nothing there (or unreadable) — nothing to quarantine + } + if (!stat.isSymbolicLink()) return null; + const destRel = path.join( + QUARANTINE_DIR, + `${p.rel.split(path.sep).join('__')}.${Date.now()}-${process.pid}.symlink` + ); + const dest = assertNoSymlinkAncestors(p.storeRoot, destRel); + if (!dest) return null; + try { + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.renameSync(p.full, dest); + } catch { + return null; + } + return destRel.split(path.sep).join('/'); +} diff --git a/packages/harness/lib/knowledge/lifecycle.mjs b/packages/harness/lib/knowledge/lifecycle.mjs index dd8d0763..150a9f54 100644 --- a/packages/harness/lib/knowledge/lifecycle.mjs +++ b/packages/harness/lib/knowledge/lifecycle.mjs @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { storeDir, withStoreTransaction, StoreTransactionAbort, listLearnings, serializeLearning, appendGovernance } from './store.mjs'; import { updateFrontmatterField, todayClamped, rebuildIndex } from './apply.mjs'; +import { writeLearningFile } from './learning-io.mjs'; import { absorbOrAbort, mirrorLearnings } from './admin.mjs'; /** @@ -143,7 +144,13 @@ export function setLearningStatus({ workspace, id, action, reason, to, home, log // safe route to add it in its canonical position. status is left // untouched — promotion never overwrites the learning's own status. const nextFm = { ...learning.fm, promoted_to: promotedTo }; - fs.writeFileSync(learning.file, serializeLearning(nextFm, learning.body), 'utf8'); + // Through the choke point (S1) — a symlink at a learning path is refused + // here exactly as it is in every other learning writer. Fail closed: the + // throw rolls the whole lifecycle transaction back rather than recording + // a governance `promote` for a frontmatter write that never landed. + if (!writeLearningFile(learning.file, serializeLearning(nextFm, learning.body))) { + throw new Error(`refused to promote ${id}: the learning path does not resolve safely inside the knowledge store`); + } // Same as every other store writer (applyOps, absorbHandEdits, purge): // rebuild INDEX.md in the same commit as the mutation, so a promoted // learning drops out of the index immediately rather than waiting for @@ -162,8 +169,12 @@ export function setLearningStatus({ workspace, id, action, reason, to, home, log return { kind: 'success', commitMessage: `promote ${id}: ${promotedTo}`, status: 'promoted' }; } - updateFrontmatterField(learning.file, 'status', TARGET_STATUS[action]); - if (action === 'confirm') updateFrontmatterField(learning.file, 'last_confirmed', todayClamped()); + if (!updateFrontmatterField(learning.file, 'status', TARGET_STATUS[action])) { + throw new Error(`refused to ${action} ${id}: the learning path does not resolve safely inside the knowledge store`); + } + if (action === 'confirm' && !updateFrontmatterField(learning.file, 'last_confirmed', todayClamped())) { + throw new Error(`refused to confirm ${id}: the learning path does not resolve safely inside the knowledge store`); + } rebuildIndex(dir); // Governance record (Milestone 4): appended BEFORE the transaction's own // commit, same reasoning as the promote branch above — one commit diff --git a/packages/harness/lib/knowledge/listing.mjs b/packages/harness/lib/knowledge/listing.mjs index 672fcc36..89a5da13 100644 --- a/packages/harness/lib/knowledge/listing.mjs +++ b/packages/harness/lib/knowledge/listing.mjs @@ -38,13 +38,41 @@ function failureCounts(workspace) { return counts; } +/** + * EVERY SCALAR THIS MODULE EMITS IS SANITIZED HERE, NOT AT THE RENDER SITE. + * + * `trigger`, `claimLine`, and episode `path`/`plan` were already passed through + * `inertLine` — but `status`, `source`, episode `kind`, `id`, `supersededBy`, + * `promotedTo`, `mergedFrom`, and `lastConfirmed` were emitted RAW. They are + * `unquote`d frontmatter scalars, and `unquote` DECODES `\n`/`\r`/`\t` escapes + * back into real control characters — so a hand-edited or legacy learning can + * carry an embedded newline in any of them, and every one of them lands in a + * single-line human surface (`ui.line`, the `learningNote` status string, the + * muted episode bullets) as well as in `--json`. + * + * Fixing this at the render sites in commands.mjs would leave `--json` raw and + * would have to be re-remembered at every new surface. Fixing it HERE means the + * view objects these two functions return simply cannot carry a control char, + * whoever renders them. + * + * `status`, `source`, and episode `kind` are CODE SETS, so they get the + * stronger treatment: an allow-list (rule 3), not an escape pass. A value + * outside the set renders as `unknown` rather than as itself-with-spaces — + * a code set with an open range is not a code set. + */ +const STATUS_VALUES = new Set(['active', 'provisional', 'retired', 'disputed', 'superseded', 'promoted']); +const SOURCE_VALUES = new Set(['auto', 'human']); +const EPISODE_KINDS = new Set(['fix', 'insight', 'human-teaching']); + +const allowed = (set, value, fallback) => (set.has(value) ? value : fallback); + // fm.status never literally holds "superseded" (apply.mjs tracks it via the // separate superseded_by pointer) — synthesize it here since the listing row // carries a single status field and the render step needs it to fence pending rows. function effectiveStatus(fm) { if (fm.superseded_by) return 'superseded'; if (fm.promoted_to) return 'promoted'; - return fm.status || 'active'; + return allowed(STATUS_VALUES, fm.status || 'active', 'unknown'); } // Exported so apply.mjs's own STRENGTHEN path (the only other place that @@ -62,6 +90,16 @@ export function parseMergedFrom(raw) { return items.length ? items : null; } +/** parseMergedFrom for a RENDER surface: the same parse, then inertLine per id + * (see the code-set note above — merged_from ids are free-form scalars off a + * hand-editable frontmatter line, not a code set). apply.mjs's re-render path + * deliberately keeps using the raw parseMergedFrom: it is writing the value + * back to disk, where yamlQuote re-escapes it, not rendering it. */ +export function parseMergedFromForRender(raw) { + const items = parseMergedFrom(raw); + return items ? items.map((id) => inertLine(id)) : null; +} + export function listingView({ workspace, copilotHome, domain, home }) { const dir = storeDir(workspace, { home }); // Read-only: a storeless workspace must never be materialized by a listing @@ -75,9 +113,11 @@ export function listingView({ workspace, copilotHome, domain, home }) { .map((l) => { const { verified, plans } = verifiedAndPlans(l.fm); return { - id: l.id, + // A learning id is built from directory and file names, which on POSIX + // may contain any byte but `/` and NUL — including control chars. + id: inertLine(l.id), status: effectiveStatus(l.fm), - source: l.fm.source || 'auto', + source: allowed(SOURCE_VALUES, l.fm.source || 'auto', 'unknown'), // inertLine: a legacy/hand-edited learning's trigger can still carry // an embedded control char (store.mjs's doc comment) — collapsed to // a space so this listing row always renders as one line. @@ -120,23 +160,29 @@ export function whyView({ workspace, id, home }) { const failures = failureCounts(workspace).get(id) || 0; return { - id, + // `id` is the caller's lookup string echoed back into a rendered row. + id: inertLine(id), // inertLine: same render-side normalization as listingView above — a // legacy/hand-edited trigger can still carry an embedded control char. trigger: inertLine(redactSecrets(fm.trigger || '')), claimLine, status: effectiveStatus(fm), - source: fm.source || 'auto', - lastConfirmed: fm.last_confirmed || null, - supersededBy: fm.superseded_by || null, - promotedTo: fm.promoted_to || null, - mergedFrom: parseMergedFrom(fm.merged_from), + source: allowed(SOURCE_VALUES, fm.source || 'auto', 'unknown'), + // The remaining frontmatter scalars are free-form (a date, two learning + // ids, a workspace-relative primitive path), all `unquote`d off a + // hand-editable line, all rendered on a single line — same treatment. + lastConfirmed: fm.last_confirmed ? inertLine(fm.last_confirmed) : null, + supersededBy: fm.superseded_by ? inertLine(fm.superseded_by) : null, + promotedTo: fm.promoted_to ? inertLine(redactSecrets(String(fm.promoted_to))) : null, + mergedFrom: parseMergedFromForRender(fm.merged_from), // Episode paths and plan refs come from learning frontmatter, which is // hand-editable — same untrusted class as trigger/claim, so they get the // same treatment rather than being emitted raw. episodes: (fm.episodes || []).map((e) => ({ path: inertLine(redactSecrets(String(e.path || ''))), - kind: e.kind, + // Code set (episodeLines normalizes an unknown kind to 'fix' at WRITE + // time; a legacy or hand-edited file can still carry anything here). + kind: allowed(EPISODE_KINDS, e.kind, 'unknown'), plan: e.plan ? inertLine(redactSecrets(String(e.plan))) : null, })), verified, diff --git a/packages/harness/lib/knowledge/promote.mjs b/packages/harness/lib/knowledge/promote.mjs index a4fa9201..79076886 100644 --- a/packages/harness/lib/knowledge/promote.mjs +++ b/packages/harness/lib/knowledge/promote.mjs @@ -6,6 +6,7 @@ import { isActiveFm, MAX_OPS_PER_RUN } from './consolidate.mjs'; import { bucketDirFor, readBucketMeta, listBuckets, bucketAncestryOk, isSafeBucketKey } from './overlay.mjs'; import { deriveGitContext, isDetachedKey } from '../git-context.mjs'; import { writeFileContained } from '../fs-safe.mjs'; +import { readLearningFile } from './learning-io.mjs'; /** * `harness knowledge promote` (blueprint §5): emits a REVIEWABLE op-set at @@ -108,12 +109,25 @@ export function buildPromotionOps({ workspace, home, branchKey = null, ids = nul const skipped = []; const promotable = []; + // Through the choke point (S1), and the SAME reader apply.mjs re-hashes with + // at write time — so the emitted `source.sha256` and the write-time + // re-verification can never disagree about what the file's bytes are, and + // neither of them can be steered onto an outside file by a planted symlink. + const sourceSha = (learning) => { + const text = readLearningFile(learning.file); + return text === null ? null : crypto.createHash('sha256').update(text).digest('hex'); + }; for (const source of sources) { const decision = governance.get(source.id); if (decision && ['retire', 'dispute', 'promote'].includes(decision.action)) { skipped.push({ id: source.id, reason: `standing governance decision: ${decision.action}` }); continue; } + const sha256 = sourceSha(source); + if (sha256 === null) { + skipped.push({ id: source.id, reason: 'learning file could not be read safely from the store' }); + continue; + } const twin = goldenById.get(source.id); if (twin) { const sameClaim = (twin.fm.trigger || '') === (source.fm.trigger || '') && twin.body.trim() === source.body.trim(); @@ -128,7 +142,7 @@ export function buildPromotionOps({ workspace, home, branchKey = null, ids = nul op: 'STRENGTHEN', target: source.id, episodes: newEpisodes.map((e) => ({ path: e.path, sha256: e.sha256, kind: e.kind, plan: e.plan || null })), - source: { id: source.id, sha256: crypto.createHash('sha256').update(fs.readFileSync(source.file)).digest('hex') }, + source: { id: source.id, sha256 }, }); continue; } @@ -141,7 +155,7 @@ export function buildPromotionOps({ workspace, home, branchKey = null, ids = nul trigger: source.fm.trigger || '', body: source.body, episodes: (source.fm.episodes || []).filter((e) => e.path).map((e) => ({ path: e.path, sha256: e.sha256, kind: e.kind, plan: e.plan || null })), - source: { id: source.id, sha256: crypto.createHash('sha256').update(fs.readFileSync(source.file)).digest('hex') }, + source: { id: source.id, sha256 }, }); } diff --git a/packages/harness/lib/knowledge/store.mjs b/packages/harness/lib/knowledge/store.mjs index acb98a9a..666a8358 100644 --- a/packages/harness/lib/knowledge/store.mjs +++ b/packages/harness/lib/knowledge/store.mjs @@ -3,7 +3,8 @@ import path from 'node:path'; import crypto from 'node:crypto'; import { spawnSync } from 'node:child_process'; import { harnessGlobalHome } from '../paths.mjs'; -import { DEFAULT_MAX_BYTES, readFileNoFollow, writeFileContained, assertRealpathContained } from '../fs-safe.mjs'; +import { readFileNoFollow, writeFileContained, assertRealpathContained } from '../fs-safe.mjs'; +import { readLearningFile, QUARANTINE_DIR } from './learning-io.mjs'; /** * The local knowledge store: a CLI-managed git repo OUTSIDE the working tree @@ -118,12 +119,65 @@ export function assertStoreSchemaSupported(dir) { return recorded; } +/** + * The store's `.gitignore` (S2 — LOCK LOSS MUST BE STRUCTURALLY IMPOSSIBLE). + * + * `rollbackStore` runs `git clean -fd`, which sweeps every untracked directory + * in the store — including the `.lock` the running transaction is holding. The + * previous rounds patched that by re-asserting the lock with a bare + * `fs.mkdirSync(lockPath)` afterwards and swallowing EEXIST as "still there": + * if a second writer had grabbed the freed lock in that window, the first + * writer carried on regardless, `git add -A`-ed the other writer's in-flight + * files, and finally `rmSync`-ed THEIR lock. + * + * A `.gitignore` removes the window instead of racing inside it: `git clean` + * without `-x` never touches an ignored path, and `git add -A` never stages + * one. The lock (and its stale-takeover tombstones, and the symlink quarantine + * bucket) therefore survive every rollback by construction. Ownership tokens + * below are the second, independent layer — belt to this brace — because a + * store whose `.gitignore` a human deleted must still never release a lock it + * does not own. + * + * The transaction journal needs no entry: it lives at `.git/harness-txn.json`, + * which git neither stages nor cleans. + */ +const STORE_IGNORE_ENTRIES = ['/.lock/', '/.lock.stale-*', `/${QUARANTINE_DIR}/`]; + +/** + * Write or migrate the store `.gitignore`. Existing stores predate it, so this + * runs on EVERY open (ensureStore) rather than only at creation: a store built + * by an older CLI gains the entries the first time any command touches it, + * which is the only migration point that does not require the user to know a + * migration exists. Idempotent and additive — an entry a human already wrote + * is not duplicated, and lines this CLI does not own are preserved verbatim. + */ +function ensureStoreGitignore(dir) { + // fs-safe on both halves (rule 1): `.gitignore` sits in the store root, a + // directory a human writes to, so it is as symlink-plantable as any learning + // path — following one would append these entries to (and, with the read + // returning that file's content, rewrite) an arbitrary outside file. + const existing = readFileNoFollow(path.join(dir, '.gitignore'), { root: dir }) ?? ''; + const lines = existing.split('\n').map((l) => l.trim()); + const missing = STORE_IGNORE_ENTRIES.filter((entry) => !lines.includes(entry)); + if (!missing.length) return; + const header = existing ? (existing.endsWith('\n') ? '' : '\n') : '# harness knowledge store — never staged, never swept by `git clean -fd`\n'; + try { + // Best effort: an unwritable (or symlinked) `.gitignore` still lets the + // store run — it just falls back to the ownership-token layer below for + // lock safety, which is independent of this file. + writeFileContained(dir, '.gitignore', existing + header + missing.join('\n') + '\n'); + } catch { + // writeFileContained mkdirs the parent, which can throw on a hand-built store + } +} + export function ensureStore(workspace, { home, dryRun = false } = {}) { const dir = storeDir(workspace, { home }); assertStoreSchemaSupported(dir); const created = !fs.existsSync(path.join(dir, 'consolidated.jsonl')); if (dryRun) return { dir, created, git: fs.existsSync(path.join(dir, '.git')) }; fs.mkdirSync(path.join(dir, 'learnings'), { recursive: true }); + ensureStoreGitignore(dir); let gitOk = fs.existsSync(path.join(dir, '.git')); if (!gitOk) { gitOk = spawnSync('git', ['init', '-q'], { cwd: dir, encoding: 'utf8' }).status === 0; @@ -569,17 +623,16 @@ export function listLearnings(dir) { for (const f of fs.readdirSync(dPath)) { if (!f.endsWith('.md')) continue; const file = path.join(dPath, f); - // Read-size cap (sweep P3 DoS): CLI writes byte-cap a learning file, but a - // hand-planted over-cap file in the local store would otherwise be read - // whole on every listing/rank. Skip it — never read whole. - let size; - try { - size = fs.statSync(file).size; - } catch { - continue; - } - if (size > DEFAULT_MAX_BYTES) continue; - const text = fs.readFileSync(file, 'utf8'); + // THE ONLY READ (S1): `readLearningFile` (learning-io.mjs) is the store's + // single learning-file reader. It returns null — and this entry is simply + // not a learning — for a symlinked leaf or ancestor, a path resolving + // outside the store, a file over the DEFAULT_MAX_BYTES read cap (the + // hand-planted over-cap DoS this loop used to statSync for), or an absent + // file. That null is what makes a planted symlink INERT here: it is never + // presented as an active learning, so nothing downstream — retrieval, + // ranking, STRENGTHEN target resolution, the mirror — can reach it. + const text = readLearningFile(file); + if (text === null) continue; const { fm, body } = parseLearningFrontmatter(text); const slug = f.replace(/\.md$/, ''); out.push({ @@ -616,7 +669,7 @@ export function commitStore(dir, message) { if (addRes.status !== 0) { return { committed: false, ok: false, stderr: addRes.stderr || `git add exited ${addRes.status}` }; } - const statusRes = spawnSync('git', ['status', '--porcelain'], { cwd: dir, encoding: 'utf8' }); + const statusRes = spawnSync('git', ['status', '--porcelain', '-z'], { cwd: dir, encoding: 'utf8' }); if (statusRes.status !== 0) { return { committed: false, ok: false, stderr: statusRes.stderr || `git status exited ${statusRes.status}` }; } @@ -658,10 +711,109 @@ const STALE_LOCK_MS = 10 * 60 * 1000; * lock is genuinely held by someone else right now (`ageMs` is the lock * directory's current age, for a caller that wants to report it). */ +/** + * OWNER TOKENS (S2). The `.lock` directory alone says "someone holds this"; it + * never said WHO, so every re-assert and every release was an unverified + * guess. `fs.mkdirSync(lockPath)` after a `git clean -fd` swept the lock threw + * EEXIST when a SECOND process had taken it in the meantime, and that EEXIST + * was swallowed as "still there" — the first writer then continued inside a + * lock it no longer held and finally `rmSync`-ed the other writer's. + * + * A token — pid plus a random nonce, written INSIDE the lock directory the + * instant it is created — makes ownership checkable. `releaseStoreLock` and + * `reassertStoreLock` below both verify it and NEVER remove or claim a lock + * whose owner file names somebody else. The nonce (not just the pid) is what + * makes it sound across pid reuse and across two transactions in one process. + */ +const LOCK_OWNER_FILE = 'owner.json'; + +function newLockToken() { + return `${process.pid}-${crypto.randomBytes(12).toString('hex')}`; +} + +/** Stamp ownership into a lock directory we just created. Best effort: a lock + * whose owner file could not be written reads as `unknown` below, which is + * treated as NOT ours — fail closed, never claim what we cannot prove. */ +function writeLockOwner(lockPath, token) { + try { + // Contained write (rule 1), matching the contained read in `lockOwnership`: + // the lock directory is freshly mkdir'd, but the owner file inside it is + // still a path another process could reach, so neither half touches it with + // a bare `fs` call. + writeFileContained(lockPath, LOCK_OWNER_FILE, JSON.stringify({ token, pid: process.pid, at: new Date().toISOString() }) + '\n'); + } catch { + // ignored — see the doc comment above: an unwritable owner stamp reads back + // as `foreign`, i.e. NOT ours, which fails closed. + } +} + +/** + * `'absent'` (no lock directory at all), `'owned'` (the owner file names + * `token`), or `'foreign'` (it names something else, or cannot be read at all + * — an unreadable/absent/symlinked owner file is never assumed to be ours). + * The read goes through `readFileNoFollow` contained to the lock directory: + * `.lock` sits in a directory a human can write to, so the owner file is as + * attacker-influenced as anything else in the store. + */ +export function lockOwnership(lockPath, token) { + if (!fs.existsSync(lockPath)) return 'absent'; + const text = readFileNoFollow(path.join(lockPath, LOCK_OWNER_FILE), { root: lockPath }); + if (text === null) return 'foreign'; + try { + const parsed = JSON.parse(text); + return parsed && parsed.token && parsed.token === token ? 'owned' : 'foreign'; + } catch { + return 'foreign'; + } +} + +/** + * Re-establish the lock after an operation that COULD have removed it, without + * ever stealing one. `.lock` is gitignored (STORE_IGNORE_ENTRIES) so + * `git clean -fd` can no longer sweep it, but this is the independent second + * layer: absent → recreate and re-stamp (it was ours, nobody else took it); + * ours → nothing to do; SOMEBODY ELSE'S → return false, and the caller must + * abort rather than proceed inside a lock it does not hold. + */ +export function reassertStoreLock(lockPath, token) { + const state = lockOwnership(lockPath, token); + if (state === 'owned') return true; + if (state === 'foreign') return false; + try { + fs.mkdirSync(lockPath, { recursive: true }); + } catch { + return false; + } + // Between the existence check and this mkdir another writer may have won the + // race; re-verify rather than assume the mkdir means we hold it. + writeLockOwner(lockPath, token); + return lockOwnership(lockPath, token) === 'owned'; +} + +/** + * Release ONLY a lock this holder owns. Returns true when the lock is gone (or + * was already gone) because of us, false when it belongs to someone else and + * was therefore LEFT ALONE — the single rule that keeps a confused writer from + * unlocking a live transaction it never held. + */ +export function releaseStoreLock(lockPath, token) { + const state = lockOwnership(lockPath, token); + if (state === 'absent') return true; + if (state === 'foreign') return false; + try { + fs.rmSync(lockPath, { recursive: true, force: true }); + return true; + } catch { + return false; + } +} + export function acquireStoreLock(lockPath) { + const token = newLockToken(); try { fs.mkdirSync(lockPath); - return { acquired: true, staleLockNote: null }; + writeLockOwner(lockPath, token); + return { acquired: true, staleLockNote: null, token }; } catch { // fall through to the stale-takeover attempt below } @@ -686,6 +838,7 @@ export function acquireStoreLock(lockPath) { let staleLockNote = null; try { fs.mkdirSync(lockPath); + writeLockOwner(lockPath, token); staleLockNote = `stale lock (${Math.round(ageMs / 60000)}m old) removed`; recovered = true; } catch { @@ -696,10 +849,12 @@ export function acquireStoreLock(lockPath) { } catch { // ignored — an orphaned tombstone is harmless disk debris either way } - if (recovered) return { acquired: true, staleLockNote }; + // Only a lock the owner stamp confirms as OURS counts as acquired: the + // mkdir above can win while a racing takeover re-stamps it a moment later. + if (recovered && lockOwnership(lockPath, token) === 'owned') return { acquired: true, staleLockNote, token }; } } - return { acquired: false, ageMs, lockPath }; + return { acquired: false, ageMs, lockPath, token: null }; } /** @@ -715,10 +870,36 @@ export function acquireStoreLock(lockPath) { * keeping the tree clean before returning — otherwise withStoreTransaction's * own finalize step would inherit the dirt and risk masking the real * rejection behind a generic commit-failure error. + * + * VERIFIED, NEVER ASSUMED (S4). Both spawns used to be fire-and-forget and + * this function returned nothing, so a `git reset --hard` that failed — a + * stray `.git/index.lock`, a read-only store, a bad target sha — left the + * materialized mutation sitting in the working tree while every caller + * proceeded as though the store were clean, and the transaction's own + * `commitStore` then PUBLISHED exactly what the rollback existed to discard. + * It now reports `{ ok, stderr }`: both spawns are checked, and — because a + * zero exit is not the same thing as a clean tree — the tree itself is + * re-read afterwards and a still-dirty store is a FAILED rollback. Every + * caller acts on it; none may ignore it. */ export function rollbackStore(dir, targetSha) { - spawnSync('git', targetSha ? ['reset', '--hard', targetSha] : ['reset', '--hard'], { cwd: dir, encoding: 'utf8' }); - spawnSync('git', ['clean', '-fd'], { cwd: dir, encoding: 'utf8' }); + const reset = spawnSync('git', targetSha ? ['reset', '--hard', targetSha] : ['reset', '--hard'], { cwd: dir, encoding: 'utf8' }); + if (reset.error || reset.status !== 0) { + return { ok: false, stderr: reset.error ? reset.error.message : reset.stderr || `git reset exited ${reset.status}` }; + } + const clean = spawnSync('git', ['clean', '-fd'], { cwd: dir, encoding: 'utf8' }); + if (clean.error || clean.status !== 0) { + return { ok: false, stderr: clean.error ? clean.error.message : clean.stderr || `git clean exited ${clean.status}` }; + } + // Exit codes alone are not proof: `git reset --hard ` and a + // partially-applied clean can both leave content behind. Judge the POST + // STATE — the same discipline purgeEpisode already applies to its own + // completion check. `.lock`/`.quarantine` are gitignored (S2), so they never + // show up here as false dirt. + const remaining = dirtyPaths(dir); + if (remaining === null) return { ok: false, stderr: 'git status unreadable after rollback — cannot confirm the store is clean' }; + if (remaining.length) return { ok: false, stderr: `store still dirty after rollback: ${remaining.slice(0, 5).join(', ')}` }; + return { ok: true, stderr: null }; } /** @@ -728,7 +909,7 @@ export function rollbackStore(dir, targetSha) { * never left for transaction rollback to destroy) — the shapes * absorbHandEdits (admin.mjs) treats as an absorbable hand edit. Capture * groups: [1] = bucket key (undefined for golden), [2] = domain, [3] = slug. - * Exported for admin.mjs's own porcelain scan; no longer used by store.mjs + * Exported for admin.mjs's own `parsePorcelainZ` scan; no longer used by store.mjs * itself (an earlier version of the rollback guard here matched dirty paths * against it, which incorrectly protected a path a transaction's OWN * legitimate mutation re-dirtied after an earlier absorb commit already @@ -737,17 +918,61 @@ export function rollbackStore(dir, targetSha) { */ export const LEARNING_FILE_RE = /^(?:branches\/([^/]+)\/)?learnings\/([^/]+)\/([^/]+)\.md$/; -/** Parse one `git status --porcelain` line into its status code and path — - * shared by admin.mjs's absorbHandEdits scan. */ -export function parsePorcelainLine(line) { - const status = line.slice(0, 2); - let rest = line.slice(3); - const arrow = rest.indexOf(' -> '); - if (arrow !== -1) rest = rest.slice(arrow + 4); // rename/copy: use the new path - if (rest.startsWith('"') && rest.endsWith('"')) { - rest = rest.slice(1, -1).replace(/\\(.)/g, '$1'); // git-quoted path (rare) +/** + * Statuses whose porcelain entry carries a SECOND, NUL-terminated field (the + * original path). `git status --porcelain -z` emits `XY \0\0` for a + * rename or copy — new path FIRST, unlike the line-oriented format's + * `XY -> ` — so the parser must consume that extra field or every + * subsequent entry is misaligned by one. + */ +const PORCELAIN_PAIRED = new Set(['R', 'C']); + +/** + * THE ONE PORCELAIN PARSER (S3), NUL-DELIMITED. + * + * The line-oriented `git status --porcelain` format is lossy in two ways that + * both produced silent, reported-as-success no-ops here: + * + * 1. C-QUOTING. A path with a non-ASCII byte, a quote, a backslash, or a + * control char is emitted quoted and octal-escaped: + * `?? "learnings/caf\303\251/x.md"`. The old parser stripped the quotes + * and ran `.replace(/\\(.)/g, '$1')`, which turns `\303\251` into + * `303251` — decoding `learnings/café/x.md` as `learnings/caf303251/x.md`. + * Residue discard then "discarded" a path that does not exist (reporting + * success), absorb missed the same file, and the next `commitStore`'s + * `git add -A` swept the REAL file into store history unvalidated and + * unscanned. + * 2. THE ` -> ` SPLIT. The old parser split on any literal ` -> ` anywhere in + * the rest of the line, so an ordinary file named `learnings/a -> b/c.md` + * parsed as `b/c.md`. + * + * `-z` has neither problem: pathnames are emitted VERBATIM (no quoting, no + * escaping — the terminator is NUL, which cannot occur in a pathname) and the + * rename pair is two separate NUL-terminated fields instead of an in-band + * separator. Parsing that is the only correct option, so it is the only one + * available: `parsePorcelainLine` is gone, and both consumers (dirtyPaths + * below, absorbHandEdits in admin.mjs) call THIS function on `-z` output. + * + * Returns `[{ status, path, origPath }]`. `origPath` is null except on a + * rename/copy, where it names where the file came from. + */ +export function parsePorcelainZ(stdout) { + const fields = String(stdout ?? '').split('\0'); + const out = []; + for (let i = 0; i < fields.length; i += 1) { + const entry = fields[i]; + // The final NUL leaves a trailing empty field; a path is never empty. + if (!entry || entry.length < 4) continue; + const status = entry.slice(0, 2); + const p = entry.slice(3); + let origPath = null; + if (PORCELAIN_PAIRED.has(status[0]) || PORCELAIN_PAIRED.has(status[1])) { + i += 1; + origPath = fields[i] ?? null; + } + out.push({ status, path: p, origPath }); } - return { status, path: rest }; + return out; } /** The store's current HEAD commit sha, or null on a store with no commits @@ -770,12 +995,9 @@ function currentHeadSha(dir) { * not actually inspect. */ function dirtyPaths(dir) { - const res = spawnSync('git', ['status', '--porcelain', '-uall'], { cwd: dir, encoding: 'utf8' }); + const res = spawnSync('git', ['status', '--porcelain', '-uall', '-z'], { cwd: dir, encoding: 'utf8' }); if (res.error || res.status !== 0) return null; - return res.stdout - .split('\n') - .filter(Boolean) - .map((line) => parsePorcelainLine(line).path); + return parsePorcelainZ(res.stdout).map((e) => e.path); } /** @@ -907,42 +1129,40 @@ function clearTxnJournal(dir) { * exactly the behavior to preserve. Returns a human-readable note, or null * when nothing was recovered. */ -function recoverInterruptedTransaction(dir, git, lockPath) { +function recoverInterruptedTransaction(dir, git, lockPath, token) { const journal = readTxnJournal(dir); - if (!journal) return null; + if (!journal) return { note: null, lockLost: false }; clearTxnJournal(dir); - if (!git) return null; + if (!git) return { note: null, lockLost: false }; const before = journalDirtySet(journal); - if (before === null) return null; // the journal cannot say — hands off + if (before === null) return { note: null, lockLost: false }; // the journal cannot say — hands off const now = dirtyPaths(dir); - if (now === null) return null; // unreadable status — fail closed, touch nothing + if (now === null) return { note: null, lockLost: false }; // unreadable status — fail closed, touch nothing const residue = now.filter((p) => !before.has(p)); - if (!residue.length) return null; + if (!residue.length) return { note: null, lockLost: false }; const checkpoint = typeof journal.checkpoint === 'string' && /^[0-9a-f]{40,64}$/.test(journal.checkpoint) ? journal.checkpoint : null; + let lockLost = false; if (before.size === 0) { // Nothing was dirty at the start, so EVERY uncommitted byte is residue — // the store's own whole-tree rollback is both the cheapest and the most // thorough discard. - rollbackStore(dir, checkpoint); + // // A recorded checkpoint can be unreachable (a store rewritten under the - // dead writer's feet) — `git reset --hard ` then fails silently and - // leaves the residue in place. Fail closed to the store's plain - // "discard everything uncommitted" reset rather than let it through. - if (dirtyPaths(dir)?.length !== 0) rollbackStore(dir); - // rollbackStore's `git clean -fd` sweeps untracked directories — including - // the `.lock` this transaction is holding right now. Re-assert it before - // anything else runs. - try { - fs.mkdirSync(lockPath); - } catch { - // still there — nothing to re-assert - } + // dead writer's feet) — `git reset --hard ` then fails and leaves the + // residue in place. rollbackStore now REPORTS that (S4), so the fallback to + // the plain "discard everything uncommitted" reset keys off the honest + // result rather than re-reading the tree by hand. + if (!rollbackStore(dir, checkpoint).ok) rollbackStore(dir); + // `.lock` is gitignored (S2) so `git clean -fd` can no longer sweep it, but + // re-assert through the OWNER-CHECKED path anyway: if this lock somehow + // went away and another writer took it, we must abort, never mkdir over it. + if (!reassertStoreLock(lockPath, token)) lockLost = true; } else { // Mixed tree: discard only what this dead writer added, one path at a // time, so the pre-existing dirt survives untouched. for (const rel of residue) discardResiduePath(dir, rel, checkpoint); } - return 'discarded interrupted write residue'; + return { note: 'discarded interrupted write residue', lockLost }; } /** @@ -1067,13 +1287,31 @@ export function withStoreTransaction(workspace, { home, label, afterCommit } = { if (!lock.acquired) { return { ok: false, locked: true, rolledBack: false, error: null, committed: false, result: null, dir, git, staleLockNote: null }; } + const token = lock.token; // Crash recovery BEFORE anything reads the tree (see // recoverInterruptedTransaction): a dead writer's uncommitted residue is // discarded here rather than inherited by the absorb step below as human // authority. Both notes ride the one existing recovery channel callers // already surface as `staleLockRemoved`. - const residueNote = recoverInterruptedTransaction(dir, git, lockPath); - const staleLockNote = [lock.staleLockNote, residueNote].filter(Boolean).join('; ') || null; + const recovery = recoverInterruptedTransaction(dir, git, lockPath, token); + const staleLockNote = [lock.staleLockNote, recovery.note].filter(Boolean).join('; ') || null; + if (recovery.lockLost) { + // Somebody else's lock is sitting where ours was. Nothing has been mutated + // by this transaction, and their lock is NOT ours to remove — refuse loudly + // and leave the store exactly as it is. + clearTxnJournal(dir); + return { + ok: false, + locked: true, + rolledBack: false, + error: new Error('store lock was taken over by another writer during crash recovery — refusing to run'), + committed: false, + result: null, + dir, + git, + staleLockNote, + }; + } // The rollback floor: entry HEAD, advanced by recordCheckpoint() whenever // an intra-transaction commit lands. Re-queried from git (not a @@ -1095,7 +1333,7 @@ export function withStoreTransaction(workspace, { home, label, afterCommit } = { const dirty = dirtyPaths(dir); if (dirty === null || !writeTxnJournal(dir, { ...journalBase, checkpoint: checkpointSha, dirty })) { clearTxnJournal(dir); - fs.rmSync(lockPath, { recursive: true, force: true }); + releaseStoreLock(lockPath, token); return { ok: false, locked: false, @@ -1114,27 +1352,61 @@ export function withStoreTransaction(workspace, { home, label, afterCommit } = { checkpointSha = currentHeadSha(dir); // The intra-transaction commit just cleaned the tree, so whatever is // dirty from here on is unambiguously this transaction's own work — even - // if a hand edit WAS pending when the journal was first written. A refresh - // that fails leaves the PREVIOUS journal in place, which is strictly more - // conservative (an older checkpoint, a larger already-dirty set) — the - // transaction stays marked either way, so this one stays best effort. - writeTxnJournal(dir, { ...journalBase, checkpoint: checkpointSha, dirty: [] }); + // if a hand edit WAS pending when the journal was first written. + // + // A FAILED REFRESH IS NOT "STRICTLY MORE CONSERVATIVE" — the comment that + // used to stand here was simply wrong, and the wrongness was load-bearing. + // Leaving the PREVIOUS journal in place leaves an OLDER checkpoint on + // disk, and `recoverInterruptedTransaction` resets `--hard` to exactly + // that sha: the sub-commit just landed — an absorbed HUMAN HAND EDIT, in + // absorbOrAbort's case — is then destroyed by the next writer's recovery, + // which is the one outcome the whole journal exists to prevent. The tree is + // clean at this instant and the checkpoint IS the sub-commit, so aborting + // here costs nothing already written and keeps every recorded checkpoint + // truthful. StoreTransactionAbort (not a plain throw) because the standard + // rollback would itself reset past the commit we just failed to record. + if (!writeTxnJournal(dir, { ...journalBase, checkpoint: checkpointSha, dirty: [] })) { + throw new StoreTransactionAbort( + 'store transaction journal could not record the checkpoint after an intra-transaction commit — aborting rather than leaving a stale checkpoint a later recovery would reset past' + ); + } } + // Set once a rollback finds the lock in somebody else's hands: the `finally` + // must then leave that lock strictly alone. + let lockLost = false; + // Set by ANY failed guardedRollback. This is the structural half of S4: a + // transaction that could not discard what it meant to discard must never + // reach `commitStore`, no matter what `fn` does with the boolean it was + // handed. A caller that ignores the return value can no longer publish the + // residue the rollback failed to remove — the commit simply does not happen. + let rollbackFailed = false; + let rollbackError = null; + + /** + * Roll back to the checkpoint and REPORT whether it worked (S4). Returns true + * only when git actually reset+cleaned the tree AND this transaction still + * holds its lock. A false return is not advisory — every caller must abort; + * `fn` bodies use it via `rollbackToCheckpoint`, and the terminal paths below + * carry it into `rolledBack`. + */ function guardedRollback() { if (!git) return false; - rollbackStore(dir, checkpointSha); - // `git clean -fd` sweeps untracked directories — including the `.lock` - // this transaction still holds. Re-assert it: a rollback taken MID-`fn` - // (rollbackToCheckpoint below) must never hand the store to a concurrent - // writer before this transaction has finished. Harmless for the terminal - // rollbacks — the `finally` removes the lock immediately afterwards. - try { - fs.mkdirSync(lockPath); - } catch { - // still there — nothing to re-assert + const res = rollbackStore(dir, checkpointSha); + // `.lock` is gitignored (S2), so `git clean -fd` no longer sweeps it — but + // verify ownership rather than assume it: a rollback taken MID-`fn` + // (rollbackToCheckpoint) must never let this transaction keep writing after + // the store has been handed to a concurrent writer. + const held = reassertStoreLock(lockPath, token); + if (!held) lockLost = true; + const ok = res.ok && held; + if (!ok) { + rollbackFailed = true; + rollbackError = res.ok + ? 'store lock was taken over by another writer during rollback' + : `store rollback failed: ${res.stderr || 'unknown git failure'}`; } - return true; + return ok; } try { @@ -1144,7 +1416,30 @@ export function withStoreTransaction(workspace, { home, label, afterCommit } = { } catch (err) { const isAbort = err instanceof StoreTransactionAbort; const rolledBack = isAbort ? false : guardedRollback(); - return { ok: false, locked: false, rolledBack, error: err, committed: false, result: null, dir, git, staleLockNote }; + // A rollback that itself failed is reported IN the error, not hidden + // behind a bare `rolledBack: false` no caller reads (S4). + const error = !isAbort && !rolledBack && rollbackError ? new Error(`${err.message} — AND ${rollbackError}`) : err; + return { ok: false, locked: false, rolledBack, error, committed: false, result: null, dir, git, staleLockNote }; + } + // A FAILED ROLLBACK CAN NEVER BE FOLLOWED BY A COMMIT (S4). `fn` may have + // called `rollbackToCheckpoint` and returned normally — apply.mjs's + // write-time E_HEAD_MOVED gate does exactly that — so if that rollback did + // not actually clean the tree, committing here would publish the very + // mutation the rejection claims was never written. Refuse instead, and say + // why. Enforced HERE rather than trusting each `fn` to check, because a + // caller forgetting to check is precisely how this defect shipped. + if (rollbackFailed) { + return { + ok: false, + locked: false, + rolledBack: false, + error: new Error(`${rollbackError} — refusing to commit a store this transaction could not roll back`), + committed: false, + result: null, + dir, + git, + staleLockNote, + }; } let commitRes = { committed: false, ok: true }; if (git) { @@ -1152,11 +1447,12 @@ export function withStoreTransaction(workspace, { home, label, afterCommit } = { } if (!commitRes.ok) { const rolledBack = guardedRollback(); + const base = commitRes.stderr || 'git commit failed'; return { ok: false, locked: false, rolledBack, - error: new Error(commitRes.stderr || 'git commit failed'), + error: new Error(!rolledBack && rollbackError ? `${base} — AND ${rollbackError}` : base), committed: false, result: null, dir, @@ -1184,9 +1480,12 @@ export function withStoreTransaction(workspace, { home, label, afterCommit } = { // Cleared only once the commit or rollback above has finished: while it // exists, a crash at any point leaves the residue classifiable. clearTxnJournal(dir); - // The rollback above may have already removed the untracked .lock - // directory via `git clean -fd` — tolerate that instead of throwing. - fs.rmSync(lockPath, { recursive: true, force: true }); + // OWNER-CHECKED RELEASE (S2), on every exit path including this one. The + // old unconditional `rmSync(lockPath)` was the release half of the lock-loss + // class: if anything had taken the lock in the meantime, this deleted a LIVE + // writer's lock. `releaseStoreLock` removes it only when the owner stamp is + // ours, and `lockLost` records that a rollback already found it foreign. + if (!lockLost) releaseStoreLock(lockPath, token); } } diff --git a/packages/harness/test/consolidate-apply.test.mjs b/packages/harness/test/consolidate-apply.test.mjs index f0cd4fdc..eb317668 100644 --- a/packages/harness/test/consolidate-apply.test.mjs +++ b/packages/harness/test/consolidate-apply.test.mjs @@ -1059,23 +1059,53 @@ test('an ADD asserting kind: fix for a real file whose own frontmatter says kind }); test('updateFrontmatterField inserts a missing field on a CRLF-terminated learning file instead of silently no-opping', () => { - const file = path.join(tempDir('apply-crlf-'), 'crlf-learning.md'); + // The fixture lives at a REAL learning path shape (`/learnings// + // .md`): updateFrontmatterField reads and writes through the learning-io + // choke point, which derives its containment root from exactly that shape and + // refuses anything else outright. + const file = path.join(tempDir('apply-crlf-'), 'learnings', 'sql', 'crlf-learning.md'); + fs.mkdirSync(path.dirname(file), { recursive: true }); const text = '---\r\ntrigger: "x"\r\nstatus: active\r\n---\r\n\r\nbody\r\n'; fs.writeFileSync(file, text); - updateFrontmatterField(file, 'superseded_by', 'sql/replacement'); + assert.equal(updateFrontmatterField(file, 'superseded_by', 'sql/replacement'), true); const after = fs.readFileSync(file, 'utf8'); assert.notEqual(after, text, 'the field must actually be inserted, not silently dropped'); assert.match(after, /superseded_by: sql\/replacement/); }); -test('op.merged_from asserted as a non-array (e.g. a bare string) is rejected with E_SCHEMA', () => { - const c = ctx(); - const op = ADD(c.ws, { merged_from: 'sql/some-id' }); - const res = run(c, ['consolidate', '--apply', '--ops', writeOps(c.ws, [op])]); - assert.equal(res.status, 1, res.stderr || res.stdout); - const out = JSON.parse(res.stdout); - assert.equal(out.rejected[0].code, 'E_SCHEMA'); - assert.match(out.rejected[0].reason, /merged_from must be an array of strings/); +test('updateFrontmatterField refuses a path that is not a learning file, and refuses a symlinked learning path', () => { + const root = tempDir('apply-choke-'); + const stray = path.join(root, 'not-a-learning.md'); + fs.writeFileSync(stray, '---\ntrigger: "x"\n---\n\nbody\n', 'utf8'); + assert.equal(updateFrontmatterField(stray, 'status', 'retired'), false, 'a non-learning path is not writable through the choke point'); + assert.match(fs.readFileSync(stray, 'utf8'), /trigger: "x"/, 'and it is left byte-identical'); + + const victim = path.join(root, 'victim.md'); + fs.writeFileSync(victim, 'OUTSIDE\n', 'utf8'); + const link = path.join(root, 'learnings', 'sql', 'linked.md'); + fs.mkdirSync(path.dirname(link), { recursive: true }); + fs.symlinkSync(victim, link); + assert.equal(updateFrontmatterField(link, 'status', 'retired'), false, 'a symlinked learning path is never written through'); + assert.equal(fs.readFileSync(victim, 'utf8'), 'OUTSIDE\n', 'the symlink target is untouched'); +}); + +// merged_from records that THIS store consolidated those ids into this claim, +// tombstoning each one in the same run. It is derived from a MERGE's own +// validated targets — never assertable by an op, in either shape. Accepting it +// let any op JSON (including a hand-edited `.harness/promote-ops.json`) stamp +// forged consolidation provenance onto a fresh claim while merging nothing. +test('op.merged_from cannot be asserted by an op — neither a bare string nor a well-formed array of ids', () => { + for (const value of ['sql/some-id', ['sql/some-id', 'sql/other-id']]) { + const c = ctx(); + const op = ADD(c.ws, { merged_from: value }); + const res = run(c, ['consolidate', '--apply', '--ops', writeOps(c.ws, [op])]); + assert.equal(res.status, 1, res.stderr || res.stdout); + const out = JSON.parse(res.stdout); + assert.equal(out.rejected[0].code, 'E_SCHEMA'); + assert.match(out.rejected[0].reason, /merged_from is derived from a MERGE's own targets/); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + assert.equal(listLearnings(dir).length, 0, 'and nothing with forged provenance was written'); + } }); test('validateEpisodes rejects malformed episode field types (path: 42, sha256: null) with E_SCHEMA, not a throw', () => { diff --git a/packages/harness/test/hand-edits.test.mjs b/packages/harness/test/hand-edits.test.mjs index dcdd05b3..e9328970 100644 --- a/packages/harness/test/hand-edits.test.mjs +++ b/packages/harness/test/hand-edits.test.mjs @@ -10,6 +10,7 @@ import { applyOps } from '../lib/knowledge/apply.mjs'; import { absorbHandEdits, absorbOrAbort, removeEpisodeLink } from '../lib/knowledge/admin.mjs'; import { setLearningStatus } from '../lib/knowledge/lifecycle.mjs'; import { ensureBucket } from '../lib/knowledge/layer.mjs'; +import { QUARANTINE_DIR } from '../lib/knowledge/learning-io.mjs'; import { ensureStore, storeDir, listLearnings, readLedger, parseLearningFrontmatter, serializeLearning, StoreTransactionAbort } from '../lib/knowledge/store.mjs'; const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -650,8 +651,19 @@ test('a planted SYMLINK at a learning path is refused, never followed — the ou assert.equal(fs.readFileSync(goldenVictim, 'utf8'), original, 'the golden symlink target is byte-identical'); assert.equal(fs.readFileSync(bucketVictim, 'utf8'), original, 'the bucket symlink target is byte-identical'); - assert.ok(fs.lstatSync(goldenLink).isSymbolicLink(), 'the planted symlink was refused, not replaced'); - assert.ok(fs.lstatSync(bucketLink).isSymbolicLink()); + // REFUSED **AND** MADE INERT. The link itself is moved out of `learnings/` + // into the gitignored `/.quarantine/` — leaving it live at a learning + // path is what let the previous round's "refused in absorb" fix still end in + // a truncated outside file, because every OTHER reader/writer still met a + // live symlink there. + assert.equal(fs.existsSync(goldenLink), false, 'the planted golden symlink no longer sits at a learning path'); + assert.equal(fs.existsSync(bucketLink), false, 'nor does the bucket one'); + const quarantined = fs.readdirSync(path.join(dir, QUARANTINE_DIR)); + assert.equal(quarantined.length, 2, `both links are quarantined: ${quarantined.join(', ')}`); + for (const name of quarantined) { + assert.ok(fs.lstatSync(path.join(dir, QUARANTINE_DIR, name)).isSymbolicLink(), 'the LINK was moved, never its target'); + } + assert.ok(logged.some((m) => /moved to \.quarantine/.test(m)), 'and the move is reported'); assert.equal(fs.existsSync(path.join(c.ws, 'docs', 'solutions', 'teachings')), false, 'no teaching snapshot fabricated from an outside file'); }); diff --git a/packages/harness/test/knowledge-boundary-hardening.test.mjs b/packages/harness/test/knowledge-boundary-hardening.test.mjs index f35ebe75..399693d5 100644 --- a/packages/harness/test/knowledge-boundary-hardening.test.mjs +++ b/packages/harness/test/knowledge-boundary-hardening.test.mjs @@ -573,7 +573,13 @@ test('K: every destructive knowledge path routes through an fs-safe realpath gua const guarded = [ ['lib/knowledge/prune.mjs', /assertRealpathContained\(txDir, path\.join\('branches'/], ['lib/knowledge/layer.mjs', /assertRealpathContained\(dir, path\.join\('branches'/], - ['lib/knowledge/apply.mjs', /assertRealpathContained\(dir, path\.relative\(dir, src\.file\)\)/], + // Learning-file I/O no longer guards itself per call site: every read, + // write, and delete of a learning goes through learning-io.mjs, which owns + // the guard once (S1). The contract is therefore that the choke point holds + // it AND that apply.mjs's promotion tombstone goes through the choke point + // rather than touching `fs` directly. + ['lib/knowledge/learning-io.mjs', /assertRealpathContained\(p\.storeRoot, p\.rel\)/], + ['lib/knowledge/apply.mjs', /writeLearningFile\(src\.file, serializeLearning\(/], ]; for (const [rel, pattern] of guarded) { const src = fs.readFileSync(path.join(packageRoot, rel), 'utf8'); diff --git a/packages/harness/test/knowledge-structural-hardening.test.mjs b/packages/harness/test/knowledge-structural-hardening.test.mjs new file mode 100644 index 00000000..1c45e64c --- /dev/null +++ b/packages/harness/test/knowledge-structural-hardening.test.mjs @@ -0,0 +1,489 @@ +// Structural regressions for four defect CLASSES in the knowledge store, each +// of which survived multiple rounds of per-call-site fixes because the class +// itself stayed representable: +// +// S1 learning-file I/O outside the one guarded choke point (the symlink class) +// S2 a `.lock` a general-purpose rollback could delete, and a release that +// never checked ownership (the lock-release class) +// S3 `git status --porcelain` parsed by hand (the path-parsing class) +// S4 a rollback whose result nobody checked (the silent-failure class) +// +// Every test here is written against the ATTACKER'S move or the failure mode, +// not against the shape of the fix. + +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { test } from 'node:test'; + +import { applyOps } from '../lib/knowledge/apply.mjs'; +import { absorbHandEdits, mirrorLearnings } from '../lib/knowledge/admin.mjs'; +import { + ensureStore, + listLearnings, + parsePorcelainZ, + rollbackStore, + withStoreTransaction, + writeStoreConfig, + acquireStoreLock, + releaseStoreLock, + lockOwnership, + reassertStoreLock, +} from '../lib/knowledge/store.mjs'; +import { QUARANTINE_DIR, readLearningFile, writeLearningFile } from '../lib/knowledge/learning-io.mjs'; + +const tempDir = (p) => fs.mkdtempSync(path.join(os.tmpdir(), p)); +const isRoot = typeof process.getuid === 'function' && process.getuid() === 0; + +// A non-git workspace, exactly like hand-edits.test.mjs: with no workspace git +// context every write routes to the GOLDEN layer, which is what these tests are +// about. Layer routing has its own suite. +const ctx = () => ({ ws: tempDir('sh-ws-'), home: tempDir('sh-home-'), harnessHome: tempDir('sh-hh-') }); + +function git(cwd, args) { + return spawnSync('git', args, { cwd, encoding: 'utf8', env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' } }); +} + +function writeOps(dir, ops) { + const p = path.join(dir, 'ops.json'); + fs.writeFileSync(p, JSON.stringify({ schema: 1, ops })); + return p; +} + +function EP(ws, rel) { + const full = path.join(ws, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + const content = `fix evidence body for ${rel}.\n`; + fs.writeFileSync(full, content, 'utf8'); + return { path: rel, sha256: crypto.createHash('sha256').update(content).digest('hex'), kind: 'fix', plan: 'docs/plans/p1.md' }; +} + +function seedLearning(c, slug = 'seeded-claim') { + const op = { + op: 'ADD', + domain: 'sql', + slug, + trigger: `trigger for ${slug}`, + body: `Claim body for ${slug}.`, + episodes: [EP(c.ws, `docs/solutions/perf/${slug}.md`)], + }; + const res = applyOps({ workspace: c.ws, opsPath: writeOps(c.ws, [op]), home: c.harnessHome }); + assert.equal(res.exitCode, 0, JSON.stringify(res.rejected)); + return `sql/${slug}`; +} + +// --------------------------------------------------------------------------- +// S1 — a symlink at a learning path is INERT EVERYWHERE, not just in absorb +// --------------------------------------------------------------------------- + +// The verified exploit, verbatim: plant `learnings/sql/timeout.md -> `, then run a STRENGTHEN naming `sql/timeout`. Before the choke point, +// absorb refused the link but LEFT IT LIVE, so listLearnings presented it as an +// active learning, the STRENGTHEN resolved against it, and the write replaced +// the OUTSIDE FILE with a rendered learning. +test('S1: a planted symlink at a learning path cannot be strengthened — the outside target is byte-identical afterwards', () => { + const c = ctx(); + seedLearning(c, 'anchor-claim'); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + + const outside = tempDir('sh-outside-'); + const victim = path.join(outside, 'precious.rc'); + const original = 'export PATH=/usr/bin\n# a file that is not a learning\n'; + fs.writeFileSync(victim, original, 'utf8'); + + const linkPath = path.join(dir, 'learnings', 'sql', 'timeout.md'); + fs.mkdirSync(path.dirname(linkPath), { recursive: true }); + fs.symlinkSync(victim, linkPath); + + // The link is not a learning to ANY reader. + assert.equal(listLearnings(dir).some((l) => l.id === 'sql/timeout'), false, 'a symlink is never listed as a learning'); + assert.equal(readLearningFile(linkPath), null, 'and is never read through'); + + const strengthen = { + op: 'STRENGTHEN', + target: 'sql/timeout', + episodes: [EP(c.ws, 'docs/solutions/perf/timeout-more.md')], + }; + const res = applyOps({ workspace: c.ws, opsPath: writeOps(c.ws, [strengthen]), home: c.harnessHome }); + assert.equal(res.exitCode, 1, JSON.stringify(res)); + assert.equal(res.rejected[0].code, 'E_TARGET'); + + assert.equal(fs.readFileSync(victim, 'utf8'), original, 'the symlink target was never written through'); +}); + +test('S1: writeLearningFile refuses a symlinked leaf and a non-learning path shape', () => { + const root = tempDir('sh-io-'); + const victim = path.join(root, 'victim.txt'); + fs.writeFileSync(victim, 'OUTSIDE\n', 'utf8'); + const link = path.join(root, 'learnings', 'sql', 'linked.md'); + fs.mkdirSync(path.dirname(link), { recursive: true }); + fs.symlinkSync(victim, link); + + assert.equal(writeLearningFile(link, 'rendered learning\n'), false); + assert.equal(fs.readFileSync(victim, 'utf8'), 'OUTSIDE\n'); + + // Not a learning path at all — refused rather than "trusted because the + // caller asked", which is what a root argument would have permitted. + assert.equal(writeLearningFile(path.join(root, 'loose.md'), 'x'), false); + assert.equal(writeLearningFile(path.join(root, 'learnings', 'deep', 'nested', 'x.md'), 'x'), false); +}); + +// `knowledge commit repo` copies learning bytes into a COMMITTED workspace +// path, so following a planted link here published an arbitrary outside file +// into the product repo's PR flow. +test('S1: mirrorLearnings never mirrors content read through a symlinked learning path', () => { + const c = ctx(); + seedLearning(c, 'mirrored-claim'); + const cfg = writeStoreConfig(c.ws, { home: c.harnessHome, commit: 'repo' }); + assert.equal(cfg.pass, true, cfg.blockedReason); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + + const outside = tempDir('sh-mirror-outside-'); + const victim = path.join(outside, 'secretish.md'); + fs.writeFileSync(victim, 'OUTSIDE CONTENT THAT MUST NOT BE PUBLISHED\n', 'utf8'); + const link = path.join(dir, 'learnings', 'sql', 'linked-claim.md'); + fs.symlinkSync(victim, link); + + mirrorLearnings({ workspace: c.ws, home: c.harnessHome }); + + const mirrorRoot = path.join(c.ws, 'docs', 'knowledge', 'learnings'); + const mirrored = fs.existsSync(mirrorRoot) + ? fs + .readdirSync(mirrorRoot, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .flatMap((d) => fs.readdirSync(path.join(mirrorRoot, d.name)).map((f) => fs.readFileSync(path.join(mirrorRoot, d.name, f), 'utf8'))) + : []; + assert.equal(mirrored.some((t) => t.includes('OUTSIDE CONTENT')), false, 'no followed content reached the workspace mirror'); + assert.equal(fs.existsSync(path.join(mirrorRoot, 'sql', 'linked-claim.md')), false, 'and the symlinked id was not mirrored at all'); +}); + +test('S1: absorb quarantines the planted link out of learnings/ instead of leaving it live', () => { + const c = ctx(); + seedLearning(c, 'quarantine-anchor'); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + + const outside = tempDir('sh-q-outside-'); + const victim = path.join(outside, 'target.md'); + fs.writeFileSync(victim, 'OUTSIDE\n', 'utf8'); + const link = path.join(dir, 'learnings', 'sql', 'planted.md'); + fs.symlinkSync(victim, link); + + absorbHandEdits({ workspace: c.ws, home: c.harnessHome }); + + assert.equal(fs.existsSync(link), false, 'the link no longer occupies a learning path'); + const q = fs.readdirSync(path.join(dir, QUARANTINE_DIR)); + assert.equal(q.length, 1); + assert.ok(fs.lstatSync(path.join(dir, QUARANTINE_DIR, q[0])).isSymbolicLink(), 'the LINK was moved, not its target'); + assert.equal(fs.readFileSync(victim, 'utf8'), 'OUTSIDE\n', 'the target is untouched'); + + // The quarantine bucket is gitignored, so it never reaches store history. + const tracked = git(dir, ['status', '--porcelain', '-uall', '-z']).stdout; + assert.equal(tracked.includes(QUARANTINE_DIR), false, `quarantine must be gitignored: ${JSON.stringify(tracked)}`); +}); + +// --------------------------------------------------------------------------- +// S2 — the lock survives `git clean -fd`, and is never released by a non-owner +// --------------------------------------------------------------------------- + +test('S2: the store carries a .gitignore, and `git clean -fd` cannot sweep the lock', () => { + const c = ctx(); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + + const ignore = fs.readFileSync(path.join(dir, '.gitignore'), 'utf8'); + assert.match(ignore, /^\/\.lock\/$/m, 'the lock directory is ignored'); + assert.match(ignore, new RegExp(`^/${QUARANTINE_DIR}/$`, 'm')); + + fs.mkdirSync(path.join(dir, '.lock'), { recursive: true }); + fs.writeFileSync(path.join(dir, '.lock', 'owner.json'), '{"token":"t"}\n', 'utf8'); + const clean = git(dir, ['clean', '-fd']); + assert.equal(clean.status, 0, clean.stderr); + assert.ok(fs.existsSync(path.join(dir, '.lock')), '`git clean -fd` must not be able to delete the lock'); +}); + +test('S2: a legacy store with no .gitignore gains one the next time it is opened', () => { + const c = ctx(); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + fs.rmSync(path.join(dir, '.gitignore'), { force: true }); + assert.equal(fs.existsSync(path.join(dir, '.gitignore')), false, 'precondition: no .gitignore'); + + ensureStore(c.ws, { home: c.harnessHome }); + assert.match(fs.readFileSync(path.join(dir, '.gitignore'), 'utf8'), /^\/\.lock\/$/m); +}); + +test('S2: a .gitignore a human already wrote is extended, never replaced', () => { + const c = ctx(); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + fs.writeFileSync(path.join(dir, '.gitignore'), '# mine\nscratch/\n', 'utf8'); + + ensureStore(c.ws, { home: c.harnessHome }); + const after = fs.readFileSync(path.join(dir, '.gitignore'), 'utf8'); + assert.match(after, /^scratch\/$/m, 'the human entry survives'); + assert.match(after, /^\/\.lock\/$/m, 'and ours was appended'); + + // Idempotent: a second open adds nothing. + ensureStore(c.ws, { home: c.harnessHome }); + assert.equal(fs.readFileSync(path.join(dir, '.gitignore'), 'utf8'), after); +}); + +test('S2: releaseStoreLock never removes a lock owned by somebody else', () => { + const c = ctx(); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + const lockPath = path.join(dir, '.lock'); + + const mine = acquireStoreLock(lockPath); + assert.equal(mine.acquired, true); + assert.equal(lockOwnership(lockPath, mine.token), 'owned'); + + // Another writer takes over the lock directory (the exact state the old + // "mkdir, swallow EEXIST" re-assert could not see). + fs.writeFileSync(path.join(lockPath, 'owner.json'), JSON.stringify({ token: 'someone-else', pid: 1 }) + '\n', 'utf8'); + assert.equal(lockOwnership(lockPath, mine.token), 'foreign'); + assert.equal(reassertStoreLock(lockPath, mine.token), false, 'a foreign lock is never re-claimed'); + assert.equal(releaseStoreLock(lockPath, mine.token), false, 'and never released'); + assert.ok(fs.existsSync(lockPath), "the other writer's lock is still standing"); + + // An unreadable/absent owner stamp is treated as foreign, never as ours. + fs.rmSync(path.join(lockPath, 'owner.json'), { force: true }); + assert.equal(lockOwnership(lockPath, mine.token), 'foreign'); +}); + +test('S2: a transaction that loses its lock mid-flight leaves the new holder alone', () => { + const c = ctx(); + seedLearning(c, 'lock-anchor'); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + const lockPath = path.join(dir, '.lock'); + + const tx = withStoreTransaction(c.ws, { home: c.harnessHome, label: 'test: steal' }, () => { + // Simulate another writer taking the lock while we work. + fs.writeFileSync(path.join(lockPath, 'owner.json'), JSON.stringify({ token: 'thief', pid: 999999 }) + '\n', 'utf8'); + return { commitMessage: 'test: steal' }; + }); + assert.equal(tx.ok, true, 'the run itself completes'); + assert.ok(fs.existsSync(lockPath), "the thief's lock was NOT deleted by our release"); + assert.equal(JSON.parse(fs.readFileSync(path.join(lockPath, 'owner.json'), 'utf8')).token, 'thief'); +}); + +// --------------------------------------------------------------------------- +// S3 — porcelain is parsed from `-z`, never from the C-quoted line format +// --------------------------------------------------------------------------- + +test('S3: parsePorcelainZ decodes non-ASCII, spaces, quotes, backslashes, a literal " -> ", and rename pairs', () => { + const z = [ + '?? learnings/café/x.md', + '?? learnings/a -> b/c.md', + '?? learnings/quo"te\\back/d.md', + '?? learnings/with space/e.md', + 'R learnings/sql/new.md', + 'learnings/sql/old.md', + ' M learnings/sql/modified.md', + ].join('\0') + '\0'; + + const parsed = parsePorcelainZ(z); + assert.deepEqual( + parsed.map((e) => e.path), + [ + 'learnings/café/x.md', + 'learnings/a -> b/c.md', + 'learnings/quo"te\\back/d.md', + 'learnings/with space/e.md', + 'learnings/sql/new.md', + 'learnings/sql/modified.md', + ] + ); + const rename = parsed.find((e) => e.status === 'R '); + assert.equal(rename.path, 'learnings/sql/new.md', '-z puts the NEW path first'); + assert.equal(rename.origPath, 'learnings/sql/old.md', 'and the original in its own field'); + assert.equal(parsed[parsed.length - 1].status, ' M', 'the entry AFTER a rename is not shifted by one'); +}); + +test('S3: a hand edit to a non-ASCII learning path is absorbed, not silently skipped', () => { + const c = ctx(); + seedLearning(c, 'ascii-anchor'); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + + const planted = path.join(dir, 'learnings', 'café', 'délai.md'); + fs.mkdirSync(path.dirname(planted), { recursive: true }); + fs.writeFileSync( + planted, + ['---', 'schema: 1', 'trigger: "un déclencheur"', 'status: active', 'source: auto', 'episodes:', 'anchors: []', 'origin: hand', '---', '', 'Le corps de la revendication.', ''].join('\n'), + 'utf8' + ); + + const result = absorbHandEdits({ workspace: c.ws, home: c.harnessHome }); + assert.deepEqual(result.absorbed.map((a) => a.id), ['café/délai'], 'the non-ASCII path was decoded correctly'); + assert.equal(listLearnings(dir).find((l) => l.id === 'café/délai').fm.source, 'human', 'and absorbed with honest provenance'); +}); + +test('S3: a learning path containing a literal " -> " is absorbed, not mis-split into a phantom path', () => { + const c = ctx(); + seedLearning(c, 'arrow-anchor'); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + + const planted = path.join(dir, 'learnings', 'a -> b', 'c.md'); + fs.mkdirSync(path.dirname(planted), { recursive: true }); + fs.writeFileSync( + planted, + ['---', 'schema: 1', 'trigger: "arrow trigger"', 'status: active', 'source: auto', 'episodes:', 'anchors: []', 'origin: hand', '---', '', 'Arrow claim body.', ''].join('\n'), + 'utf8' + ); + + const result = absorbHandEdits({ workspace: c.ws, home: c.harnessHome }); + assert.deepEqual(result.absorbed.map((a) => a.id), ['a -> b/c']); +}); + +// --------------------------------------------------------------------------- +// S4 — rollback results are honest, and acted on +// --------------------------------------------------------------------------- + +test('S4: rollbackStore reports failure when git cannot reset, instead of returning silently', () => { + const c = ctx(); + seedLearning(c, 'rollback-anchor'); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + + fs.writeFileSync(path.join(dir, 'learnings', 'sql', 'dirty.md'), 'dirt\n', 'utf8'); + // A stray index.lock is exactly the real-world cause this defect cited. + fs.writeFileSync(path.join(dir, '.git', 'index.lock'), '', 'utf8'); + + const res = rollbackStore(dir); + assert.equal(res.ok, false, 'a failed reset must be reported as a failed rollback'); + assert.ok(res.stderr, 'with a reason attached'); + assert.ok(fs.existsSync(path.join(dir, 'learnings', 'sql', 'dirty.md')), 'precondition: the dirt really did survive'); + + fs.rmSync(path.join(dir, '.git', 'index.lock'), { force: true }); + const ok = rollbackStore(dir); + assert.equal(ok.ok, true, 'and a real rollback reports success'); + assert.equal(fs.existsSync(path.join(dir, 'learnings', 'sql', 'dirty.md')), false); +}); + +test('S4: rollbackStore reports failure when the tree is still dirty despite a zero exit', () => { + const c = ctx(); + seedLearning(c, 'unreachable-anchor'); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + fs.writeFileSync(path.join(dir, 'learnings', 'sql', 'tracked-edit.md'), 'x\n', 'utf8'); + git(dir, ['add', '-A']); + git(dir, ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'tracked']); + fs.writeFileSync(path.join(dir, 'learnings', 'sql', 'tracked-edit.md'), 'y\n', 'utf8'); + + // An unreachable checkpoint: `git reset --hard ` fails, so the edit is + // still there — reported, never mistaken for a clean tree. + const res = rollbackStore(dir, 'f'.repeat(40)); + assert.equal(res.ok, false); + assert.equal(fs.readFileSync(path.join(dir, 'learnings', 'sql', 'tracked-edit.md'), 'utf8'), 'y\n'); +}); + +// This is the machinery `rollbackToCheckpoint` exists for and that had ZERO +// coverage: apply.mjs's write-time E_HEAD_MOVED gate is reached only AFTER a +// branch bucket has been materialized, and its "nothing was written" promise +// depends entirely on this discard actually happening before the transaction's +// finalize commit. +test('S4: rollbackToCheckpoint discards a post-materialization write back to the checkpoint', () => { + const c = ctx(); + seedLearning(c, 'checkpoint-anchor'); + + let rolledBack = null; + const tx = withStoreTransaction(c.ws, { home: c.harnessHome, label: 'test: late gate' }, ({ dir, rollbackToCheckpoint }) => { + // Materialize a bucket exactly as runOnce does on its way to the gate. + fs.mkdirSync(path.join(dir, 'branches', 'feature-x', 'learnings', 'sql'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'branches', 'feature-x', 'meta.json'), '{}\n', 'utf8'); + fs.writeFileSync(path.join(dir, 'branches', 'feature-x', 'learnings', 'sql', 'staged.md'), 'staged\n', 'utf8'); + rolledBack = rollbackToCheckpoint(); + return { commitMessage: 'test: late gate' }; + }); + + assert.equal(rolledBack, true, 'the rollback reports success'); + assert.equal(tx.ok, true); + assert.equal(tx.committed, false, 'nothing was left to commit'); + assert.equal(fs.existsSync(path.join(tx.dir, 'branches')), false, 'the materialized bucket is gone'); + assert.equal(git(tx.dir, ['status', '--porcelain', '-uall', '-z']).stdout, '', 'and the store tree is clean'); +}); + +test('S4: a transaction whose rollbackToCheckpoint FAILED never commits, even if fn returns normally', () => { + const c = ctx(); + seedLearning(c, 'failed-rollback-anchor'); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + const headBefore = git(dir, ['rev-parse', 'HEAD']).stdout.trim(); + + let reported = null; + const tx = withStoreTransaction(c.ws, { home: c.harnessHome, label: 'test: failed rollback' }, ({ dir: txDir, rollbackToCheckpoint }) => { + fs.mkdirSync(path.join(txDir, 'branches', 'feature-y', 'learnings', 'sql'), { recursive: true }); + fs.writeFileSync(path.join(txDir, 'branches', 'feature-y', 'learnings', 'sql', 'staged.md'), 'staged\n', 'utf8'); + // Make the rollback genuinely impossible. + fs.writeFileSync(path.join(txDir, '.git', 'index.lock'), '', 'utf8'); + reported = rollbackToCheckpoint(); + // A caller that IGNORES the result and returns normally, which is exactly + // what apply.mjs's E_HEAD_MOVED gate used to do. + return { commitMessage: 'test: must never land' }; + }); + + assert.equal(reported, false, 'the failed rollback is reported as failed'); + assert.equal(tx.ok, false, 'and the transaction fails rather than proceeding'); + assert.equal(tx.committed, false); + assert.match(tx.error.message, /could not roll back|rollback failed/i); + assert.equal(git(dir, ['rev-parse', 'HEAD']).stdout.trim(), headBefore, 'no commit landed on top of the checkpoint'); + assert.equal( + git(dir, ['log', '--format=%s', '-1']).stdout.trim().includes('must never land'), + false, + 'the residue was never published' + ); +}); + +// A refresh that fails leaves an OLDER checkpoint on disk, and recovery resets +// `--hard` to exactly that sha — destroying the sub-commit (an absorbed HUMAN +// hand edit, in absorbOrAbort's case) that had just landed. +test('S4: recordCheckpoint aborts when it cannot refresh the journal, rather than leaving a stale checkpoint', () => { + const c = ctx(); + seedLearning(c, 'journal-anchor'); + + const tx = withStoreTransaction(c.ws, { home: c.harnessHome, label: 'test: journal' }, ({ dir, recordCheckpoint }) => { + // Land an intra-transaction sub-commit, exactly as absorbOrAbort does. + fs.writeFileSync(path.join(dir, 'learnings', 'sql', 'sub.md'), 'sub\n', 'utf8'); + git(dir, ['add', '-A']); + git(dir, ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'sub-commit']); + // Now make the journal path unwritable: a non-empty directory cannot be + // replaced by the journal's temp-then-rename write. + const journal = path.join(dir, '.git', 'harness-txn.json'); + fs.rmSync(journal, { force: true }); + fs.mkdirSync(journal, { recursive: true }); + fs.writeFileSync(path.join(journal, 'blocker'), 'x', 'utf8'); + recordCheckpoint(); + return { commitMessage: 'test: unreachable' }; + }); + + assert.equal(tx.ok, false, 'the transaction aborts'); + assert.equal(tx.rolledBack, false, 'without a rollback that would reset past the sub-commit it just made'); + assert.match(tx.error.message, /checkpoint/i); + assert.match(git(tx.dir, ['log', '--format=%s', '-1']).stdout, /sub-commit/, 'the sub-commit survives'); +}); + +// --------------------------------------------------------------------------- +// Orphan teaching snapshot — snapshot and rewrite are all-or-nothing +// --------------------------------------------------------------------------- + +test('a refused absorb rewrite leaves no orphaned teaching snapshot behind', { skip: isRoot ? 'chmod is not enforced for root' : false }, () => { + const c = ctx(); + const id = seedLearning(c, 'orphan-anchor'); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + const learning = listLearnings(dir).find((l) => l.id === id); + + // A real hand edit, so absorb reaches the snapshot + rewrite steps. + fs.writeFileSync(learning.file, fs.readFileSync(learning.file, 'utf8').replace('Claim body', 'Hand-edited body'), 'utf8'); + + // Then make the rewrite impossible (the domain directory is read-only, so the + // contained temp+rename write cannot create its temp file). + const domainDir = path.dirname(learning.file); + fs.chmodSync(domainDir, 0o555); + let logged = []; + try { + absorbHandEdits({ workspace: c.ws, home: c.harnessHome, log: (m) => logged.push(m) }); + } finally { + fs.chmodSync(domainDir, 0o755); + } + + assert.ok(logged.some((m) => /refused to rewrite/.test(m)), `the refusal is reported: ${logged.join(' | ')}`); + const teachDir = path.join(c.ws, 'docs', 'solutions', 'teachings'); + const orphans = fs.existsSync(teachDir) ? fs.readdirSync(teachDir) : []; + assert.deepEqual(orphans, [], 'no uncited human-teaching candidate episode was left in the workspace'); +}); diff --git a/packages/harness/test/learnings-listing.test.mjs b/packages/harness/test/learnings-listing.test.mjs index c7920670..3bcba178 100644 --- a/packages/harness/test/learnings-listing.test.mjs +++ b/packages/harness/test/learnings-listing.test.mjs @@ -284,14 +284,30 @@ test('learnings --why exposes lastConfirmed, supersededBy, mergedFrom, and claim const c = ctx(); const { oldId, newId } = seedLegacyAndSupersede(c); - const mergedOp = { + // mergedFrom is DERIVED provenance — an op can no longer assert it — so the + // fixture performs a real MERGE and gets the merged_from the writer itself + // stamped. MERGE requires at least two ACTIVE targets, so a second claim is + // seeded alongside the superseding one. + const extraOp = { op: 'ADD', domain: 'sql', + slug: 'legacy-claim-alt', + trigger: 'an alternate legacy trigger', + body: 'The alternate claim body.', + episodes: [{ ...writeFixEpisode(c.ws, 'docs/solutions/perf/legacy-alt.md'), kind: 'fix', plan: 'docs/plans/p11b.md' }], + }; + const extraRes = run(c, ['consolidate', '--apply', '--ops', writeOps(c.ws, [extraOp])]); + assert.equal(extraRes.status, 0, extraRes.stderr || extraRes.stdout); + const altId = JSON.parse(extraRes.stdout).applied[0].id; + + const mergedOp = { + op: 'MERGE', + targets: [newId, altId], + domain: 'sql', slug: 'merged-claim', trigger: 'a merged trigger', body: 'The merged claim body.', episodes: [{ ...writeFixEpisode(c.ws, 'docs/solutions/perf/merged.md'), kind: 'fix', plan: 'docs/plans/p12.md' }], - merged_from: ['sql/legacy-claim-alt'], }; const mergedRes = run(c, ['consolidate', '--apply', '--ops', writeOps(c.ws, [mergedOp])]); assert.equal(mergedRes.status, 0, mergedRes.stderr || mergedRes.stdout); @@ -307,7 +323,7 @@ test('learnings --why exposes lastConfirmed, supersededBy, mergedFrom, and claim const whyMerged = JSON.parse(run(c, ['learnings', '--why', mergedId]).stdout); assert.equal(whyMerged.lastConfirmed, today); - assert.deepEqual(whyMerged.mergedFrom, ['sql/legacy-claim-alt']); + assert.deepEqual(whyMerged.mergedFrom, [newId, altId]); assert.equal(whyMerged.claimLine, 'The merged claim body.'); }); diff --git a/packages/harness/test/listing-redaction.test.mjs b/packages/harness/test/listing-redaction.test.mjs index 84366b8b..5c9d8cdb 100644 --- a/packages/harness/test/listing-redaction.test.mjs +++ b/packages/harness/test/listing-redaction.test.mjs @@ -33,22 +33,6 @@ function makeStore(t) { return { home, workspace }; } -function writeLearning({ home, workspace }, { trigger, body, episodePath }) { - // Mirror the on-disk store layout directly: this test is about the RENDER - // path, so it must not depend on the writer's own validation refusing the - // content (which is exactly what a hand edit bypasses). - const { repoId } = { repoId: null }; - void repoId; - const storeRoot = path.join(home, 'knowledge'); - const dirs = fs.existsSync(storeRoot) ? fs.readdirSync(storeRoot) : []; - let dir = dirs.length ? path.join(storeRoot, dirs[0]) : null; - if (!dir) { - // Let the store module derive its own id by asking it for the path. - dir = null; - } - return { dir, trigger, body, episodePath }; -} - test('listing and why redact secrets in trigger, claim, and episode refs', async (t) => { const { home, workspace } = makeStore(t); const { storeDir } = await import('../lib/knowledge/store.mjs'); @@ -108,3 +92,90 @@ test('listing and why redact secrets in trigger, claim, and episode refs', async assert.ok(!ep.plan.includes(SECRET), `episode plan leaked the key: ${ep.plan}`); assert.equal(ep.kind, 'fix', 'episode kind is a code-set token and is preserved'); }); + +// The scalars the render sites used to emit RAW. `unquote` (store.mjs) DECODES +// `\n`/`\r`/`\t` back into real control characters when a file is parsed off +// disk, so a hand-edited or legacy learning can carry an embedded newline in +// any of these — and every one lands in a single-line human surface (ui.line, +// the learningNote status string, the muted episode bullets) as well as in +// --json. Sanitized in listing.mjs, so no render site can forget. +test('listing and why never emit a raw control char in status, source, kind, id, or the pointer fields', async (t) => { + const { home, workspace } = makeStore(t); + const { storeDir } = await import('../lib/knowledge/store.mjs'); + const dir = storeDir(workspace, { home }); + fs.mkdirSync(path.join(dir, 'learnings', 'sql'), { recursive: true }); + + // Two fixtures: the pointer fields (superseded_by / promoted_to) synthesize + // their own status, so a hostile `status:` needs a learning without them. + fs.writeFileSync( + path.join(dir, 'learnings', 'sql', 'hostile-code-set.md'), + [ + '---', + 'schema: 1', + 'trigger: "a trigger"', + 'status: "active\\n- [sql/fake] injected row"', + 'source: "human\\nFORGED AUTHORITY"', + 'episodes:', + ' - path: docs/solutions/perf/a.md', + ' kind: "fix\\nmore"', + ' plan: docs/plans/p1.md', + 'origin: test', + '---', + '', + 'The claim body.', + '', + ].join('\n'), + 'utf8' + ); + fs.writeFileSync( + path.join(dir, 'learnings', 'sql', 'hostile-pointers.md'), + [ + '---', + 'schema: 1', + 'trigger: "another trigger"', + 'status: active', + 'source: auto', + 'episodes:', + 'superseded_by: "sql/other\\ninjected"', + 'last_confirmed: "2026-01-01\\ninjected"', + 'merged_from: [sql/a\\ninjected, sql/b]', + 'origin: test', + '---', + '', + 'Another claim body.', + '', + ].join('\n'), + 'utf8' + ); + + const control = /[\x00-\x1f\x7f]/; + const assertInert = (obj, label) => { + for (const [key, value] of Object.entries(obj)) { + if (typeof value === 'string') { + assert.equal(control.test(value), false, `${label}.${key} carries a control char: ${JSON.stringify(value)}`); + } + } + }; + + const listing = listingView({ workspace, home }); + const codeSetRow = listing.learnings.find((l) => l.id === 'sql/hostile-code-set'); + assert.ok(codeSetRow, 'the learning is listed'); + assert.equal(codeSetRow.status, 'unknown', 'an out-of-set status renders as unknown, never as itself-plus-a-newline'); + assert.equal(codeSetRow.source, 'unknown', 'and so does an out-of-set source'); + for (const row of listing.learnings) assertInert(row, 'listing row'); + + const why = whyView({ workspace, id: 'sql/hostile-code-set', home }); + assert.equal(why.status, 'unknown'); + assert.equal(why.source, 'unknown'); + assert.equal(why.episodes[0].kind, 'unknown', 'an out-of-set episode kind renders as unknown'); + assertInert(why, '--why'); + assertInert(why.episodes[0], '--why episode'); + + const whyPointers = whyView({ workspace, id: 'sql/hostile-pointers', home }); + assertInert(whyPointers, '--why pointers'); + for (const id of whyPointers.mergedFrom || []) { + assert.equal(control.test(id), false, `--why mergedFrom carries a control char: ${JSON.stringify(id)}`); + } + assert.ok(whyPointers.supersededBy && !control.test(whyPointers.supersededBy)); + assert.ok(whyPointers.lastConfirmed && !control.test(whyPointers.lastConfirmed)); +}); From 42a28078170b4a6b2e0abae02f3aad9f6a18fba4 Mon Sep 17 00:00:00 2001 From: Krish Date: Thu, 6 Aug 2026 16:44:17 -0400 Subject: [PATCH 22/24] fix: guard every store write and quarantine typechanged learning links --- docs/MEMORY-MODEL.md | 105 +++- packages/harness/lib/fs-safe.mjs | 98 ++++ packages/harness/lib/knowledge/admin.mjs | 127 ++-- packages/harness/lib/knowledge/apply.mjs | 38 +- packages/harness/lib/knowledge/layer.mjs | 36 +- .../harness/lib/knowledge/learning-io.mjs | 181 ------ packages/harness/lib/knowledge/lifecycle.mjs | 2 +- packages/harness/lib/knowledge/overlay.mjs | 3 +- packages/harness/lib/knowledge/promote.mjs | 2 +- packages/harness/lib/knowledge/remember.mjs | 9 +- packages/harness/lib/knowledge/store-io.mjs | 324 +++++++++++ packages/harness/lib/knowledge/store.mjs | 517 +++++++++++++---- .../harness/test/consolidate-apply.test.mjs | 2 +- packages/harness/test/hand-edits.test.mjs | 2 +- .../knowledge-boundary-hardening.test.mjs | 4 +- .../knowledge-store-io-hardening.test.mjs | 549 ++++++++++++++++++ .../knowledge-structural-hardening.test.mjs | 40 +- 17 files changed, 1624 insertions(+), 415 deletions(-) delete mode 100644 packages/harness/lib/knowledge/learning-io.mjs create mode 100644 packages/harness/lib/knowledge/store-io.mjs create mode 100644 packages/harness/test/knowledge-store-io-hardening.test.mjs diff --git a/docs/MEMORY-MODEL.md b/docs/MEMORY-MODEL.md index 16f3503a..23d639a2 100644 --- a/docs/MEMORY-MODEL.md +++ b/docs/MEMORY-MODEL.md @@ -657,29 +657,56 @@ runs `git status --porcelain -uall` in the store first and commits any dirty edi - The absorbed content may exceed the 1,200-byte learning cap — human authority overrides the cap for hand edits (logged, not rejected; the cap binds only the sole writer's own ops). -- **A symlink at a learning path is never a learning — and it is made inert, not just - refused.** Every read, write, delete, and size check of a `learnings//.md` - file (or its `branches//` equivalent) goes through ONE internal choke point - (`lib/knowledge/learning-io.mjs`), built on the shared `fs-safe` primitives and contained - against the STORE root, which it derives from the path's own required shape rather than - from a caller-supplied argument. So a planted symlink is: never read through, never - written through, never deleted through, never listed as an active learning by - `harness learnings` / retrieval / ranking, and never copied into the workspace mirror - under `knowledge commit repo`. Following it would otherwise pull an arbitrary outside file - into store history and a workspace teaching snapshot, then overwrite that outside file - with a serialized learning. - **The link itself is quarantined.** Refusing to follow it while leaving it sitting at a - live learning path was not enough — it still looked like a learning to anything that only - read the directory listing, and the next writer reaching for that id met a live link. The - next `absorbHandEdits` therefore MOVES the link (never its target; `rename` does not - follow a symlink) into `/.quarantine/`, logs where it went, and leaves it there for - inspection. `.quarantine/` is gitignored, so a quarantined link never enters store - history and is never swept by `git clean -fd`. -- **The store lock cannot be lost, and is never released by a non-owner.** `ensureStore` - writes (and, for stores created by an older CLI, migrates in) a `/.gitignore` - covering `/.lock/`, `/.lock.stale-*`, and `/.quarantine/`. That is what makes lock loss - structurally impossible rather than recoverable: the transaction rollback runs - `git clean -fd`, which used to sweep the untracked `.lock` out from under the very +- **A symlink at ANY store path is inert — not just refused, and not just for learnings.** + Every read, write, append, delete, and existence check of a file the store owns goes + through ONE internal choke point (`lib/knowledge/store-io.mjs`), built on the shared + `fs-safe` primitives and contained against the STORE root, which it derives from the + path's own **allow-listed shape** rather than from a caller-supplied argument. The + allow-list covers `learnings//.md` and its `branches//` equivalent, + the store-root metadata (`INDEX.md`, `consolidated.jsonl`, `governance.jsonl`, + `config.json`, `store.json`, `stale.json`, `.gitignore`), the bucket metadata + (`branches//INDEX.md|consolidated.jsonl|meta.json`), and the two nested files the + CLI owns (`.git/harness-txn.json`, `.lock/owner.json`). A planted symlink is therefore + never read through, never written through, never appended through, never deleted through, + never listed as an active learning by `harness learnings` / retrieval / ranking, never + copied into the workspace mirror under `knowledge commit repo` — and, critically, never + `existsSync`-trusted into "already fine": `existsSync` FOLLOWS a link, so the store now + asks `storeFileState`, which reports `symlink`, never `file`. + Scoping this to learning files was wrong for exactly the reason the store `.gitignore` + was already inside the choke point: the store root is a directory a human writes to, so + every metadata file in it is as plantable as a learning path. `ln -sf ~/.zshrc + /INDEX.md` plus any command that rebuilds the index — retire, dispute, confirm, + promote, apply, absorb, purge, rebuild — truncated and replaced that outside file. + **The link itself is quarantined.** Refusing to follow it while leaving it standing was + not enough — it still looked like a real file to anything that only read the directory + listing, and the next writer met a live link. A write through the choke point MOVES the + link (never its target; `rename` does not follow a symlink) into `/.quarantine/` + and writes the real file in its place; `absorbHandEdits` does the same for a learning + path and logs where it went. `.quarantine/` is gitignored, so a quarantined link never + enters store history and is never swept by `git clean -fd`. + A **filter-rewrite** (purge's ledger/governance rewrites) additionally fails CLOSED when + the file exists but could not be read: writing a filtered version of a file you never saw + is a truncation. An **append** never becomes a read-modify-write for the same reason — it + is a real `O_NOFOLLOW|O_APPEND` write, so a ledger too large to read whole is still + appendable rather than silently truncated by its next append. And a refused write, + append, or truncate throws rather than returning quietly: a store write nobody checked is + the same defect class as a rollback nobody checked. +- **A tracked learning replaced by a symlink is quarantined too.** Replacing a *tracked* + file with a link is a git TYPECHANGE — `git status` emits ` T`, which is neither `??` nor + contains `M`. The absorb loop's status-code filter ran BEFORE the symlink branch, so + ` T` (and staged `A `) `continue`d: never quarantined, never logged, and the next + `git add -A` committed the symlink into store history while `listLearnings` silently + dropped the learning. The symlink check now runs before any code filter that could + exclude it, and the filter itself is an allow-list (`??`, `M`, `A`, `T`, with unmerged + codes carved out explicitly) rather than a deny-list. +- **The store lock cannot be lost, and is never released by a non-owner.** The first + transaction to open a store writes (and, for stores created by an older CLI, migrates in) + a `/.gitignore` covering `/.lock/`, `/.lock.stale-*`, and `/.quarantine/`. It is + written **under the lock** — writing it is a store mutation, and `ensureStore` runs before + the lock is acquired — and an existing `.gitignore` that cannot be read for a reason other + than "it is a symlink" is left exactly as found rather than replaced. That file is what + makes lock loss structurally impossible rather than recoverable: the transaction rollback + runs `git clean -fd`, which used to sweep the untracked `.lock` out from under the very transaction holding it, after which the code re-asserted with a bare `mkdir` and swallowed `EEXIST` as "still there" — so if a second writer had claimed the freed lock in that window, the first carried on inside a lock it no longer held, `git add -A`-ed the other @@ -689,7 +716,25 @@ runs `git status --porcelain -uall` in the store first and commits any dirty edi inside the lock directory at acquisition. Re-assert and release both verify it: a lock whose owner stamp names somebody else (or cannot be read) is never reclaimed and never removed, and a transaction that discovers its lock has been taken over aborts instead of - continuing. The store `.gitignore` is additive — entries a human added are preserved. + continuing. A lock whose owner stamp could NOT be written **fails the acquisition** and is + removed again: leaving one live wedged the store for the full ten-minute staleness window, + because nobody — including its own creator — could then prove ownership to release it. + The store `.gitignore` is additive — entries a human added are preserved. +- **Stale-lock takeover is a compare-and-swap, not a narrowed race.** Taking over a lock + older than ten minutes renames it to a tombstone whose name is DERIVED FROM THE IDENTITY + OBSERVED BEFORE THE RENAME, so the rename itself is the atomic swap: a second writer that + observed the same stale lock finds that exact tombstone in its way and fails. If it does + win the rename (the first writer already cleaned its tombstone up), the post-swap verify + catches that what it moved is not what it observed, and it puts the live lock straight + back. Previously both writers renamed, re-created, stamped and verified in turn, and both + returned "acquired". +- **A transaction never clears a journal it does not own.** The intent journal is one shared + file, and clearing it used to be unconditional: a writer whose recovery rollback lost the + lock still removed the journal the WINNING writer had just written, leaving that writer + running unmarked — the exact state the fail-closed journal check exists to prevent, + reached from the other side. A journal now carries the same owner token its writer's lock + does, and is cleared only by its owner. Crash recovery is the one legitimate foreign + clear: it consumes a dead writer's journal under a freshly acquired lock. - **A rollback that failed is reported as failed.** `rollbackStore` checks both git invocations AND re-reads the tree afterwards (a zero exit is not the same thing as a clean tree), and a transaction that could not roll back what it meant to discard NEVER reaches @@ -723,7 +768,17 @@ runs `git status --porcelain -uall` in the store first and commits any dirty edi intra-transaction commit aborts the transaction too, rather than leaving the previous journal in place: the previous journal names an OLDER checkpoint, and recovery resets `--hard` to exactly that sha — destroying the sub-commit that had just landed, which for - `absorbOrAbort` is an absorbed human hand edit. + `absorbOrAbort` is an absorbed human hand edit. **A recovery whose rollback could not + clean the tree refuses the run**: the fallback rollback's result used to be discarded, so + a residue that survived both attempts was reported as recovered and inherited by the next + transaction's absorb as human authority. +- **Every rollback goes through the transaction's own guard.** `rollbackToCheckpoint` + unwinds to the last checkpoint; `rollbackUncommitted` discards only what is uncommitted, + for an `fn` undoing its own failed sub-commit attempt (the quarantine-strike recorder). + Both set the "this transaction could not discard what it meant to discard" latch and + re-assert lock ownership afterwards. No `fn` calls `rollbackStore` directly any more: the + strike recorder's bare call was the last `git clean -fd` in the codebase that could free + the lock with nobody checking who held it next. Use `harness remember` to add a new claim and `harness learning retire|dispute|confirm` to change a learning's status when a CLI command is more convenient than a direct edit — both diff --git a/packages/harness/lib/fs-safe.mjs b/packages/harness/lib/fs-safe.mjs index d0590dab..1c0742ab 100644 --- a/packages/harness/lib/fs-safe.mjs +++ b/packages/harness/lib/fs-safe.mjs @@ -303,6 +303,104 @@ export function realpathParentContained(root, full) { return containedUnder(realParent, realRoot); } +/** + * Contained APPEND via canonicalize-after-acquire — the append-only sibling of + * writeFileContained, for the store's two append-only ledgers + * (consolidated.jsonl, governance.jsonl). + * + * WHY NOT read-modify-write. Rewriting the whole file to append one line makes + * the append inherit the READ's failure modes: a ledger over DEFAULT_MAX_BYTES + * (or unreadable for any other reason) would read as empty and the "append" + * would TRUNCATE it. An append must be able to succeed on a file it cannot + * read, so it is a real O_APPEND write: + * 1. assertNoSymlinkAncestors — cheap lexical + ancestor-symlink pre-filter. + * 2. open O_RDWR|O_APPEND|O_CREAT|O_NOFOLLOW — O_NOFOLLOW makes the kernel + * refuse atomically if the FINAL component is a symlink, so a planted link + * is never appended through; O_APPEND makes every write land at EOF. + * 3. fstat the fd, then realpath-contain + inode-match it against the root + * (fdMatchesCanonicalUnderRoot) — closing the ancestor-swap window step 1 + * cannot. On failure the fd is closed and, if THIS call created the file, + * the empty file is unlinked again; a pre-existing file is left untouched. + * 4. Write through the verified descriptor, never by re-opening the path. + * `newlineGuard` reproduces the store's own append idiom (insert a separating + * newline when the file is non-empty and does not already end in one) by + * reading the last byte THROUGH the verified fd rather than re-reading the + * whole file. Returns the appended-to absolute path, or null on any refusal. + */ +export function appendFileContained(root, rel, content, { newlineGuard = false } = {}) { + const rootFull = path.resolve(root); + const full = assertNoSymlinkAncestors(rootFull, rel); + if (!full) return null; + const realRoot = canonicalRoot(rootFull); + if (realRoot === null) return null; + try { + fs.mkdirSync(path.dirname(full), { recursive: true }); + } catch { + return null; + } + let existedBefore = true; + try { + fs.lstatSync(full); + } catch { + existedBefore = false; + } + const flags = fs.constants.O_RDWR | fs.constants.O_APPEND | fs.constants.O_CREAT | (O_NOFOLLOW === null ? 0 : O_NOFOLLOW); + let fd; + try { + fd = fs.openSync(full, flags, 0o666); + } catch { + return null; + } + let closed = false; + const close = () => { + if (closed) return; + closed = true; + try { + fs.closeSync(fd); + } catch { + /* already gone */ + } + }; + const refuse = () => { + close(); + // Only ever unlink a file THIS call brought into existence — a + // pre-existing file is never ours to remove on a refusal. + if (!existedBefore) { + try { + fs.unlinkSync(full); + } catch { + /* best effort */ + } + } + return null; + }; + try { + const stat = fs.fstatSync(fd); + if (!stat.isFile()) return refuse(); + // Windows / no O_NOFOLLOW: the open above followed a symlinked leaf, so + // detect it the non-atomic way before writing a single byte. + if (O_NOFOLLOW === null) { + try { + if (fs.lstatSync(full).isSymbolicLink()) return refuse(); + } catch { + return refuse(); + } + } + if (!fdMatchesCanonicalUnderRoot(full, stat, realRoot)) return refuse(); + let prefix = ''; + if (newlineGuard && stat.size > 0) { + const last = Buffer.alloc(1); + fs.readSync(fd, last, 0, 1, stat.size - 1); + if (last.toString('utf8') !== '\n') prefix = '\n'; + } + fs.writeSync(fd, Buffer.from(prefix + content, 'utf8')); + close(); + return full; + } catch { + return refuse(); + } +} + /** * Contained, atomic write via canonicalize-after-acquire. The sequence is * create-EMPTY → verify → write-through-fd → rename, so no content byte is ever diff --git a/packages/harness/lib/knowledge/admin.mjs b/packages/harness/lib/knowledge/admin.mjs index 9acdefc7..09a73aec 100644 --- a/packages/harness/lib/knowledge/admin.mjs +++ b/packages/harness/lib/knowledge/admin.mjs @@ -16,6 +16,7 @@ import { listLearnings, readLedger, appendLedger, + writeLedger, commitStore, parseLearningFrontmatter, serializeLearning, @@ -29,7 +30,15 @@ import { consolidateStatus, LEARNING_BYTE_CAP, isActiveFm } from './consolidate. import { listBuckets, branchesRoot, bucketDirFor } from './overlay.mjs'; import { scanSecrets } from '../secret-scan.mjs'; import { assertNoSymlinkAncestors, assertRealpathContained, writeFileContained, readFileNoFollow } from '../fs-safe.mjs'; -import { readLearningFile, writeLearningFile, removeLearningFile, quarantineSymlinkedLearning } from './learning-io.mjs'; +import { + readLearningFile, + writeLearningFile, + removeLearningFile, + quarantineSymlinkedLearning, + writeStoreFile, + removeStoreFile, + storeFileState, +} from './store-io.mjs'; import { runIndexKnowledge } from '../index-knowledge.mjs'; import { loadManifest } from '../recall-rank.mjs'; @@ -260,6 +269,19 @@ export function mirrorLearnings({ workspace, home, log = () => {}, retiredIds = * overwriting that outside file with a canonically serialized learning. Such a * path is refused with a logged note, never followed. */ +/** Truncate a store-owned file through the choke point, failing closed: a wipe + * that was refused must never be reported as a completed purge/rebuild. */ +function wipe(file) { + if (!writeStoreFile(file, '')) { + throw new Error(`refused to truncate ${file} — the path does not resolve safely inside the knowledge store`); + } +} + +/** Per-character allow-list of porcelain status codes an absorb acts on, plus + * the unmerged codes carved out of it. See the comment at the filter below. */ +const ABSORBABLE_CODES = new Set(['M', 'A', 'T']); +const UNMERGED_CODES = new Set(['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU']); + export function absorbHandEdits({ workspace, home, log = () => {} }) { const empty = { absorbed: [], deleted: [], committed: false }; const dir = storeDir(workspace, { home }); @@ -307,28 +329,24 @@ export function absorbHandEdits({ workspace, home, log = () => {} }) { const id = `${domain}/${slug}`; const layerRoot = bucketKey ? bucketDirFor(dir, bucketKey) : dir; - if (code.includes('D')) { - // Human deletion always wins — nothing left to parse or re-render. - deleted.push(id); - if (bucketKey) touchedBucketRoots.add(layerRoot); - continue; - } - // `??` (planted, never tracked) absorbs exactly like `M` (see the doc - // comment above) — anything else (staged-only, renamed-into, conflicted) - // stays out of absorb scope. - if (code !== '??' && !code.includes('M')) continue; - - // A SYMLINK AT A LEARNING PATH IS NEVER A LEARNING (P1). `rel` comes - // straight from `git status` over a directory a human hand-edits, and this - // block both READS the path and later REWRITES it canonically — so a - // planted symlink was followed BOTH ways: the read pulled an arbitrary - // outside file's content into the absorb pipeline (snapshotted into the - // workspace, committed into store history) and the rewrite overwrote that - // outside file with a serialized learning. Refused here rather than - // followed, through the same fs-safe.mjs primitives every other writer in - // this module uses: the ancestor walk rejects a symlinked component (or - // leaf) up front, and `readFileNoFollow` re-verifies against the store root - // after acquiring the descriptor, closing the swap window the walk cannot. + // A SYMLINK AT A LEARNING PATH IS NEVER A LEARNING (P1), AND THE CHECK RUNS + // BEFORE ANY STATUS-CODE FILTER (R2). `rel` comes straight from `git status` + // over a directory a human hand-edits, and this block both READS the path + // and later REWRITES it canonically — so a planted symlink was followed + // BOTH ways: the read pulled an arbitrary outside file's content into the + // absorb pipeline (snapshotted into the workspace, committed into store + // history) and the rewrite overwrote that outside file with a serialized + // learning. + // + // THE ORDER IS THE FIX. The absorbable-code filter used to run FIRST, and + // the likeliest plant of all — replacing a TRACKED learning file with a + // symlink — makes git emit ` T` (typechange; git-status(1): "[ MTARC] T + // type changed in the work tree since the index"), which is neither `??` + // nor contains `M`. It `continue`d here: never quarantined, never logged, + // and the next `commitStore`'s `git add -A` committed the symlink into + // store history while `listLearnings` silently dropped the learning. A + // deny-list filter excluded the case that mattered, so the symlink check + // now precedes every filter that could exclude it. const file = assertNoSymlinkAncestors(dir, rel); if (!file) { // REFUSING IS NOT ENOUGH — THE LINK MUST BECOME INERT (S1). The previous @@ -345,6 +363,25 @@ export function absorbHandEdits({ workspace, home, log = () => {} }) { ); continue; } + + if (code.includes('D')) { + // Human deletion always wins — nothing left to parse or re-render. + deleted.push(id); + if (bucketKey) touchedBucketRoots.add(layerRoot); + continue; + } + // ALLOW-LIST, NOT DENY-LIST (rule 3). These are the codes whose worktree + // state means "this learning file's content is not what the last commit + // recorded", so it must be absorbed rather than swept into store history by + // the next `git add -A`: + // `??` planted and never tracked `M` modified in the worktree/index + // `A` staged but never committed `T` type changed back to a real file + // Unmerged codes are carved out explicitly: a store repo never merges, and + // absorbing half a conflict would be worse than leaving it. Everything else + // (rename-into, copy) stays out of absorb scope as before. + if (UNMERGED_CODES.has(code)) continue; + if (code !== '??' && ![...code].some((ch) => ABSORBABLE_CODES.has(ch))) continue; + const text = readLearningFile(file); if (text === null) { // Vanished between status and read, swapped for a symlink since the walk @@ -946,11 +983,10 @@ export function purgeEpisode({ workspace, target, copilotHome, home, log = () => } const keptLedger = m.ledger.filter((e) => e.path !== target); if (keptLedger.length !== m.ledger.length) { - fs.writeFileSync( - path.join(m.root, 'consolidated.jsonl'), - keptLedger.length ? keptLedger.map((e) => JSON.stringify(e)).join('\n') + '\n' : '', - 'utf8' - ); + // Through the choke point (R1), and fail-closed: `writeLedger` refuses + // when the ledger exists but could not be read, so a filtered rewrite + // can never truncate a ledger this pass did not actually see. + writeLedger(m.root, keptLedger); ledgerRemoved += m.ledger.length - keptLedger.length; } if (m.learnings.length) rebuildIndex(m.root); @@ -1150,7 +1186,12 @@ export function purgeAll({ workspace, home, log = () => {} }) { if (!domain.isDirectory()) continue; const dPath = path.join(learningsDir, domain.name); n += fs.readdirSync(dPath).filter((f) => f.endsWith('.md')).length; - fs.rmSync(dPath, { recursive: true, force: true }); + // Defense in depth (fs-safe.mjs): a recursive delete follows a + // symlinked ANCESTOR — a `learnings/` replaced by a link would make + // this sweep an outside directory tree. Delete only a path whose real + // location is still inside the store. + const contained = assertRealpathContained(dir, path.join('learnings', domain.name)); + if (contained) fs.rmSync(contained, { recursive: true, force: true }); } } // Layer cascade (blueprint §5a): purge --all wipes `branches/` whole — @@ -1159,12 +1200,17 @@ export function purgeAll({ workspace, home, log = () => {} }) { for (const bucket of listBuckets(dir)) { n += listLearnings(bucket.dir).length; } - fs.rmSync(branchesRoot(dir), { recursive: true, force: true }); - fs.writeFileSync(path.join(dir, 'consolidated.jsonl'), '', 'utf8'); + const containedBranches = assertRealpathContained(dir, 'branches'); + if (containedBranches) fs.rmSync(containedBranches, { recursive: true, force: true }); + // A deliberate WIPE, not a filtered rewrite: it needs no prior read, so it + // goes straight through the choke point rather than through writeLedger. + // Checked, never silent — a wipe that did not happen must not be reported + // as a completed purge. + wipe(path.join(dir, 'consolidated.jsonl')); // Truncate rather than rewriteGovernance(dir, () => false): purge --all // erases the entire store, so there is no surviving id left for a // predicate to filter against — a full truncate is equivalent and simpler. - fs.writeFileSync(path.join(dir, 'governance.jsonl'), '', 'utf8'); + wipe(path.join(dir, 'governance.jsonl')); rebuildIndex(dir); return { kind: 'success', commitMessage: 'purge: --all (store reset)', removedCount: n, idsBeforeReset }; } @@ -1277,10 +1323,12 @@ export function rebuildStore({ workspace, home, yes, copilotHome, log = () => {} if (fs.existsSync(learningsDir)) { for (const domain of fs.readdirSync(learningsDir, { withFileTypes: true })) { if (!domain.isDirectory()) continue; - fs.rmSync(path.join(learningsDir, domain.name), { recursive: true, force: true }); + // Same containment guard as purgeAll's sweep above. + const contained = assertRealpathContained(dir, path.join('learnings', domain.name)); + if (contained) fs.rmSync(contained, { recursive: true, force: true }); } } - fs.writeFileSync(path.join(dir, 'consolidated.jsonl'), '', 'utf8'); + wipe(path.join(dir, 'consolidated.jsonl')); // Per-layer rebuild (blueprint §5a): every bucket's learnings and ledger // are wiped too — bucket meta.json survives as the layer's identity — so // each lane re-derives from raw episodes routed by their `branch:` @@ -1291,14 +1339,15 @@ export function rebuildStore({ workspace, home, yes, copilotHome, log = () => {} let archivedBranch = 0; for (const bucket of listBuckets(dir)) { archivedBranch += listLearnings(bucket.dir).length; - fs.rmSync(path.join(bucket.dir, 'learnings'), { recursive: true, force: true }); + const containedBucketLearnings = assertRealpathContained(dir, path.join('branches', bucket.key, 'learnings')); + if (containedBucketLearnings) fs.rmSync(containedBucketLearnings, { recursive: true, force: true }); fs.mkdirSync(path.join(bucket.dir, 'learnings'), { recursive: true }); - fs.writeFileSync(path.join(bucket.dir, 'consolidated.jsonl'), '', 'utf8'); + wipe(path.join(bucket.dir, 'consolidated.jsonl')); rebuildIndex(bucket.dir); } const archived = archivedLearnings.length + archivedBranch; rebuildIndex(dir); - fs.rmSync(path.join(dir, 'stale.json'), { force: true }); + removeStoreFile(path.join(dir, 'stale.json')); return { kind: 'success', commitMessage: `consolidate: rebuild reset (${archived} learnings archived to git history)`, @@ -1390,7 +1439,7 @@ export function migrateStrandedStore({ workspace, home, log = () => {} }) { // Non-creating gate: a workspace with no legacy path-keyed store on disk // must never be materialized by this command just to discover that. - if (!fs.existsSync(path.join(legacyDir, 'consolidated.jsonl'))) { + if (storeFileState(path.join(legacyDir, 'consolidated.jsonl')) !== 'file') { return { pass: false, exitCode: 2, @@ -1464,7 +1513,7 @@ export function migrateStrandedStore({ workspace, home, log = () => {} }) { // non-empty" refusal caused by OUR OWN interrupted attempt. try { fs.cpSync(legacyDir, targetDir, { recursive: true }); - if (!fs.existsSync(path.join(targetDir, 'consolidated.jsonl'))) { + if (storeFileState(path.join(targetDir, 'consolidated.jsonl')) !== 'file') { throw new Error('cross-device copy did not verify — legacy store left untouched'); } } catch (copyErr) { diff --git a/packages/harness/lib/knowledge/apply.mjs b/packages/harness/lib/knowledge/apply.mjs index 9fafdb86..ceba25d9 100644 --- a/packages/harness/lib/knowledge/apply.mjs +++ b/packages/harness/lib/knowledge/apply.mjs @@ -5,7 +5,6 @@ import { ensureStore, withStoreTransaction, StoreTransactionAbort, - rollbackStore, appendLedger, readLedger, listLearnings, @@ -30,7 +29,7 @@ import { parseMergedFrom } from './listing.mjs'; import { resolveWriteLayer, ensureBucket, migrateRenamedBucket, episodeEligibleForLayer, storeHasBuckets } from './layer.mjs'; import { bucketDirFor, readBucketMeta, bucketAncestryOk, isSafeBucketKey } from './overlay.mjs'; import { readFileNoFollow, assertNoSymlinkAncestors } from '../fs-safe.mjs'; -import { readLearningFile, writeLearningFile } from './learning-io.mjs'; +import { readLearningFile, writeLearningFile, writeStoreFile } from './store-io.mjs'; /** * The SOLE writer of the learnings store. The consolidation skill emits an @@ -902,7 +901,7 @@ export function applyOps({ * which propagates as a thrown exception instead and lets * withStoreTransaction perform the rollback. */ - function runOnce({ dir, git, recordCheckpoint = () => {}, rollbackToCheckpoint = () => false }) { + function runOnce({ dir, git, recordCheckpoint = () => {}, rollbackToCheckpoint = () => false, rollbackUncommitted = () => false }) { // HEAD RE-VALIDATION BEFORE THE FIRST MUTATION (P1). Bucket // materialization/migration below already WRITES to the store, so the // write-time gate further down was no longer the "nothing has been written @@ -1053,13 +1052,16 @@ export function applyOps({ appendLedger(dir, entries); const commitRes = commitStore(dir, `consolidate: record failure ${code}`); if (!commitRes.ok) { - // Verified rollback (S4): `rollbackStore` now reports whether the - // tree is actually clean again. A failed discard cannot be folded - // into the rejection note — the partial ledger append is still on - // disk and finalize would commit it. - const rb = rollbackStore(dir); - if (!rb.ok) { - unrecoverable = `strike recording failed to commit (${commitRes.stderr || 'git commit failed'}) and could not be rolled back: ${rb.stderr}`; + // GUARDED rollback (R4): this used to call `rollbackStore(dir)` + // directly — the last rollback in the codebase whose `git clean -fd` + // could free the lock with NO ownership re-check and NO + // `rollbackFailed` latch, so a store handed to another writer here + // was never noticed and the transaction went on to commit. + // `rollbackUncommitted` keeps this rollback's semantics (undo only + // this strike attempt, never unwind to the checkpoint) while setting + // the latch and re-asserting ownership like every other rollback. + if (!rollbackUncommitted()) { + unrecoverable = `strike recording failed to commit (${commitRes.stderr || 'git commit failed'}) and could not be rolled back`; } return `strike recording failed to commit: ${commitRes.stderr || 'git commit failed'}`; } @@ -2271,7 +2273,7 @@ export function applyOps({ mirrorLearnings({ workspace, home, log }); }, }, - ({ dir, git, recordCheckpoint, rollbackToCheckpoint }) => { + ({ dir, git, recordCheckpoint, rollbackToCheckpoint, rollbackUncommitted }) => { // The run's ONE git snapshot (routing + provenance), taken under the store // lock rather than before it, and re-validated at write time // (assertHeadUnmoved) — see deriveRouting's doc comment above. @@ -2308,7 +2310,7 @@ export function applyOps({ : `knowledge mode is ${freshMode} — run: harness knowledge on`; return { kind: 'reject', applied: [], governed: [], rejected: [{ code: 'E_MODE', reason }], committed: false, exitCode: 2 }; } - return runOnce({ dir, git, recordCheckpoint, rollbackToCheckpoint }); + return runOnce({ dir, git, recordCheckpoint, rollbackToCheckpoint, rollbackUncommitted }); }); if (!tx.ok) { @@ -2447,5 +2449,15 @@ export function rebuildIndex(dir) { ...active.map((l) => `- [${l.id}] ${inertLine(l.fm.trigger || '')}`), '', ]; - fs.writeFileSync(path.join(dir, 'INDEX.md'), lines.join('\n'), 'utf8'); + // THE CHOKE POINT (R1). This was the verified exploit: `ln -sf ~/.zshrc + // /INDEX.md` plus ANY command that rebuilds the index — retire, + // dispute, confirm, promote, apply, absorb, purge, rebuild — truncated and + // replaced the outside file, because `ensureStore`'s `fs.existsSync` had + // followed the link and reported it as already fine. `writeStoreFile` + // quarantines a planted link and writes the real file; a refusal is raised + // rather than swallowed, so the surrounding transaction rolls back instead of + // reporting a rebuild that never happened. + if (!writeStoreFile(path.join(dir, 'INDEX.md'), lines.join('\n'))) { + throw new Error(`refused to rebuild ${path.join(dir, 'INDEX.md')} — the path does not resolve safely inside the knowledge store`); + } } diff --git a/packages/harness/lib/knowledge/layer.mjs b/packages/harness/lib/knowledge/layer.mjs index 07b1d6b1..16157042 100644 --- a/packages/harness/lib/knowledge/layer.mjs +++ b/packages/harness/lib/knowledge/layer.mjs @@ -5,6 +5,7 @@ import { deriveGitContext, resolveDefaultBranch, isDetachedKey } from '../git-co import { branchesRoot, bucketDirFor, listBuckets, bucketAncestryOk, isSafeBucketKey } from './overlay.mjs'; import { readSession } from '../session.mjs'; import { assertRealpathContained, assertNoSymlinkAncestors } from '../fs-safe.mjs'; +import { writeStoreFile, storeFileState } from './store-io.mjs'; /** * Layer-aware WRITE routing (blueprint P4, normative routing table): @@ -81,14 +82,26 @@ export function resolveWriteLayer({ workspace, home, layerOverride = null, log = export function ensureBucket(dir, { key, branch = null, baseSha = null }) { const bucketDir = bucketDirFor(dir, key); fs.mkdirSync(path.join(bucketDir, 'learnings'), { recursive: true }); + // Every bucket file goes through the choke point (R1), and `storeFileState` + // — never `fs.existsSync`, which FOLLOWS a symlink — decides whether one is + // already there: a planted link at a bucket's ledger/index/meta read as + // "already fine" and was then written through by the next writer. + // Same rule as ensureStore's seed: create only what is absent or a + // quarantinable plant; never replace a real file or something that is not a + // file at all. + const seed = (file, content) => { + const state = storeFileState(file); + if (state !== 'absent' && state !== 'symlink') return; + if (!writeStoreFile(file, content)) { + throw new Error(`refused to create ${file} — the path does not resolve safely inside the knowledge store`); + } + }; const ledgerPath = path.join(bucketDir, 'consolidated.jsonl'); - if (!fs.existsSync(ledgerPath)) fs.writeFileSync(ledgerPath, '', 'utf8'); + seed(ledgerPath, ''); const indexPath = path.join(bucketDir, 'INDEX.md'); - if (!fs.existsSync(indexPath)) { - fs.writeFileSync(indexPath, '# Learnings Index (branch bucket)\n\n_Rebuilt by `harness consolidate --apply`._\n', 'utf8'); - } + seed(indexPath, '# Learnings Index (branch bucket)\n\n_Rebuilt by `harness consolidate --apply`._\n'); const metaPath = path.join(bucketDir, 'meta.json'); - if (!fs.existsSync(metaPath)) { + if (storeFileState(metaPath) !== 'file') { const meta = { branch, branchKey: key, @@ -96,7 +109,7 @@ export function ensureBucket(dir, { key, branch = null, baseSha = null }) { createdAt: new Date().toISOString(), promotable: !isDetachedKey(key), }; - fs.writeFileSync(metaPath, JSON.stringify(meta) + '\n', 'utf8'); + seed(metaPath, JSON.stringify(meta) + '\n'); } return bucketDir; } @@ -175,11 +188,12 @@ export function migrateRenamedBucket(dir, { workspace, context }) { // never authority — a failed rename leaving updated meta under the old // key is the recoverable orphan `knowledge status`/doctor K5 surface.) const meta = source.meta || {}; - fs.writeFileSync( - path.join(source.dir, 'meta.json'), - JSON.stringify({ ...meta, branch: context.branch, branchKey: context.branchKey }) + '\n', - 'utf8' - ); + // A refused meta write must STOP the migration: the rename below would + // otherwise move the bucket with a stale `meta.branch`, which is exactly + // the migrated-but-unrecorded state the write-then-rename order avoids. + if (!writeStoreFile(path.join(source.dir, 'meta.json'), JSON.stringify({ ...meta, branch: context.branch, branchKey: context.branchKey }) + '\n')) { + return null; + } fs.renameSync(containedSource, target); return { migrated: true, from: source.key, to: context.branchKey }; } catch { diff --git a/packages/harness/lib/knowledge/learning-io.mjs b/packages/harness/lib/knowledge/learning-io.mjs deleted file mode 100644 index cbdd278c..00000000 --- a/packages/harness/lib/knowledge/learning-io.mjs +++ /dev/null @@ -1,181 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { DEFAULT_MAX_BYTES, readFileNoFollow, writeFileContained, assertNoSymlinkAncestors, assertRealpathContained } from '../fs-safe.mjs'; - -/** - * THE ONE CHOKE POINT FOR LEARNING-FILE I/O (S1). - * - * A learning file is the only content in the store a human hand-edits in - * place, so its path is the store's single largest attacker-influenced - * surface: anyone who can write into `/learnings/` can replace a - * learning with a SYMLINK and, unless every reader and writer refuses to - * follow it, make the CLI read an arbitrary outside file into store history / - * a workspace mirror, or overwrite an arbitrary outside file with a rendered - * learning. - * - * Four consecutive rounds of review fixed this ONE CALL SITE AT A TIME — - * absorb refused the symlink while `listLearnings`, `mirrorLearnings`, - * `updateFrontmatterField`, the ADD/SUPERSEDE/STRENGTHEN writers, the - * promotion tombstone, the purge delink, and lifecycle's status write all - * still followed it. The class survived every fix because the class was still - * REPRESENTABLE: `fs.readFileSync(learning.file)` was a legal thing to write. - * - * This module removes that. Every read, write, delete, and size check of a - * learning file goes through the four functions below; `fs` is not imported - * for learning paths anywhere else in lib/knowledge/. A symlink planted at a - * learning path is therefore INERT EVERYWHERE: - * - never read through (readLearningFile → readFileNoFollow) - * - never written through (writeLearningFile → writeFileContained) - * - never deleted through (removeLearningFile → assertRealpathContained) - * - never listed as a learning (listLearnings skips a null read) - * - never mirrored (mirrorLearnings skips a null read) - * and the planted link ITSELF is quarantined out of `learnings/` by the next - * absorb (quarantineSymlinkedLearning) rather than left live for the next - * reader to trip over — leaving it in place is exactly what let the previous - * round's "refused in absorb" fix still end in a truncated `~/.zshrc`. - * - * NO ROOT ARGUMENT, BY DESIGN. An earlier draft took `(root, file)`; that just - * moves the defect to "which root did this caller pass?" — a caller holding a - * bucket root (`/branches/`) would contain against the bucket, and - * a symlinked `/branches` would escape containment while satisfying it. - * The containment root is DERIVED from the path's own required shape instead, - * so no caller can supply a wrong one: - * - * /learnings//.md - * /branches//learnings//.md - * - * Anything not matching that allow-listed shape (rule 3: allow-lists, not - * deny-lists) is refused outright — there is no "unknown shape, assume the - * caller knows best" path. - */ - -/** Quarantine bucket for planted symlinks. Gitignored by ensureStore (S2), so - * a quarantined link is never staged into store history nor swept by - * `git clean -fd`. */ -export const QUARANTINE_DIR = '.quarantine'; - -/** - * Derive `{ storeRoot, rel }` from a learning file path's own shape, or null - * when the path is not a learning path at all. Purely lexical (path.resolve + - * component inspection) — it never touches the filesystem, so it cannot be - * raced, and it is the single definition of "which root contains this file" - * that every function below shares. - */ -export function learningPathParts(file) { - if (typeof file !== 'string' || !file) return null; - const full = path.resolve(file); - const parts = full.split(path.sep); - const n = parts.length; - // /learnings//.md — exactly one domain level. - if (n < 4) return null; - if (parts[n - 3] !== 'learnings') return null; - if (!parts[n - 2] || !parts[n - 1].endsWith('.md')) return null; - const layerParts = parts.slice(0, n - 3); - // A bucket layer root is `/branches/`; golden's layer root - // IS the store root. Contain against the STORE root in both cases so a - // symlinked `branches/` component is inside the walked span, not above it. - const isBucket = layerParts.length >= 2 && layerParts[layerParts.length - 2] === 'branches'; - const rootParts = isBucket ? layerParts.slice(0, layerParts.length - 2) : layerParts; - const storeRoot = rootParts.join(path.sep) || path.sep; - const rel = path.relative(storeRoot, full); - if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null; - return { storeRoot, rel, full, bucket: isBucket }; -} - -/** - * Read a learning file, or null when it must not be read: not a learning - * path, a symlink at the leaf or any ancestor between the store root and it, - * resolving outside the store, over DEFAULT_MAX_BYTES (the read-size DoS cap - * every other less-trusted read shares), or simply absent. Null is the ONE - * signal every caller acts on — skip the entry, refuse the op — so a symlink - * and a missing file are indistinguishable to a reader, which is exactly the - * inertness this module promises. - */ -export function readLearningFile(file) { - const p = learningPathParts(file); - if (!p) return null; - // Cheap scan-time ancestor pre-filter first (short-circuits before any - // open), then readFileNoFollow's canonicalize-after-acquire closes the - // ancestor-swap window the walk cannot. - if (!assertNoSymlinkAncestors(p.storeRoot, p.rel)) return null; - return readFileNoFollow(p.full, { root: p.storeRoot, maxBytes: DEFAULT_MAX_BYTES }); -} - -/** - * Contained, atomic learning write (create-empty → verify → write-through-fd → - * rename). Returns true only when the bytes landed at a path proven inside the - * store; false on any refusal. The rename REPLACES a leaf rather than writing - * through it, and `assertNoSymlinkAncestors` inside `writeFileContained` - * refuses a symlinked leaf before that — so neither the link nor its target is - * ever written. - */ -export function writeLearningFile(file, content) { - const p = learningPathParts(file); - if (!p) return false; - return Boolean(writeFileContained(p.storeRoot, p.rel, content)); -} - -/** - * Remove a learning file. Refuses a symlink (assertRealpathContained rejects a - * symlinked leaf or ancestor) and anything whose real path escapes the store, - * so a purge cascade can never unlink an outside file. Returns true only when - * something was actually removable. - */ -export function removeLearningFile(file) { - const p = learningPathParts(file); - if (!p) return false; - const full = assertRealpathContained(p.storeRoot, p.rel); - if (!full) return false; - try { - fs.rmSync(full, { force: true }); - return true; - } catch { - return false; - } -} - -/** - * Move a symlink planted AT a learning path out of `learnings/` and into - * `/.quarantine/`, returning the store-relative quarantine path (or - * null when there was nothing to quarantine). - * - * WHY MOVE RATHER THAN LEAVE OR DELETE. Leaving it is what caused the finding - * this module closes: every reader refused it, but it stayed on disk looking - * like an active learning to anything that only checked `readdir`, and the - * next writer to reach for that id had a live link waiting. Deleting it would - * destroy something a human may have put there deliberately. Renaming the LINK - * (rename never follows a symlink, and never touches its target) makes it - * inert while preserving it for inspection, and the move is reported by the - * caller so a person sees that it happened. - * - * Only the leaf may be a symlink: every ancestor from the store root down to - * the containing domain directory must be a real directory, or the rename - * itself could be steered outside the store — in that case this refuses and - * the caller simply logs. - */ -export function quarantineSymlinkedLearning(file) { - const p = learningPathParts(file); - if (!p) return null; - const parentRel = path.dirname(p.rel); - if (!assertNoSymlinkAncestors(p.storeRoot, parentRel)) return null; - let stat; - try { - stat = fs.lstatSync(p.full); - } catch { - return null; // nothing there (or unreadable) — nothing to quarantine - } - if (!stat.isSymbolicLink()) return null; - const destRel = path.join( - QUARANTINE_DIR, - `${p.rel.split(path.sep).join('__')}.${Date.now()}-${process.pid}.symlink` - ); - const dest = assertNoSymlinkAncestors(p.storeRoot, destRel); - if (!dest) return null; - try { - fs.mkdirSync(path.dirname(dest), { recursive: true }); - fs.renameSync(p.full, dest); - } catch { - return null; - } - return destRel.split(path.sep).join('/'); -} diff --git a/packages/harness/lib/knowledge/lifecycle.mjs b/packages/harness/lib/knowledge/lifecycle.mjs index 150a9f54..62c1706a 100644 --- a/packages/harness/lib/knowledge/lifecycle.mjs +++ b/packages/harness/lib/knowledge/lifecycle.mjs @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { storeDir, withStoreTransaction, StoreTransactionAbort, listLearnings, serializeLearning, appendGovernance } from './store.mjs'; import { updateFrontmatterField, todayClamped, rebuildIndex } from './apply.mjs'; -import { writeLearningFile } from './learning-io.mjs'; +import { writeLearningFile } from './store-io.mjs'; import { absorbOrAbort, mirrorLearnings } from './admin.mjs'; /** diff --git a/packages/harness/lib/knowledge/overlay.mjs b/packages/harness/lib/knowledge/overlay.mjs index 05330df1..2978bac1 100644 --- a/packages/harness/lib/knowledge/overlay.mjs +++ b/packages/harness/lib/knowledge/overlay.mjs @@ -4,6 +4,7 @@ import { spawnSync } from 'node:child_process'; import { storeDir, listLearnings, readGovernance, inertLine } from './store.mjs'; import { deriveGitContext } from '../git-context.mjs'; import { redactSecrets } from '../secret-scan.mjs'; +import { readStoreFile } from './store-io.mjs'; /** * The layered read path (harness evolution blueprint §4) — ONE exported @@ -104,7 +105,7 @@ export function safeBranchName(value) { * time; meta only carries display/ancestry hints. */ export function readBucketMeta(bucketDir) { try { - const parsed = JSON.parse(fs.readFileSync(path.join(bucketDir, 'meta.json'), 'utf8')); + const parsed = JSON.parse(readStoreFile(path.join(bucketDir, 'meta.json'))); return parsed && typeof parsed === 'object' ? parsed : null; } catch { return null; diff --git a/packages/harness/lib/knowledge/promote.mjs b/packages/harness/lib/knowledge/promote.mjs index 79076886..064510ba 100644 --- a/packages/harness/lib/knowledge/promote.mjs +++ b/packages/harness/lib/knowledge/promote.mjs @@ -6,7 +6,7 @@ import { isActiveFm, MAX_OPS_PER_RUN } from './consolidate.mjs'; import { bucketDirFor, readBucketMeta, listBuckets, bucketAncestryOk, isSafeBucketKey } from './overlay.mjs'; import { deriveGitContext, isDetachedKey } from '../git-context.mjs'; import { writeFileContained } from '../fs-safe.mjs'; -import { readLearningFile } from './learning-io.mjs'; +import { readLearningFile } from './store-io.mjs'; /** * `harness knowledge promote` (blueprint §5): emits a REVIEWABLE op-set at diff --git a/packages/harness/lib/knowledge/remember.mjs b/packages/harness/lib/knowledge/remember.mjs index ff934520..61fa1b6a 100644 --- a/packages/harness/lib/knowledge/remember.mjs +++ b/packages/harness/lib/knowledge/remember.mjs @@ -4,7 +4,7 @@ import path from 'node:path'; import { runInsightCompound } from '../compound.mjs'; import { runIndexKnowledge } from '../index-knowledge.mjs'; import { applyOps } from './apply.mjs'; -import { normalizeSlug, readStoreConfig, storeDir, listLearnings, withStoreTransaction, StoreTransactionAbort, readLedger } from './store.mjs'; +import { normalizeSlug, readStoreConfig, storeDir, listLearnings, withStoreTransaction, StoreTransactionAbort, readLedger, writeLedger } from './store.mjs'; import { absorbOrAbort } from './admin.mjs'; import { resolveWriteLayer } from './layer.mjs'; import { bucketDirFor } from './overlay.mjs'; @@ -210,11 +210,8 @@ export function runRemember({ workspace, copilotHome, flags, argv, log = () => { if (keptLedger.length === ledger.length) { return { kind: 'success', commitMessage: null }; } - fs.writeFileSync( - path.join(dir, 'consolidated.jsonl'), - keptLedger.length ? keptLedger.map((e) => JSON.stringify(e)).join('\n') + '\n' : '', - 'utf8' - ); + // Through the choke point, fail-closed on an unreadable ledger (R1). + writeLedger(dir, keptLedger); return { kind: 'success', commitMessage: `remember: clear failure bookkeeping for ${episode.path}` }; }); } catch { diff --git a/packages/harness/lib/knowledge/store-io.mjs b/packages/harness/lib/knowledge/store-io.mjs new file mode 100644 index 00000000..3338d93f --- /dev/null +++ b/packages/harness/lib/knowledge/store-io.mjs @@ -0,0 +1,324 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { + DEFAULT_MAX_BYTES, + readFileNoFollow, + writeFileContained, + appendFileContained, + assertNoSymlinkAncestors, + assertRealpathContained, +} from '../fs-safe.mjs'; + +/** + * THE ONE CHOKE POINT FOR STORE-FILE I/O (S1/R1). + * + * Everything the knowledge store owns lives in ONE directory a human writes + * to, so every file in it is equally symlink-plantable: anyone who can write + * into `/` can replace ANY of them with a symlink and, unless every + * reader and writer refuses to follow it, make the CLI read an arbitrary + * outside file into store history / a workspace mirror, or overwrite (or + * truncate) an arbitrary outside file. + * + * Round after round of review fixed this ONE CALL SITE AT A TIME. Round 5 + * finally made LEARNING files structurally safe by routing every read, write + * and delete of one through this module — but it drew the boundary at + * "learning files" and scoped store METADATA out as "a separate class". It is + * not a separate class, by that same round's own reasoning: it converted + * `/.gitignore` on the grounds that "the store root is a directory a + * human writes to, so it is as symlink-plantable as any learning path". Every + * metadata file sits in that same directory, and the survivors were live: + * `ln -sf ~/.zshrc /INDEX.md` plus any `harness learning retire` (which + * runs rebuildIndex) truncated and replaced `~/.zshrc`, while `ensureStore`'s + * `fs.existsSync` followed the link and never noticed. + * + * This module removes the whole class. Every read, write, append, delete and + * existence check of a STORE-OWNED file — learning, index, ledger, governance + * ledger, config, schema marker, stale report, bucket metadata, the lock owner + * stamp, the transaction journal — goes through the functions below; `fs` is + * not used on a store-owned path anywhere else in lib/knowledge/. A symlink + * planted at ANY store path is therefore INERT: + * - never read through (readStoreFile → readFileNoFollow) + * - never written through (writeStoreFile quarantines the link first) + * - never appended through (appendStoreFile → O_NOFOLLOW append) + * - never deleted through (removeStoreFile → assertRealpathContained) + * - never existsSync-trusted (storeFileState reports 'symlink', not 'file') + * - never listed as a learning (listLearnings skips a null read) + * and the planted link ITSELF is moved into `/.quarantine/` rather than + * left live for the next reader to trip over — leaving it in place is exactly + * what let an earlier "refused in absorb" fix still end in a truncated + * `~/.zshrc`. + * + * NO ROOT ARGUMENT, BY DESIGN. An earlier draft took `(root, file)`; that just + * moves the defect to "which root did this caller pass?" — a caller holding a + * bucket root (`/branches/`) would contain against the bucket, and + * a symlinked `/branches` would escape containment while satisfying it. + * The containment root is DERIVED from the path's own required shape instead, + * so no caller can supply a wrong one. Extending the module to metadata + * therefore extends the ALLOW-LIST of shapes, never the signature: + * + * /learnings//.md + * /branches//learnings//.md + * / + * /branches// + * /.git/harness-txn.json + * /.lock/owner.json + * + * Anything not matching that allow-listed shape (rule 3: allow-lists, not + * deny-lists) is refused outright — there is no "unknown shape, assume the + * caller knows best" path. + */ + +/** Quarantine bucket for planted symlinks. Gitignored by the store `.gitignore` + * (S2), so a quarantined link is never staged into store history nor swept by + * `git clean -fd`. */ +export const QUARANTINE_DIR = '.quarantine'; + +/** Store-root metadata files. Bucket roots get their OWN, smaller set below: + * a bucket has no config/governance/schema/stale state of its own, and the + * store root has no `meta.json` — being strict about WHICH name is legal at + * WHICH layer is the allow-list doing its job. */ +const STORE_ROOT_FILES = new Set([ + 'INDEX.md', + 'consolidated.jsonl', + 'governance.jsonl', + 'config.json', + 'store.json', + 'stale.json', + '.gitignore', +]); +const BUCKET_FILES = new Set(['INDEX.md', 'consolidated.jsonl', 'meta.json']); +/** Store-owned files one directory DOWN from the store root. `.git/` and + * `.lock/` are both directories a human can reach, so the two files the CLI + * owns inside them are contained against the STORE root — not against `.git` + * or `.lock` — so a symlinked `.git`/`.lock` component is inside the walked + * span rather than above it. */ +const NESTED_STORE_FILES = new Set(['.git/harness-txn.json', '.lock/owner.json']); + +function parts(rootParts, full, kind, bucket) { + const storeRoot = rootParts.join(path.sep) || path.sep; + const rel = path.relative(storeRoot, full); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null; + return { storeRoot, rel, full, kind, bucket }; +} + +/** + * Derive `{ storeRoot, rel, full, kind, bucket }` from a store file path's own + * shape, or null when the path is not an allow-listed store file at all. + * Purely lexical (path.resolve + component inspection) — it never touches the + * filesystem, so it cannot be raced, and it is the single definition of "which + * root contains this file" that every function below shares. + */ +export function storePathParts(file) { + if (typeof file !== 'string' || !file) return null; + const full = path.resolve(file); + const seg = full.split(path.sep); + const n = seg.length; + const at = (i) => (i >= 0 && i < n ? seg[i] : undefined); + + // /learnings//.md — exactly one domain level. + if (n >= 4 && at(n - 3) === 'learnings' && at(n - 2) && String(at(n - 1)).endsWith('.md')) { + const layerParts = seg.slice(0, n - 3); + // A bucket layer root is `/branches/`; golden's layer root + // IS the store root. Contain against the STORE root in both cases so a + // symlinked `branches/` component is inside the walked span, not above it. + const isBucket = layerParts.length >= 2 && layerParts[layerParts.length - 2] === 'branches'; + const rootParts = isBucket ? layerParts.slice(0, layerParts.length - 2) : layerParts; + return parts(rootParts, full, 'learning', isBucket); + } + // /.git/harness-txn.json, /.lock/owner.json + if (n >= 3 && NESTED_STORE_FILES.has(`${at(n - 2)}/${at(n - 1)}`)) { + return parts(seg.slice(0, n - 2), full, 'nested', false); + } + // /branches//. Checked BEFORE the store-root + // shape so `branches//INDEX.md` contains against the store, not the + // bucket. + if (n >= 4 && at(n - 3) === 'branches' && at(n - 2) && BUCKET_FILES.has(at(n - 1))) { + return parts(seg.slice(0, n - 3), full, 'bucket-meta', true); + } + // / + if (n >= 2 && STORE_ROOT_FILES.has(at(n - 1))) { + return parts(seg.slice(0, n - 1), full, 'store-meta', false); + } + return null; +} + +/** The learning-only view of the same derivation: used by the learning-specific + * wrappers below so a caller reaching for `writeLearningFile` can never be + * handed a metadata path (and vice versa). */ +export function learningPathParts(file) { + const p = storePathParts(file); + return p && p.kind === 'learning' ? p : null; +} + +/** + * `'absent'` (nothing at the path), `'file'` (a real regular file, safely + * reachable), `'symlink'` (a planted link — NEVER 'file'), `'other'` (a + * directory or special file), or `'blocked'` (not a store path at all, or a + * symlinked ancestor stands between the store root and it). + * + * This is the ONLY existence check any store caller may use. `fs.existsSync` + * FOLLOWS a symlink, so `if (!fs.existsSync(indexPath))` read a planted link + * as "already fine" and left it live for the next writer to follow — the exact + * step that made the INDEX.md exploit reachable. + */ +export function storeFileState(file) { + const p = storePathParts(file); + if (!p) return 'blocked'; + if (!assertNoSymlinkAncestors(p.storeRoot, path.dirname(p.rel))) return 'blocked'; + let stat; + try { + stat = fs.lstatSync(p.full); + } catch { + return 'absent'; + } + if (stat.isSymbolicLink()) return 'symlink'; + return stat.isFile() ? 'file' : 'other'; +} + +/** + * Read a store file, or null when it must not be read: not a store path, a + * symlink at the leaf or any ancestor between the store root and it, resolving + * outside the store, over DEFAULT_MAX_BYTES (the read-size DoS cap every other + * less-trusted read shares), or simply absent. Null is the ONE signal every + * caller acts on — skip the entry, refuse the op, fall back to a default — so a + * symlink and a missing file are indistinguishable to a reader, which is + * exactly the inertness this module promises. A caller that must tell "absent" + * from "present but unreadable" apart (never truncate what you could not read) + * asks `storeFileState` as well. + */ +export function readStoreFile(file) { + const p = storePathParts(file); + if (!p) return null; + // Cheap scan-time ancestor pre-filter first (short-circuits before any + // open), then readFileNoFollow's canonicalize-after-acquire closes the + // ancestor-swap window the walk cannot. + if (!assertNoSymlinkAncestors(p.storeRoot, p.rel)) return null; + return readFileNoFollow(p.full, { root: p.storeRoot, maxBytes: DEFAULT_MAX_BYTES }); +} + +/** + * Contained, atomic store write (create-empty → verify → write-through-fd → + * rename). Returns true only when the bytes landed at a path proven inside the + * store; false on any refusal. + * + * A SYMLINK AT THE LEAF IS QUARANTINED, NOT MERELY REFUSED. Refusing alone + * leaves the link standing at a live store path forever — the CLI silently + * stops maintaining that file, and the next reader/writer meets the link + * again. Moving the LINK (rename never follows a symlink, and never touches + * its target) into `/.quarantine/` makes it inert AND lets the real + * file be rebuilt, while preserving the link for inspection. + */ +export function writeStoreFile(file, content) { + const p = storePathParts(file); + if (!p) return false; + quarantineSymlinkedStorePath(p.full); + return Boolean(writeFileContained(p.storeRoot, p.rel, content)); +} + +/** + * Contained append for the store's append-only ledgers. Same quarantine-first + * rule as writeStoreFile, then a real O_NOFOLLOW/O_APPEND write (fs-safe.mjs) + * rather than a read-modify-write — an append must be able to succeed on a + * ledger it cannot read whole, or an over-cap/unreadable ledger would be + * silently TRUNCATED by its next append. `newlineGuard` inserts a separating + * newline when the file is non-empty and does not already end in one. + */ +export function appendStoreFile(file, content, { newlineGuard = false } = {}) { + const p = storePathParts(file); + if (!p) return false; + quarantineSymlinkedStorePath(p.full); + return Boolean(appendFileContained(p.storeRoot, p.rel, content, { newlineGuard })); +} + +/** + * Remove a store file. Refuses a symlink (assertRealpathContained rejects a + * symlinked leaf or ancestor) and anything whose real path escapes the store, + * so a purge cascade — or a journal cleanup — can never unlink an outside + * file. Returns true only when something was actually removable. + */ +export function removeStoreFile(file) { + const p = storePathParts(file); + if (!p) return false; + const full = assertRealpathContained(p.storeRoot, p.rel); + if (!full) return false; + try { + fs.rmSync(full, { force: true }); + return true; + } catch { + return false; + } +} + +/** + * Move a symlink planted AT a store path out of the store's live tree and into + * `/.quarantine/`, returning the store-relative quarantine path (or + * null when there was nothing to quarantine). + * + * WHY MOVE RATHER THAN LEAVE OR DELETE. Leaving it is the defect this module + * closes: every reader refused it, but it stayed on disk looking like an + * active store file to anything that only checked `readdir`/`existsSync`, and + * the next writer to reach for that path had a live link waiting. Deleting it + * would destroy something a human may have put there deliberately. Renaming + * the LINK (rename never follows a symlink, and never touches its target) + * makes it inert while preserving it for inspection. + * + * Only the leaf may be a symlink: every ancestor from the store root down to + * the containing directory must be a real directory, or the rename itself + * could be steered outside the store — in that case this refuses. + */ +export function quarantineSymlinkedStorePath(file) { + const p = storePathParts(file); + if (!p) return null; + const parentRel = path.dirname(p.rel); + if (!assertNoSymlinkAncestors(p.storeRoot, parentRel)) return null; + let stat; + try { + stat = fs.lstatSync(p.full); + } catch { + return null; // nothing there (or unreadable) — nothing to quarantine + } + if (!stat.isSymbolicLink()) return null; + const destRel = path.join( + QUARANTINE_DIR, + `${p.rel.split(path.sep).join('__')}.${Date.now()}-${process.pid}.symlink` + ); + const dest = assertNoSymlinkAncestors(p.storeRoot, destRel); + if (!dest) return null; + try { + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.renameSync(p.full, dest); + } catch { + return null; + } + return destRel.split(path.sep).join('/'); +} + +// --------------------------------------------------------------------------- +// Learning-shaped wrappers. +// +// Same guards, narrowed to `kind: 'learning'`: a caller reaching for the +// learning API can never be handed a metadata path by a crafted id, and a +// metadata writer can never be routed through the learning API. Every existing +// call site keeps its precise, self-documenting name. +// --------------------------------------------------------------------------- + +/** Read a learning file, or null when it must not be read (see readStoreFile). */ +export function readLearningFile(file) { + return learningPathParts(file) ? readStoreFile(file) : null; +} + +/** Contained, atomic learning write. False on any refusal. */ +export function writeLearningFile(file, content) { + return learningPathParts(file) ? writeStoreFile(file, content) : false; +} + +/** Remove a learning file; refuses a symlink or an escaping path. */ +export function removeLearningFile(file) { + return learningPathParts(file) ? removeStoreFile(file) : false; +} + +/** Quarantine a symlink planted at a LEARNING path (absorb's reporting path). */ +export function quarantineSymlinkedLearning(file) { + return learningPathParts(file) ? quarantineSymlinkedStorePath(file) : null; +} diff --git a/packages/harness/lib/knowledge/store.mjs b/packages/harness/lib/knowledge/store.mjs index 666a8358..c0a295d1 100644 --- a/packages/harness/lib/knowledge/store.mjs +++ b/packages/harness/lib/knowledge/store.mjs @@ -3,8 +3,16 @@ import path from 'node:path'; import crypto from 'node:crypto'; import { spawnSync } from 'node:child_process'; import { harnessGlobalHome } from '../paths.mjs'; -import { readFileNoFollow, writeFileContained, assertRealpathContained } from '../fs-safe.mjs'; -import { readLearningFile, QUARANTINE_DIR } from './learning-io.mjs'; +import { assertNoSymlinkAncestors, assertRealpathContained, readFileNoFollow } from '../fs-safe.mjs'; +import { + readLearningFile, + readStoreFile, + writeStoreFile, + appendStoreFile, + removeStoreFile, + storeFileState, + QUARANTINE_DIR, +} from './store-io.mjs'; /** * The local knowledge store: a CLI-managed git repo OUTSIDE the working tree @@ -103,10 +111,10 @@ export const STORE_SCHEMA = 2; export function assertStoreSchemaSupported(dir) { let recorded = null; try { - const parsed = JSON.parse(fs.readFileSync(path.join(dir, 'store.json'), 'utf8')); + const parsed = JSON.parse(readStoreFile(path.join(dir, 'store.json'))); if (parsed && Number.isInteger(parsed.schema)) recorded = parsed.schema; } catch { - recorded = null; // absent or corrupt — legacy/current, never a refusal + recorded = null; // absent, unreadable, symlinked, or corrupt — never a refusal } if (recorded !== null && recorded > STORE_SCHEMA) { const err = new Error( @@ -151,21 +159,44 @@ const STORE_IGNORE_ENTRIES = ['/.lock/', '/.lock.stale-*', `/${QUARANTINE_DIR}/` * migration exists. Idempotent and additive — an entry a human already wrote * is not duplicated, and lines this CLI does not own are preserved verbatim. */ +/** + * Called from inside `withStoreTransaction` UNDER THE LOCK (P3), never from + * `ensureStore`: it used to run before `acquireStoreLock`, which made it a + * store MUTATION outside the lock — two writers could interleave a + * read-modify-write of the same file, and the very file that keeps a rollback + * from sweeping the lock was written by an unlocked path. + * + * NEVER CLOBBER WHAT YOU COULD NOT READ (P3). A `.gitignore` that exists but + * fails `readStoreFile` for a reason OTHER than "it is a symlink" — over + * DEFAULT_MAX_BYTES, unreadable permissions — used to read as `''` and was then + * FULLY REPLACED rather than extended, destroying whatever a human had put + * there. Such a file is now left exactly as found: the entries are merely a + * belt to the ownership-token brace, so running without them is degraded, not + * unsafe, while silently rewriting a file we cannot see is neither. + */ function ensureStoreGitignore(dir) { - // fs-safe on both halves (rule 1): `.gitignore` sits in the store root, a - // directory a human writes to, so it is as symlink-plantable as any learning - // path — following one would append these entries to (and, with the read - // returning that file's content, rewrite) an arbitrary outside file. - const existing = readFileNoFollow(path.join(dir, '.gitignore'), { root: dir }) ?? ''; + const gitignore = path.join(dir, '.gitignore'); + const state = storeFileState(gitignore); + // 'other'/'blocked': a directory at that name, or a symlinked ancestor — + // nothing this function may safely touch. 'symlink' falls through: the + // planted link is quarantined by writeStoreFile and a real file takes its + // place, which is the whole point of making a plant inert. + if (state === 'other' || state === 'blocked') return; + let existing = ''; + if (state === 'file') { + const text = readStoreFile(gitignore); + if (text === null) return; // present and real, but unreadable — never rewrite it + existing = text; + } const lines = existing.split('\n').map((l) => l.trim()); const missing = STORE_IGNORE_ENTRIES.filter((entry) => !lines.includes(entry)); if (!missing.length) return; const header = existing ? (existing.endsWith('\n') ? '' : '\n') : '# harness knowledge store — never staged, never swept by `git clean -fd`\n'; try { - // Best effort: an unwritable (or symlinked) `.gitignore` still lets the - // store run — it just falls back to the ownership-token layer below for - // lock safety, which is independent of this file. - writeFileContained(dir, '.gitignore', existing + header + missing.join('\n') + '\n'); + // Best effort: an unwritable `.gitignore` still lets the store run — it + // just falls back to the ownership-token layer below for lock safety, + // which is independent of this file. + writeStoreFile(gitignore, existing + header + missing.join('\n') + '\n'); } catch { // writeFileContained mkdirs the parent, which can throw on a hand-built store } @@ -174,20 +205,39 @@ function ensureStoreGitignore(dir) { export function ensureStore(workspace, { home, dryRun = false } = {}) { const dir = storeDir(workspace, { home }); assertStoreSchemaSupported(dir); - const created = !fs.existsSync(path.join(dir, 'consolidated.jsonl')); + const ledgerPath = path.join(dir, 'consolidated.jsonl'); + const created = storeFileState(ledgerPath) !== 'file'; + // `.git` is a DIRECTORY probe, not a store-owned file read — the store's git + // repo is created and read by git itself, never by this module. if (dryRun) return { dir, created, git: fs.existsSync(path.join(dir, '.git')) }; fs.mkdirSync(path.join(dir, 'learnings'), { recursive: true }); - ensureStoreGitignore(dir); let gitOk = fs.existsSync(path.join(dir, '.git')); if (!gitOk) { gitOk = spawnSync('git', ['init', '-q'], { cwd: dir, encoding: 'utf8' }).status === 0; } - const indexPath = path.join(dir, 'INDEX.md'); - if (!fs.existsSync(indexPath)) fs.writeFileSync(indexPath, INDEX_STUB, 'utf8'); - const ledgerPath = path.join(dir, 'consolidated.jsonl'); - if (!fs.existsSync(ledgerPath)) fs.writeFileSync(ledgerPath, '', 'utf8'); - const schemaPath = path.join(dir, 'store.json'); - if (!fs.existsSync(schemaPath)) fs.writeFileSync(schemaPath, JSON.stringify({ schema: STORE_SCHEMA }) + '\n', 'utf8'); + // `storeFileState` — never `fs.existsSync` — decides "is this file already + // there?": existsSync FOLLOWS a symlink, so a planted link read as "already + // fine" and was left live for the next writer to follow (the verified + // INDEX.md exploit). A 'symlink'/'absent'/'other' state all mean "write the + // real file", and writeStoreFile quarantines the link on the way. + // A refused write throws (as the bare `fs.writeFileSync` these replaced did): + // a store missing its index/ledger/schema marker is not a store this CLI may + // pretend it opened. + // Only 'absent' and 'symlink' are seeded: 'absent' is a fresh/legacy store, + // and a 'symlink' is a plant that writeStoreFile quarantines on the way. A + // real file is already correct, and 'other' (a directory, a device node) is + // NOT ours to replace — whichever writer actually needs it will fail closed + // inside the transaction, where a rollback exists. + const seed = (file, content) => { + const state = storeFileState(file); + if (state !== 'absent' && state !== 'symlink') return; + if (!writeStoreFile(file, content)) { + throw new Error(`refused to create ${file} — the path does not resolve safely inside the knowledge store`); + } + }; + seed(path.join(dir, 'INDEX.md'), INDEX_STUB); + seed(ledgerPath, ''); + seed(path.join(dir, 'store.json'), JSON.stringify({ schema: STORE_SCHEMA }) + '\n'); return { dir, created, git: gitOk }; } @@ -217,7 +267,7 @@ export function readStoreConfig(workspace, { home } = {}) { let mode = 'on'; let commit = 'none'; try { - const parsed = JSON.parse(fs.readFileSync(path.join(dir, 'config.json'), 'utf8')); + const parsed = JSON.parse(readStoreFile(path.join(dir, 'config.json'))); if (parsed && KNOWLEDGE_MODES.has(parsed.mode)) mode = parsed.mode; if (parsed && KNOWLEDGE_COMMIT_MODES.has(parsed.commit)) commit = parsed.commit; } catch { @@ -243,12 +293,17 @@ export function writeStoreConfig(workspace, { home, mode, commit } = {}) { // read-modify-write owns only mode/commit, never the whole file. let raw = {}; try { - const parsed = JSON.parse(fs.readFileSync(path.join(dir, 'config.json'), 'utf8')); + const parsed = JSON.parse(readStoreFile(path.join(dir, 'config.json'))); if (parsed && typeof parsed === 'object') raw = parsed; } catch { // absent/corrupt — nothing extra to preserve } - fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ ...raw, mode: nextMode, commit: nextCommit }) + '\n', 'utf8'); + // Through the choke point (R1), and CHECKED: a refused config write used to + // be invisible, so `knowledge freeze` reported the new mode while the store + // kept running in the old one. A refusal fails the transaction instead. + if (!writeStoreFile(path.join(dir, 'config.json'), JSON.stringify({ ...raw, mode: nextMode, commit: nextCommit }) + '\n')) { + throw new Error('refused to write config.json — the path does not resolve safely inside the knowledge store'); + } const message = mode !== undefined ? `knowledge: mode ${nextMode}` : `knowledge: commit ${nextCommit}`; return { nextMode, nextCommit, commitMessage: message }; }); @@ -273,12 +328,20 @@ export function writeStoreConfig(workspace, { home, mode, commit } = {}) { }; } -/** Append-only episode-consumption ledger. Torn tail lines are tolerated. */ -export function readLedger(dir) { - const ledgerPath = path.join(dir, 'consolidated.jsonl'); - if (!fs.existsSync(ledgerPath)) return []; +function ledgerPathFor(root) { + return path.join(root, 'consolidated.jsonl'); +} + +/** Append-only episode-consumption ledger. Torn tail lines are tolerated; a + * ledger that cannot be read safely (symlinked, over the read cap, outside the + * store) reads as empty — the same tolerant default an absent one gets. Every + * REWRITE goes through `writeLedger` below, which refuses on exactly that + * unreadable case rather than truncating what it could not see. */ +export function readLedger(root) { + const text = readStoreFile(ledgerPathFor(root)); + if (text === null) return []; const entries = []; - for (const line of fs.readFileSync(ledgerPath, 'utf8').split('\n')) { + for (const line of text.split('\n')) { if (!line.trim()) continue; try { entries.push(JSON.parse(line)); @@ -289,12 +352,42 @@ export function readLedger(dir) { return entries; } -export function appendLedger(dir, entries) { +/** + * Append through the choke point: a real O_NOFOLLOW/O_APPEND write, so a ledger + * too large to read whole is still appendable (a read-modify-write "append" + * would truncate it). + * + * THROWS ON REFUSAL, never silently drops the entries (same discipline as S4's + * "a rollback whose result nobody checked"): the ledger is what counts episode + * consumption and three-strikes quarantine, so an append nobody noticed failing + * would leave the store's own bookkeeping quietly wrong. Every caller runs + * inside a transaction, which turns the throw into a rollback. + */ +export function appendLedger(root, entries) { if (!entries || !entries.length) return; - const ledgerPath = path.join(dir, 'consolidated.jsonl'); - const existing = fs.existsSync(ledgerPath) ? fs.readFileSync(ledgerPath, 'utf8') : ''; - const prefix = existing && !existing.endsWith('\n') ? '\n' : ''; - fs.appendFileSync(ledgerPath, prefix + entries.map((e) => JSON.stringify(e)).join('\n') + '\n'); + const ledgerPath = ledgerPathFor(root); + if (!appendStoreFile(ledgerPath, entries.map((e) => JSON.stringify(e)).join('\n') + '\n', { newlineGuard: true })) { + throw new Error(`refused to append to ${ledgerPath} — the path does not resolve safely inside the knowledge store`); + } +} + +/** + * Full ledger REWRITE (the purge/cleanup filter shape: read → drop entries → + * write back). Unlike an append or a deliberate truncate, this one is only + * correct if the read that produced `keptEntries` actually saw the file — so + * it FAILS CLOSED when the ledger is present but unreadable, rather than + * writing a filtered version of nothing over it. Throws so the surrounding + * transaction rolls back; callers doing a deliberate WIPE write '' directly + * and never come through here. + */ +export function writeLedger(root, keptEntries) { + const ledgerPath = ledgerPathFor(root); + if (storeFileState(ledgerPath) === 'file' && readStoreFile(ledgerPath) === null) { + throw new Error(`refused to rewrite ${ledgerPath} — it exists but could not be read safely`); + } + if (!writeStoreFile(ledgerPath, keptEntries.length ? keptEntries.map((e) => JSON.stringify(e)).join('\n') + '\n' : '')) { + throw new Error(`refused to write ${ledgerPath} — the path does not resolve safely inside the knowledge store`); + } } /** @@ -307,9 +400,14 @@ export function appendLedger(dir, entries) { */ function readGovernanceEntries(dir) { const govPath = path.join(dir, 'governance.jsonl'); - if (!fs.existsSync(govPath)) return []; + if (storeFileState(govPath) === 'absent') return []; + const text = readStoreFile(govPath); + // Present but unreadable (symlinked, over the read cap, escaping the store). + // Distinguished from absent — a tolerant READER treats it as empty, but a + // REWRITE must never truncate a file it could not see. + if (text === null) return null; const entries = []; - for (const line of fs.readFileSync(govPath, 'utf8').split('\n')) { + for (const line of text.split('\n')) { if (!line.trim()) continue; try { entries.push(JSON.parse(line)); @@ -358,7 +456,7 @@ const REPLAY_DECISION_ACTIONS = new Set(['retire', 'dispute', 'confirm', 'promot export function readGovernance(dir) { const map = new Map(); - for (const entry of readGovernanceEntries(dir)) { + for (const entry of readGovernanceEntries(dir) || []) { if (!entry || !entry.id) continue; if (!REPLAY_DECISION_ACTIONS.has(entry.action)) continue; // audit-only (absorb-branch, future actions) const existing = map.get(entry.id); @@ -368,12 +466,14 @@ export function readGovernance(dir) { return map; } -/** Append one governance decision. Same newline-guard idiom as appendLedger. */ +/** Append one governance decision. Same choke-point append as appendLedger, and + * the same fail-closed rule: a standing human decision that silently failed to + * land would be re-derived away by the next `consolidate --rebuild`. */ export function appendGovernance(dir, entry) { const govPath = path.join(dir, 'governance.jsonl'); - const existing = fs.existsSync(govPath) ? fs.readFileSync(govPath, 'utf8') : ''; - const prefix = existing && !existing.endsWith('\n') ? '\n' : ''; - fs.appendFileSync(govPath, prefix + JSON.stringify(entry) + '\n'); + if (!appendStoreFile(govPath, JSON.stringify(entry) + '\n', { newlineGuard: true })) { + throw new Error(`refused to append to ${govPath} — the path does not resolve safely inside the knowledge store`); + } } /** @@ -385,9 +485,16 @@ export function appendGovernance(dir, entry) { */ export function rewriteGovernance(dir, keepPredicate) { const govPath = path.join(dir, 'governance.jsonl'); - if (!fs.existsSync(govPath)) return; - const kept = readGovernanceEntries(dir).filter(keepPredicate); - fs.writeFileSync(govPath, kept.length ? kept.map((e) => JSON.stringify(e)).join('\n') + '\n' : '', 'utf8'); + if (storeFileState(govPath) === 'absent') return; + const entries = readGovernanceEntries(dir); + // Fail closed, exactly like writeLedger: a filter-rewrite is only correct if + // the read saw the file. Truncating a governance ledger we could not read + // would silently erase standing human decisions. + if (entries === null) throw new Error(`refused to rewrite ${govPath} — it exists but could not be read safely`); + const kept = entries.filter(keepPredicate); + if (!writeStoreFile(govPath, kept.length ? kept.map((e) => JSON.stringify(e)).join('\n') + '\n' : '')) { + throw new Error(`refused to write ${govPath} — the path does not resolve safely inside the knowledge store`); + } } /** @@ -614,16 +721,21 @@ export function serializeLearning(fm, body) { } export function listLearnings(dir) { - const root = path.join(dir, 'learnings'); const out = []; - if (!fs.existsSync(root)) return out; + // A symlinked `learnings/` (or a symlinked store root) would make this scan + // enumerate an arbitrary outside directory. Every individual read below is + // already inert against it — readLearningFile's ancestor walk refuses the + // whole subtree — but refusing the WALK up front means the scan never even + // enumerates names outside the store. + const root = assertNoSymlinkAncestors(dir, 'learnings'); + if (!root || !fs.existsSync(root)) return out; for (const domain of fs.readdirSync(root, { withFileTypes: true })) { if (!domain.isDirectory()) continue; const dPath = path.join(root, domain.name); for (const f of fs.readdirSync(dPath)) { if (!f.endsWith('.md')) continue; const file = path.join(dPath, f); - // THE ONLY READ (S1): `readLearningFile` (learning-io.mjs) is the store's + // THE ONLY READ (S1): `readLearningFile` (store-io.mjs) is the store's // single learning-file reader. It returns null — and this entry is simply // not a learning — for a symlinked leaf or ancestor, a path resolving // outside the store, a file over the DEFAULT_MAX_BYTES read cap (the @@ -731,19 +843,27 @@ function newLockToken() { return `${process.pid}-${crypto.randomBytes(12).toString('hex')}`; } -/** Stamp ownership into a lock directory we just created. Best effort: a lock - * whose owner file could not be written reads as `unknown` below, which is - * treated as NOT ours — fail closed, never claim what we cannot prove. */ +/** + * Stamp ownership into a lock directory we just created, and REPORT whether it + * landed (P3). It used to be best-effort-and-ignored, which quietly wedged the + * store for STALE_LOCK_MS: a failed stamp makes `lockOwnership` report our own + * lock `foreign`, so `releaseStoreLock` refuses to remove it and the live, + * unowned lock sits there blocking every writer — including us — for ten + * minutes. `acquireStoreLock` now fails the acquisition (and removes the lock + * it just created) instead of leaving one behind that nobody can release. + * + * The write goes through the choke point (store-io.mjs), matching the contained + * read in `lockOwnership`: the lock directory is freshly mkdir'd, but the owner + * file inside it is still a path another process could reach. + */ function writeLockOwner(lockPath, token) { try { - // Contained write (rule 1), matching the contained read in `lockOwnership`: - // the lock directory is freshly mkdir'd, but the owner file inside it is - // still a path another process could reach, so neither half touches it with - // a bare `fs` call. - writeFileContained(lockPath, LOCK_OWNER_FILE, JSON.stringify({ token, pid: process.pid, at: new Date().toISOString() }) + '\n'); + return writeStoreFile( + path.join(lockPath, LOCK_OWNER_FILE), + JSON.stringify({ token, pid: process.pid, at: new Date().toISOString() }) + '\n' + ); } catch { - // ignored — see the doc comment above: an unwritable owner stamp reads back - // as `foreign`, i.e. NOT ours, which fails closed. + return false; } } @@ -757,7 +877,7 @@ function writeLockOwner(lockPath, token) { */ export function lockOwnership(lockPath, token) { if (!fs.existsSync(lockPath)) return 'absent'; - const text = readFileNoFollow(path.join(lockPath, LOCK_OWNER_FILE), { root: lockPath }); + const text = readStoreFile(path.join(lockPath, LOCK_OWNER_FILE)); if (text === null) return 'foreign'; try { const parsed = JSON.parse(text); @@ -808,53 +928,138 @@ export function releaseStoreLock(lockPath, token) { } } -export function acquireStoreLock(lockPath) { - const token = newLockToken(); +/** + * The IDENTITY of the lock currently sitting at `lockPath`, observed BEFORE any + * takeover attempt, or null when there is nothing there / it is not stale yet. + * `{ ageMs, key }` — `key` is the raw owner stamp when it can be read, and a + * dev+ino+mtime fingerprint when it cannot, so two processes looking at the + * SAME stale lock always derive the SAME key while a lock replaced in between + * derives a different one. + * + * Exported with `takeOverStaleLock` so the compare-and-swap can be exercised + * with both observations taken before either takeover runs — the exact + * interleaving that used to let two writers both believe they held the lock. + */ +export function observeStaleLock(lockPath) { + let stat; try { - fs.mkdirSync(lockPath); - writeLockOwner(lockPath, token); - return { acquired: true, staleLockNote: null, token }; + stat = fs.statSync(lockPath); } catch { - // fall through to the stale-takeover attempt below + return null; } - let stat; + const ageMs = Date.now() - stat.mtimeMs; + if (ageMs <= STALE_LOCK_MS) return { ageMs, key: null, stale: false }; + const owner = readStoreFile(path.join(lockPath, LOCK_OWNER_FILE)); + const key = owner === null ? `ino:${stat.dev}:${stat.ino}:${stat.mtimeMs}` : `owner:${owner}`; + return { ageMs, key, stale: true }; +} + +/** + * COMPARE-AND-SWAP TAKEOVER (P2). The old dance — stat, rename to a + * pid/time-named tombstone, mkdir, stamp, verify — was narrowed by its final + * ownership check but never atomic: A renames, mkdirs, stamps and verifies + * owned; B's rename then succeeds against A's FRESH lock, and B mkdirs, stamps + * and verifies owned too. Both return acquired. + * + * The tombstone name is now DERIVED FROM THE OBSERVED IDENTITY, which makes the + * rename itself the compare-and-swap: the first writer to move identity `I` + * aside leaves a non-empty directory at that exact name, so the second writer's + * rename fails (POSIX rename refuses to replace a non-empty directory). And + * because a winner removes its tombstone at the end, the swap is re-verified + * after the fact: whatever we just moved must still carry the identity we + * observed BEFORE the rename, or we moved somebody's LIVE lock — in which case + * we put it straight back and refuse. + */ +export function takeOverStaleLock(lockPath, observed, token) { + if (!observed || !observed.stale || !observed.key) return { acquired: false, ageMs: observed?.ageMs || 0, lockPath, token: null }; + const fingerprint = crypto.createHash('sha256').update(observed.key).digest('hex').slice(0, 16); + const tombstone = `${lockPath}.stale-${fingerprint}`; try { - stat = fs.statSync(lockPath); + fs.renameSync(lockPath, tombstone); } catch { - stat = null; + // Another process already moved this exact identity aside (its tombstone + // stands in the way), or the lock vanished — either way we did not win. + return { acquired: false, ageMs: observed.ageMs, lockPath, token: null }; } - const ageMs = stat ? Date.now() - stat.mtimeMs : 0; - if (stat && ageMs > STALE_LOCK_MS) { - const tombstone = `${lockPath}.stale-${process.pid}-${Date.now()}`; - let claimed = false; + // Post-swap verify: is what we moved the object we observed? The tombstone is + // a TRANSIENT rename target this call just created, not a store-owned path, + // so it has no allow-listed shape in store-io.mjs — it is read through the + // same fs-safe primitive, contained against the STORE root (the tombstone's + // parent), which is the identical guarantee `readStoreFile` gives the live + // `.lock/owner.json` above. + const movedOwner = readFileNoFollow(path.join(tombstone, LOCK_OWNER_FILE), { root: path.dirname(tombstone) }); + const movedKey = movedOwner === null ? null : `owner:${movedOwner}`; + if (observed.key.startsWith('owner:') ? movedKey !== observed.key : movedKey !== null) { + // We moved a DIFFERENT lock — a fresh one a winner planted after we + // observed. Put it back; it is not ours to hold or to destroy. try { - fs.renameSync(lockPath, tombstone); - claimed = true; + fs.renameSync(tombstone, lockPath); } catch { - claimed = false; // another process already won the takeover race + // The winner already re-created its lock, so the rename-back is refused; + // the tombstone is gitignored debris, and their live lock stands. } - if (claimed) { - let recovered = false; - let staleLockNote = null; - try { - fs.mkdirSync(lockPath); - writeLockOwner(lockPath, token); - staleLockNote = `stale lock (${Math.round(ageMs / 60000)}m old) removed`; - recovered = true; - } catch { - recovered = false; - } - try { - fs.rmSync(tombstone, { recursive: true, force: true }); - } catch { - // ignored — an orphaned tombstone is harmless disk debris either way - } - // Only a lock the owner stamp confirms as OURS counts as acquired: the - // mkdir above can win while a racing takeover re-stamps it a moment later. - if (recovered && lockOwnership(lockPath, token) === 'owned') return { acquired: true, staleLockNote, token }; + return { acquired: false, ageMs: observed.ageMs, lockPath, token: null }; + } + // `createdDir` is tracked SEPARATELY from `recovered`: the cleanup below may + // only ever remove a lock directory THIS call created. Folding the two + // together would mean a failed exclusive mkdir — which happens precisely + // because somebody ELSE now holds the lock — took their lock away. + let createdDir = false; + try { + fs.mkdirSync(lockPath); + createdDir = true; + } catch { + createdDir = false; + } + const recovered = createdDir && writeLockOwner(lockPath, token); + if (createdDir && !recovered) { + // Never leave a live lock nobody can prove they own (P3). Ours to remove: + // the exclusive mkdir above is what created it. + try { + fs.rmSync(lockPath, { recursive: true, force: true }); + } catch { + // best effort } } - return { acquired: false, ageMs, lockPath, token: null }; + try { + fs.rmSync(tombstone, { recursive: true, force: true }); + } catch { + // ignored — an orphaned tombstone is harmless, gitignored disk debris + } + if (recovered && lockOwnership(lockPath, token) === 'owned') { + return { acquired: true, staleLockNote: `stale lock (${Math.round(observed.ageMs / 60000)}m old) removed`, token }; + } + return { acquired: false, ageMs: observed.ageMs, lockPath, token: null }; +} + +export function acquireStoreLock(lockPath) { + const token = newLockToken(); + let created = false; + try { + fs.mkdirSync(lockPath); + created = true; + } catch { + created = false; // occupied — fall through to the stale-takeover attempt + } + if (created) { + // AN UNSTAMPABLE LOCK IS NOT ACQUIRED (P3). Leaving one live wedges the + // store for STALE_LOCK_MS: nobody — including us — can prove ownership, so + // `releaseStoreLock` refuses to remove it. We created this directory, so + // removing it again is unambiguously ours to do. + if (writeLockOwner(lockPath, token) && lockOwnership(lockPath, token) === 'owned') { + return { acquired: true, staleLockNote: null, token }; + } + try { + fs.rmSync(lockPath, { recursive: true, force: true }); + } catch { + // best effort — a lock we could not remove is taken over as stale later + } + return { acquired: false, ageMs: 0, lockPath, token: null }; + } + const observed = observeStaleLock(lockPath); + if (!observed) return { acquired: false, ageMs: 0, lockPath, token: null }; + if (!observed.stale) return { acquired: false, ageMs: observed.ageMs, lockPath, token: null }; + return takeOverStaleLock(lockPath, observed, token); } /** @@ -1050,14 +1255,14 @@ const TXN_JOURNAL_REL = path.join('.git', 'harness-txn.json'); * would escape with the lock still held; false simply refuses the run. */ function writeTxnJournal(dir, data) { try { - return Boolean(writeFileContained(dir, TXN_JOURNAL_REL, JSON.stringify(data) + '\n')); + return writeStoreFile(path.join(dir, TXN_JOURNAL_REL), JSON.stringify(data) + '\n'); } catch { return false; } } function readTxnJournal(dir) { - const text = readFileNoFollow(path.join(dir, TXN_JOURNAL_REL), { root: dir }); + const text = readStoreFile(path.join(dir, TXN_JOURNAL_REL)); if (text === null) return null; // absent, symlinked, or outside the store try { const parsed = JSON.parse(text); @@ -1109,9 +1314,29 @@ function discardResiduePath(dir, rel, targetSha) { } } -function clearTxnJournal(dir) { +/** + * OWNER-CHECKED JOURNAL CLEARING (P2) — the journal-side twin of + * `releaseStoreLock`. The journal is ONE SHARED FILE, and clearing it used to + * be unconditional: a writer whose recovery rollback LOST the lock still ran + * `rmSync` on its way out, deleting the journal the WINNING writer had just + * written. That winner then ran unmarked — precisely the state the fail-closed + * journal-write check exists to prevent, reached from the other direction. + * + * A journal now carries the same `owner` token its writer's lock does, and this + * removes only a journal stamped with `token` (or an unstamped legacy one). + * `force: true` is the ONE legitimate foreign clear: crash recovery, running + * under a freshly acquired lock, is consuming a DEAD writer's journal, which by + * definition names somebody else. + */ +function clearTxnJournal(dir, { token = null, force = false } = {}) { + if (!force) { + const journal = readTxnJournal(dir); + // A journal whose owner is someone else's is not ours to remove. An + // unreadable/absent one (null) falls through: nothing to protect. + if (journal && typeof journal.owner === 'string' && journal.owner !== token) return; + } try { - fs.rmSync(path.join(dir, TXN_JOURNAL_REL), { force: true }); + removeStoreFile(path.join(dir, TXN_JOURNAL_REL)); } catch { // ignored — a stranded journal only ever costs one extra recovery pass } @@ -1131,17 +1356,20 @@ function clearTxnJournal(dir) { */ function recoverInterruptedTransaction(dir, git, lockPath, token) { const journal = readTxnJournal(dir); - if (!journal) return { note: null, lockLost: false }; - clearTxnJournal(dir); - if (!git) return { note: null, lockLost: false }; + if (!journal) return { note: null, lockLost: false, failed: false }; + // The one legitimate FOREIGN clear: this journal belongs to a writer that + // died, and we hold the lock now. + clearTxnJournal(dir, { force: true }); + if (!git) return { note: null, lockLost: false, failed: false }; const before = journalDirtySet(journal); - if (before === null) return { note: null, lockLost: false }; // the journal cannot say — hands off + if (before === null) return { note: null, lockLost: false, failed: false }; // the journal cannot say — hands off const now = dirtyPaths(dir); - if (now === null) return { note: null, lockLost: false }; // unreadable status — fail closed, touch nothing + if (now === null) return { note: null, lockLost: false, failed: false }; // unreadable status — fail closed, touch nothing const residue = now.filter((p) => !before.has(p)); - if (!residue.length) return { note: null, lockLost: false }; + if (!residue.length) return { note: null, lockLost: false, failed: false }; const checkpoint = typeof journal.checkpoint === 'string' && /^[0-9a-f]{40,64}$/.test(journal.checkpoint) ? journal.checkpoint : null; let lockLost = false; + let failed = false; if (before.size === 0) { // Nothing was dirty at the start, so EVERY uncommitted byte is residue — // the store's own whole-tree rollback is both the cheapest and the most @@ -1152,7 +1380,13 @@ function recoverInterruptedTransaction(dir, git, lockPath, token) { // residue in place. rollbackStore now REPORTS that (S4), so the fallback to // the plain "discard everything uncommitted" reset keys off the honest // result rather than re-reading the tree by hand. - if (!rollbackStore(dir, checkpoint).ok) rollbackStore(dir); + // + // BOTH RESULTS ARE CHECKED (P3). The fallback's result used to be thrown + // away — `if (!rollbackStore(dir, checkpoint).ok) rollbackStore(dir);` — so + // a recovery that could not discard the residue reported success anyway and + // the next transaction inherited the dead writer's uncommitted bytes as a + // hand edit, which is the exact laundering the journal exists to stop. + if (!rollbackStore(dir, checkpoint).ok && !rollbackStore(dir).ok) failed = true; // `.lock` is gitignored (S2) so `git clean -fd` can no longer sweep it, but // re-assert through the OWNER-CHECKED path anyway: if this lock somehow // went away and another writer took it, we must abort, never mkdir over it. @@ -1162,7 +1396,7 @@ function recoverInterruptedTransaction(dir, git, lockPath, token) { // time, so the pre-existing dirt survives untouched. for (const rel of residue) discardResiduePath(dir, rel, checkpoint); } - return { note: 'discarded interrupted write residue', lockLost }; + return { note: failed ? null : 'discarded interrupted write residue', lockLost, failed }; } /** @@ -1225,7 +1459,12 @@ export class StoreTransactionAbort extends Error { * against the intent journal and writes a fresh one (see * recoverInterruptedTransaction above — a dead writer's uncommitted residue is * discarded rather than absorbed as human authority by the next transaction), - * all BEFORE calling `fn({ dir, git, recordCheckpoint, rollbackToCheckpoint })`. + * all BEFORE calling + * `fn({ dir, git, recordCheckpoint, rollbackToCheckpoint, rollbackUncommitted })`. + * `rollbackUncommitted` is the same guarded rollback narrowed to "discard what + * is uncommitted" rather than "reset to the checkpoint" — the only shape an + * `fn` that made its own failed sub-commit attempt needs, and the reason no + * `fn` has any business calling `rollbackStore` itself. * `rollbackToCheckpoint` is the same rollback the failure paths below use, * exposed so an `fn` that REJECTS after already mutating (apply.mjs's write-time * `E_HEAD_MOVED`, which can only be reached once a branch bucket has been @@ -1288,6 +1527,12 @@ export function withStoreTransaction(workspace, { home, label, afterCommit } = { return { ok: false, locked: true, rolledBack: false, error: null, committed: false, result: null, dir, git, staleLockNote: null }; } const token = lock.token; + // UNDER THE LOCK (P3). `.gitignore` is what keeps a rollback's `git clean -fd` + // from sweeping the lock, and writing it is a store mutation — it used to run + // inside `ensureStore`, i.e. BEFORE `acquireStoreLock`, which is the one place + // a store mutation must never happen. It runs here, before recovery, because + // recovery's own rollback is the first thing that depends on it. + ensureStoreGitignore(dir); // Crash recovery BEFORE anything reads the tree (see // recoverInterruptedTransaction): a dead writer's uncommitted residue is // discarded here rather than inherited by the absorb step below as human @@ -1295,16 +1540,23 @@ export function withStoreTransaction(workspace, { home, label, afterCommit } = { // already surface as `staleLockRemoved`. const recovery = recoverInterruptedTransaction(dir, git, lockPath, token); const staleLockNote = [lock.staleLockNote, recovery.note].filter(Boolean).join('; ') || null; - if (recovery.lockLost) { - // Somebody else's lock is sitting where ours was. Nothing has been mutated - // by this transaction, and their lock is NOT ours to remove — refuse loudly - // and leave the store exactly as it is. - clearTxnJournal(dir); + if (recovery.lockLost || recovery.failed) { + // Either somebody else's lock is sitting where ours was, or the dead + // writer's residue is still in the tree. Nothing has been mutated by THIS + // transaction, and a foreign lock is not ours to remove — refuse loudly and + // leave the store exactly as it is. The journal clear is owner-checked (P2): + // if another writer took the lock, the journal on disk is THEIRS. + clearTxnJournal(dir, { token }); + if (!recovery.lockLost) releaseStoreLock(lockPath, token); return { ok: false, - locked: true, + locked: recovery.lockLost, rolledBack: false, - error: new Error('store lock was taken over by another writer during crash recovery — refusing to run'), + error: new Error( + recovery.lockLost + ? 'store lock was taken over by another writer during crash recovery — refusing to run' + : 'could not discard the interrupted write residue left by a previous transaction — refusing to run on a store this CLI cannot clean' + ), committed: false, result: null, dir, @@ -1320,7 +1572,9 @@ export function withStoreTransaction(workspace, { home, label, afterCommit } = { // catches up — it can never make checkpointSha wrong the way a // hand-maintained value could. let checkpointSha = git ? currentHeadSha(dir) : null; - const journalBase = { pid: process.pid, at: new Date().toISOString(), label: label || null }; + // `owner` stamps the journal with the SAME token the lock carries, so + // `clearTxnJournal` can refuse to delete a journal another writer owns (P2). + const journalBase = { pid: process.pid, at: new Date().toISOString(), label: label || null, owner: token }; // FAIL CLOSED ON A JOURNAL WRITE FAILURE (P1). The journal is the ONLY thing // that tells this transaction's crash residue apart from a human hand edit, // so a best-effort write that silently failed left the transaction running @@ -1332,7 +1586,7 @@ export function withStoreTransaction(workspace, { home, label, afterCommit } = { if (git) { const dirty = dirtyPaths(dir); if (dirty === null || !writeTxnJournal(dir, { ...journalBase, checkpoint: checkpointSha, dirty })) { - clearTxnJournal(dir); + clearTxnJournal(dir, { token }); releaseStoreLock(lockPath, token); return { ok: false, @@ -1390,9 +1644,18 @@ export function withStoreTransaction(workspace, { home, label, afterCommit } = { * `fn` bodies use it via `rollbackToCheckpoint`, and the terminal paths below * carry it into `rolledBack`. */ - function guardedRollback() { + /** + * `toHead: true` discards only what is UNCOMMITTED (a plain + * `git reset --hard` + clean) instead of resetting to the checkpoint — the + * shape apply.mjs's `recordContentFailure` needs to undo its own failed + * strike sub-commit attempt without unwinding to the checkpoint. It used to + * call `rollbackStore` directly, which set no `rollbackFailed` latch and + * re-asserted no ownership: its `git clean -fd` was the last rollback in the + * codebase that could free the lock with nobody checking (R4). + */ + function guardedRollback({ toHead = false } = {}) { if (!git) return false; - const res = rollbackStore(dir, checkpointSha); + const res = toHead ? rollbackStore(dir) : rollbackStore(dir, checkpointSha); // `.lock` is gitignored (S2), so `git clean -fd` no longer sweeps it — but // verify ownership rather than assume it: a rollback taken MID-`fn` // (rollbackToCheckpoint) must never let this transaction keep writing after @@ -1412,7 +1675,13 @@ export function withStoreTransaction(workspace, { home, label, afterCommit } = { try { let result; try { - result = fn({ dir, git, recordCheckpoint, rollbackToCheckpoint: guardedRollback }); + result = fn({ + dir, + git, + recordCheckpoint, + rollbackToCheckpoint: () => guardedRollback(), + rollbackUncommitted: () => guardedRollback({ toHead: true }), + }); } catch (err) { const isAbort = err instanceof StoreTransactionAbort; const rolledBack = isAbort ? false : guardedRollback(); @@ -1478,8 +1747,10 @@ export function withStoreTransaction(workspace, { home, label, afterCommit } = { return { ok: true, locked: false, rolledBack: false, error: null, committed: commitRes.committed, result, dir, git, staleLockNote }; } finally { // Cleared only once the commit or rollback above has finished: while it - // exists, a crash at any point leaves the residue classifiable. - clearTxnJournal(dir); + // exists, a crash at any point leaves the residue classifiable. OWNER- + // CHECKED (P2): reached with `lockLost === true` this would otherwise + // delete the WINNING writer's journal and leave them running unmarked. + clearTxnJournal(dir, { token }); // OWNER-CHECKED RELEASE (S2), on every exit path including this one. The // old unconditional `rmSync(lockPath)` was the release half of the lock-loss // class: if anything had taken the lock in the meantime, this deleted a LIVE @@ -1509,7 +1780,7 @@ export function normalizeSlug(text) { */ export function readStaleExclusions(dir) { try { - const parsed = JSON.parse(fs.readFileSync(path.join(dir, 'stale.json'), 'utf8')); + const parsed = JSON.parse(readStoreFile(path.join(dir, 'stale.json'))); if (parsed && parsed.excluded && typeof parsed.excluded === 'object') { return { excluded: parsed.excluded }; } @@ -1520,5 +1791,5 @@ export function readStaleExclusions(dir) { } export function writeStaleExclusions(dir, data) { - fs.writeFileSync(path.join(dir, 'stale.json'), JSON.stringify(data) + '\n', 'utf8'); + return writeStoreFile(path.join(dir, 'stale.json'), JSON.stringify(data) + '\n'); } diff --git a/packages/harness/test/consolidate-apply.test.mjs b/packages/harness/test/consolidate-apply.test.mjs index eb317668..715d28dd 100644 --- a/packages/harness/test/consolidate-apply.test.mjs +++ b/packages/harness/test/consolidate-apply.test.mjs @@ -1060,7 +1060,7 @@ test('an ADD asserting kind: fix for a real file whose own frontmatter says kind test('updateFrontmatterField inserts a missing field on a CRLF-terminated learning file instead of silently no-opping', () => { // The fixture lives at a REAL learning path shape (`/learnings// - // .md`): updateFrontmatterField reads and writes through the learning-io + // .md`): updateFrontmatterField reads and writes through the store-io // choke point, which derives its containment root from exactly that shape and // refuses anything else outright. const file = path.join(tempDir('apply-crlf-'), 'learnings', 'sql', 'crlf-learning.md'); diff --git a/packages/harness/test/hand-edits.test.mjs b/packages/harness/test/hand-edits.test.mjs index e9328970..ea58d96a 100644 --- a/packages/harness/test/hand-edits.test.mjs +++ b/packages/harness/test/hand-edits.test.mjs @@ -10,7 +10,7 @@ import { applyOps } from '../lib/knowledge/apply.mjs'; import { absorbHandEdits, absorbOrAbort, removeEpisodeLink } from '../lib/knowledge/admin.mjs'; import { setLearningStatus } from '../lib/knowledge/lifecycle.mjs'; import { ensureBucket } from '../lib/knowledge/layer.mjs'; -import { QUARANTINE_DIR } from '../lib/knowledge/learning-io.mjs'; +import { QUARANTINE_DIR } from '../lib/knowledge/store-io.mjs'; import { ensureStore, storeDir, listLearnings, readLedger, parseLearningFrontmatter, serializeLearning, StoreTransactionAbort } from '../lib/knowledge/store.mjs'; const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); diff --git a/packages/harness/test/knowledge-boundary-hardening.test.mjs b/packages/harness/test/knowledge-boundary-hardening.test.mjs index 399693d5..7f3b51e0 100644 --- a/packages/harness/test/knowledge-boundary-hardening.test.mjs +++ b/packages/harness/test/knowledge-boundary-hardening.test.mjs @@ -574,11 +574,11 @@ test('K: every destructive knowledge path routes through an fs-safe realpath gua ['lib/knowledge/prune.mjs', /assertRealpathContained\(txDir, path\.join\('branches'/], ['lib/knowledge/layer.mjs', /assertRealpathContained\(dir, path\.join\('branches'/], // Learning-file I/O no longer guards itself per call site: every read, - // write, and delete of a learning goes through learning-io.mjs, which owns + // write, and delete of a learning goes through store-io.mjs, which owns // the guard once (S1). The contract is therefore that the choke point holds // it AND that apply.mjs's promotion tombstone goes through the choke point // rather than touching `fs` directly. - ['lib/knowledge/learning-io.mjs', /assertRealpathContained\(p\.storeRoot, p\.rel\)/], + ['lib/knowledge/store-io.mjs', /assertRealpathContained\(p\.storeRoot, p\.rel\)/], ['lib/knowledge/apply.mjs', /writeLearningFile\(src\.file, serializeLearning\(/], ]; for (const [rel, pattern] of guarded) { diff --git a/packages/harness/test/knowledge-store-io-hardening.test.mjs b/packages/harness/test/knowledge-store-io-hardening.test.mjs new file mode 100644 index 00000000..aff76730 --- /dev/null +++ b/packages/harness/test/knowledge-store-io-hardening.test.mjs @@ -0,0 +1,549 @@ +// Structural regressions for the SECOND half of the store-I/O choke point. +// +// Round 5 built one guarded choke point for LEARNING files and explicitly +// scoped store METADATA out as "a separate class". It is not a separate class: +// every metadata file sits in the same human-writable directory a learning +// does, so every one of them is as symlink-plantable as a learning path. +// +// R1 a store-owned file written/read/removed outside the choke point +// R2 a quarantine unreachable for a TRACKED file replaced by a symlink +// (git reports ` T`, which contains no `M` and is not `??`) +// R3 a losing writer deleting the winning writer's transaction journal +// R4 a rollback that frees the lock with no ownership re-check +// R5 a stale-lock takeover that is narrowed but not atomic +// R6 an unowned live lock, a pre-lock store mutation, a clobbered +// `.gitignore`, an unchecked recovery rollback, and porcelain rename +// field order pinned only by a hand-built string +// +// Every test is written against the ATTACKER'S move or the failure mode, never +// against the shape of the fix. + +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; + +import { applyOps, rebuildIndex } from '../lib/knowledge/apply.mjs'; +import { absorbHandEdits } from '../lib/knowledge/admin.mjs'; +import { setLearningStatus } from '../lib/knowledge/lifecycle.mjs'; +import { ensureBucket } from '../lib/knowledge/layer.mjs'; +import { + ensureStore, + storeDir, + listLearnings, + parsePorcelainZ, + withStoreTransaction, + writeStoreConfig, + readStoreConfig, + readLedger, + readGovernance, + writeStaleExclusions, + readStaleExclusions, + acquireStoreLock, + observeStaleLock, + takeOverStaleLock, + lockOwnership, +} from '../lib/knowledge/store.mjs'; +import { QUARANTINE_DIR } from '../lib/knowledge/store-io.mjs'; + +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const tempDir = (p) => fs.mkdtempSync(path.join(os.tmpdir(), p)); + +const ctx = () => ({ ws: tempDir('sio-ws-'), home: tempDir('sio-home-'), harnessHome: tempDir('sio-hh-') }); + +function git(cwd, args) { + return spawnSync('git', args, { + cwd, + encoding: 'utf8', + env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' }, + }); +} + +function writeOps(dir, ops) { + const p = path.join(dir, 'ops.json'); + fs.writeFileSync(p, JSON.stringify({ schema: 1, ops })); + return p; +} + +function EP(ws, rel) { + const full = path.join(ws, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + const content = `fix evidence body for ${rel}.\n`; + fs.writeFileSync(full, content, 'utf8'); + return { path: rel, sha256: crypto.createHash('sha256').update(content).digest('hex'), kind: 'fix', plan: 'docs/plans/p1.md' }; +} + +function seedLearning(c, slug = 'seeded-claim') { + const res = applyOps({ + workspace: c.ws, + opsPath: writeOps(c.ws, [ + { + op: 'ADD', + domain: 'sql', + slug, + trigger: `trigger for ${slug}`, + body: `Claim body for ${slug}.`, + episodes: [EP(c.ws, `docs/solutions/perf/${slug}.md`)], + }, + ]), + home: c.harnessHome, + }); + assert.equal(res.exitCode, 0, JSON.stringify(res.rejected)); + return `sql/${slug}`; +} + +/** A file OUTSIDE the store that a planted symlink points at. */ +function outsideFile(name = 'zshrc') { + const dir = tempDir('sio-outside-'); + const full = path.join(dir, name); + const content = `# precious outside content for ${name}\nexport TOKEN=keepme\n`; + fs.writeFileSync(full, content, 'utf8'); + return { full, content }; +} + +function plantSymlink(target, at) { + fs.rmSync(at, { force: true }); + fs.symlinkSync(target, at); +} + +function quarantined(dir) { + const q = path.join(dir, QUARANTINE_DIR); + return fs.existsSync(q) ? fs.readdirSync(q) : []; +} + +// --------------------------------------------------------------------------- +// R1 — every store-owned file goes through the choke point +// --------------------------------------------------------------------------- + +// The verified exploit, verbatim: `ln -sf ~/.zshrc /INDEX.md`, then any +// `harness learning retire ` — rebuildIndex runs on retire/apply/confirm/ +// dispute/promote/absorb/purge/rebuild — truncates and replaces the outside +// file. `ensureStore`'s fs.existsSync followed the link, so it never noticed. +test('R1: a symlinked INDEX.md cannot be written through — the outside target survives a retire', () => { + const c = ctx(); + const id = seedLearning(c, 'index-victim'); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + const victim = outsideFile('zshrc'); + + plantSymlink(victim.full, path.join(dir, 'INDEX.md')); + + const res = setLearningStatus({ workspace: c.ws, id, action: 'retire', reason: 'cleanup', home: c.harnessHome }); + assert.equal(res.pass, true, res.blockedReason || ''); + + assert.equal(fs.readFileSync(victim.full, 'utf8'), victim.content, 'the outside file must be byte-identical'); + assert.equal(fs.lstatSync(path.join(dir, 'INDEX.md')).isSymbolicLink(), false, 'the planted link must not still stand at INDEX.md'); + assert.ok(quarantined(dir).some((f) => f.includes('INDEX.md')), 'the planted link is quarantined, not left live'); +}); + +test('R1: a symlinked consolidated.jsonl cannot be appended through', () => { + const c = ctx(); + seedLearning(c, 'ledger-anchor'); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + const victim = outsideFile('bashrc'); + + plantSymlink(victim.full, path.join(dir, 'consolidated.jsonl')); + + const res = applyOps({ + workspace: c.ws, + opsPath: writeOps(c.ws, [ + { + op: 'ADD', + domain: 'sql', + slug: 'ledger-victim', + trigger: 'trigger for ledger-victim', + body: 'Claim body for ledger-victim.', + episodes: [EP(c.ws, 'docs/solutions/perf/ledger-victim.md')], + }, + ]), + home: c.harnessHome, + }); + assert.equal(res.exitCode, 0, JSON.stringify(res.rejected)); + + assert.equal(fs.readFileSync(victim.full, 'utf8'), victim.content, 'the outside file must be byte-identical'); + assert.equal(fs.lstatSync(path.join(dir, 'consolidated.jsonl')).isSymbolicLink(), false); + assert.ok(readLedger(dir).some((e) => e.learning === 'sql/ledger-victim'), 'the ledger entry still landed in the real store file'); +}); + +test('R1: a symlinked governance.jsonl cannot be appended through', () => { + const c = ctx(); + const id = seedLearning(c, 'gov-victim'); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + const victim = outsideFile('profile'); + + plantSymlink(victim.full, path.join(dir, 'governance.jsonl')); + + const res = setLearningStatus({ workspace: c.ws, id, action: 'dispute', reason: 'wrong', home: c.harnessHome }); + assert.equal(res.pass, true, res.blockedReason || ''); + + assert.equal(fs.readFileSync(victim.full, 'utf8'), victim.content, 'the outside file must be byte-identical'); + assert.equal(fs.lstatSync(path.join(dir, 'governance.jsonl')).isSymbolicLink(), false); + assert.equal(readGovernance(dir).get(id)?.action, 'dispute', 'the decision still landed in the real store file'); +}); + +test('R1: a symlinked config.json cannot be written through', () => { + const c = ctx(); + seedLearning(c, 'config-anchor'); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + const victim = outsideFile('gitconfig'); + + plantSymlink(victim.full, path.join(dir, 'config.json')); + + const res = writeStoreConfig(c.ws, { home: c.harnessHome, mode: 'freeze' }); + assert.equal(res.pass, true, res.blockedReason || ''); + + assert.equal(fs.readFileSync(victim.full, 'utf8'), victim.content, 'the outside file must be byte-identical'); + assert.equal(fs.lstatSync(path.join(dir, 'config.json')).isSymbolicLink(), false); + assert.equal(readStoreConfig(c.ws, { home: c.harnessHome }).mode, 'freeze'); +}); + +test('R1: a symlinked stale.json cannot be written through', () => { + const c = ctx(); + seedLearning(c, 'stale-anchor'); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + const victim = outsideFile('netrc'); + + plantSymlink(victim.full, path.join(dir, 'stale.json')); + writeStaleExclusions(dir, { excluded: { 'sql/stale-anchor': ['a.ts'] } }); + + assert.equal(fs.readFileSync(victim.full, 'utf8'), victim.content, 'the outside file must be byte-identical'); + assert.equal(fs.lstatSync(path.join(dir, 'stale.json')).isSymbolicLink(), false); + assert.deepEqual(readStaleExclusions(dir).excluded['sql/stale-anchor'], ['a.ts']); +}); + +test('R1: a symlinked bucket meta.json / INDEX.md cannot be written through', () => { + const c = ctx(); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + const metaVictim = outsideFile('meta-target'); + const indexVictim = outsideFile('index-target'); + + const bucketDir = path.join(dir, 'branches', 'feature-x'); + fs.mkdirSync(bucketDir, { recursive: true }); + plantSymlink(metaVictim.full, path.join(bucketDir, 'meta.json')); + plantSymlink(indexVictim.full, path.join(bucketDir, 'INDEX.md')); + + ensureBucket(dir, { key: 'feature-x', branch: 'feature/x', baseSha: null }); + rebuildIndex(bucketDir); + + assert.equal(fs.readFileSync(metaVictim.full, 'utf8'), metaVictim.content, 'meta.json target must be byte-identical'); + assert.equal(fs.readFileSync(indexVictim.full, 'utf8'), indexVictim.content, 'INDEX.md target must be byte-identical'); + assert.equal(fs.lstatSync(path.join(bucketDir, 'meta.json')).isSymbolicLink(), false); + assert.equal(fs.lstatSync(path.join(bucketDir, 'INDEX.md')).isSymbolicLink(), false); + assert.equal(JSON.parse(fs.readFileSync(path.join(bucketDir, 'meta.json'), 'utf8')).branchKey, 'feature-x'); +}); + +// The class-completeness contract: no store-owned FILE NAME may appear as the +// argument of a bare `fs` read/write/append/exists/remove anywhere in +// lib/knowledge/. Rule 3 (allow-lists, not deny-lists) applied to the source +// itself — a new metadata writer that skips the choke point fails here. +test('R1: no bare fs call in lib/knowledge names a store-owned file', () => { + const storeOwned = [ + 'INDEX.md', + 'consolidated.jsonl', + 'governance.jsonl', + 'config.json', + 'store.json', + 'stale.json', + 'meta.json', + '.gitignore', + 'harness-txn.json', + 'owner.json', + ]; + // Comments stripped first (they discuss these filenames constantly), then the + // call's FULL argument list is read by balancing parentheses — a bare call + // split across lines is exactly the shape a line-by-line scan would miss. + const stripComments = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); + const argsOf = (src, openIdx) => { + let depth = 0; + for (let i = openIdx; i < src.length; i += 1) { + if (src[i] === '(') depth += 1; + else if (src[i] === ')') { + depth -= 1; + if (depth === 0) return src.slice(openIdx + 1, i); + } + } + return src.slice(openIdx + 1, openIdx + 400); + }; + const bare = /fs\.(readFileSync|writeFileSync|appendFileSync|statSync|existsSync|rmSync)\(/g; + const offenders = []; + const knowledgeDir = path.join(packageRoot, 'lib', 'knowledge'); + for (const f of fs.readdirSync(knowledgeDir).filter((n) => n.endsWith('.mjs'))) { + const src = stripComments(fs.readFileSync(path.join(knowledgeDir, f), 'utf8')); + bare.lastIndex = 0; + let m; + while ((m = bare.exec(src)) !== null) { + const args = argsOf(src, m.index + m[0].length - 1); + if (storeOwned.some((name) => args.includes(`'${name}'`))) offenders.push(`${f}: fs.${m[1]}(${args.replace(/\s+/g, ' ').slice(0, 90)})`); + } + } + assert.deepEqual(offenders, [], 'every store-owned file must go through store-io.mjs'); +}); + +// --------------------------------------------------------------------------- +// R2 — the quarantine must be reachable for the likeliest plant +// --------------------------------------------------------------------------- + +// A TRACKED learning replaced by a symlink is a git TYPECHANGE: `git status` +// emits ` T`, which is neither `??` nor contains `M`, so the pre-filter +// `continue`d before the symlink branch ever ran. The link was never +// quarantined, never logged, and `git add -A` committed it into store history +// while listLearnings silently dropped the learning. REAL git state, not a +// hand-built status string. +test('R2: a tracked golden learning replaced by a symlink (real ` T` typechange) is quarantined, never committed', () => { + const c = ctx(); + const id = seedLearning(c, 'typechange-victim'); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + const victim = outsideFile('ssh-config'); + const learningPath = path.join(dir, 'learnings', 'sql', 'typechange-victim.md'); + + // The file is TRACKED (applyOps committed it) — replace it with a symlink. + fs.rmSync(learningPath); + fs.symlinkSync(victim.full, learningPath); + + const porcelain = git(dir, ['status', '--porcelain', '-uall', '-z']).stdout; + const entry = parsePorcelainZ(porcelain).find((e) => e.path.endsWith('typechange-victim.md')); + assert.ok(entry, 'git must report the replaced learning'); + assert.equal(entry.status.includes('T'), true, `git must report a typechange, got ${JSON.stringify(entry.status)}`); + assert.equal(entry.status.includes('M'), false, 'the pre-filter that this test exists for excluded exactly this code'); + + const notes = []; + absorbHandEdits({ workspace: c.ws, home: c.harnessHome, log: (m) => notes.push(m) }); + + assert.equal(fs.readFileSync(victim.full, 'utf8'), victim.content, 'the outside file must be byte-identical'); + assert.equal(fs.existsSync(learningPath), false, 'the planted link must be gone from learnings/'); + assert.ok(quarantined(dir).some((f) => f.includes('typechange-victim')), 'the link is quarantined'); + assert.ok(notes.some((n) => /symlink/i.test(n)), `the refusal must be logged: ${JSON.stringify(notes)}`); + assert.equal(listLearnings(dir).some((l) => l.id === id), false, 'the symlink is never presented as a learning'); + + const tracked = git(dir, ['ls-files', '-s', 'learnings/sql/typechange-victim.md']).stdout; + assert.equal(/^120000/.test(tracked.trim()), false, `a symlink must never be committed into store history: ${tracked}`); +}); + +test('R2: a tracked BUCKET learning replaced by a symlink is quarantined too', () => { + const c = ctx(); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + const victim = outsideFile('bucket-target'); + + // Hand-build a tracked bucket learning, then commit it through the store's + // own git so the replacement below is a REAL typechange. + const rel = path.join('branches', 'feature-y', 'learnings', 'sql', 'bucket-victim.md'); + const full = path.join(dir, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync( + full, + '---\nschema: 1\ntrigger: "bucket claim"\nstatus: active\nsource: auto\nepisodes:\nanchors: []\nsuperseded_by: null\nlast_confirmed: null\norigin: unknown\n---\n\nBucket claim body.\n', + 'utf8' + ); + git(dir, ['add', '-A']); + git(dir, ['-c', 'user.name=t', '-c', 'user.email=t@example.test', 'commit', '-qm', 'seed bucket']); + + fs.rmSync(full); + fs.symlinkSync(victim.full, full); + + const notes = []; + absorbHandEdits({ workspace: c.ws, home: c.harnessHome, log: (m) => notes.push(m) }); + + assert.equal(fs.readFileSync(victim.full, 'utf8'), victim.content, 'the outside file must be byte-identical'); + assert.equal(fs.existsSync(full), false, 'the planted link must be gone from the bucket'); + assert.ok(quarantined(dir).some((f) => f.includes('bucket-victim')), 'the bucket link is quarantined'); +}); + +// --------------------------------------------------------------------------- +// R3 — never clear a journal you do not own +// --------------------------------------------------------------------------- + +// A's recovery rollback loses the lock; B acquires it and writes ITS journal; +// A finalizes and rmSyncs B's journal — so B runs UNMARKED, exactly the state +// the fail-closed journal check exists to prevent. +test('R3: a transaction never clears a transaction journal another writer owns', () => { + const c = ctx(); + seedLearning(c, 'journal-anchor'); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + const journalPath = path.join(dir, '.git', 'harness-txn.json'); + const foreign = { pid: 999999, at: new Date().toISOString(), label: 'writer B', owner: 'B-token-abcdef', checkpoint: null, dirty: [] }; + + const tx = withStoreTransaction(c.ws, { home: c.harnessHome, label: 'writer A' }, () => { + // Writer B took the store while A was mid-flight and wrote its own journal. + fs.writeFileSync(journalPath, JSON.stringify(foreign) + '\n', 'utf8'); + return { commitMessage: 'writer A finished' }; + }); + assert.equal(tx.ok, true, String(tx.error || '')); + + assert.equal(fs.existsSync(journalPath), true, "A must not delete B's journal"); + assert.equal(JSON.parse(fs.readFileSync(journalPath, 'utf8')).owner, 'B-token-abcdef'); +}); + +// --------------------------------------------------------------------------- +// R4 — no rollback may free the lock without an ownership re-check +// --------------------------------------------------------------------------- + +test('R4: a mid-fn uncommitted rollback that finds a foreign lock aborts the transaction', () => { + const c = ctx(); + seedLearning(c, 'rollback-anchor'); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + const lockPath = path.join(dir, '.lock'); + + let rolledBack = null; + const tx = withStoreTransaction(c.ws, { home: c.harnessHome, label: 'strike' }, ({ rollbackUncommitted }) => { + // Another writer took the lock while this transaction was mid-flight. + fs.rmSync(lockPath, { recursive: true, force: true }); + fs.mkdirSync(lockPath, { recursive: true }); + fs.writeFileSync(path.join(lockPath, 'owner.json'), JSON.stringify({ token: 'other-writer', pid: 4242 }) + '\n', 'utf8'); + rolledBack = rollbackUncommitted(); + return { commitMessage: 'must never be committed' }; + }); + + assert.equal(rolledBack, false, 'a rollback that lost the lock must report failure'); + assert.equal(tx.ok, false, 'the transaction must refuse to commit after a lost lock'); + assert.match(String(tx.error?.message || ''), /taken over by another writer/i); + assert.equal( + JSON.parse(fs.readFileSync(path.join(lockPath, 'owner.json'), 'utf8')).token, + 'other-writer', + "the other writer's lock must be left strictly alone" + ); +}); + +test('R4: recordContentFailure no longer rolls back outside the transaction guard', () => { + const src = fs.readFileSync(path.join(packageRoot, 'lib', 'knowledge', 'apply.mjs'), 'utf8'); + const code = src + .split('\n') + .filter((l) => !l.trim().startsWith('*') && !l.trim().startsWith('//') && !l.trim().startsWith('/*')) + .join('\n'); + assert.equal(/[^a-zA-Z]rollbackStore\(/.test(code), false, 'apply.mjs must not call rollbackStore directly — it bypasses the latch and the ownership re-check'); + assert.match(code, /rollbackUncommitted\(\)/, 'the strike rollback goes through the transaction-owned guarded rollback'); +}); + +// --------------------------------------------------------------------------- +// R5 — stale-lock takeover must be atomic, not merely narrowed +// --------------------------------------------------------------------------- + +// Two processes both stat the same >10-min lock. A renames it to a tombstone, +// mkdirs, stamps, verifies owned, returns acquired. B's rename then succeeds +// against A's FRESH lock, B mkdirs, stamps, verifies owned — and both believe +// they hold it. Deterministic here: both observations are taken BEFORE either +// takeover runs, which is exactly the interleaving. +test('R5: two writers that both observed the same stale lock cannot both acquire it', () => { + const c = ctx(); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + const lockPath = path.join(dir, '.lock'); + fs.mkdirSync(lockPath, { recursive: true }); + fs.writeFileSync(path.join(lockPath, 'owner.json'), JSON.stringify({ token: 'dead-writer', pid: 1 }) + '\n', 'utf8'); + const old = Date.now() - 40 * 60 * 1000; + fs.utimesSync(lockPath, old / 1000, old / 1000); + + const observedByA = observeStaleLock(lockPath); + const observedByB = observeStaleLock(lockPath); + assert.ok(observedByA && observedByB, 'both writers must see the same stale lock'); + + const a = takeOverStaleLock(lockPath, observedByA, 'token-A'); + const b = takeOverStaleLock(lockPath, observedByB, 'token-B'); + + assert.equal(a.acquired, true, 'the first writer takes over the stale lock'); + assert.equal(b.acquired, false, 'the second writer must NOT also acquire it'); + assert.equal(lockOwnership(lockPath, 'token-A'), 'owned', "the winner's lock must still stand"); +}); + +// --------------------------------------------------------------------------- +// R6 — the smaller verified findings +// --------------------------------------------------------------------------- + +// A failed owner stamp made lockOwnership report our OWN lock `foreign`, so +// releaseStoreLock never removed it and the store wedged for 10 minutes. +test('R6: a lock whose owner stamp cannot be written is never left live', () => { + const c = ctx(); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + const lockPath = path.join(dir, '.lock'); + const realOpen = fs.openSync; + fs.openSync = (p, ...rest) => { + if (typeof p === 'string' && p.includes('.tmp-owner.json')) throw new Error('simulated stamp failure'); + return realOpen(p, ...rest); + }; + let lock; + try { + lock = acquireStoreLock(lockPath); + } finally { + fs.openSync = realOpen; + } + assert.equal(lock.acquired, false, 'an unstampable lock must fail the acquisition'); + assert.equal(fs.existsSync(lockPath), false, 'and must never be left live to wedge the store'); +}); + +test('R6: the store .gitignore is written under the lock, not by ensureStore', () => { + const c = ctx(); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + assert.equal(fs.existsSync(path.join(dir, '.gitignore')), false, 'ensureStore must not mutate the store outside the lock'); + + const tx = withStoreTransaction(c.ws, { home: c.harnessHome, label: 'gitignore' }, () => ({ commitMessage: 'noop' })); + assert.equal(tx.ok, true, String(tx.error || '')); + const gi = fs.readFileSync(path.join(dir, '.gitignore'), 'utf8'); + assert.match(gi, /^\/\.lock\/$/m); + assert.match(gi, new RegExp(`^/${QUARANTINE_DIR}/$`, 'm')); +}); + +test('R6: a present-but-unreadable .gitignore is never clobbered', () => { + const c = ctx(); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + // Over the shared read cap (DEFAULT_MAX_BYTES): readFileNoFollow returns + // null for a reason that is NOT "this is a symlink", so the entries cannot + // be appended — but the file must not be REPLACED either. + const huge = Buffer.alloc(10_000_001, 0x61); + fs.writeFileSync(path.join(dir, '.gitignore'), huge); + + const tx = withStoreTransaction(c.ws, { home: c.harnessHome, label: 'gitignore-clobber' }, () => ({ commitMessage: 'noop' })); + assert.equal(tx.ok, true, String(tx.error || '')); + assert.equal(fs.statSync(path.join(dir, '.gitignore')).size, huge.length, 'an unexplained read failure must never become a rewrite'); +}); + +test('R6: a crash recovery whose rollback cannot clean the tree refuses to run', () => { + const c = ctx(); + seedLearning(c, 'recovery-anchor'); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + + // A dead writer's journal: nothing was dirty at start, so everything dirty + // now is its residue and recovery takes the whole-tree rollback path. + fs.writeFileSync( + path.join(dir, '.git', 'harness-txn.json'), + JSON.stringify({ pid: 999999, at: new Date().toISOString(), label: 'dead', checkpoint: 'f'.repeat(40), dirty: [] }) + '\n', + 'utf8' + ); + // Residue git cannot sweep: an untracked file inside a directory the process + // may not write to. `git reset --hard ` fails outright and + // the plain fallback reset leaves the tree dirty — BOTH rollbacks fail, and + // the second one's result is the one nothing used to check. + const blocked = path.join(dir, 'blocked-residue'); + fs.mkdirSync(blocked, { recursive: true }); + fs.writeFileSync(path.join(blocked, 'residue.txt'), 'dead writer residue\n', 'utf8'); + const mode = fs.statSync(blocked).mode; + fs.chmodSync(blocked, 0o555); + let tx; + try { + tx = withStoreTransaction(c.ws, { home: c.harnessHome, label: 'after-crash' }, () => ({ commitMessage: 'must not run' })); + } finally { + fs.chmodSync(blocked, mode); + } + assert.equal(tx.ok, false, 'a recovery that could not discard the residue must refuse the run'); + assert.match(String(tx.error?.message || ''), /residue|rollback/i); +}); + +// The single most-likely-wrong assumption in the porcelain parser — that `-z` +// emits the NEW path first and the ORIGINAL second — verified against git +// itself rather than a hand-built status string. +test('R6/S3: parsePorcelainZ decodes a REAL git rename new-path-first', () => { + const repo = tempDir('sio-rename-'); + git(repo, ['init', '-q', '-b', 'main']); + fs.writeFileSync(path.join(repo, 'orig-name.txt'), 'content\n'); + git(repo, ['add', '-A']); + git(repo, ['-c', 'user.name=t', '-c', 'user.email=t@example.test', 'commit', '-qm', 'seed']); + git(repo, ['mv', 'orig-name.txt', 'new-name.txt']); + + const out = git(repo, ['status', '--porcelain', '-uall', '-z']).stdout; + const entries = parsePorcelainZ(out); + const rename = entries.find((e) => e.status.includes('R')); + assert.ok(rename, `git must report a rename: ${JSON.stringify(out)}`); + assert.equal(rename.path, 'new-name.txt', 'the FIRST field is the new path'); + assert.equal(rename.origPath, 'orig-name.txt', 'the SECOND field is the original path'); + assert.equal(entries.length, 1, 'the paired field must be consumed, not left to misalign the next entry'); +}); diff --git a/packages/harness/test/knowledge-structural-hardening.test.mjs b/packages/harness/test/knowledge-structural-hardening.test.mjs index 1c45e64c..2713ff60 100644 --- a/packages/harness/test/knowledge-structural-hardening.test.mjs +++ b/packages/harness/test/knowledge-structural-hardening.test.mjs @@ -33,7 +33,7 @@ import { lockOwnership, reassertStoreLock, } from '../lib/knowledge/store.mjs'; -import { QUARANTINE_DIR, readLearningFile, writeLearningFile } from '../lib/knowledge/learning-io.mjs'; +import { QUARANTINE_DIR, readLearningFile, writeLearningFile } from '../lib/knowledge/store-io.mjs'; const tempDir = (p) => fs.mkdtempSync(path.join(os.tmpdir(), p)); const isRoot = typeof process.getuid === 'function' && process.getuid() === 0; @@ -114,7 +114,7 @@ test('S1: a planted symlink at a learning path cannot be strengthened — the ou assert.equal(fs.readFileSync(victim, 'utf8'), original, 'the symlink target was never written through'); }); -test('S1: writeLearningFile refuses a symlinked leaf and a non-learning path shape', () => { +test('S1: writeLearningFile never writes THROUGH a symlinked leaf, and refuses a non-learning path shape', () => { const root = tempDir('sh-io-'); const victim = path.join(root, 'victim.txt'); fs.writeFileSync(victim, 'OUTSIDE\n', 'utf8'); @@ -122,8 +122,18 @@ test('S1: writeLearningFile refuses a symlinked leaf and a non-learning path sha fs.mkdirSync(path.dirname(link), { recursive: true }); fs.symlinkSync(victim, link); - assert.equal(writeLearningFile(link, 'rendered learning\n'), false); - assert.equal(fs.readFileSync(victim, 'utf8'), 'OUTSIDE\n'); + // The planted LINK is moved into `.quarantine/` (rename never follows a + // symlink) and the real file is written in its place, so the path stops being + // a trap instead of being refused forever — the same rule the store's + // metadata writers follow. What must never happen is the write landing on the + // link's TARGET. + assert.equal(writeLearningFile(link, 'rendered learning\n'), true); + assert.equal(fs.readFileSync(victim, 'utf8'), 'OUTSIDE\n', 'the outside target is untouched'); + assert.equal(fs.lstatSync(link).isSymbolicLink(), false); + assert.equal(fs.readFileSync(link, 'utf8'), 'rendered learning\n'); + const q = fs.readdirSync(path.join(root, QUARANTINE_DIR)); + assert.equal(q.length, 1, 'the link was preserved for inspection, not deleted'); + assert.ok(fs.lstatSync(path.join(root, QUARANTINE_DIR, q[0])).isSymbolicLink()); // Not a learning path at all — refused rather than "trusted because the // caller asked", which is what a root argument would have permitted. @@ -188,9 +198,19 @@ test('S1: absorb quarantines the planted link out of learnings/ instead of leavi // S2 — the lock survives `git clean -fd`, and is never released by a non-owner // --------------------------------------------------------------------------- +// The `.gitignore` is written by the first TRANSACTION, not by `ensureStore`: +// writing it is a store mutation, and `ensureStore` runs before the lock is +// acquired (P3). `openStore` below is therefore how a store is "opened" for +// these tests — one no-op transaction, exactly what any real command does. +const openStore = (c) => { + const tx = withStoreTransaction(c.ws, { home: c.harnessHome, label: 'open' }, () => ({ commitMessage: 'open' })); + assert.equal(tx.ok, true, String(tx.error || '')); + return tx.dir; +}; + test('S2: the store carries a .gitignore, and `git clean -fd` cannot sweep the lock', () => { const c = ctx(); - const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + const dir = openStore(c); const ignore = fs.readFileSync(path.join(dir, '.gitignore'), 'utf8'); assert.match(ignore, /^\/\.lock\/$/m, 'the lock directory is ignored'); @@ -205,26 +225,26 @@ test('S2: the store carries a .gitignore, and `git clean -fd` cannot sweep the l test('S2: a legacy store with no .gitignore gains one the next time it is opened', () => { const c = ctx(); - const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + const dir = openStore(c); fs.rmSync(path.join(dir, '.gitignore'), { force: true }); assert.equal(fs.existsSync(path.join(dir, '.gitignore')), false, 'precondition: no .gitignore'); - ensureStore(c.ws, { home: c.harnessHome }); + openStore(c); assert.match(fs.readFileSync(path.join(dir, '.gitignore'), 'utf8'), /^\/\.lock\/$/m); }); test('S2: a .gitignore a human already wrote is extended, never replaced', () => { const c = ctx(); - const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + const dir = openStore(c); fs.writeFileSync(path.join(dir, '.gitignore'), '# mine\nscratch/\n', 'utf8'); - ensureStore(c.ws, { home: c.harnessHome }); + openStore(c); const after = fs.readFileSync(path.join(dir, '.gitignore'), 'utf8'); assert.match(after, /^scratch\/$/m, 'the human entry survives'); assert.match(after, /^\/\.lock\/$/m, 'and ours was appended'); // Idempotent: a second open adds nothing. - ensureStore(c.ws, { home: c.harnessHome }); + openStore(c); assert.equal(fs.readFileSync(path.join(dir, '.gitignore'), 'utf8'), after); }); From 2cdc5cd6b37abe440a2556d90f94a88f5eec534e Mon Sep 17 00:00:00 2001 From: Krish Date: Thu, 6 Aug 2026 17:41:50 -0400 Subject: [PATCH 23/24] fix: guard store directories and make the store-IO contract unevadable --- docs/MEMORY-MODEL.md | 35 +- packages/harness/lib/fs-safe.mjs | 15 +- packages/harness/lib/knowledge/admin.mjs | 27 +- packages/harness/lib/knowledge/store-io.mjs | 211 +++++- packages/harness/lib/knowledge/store.mjs | 97 +++ .../harness/test/consolidate-apply.test.mjs | 12 +- .../knowledge-boundary-hardening.test.mjs | 4 +- .../knowledge-store-io-hardening.test.mjs | 625 ++++++++++++++++-- .../knowledge-structural-hardening.test.mjs | 7 +- 9 files changed, 960 insertions(+), 73 deletions(-) diff --git a/docs/MEMORY-MODEL.md b/docs/MEMORY-MODEL.md index 23d639a2..2e9c4852 100644 --- a/docs/MEMORY-MODEL.md +++ b/docs/MEMORY-MODEL.md @@ -657,11 +657,17 @@ runs `git status --porcelain -uall` in the store first and commits any dirty edi - The absorbed content may exceed the 1,200-byte learning cap — human authority overrides the cap for hand edits (logged, not rejected; the cap binds only the sole writer's own ops). -- **A symlink at ANY store path is inert — not just refused, and not just for learnings.** +- **A symlink at any store FILE is inert — not just refused, and not just for learnings.** + (Store *directories* are a separate case with a separate answer — see the next bullet.) Every read, write, append, delete, and existence check of a file the store owns goes through ONE internal choke point (`lib/knowledge/store-io.mjs`), built on the shared `fs-safe` primitives and contained against the STORE root, which it derives from the - path's own **allow-listed shape** rather than from a caller-supplied argument. The + path's own **allow-listed shape** rather than from a caller-supplied argument. That + derivation makes the root a fixed function of the path; it does not by itself prove the + path *is* a store path, since the shape matches by basename anywhere on the filesystem — + so the derived root must additionally sit inside a `knowledge/` directory, the only shape + `storeDirForId` ever builds. (Without that check `writeStoreFile('~/.ssh/config.json')` + was accepted and contained against `~/.ssh`.) The allow-list covers `learnings//.md` and its `branches//` equivalent, the store-root metadata (`INDEX.md`, `consolidated.jsonl`, `governance.jsonl`, `config.json`, `store.json`, `stale.json`, `.gitignore`), the bucket metadata @@ -697,8 +703,29 @@ runs `git status --porcelain -uall` in the store first and commits any dirty edi ` T` (and staged `A `) `continue`d: never quarantined, never logged, and the next `git add -A` committed the symlink into store history while `listLearnings` silently dropped the learning. The symlink check now runs before any code filter that could - exclude it, and the filter itself is an allow-list (`??`, `M`, `A`, `T`, with unmerged - codes carved out explicitly) rather than a deny-list. + exclude it, and the filter itself is an allow-list (`??`, `M`, `A`, `T`) rather than a + deny-list. The unmerged codes are carved out **before** any code is interpreted: `DD`, + `UD` and `DU` all contain a literal `D`, so while the carve-out sat below the deletion + branch an unresolved conflict was recorded as a hand deletion — a governance `retire` + that binds both layers and survives `consolidate --rebuild`. +- **A symlink at a store-owned DIRECTORY is quarantined, restored, and then refused.** + The choke point's allow-list covers file *leaves*, and the absorb loop's learning-path + regex matches `…//.md` — so a symlink at `/learnings`, a domain + directory, `/branches`, a bucket, or a bucket's learnings tree produced **no + absorb entry at all**: nothing quarantined it, every read path silently returned nothing + (the ancestor walk correctly refuses the whole subtree), and the next `git add -A` + recorded the link as a `120000` blob while the CLI reported success. Once tracked it is + self-reviving — every rollback `git reset --hard` re-materializes it, and `git clean -fd` + cannot sweep a tracked path. There is no arbitrary-file read or write in it (git stores + the link's target *path*, not the target's bytes, and writes still refuse via the + ancestor walk); it is silent, committed, reported-as-success **data loss**. + Two guards close it. `withStoreTransaction` sweeps the owned directory shapes as the + first thing it does under the lock — before crash recovery, before `fn`, before any git + read — quarantines each planted link, recreates the real directory, restores the subtree + from the last commit so the hidden learnings **reappear**, and then refuses *this* run + naming the directory. And `commitStore` — the one place `git add -A` runs — refuses to + stage at all while such a link stands, so no present or future caller can reach staging + around the sweep. - **The store lock cannot be lost, and is never released by a non-owner.** The first transaction to open a store writes (and, for stores created by an older CLI, migrates in) a `/.gitignore` covering `/.lock/`, `/.lock.stale-*`, and `/.quarantine/`. It is diff --git a/packages/harness/lib/fs-safe.mjs b/packages/harness/lib/fs-safe.mjs index 1c0742ab..64774539 100644 --- a/packages/harness/lib/fs-safe.mjs +++ b/packages/harness/lib/fs-safe.mjs @@ -393,7 +393,20 @@ export function appendFileContained(root, rel, content, { newlineGuard = false } fs.readSync(fd, last, 0, 1, stat.size - 1); if (last.toString('utf8') !== '\n') prefix = '\n'; } - fs.writeSync(fd, Buffer.from(prefix + content, 'utf8')); + // WRITE UNTIL IT IS ALL WRITTEN. `fs.writeSync` issues ONE `write(2)` and + // does not loop, and `write(2)` is permitted to write fewer bytes than it + // was given. The return value used to be ignored, so a short write left a + // TRUNCATED record — half a JSON line — in an append-only ledger and + // reported success. Zero progress cannot be retried usefully (it is not the + // documented EAGAIN shape for a blocking fd), so it refuses rather than + // spinning. + const buf = Buffer.from(prefix + content, 'utf8'); + let written = 0; + while (written < buf.length) { + const n = fs.writeSync(fd, buf, written, buf.length - written); + if (!(n > 0)) return refuse(); + written += n; + } close(); return full; } catch { diff --git a/packages/harness/lib/knowledge/admin.mjs b/packages/harness/lib/knowledge/admin.mjs index 09a73aec..6becb6c0 100644 --- a/packages/harness/lib/knowledge/admin.mjs +++ b/packages/harness/lib/knowledge/admin.mjs @@ -268,6 +268,18 @@ export function mirrorLearnings({ workspace, home, log = () => {}, retiredIds = * outside file into store history and a workspace teaching snapshot, then * overwriting that outside file with a canonically serialized learning. Such a * path is refused with a logged note, never followed. + * + * THAT COVERS A SYMLINK AT A LEARNING FILE, AND ONLY THAT. A symlink at a + * store-owned DIRECTORY (`learnings/`, a domain directory, `branches/`, a + * bucket, a bucket's learnings tree) never reaches this loop at all: + * LEARNING_FILE_RE matches `…//.md`, so a directory plant yields + * no entry to quarantine, and every learning it hides looks to absorb like it + * was simply never there. Worse, absorb would then read the resulting `D` + * entries as a human deleting every learning at once and record a governance + * `retire` for each. That whole class is handled UPSTREAM instead, before this + * function is ever called: `withStoreTransaction` (store.mjs) sweeps the owned + * directory shapes under the lock — quarantine, restore from the last commit, + * then refuse the run — and `commitStore` refuses to stage while one stands. */ /** Truncate a store-owned file through the choke point, failing closed: a wipe * that was refused must never be reported as a completed purge/rebuild. */ @@ -364,6 +376,16 @@ export function absorbHandEdits({ workspace, home, log = () => {} }) { continue; } + // THE UNMERGED CARVE-OUT RUNS FIRST, BEFORE ANY CODE IS INTERPRETED (R7). + // A store repo never merges, and absorbing half a conflict would be worse + // than leaving it — but the carve-out used to sit BELOW the deletion branch, + // and every unmerged code that names a deletion (`DD` both deleted, `UD` + // deleted by them, `DU` deleted by us) contains a literal `D`. Those states + // were therefore recorded as HAND DELETIONS: a governance `retire` that + // binds both layers and survives `consolidate --rebuild`, written for a + // conflict nobody has resolved yet. The carve-out never ran, exactly + // contrary to the comment that claimed it did. Order is the fix. + if (UNMERGED_CODES.has(code)) continue; if (code.includes('D')) { // Human deletion always wins — nothing left to parse or re-render. deleted.push(id); @@ -376,10 +398,7 @@ export function absorbHandEdits({ workspace, home, log = () => {} }) { // the next `git add -A`: // `??` planted and never tracked `M` modified in the worktree/index // `A` staged but never committed `T` type changed back to a real file - // Unmerged codes are carved out explicitly: a store repo never merges, and - // absorbing half a conflict would be worse than leaving it. Everything else - // (rename-into, copy) stays out of absorb scope as before. - if (UNMERGED_CODES.has(code)) continue; + // Everything else (rename-into, copy) stays out of absorb scope as before. if (code !== '??' && ![...code].some((ch) => ABSORBABLE_CODES.has(ch))) continue; const text = readLearningFile(file); diff --git a/packages/harness/lib/knowledge/store-io.mjs b/packages/harness/lib/knowledge/store-io.mjs index 3338d93f..7799c308 100644 --- a/packages/harness/lib/knowledge/store-io.mjs +++ b/packages/harness/lib/knowledge/store-io.mjs @@ -36,7 +36,7 @@ import { * ledger, config, schema marker, stale report, bucket metadata, the lock owner * stamp, the transaction journal — goes through the functions below; `fs` is * not used on a store-owned path anywhere else in lib/knowledge/. A symlink - * planted at ANY store path is therefore INERT: + * planted at ANY store FILE path is therefore INERT: * - never read through (readStoreFile → readFileNoFollow) * - never written through (writeStoreFile quarantines the link first) * - never appended through (appendStoreFile → O_NOFOLLOW append) @@ -48,12 +48,29 @@ import { * what let an earlier "refused in absorb" fix still end in a truncated * `~/.zshrc`. * - * NO ROOT ARGUMENT, BY DESIGN. An earlier draft took `(root, file)`; that just - * moves the defect to "which root did this caller pass?" — a caller holding a - * bucket root (`/branches/`) would contain against the bucket, and - * a symlinked `/branches` would escape containment while satisfying it. - * The containment root is DERIVED from the path's own required shape instead, - * so no caller can supply a wrong one. Extending the module to metadata + * FILE LEAVES ARE ONLY HALF THE STORE. The paragraph above was, for one round, + * written as if it covered every plant; it covered every plant AT A FILE. A + * symlink at a store-owned DIRECTORY (`/learnings`, a domain directory, + * `/branches`, a bucket, a bucket's learnings tree) produced no absorb + * entry at all — LEARNING_FILE_RE (store.mjs) matches + * `…//.md` and nothing else — so nothing quarantined it, every + * read path silently returned NOTHING (assertNoSymlinkAncestors correctly + * refuses the whole subtree), and the next `git add -A` recorded the link as a + * `120000` blob while the CLI reported success. That plant is self-reviving + * once tracked: every rollback `git reset --hard` re-materializes it and + * `git clean -fd` cannot sweep a tracked path. There is no arbitrary-file + * READ or WRITE in it — git stores the link's target path, not the target's + * bytes, and writes still refuse via the ancestor walk — but silent, + * committed, reported-as-success data loss is its own failure. + * `findSymlinkedStoreDirectories` / `reclaimSymlinkedStoreDirectory` below + * close it, and `commitStore` (store.mjs) refuses to stage while one stands. + * + * NO ROOT ARGUMENT FOR A FILE PATH, BY DESIGN. An earlier draft took + * `(root, file)`; that just moves the defect to "which root did this caller + * pass?" — a caller holding a bucket root (`/branches/`) would + * contain against the bucket, and a symlinked `/branches` would escape + * containment while satisfying it. The containment root is DERIVED from the + * path's own required shape instead. Extending the module to metadata * therefore extends the ALLOW-LIST of shapes, never the signature: * * /learnings//.md @@ -67,6 +84,19 @@ import { * Anything not matching that allow-listed shape (rule 3: allow-lists, not * deny-lists) is refused outright — there is no "unknown shape, assume the * caller knows best" path. + * + * WHAT THAT DERIVATION DOES AND DOES NOT PROVE. It proves the containment root + * is a fixed function of the path, so two callers holding the same path always + * contain against the same root; it does NOT prove the path is a store path. + * The shape match is by BASENAME, anywhere on the filesystem, so + * `/config.json` matched and `writeStoreFile('/Users/x/.ssh/config.json')` + * was accepted with `/Users/x/.ssh` as its own containment root — harmless + * only because every present-day caller happens to pass a real store path, + * which is an argument about callers, not about this module. The + * `isPlausibleStoreRoot` check below removes the "happens to" from that + * sentence: every store this CLI can build lives at `/knowledge/` + * (storeDirForId, store.mjs is the ONE constructor), so a derived root whose + * parent is not named `knowledge` is not a store root and the path is refused. */ /** Quarantine bucket for planted symlinks. Gitignored by the store `.gitignore` @@ -95,8 +125,30 @@ const BUCKET_FILES = new Set(['INDEX.md', 'consolidated.jsonl', 'meta.json']); * span rather than above it. */ const NESTED_STORE_FILES = new Set(['.git/harness-txn.json', '.lock/owner.json']); +/** + * The directory every knowledge store sits directly inside. `storeDirForId` + * (store.mjs) is the ONE place a store path is ever constructed, and it is + * always `path.join(home, 'knowledge', id)` — so `/knowledge/` is + * not a heuristic about store paths, it is their definition. + */ +const STORE_PARENT_DIR = 'knowledge'; + +/** + * Whether a DERIVED root can be a knowledge store root at all. Without this, + * the shape allow-list matches by basename ANYWHERE on the filesystem: a + * caller passing `/Users/x/.ssh/config.json` derived `/Users/x/.ssh` as "the + * store root" and was accepted, contained against a directory that is not a + * store. Refusing an implausible root turns "no present-day caller passes a + * wrong path" (a claim about callers) into "a wrong path is refused" (a claim + * about this module). + */ +function isPlausibleStoreRoot(storeRoot) { + return path.basename(path.dirname(storeRoot)) === STORE_PARENT_DIR; +} + function parts(rootParts, full, kind, bucket) { const storeRoot = rootParts.join(path.sep) || path.sep; + if (!isPlausibleStoreRoot(storeRoot)) return null; const rel = path.relative(storeRoot, full); if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null; return { storeRoot, rel, full, kind, bucket }; @@ -270,30 +322,157 @@ export function removeStoreFile(file) { export function quarantineSymlinkedStorePath(file) { const p = storePathParts(file); if (!p) return null; - const parentRel = path.dirname(p.rel); - if (!assertNoSymlinkAncestors(p.storeRoot, parentRel)) return null; + return quarantineLink(p.storeRoot, p.rel); +} + +/** + * The rename itself, shared by the file-shaped and directory-shaped entry + * points: verify every ancestor from the store root down is a real directory, + * verify the leaf really IS a symlink, then rename the LINK (never its target) + * into `/.quarantine/`. Returns the store-relative quarantine path, or + * null when there was nothing to quarantine or it could not be moved. + */ +function quarantineLink(storeRoot, rel) { + if (!assertNoSymlinkAncestors(storeRoot, path.dirname(rel))) return null; + const full = path.join(storeRoot, rel); let stat; try { - stat = fs.lstatSync(p.full); + stat = fs.lstatSync(full); } catch { return null; // nothing there (or unreadable) — nothing to quarantine } if (!stat.isSymbolicLink()) return null; - const destRel = path.join( - QUARANTINE_DIR, - `${p.rel.split(path.sep).join('__')}.${Date.now()}-${process.pid}.symlink` - ); - const dest = assertNoSymlinkAncestors(p.storeRoot, destRel); + const destRel = path.join(QUARANTINE_DIR, `${rel.split(path.sep).join('__')}.${Date.now()}-${process.pid}.symlink`); + const dest = assertNoSymlinkAncestors(storeRoot, destRel); if (!dest) return null; try { fs.mkdirSync(path.dirname(dest), { recursive: true }); - fs.renameSync(p.full, dest); + fs.renameSync(full, dest); } catch { return null; } return destRel.split(path.sep).join('/'); } +// --------------------------------------------------------------------------- +// Store-owned DIRECTORY shapes. +// +// WHY THE DERIVATION RUNS THE OTHER WAY HERE. A store FILE names its own root +// (see storePathParts): the shape is long enough that the root is a function of +// the path. A store DIRECTORY is not — `/learnings` is one path +// component, so deriving a root upward from it would accept any directory on +// the filesystem called `learnings`. These functions therefore ENUMERATE +// DOWNWARD from a store root the caller already holds (store.mjs's `dir`, the +// same value it passes to `commitStore` and every git call), and every `rel` +// they act on is produced by their OWN walk — never taken from a caller, never +// derived from attacker-controlled text. `isPlausibleStoreRoot` still gates the +// root, so a caller cannot point the sweep at an arbitrary directory tree. +// +// The walk never follows a link: `lstat` decides each component, and +// `readdirSync(withFileTypes)` reports a child's OWN type, so a symlinked +// directory is detected instead of being descended into. +// --------------------------------------------------------------------------- + +/** `learnings/` and, one level down, its domain directories. A store `learnings` + * directory may contain ONLY real domain directories, so ANY symlink directly + * inside it is a plant regardless of name. */ +function scanLearningsTree(storeRoot, layerRel, found) { + const rel = layerRel ? path.join(layerRel, 'learnings') : 'learnings'; + const full = path.join(storeRoot, rel); + let stat; + try { + stat = fs.lstatSync(full); + } catch { + return; // absent — nothing to scan + } + if (stat.isSymbolicLink()) { + found.push(rel); + return; // never descend through a link + } + if (!stat.isDirectory()) return; + let entries; + try { + entries = fs.readdirSync(full, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + if (e.isSymbolicLink()) found.push(path.join(rel, e.name)); + } +} + +/** + * Every store-owned DIRECTORY that is currently a symlink, as `/`-joined + * store-relative paths: + * + * learnings learnings/ + * branches branches/ + * branches//learnings branches//learnings/ + * + * Returns `[]` for a clean store, an implausible root, or an unreadable one — + * a scan that cannot see is never mistaken for a scan that found nothing, + * because callers treat a NON-empty result as the alarm and pair this with the + * `commitStore` refusal that fails closed either way. + */ +export function findSymlinkedStoreDirectories(storeRoot) { + const root = path.resolve(storeRoot); + if (!isPlausibleStoreRoot(root)) return []; + const found = []; + scanLearningsTree(root, '', found); + const branchesFull = path.join(root, 'branches'); + let branchesStat = null; + try { + branchesStat = fs.lstatSync(branchesFull); + } catch { + branchesStat = null; + } + if (branchesStat) { + if (branchesStat.isSymbolicLink()) { + found.push('branches'); + } else if (branchesStat.isDirectory()) { + let keys = []; + try { + keys = fs.readdirSync(branchesFull, { withFileTypes: true }); + } catch { + keys = []; + } + for (const k of keys) { + const keyRel = path.join('branches', k.name); + if (k.isSymbolicLink()) found.push(keyRel); + else if (k.isDirectory()) scanLearningsTree(root, keyRel, found); + } + } + } + return found.map((r) => r.split(path.sep).join('/')); +} + +/** + * Make a symlinked store DIRECTORY inert and put the real directory back: + * quarantine the link (same rename discipline as the file case — the link + * itself moves, its target is never touched) and recreate an empty real + * directory in its place, so the learnings the plant hid can be restored into + * it and every read path stops silently returning nothing. + * + * Returns `{ quarantined, ok }`. `ok` is false when the link is still standing + * at a live store path — the caller must then refuse rather than proceed, since + * a `git add -A` would record it as a `120000` blob. + */ +export function reclaimSymlinkedStoreDirectory(storeRoot, rel) { + const root = path.resolve(storeRoot); + if (!isPlausibleStoreRoot(root)) return { quarantined: null, ok: false }; + const relNative = String(rel).split('/').join(path.sep); + const quarantinedTo = quarantineLink(root, relNative); + if (!quarantinedTo) return { quarantined: null, ok: false }; + const full = assertNoSymlinkAncestors(root, relNative); + if (!full) return { quarantined: quarantinedTo, ok: false }; + try { + fs.mkdirSync(full, { recursive: true }); + } catch { + return { quarantined: quarantinedTo, ok: false }; + } + return { quarantined: quarantinedTo, ok: true }; +} + // --------------------------------------------------------------------------- // Learning-shaped wrappers. // diff --git a/packages/harness/lib/knowledge/store.mjs b/packages/harness/lib/knowledge/store.mjs index c0a295d1..e00f5f0e 100644 --- a/packages/harness/lib/knowledge/store.mjs +++ b/packages/harness/lib/knowledge/store.mjs @@ -11,6 +11,8 @@ import { appendStoreFile, removeStoreFile, storeFileState, + findSymlinkedStoreDirectories, + reclaimSymlinkedStoreDirectory, QUARANTINE_DIR, } from './store-io.mjs'; @@ -777,6 +779,25 @@ export function listLearnings(dir) { * output is neither. */ export function commitStore(dir, message) { + // THE LAST LINE BEFORE `git add -A` (R7). A symlink at a store-owned + // DIRECTORY is the one plant that reaches staging: it produces no absorb + // entry (LEARNING_FILE_RE matches file leaves only), every read path silently + // returns nothing through it, and `git add -A` records it as a `120000` blob + // — after which it is SELF-REVIVING, since every rollback `git reset --hard` + // re-materializes a tracked path and `git clean -fd` cannot sweep one. + // Staging happens in exactly this one function, so the refusal belongs here: + // no present or future caller can reach `git add -A` around it. Detect and + // REFUSE only — quarantining is a mutation, and `withStoreTransaction` + // (which owns the lock, the journal and the rollback) is where the store is + // allowed to be repaired. + const plantedDirs = findSymlinkedStoreDirectories(dir); + if (plantedDirs.length) { + return { + committed: false, + ok: false, + stderr: `refusing to stage the store: a symlink stands at store-owned director${plantedDirs.length > 1 ? 'ies' : 'y'} ${plantedDirs.join(', ')} — staging it would commit the link into store history and hide every learning under it`, + }; + } const addRes = spawnSync('git', ['add', '-A'], { cwd: dir, encoding: 'utf8' }); if (addRes.status !== 0) { return { committed: false, ok: false, stderr: addRes.stderr || `git add exited ${addRes.status}` }; @@ -1342,6 +1363,59 @@ function clearTxnJournal(dir, { token = null, force = false } = {}) { } } +/** + * SYMLINKED STORE DIRECTORIES: QUARANTINE, RESTORE, THEN REFUSE (R7). + * + * Run under the lock as the FIRST thing a transaction does — before crash + * recovery, before `fn`, before anything reads the tree — because every step + * after it is wrong in the presence of one: + * - `listLearnings` returns NOTHING through a symlinked `learnings/` + * (assertNoSymlinkAncestors correctly refuses the whole subtree), so absorb + * would see a store-wide "the human deleted everything" and record a + * governance `retire` per learning; + * - recovery's `git reset --hard` would try to check paths back out THROUGH + * the link; + * - `git add -A` would record the link itself as a `120000` blob, after which + * it is self-reviving. + * + * Three steps, in this order: + * 1. QUARANTINE the link (`reclaimSymlinkedStoreDirectory`, store-io.mjs) — + * the link moves, its target is never touched — and put a real, empty + * directory back in its place. + * 2. RESTORE what the last commit held under that directory (`git reset` to + * HEAD for the path, then `git checkout`), so the learnings the plant hid + * REAPPEAR instead of staying invisible. Nothing is destroyed by this: the + * plant already replaced the whole subtree, so there is no worktree state + * under it left to preserve. + * 3. REFUSE the transaction anyway. The auto-heal makes the NEXT run clean; + * this run has already read a store that was lying to it, and "we found a + * symlink where your learnings live" is not something to fix silently. + * + * Returns `{ planted, note }` — `planted` empty means nothing was found and the + * transaction proceeds normally. + */ +function healSymlinkedStoreDirectories(dir, git) { + const planted = findSymlinkedStoreDirectories(dir); + if (!planted.length) return { planted, note: null }; + const notes = []; + for (const rel of planted) { + const { quarantined, ok } = reclaimSymlinkedStoreDirectory(dir, rel); + notes.push( + ok + ? `${rel} was a symlink — never followed; moved to ${quarantined}` + : `${rel} is a symlink this CLI could not quarantine — move it aside by hand` + ); + if (!ok || !git) continue; + // Restore the subtree from the last commit. Both halves, exactly as + // discardResiduePath does: `reset` first so a staged deletion cannot make + // `checkout` a no-op, then `checkout` to materialize the files again. + const opts = { cwd: dir, encoding: 'utf8' }; + spawnSync('git', ['reset', '-q', 'HEAD', '--', rel], opts); + spawnSync('git', ['checkout', '-q', '--', rel], opts); + } + return { planted, note: notes.join('; ') }; +} + /** * Crash recovery, run under the freshly-acquired lock: a journal still on * disk means the previous holder never reached its commit or rollback. Every @@ -1533,6 +1607,29 @@ export function withStoreTransaction(workspace, { home, label, afterCommit } = { // a store mutation must never happen. It runs here, before recovery, because // recovery's own rollback is the first thing that depends on it. ensureStoreGitignore(dir); + // BEFORE RECOVERY, BEFORE `fn`, BEFORE ANY GIT READ (R7). A symlink at a + // store-owned directory makes every step below operate on a store that is + // lying about its own contents — see healSymlinkedStoreDirectories. The + // link is quarantined and the real directory restored (so the next run is + // clean), and THIS run refuses: nothing has been mutated by the transaction + // itself at this point, so refusing costs only the run. + const dirGuard = healSymlinkedStoreDirectories(dir, git); + if (dirGuard.planted.length) { + releaseStoreLock(lockPath, token); + return { + ok: false, + locked: false, + rolledBack: false, + error: new Error( + `a symlink stands at store-owned director${dirGuard.planted.length > 1 ? 'ies' : 'y'} ${dirGuard.planted.join(', ')} — every learning under it was hidden from every read path; ${dirGuard.note}` + ), + committed: false, + result: null, + dir, + git, + staleLockNote: [lock.staleLockNote, dirGuard.note].filter(Boolean).join('; ') || null, + }; + } // Crash recovery BEFORE anything reads the tree (see // recoverInterruptedTransaction): a dead writer's uncommitted residue is // discarded here rather than inherited by the absorb step below as human diff --git a/packages/harness/test/consolidate-apply.test.mjs b/packages/harness/test/consolidate-apply.test.mjs index 715d28dd..cdc74134 100644 --- a/packages/harness/test/consolidate-apply.test.mjs +++ b/packages/harness/test/consolidate-apply.test.mjs @@ -1059,11 +1059,13 @@ test('an ADD asserting kind: fix for a real file whose own frontmatter says kind }); test('updateFrontmatterField inserts a missing field on a CRLF-terminated learning file instead of silently no-opping', () => { - // The fixture lives at a REAL learning path shape (`/learnings// - // .md`): updateFrontmatterField reads and writes through the store-io - // choke point, which derives its containment root from exactly that shape and - // refuses anything else outright. - const file = path.join(tempDir('apply-crlf-'), 'learnings', 'sql', 'crlf-learning.md'); + // The fixture lives at a REAL store path (`/knowledge//learnings/ + // /.md`): updateFrontmatterField reads and writes through the + // store-io choke point, which derives its containment root from exactly that + // shape — and requires the derived root to sit inside a `knowledge/` + // directory, since `storeDirForId` is the only thing that ever builds one — + // refusing anything else outright. + const file = path.join(tempDir('apply-crlf-'), 'knowledge', 'repo-id', 'learnings', 'sql', 'crlf-learning.md'); fs.mkdirSync(path.dirname(file), { recursive: true }); const text = '---\r\ntrigger: "x"\r\nstatus: active\r\n---\r\n\r\nbody\r\n'; fs.writeFileSync(file, text); diff --git a/packages/harness/test/knowledge-boundary-hardening.test.mjs b/packages/harness/test/knowledge-boundary-hardening.test.mjs index 7f3b51e0..a8ab60a6 100644 --- a/packages/harness/test/knowledge-boundary-hardening.test.mjs +++ b/packages/harness/test/knowledge-boundary-hardening.test.mjs @@ -656,7 +656,9 @@ test('a branch-lane re-teach cannot append a confirm that cancels a standing GOL }); test('rebuildIndex excludes branch→golden tombstones so INDEX.md agrees with retrievalExclusion', () => { - const dir = tempDir('bh-index-'); + // A REAL store path shape (`/knowledge/`): the store-io choke point + // refuses a derived root that could not be a store root at all. + const dir = path.join(tempDir('bh-index-'), 'knowledge', 'repo-id'); fs.mkdirSync(path.join(dir, 'learnings', 'sql'), { recursive: true }); fs.writeFileSync( path.join(dir, 'learnings', 'sql', 'gone.md'), diff --git a/packages/harness/test/knowledge-store-io-hardening.test.mjs b/packages/harness/test/knowledge-store-io-hardening.test.mjs index aff76730..56d20948 100644 --- a/packages/harness/test/knowledge-store-io-hardening.test.mjs +++ b/packages/harness/test/knowledge-store-io-hardening.test.mjs @@ -14,6 +14,10 @@ // R6 an unowned live lock, a pre-lock store mutation, a clobbered // `.gitignore`, an unchecked recovery rollback, and porcelain rename // field order pinned only by a hand-built string +// R7 a symlink planted at a store-owned DIRECTORY (the allow-list covered +// file leaves only), a source contract evadable by how a path is spelled, +// a short `writeSync` truncating a ledger append, and an unmerged +// porcelain code recorded as a hand deletion // // Every test is written against the ATTACKER'S move or the failure mode, never // against the shape of the fix. @@ -47,8 +51,10 @@ import { observeStaleLock, takeOverStaleLock, lockOwnership, + commitStore, } from '../lib/knowledge/store.mjs'; -import { QUARANTINE_DIR } from '../lib/knowledge/store-io.mjs'; +import { QUARANTINE_DIR, findSymlinkedStoreDirectories, storePathParts, writeStoreFile } from '../lib/knowledge/store-io.mjs'; +import { appendFileContained } from '../lib/fs-safe.mjs'; const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const tempDir = (p) => fs.mkdtempSync(path.join(os.tmpdir(), p)); @@ -235,51 +241,350 @@ test('R1: a symlinked bucket meta.json / INDEX.md cannot be written through', () assert.equal(JSON.parse(fs.readFileSync(path.join(bucketDir, 'meta.json'), 'utf8')).branchKey, 'feature-x'); }); -// The class-completeness contract: no store-owned FILE NAME may appear as the -// argument of a bare `fs` read/write/append/exists/remove anywhere in -// lib/knowledge/. Rule 3 (allow-lists, not deny-lists) applied to the source -// itself — a new metadata writer that skips the choke point fails here. -test('R1: no bare fs call in lib/knowledge names a store-owned file', () => { - const storeOwned = [ - 'INDEX.md', - 'consolidated.jsonl', - 'governance.jsonl', - 'config.json', - 'store.json', - 'stale.json', - 'meta.json', - '.gitignore', - 'harness-txn.json', - 'owner.json', - ]; - // Comments stripped first (they discuss these filenames constantly), then the - // call's FULL argument list is read by balancing parentheses — a bare call - // split across lines is exactly the shape a line-by-line scan would miss. - const stripComments = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); - const argsOf = (src, openIdx) => { - let depth = 0; - for (let i = openIdx; i < src.length; i += 1) { - if (src[i] === '(') depth += 1; - else if (src[i] === ')') { - depth -= 1; - if (depth === 0) return src.slice(openIdx + 1, i); +// --------------------------------------------------------------------------- +// R1/R7 — THE SOURCE CONTRACT, MADE UNEVADABLE +// --------------------------------------------------------------------------- +// +// The previous version of this contract grepped for bare `fs` verbs whose +// ARGUMENT TEXT contained a store filename LITERAL. That is a contract about +// how a path is SPELLED, and every spelling dodges it — including the exact +// shape of the historical `ensureStore` bug the round existed to close: +// +// const indexPath = path.join(dir, 'INDEX.md'); +// fs.writeFileSync(indexPath, INDEX_STUB); // literal not in the args +// +// so did double quotes, template literals, `dir + '/INDEX.md'`, a destructured +// `import { writeFileSync } from 'node:fs'`, a helper alias, +// `fs.promises.writeFile`, and every verb outside its six-name list +// (renameSync, unlinkSync, openSync+writeSync, truncateSync, cpSync, lstatSync, +// readdirSync). It also scanned `lib/knowledge` NON-recursively. +// +// The contract below never looks at a path at all. It is three structural +// rules whose conjunction is exhaustive: +// +// 1. REACHABILITY. In a store-writing module the fs module may be reached in +// exactly ONE way: `import fs from 'node:fs'`. No named import, no +// namespace import, no `node:fs/promises`, no `require`/dynamic `import`, +// no destructuring off `fs`, no computed `fs[...]`, no aliasing the module +// object. This is what makes rule 2's textual scan COMPLETE: after rule 1, +// every raw fs use in the file is literally `fs.`. +// 2. PER-FILE VERB ALLOW-LIST. Every `fs.` must appear in the table +// below for that file. Not a deny-list of dangerous verbs — an allow-list +// of the ones each module is known to need, each justified. A NEW verb in +// an existing module, or ANY verb in a new module, fails until a human +// adds it deliberately. +// 3. SCOPE. `lib/knowledge/**` RECURSIVELY (so a future subdirectory is +// covered), plus every module anywhere under `lib/` that imports +// `store-io.mjs` — i.e. the import-graph definition of "a store-writing +// module", so one living outside lib/knowledge is held to the same rule. + +/** + * Permitted raw-`fs` verbs, per module, keyed by path relative to `lib/`. + * Every entry is a DIRECTORY/PROBE operation on the store tree or a + * WORKSPACE-path operation. No module may read, write, append or truncate + * FILE BYTES on a store path — that is store-io.mjs's monopoly, and the two + * content verbs that survive anywhere in the tree are on caller-supplied + * workspace paths, named individually below. + */ +const RAW_FS_ALLOW = new Map([ + // The choke point itself: the ONE module allowed to touch store file leaves. + // `renameSync` is the quarantine (it moves the LINK, never its target); + // `lstatSync` is the never-follow probe; `rmSync` is removeStoreFile after + // assertRealpathContained; `mkdirSync` creates the quarantine bucket and + // re-creates a reclaimed store directory; `readdirSync(withFileTypes)` is the + // store-owned-DIRECTORY sweep, which reports each child's OWN type so the + // walk detects a symlinked directory instead of descending through it. + ['knowledge/store-io.mjs', ['lstatSync', 'mkdirSync', 'readdirSync', 'renameSync', 'rmSync']], + // Store repo plumbing: `.git`/`.lock` DIRECTORY probes, the lock mkdir/rmdir + // and its stale-takeover rename, the learnings-tree walk, residue removal, + // and realpathSync for the path-keyed store id. No content verb. + ['knowledge/store.mjs', ['existsSync', 'mkdirSync', 'readdirSync', 'realpathSync', 'renameSync', 'rmSync', 'statSync']], + // Maintenance: directory probes/walks, the workspace mirror tree, and the + // store-migration copy+remove. `cpSync` copies a legacy store DIRECTORY into + // its new id, never a store file's bytes on their own. + ['knowledge/admin.mjs', ['cpSync', 'existsSync', 'mkdirSync', 'readdirSync', 'renameSync', 'rmSync', 'rmdirSync']], + // `readFileSync` reads the CALLER'S ops.json (a workspace path the user + // passes on the command line), never a store file. + ['knowledge/apply.mjs', ['existsSync', 'readFileSync']], + // `readFileSync` reads a workspace EPISODE the caller cited; `writeFileSync` + // writes the temp ops.json handed to applyOps. Both workspace paths. + ['knowledge/remember.mjs', ['existsSync', 'mkdirSync', 'readFileSync', 'rmSync', 'writeFileSync']], + ['knowledge/consolidate.mjs', ['existsSync', 'readdirSync']], + ['knowledge/eval.mjs', ['existsSync']], + ['knowledge/layer.mjs', ['existsSync', 'mkdirSync', 'renameSync']], + ['knowledge/lifecycle.mjs', ['existsSync']], + ['knowledge/listing.mjs', ['existsSync']], + ['knowledge/overlay.mjs', ['existsSync', 'readdirSync']], + ['knowledge/promote.mjs', ['existsSync']], + ['knowledge/prune.mjs', ['existsSync', 'rmSync']], + ['knowledge/retrieve.mjs', ['existsSync']], + ['knowledge/status.mjs', ['existsSync']], +]); + +/** + * Comment stripper that respects string and regex literals, so a `//` inside + * `/^[a-z+]+:\/\//` or inside `'https://…'` never eats the rest of the line + * (over-stripping would HIDE a violation, which is the one direction a + * contract test must not fail in). + */ +function stripCommentsJs(src) { + const REGEX_ALLOWED_AFTER = /[(,=:[!&|?{};+\-*%~^<>]/; + const REGEX_AFTER_WORD = new Set(['return', 'typeof', 'case', 'in', 'of', 'new', 'delete', 'void', 'instanceof', 'do', 'else', 'yield', 'await']); + let out = ''; + let prevSig = ''; + let prevWord = ''; + let i = 0; + while (i < src.length) { + const ch = src[i]; + const nx = src[i + 1]; + if (ch === '/' && nx === '/') { + while (i < src.length && src[i] !== '\n') i += 1; + continue; + } + if (ch === '/' && nx === '*') { + i += 2; + while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) i += 1; + i += 2; + continue; + } + if (ch === '"' || ch === "'" || ch === '`') { + out += ch; + i += 1; + while (i < src.length) { + if (src[i] === '\\') { + out += src[i] + (src[i + 1] ?? ''); + i += 2; + continue; + } + out += src[i]; + const done = src[i] === ch; + i += 1; + if (done) break; } + prevSig = ch; + prevWord = ''; + continue; } - return src.slice(openIdx + 1, openIdx + 400); - }; - const bare = /fs\.(readFileSync|writeFileSync|appendFileSync|statSync|existsSync|rmSync)\(/g; - const offenders = []; - const knowledgeDir = path.join(packageRoot, 'lib', 'knowledge'); - for (const f of fs.readdirSync(knowledgeDir).filter((n) => n.endsWith('.mjs'))) { - const src = stripComments(fs.readFileSync(path.join(knowledgeDir, f), 'utf8')); - bare.lastIndex = 0; + if (ch === '/' && (REGEX_ALLOWED_AFTER.test(prevSig) || REGEX_AFTER_WORD.has(prevWord) || prevSig === '')) { + out += ch; + i += 1; + let inClass = false; + while (i < src.length) { + if (src[i] === '\\') { + out += src[i] + (src[i + 1] ?? ''); + i += 2; + continue; + } + if (src[i] === '[') inClass = true; + else if (src[i] === ']') inClass = false; + out += src[i]; + const done = src[i] === '/' && !inClass; + i += 1; + if (done) break; + } + prevSig = '/'; + prevWord = ''; + continue; + } + out += ch; + if (!/\s/.test(ch)) { + prevSig = ch; + prevWord = /[A-Za-z_$\w]/.test(ch) ? prevWord + ch : ''; + } + i += 1; + } + return out; +} + +/** + * Rule 1, stated as an ALLOW-LIST (rule 3: allow-lists, not deny-lists). + * + * An ESM module can obtain another module's binding in exactly three ways: + * a static `import`, a dynamic `import()`, or `require()`. The first is + * allow-listed to ONE permitted form; the other two are not available at all. + * `fsImportViolations` therefore enumerates every static import of the fs + * module and requires each to be verbatim `import fs from 'node:fs'`, so a + * spelling nobody thought of fails by default rather than by omission. + */ +const FS_STATIC_IMPORT = /import\s+([^;'"]*?)\s*from\s*['"]((?:node:)?fs(?:\/promises)?)['"]/g; +function fsImportViolations(src) { + const out = []; + FS_STATIC_IMPORT.lastIndex = 0; + let m; + while ((m = FS_STATIC_IMPORT.exec(src)) !== null) { + const [, clause, specifier] = m; + if (clause.trim() !== 'fs' || specifier !== 'node:fs') { + out.push(`only \`import fs from 'node:fs'\` may reach the fs module, found \`import ${clause.trim()} from '${specifier}'\``); + } + } + return out; +} + +/** The non-`import` ways a binding to fs (or to one of its verbs) can appear. */ +const FS_REACH_VIOLATIONS = [ + [/require\s*\(\s*['"](node:)?fs/, 'require() of fs'], + [/import\s*\(\s*['"](node:)?fs/, 'dynamic import() of fs'], + [/(?:const|let|var)\s*\{[^}]*\}\s*=\s*fs\b/, 'destructuring verbs off the fs module object'], + [/\bfs\s*\[/, 'computed member access on fs (fs[...])'], + [/=\s*fs\s*[;,)]/, 'aliasing the whole fs module object'], +]; + +/** All `.mjs` under `dir`, recursively, as paths relative to `relativeTo`. */ +function mjsFiles(dir, relativeTo) { + const out = []; + let entries = []; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return out; + } + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) out.push(...mjsFiles(full, relativeTo)); + else if (e.isFile() && e.name.endsWith('.mjs')) out.push({ key: path.relative(relativeTo, full).split(path.sep).join('/'), full }); + } + return out; +} + +/** + * The checker, run against the SHIPPED source below and against a temp fixture + * of every evasion shape. Returns a list of violation strings; empty means the + * contract holds. + */ +function rawFsContractViolations({ libDir, knowledgeDir, allow = RAW_FS_ALLOW }) { + const scanned = new Map(); + for (const f of mjsFiles(knowledgeDir, libDir)) scanned.set(f.key, f.full); + // The import-graph half: a store-writing module ANYWHERE under lib/. + for (const f of mjsFiles(libDir, libDir)) { + if (scanned.has(f.key)) continue; + const raw = fs.readFileSync(f.full, 'utf8'); + if (/from\s*['"][^'"]*store-io\.mjs['"]/.test(stripCommentsJs(raw))) scanned.set(f.key, f.full); + } + const violations = []; + for (const [key, full] of [...scanned].sort()) { + const src = stripCommentsJs(fs.readFileSync(full, 'utf8')); + if (!/\bfs\b/.test(src)) continue; + for (const why of fsImportViolations(src)) violations.push(`${key}: ${why}`); + for (const [re, why] of FS_REACH_VIOLATIONS) { + if (re.test(src)) violations.push(`${key}: ${why}`); + } + const permitted = new Set(allow.get(key) || []); + const seen = new Set(); + const member = /\bfs\.([A-Za-z_$][\w$]*)/g; let m; - while ((m = bare.exec(src)) !== null) { - const args = argsOf(src, m.index + m[0].length - 1); - if (storeOwned.some((name) => args.includes(`'${name}'`))) offenders.push(`${f}: fs.${m[1]}(${args.replace(/\s+/g, ' ').slice(0, 90)})`); + while ((m = member.exec(src)) !== null) { + if (permitted.has(m[1]) || seen.has(m[1])) continue; + seen.add(m[1]); + violations.push( + allow.has(key) + ? `${key}: fs.${m[1]} is not in the module's raw-fs allow-list` + : `${key}: reaches raw fs but is not in the raw-fs allow-list at all` + ); } } - assert.deepEqual(offenders, [], 'every store-owned file must go through store-io.mjs'); + return violations; +} + +test('R1/R7: every store-writing module obeys the raw-fs source contract', () => { + const libDir = path.join(packageRoot, 'lib'); + const knowledgeDir = path.join(libDir, 'knowledge'); + assert.deepEqual(rawFsContractViolations({ libDir, knowledgeDir }), [], 'every store-owned file must go through store-io.mjs'); + + // The allow-list must not rot: an entry for a module that no longer exists + // silently widens nothing today and hides a real module tomorrow. + for (const key of RAW_FS_ALLOW.keys()) { + assert.ok(fs.existsSync(path.join(libDir, key)), `stale raw-fs allow-list entry: ${key}`); + } + // Canary: prove the comment stripper did not eat live code (over-stripping is + // the one failure direction a contract test must never have). + const stripped = stripCommentsJs(fs.readFileSync(path.join(knowledgeDir, 'store.mjs'), 'utf8')); + assert.match(stripped, /export function commitStore\(/); + assert.match(stripped, /export function withStoreTransaction\(/); +}); + +// Every shape that evaded the old filename-literal grep, constructed as a temp +// fixture and fed to the SAME checker. Each fixture is named after a real +// module so the SHIPPED allow-list is the one being applied. +test('R1/R7: the raw-fs contract rejects every spelling that evaded the filename grep', () => { + const evasions = [ + // The historical ensureStore bug's own shape: the literal is in a variable. + ['knowledge/store.mjs', "const indexPath = path.join(dir, 'INDEX.md');\nfs.writeFileSync(indexPath, INDEX_STUB);", 'writeFileSync'], + ['knowledge/store.mjs', 'fs.writeFileSync(path.join(dir, "INDEX.md"), INDEX_STUB);', 'double-quoted literal'], + ['knowledge/store.mjs', 'fs.writeFileSync(path.join(dir, `INDEX.md`), INDEX_STUB);', 'template literal'], + ['knowledge/store.mjs', "fs.writeFileSync(dir + '/INDEX.md', INDEX_STUB);", 'string concatenation'], + ['knowledge/store.mjs', 'fs.promises.writeFile(p, INDEX_STUB);', 'fs.promises'], + ['knowledge/store.mjs', 'const w = fs.writeFileSync;\nw(p, INDEX_STUB);', 'helper alias'], + ['knowledge/store.mjs', 'fs.unlinkSync(p);', 'unlinkSync'], + ['knowledge/store.mjs', 'const fd = fs.openSync(p, "w");\nfs.writeSync(fd, buf);', 'openSync + writeSync'], + ['knowledge/store.mjs', 'fs.truncateSync(p, 0);', 'truncateSync'], + ['knowledge/store.mjs', 'fs.cpSync(a, b);', 'cpSync'], + ['knowledge/store.mjs', 'fs.appendFileSync(p, line);', 'appendFileSync'], + ['knowledge/store.mjs', 'fs.createWriteStream(p).end(text);', 'createWriteStream'], + ['knowledge/lifecycle.mjs', 'fs.lstatSync(p);', 'lstatSync in a module that may not stat'], + ['knowledge/lifecycle.mjs', 'fs.readdirSync(p);', 'readdirSync in a module that may not walk'], + ['knowledge/lifecycle.mjs', 'fs.renameSync(a, b);', 'renameSync in a module that may not rename'], + // A future subdirectory under lib/knowledge — the non-recursive scan missed it. + ['knowledge/sub/writer.mjs', "fs.writeFileSync(path.join(dir, 'INDEX.md'), text);", 'a module in a lib/knowledge subdirectory'], + // A brand-new store-writing module with NO allow-list entry at all. + ['knowledge/newcomer.mjs', 'fs.existsSync(p);', 'a new lib/knowledge module'], + ]; + const importLine = "import fs from 'node:fs';\nimport path from 'node:path';\n"; + for (const [key, body, label] of evasions) { + const root = tempDir('sio-contract-'); + const full = path.join(root, key); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, importLine + body + '\n', 'utf8'); + const found = rawFsContractViolations({ libDir: root, knowledgeDir: path.join(root, 'knowledge') }); + assert.ok(found.length > 0, `the contract must reject: ${label}`); + } + + // Rule 1: every way of reaching fs other than `import fs from 'node:fs'`. + const reaches = [ + ["import { writeFileSync } from 'node:fs';\nwriteFileSync(p, text);", 'destructured import'], + ["import * as nodefs from 'node:fs';\nnodefs.writeFileSync(p, text);", 'namespace import'], + ["import fsp from 'node:fs/promises';\nawait fsp.writeFile(p, text);", 'node:fs/promises'], + ["import fs from 'fs';\nfs.existsSync(p);", "bare 'fs' specifier"], + ["const fs = require('node:fs');\nfs.existsSync(p);", 'require()'], + ["const fs = await import('node:fs');\nfs.existsSync(p);", 'dynamic import()'], + ["import fs from 'node:fs';\nconst { writeFileSync } = fs;\nwriteFileSync(p, text);", 'destructuring off fs'], + ["import fs from 'node:fs';\nfs['writeFileSync'](p, text);", 'computed fs[...]'], + ["import fs from 'node:fs';\nconst raw = fs;\nraw.writeFileSync(p, text);", 'aliasing the module object'], + ["import fs, { writeFileSync } from 'node:fs';\nwriteFileSync(p, text);", 'default PLUS named import'], + ["import myfs from 'node:fs';\nmyfs.writeFileSync(p, text);", 'default import bound to another name'], + ]; + for (const [body, label] of reaches) { + const root = tempDir('sio-reach-'); + const full = path.join(root, 'knowledge', 'store.mjs'); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, body + '\n', 'utf8'); + const found = rawFsContractViolations({ libDir: root, knowledgeDir: path.join(root, 'knowledge') }); + assert.ok(found.length > 0, `the contract must reject: ${label}`); + } + + // And a store-writing module OUTSIDE lib/knowledge is scanned via the + // import graph, not by its location. + const root = tempDir('sio-graph-'); + fs.mkdirSync(path.join(root, 'knowledge'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'rogue-writer.mjs'), + "import fs from 'node:fs';\nimport { writeStoreFile } from './knowledge/store-io.mjs';\nfs.writeFileSync(p, text);\nexport { writeStoreFile };\n", + 'utf8' + ); + assert.ok( + rawFsContractViolations({ libDir: root, knowledgeDir: path.join(root, 'knowledge') }).some((v) => v.startsWith('rogue-writer.mjs')), + 'a store-writing module outside lib/knowledge must be scanned too' + ); + + // Control: the shape the contract must NOT flag — an allow-listed verb in + // the module that owns it, however the path is spelled. + const okRoot = tempDir('sio-control-'); + fs.mkdirSync(path.join(okRoot, 'knowledge'), { recursive: true }); + fs.writeFileSync( + path.join(okRoot, 'knowledge', 'store.mjs'), + "import fs from 'node:fs';\nconst p = `${dir}/INDEX.md`;\nif (fs.existsSync(path.join(dir, '.git'))) fs.mkdirSync(p, { recursive: true });\n", + 'utf8' + ); + assert.deepEqual(rawFsContractViolations({ libDir: okRoot, knowledgeDir: path.join(okRoot, 'knowledge') }), []); }); // --------------------------------------------------------------------------- @@ -547,3 +852,241 @@ test('R6/S3: parsePorcelainZ decodes a REAL git rename new-path-first', () => { assert.equal(rename.origPath, 'orig-name.txt', 'the SECOND field is the original path'); assert.equal(entries.length, 1, 'the paired field must be consumed, not left to misalign the next entry'); }); + +// --------------------------------------------------------------------------- +// R7 — a symlink planted at a store-owned DIRECTORY +// --------------------------------------------------------------------------- +// +// The choke point's allow-list covers FILE LEAVES only, and LEARNING_FILE_RE +// matches `…//.md` only, so a symlinked DIRECTORY produced no +// absorb entry to quarantine at all. Verified end to end: +// +// rm -rf /learnings/sql && ln -s /tmp/evil /learnings/sql +// → absorbHandEdits: absorbed=[] deleted=[] committed=false +// → next transaction → pass:true +// → git ls-files -s: 120000 … learnings/sql (symlink COMMITTED) +// → listLearnings(dir): [] (every learning silently gone) +// +// `ln -s /tmp/x /learnings` is worse: ALL learnings vanish from every +// read path and the CLI reports success throughout. The plant is self-reviving +// — once tracked, every `git reset --hard` re-materializes it and `git clean +// -fd` cannot sweep it. + +/** A directory OUTSIDE the store a planted directory symlink points at. */ +function outsideDir(name = 'evil') { + const dir = tempDir(`sio-outside-${name}-`); + fs.writeFileSync(path.join(dir, 'bystander.txt'), `outside content for ${name}\n`, 'utf8'); + return dir; +} + +/** Every mode-120000 entry `git ls-files -s` reports — a symlink in history. */ +function trackedSymlinks(dir) { + return git(dir, ['ls-files', '-s']) + .stdout.split('\n') + .filter((l) => l.startsWith('120000')); +} + +function plantDirSymlink(target, at) { + fs.rmSync(at, { recursive: true, force: true }); + fs.symlinkSync(target, at); +} + +for (const shape of ['learnings', path.join('learnings', 'sql'), 'branches']) { + test(`R7: a symlink planted at /${shape.split(path.sep).join('/')} is quarantined or refused, never committed`, () => { + const c = ctx(); + const id = seedLearning(c, 'dir-plant-victim'); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + // A bucket layer exists too, so `branches` is a real directory to replace. + ensureBucket(dir, { key: 'feature-z', branch: 'feature/z', baseSha: null }); + commitStore(dir, 'seed bucket'); + const outside = outsideDir(shape.split(path.sep).join('-')); + const outsideBefore = fs.readdirSync(outside); + + plantDirSymlink(outside, path.join(dir, shape)); + + const res = setLearningStatus({ workspace: c.ws, id, action: 'retire', reason: 'cleanup', home: c.harnessHome }); + + // 1. NEVER a silent success while a store directory is a symlink. + assert.equal(res.pass, false, `a transaction must never report success with /${shape} symlinked`); + assert.match(String(res.blockedReason || ''), new RegExp(shape.split(path.sep).join('/')), 'the refusal must name the symlinked directory'); + + // 2. The link itself is inert — quarantined out of the live tree. + assert.equal( + fs.existsSync(path.join(dir, shape)) && fs.lstatSync(path.join(dir, shape)).isSymbolicLink(), + false, + 'the planted directory link must not still stand at a live store path' + ); + assert.ok( + quarantined(dir).some((f) => f.includes(shape.split(path.sep).join('__'))), + `the planted directory link is quarantined: ${JSON.stringify(quarantined(dir))}` + ); + + // 3. It is NEVER recorded as a 120000 blob in store history. + assert.deepEqual(trackedSymlinks(dir), [], 'a symlink must never be committed into store history'); + + // 4. The outside target is untouched. + assert.deepEqual(fs.readdirSync(outside), outsideBefore, 'the outside directory must be byte-for-byte unchanged'); + + // 5. The learnings the plant hid are back, and the next run is clean. + assert.ok(listLearnings(dir).some((l) => l.id === id), 'the real directory is restored, so the learnings reappear'); + const after = setLearningStatus({ workspace: c.ws, id, action: 'retire', reason: 'cleanup', home: c.harnessHome }); + assert.equal(after.pass, true, after.blockedReason || ''); + assert.deepEqual(trackedSymlinks(dir), [], 'and still no symlink in history after the healed run'); + }); +} + +test('R7: a symlink planted at a BUCKET learnings directory is quarantined or refused too', () => { + const c = ctx(); + const id = seedLearning(c, 'bucket-dir-victim'); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + const bucketDir = ensureBucket(dir, { key: 'feature-q', branch: 'feature/q', baseSha: null }); + const bucketLearnings = path.join(dir, 'branches', 'feature-q', 'learnings', 'sql'); + fs.mkdirSync(bucketLearnings, { recursive: true }); + fs.writeFileSync( + path.join(bucketLearnings, 'bucket-claim.md'), + '---\nschema: 1\ntrigger: "bucket claim"\nstatus: active\nsource: auto\nepisodes:\nanchors: []\nsuperseded_by: null\nlast_confirmed: null\norigin: unknown\n---\n\nBucket claim body.\n', + 'utf8' + ); + commitStore(dir, 'seed bucket learning'); + assert.ok(bucketDir); + + const outside = outsideDir('bucket'); + plantDirSymlink(outside, bucketLearnings); + + const res = setLearningStatus({ workspace: c.ws, id, action: 'retire', reason: 'cleanup', home: c.harnessHome }); + assert.equal(res.pass, false, 'a bucket learnings directory symlink must refuse the transaction too'); + assert.match(String(res.blockedReason || ''), /branches\/feature-q\/learnings\/sql/); + assert.deepEqual(trackedSymlinks(dir), [], 'a symlink must never be committed into store history'); + assert.ok(quarantined(dir).some((f) => f.includes('feature-q')), 'the bucket directory link is quarantined'); +}); + +// The structural half: `git add -A` runs in exactly ONE place, so the refusal +// belongs there — no future caller can reach staging around it. +test('R7: commitStore itself refuses to stage a store whose owned directory is a symlink', () => { + const c = ctx(); + seedLearning(c, 'commit-guard-anchor'); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + const outside = outsideDir('commit'); + + plantDirSymlink(outside, path.join(dir, 'learnings')); + + const res = commitStore(dir, 'must never land'); + assert.equal(res.ok, false, 'staging must fail closed'); + assert.equal(res.committed, false); + assert.match(String(res.stderr || ''), /learnings/, 'the refusal must name the symlinked directory'); + assert.deepEqual(trackedSymlinks(dir), [], 'git add -A must never have run'); +}); + +test('R7: findSymlinkedStoreDirectories reports every owned directory shape and nothing else', () => { + const c = ctx(); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + const outside = outsideDir('shapes'); + fs.mkdirSync(path.join(dir, 'learnings', 'sql'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'branches', 'k1', 'learnings', 'py'), { recursive: true }); + + assert.deepEqual(findSymlinkedStoreDirectories(dir), [], 'a clean store reports nothing'); + + plantDirSymlink(outside, path.join(dir, 'learnings', 'sql')); + plantDirSymlink(outside, path.join(dir, 'branches', 'k1', 'learnings', 'py')); + // A symlink OUTSIDE the owned shapes is not this scan's business. + fs.symlinkSync(outside, path.join(dir, 'unrelated-link')); + + assert.deepEqual(findSymlinkedStoreDirectories(dir).sort(), ['branches/k1/learnings/py', 'learnings/sql']); +}); + +// --------------------------------------------------------------------------- +// R7 — "no caller can supply a wrong root" was a claim about CALLERS +// --------------------------------------------------------------------------- + +// Deriving the containment root from the path's own shape does make the root a +// fixed function of the path — but the shape match is by BASENAME, ANYWHERE on +// the filesystem, so `/config.json` matched and was contained against +// its own parent. `writeStoreFile('/Users/x/.ssh/config.json')` was accepted. +test('R7: a store-shaped basename outside any store root is refused, not contained against its own parent', () => { + const home = tempDir('sio-plausible-'); + const ssh = path.join(home, '.ssh'); + fs.mkdirSync(ssh, { recursive: true }); + const victim = path.join(ssh, 'config.json'); + fs.writeFileSync(victim, 'Host secret\n IdentityFile ~/.ssh/id_ed25519\n', 'utf8'); + const before = fs.readFileSync(victim, 'utf8'); + + assert.equal(storePathParts(victim), null, 'a derived root that could not be a store root is not a store path'); + assert.equal(writeStoreFile(victim, 'owned by the store\n'), false, 'the write must be refused outright'); + assert.equal(fs.readFileSync(victim, 'utf8'), before, 'the outside file must be byte-identical'); + + // The same basename inside a REAL store root (`/knowledge/`, the + // only shape storeDirForId ever builds) still works — the check narrows the + // allow-list, it does not break the store. + const store = path.join(home, 'knowledge', 'repo-id'); + fs.mkdirSync(store, { recursive: true }); + assert.equal(writeStoreFile(path.join(store, 'config.json'), '{"mode":"on"}\n'), true); +}); + +// --------------------------------------------------------------------------- +// R7 — a short write must never silently truncate an appended ledger record +// --------------------------------------------------------------------------- + +// `fs.writeSync` issues ONE write(2) and does not loop: a short write left the +// ledger holding a partial JSON line and reported success. +test('R7: appendFileContained loops until the whole record is written', () => { + const root = tempDir('sio-append-'); + const record = `${JSON.stringify({ path: 'docs/solutions/x.md', learning: 'sql/x', at: '2026-01-01' })}\n`; + const realWrite = fs.writeSync; + let shortened = false; + fs.writeSync = (fd, buf, off, len, ...rest) => { + const offset = typeof off === 'number' ? off : 0; + const length = typeof len === 'number' ? len : buf.length - offset; + if (!shortened && length > 1) { + shortened = true; + return realWrite(fd, buf, offset, 1); + } + return realWrite(fd, buf, offset, length, ...rest); + }; + let written; + try { + written = appendFileContained(root, 'consolidated.jsonl', record); + } finally { + fs.writeSync = realWrite; + } + assert.ok(shortened, 'the test must actually have forced a short write'); + assert.ok(written, 'the append must still succeed'); + assert.equal(fs.readFileSync(path.join(root, 'consolidated.jsonl'), 'utf8'), record, 'a short write must never truncate the record'); +}); + +// --------------------------------------------------------------------------- +// R7 — an unmerged porcelain code is not a hand deletion +// --------------------------------------------------------------------------- + +// The `code.includes('D')` branch ran BEFORE the UNMERGED_CODES carve-out, so +// `DD`/`UD`/`DU` were recorded as hand deletions → a governance `retire` that +// survives `consolidate --rebuild`, for a conflict nobody resolved. +test('R7: an unmerged (deleted-by-one-side) learning is never absorbed as a hand deletion', () => { + const c = ctx(); + const id = seedLearning(c, 'unmerged-victim'); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + const rel = 'learnings/sql/unmerged-victim.md'; + const commit = (msg) => git(dir, ['-c', 'user.name=t', '-c', 'user.email=t@example.test', 'commit', '-qm', msg]); + + // REAL git conflict state: one side edits the learning, the other deletes it. + const base = git(dir, ['rev-parse', 'HEAD']).stdout.trim(); + git(dir, ['checkout', '-q', '-b', 'edited']); + fs.appendFileSync(path.join(dir, rel), '\nEdited on the branch.\n', 'utf8'); + git(dir, ['add', '-A']); + commit('edit on branch'); + git(dir, ['checkout', '-q', base]); + git(dir, ['checkout', '-q', '-b', 'deleted-side']); + fs.rmSync(path.join(dir, rel)); + git(dir, ['add', '-A']); + commit('delete on branch'); + const merge = git(dir, ['merge', '--no-commit', 'edited']); + assert.notEqual(merge.status, 0, 'the merge must actually conflict'); + + const entry = parsePorcelainZ(git(dir, ['status', '--porcelain', '-uall', '-z']).stdout).find((e) => e.path === rel); + assert.ok(entry, 'git must report the conflicted learning'); + assert.equal(entry.status.includes('D'), true, `the unmerged code must contain D, got ${JSON.stringify(entry.status)}`); + assert.ok(['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU'].includes(entry.status), `must be an unmerged code, got ${JSON.stringify(entry.status)}`); + + const res = absorbHandEdits({ workspace: c.ws, home: c.harnessHome, log: () => {} }); + assert.deepEqual(res.deleted, [], 'an unresolved conflict is not a hand deletion'); + assert.equal(readGovernance(dir).get(id)?.action, undefined, 'and it must never record a governance retire'); +}); diff --git a/packages/harness/test/knowledge-structural-hardening.test.mjs b/packages/harness/test/knowledge-structural-hardening.test.mjs index 2713ff60..267ce286 100644 --- a/packages/harness/test/knowledge-structural-hardening.test.mjs +++ b/packages/harness/test/knowledge-structural-hardening.test.mjs @@ -115,7 +115,12 @@ test('S1: a planted symlink at a learning path cannot be strengthened — the ou }); test('S1: writeLearningFile never writes THROUGH a symlinked leaf, and refuses a non-learning path shape', () => { - const root = tempDir('sh-io-'); + // A REAL store path shape (`/knowledge/`): the choke point refuses a + // derived root that could not be a store root at all, so `writeStoreFile` can + // no longer be handed `/Users/x/.ssh/config.json` and contain it against its + // own parent. + const root = path.join(tempDir('sh-io-'), 'knowledge', 'repo-id'); + fs.mkdirSync(root, { recursive: true }); const victim = path.join(root, 'victim.txt'); fs.writeFileSync(victim, 'OUTSIDE\n', 'utf8'); const link = path.join(root, 'learnings', 'sql', 'linked.md'); From 7dad8f96d4a424286aa403a941fc20155950877d Mon Sep 17 00:00:00 2001 From: Krish Date: Thu, 6 Aug 2026 18:09:49 -0400 Subject: [PATCH 24/24] fix: close review findings on ledger evidence, prune atomicity, and index publication --- .github/workflows/harness-tests.yml | 5 + ...8-06-feat-harness-evolution-phase1-plan.md | 10 +- packages/harness/README.md | 2 +- packages/harness/lib/commands.mjs | 8 +- packages/harness/lib/flags.mjs | 25 ++++- packages/harness/lib/knowledge/admin.mjs | 29 ++++- packages/harness/lib/knowledge/overlay.mjs | 19 +++- packages/harness/lib/knowledge/prune.mjs | 23 +++- packages/harness/lib/knowledge/status.mjs | 9 +- .../harness/lib/repo-map/structural-index.mjs | 105 +++++++++++++++--- packages/harness/lib/verify.mjs | 23 +++- packages/harness/test/hand-edits.test.mjs | 31 ++++++ .../harness/test/knowledge-promote.test.mjs | 21 ++++ .../harness/test/knowledge-status.test.mjs | 41 +++++++ .../harness/test/structural-index.test.mjs | 72 ++++++++++++ .../test/verify-advisory-hint.test.mjs | 5 +- .../test/verify-severity-hardening.test.mjs | 21 ++++ 17 files changed, 410 insertions(+), 39 deletions(-) diff --git a/.github/workflows/harness-tests.yml b/.github/workflows/harness-tests.yml index e2964577..5a63b11e 100644 --- a/.github/workflows/harness-tests.yml +++ b/.github/workflows/harness-tests.yml @@ -21,7 +21,12 @@ jobs: test: runs-on: ubuntu-latest steps: + # persist-credentials: false — nothing in this job talks to GitHub after + # the clone, so the job token has no reason to sit in `.git/config` where + # every later step (and every action they invoke) could read it. - uses: actions/checkout@v4 + with: + persist-credentials: false # Node 22: the lowest LTS the package's `engines: >=20` still admits # (Node 20 left maintenance in April 2026). Single version on purpose — diff --git a/docs/plans/2026-08-06-feat-harness-evolution-phase1-plan.md b/docs/plans/2026-08-06-feat-harness-evolution-phase1-plan.md index 7d7147a3..d1500716 100644 --- a/docs/plans/2026-08-06-feat-harness-evolution-phase1-plan.md +++ b/docs/plans/2026-08-06-feat-harness-evolution-phase1-plan.md @@ -175,9 +175,13 @@ Named checks only: `harness-tests` (full suite, includes all new tests) and `pro governance replay (regression: retire → absorb-branch → rebuild still lands retired); purge cascades across all layers and drops an id's governance record only when no layer still holds the id; store schema marker `store.json {schema: 2}` with - refuse-with-hint for newer stores; bucket strikes/quarantines live in the bucket's own - ledger. `consolidate --status` layer additions are additive fields (`layer`, - `bucketKey`); golden domain-pressure display stays golden-scoped. + refuse-with-hint for newer stores; episode strikes and quarantine markers are + STORE-GLOBAL — recorded in the store ROOT ledger even when the learning outcome is + routed to a branch bucket, so a count cannot reset by switching branches and every + lane reports the quarantine (`docs/MEMORY-MODEL.md` §"Caps, quarantine, and rejection + classes"); only learning OUTCOMES are per-layer. `consolidate --status` layer additions + are additive fields (`layer`, `bucketKey`); golden domain-pressure display stays + golden-scoped. - **Pre-existing tests updated for shipped behavior:** `harness-cli.test.mjs` (recall event now records — the old assertion pinned the dropped-write bug), `store-migration.test.mjs` (fixtures pin `defaultBranch` so identity-migration tests diff --git a/packages/harness/README.md b/packages/harness/README.md index 86059ac0..917a9a64 100644 --- a/packages/harness/README.md +++ b/packages/harness/README.md @@ -88,7 +88,7 @@ to Deliver before editing. | `get` | Bounded doc excerpt by `--docid` or `--path` | | `validate-plan` | Read-only plan template / intent compliance | | `index` | Rebuild `knowledge/manifest.yaml` + `.harness-index/` | -| `index --structural [--since ]` | Build the persistent structural code index at `~/.harness/index//structural/` (optional tree-sitter WASM tier for TS/JS/TSX, Python, Java; per-file lexical fallback; incremental; `--since` re-parses only the ref diff). Derived and rebuildable — safe to delete | +| `index --structural [--since ]` | Build the persistent structural code index at `~/.harness/index///structural/` (optional tree-sitter WASM tier for TS/JS/TSX, Python, Java; per-file lexical fallback; incremental; `--since` re-parses only the ref diff). Derived and rebuildable — safe to delete | | `compound` | Consume passed evidence, index learning, and record usage/outcome telemetry | | `compound --insight` | Evidence-free capture of investigation learnings (`kind: insight`, secret-scanned, ranked below verified fixes, never promotable) | | `consolidate` | Knowledge loop: `--status` debt gauge (quarantine + at-cap domains surfaced) · `--candidates` deterministic work packet, plus any id a human already retired/disputed/promoted (`governed`) so the skill doesn't waste an op re-deriving it · `--apply --ops ` validated sole writer of learnings via ADD/STRENGTHEN/SUPERSEDE/MERGE/NOOP ops (`suggest` mode requires `--yes`); mechanically reapplies a standing governance decision when a regenerated id matches one, returning `governed` | diff --git a/packages/harness/lib/commands.mjs b/packages/harness/lib/commands.mjs index 97a92d76..735e51b1 100644 --- a/packages/harness/lib/commands.mjs +++ b/packages/harness/lib/commands.mjs @@ -669,7 +669,7 @@ export async function cmdGate(argv) { } export async function cmdVerify(argv) { - const { runVerify, exitCodeForOutcome } = await import('./verify.mjs'); + const { runVerify, exitCodeForOutcome, isGatingCheck } = await import('./verify.mjs'); const flags = parseFlags(argv); const workspace = path.resolve(flags.workspace); const result = runVerify({ workspace, flags }); @@ -705,8 +705,10 @@ export async function cmdVerify(argv) { // check is neutral for the same reason — it cannot move the outcome // (resolveOutcome excludes it), so counting it here or pointing the agent // at it would route attention to the one check that can never unblock the - // run. Both stay visible as rows and in `advisoryFailures`. - const gating = (c) => c.status !== 'passed' && c.status !== 'skipped' && c.severity !== 'advisory'; + // run. Both stay visible as rows and in `advisoryFailures`. The predicate + // itself lives in verify.mjs (`isGatingCheck`) so this surface and the test + // that pins it can never drift apart. + const gating = isGatingCheck; const failed = result.checks.filter(gating).length; const passed = result.outcome === 'passed'; console.log( diff --git a/packages/harness/lib/flags.mjs b/packages/harness/lib/flags.mjs index 63c1591e..a6ea7cbb 100644 --- a/packages/harness/lib/flags.mjs +++ b/packages/harness/lib/flags.mjs @@ -206,10 +206,27 @@ export function parseFlags(argv) { else if (a === '--yes') flags.yes = true; else if (a.startsWith('--layer=')) flags.layer = parseLayer(a.split('=')[1]); else if (a === '--layer') flags.layer = parseLayer(argv[++i]); - else if (a.startsWith('--branch=')) flags.branch = a.split('=').slice(1).join('='); - else if (a === '--branch') flags.branch = argv[++i]; - else if (a.startsWith('--ids=')) flags.ids = a.split('=').slice(1).join('='); - else if (a === '--ids') flags.ids = argv[++i]; + // Same flag-shaped-value guard `--since` carries above: a separated form + // with a missing value used to swallow the NEXT flag as its argument + // (`--branch --ids x` set branch to "--ids" and dropped `--ids`' own + // effect), so a typo silently ran a DIFFERENT command than the one typed. + else if (a.startsWith('--branch=')) { + const value = a.split('=').slice(1).join('='); + if (!value) invalidFlag('--branch', value, 'requires a bucket key value'); + flags.branch = value; + } else if (a === '--branch') { + const next = argv[++i]; + if (next === undefined || next.startsWith('--')) invalidFlag('--branch', next, 'requires a bucket key value'); + flags.branch = next; + } else if (a.startsWith('--ids=')) { + const value = a.split('=').slice(1).join('='); + if (!value) invalidFlag('--ids', value, 'requires a comma-separated learning id list'); + flags.ids = value; + } else if (a === '--ids') { + const next = argv[++i]; + if (next === undefined || next.startsWith('--')) invalidFlag('--ids', next, 'requires a comma-separated learning id list'); + flags.ids = next; + } else if (a === '--all') flags.all = true; else if (a === '--merged') flags.merged = true; else if (a.startsWith('--stale=')) flags.stale = parsePositiveInt(a.split('=')[1], '--stale'); diff --git a/packages/harness/lib/knowledge/admin.mjs b/packages/harness/lib/knowledge/admin.mjs index 6becb6c0..60758f09 100644 --- a/packages/harness/lib/knowledge/admin.mjs +++ b/packages/harness/lib/knowledge/admin.mjs @@ -517,7 +517,30 @@ export function absorbHandEdits({ workspace, home, log = () => {} }) { if (!absorbed.length && !deleted.length) return empty; - for (const [root, entries] of ledgerByRoot) appendLedger(root, entries); + // THE EVIDENCE IS PART OF THE ABSORB, NOT A SIDE EFFECT (review finding). + // `appendLedger`/`appendGovernance` already fail closed by THROWING on a + // refused write — but a plain Error thrown from here lands in every + // transaction adopter's `catch (err) { if (err instanceof + // StoreTransactionAbort) throw err; }`, which swallows anything else as a + // best-effort absorb hiccup. By this point the learning files have ALREADY + // been rewritten, so the adopter went on to mutate further and the + // transaction's finalize commit published hand-rewritten learning content + // with no ledger line citing its teaching snapshot (and a hand DELETION with + // no governance `retire` a later `consolidate --rebuild` would honor). + // + // Re-raised as StoreTransactionAbort — abort, deliberately NOT a rollback: + // the absorb's own commit is still below, so the standard rollback + // (`git reset --hard` + `clean -fd`) would destroy the human's edit sitting + // uncommitted in the tree. Exactly the reasoning absorbOrAbort applies to a + // failed absorb sub-commit. + const recordEvidence = (write) => { + try { + write(); + } catch (err) { + throw new StoreTransactionAbort(`hand-edit absorb could not record its evidence: ${err.message}`); + } + }; + for (const [root, entries] of ledgerByRoot) recordEvidence(() => appendLedger(root, entries)); // Governance record (Milestone 4): a human deleting a learning file // directly is a retirement just as much as `learning retire` — recorded // here so it survives a later `consolidate --rebuild`. Appended before the @@ -564,7 +587,9 @@ export function absorbHandEdits({ workspace, home, log = () => {} }) { log(`hand-edit absorb: ${id} removed from one layer but still held by another — no store-wide retire recorded`); continue; } - appendGovernance(dir, { id, action: 'retire', reason: 'hand deletion (absorbed)', to: null, at: governanceAt }); + recordEvidence(() => + appendGovernance(dir, { id, action: 'retire', reason: 'hand deletion (absorbed)', to: null, at: governanceAt }) + ); } rebuildIndex(dir); // existsSync guard: a human may have deleted the whole bucket directory, diff --git a/packages/harness/lib/knowledge/overlay.mjs b/packages/harness/lib/knowledge/overlay.mjs index 2978bac1..5734e84a 100644 --- a/packages/harness/lib/knowledge/overlay.mjs +++ b/packages/harness/lib/knowledge/overlay.mjs @@ -130,6 +130,14 @@ export function listBuckets(dir) { return out.sort((a, b) => a.key.localeCompare(b.key)); } +/** The bucket `meta.baseSha` shape gate, shared by the ancestry check below and + * by every surface that RENDERS a baseSha (`knowledge status`). meta.json is a + * hand-editable cache, so a value the ancestry gate would refuse to feed to git + * must not be echoed onto a CLI row or into the `--json` lane either. */ +export function isBucketBaseSha(value) { + return typeof value === 'string' && SHA_RE.test(value); +} + /** * Ancestry gate for a bucket's recorded baseSha against the workspace HEAD. * `true` = verified ancestor; `false` = verified NOT an ancestor (or the sha @@ -139,7 +147,7 @@ export function listBuckets(dir) { * not against missing metadata on a legacy bucket. */ export function bucketAncestryOk(workspace, meta) { - if (!meta || typeof meta.baseSha !== 'string' || !SHA_RE.test(meta.baseSha)) return null; + if (!meta || !isBucketBaseSha(meta.baseSha)) return null; try { const res = spawnSync('git', ['merge-base', '--is-ancestor', meta.baseSha, 'HEAD'], { cwd: workspace, @@ -186,7 +194,14 @@ export function loadLayeredLearnings({ workspace, home } = {}) { } catch { context = null; } - if (!context?.branchKey) return { learnings: golden, layered: false, context }; + // Shape-check the key before it reaches `bucketDirFor`'s path.join — the + // same gate layer.mjs, promote.mjs, and apply.mjs already apply to a key they + // are about to join. `deriveGitContext` derives this one, so it is safe + // today; validating here keeps that a property of the READ path rather than + // of one particular producer staying honest. + if (!context?.branchKey || !isSafeBucketKey(context.branchKey)) { + return { learnings: golden, layered: false, context }; + } const bucketDir = bucketDirFor(dir, context.branchKey); if (!fs.existsSync(path.join(bucketDir, 'learnings'))) { diff --git a/packages/harness/lib/knowledge/prune.mjs b/packages/harness/lib/knowledge/prune.mjs index 31a9c3d6..7ca1d0b0 100644 --- a/packages/harness/lib/knowledge/prune.mjs +++ b/packages/harness/lib/knowledge/prune.mjs @@ -173,11 +173,19 @@ export function pruneBuckets({ workspace, home, branchKey = null, merged = false } const keys = [...selected.keys()].sort(); + // ALL-OR-NOTHING (review finding). Defense in depth (fs-safe.mjs): a + // recursive delete is the single most destructive syscall in this module, + // and `branches/` is a hand-editable tree, so a bucket whose real path + // resolves outside the store is refused rather than letting rmSync follow a + // swapped ancestor. That check used to run INSIDE the delete loop, which + // made the refusal PARTIAL: the buckets ahead of the offending one were + // already gone, the run returned `removed: []` (this reject path reports + // nothing removed), and withStoreTransaction still committed the deletion + // under the generic label — a silent, unreported loss. Every selected + // bucket is therefore containment-verified BEFORE the first rmSync; a + // refusal now costs the whole prune, not half of it. + const targets = []; for (const b of selected.values()) { - // Defense in depth (fs-safe.mjs): a recursive delete is the single most - // destructive syscall in this module, and `branches/` is a hand-editable - // tree. Refuse a bucket whose real path resolves outside the store rather - // than letting rmSync follow a swapped ancestor. const contained = assertRealpathContained(txDir, path.join('branches', b.key)); if (!contained) { return { @@ -187,9 +195,12 @@ export function pruneBuckets({ workspace, home, branchKey = null, merged = false blockedReason: `refused to prune ${b.key} — its real path resolves outside the knowledge store`, }; } + targets.push({ bucket: b, contained }); + } + for (const { bucket, contained } of targets) { fs.rmSync(contained, { recursive: true, force: true }); - const shown = safeBranchName(b.meta?.branch); - log(`pruned bucket ${b.key}${shown ? ` (${shown})` : ''}`); + const shown = safeBranchName(bucket.meta?.branch); + log(`pruned bucket ${bucket.key}${shown ? ` (${shown})` : ''}`); } return { kind: 'success', commitMessage: `knowledge: prune ${keys.join(', ')}`, keys, preview }; }); diff --git a/packages/harness/lib/knowledge/status.mjs b/packages/harness/lib/knowledge/status.mjs index 93c862e2..f9c8b064 100644 --- a/packages/harness/lib/knowledge/status.mjs +++ b/packages/harness/lib/knowledge/status.mjs @@ -1,7 +1,7 @@ import fs from 'node:fs'; import { storeDir, listLearnings, readStoreConfig } from './store.mjs'; import { isActiveFm, bucketCounts } from './consolidate.mjs'; -import { listBuckets, bucketAncestryOk, safeBranchName } from './overlay.mjs'; +import { listBuckets, bucketAncestryOk, safeBranchName, isBucketBaseSha } from './overlay.mjs'; import { deriveGitContext, isDetachedKey } from '../git-context.mjs'; import { indexStatus } from '../index-status.mjs'; @@ -71,7 +71,12 @@ export function knowledgeStatus({ workspace, copilotHome, home } = {}) { buckets.push({ key, branch: safeBranchName(meta?.branch), - baseSha: typeof meta?.baseSha === 'string' ? meta.baseSha : null, + // Same 40-hex gate `bucketAncestryOk` applies before handing the value + // to git (isBucketBaseSha, overlay.mjs). meta.json is hand-editable, so + // an arbitrary string here reached both the rendered row and the + // `--json` lane verbatim — the one untreated field on a report whose + // every other untrusted string is shape-checked or redacted. + baseSha: isBucketBaseSha(meta?.baseSha) ? meta.baseSha : null, createdAt, ageDays, // Derived from the key shape, never trusted from meta (cache only). diff --git a/packages/harness/lib/repo-map/structural-index.mjs b/packages/harness/lib/repo-map/structural-index.mjs index f46e16a8..b73e1738 100644 --- a/packages/harness/lib/repo-map/structural-index.mjs +++ b/packages/harness/lib/repo-map/structural-index.mjs @@ -11,7 +11,9 @@ // unresolved edges preserved EXPLICITLY, never fabricated // meta.json { sha, branch, baseSha, generatedAt, extractorTier, // grammarVersions, ... } — the P9 generation-context stamp -// All writes are atomic temp+rename through fs-safe's writeFileContained. +// All writes are atomic temp+rename through fs-safe's writeFileContained, and +// the four together are published as ONE generation via a staged directory +// swap (publishGeneration) so no reader can mix generations. // Building is async-command-path work (harness index --structural); READING // is fully synchronous so buildRepoMap/orient stay sync and model-free. // @@ -353,7 +355,20 @@ function symbolDelta(priorSymbols, nextSymbols) { // intact), so `'constructor' in prior` would be true for every table. for (const name of Object.keys(nextSymbols)) { if (!Object.hasOwn(prior, name)) added.push(name); - else if (JSON.stringify(prior[name].defs) !== JSON.stringify(nextSymbols[name].defs)) changed.push(name); + // A CORRUPT PRIOR REBUILDS, IT NEVER CRASHES (review finding). `prior` + // comes from a hand-editable, possibly truncated symbols.json, so an entry + // may be null or a primitive — `prior[name].defs` then threw a TypeError + // straight out of `harness index --structural`, and only deleting the index + // by hand recovered. Same discipline as `usablePriorEntry` for files.json: + // an entry that is not an object is not comparable, so it counts as CHANGED + // (the safe direction — never silently "unchanged"). + else if ( + !prior[name] || + typeof prior[name] !== 'object' || + JSON.stringify(prior[name].defs) !== JSON.stringify(nextSymbols[name].defs) + ) { + changed.push(name); + } } for (const name of Object.keys(prior)) { if (!Object.hasOwn(nextSymbols, name)) removed.push(name); @@ -362,6 +377,71 @@ function symbolDelta(priorSymbols, nextSymbols) { return { added: cap(added), removed: cap(removed), changed: cap(changed) }; } +/** + * Publish the four tables as ONE generation (review finding). + * + * Each individual write is atomic, and meta.json is written LAST as the + * completeness signal — but that only orders the writes, it does not make the + * SET atomic. Both readers (`readStructuralIndex` here and shape.mjs's) read + * meta.json first and then the tables, so a build landing between those reads + * hands back meta.json from generation N-1 beside files.json from generation N + * — mixed generations, with symbol rows citing files the meta never saw. + * + * The whole generation is therefore staged in a sibling directory and swapped + * in with two renames. A concurrent reader sees the previous generation whole, + * the new one whole, or — for the instant between the renames — no index + * directory at all, which every reader already treats as "absent, skip". On any + * failure the previous generation is renamed back, so a refused publish leaves + * the index exactly as it was rather than empty. Returns `{ ok }` plus the + * table name that refused, for the caller's log line. + */ +function publishGeneration(dir, writes) { + const parent = path.dirname(dir); + const suffix = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const staging = path.join(parent, `.staging-${path.basename(dir)}-${suffix}`); + const retired = path.join(parent, `.retired-${path.basename(dir)}-${suffix}`); + const discard = (p) => { + try { + fs.rmSync(p, { recursive: true, force: true }); + } catch { + // best effort — derived, rebuildable data; never worth failing a build + } + }; + try { + fs.mkdirSync(staging, { recursive: true }); + } catch { + return { ok: false, failed: 'staging directory' }; + } + for (const [name, data] of writes) { + if (!writeFileContained(staging, name, JSON.stringify(data) + '\n')) { + discard(staging); + return { ok: false, failed: name }; + } + } + let movedAside = false; + try { + if (fs.existsSync(dir)) { + fs.renameSync(dir, retired); + movedAside = true; + } + fs.renameSync(staging, dir); + } catch { + // Restore the previous generation rather than leaving the index absent. + if (movedAside && !fs.existsSync(dir)) { + try { + fs.renameSync(retired, dir); + } catch { + discard(retired); + } + } + discard(staging); + discard(retired); + return { ok: false, failed: 'generation swap' }; + } + discard(retired); + return { ok: true, failed: null }; +} + /** * Build (or incrementally refresh) the structural index. Async only because * the command path around it is async — the work itself is local fs + git + @@ -477,21 +557,20 @@ export async function buildStructuralIndex({ workspace, home, extractor, since = }; if (!dryRun) { - // meta.json is written LAST: readers treat meta as the completeness - // signal, so a crashed build leaves the previous stamp in place instead - // of presenting fresh-looking metadata over half-written tables. Each - // individual write is atomic (fs-safe temp + rename). - const writes = [ + // ONE GENERATION, PUBLISHED ATOMICALLY (publishGeneration above). meta.json + // is still written last within the staged set — readers treat meta as the + // completeness signal — but the whole set now becomes visible in a single + // directory swap, so no reader can pair this build's tables with the + // previous build's stamp. + const published = publishGeneration(dir, [ ['files.json', nextFiles], ['symbols.json', symbols], ['graph.json', graph], ['meta.json', meta], - ]; - for (const [name, data] of writes) { - if (!writeFileContained(dir, name, JSON.stringify(data) + '\n')) { - log(`structural index write refused: ${name}`); - return { dir, written: false, reparsed, reused, removedFiles, delta, meta, sinceIgnored, priorUnreadable, basedOn }; - } + ]); + if (!published.ok) { + log(`structural index write refused: ${published.failed}`); + return { dir, written: false, reparsed, reused, removedFiles, delta, meta, sinceIgnored, priorUnreadable, basedOn }; } } diff --git a/packages/harness/lib/verify.mjs b/packages/harness/lib/verify.mjs index 5d7fc6cb..b9367875 100644 --- a/packages/harness/lib/verify.mjs +++ b/packages/harness/lib/verify.mjs @@ -89,6 +89,22 @@ function checkStatusForEvidence(mapped, byId) { return statuses.every((status) => status === 'passed') ? 'passed' : 'inconclusive'; } +/** + * Is this check one that can actually hold the run back? `skipped` is neutral + * (e.g. the advisory structural check with no index) and so is `advisory` — + * resolveOutcome excludes it, so counting it as a failure or offering it as the + * next fix target would point the agent at the one check that can never unblock + * the run. A check with NO severity field predates policy v2 and still counts. + * + * EXPORTED because it is a CONTRACT, not a local convenience (review finding): + * the CLI's failure count and "next fix" line (commands.mjs) and the test that + * pins this behavior must be the same predicate. A copy in the test can go on + * passing while production drifts away from it. + */ +export function isGatingCheck(check) { + return check.status !== 'passed' && check.status !== 'skipped' && check.severity !== 'advisory'; +} + // Outcome reflects only non-advisory checks: an advisory failure is reported // (checks + advisoryFailures in the evidence payload) but never flips the // outcome or the exit code. A warn-severity failure degrades to inconclusive @@ -162,7 +178,12 @@ const CHECK_DEPTH_CAP = 3; // `stdout`/`stderr` are the trusted named command's own output, already // length-bounded by trimOutput and deliberately left multi-line so a failing // check stays readable. -const SANITIZED_CHECK_LISTS = ['findings', 'informational']; +// `details` (plan-schema / plan-readiness sub-check messages) and `openTasks` +// (verbatim `- [ ]` lines lifted out of the plan body) are PLAN-DERIVED text on +// exactly the same surfaces — `.harness/evidence/*.json`, `verify --json`, the +// event log — and a plan is an ordinary repo file a human or model writes. They +// were the two list payloads shipping unredacted, unflattened, and unbounded. +const SANITIZED_CHECK_LISTS = ['findings', 'informational', 'details', 'openTasks']; function checkText(value) { return inertLine(redactSecrets(String(value ?? ''))).slice(0, CHECK_TEXT_CAP); diff --git a/packages/harness/test/hand-edits.test.mjs b/packages/harness/test/hand-edits.test.mjs index ea58d96a..c9547522 100644 --- a/packages/harness/test/hand-edits.test.mjs +++ b/packages/harness/test/hand-edits.test.mjs @@ -843,3 +843,34 @@ test('P2: a git status failure makes absorbHandEdits fail closed (ok:false), not 'absorbOrAbort must fail closed on a git status failure' ); }); + +// A REFUSED EVIDENCE APPEND MUST ABORT THE ABSORB, NOT FALL THROUGH (review +// finding). `appendLedger`/`appendGovernance` fail closed by throwing, but a +// PLAIN Error thrown from absorbHandEdits lands in every transaction adopter's +// `catch (err) { if (err instanceof StoreTransactionAbort) throw err; }` — +// which swallows it as a best-effort absorb hiccup. The learning file has +// already been rewritten by then, so the adopter went on to finalize and commit +// hand-rewritten content with NO ledger line citing its teaching snapshot. +test('a refused ledger append aborts the absorb instead of committing evidence-free content', () => { + const c = ctx(); + seedLearning(c); + const { dir } = ensureStore(c.ws, { home: c.harnessHome }); + handEditBody(path.join(dir, 'learnings', 'sql', 'not-null-hot-tables.md'), 'Hand-rewritten claim body.'); + + // Poison the ledger path with a DIRECTORY: appendFileContained's + // O_APPEND|O_CREAT|O_NOFOLLOW open then fails (EISDIR), which is exactly the + // refusal appendLedger raises. (Not a symlink — a symlinked LEAF is + // quarantined and rewritten fresh, so it never refuses.) `consolidated.jsonl` + // is not a learning path, so LEARNING_FILE_RE skips it and the absorb still + // reaches the append. + fs.rmSync(path.join(dir, 'consolidated.jsonl'), { force: true }); + fs.mkdirSync(path.join(dir, 'consolidated.jsonl')); + + const commitsBefore = gitLog(dir).length; + assert.throws( + () => absorbOrAbort({ workspace: c.ws, home: c.harnessHome }), + (err) => err instanceof StoreTransactionAbort && /could not record its evidence/.test(err.message), + 'a refused evidence append must abort the transaction, never be swallowed as a best-effort hiccup' + ); + assert.equal(gitLog(dir).length, commitsBefore, 'and nothing is committed on the aborted path'); +}); diff --git a/packages/harness/test/knowledge-promote.test.mjs b/packages/harness/test/knowledge-promote.test.mjs index a03da1b6..8c292c4a 100644 --- a/packages/harness/test/knowledge-promote.test.mjs +++ b/packages/harness/test/knowledge-promote.test.mjs @@ -518,6 +518,27 @@ test('prune resolves bucket discovery and selection INSIDE the store transaction } }); +test('prune containment-verifies EVERY selected bucket before the first delete (all-or-nothing)', () => { + // Structural assertion for the same reason as the TOCTOU guard above: the + // refusal it guards is only reachable by an ancestor swap racing the loop + // (`listBuckets` skips a symlinked bucket outright, so no single-process test + // can plant one), but the CONSEQUENCE of getting the order wrong is a silent + // partial deletion — the buckets ahead of the refused one already gone, the + // run reporting `removed: []`, and the transaction still committing it. + const src = fs.readFileSync(new URL('../lib/knowledge/prune.mjs', import.meta.url), 'utf8'); + const firstDeleteAt = src.indexOf('fs.rmSync('); + assert.ok(firstDeleteAt !== -1, 'prune deletes bucket directories with rmSync'); + for (const m of [...src.matchAll(/assertRealpathContained\(/g)]) { + assert.ok(m.index < firstDeleteAt, 'every containment check runs before anything is deleted'); + } + // The delete loop must iterate the PRE-VALIDATED list, never the raw + // selection, and must not re-validate inside itself — validating in the + // delete loop is exactly what made a refusal partial. + const deleteLoop = src.slice(src.lastIndexOf('for (', firstDeleteAt), firstDeleteAt); + assert.doesNotMatch(deleteLoop, /assertRealpathContained/, 'no containment check inside the delete loop'); + assert.doesNotMatch(deleteLoop, /selected\.values\(\)/, 'the delete loop never iterates the unvalidated selection'); +}); + test('pruneBuckets refuses a non-integer staleDays at its own boundary', () => { const ws = featureWorkspace('feature/staleness'); const home = tempDir('promo-home11-'); diff --git a/packages/harness/test/knowledge-status.test.mjs b/packages/harness/test/knowledge-status.test.mjs index 7c1181d7..5435c6a2 100644 --- a/packages/harness/test/knowledge-status.test.mjs +++ b/packages/harness/test/knowledge-status.test.mjs @@ -116,6 +116,28 @@ test('a bucket with a non-ancestor base is flagged ancestryOk: false', () => { assert.equal(report.buckets[0].ancestryOk, false); }); +// meta.json is a hand-editable cache, so an arbitrary `baseSha` string reached +// both the rendered CLI row and the `--json` lane verbatim — the one untreated +// field on a report whose every other untrusted string is shape-checked or +// redacted. It now passes the same 40-hex gate `bucketAncestryOk` applies +// before handing the value to git. +test('a malformed bucket baseSha is dropped rather than rendered verbatim', () => { + const ws = gitWorkspace('feature/basesha'); + const home = tempDir('kstatus-home4-'); + const { dir } = ensureStore(ws, { home }); + writeBucket(dir, 'hostile-11111111', { + branch: 'hostile', + baseSha: 'AKIAIOSFODNN7EXAMPLE\nnot a sha at all', + }); + const report = knowledgeStatus({ workspace: ws, home }); + assert.equal(report.buckets[0].baseSha, null, 'a value the ancestry gate would refuse is never reported'); + assert.equal(report.buckets[0].ancestryOk, null, 'and it stays unverifiable, not "proven not an ancestor"'); + + writeBucket(dir, 'ok-22222222', { branch: 'ok', baseSha: 'a'.repeat(40) }); + const again = knowledgeStatus({ workspace: ws, home }); + assert.equal(again.buckets.find((b) => b.key === 'ok-22222222').baseSha, 'a'.repeat(40), 'a well-formed sha still reports'); +}); + test('knowledge status is read-only and never materializes a store', () => { const ws = gitWorkspace('feature/empty'); const home = tempDir('kstatus-home3-'); @@ -158,3 +180,22 @@ test('CLI: harness knowledge status --json emits the report and a knowledge even assert.match(human.stdout, /golden/); assert.match(human.stdout, /sql/); }); + +// `--since` already refused an option-shaped value; `--branch` and `--ids` did +// not, so a separated form with a missing value swallowed the NEXT flag as its +// argument (`--branch --ids x` set branch to "--ids" AND dropped `--ids`' own +// effect) — a typo silently ran a different command than the one typed. +test('CLI: --branch and --ids refuse a missing or flag-shaped value instead of swallowing the next flag', () => { + const ws = gitWorkspace('feature/flagguard'); + const harnessHome = tempDir('kstatus-hh2-'); + for (const args of [ + ['knowledge', 'prune', '--branch', '--merged'], + ['knowledge', 'prune', '--branch='], + ['knowledge', 'promote', '--ids', '--all'], + ['knowledge', 'promote', '--ids='], + ]) { + const res = runHarness([...args, '--workspace', ws], { HARNESS_HOME: harnessHome }); + assert.notEqual(res.status, 0, `${args.join(' ')} must be refused`); + assert.match(res.stderr, /invalid --(branch|ids)/, `${args.join(' ')}: ${res.stderr}`); + } +}); diff --git a/packages/harness/test/structural-index.test.mjs b/packages/harness/test/structural-index.test.mjs index 02c560ca..621865f4 100644 --- a/packages/harness/test/structural-index.test.mjs +++ b/packages/harness/test/structural-index.test.mjs @@ -607,3 +607,75 @@ test('no-network guard: source-text scan of the structural read modules for mode assert.doesNotMatch(extractorSrc, /^import[^\n]*web-tree-sitter/m, 'no static web-tree-sitter import'); assert.match(extractorSrc, /await import\('web-tree-sitter'\)/, 'runtime loads lazily in the factory'); }); + +// ONE GENERATION ON DISK, ALWAYS (review finding). Writing the four tables one +// by one into the live directory is four independent publications: a write that +// refuses partway (or a reader arriving mid-build) sees this build's files.json +// beside the previous build's meta.json. `meta.filesIndexed` is written as +// `Object.keys(files).length`, so within ONE generation the two always agree — +// which makes the pair a direct, deterministic probe for a mixed set. +test('a refused table write never leaves a mixed generation on disk', async (t) => { + const { ws, git } = gitRepo(FIXTURE); + const home = tempHome(); + t.after(() => { + fs.rmSync(ws, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); + }); + await buildStructuralIndex({ workspace: ws, home, extractor: countingExtractor() }); + const dir = structuralIndexDir(ws, { home }); + + // Generation 2 indexes one more file than generation 1. + writeFiles(ws, { 'extra.py': 'class Extra:\n def run(self):\n pass\n' }); + git(['add', '.']); + git(['commit', '-qm', 'add extra']); + + // A DIRECTORY where graph.json belongs: the temp+rename write refuses + // (rename onto a directory is EISDIR), which is the same shape a symlinked + // ancestor or a full disk produces on any one table. Before the staged + // publish, files.json had ALREADY been overwritten with generation 2 while + // meta.json still described generation 1. + fs.rmSync(path.join(dir, 'graph.json'), { force: true }); + fs.mkdirSync(path.join(dir, 'graph.json')); + + await buildStructuralIndex({ workspace: ws, home, extractor: countingExtractor() }); + + const files = JSON.parse(fs.readFileSync(path.join(dir, 'files.json'), 'utf8')); + const meta = JSON.parse(fs.readFileSync(path.join(dir, 'meta.json'), 'utf8')); + assert.equal( + Object.keys(files).length, + meta.filesIndexed, + 'files.json and meta.json must always describe the SAME generation' + ); +}); + +// A CORRUPT PRIOR REBUILDS, IT NEVER CRASHES (review finding). symbols.json is +// a plain hand-editable file; a null or primitive entry made symbolDelta +// dereference `.defs` on it and throw straight out of `harness index +// --structural`, recoverable only by deleting the index by hand. +test('a null or primitive prior symbol entry rebuilds instead of crashing the build', async (t) => { + const { ws } = gitRepo(FIXTURE); + const home = tempHome(); + t.after(() => { + fs.rmSync(ws, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); + }); + await buildStructuralIndex({ workspace: ws, home, extractor: countingExtractor() }); + const dir = structuralIndexDir(ws, { home }); + + const symbols = JSON.parse(fs.readFileSync(path.join(dir, 'symbols.json'), 'utf8')); + const names = Object.keys(symbols); + assert.ok(names.length >= 2, `precondition: at least two symbols, got ${names.length}`); + symbols[names[0]] = null; + symbols[names[1]] = 'truncated'; + fs.writeFileSync(path.join(dir, 'symbols.json'), JSON.stringify(symbols)); + + const rebuilt = await buildStructuralIndex({ workspace: ws, home, extractor: countingExtractor() }); + assert.equal(rebuilt.written, true, 'the build completes over a corrupt prior symbol table'); + // Uncomparable priors count as CHANGED — the safe direction, never a silent + // "unchanged". + assert.ok( + rebuilt.delta.changed.names.includes(names[0]), + `a null prior entry is reported changed: ${JSON.stringify(rebuilt.delta.changed)}` + ); + assert.ok(rebuilt.delta.changed.names.includes(names[1]), 'and so is a primitive one'); +}); diff --git a/packages/harness/test/verify-advisory-hint.test.mjs b/packages/harness/test/verify-advisory-hint.test.mjs index 627e35b7..aac52377 100644 --- a/packages/harness/test/verify-advisory-hint.test.mjs +++ b/packages/harness/test/verify-advisory-hint.test.mjs @@ -6,8 +6,9 @@ import test from 'node:test'; import assert from 'node:assert/strict'; - -const gating = (c) => c.status !== 'passed' && c.status !== 'skipped' && c.severity !== 'advisory'; +// The PRODUCTION predicate, imported rather than restated: a copy here could go +// on passing while `harness verify`'s own counting drifted away from it. +import { isGatingCheck as gating } from '../lib/verify.mjs'; test('advisory and skipped checks are neither counted nor offered as the next fix', () => { const checks = [ diff --git a/packages/harness/test/verify-severity-hardening.test.mjs b/packages/harness/test/verify-severity-hardening.test.mjs index 84243178..245e7f59 100644 --- a/packages/harness/test/verify-severity-hardening.test.mjs +++ b/packages/harness/test/verify-severity-hardening.test.mjs @@ -382,6 +382,27 @@ for (const [surface, sanitize] of [ }); } +// `details` (plan-schema / plan-readiness sub-check messages) and `openTasks` +// (verbatim `- [ ]` lines lifted out of the plan body) ride the SAME surfaces — +// evidence JSON, `verify --json`, the event log — and a plan is an ordinary repo +// file a human or model writes. They were the two list payloads shipping +// unredacted, unflattened, and unbounded. +test('G: plan-derived details and openTasks are sanitized like every other list payload', () => { + const payload = sanitizeCheckPayload({ + id: 'plan-schema', + status: 'failed', + severity: 'enforce', + message: 'schema invalid', + details: [{ pass: false, message: `bad field\n${AWS_KEY}` }], + openTasks: [`ship it using ${AWS_KEY}`, 'x'.repeat(200_000)], + }); + const serialized = JSON.stringify(payload); + assert.ok(!serialized.includes(AWS_KEY), 'plan-derived text is redacted'); + assert.ok(!payload.details[0].message.includes('\n'), 'details flatten to one line'); + assert.equal(payload.openTasks[1].length, 240, 'an unbounded task line is capped'); + assert.equal(payload.details[0].pass, false, 'well-formed structural fields pass through intact'); +}); + test('G: the canonical payload also sanitizes informational notes and keeps structural fields intact', () => { const check = sanitizeCheckPayload(hostileCheck()); assert.ok(!JSON.stringify(check.informational).includes(AWS_KEY), 'informational notes are redacted');