From a9306eb09eddcf8a7e1a77a9935ca81180e4cf52 Mon Sep 17 00:00:00 2001 From: Jose Alekhinne Date: Sat, 18 Jul 2026 15:57:21 -0700 Subject: [PATCH 1/8] plan: decompose progressive disclosure milestone 3 (the mover) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decompose specs/progressive-disclosure.md milestone 3 into an execution plan at specs/plans/pd-m3.md: the mover that first WRITES canonical knowledge files. 18 tasks across three epics — mover core (SplitStaging lossless byte-cut, WriteThemeBullet gist write-back, Apply IO mover with validate->append->verify->single-root-rewrite), the ctx disclosure apply CLI, and the ctx-digest apply-path skill + human-gated real rollout. Entry kinds only; CONVENTIONS digestion stays M4. No blocking TBDs. Rolling-wave gate passes (pd-m2 DoD complete). TASKS.md gains the PD-M3 phase with epic anchors E1-E3 partitioning T01-T18. Spec: specs/progressive-disclosure.md Signed-off-by: Jose Alekhinne --- .context/TASKS.md | 19 +++ specs/plans/pd-m3.md | 267 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 286 insertions(+) create mode 100644 specs/plans/pd-m3.md diff --git a/.context/TASKS.md b/.context/TASKS.md index caa0325b..ce530042 100644 --- a/.context/TASKS.md +++ b/.context/TASKS.md @@ -2981,6 +2981,25 @@ M3, behind this fully-exercised read+plan path. - [x] [E3] `ctx-digest` dry-run skill + copilot sync + milestone gate; MEASUREMENT GATE T12: dry-run must produce a coherent theme plan and move nothing (T11–T14). Plan: specs/plans/pd-m2.md #priority:medium #session:87e465a0 #branch:design/progressive-disclosure #added:2026-07-18 +### Phase PD-M3: Progressive Disclosure — the mover (append→verify→remove + gist write-back) + +Plan: `specs/plans/pd-m3.md` · Spec: `specs/progressive-disclosure.md` +Milestone 3 delivers **the mover**: the first pass that WRITES canonical +files. It moves staged entries into per-theme tier-1 files and folds their +gists into `## Themes`, under the spec's guards (validate → append → verify +byte-presence → single root rewrite; fail loud, no auto-repair). Entry +kinds only; CONVENTIONS digestion is M4. The write path is guarded by code, +not agent discipline — the clobber risk class the M1 guards exist for. + +**Completion rule**: an epic is `[x]` only when every task in its range is +`[x]`/`[o]` in `specs/plans/pd-m3.md` (the plan is the source of truth). + +- [ ] [E1] Mover core: `Plan`/`Assignment` types, `SplitStaging` lossless byte-cut, `WriteThemeBullet` gist write-back, `Apply` IO mover, abort/first-run/idempotency/invariants/conservation tests (T01–T09). Plan: specs/plans/pd-m3.md #priority:medium #session:f706d9de #branch:design/progressive-disclosure #added:2026-07-18 + +- [ ] [E2] `ctx disclosure apply` CLI — reads plan JSON, refuses non-knowledge files + convention kind, write-safe on error, doc.go + wiring guards (T10–T13). Plan: specs/plans/pd-m3.md #priority:medium #session:f706d9de #branch:design/progressive-disclosure #added:2026-07-18 + +- [ ] [E3] `ctx-digest` apply-path skill + copilot sync; MEASUREMENT GATE T16: driven apply on a realistic fixture moves entries + writes gists losslessly; real LEARNINGS→DECISIONS rollout is human-gated (T17); milestone gate (T14–T18). Plan: specs/plans/pd-m3.md #priority:medium #session:f706d9de #branch:design/progressive-disclosure #added:2026-07-18 + - [ ] Progressive disclosure for canonical context files: the growth warnings (LEARNINGS/DECISIONS/CONVENTIONS over threshold) are NOT redundancy — consolidation only got LEARNINGS 98→88 because the entries are distinct, dense signal. The real lever is a structural pass: canonical files carry a tight summary/index and detail loads on demand (via `ctx index`/`ctx search` projection + an archive/detail tier). Manual design exercise first (/ctx-brainstorm → spec), then codify the repeatable procedure as a new skill (e.g. /ctx-progressive-disclosure). This exercise IS the baseline for the skill. #priority:medium #session:87e465a0 #branch:main #added:2026-07-16 DESIGN DONE 2026-07-16 (session 87e465a0): /ctx-brainstorm run to completion (Understanding Lock → approaches → stress-test → design). diff --git a/specs/plans/pd-m3.md b/specs/plans/pd-m3.md new file mode 100644 index 00000000..2d5f3f94 --- /dev/null +++ b/specs/plans/pd-m3.md @@ -0,0 +1,267 @@ +# pd-m3 Plan — the mover (append → verify → remove + gist write-back) + +**Spec:** `specs/progressive-disclosure.md` · **Status:** Ready +**Blocking TBDs resolved:** none block M3. +- Gist format — resolved **in the spec** (`### Gist format`): one bullet + `- → [](/.md)`. Consumed here, not + re-litigated. +- CONVENTIONS `##`-vs-`###` digestion — **deferrable**, graduates to + blocking at **M4**. The M3 mover handles entry kinds only (learning, + decision) and **refuses** the convention kind (`ErrApplyNotEntryKind`). +- Suggest-only trigger wiring — **deferrable**, M5. + +## Scope & DoD + +Milestone 3 delivers **the mover**: the first pass that *writes* canonical +knowledge files. It moves staged entries into per-theme tier-1 files and +folds their gists into the root's `## Themes`, under the spec's guards +(append → verify byte-presence → remove; validate precondition; crash +ordering additive-first, single root rewrite last; fail loud, no +auto-repair). It consumes the `Plan` the `ctx-digest` skill authors from +the M2 `Inspection`. + +Entry kinds only. CONVENTIONS digestion stays M4 (its `##`-section model +collides with the region delimiters — pd-m1/pd-m2 amendments). + +**The write path is guarded by code, not by agent discipline.** The M1 +clobber-bug rationale ("never regenerate from what I recognized") means +the mover must not trust the skill to move files safely — the skill +supplies *semantics* (theme assignment + gist prose); the `Apply` +mechanism enforces *safety* (validate, lossless byte-cut, verify, order). + +DoD (confirmed by measurement or by the user — never derived from task +completion): + +- [ ] `make lint` = 0 issues, `go test ./...` green, `make audit` passes +- [ ] `disclosure.Apply` moves staged entries to theme files with + append→verify→remove ordering; on any theme-write/verify failure the + **root file is byte-identical** to before (T06) +- [ ] **Conservation** on a driven fixture: `entryIDs(staging_before) == + moved ⊎ entryIDs(staging_after)` (disjoint union); every moved entry + byte-present in **exactly one** theme file; zero loss, zero dups (T09) +- [ ] **Idempotency**: `Apply` with an empty plan (or empty staging) is a + no-op; root byte-identical (T07) +- [ ] Post-apply, all four invariants pass on the result — `Validate`, + `CheckPairing`, `CheckUniqueness`, `CheckLinks` (T08) +- [ ] **Gist write-back**: a touched/new theme's bullet is + (re)written in `## Themes`; untouched theme bullets are **byte-preserved** + (T03, T08); first run on an un-migrated root **creates** `## Themes` + below staging (T07) +- [ ] `ctx disclosure apply --plan ` performs the move and + reports what it did; refuses non-knowledge files and the convention + kind, leaving the file untouched (T10–T12) +- [ ] The `ctx-digest` skill, driven on a realistic fixture, completes a + full apply that moves entries and writes gists — the milestone's + **measurement gate** (T16) +- [ ] Real `.context/LEARNINGS.md` → `.context/DECISIONS.md` rollout is + performed and verified **only on explicit user sign-off** (T17, + human-gated) + +## Data model & storage + +No state file — staging **is** the watermark (DECISION `[2026-07-16-215955]`). +New value types in `internal/disclosure` (`apply.go`/`types.go`): + +```go +// Plan is the digest plan the ctx-digest skill authors and Apply +// executes: per target theme, which staged entries move there and the +// gist to write. Entry identity is timestamp+title (IDSeparator-joined), +// matching entryIDs / CheckUniqueness. +type Plan struct { + Kind string `json:"kind"` // "learning" | "decision" + Assignments []Assignment `json:"assignments"` +} +type Assignment struct { + Theme string `json:"theme"` // theme name (bullet label + heading text) + Slug string `json:"slug"` // theme-file basename stem → /.md + Gist string `json:"gist"` // authored one-line gist (spec ### Gist format) + Entries []string `json:"entries"` // entry IDs to move here, in file order +} + +// ApplyResult reports a successful Apply, for CLI output. +type ApplyResult struct { + Moved int `json:"moved"` // entries moved + Themes []string `json:"themes"` // theme slugs created or appended +} +``` + +**Storage layout** (unchanged from spec): theme files at +`//.md`, `` ∈ {`learnings`,`decisions`} via the +`cfgDisc.ThemeDir*` constants. Root rewritten in place. + +**Theme-file body format:** on create, `# \n\n` + the moved entry +spans; on append, `\n\n` + the moved spans. The exact separator +is unconstrained by conservation (which checks entry *presence*, not file +bytes); byte-presence verify uses `strings.Contains(fileContent, span)`. + +## Contracts + +**Disclosure package additions** (`internal/disclosure`): + +- `ThemeDir(k Kind) (string, bool)` — Kind → `learnings`/`decisions` + subdir; `(_, false)` for convention (mover refuses it) and unknown. +- `SplitStaging(staging string, moveIDs []string) (moved map[string]string, remaining string, err error)` + — the **lossless byte-cut**. Computes each entry's *untrimmed* + header-to-next-header span over raw `staging` (NOT via + `heading.ParseEntryBlocks`, which trims trailing blanks and is lossy — + it is used only for *identity*). Returns each moved id → its verbatim + span and the remaining staging byte-exact. `ErrEntryNotInStaging` for an + unknown id; `ErrEntryAssignedTwice` for a repeated id. +- `verifyContains(fileContent, span string) error` — pure predicate; + `ErrVerifyFailed` when `span` is absent. The byte-presence guard. +- `WriteThemeBullet(themesRaw string, a Assignment, hadThemes bool) string` + — gist write-back over **raw** `## Themes` text: replace the bullet line + whose parsed name == `a.Theme`, else append a new bullet; create the + `## Themes` heading when `!hadThemes`. Untouched bullet lines are + preserved verbatim (spec: "leave untouched themes alone"). Renders + `- → [](/.md)` using + `token.PrefixListDash`, `token.MetaSeparator`, `cfgDisc.ThemeArrow` + (new const `" → "`), `cfgDisc.LinkOpen`. +- `Apply(rootPath string, plan Plan, ctxDir string) (ApplyResult, error)` + — the IO mover. Order (spec Guards §1,§3): + 1. Read+`Parse` (kind via `KindFor(basename)`); refuse convention/ + unknown kind (`ErrApplyNotEntryKind`). + 2. `Validate(root)` — refuse malformed, no write (Guard §2). + 3. Validate the plan against staging via `SplitStaging` (fail loud on + unknown/duplicate ids; `ErrEmptyAssignment` for an assignment with no + entries). + 4. **Additive first:** for each assignment, append its moved spans to + `//.md` (mkdir + create if new). + 5. **Verify:** re-read each written theme file; `verifyContains` every + moved span. Any miss → abort (`ErrVerifyFailed`), **root untouched**. + 6. Fold gists: `WriteThemeBullet` per assignment over `root.ThemesRaw`. + 7. **Single root rewrite, last:** write `preamble + remaining + newThemes` + to `rootPath`. This is the only root write; reached only after every + verify passes, so any earlier failure leaves the root byte-identical. + Fail-loud, no auto-repair. Worst-case crash = duplicated theme append + (recoverable), never loss. + +**CLI** (`ctx disclosure apply --plan `): + +- New subcommand under the existing `disclosure` group (registered in + `internal/cli/disclosure/disclosure.go` alongside `inspect.Cmd()`). + `Use`/`DescKey` constants in `config/embed/cmd`. +- Reads `--plan` (file path, or `-` for stdin) as JSON `Plan`, resolves + ctxDir, calls `disclosure.Apply`, renders `ApplyResult` (human + `--json` + via `internal/write/disclosure`). Kind-inference / convention refusal → + typed sentinel, non-zero exit, **no write**. + +**Skill** (`internal/assets/claude/skills/ctx-digest/SKILL.md`): + +- Extend from dry-run-only to the **apply** path. After the human approves + the proposed plan (M2 flow unchanged through step 4): write the `Plan` + JSON → run `ctx disclosure apply --plan ` → re-run + `ctx disclosure inspect` to confirm staging shrank and themes grew. + The human gates the apply explicitly (skill presents the plan and asks + before invoking). States conventions are deferred to M4. Copilot copy + synced via `make sync-copilot-skills`. + +No `ctx agent` change (spec Non-Goals). No `add`-path change. + +## Test matrix + +| invariant / rule | attempt | expected | task | +|---|---|---|---| +| Validate precondition (Guard §2) | Apply on a two-`## Themes` root | `ErrMultipleThemes`, root untouched | T05 | +| lossless byte-cut | staging with blank-line-separated entries; move a subset | `remaining` byte-exact for un-moved; each `moved` span verbatim (incl. body) | T02 | +| entry not in staging | plan id absent from staging | `ErrEntryNotInStaging`, no write | T02 | +| entry assigned twice | id in two assignments | `ErrEntryAssignedTwice`, no write | T02 | +| empty assignment | assignment with `Entries: []` | `ErrEmptyAssignment`, no write | T02 | +| verify predicate | `verifyContains` on content lacking span | `ErrVerifyFailed` | T06 | +| append→verify→remove; root untouched (Guard §1,§3) | force the *last* theme append to fail (unwritable path) | error returned; earlier theme file got its append; **root byte-identical** | T06 | +| gist write-back — new theme | plan with a new theme | `## Themes` gains `- name — gist → [name](noun/slug.md)`; `parseThemeBullet` round-trips name+link | T03 | +| gist write-back — touched theme | re-touch an existing theme | its bullet line replaced; **other bullet lines byte-preserved**; order kept | T03 | +| first-run themes creation | Apply on `HasThemes=false` root | `## Themes` created below staging; `Validate` passes | T07 | +| idempotency | Apply empty plan / empty staging | no-op; root byte-identical | T07 | +| conservation | Apply moving M of N staged | `entryIDs(before) == moved ⊎ entryIDs(after)`; each moved id present in exactly one theme file | T09 | +| post-apply invariants | after a successful Apply | `Validate`+`CheckPairing`+`CheckUniqueness`+`CheckLinks` all nil | T08 | +| convention refused | Apply on `CONVENTIONS.md` | `ErrApplyNotEntryKind`, no write | T05 | +| CLI apply writes | `apply --plan p.json` | root+theme files updated; `ApplyResult` printed | T10 | +| CLI rejects non-knowledge file | `apply README.md` | non-zero, kind sentinel, no write | T11 | +| CLI write-safety on bad root | `apply` on a malformed root | non-zero, file byte-identical | T12 | +| skill drive (measurement) | drive ctx-digest apply on realistic fixture | entries moved, gists written, conservation + all four invariants hold | T16 | + +## Task breakdown + +| id | st | task | deps | files | [P] | acceptance criterion | spec ref | +|---|---|---|---|---|---|---|---| +| T01 | [ ] | `Plan`/`Assignment`/`ApplyResult` types; `ThemeDir(k)`; `ThemeArrow` const | — | `internal/disclosure/types.go`, `apply.go`, `internal/config/disclosure/disclosure.go`, `kind_test.go` | [P] | `go test ./internal/disclosure/ -run TestThemeDir`: learning/decision → dir,true; convention/unknown → _,false | Data model | +| T02 | [ ] | `SplitStaging` lossless byte-cut + plan-vs-staging validation (uses T04 sentinels) | T01,T04 | `internal/disclosure/split.go`, `split_test.go` | | `-run TestSplitStaging`: remaining byte-exact for un-moved incl. separators; moved spans verbatim; unknown id → `ErrEntryNotInStaging`; dup id → `ErrEntryAssignedTwice`; empty entries → `ErrEmptyAssignment` | Contracts, Guards §1 | +| T03 | [ ] | `WriteThemeBullet` gist write-back over raw `## Themes` | T01 | `internal/disclosure/themes.go`, `themes_test.go` | [P] | `-run TestWriteThemeBullet`: new theme appends a bullet `parseThemeBullet` re-parses (name+link match); touched theme's line replaced, other lines byte-preserved; `!hadThemes` creates the heading | Contracts, Design step 4 | +| T04 | [ ] | all new error sentinels + YAML descriptions: `ErrEntryNotInStaging`, `ErrEntryAssignedTwice`, `ErrEmptyAssignment`, `ErrVerifyFailed`, `ErrApplyNotEntryKind` | — | `internal/err/disclosure/*.go`, `commands/text/errors.yaml` | [P] | `go test ./internal/audit/ -run TestDescKeyYAMLLinkage` green (bijection holds); all five sentinels defined | CONVENTIONS | +| T05 | [ ] | `Apply` IO mover — validate→append→verify→rewrite; kind/precondition refusal | T02,T03,T04 | `internal/disclosure/apply.go`, `apply_test.go` | | `-run TestApply` (temp dir): moves entries; theme files hold bodies; root = preamble+remaining+themes; `ApplyResult{Moved,Themes}` correct; `CONVENTIONS.md` → `ErrApplyNotEntryKind`; two-themes root → `ErrMultipleThemes`, no write | Contracts, Guards | +| T06 | [ ] | abort/ordering guarantee — root untouched on failure | T05 | `internal/disclosure/apply_abort_test.go` | [P] | `-run TestApplyAbort`: (a) `verifyContains` absent span → `ErrVerifyFailed`; (b) force last theme append to fail → error, root file byte-identical, earlier theme append present | Guards §1,§3 | +| T07 | [ ] | first-run `## Themes` creation + idempotency | T05 | `internal/disclosure/apply_firstrun_test.go` | [P] | `-run TestApplyFirstRun`: un-migrated root gets `## Themes` below staging, `Validate` nil; `-run TestApplyIdempotent`: empty plan → root byte-identical | Design step 5 | +| T08 | [ ] | post-apply invariants hold | T05 | `internal/disclosure/apply_invariants_test.go` | [P] | `-run TestApplyInvariants`: after Apply on a fixture, `Validate`+`CheckPairing`+`CheckUniqueness`+`CheckLinks` all return nil | Invariants | +| T09 | [ ] | conservation property test | T05 | `internal/disclosure/apply_conserve_test.go` | [P] | `-run TestApplyConservation`: `entryIDs(before)` == disjoint union of moved ids and `entryIDs(after)`; each moved id present in exactly one theme file; zero loss/dup | Tests (Conservation) | +| T10 | [ ] | `ctx disclosure apply` subcommand + result output | T05 | `internal/cli/disclosure/cmd/apply/{cmd.go,run.go}`, `internal/cli/disclosure/disclosure.go`, `internal/config/embed/cmd/*.go`, `internal/write/disclosure/*.go` | | `ctx disclosure apply --plan p.json` updates root+theme files, prints result; `--json` decodes to `ApplyResult` | CLI | +| T11 | [ ] | CLI rejects non-knowledge file | T10 | `internal/cli/disclosure/cmd/apply/run_test.go` | [P] | `apply README.md` exits non-zero with the kind sentinel; file untouched | Contracts | +| T12 | [ ] | CLI write-safety on malformed root | T10 | `internal/cli/disclosure/cmd/apply/run_write_test.go` | [P] | integration: `apply` on a two-`## Themes` root → non-zero, file byte-identical | Scope/DoD | +| T13 | [ ] | `doc.go` for the apply cmd + command-wiring guards | T10 | `internal/cli/disclosure/cmd/apply/doc.go` | [P] | `make audit` doc.go/docstring floors pass; `go test ./internal/compliance/ -run TestShippedHooksResolve` and command-tree tests green with the new subcommand | CONVENTIONS | +| T14 | [ ] | `ctx-digest` skill — apply path | T10 | `internal/assets/claude/skills/ctx-digest/SKILL.md` | | frontmatter test green; body describes inspect→propose→gist→**build plan JSON→human approve→`ctx disclosure apply`→confirm via inspect**, states the append→verify→remove guard and conventions=M4 | Skill | +| T15 | [ ] | copilot skill sync | T14 | `internal/assets/integrations/copilot-cli/skills/ctx-digest/**` | | `make check-copilot-skills` green | Skill | +| T16 | [ ] |XX **measurement gate** — drive skill apply on realistic fixture | T14,T08,T09 | scratchpad fixture (copy of real LEARNINGS staging) | | drive ctx-digest apply on N≥4 staged entries → ≥2 themes: entries moved, theme files created with bodies, `## Themes` gists written, conservation holds, all four invariants pass, root bounded | Scope/DoD | +| T17 | [ ] |XX real LEARNINGS→DECISIONS rollout (**human-gated**) | T16 | `.context/LEARNINGS.md`, `.context/DECISIONS.md`, `.context/learnings/**`, `.context/decisions/**` | | **only on explicit user approval:** apply on the real roots; conservation + all four invariants verified; committed on user sign-off (own commit) | Spec Phasing 3 | +| T18 | [ ] |XX milestone gate | T01–T17 | — | | `make lint` 0, `go test ./...` green, `make audit` pass; changed canonical `.md` pass invariants | Scope/DoD | + +**Execution waves:** `T01,T04` ∥ → `T02,T03` ∥ → `T05` → +`T06,T07,T08,T09` ∥ → `T10` → `T11,T12,T13` ∥ → `T14` → `T15` → +`T16` → `T17` → `T18`. + +## Epic anchors (TASKS.md projection) + +Epics **partition** the task ids: + +| Epic | Range | Count | +|---|---|---| +| E1 Mover core (disclosure package) | T01–T09 | 9 | +| E2 `ctx disclosure apply` CLI | T10–T13 | 4 | +| E3 Apply skill, rollout, gate | T14–T18 | 5 | + +Arithmetic: 9 + 4 + 5 = **18** = the task count. No id double-counted or +unclaimed. **Completion rule:** an epic is `[x]` in TASKS.md only when +every task in its range is `[x]`/`[o]` here. + +## Risks & measurement gates + +- **⚠️ T16 is the milestone's measurement gate.** If a full driven apply + cannot move entries losslessly with gists on a realistic fixture, the + mover design is wrong and must be reconsidered before touching real + canonical files (T17). +- **T17 mutates canonical `.context/` files** — the payoff, and the + clobber-risk class the M1 guards exist for. It is **human-gated**: run + only on explicit user approval, verify conservation + invariants before + committing, and land it as its own commit. The code (T01–T16) is + complete and verifiable **independent of T17**. +- **Lossless byte-cut is the fault line.** `heading.ParseEntryBlocks` + trims trailing blank lines (`internal/heading/entry.go`), so re-joining + parsed blocks is lossy. `SplitStaging` must operate on raw + header-to-next-header spans; T02's byte-exact assertion is what proves + it. Using `ParseEntryBlocks` for the *cut* (not just identity) would + silently drop inter-entry whitespace. +- **`parseThemeBullet` folds the `→ [link]` tail into `Theme.Gist`** + (M2 parse quirk — the parsed `Gist` retains the arrow+link suffix). + M3 sidesteps it by editing **raw** bullet lines (`WriteThemeBullet`), + never re-rendering from the parsed `Theme`. Noted, not fixed here (M2 + surface; not blocking). +- **Verify-miss is defensive.** After a successful append, byte-presence + always holds, so the true miss branch is only reachable via IO + corruption. T06 proves the guard two ways: the pure `verifyContains` + predicate (miss → `ErrVerifyFailed`) and the ordering property (append + failure → root untouched). + +## Out of scope (deferred, with pointers) + +| Deferred | Milestone | Note | +|---|---|---| +| CONVENTIONS digestion (`##` section model) | M4 | mover refuses `convention` kind (`ErrApplyNotEntryKind`); `##`-vs-`###` TBD is blocking there | +| Suggest-only trigger wiring (growth nudge, /ctx-remember, /ctx-wrap-up) | M5 | nudge-threshold TBD blocking there | +| `--dry-run` on `apply` | — | dry-run already covered by `inspect` + the skill's propose step (M2); not needed | +| Nesting / theme taxonomy | — | structure self-similar; deferred indefinitely (spec Non-Goals) | +| `ctx drift` wiring beyond the existing path check | — | spec does not require it | + +## Amendments + +| date | what | why | +|---|---|---| +| — | — | — | From 114deba3166bde08837af81c4901f012910a0f00 Mon Sep 17 00:00:00 2001 From: Jose Alekhinne Date: Sat, 18 Jul 2026 19:23:01 -0700 Subject: [PATCH 2/8] =?UTF-8?q?feat(pd-m3):=20the=20mover=20core=20?= =?UTF-8?q?=E2=80=94=20SplitStaging,=20WriteThemeBullet,=20Apply=20(E1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 3's disclosure-package core: the guarded pass that first WRITES canonical knowledge files (the clobber risk class the M1 guards exist for). - SplitStaging: lossless header-to-next-header byte-cut of moved entries from raw staging (NOT via ParseEntryBlocks, which trims trailing blanks and would drop inter-entry whitespace). - WriteThemeBullet: gist write-back over raw ## Themes bullet lines — replace the touched theme's line, byte-preserve the rest, create the section on first run. - Apply: validate precondition -> append bodies to theme files (additive first) -> verify byte-presence -> single root rewrite last. Fail-loud, no auto-repair; any failure leaves the root byte-identical. - Mover error sentinels + ThemeDir / ThemeArrow / link constants. Tests: split byte-exactness, abort/root-untouched ordering, first-run creation, idempotency, post-apply invariants, and conservation. Unexported helpers split into movehelpers.go per the mixed-visibility convention. Plan: specs/plans/pd-m3.md Signed-off-by: Jose Alekhinne --- .context/TASKS.md | 2 +- internal/assets/commands/text/errors.yaml | 10 + internal/config/disclosure/disclosure.go | 12 ++ internal/config/embed/text/disclosure.go | 22 ++ internal/disclosure/apply.go | 108 ++++++++++ internal/disclosure/apply_abort_test.go | 51 +++++ internal/disclosure/apply_conserve_test.go | 91 ++++++++ internal/disclosure/apply_firstrun_test.go | 71 +++++++ internal/disclosure/apply_invariants_test.go | 60 ++++++ internal/disclosure/apply_test.go | 152 ++++++++++++++ .../disclosure/apply_verify_internal_test.go | 26 +++ internal/disclosure/inspect_test.go | 28 +++ internal/disclosure/kind.go | 23 +++ internal/disclosure/movehelpers.go | 194 ++++++++++++++++++ internal/disclosure/split.go | 107 ++++++++++ internal/disclosure/split_test.go | 138 +++++++++++++ internal/disclosure/themes.go | 71 +++++++ internal/disclosure/themes_test.go | 76 +++++++ internal/disclosure/types.go | 39 ++++ internal/err/disclosure/disclosure.go | 37 ++++ internal/err/disclosure/doc.go | 15 +- specs/plans/pd-m3.md | 18 +- 22 files changed, 1336 insertions(+), 15 deletions(-) create mode 100644 internal/disclosure/apply.go create mode 100644 internal/disclosure/apply_abort_test.go create mode 100644 internal/disclosure/apply_conserve_test.go create mode 100644 internal/disclosure/apply_firstrun_test.go create mode 100644 internal/disclosure/apply_invariants_test.go create mode 100644 internal/disclosure/apply_test.go create mode 100644 internal/disclosure/apply_verify_internal_test.go create mode 100644 internal/disclosure/movehelpers.go create mode 100644 internal/disclosure/split.go create mode 100644 internal/disclosure/split_test.go create mode 100644 internal/disclosure/themes.go create mode 100644 internal/disclosure/themes_test.go diff --git a/.context/TASKS.md b/.context/TASKS.md index ce530042..8833a3dd 100644 --- a/.context/TASKS.md +++ b/.context/TASKS.md @@ -2994,7 +2994,7 @@ not agent discipline — the clobber risk class the M1 guards exist for. **Completion rule**: an epic is `[x]` only when every task in its range is `[x]`/`[o]` in `specs/plans/pd-m3.md` (the plan is the source of truth). -- [ ] [E1] Mover core: `Plan`/`Assignment` types, `SplitStaging` lossless byte-cut, `WriteThemeBullet` gist write-back, `Apply` IO mover, abort/first-run/idempotency/invariants/conservation tests (T01–T09). Plan: specs/plans/pd-m3.md #priority:medium #session:f706d9de #branch:design/progressive-disclosure #added:2026-07-18 +- [x] [E1] Mover core: `Plan`/`Assignment` types, `SplitStaging` lossless byte-cut, `WriteThemeBullet` gist write-back, `Apply` IO mover, abort/first-run/idempotency/invariants/conservation tests (T01–T09). Plan: specs/plans/pd-m3.md #priority:medium #session:f706d9de #branch:design/progressive-disclosure #added:2026-07-18 - [ ] [E2] `ctx disclosure apply` CLI — reads plan JSON, refuses non-knowledge files + convention kind, write-safe on error, doc.go + wiring guards (T10–T13). Plan: specs/plans/pd-m3.md #priority:medium #session:f706d9de #branch:design/progressive-disclosure #added:2026-07-18 diff --git a/internal/assets/commands/text/errors.yaml b/internal/assets/commands/text/errors.yaml index 7e4841f9..fe090222 100644 --- a/internal/assets/commands/text/errors.yaml +++ b/internal/assets/commands/text/errors.yaml @@ -158,6 +158,16 @@ err.disclosure.orphan-theme-file: short: 'progressive disclosure: a theme file has no matching gist in the root' err.disclosure.staging-unparsable: short: 'progressive disclosure: the staging zone could not be parsed into discrete entries' +err.disclosure.apply-not-entry-kind: + short: 'progressive disclosure: the mover digests only LEARNINGS.md and DECISIONS.md; conventions digestion is a later milestone' +err.disclosure.empty-assignment: + short: 'progressive disclosure: a digest-plan assignment names no entries to move' +err.disclosure.entry-assigned-twice: + short: 'progressive disclosure: a digest plan assigns the same entry to more than one theme' +err.disclosure.entry-not-in-staging: + short: 'progressive disclosure: a digest plan references an entry that is not in the root''s staging zone' +err.disclosure.verify-failed: + short: 'progressive disclosure: an entry body was not byte-present after being appended to its theme file; aborting with the root untouched' err.crypto.ciphertext-too-short: short: ciphertext too short err.crypto.create-cipher: diff --git a/internal/config/disclosure/disclosure.go b/internal/config/disclosure/disclosure.go index b4aa59e0..a2d66093 100644 --- a/internal/config/disclosure/disclosure.go +++ b/internal/config/disclosure/disclosure.go @@ -37,6 +37,18 @@ const ( // target; a theme gist's link is the "(...)" following it. LinkOpen = "](" + // LinkLabelOpen is the "[" that begins a markdown link's label. + LinkLabelOpen = "[" + + // LinkClose is the ")" that ends a markdown link's target. + LinkClose = ")" + + // ThemeArrow separates a theme bullet's gist from its markdown link: + // "- name — gist → [name](noun/slug.md)". The mover's gist write-back + // renders it; parseThemeBullet locates the link via LinkOpen, so the + // arrow is cosmetic structure rather than a parse anchor. + ThemeArrow = " → " + // ThemeDirLearning, ThemeDirDecision, and ThemeDirConvention name the // per-kind subdirectories of the context directory that hold theme // files (.md), reachable only via the root's links. diff --git a/internal/config/embed/text/disclosure.go b/internal/config/embed/text/disclosure.go index 118d5554..37d92305 100644 --- a/internal/config/embed/text/disclosure.go +++ b/internal/config/embed/text/disclosure.go @@ -56,3 +56,25 @@ const ( // be parsed into discrete entries. DescKeyErrDisclosureStagingUnparsable = "err.disclosure.staging-unparsable" ) + +// DescKeys for the milestone-3 mover (the digesting pass that writes +// canonical files). Each value is the key of an entry in +// commands/text/errors.yaml; the bijection is enforced by +// internal/audit.TestDescKeyYAMLLinkage. +const ( + // DescKeyErrDisclosureApplyNotEntryKind: apply was invoked on a root + // whose kind the mover does not handle (convention/unknown). + DescKeyErrDisclosureApplyNotEntryKind = "err.disclosure.apply-not-entry-kind" + // DescKeyErrDisclosureEmptyAssignment: a plan assignment names no + // entries to move. + DescKeyErrDisclosureEmptyAssignment = "err.disclosure.empty-assignment" + // DescKeyErrDisclosureEntryAssignedTwice: a plan assigns the same + // entry to more than one theme. + DescKeyErrDisclosureEntryAssignedTwice = "err.disclosure.entry-assigned-twice" + // DescKeyErrDisclosureEntryNotInStaging: a plan references an entry + // that is not in the root's staging zone. + DescKeyErrDisclosureEntryNotInStaging = "err.disclosure.entry-not-in-staging" + // DescKeyErrDisclosureVerifyFailed: after appending an entry body to a + // theme file, the body was not byte-present on re-read. + DescKeyErrDisclosureVerifyFailed = "err.disclosure.verify-failed" +) diff --git a/internal/disclosure/apply.go b/internal/disclosure/apply.go new file mode 100644 index 00000000..bc172c2a --- /dev/null +++ b/internal/disclosure/apply.go @@ -0,0 +1,108 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package disclosure + +import ( + "path/filepath" + + cfgFile "github.com/ActiveMemory/ctx/internal/config/file" + cfgFs "github.com/ActiveMemory/ctx/internal/config/fs" + errDisc "github.com/ActiveMemory/ctx/internal/err/disclosure" + internalIo "github.com/ActiveMemory/ctx/internal/io" +) + +// Apply is the mover: it digests a plan's staged entries into per-theme +// files and folds their gists into the root's ## Themes, under the spec's +// guards (specs/progressive-disclosure.md §Guards). It is the first pass +// that writes a canonical knowledge file. +// +// Order is load-bearing: +// 1. Refuse a non-knowledge file, an un-digestible kind (conventions are +// a later milestone), or a malformed root (Validate) — before any +// write. +// 2. Compute the lossless staging split and the theme-file appends. +// 3. Append entry bodies to their theme files (additive first). +// 4. Verify byte-presence by re-reading each theme file. Any miss aborts +// with the root untouched. +// 5. Rewrite the root ONCE, last: remaining staging + folded gists. The +// root write is the final syscall, so any earlier failure leaves the +// root byte-identical (append→verify→remove; never remove-then-append). +// +// Fail-loud, no auto-repair. Worst-case crash duplicates a theme-file +// append (detectable, recoverable), never loses an entry. +// +// Parameters: +// - rootPath: path to the canonical knowledge file (LEARNINGS/DECISIONS) +// - plan: the digest plan (theme assignments + gists) +// - ctxDir: the context directory theme files are written under +// +// Returns: +// - ApplyResult: entries moved and theme slugs touched +// - error: a disclosure sentinel, a path-bearing IO error, or nil +func Apply(rootPath string, plan Plan, ctxDir string) (ApplyResult, error) { + kind, ok := KindFor(filepath.Base(rootPath)) + if !ok { + return ApplyResult{}, errDisc.NotAKnowledgeFile(rootPath) + } + noun, ok := ThemeDir(kind) + if !ok { + return ApplyResult{}, errDisc.ErrApplyNotEntryKind + } + + raw, readErr := internalIo.SafeReadUserFile(filepath.Clean(rootPath)) + if readErr != nil { + return ApplyResult{}, readErr + } + root := Parse(string(raw), kind) + if valErr := Validate(root); valErr != nil { + return ApplyResult{}, valErr + } + + moveIDs, planErr := FlattenPlan(plan) + if planErr != nil { + return ApplyResult{}, planErr + } + moved, remaining, splitErr := SplitStaging(root.Staging, moveIDs) + if splitErr != nil { + return ApplyResult{}, splitErr + } + + // Empty plan: idempotent no-op, root untouched. + if len(plan.Assignments) == 0 { + return ApplyResult{}, nil + } + + themeDir := filepath.Join(ctxDir, noun) + if mkErr := internalIo.SafeMkdirAll(themeDir, cfgFs.PermExec); mkErr != nil { + return ApplyResult{}, mkErr + } + + // 3. Additive first: append each theme's bodies to its file. + touched := make([]string, 0, len(plan.Assignments)) + for _, a := range plan.Assignments { + path := filepath.Join(themeDir, a.Slug+cfgFile.ExtMarkdown) + if appErr := appendTheme(path, a, moved); appErr != nil { + return ApplyResult{}, appErr + } + touched = append(touched, a.Slug) + } + + // 4. Verify byte-presence before touching the root. + if vErr := verifyThemes(themeDir, plan, moved); vErr != nil { + return ApplyResult{}, vErr + } + + // 5. Rewrite the root once, last. + newRoot := rewriteRoot(root, remaining, plan, noun) + if wErr := internalIo.SafeWriteFile( + rootPath, []byte(newRoot), cfgFs.PermFile, + ); wErr != nil { + return ApplyResult{}, wErr + } + + return ApplyResult{Moved: len(moveIDs), Themes: touched}, nil +} diff --git a/internal/disclosure/apply_abort_test.go b/internal/disclosure/apply_abort_test.go new file mode 100644 index 00000000..a2bcedac --- /dev/null +++ b/internal/disclosure/apply_abort_test.go @@ -0,0 +1,51 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package disclosure_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/ActiveMemory/ctx/internal/disclosure" +) + +// T06(b): when a theme append fails, Apply aborts before the root rewrite, +// so the root is byte-identical — the append→verify→remove ordering means +// the root write is the last syscall and never runs on a failed pass. +// The earlier theme append is allowed to remain (duplication is +// recoverable; loss is not — spec Guards §1,§3). +func TestApplyAbort_RootUntouched(t *testing.T) { + dir := t.TempDir() + // Make learnings/hooks.md a directory so the second append fails. + if err := os.MkdirAll(filepath.Join(dir, "learnings", "hooks.md"), 0o755); err != nil { + t.Fatal(err) + } + rootPath := writeRoot(t, dir, migratedRoot()) + before := readFile(t, rootPath) + + idA := idFor("2026-01-01-000000", "Alpha") + idB := idFor("2026-01-02-000000", "Beta") + plan := disclosure.Plan{ + Kind: "learning", + Assignments: []disclosure.Assignment{ + {Theme: "context", Slug: "context", Gist: "g", Entries: []string{idA}}, // succeeds + {Theme: "hooks", Slug: "hooks", Gist: "g", Entries: []string{idB}}, // append fails (dir) + }, + } + + if _, err := disclosure.Apply(rootPath, plan, dir); err == nil { + t.Fatal("Apply err = nil, want a theme-append failure") + } + if after := readFile(t, rootPath); after != before { + t.Errorf("root mutated on abort:\nbefore=%q\nafter =%q", before, after) + } + // The earlier (context) append did land — duplication over loss. + if _, err := os.Stat(filepath.Join(dir, "learnings", "context.md")); err != nil { + t.Errorf("earlier theme append missing: %v", err) + } +} diff --git a/internal/disclosure/apply_conserve_test.go b/internal/disclosure/apply_conserve_test.go new file mode 100644 index 00000000..18e73a07 --- /dev/null +++ b/internal/disclosure/apply_conserve_test.go @@ -0,0 +1,91 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package disclosure_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ActiveMemory/ctx/internal/disclosure" +) + +// T09: conservation — every entry staged before the pass is, after it, +// either still staged or present in exactly one theme file. Zero loss, +// zero duplication. +func TestApplyConservation(t *testing.T) { + dir := t.TempDir() + learnDir := filepath.Join(dir, "learnings") + if err := os.MkdirAll(learnDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(learnDir, "hooks.md"), + []byte("# hooks\n\nexisting\n"), 0o644); err != nil { + t.Fatal(err) + } + rootPath := writeRoot(t, dir, migratedRoot()) + + before := disclosure.Parse(readFile(t, rootPath), disclosure.KindLearning) + beforeStaged := stagedHeaders(before) + if len(beforeStaged) != 2 { + t.Fatalf("precondition: %d staged, want 2", len(beforeStaged)) + } + + idA := idFor("2026-01-01-000000", "Alpha") + idB := idFor("2026-01-02-000000", "Beta") + plan := disclosure.Plan{ + Kind: "learning", + Assignments: []disclosure.Assignment{ + {Theme: "context", Slug: "context", Gist: "g1", Entries: []string{idA}}, + {Theme: "hooks", Slug: "hooks", Gist: "g2", Entries: []string{idB}}, + }, + } + res, err := disclosure.Apply(rootPath, plan, dir) + if err != nil { + t.Fatalf("Apply err = %v", err) + } + + after := disclosure.Parse(readFile(t, rootPath), disclosure.KindLearning) + afterStaged := stagedHeaders(after) + + // staging_before == moved + staging_after (count). + if res.Moved+len(afterStaged) != len(beforeStaged) { + t.Errorf("moved(%d) + after(%d) != before(%d)", + res.Moved, len(afterStaged), len(beforeStaged)) + } + + // Each moved entry appears in exactly one theme file, and nowhere in + // staging. + files, _ := filepath.Glob(filepath.Join(learnDir, "*.md")) + for _, hdr := range []string{ + "## [2026-01-01-000000] Alpha", + "## [2026-01-02-000000] Beta", + } { + got := 0 + for _, f := range files { + if strings.Contains(readFile(t, f), hdr) { + got++ + } + } + if got != 1 { + t.Errorf("%q present in %d theme files, want exactly 1", hdr, got) + } + if strings.Contains(after.Staging, hdr) { + t.Errorf("%q still in staging after move", hdr) + } + } +} + +// stagedHeaders returns the staged entries' identities for a root. +func stagedHeaders(r disclosure.Root) []string { + var ids []string + for _, e := range disclosure.StagedEntries(r) { + ids = append(ids, e.Timestamp+"|"+e.Title) + } + return ids +} diff --git a/internal/disclosure/apply_firstrun_test.go b/internal/disclosure/apply_firstrun_test.go new file mode 100644 index 00000000..720f9551 --- /dev/null +++ b/internal/disclosure/apply_firstrun_test.go @@ -0,0 +1,71 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package disclosure_test + +import ( + "strings" + "testing" + + "github.com/ActiveMemory/ctx/internal/disclosure" +) + +// T07: on an un-migrated root (no ## Themes), Apply creates the section +// below the remaining staging, leaves un-moved entries staged, and the +// result validates. +func TestApplyFirstRun(t *testing.T) { + dir := t.TempDir() + rootPath := writeRoot(t, dir, learnPreamble+stagingTwo) // no ## Themes + + idA := idFor("2026-01-01-000000", "Alpha") + plan := disclosure.Plan{ + Kind: "learning", + Assignments: []disclosure.Assignment{ + {Theme: "context", Slug: "context", Gist: "context entries", Entries: []string{idA}}, + }, + } + + if _, err := disclosure.Apply(rootPath, plan, dir); err != nil { + t.Fatalf("Apply err = %v", err) + } + + got := readFile(t, rootPath) + if !strings.Contains(got, "## Themes") { + t.Errorf("themes section not created:\n%q", got) + } + // Beta remains staged; Alpha moved out. + if !strings.Contains(got, "## [2026-01-02-000000] Beta") { + t.Errorf("un-moved Beta missing from staging:\n%q", got) + } + if strings.Contains(got, "## [2026-01-01-000000] Alpha") { + t.Errorf("moved Alpha still in root:\n%q", got) + } + // The heading sits on its own line after a blank line. + if !strings.Contains(got, "\n\n## Themes\n") { + t.Errorf("themes boundary not blank-line separated:\n%q", got) + } + if err := disclosure.Validate(disclosure.Parse(got, disclosure.KindLearning)); err != nil { + t.Errorf("post-first-run Validate = %v", err) + } +} + +// T07: an empty plan is a no-op — the root is byte-identical. +func TestApplyIdempotent(t *testing.T) { + dir := t.TempDir() + rootPath := writeRoot(t, dir, migratedRoot()) + before := readFile(t, rootPath) + + res, err := disclosure.Apply(rootPath, disclosure.Plan{Kind: "learning"}, dir) + if err != nil { + t.Fatalf("Apply err = %v", err) + } + if res.Moved != 0 { + t.Errorf("Moved = %d, want 0", res.Moved) + } + if after := readFile(t, rootPath); after != before { + t.Errorf("empty plan mutated the root") + } +} diff --git a/internal/disclosure/apply_invariants_test.go b/internal/disclosure/apply_invariants_test.go new file mode 100644 index 00000000..dfb70ae2 --- /dev/null +++ b/internal/disclosure/apply_invariants_test.go @@ -0,0 +1,60 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package disclosure_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/ActiveMemory/ctx/internal/disclosure" +) + +// T08: after a successful Apply, all four disclosure invariants hold on +// the resulting root and its theme directory — Validate (structure), +// CheckPairing / CheckUniqueness (gists ↔ files 1:1, one place per +// entry), and CheckLinks (every link resolves). +func TestApplyInvariants(t *testing.T) { + dir := t.TempDir() + learnDir := filepath.Join(dir, "learnings") + if err := os.MkdirAll(learnDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(learnDir, "hooks.md"), + []byte("# hooks\n\nexisting\n"), 0o644); err != nil { + t.Fatal(err) + } + rootPath := writeRoot(t, dir, migratedRoot()) + + idA := idFor("2026-01-01-000000", "Alpha") + idB := idFor("2026-01-02-000000", "Beta") + plan := disclosure.Plan{ + Kind: "learning", + Assignments: []disclosure.Assignment{ + {Theme: "context", Slug: "context", Gist: "context entries", Entries: []string{idA}}, + {Theme: "hooks", Slug: "hooks", Gist: "hook mechanics", Entries: []string{idB}}, + }, + } + if _, err := disclosure.Apply(rootPath, plan, dir); err != nil { + t.Fatalf("Apply err = %v", err) + } + + root := disclosure.Parse(readFile(t, rootPath), disclosure.KindLearning) + + if err := disclosure.Validate(root); err != nil { + t.Errorf("Validate = %v", err) + } + if err := disclosure.CheckPairing(root, learnDir); err != nil { + t.Errorf("CheckPairing = %v", err) + } + if err := disclosure.CheckUniqueness(root, learnDir); err != nil { + t.Errorf("CheckUniqueness = %v", err) + } + if err := disclosure.CheckLinks(root, dir); err != nil { + t.Errorf("CheckLinks = %v", err) + } +} diff --git a/internal/disclosure/apply_test.go b/internal/disclosure/apply_test.go new file mode 100644 index 00000000..ea172309 --- /dev/null +++ b/internal/disclosure/apply_test.go @@ -0,0 +1,152 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package disclosure_test + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ActiveMemory/ctx/internal/disclosure" + errDisc "github.com/ActiveMemory/ctx/internal/err/disclosure" +) + +const learnPreamble = "# Learnings\n\n\n\n" + +// migratedRoot builds a LEARNINGS root with the two-entry staging and one +// existing "hooks" theme. +func migratedRoot() string { + return learnPreamble + stagingTwo + + "## Themes\n\n- hooks — old → [hooks](learnings/hooks.md)\n" +} + +// writeRoot writes content to /LEARNINGS.md and returns the path. +func writeRoot(t *testing.T, dir, content string) string { + t.Helper() + p := filepath.Join(dir, "LEARNINGS.md") + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatalf("write root: %v", err) + } + return p +} + +// T05: Apply moves entries to their theme files (creating and appending), +// folds gists, and rewrites the root with the moved entries gone. +func TestApply(t *testing.T) { + dir := t.TempDir() + // Pre-existing hooks theme file, so the append path is exercised. + if err := os.MkdirAll(filepath.Join(dir, "learnings"), 0o755); err != nil { + t.Fatal(err) + } + hooksPath := filepath.Join(dir, "learnings", "hooks.md") + if err := os.WriteFile(hooksPath, []byte("# hooks\n\nexisting\n"), 0o644); err != nil { + t.Fatal(err) + } + rootPath := writeRoot(t, dir, migratedRoot()) + + idA := idFor("2026-01-01-000000", "Alpha") + idB := idFor("2026-01-02-000000", "Beta") + plan := disclosure.Plan{ + Kind: "learning", + Assignments: []disclosure.Assignment{ + {Theme: "context", Slug: "context", Gist: "context entries", Entries: []string{idA}}, + {Theme: "hooks", Slug: "hooks", Gist: "hook mechanics", Entries: []string{idB}}, + }, + } + + res, err := disclosure.Apply(rootPath, plan, dir) + if err != nil { + t.Fatalf("Apply err = %v", err) + } + if res.Moved != 2 { + t.Errorf("Moved = %d, want 2", res.Moved) + } + if len(res.Themes) != 2 || res.Themes[0] != "context" || res.Themes[1] != "hooks" { + t.Errorf("Themes = %v, want [context hooks]", res.Themes) + } + + // context.md created with Alpha's body. + ctxBody := readFile(t, filepath.Join(dir, "learnings", "context.md")) + if !strings.Contains(ctxBody, spanAlpha) { + t.Errorf("context.md missing Alpha span:\n%q", ctxBody) + } + // hooks.md keeps its original content and gains Beta. + hooksBody := readFile(t, hooksPath) + if !strings.Contains(hooksBody, "existing") || !strings.Contains(hooksBody, spanBeta) { + t.Errorf("hooks.md = %q", hooksBody) + } + // Root: staging emptied, gists folded, still valid. + newRoot := readFile(t, rootPath) + if strings.Contains(newRoot, "## [2026-01-01-000000] Alpha") { + t.Errorf("Alpha still in root:\n%q", newRoot) + } + if !strings.Contains(newRoot, "- hooks — hook mechanics → [hooks](learnings/hooks.md)") { + t.Errorf("hooks gist not updated:\n%q", newRoot) + } + if !strings.Contains(newRoot, "- context — context entries → [context](learnings/context.md)") { + t.Errorf("context gist not added:\n%q", newRoot) + } + if vErr := disclosure.Validate(disclosure.Parse(newRoot, disclosure.KindLearning)); vErr != nil { + t.Errorf("post-apply Validate = %v", vErr) + } +} + +// T05: Apply refuses a non-knowledge file, an un-digestible kind, and a +// malformed root — each before any write. +func TestApply_Refusals(t *testing.T) { + dir := t.TempDir() + + t.Run("non-knowledge file", func(t *testing.T) { + p := filepath.Join(dir, "README.md") + if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + _, err := disclosure.Apply(p, disclosure.Plan{}, dir) + if !errors.Is(err, errDisc.ErrNotAKnowledgeFile) { + t.Errorf("err = %v, want ErrNotAKnowledgeFile", err) + } + }) + + t.Run("convention kind refused", func(t *testing.T) { + p := filepath.Join(dir, "CONVENTIONS.md") + if err := os.WriteFile(p, []byte("# Conventions\n"), 0o644); err != nil { + t.Fatal(err) + } + _, err := disclosure.Apply(p, disclosure.Plan{}, dir) + if err != errDisc.ErrApplyNotEntryKind { + t.Errorf("err = %v, want ErrApplyNotEntryKind", err) + } + }) + + t.Run("malformed root", func(t *testing.T) { + bad := learnPreamble + "## Themes\n\n## Themes\n" + p := writeRoot(t, dir, bad) + before := readFile(t, p) + _, err := disclosure.Apply(p, disclosure.Plan{ + Assignments: []disclosure.Assignment{ + {Theme: "t", Slug: "t", Gist: "g", Entries: []string{"x"}}, + }, + }, dir) + if err != errDisc.ErrMultipleThemes { + t.Errorf("err = %v, want ErrMultipleThemes", err) + } + if after := readFile(t, p); after != before { + t.Errorf("root mutated on refusal") + } + }) +} + +func readFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(data) +} diff --git a/internal/disclosure/apply_verify_internal_test.go b/internal/disclosure/apply_verify_internal_test.go new file mode 100644 index 00000000..cd029ea3 --- /dev/null +++ b/internal/disclosure/apply_verify_internal_test.go @@ -0,0 +1,26 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package disclosure + +import ( + "testing" + + errDisc "github.com/ActiveMemory/ctx/internal/err/disclosure" +) + +// T06(a): verifyContains is the byte-presence guard — present spans pass, +// an absent span returns ErrVerifyFailed (white-box: the predicate is +// unexported and only reachable via a genuine IO race in Apply). +func TestVerifyContains(t *testing.T) { + const file = "# hooks\n\n## [2026-01-01-000000] Alpha\n\nbody\n" + if err := verifyContains(file, "## [2026-01-01-000000] Alpha\n\nbody\n"); err != nil { + t.Errorf("present span: err = %v, want nil", err) + } + if err := verifyContains(file, "## [2026-09-09-090909] Ghost\n"); err != errDisc.ErrVerifyFailed { + t.Errorf("absent span: err = %v, want ErrVerifyFailed", err) + } +} diff --git a/internal/disclosure/inspect_test.go b/internal/disclosure/inspect_test.go index 6c4231b0..361c8e13 100644 --- a/internal/disclosure/inspect_test.go +++ b/internal/disclosure/inspect_test.go @@ -40,6 +40,34 @@ func TestKindFor(t *testing.T) { } } +// T01 (pd-m3): ThemeDir maps the digestible entry kinds to their theme +// subdirectory and refuses convention/unknown so the mover never writes +// to a guessed path. +func TestThemeDir(t *testing.T) { + cases := []struct { + name string + kind disclosure.Kind + wantOK bool + want string + }{ + {"learning", disclosure.KindLearning, true, "learnings"}, + {"decision", disclosure.KindDecision, true, "decisions"}, + {"convention", disclosure.KindConvention, false, ""}, + {"unknown", disclosure.Kind(99), false, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := disclosure.ThemeDir(tc.kind) + if ok != tc.wantOK { + t.Fatalf("ThemeDir(%v) ok = %v, want %v", tc.kind, ok, tc.wantOK) + } + if got != tc.want { + t.Errorf("ThemeDir(%v) = %q, want %q", tc.kind, got, tc.want) + } + }) + } +} + // T02: StagedEntries lists the staging zone's entries in order, and is // empty when staging holds none. func TestStagedEntries(t *testing.T) { diff --git a/internal/disclosure/kind.go b/internal/disclosure/kind.go index 6e4c738b..433067a1 100644 --- a/internal/disclosure/kind.go +++ b/internal/disclosure/kind.go @@ -8,6 +8,7 @@ package disclosure import ( cfgCtx "github.com/ActiveMemory/ctx/internal/config/ctx" + cfgDisc "github.com/ActiveMemory/ctx/internal/config/disclosure" cfgEntry "github.com/ActiveMemory/ctx/internal/config/entry" ) @@ -52,3 +53,25 @@ func (k Kind) String() string { return "" } } + +// ThemeDir returns the context-relative subdirectory that holds a kind's +// theme files (/.md). It is false for the convention kind — +// whose digestion is a later milestone — and for an unknown kind, so the +// mover refuses rather than write entry bodies to a guessed path. +// +// Parameters: +// - k: the root kind +// +// Returns: +// - string: the theme-file subdirectory (meaningful only when ok) +// - bool: true for the entry kinds (learning, decision) the mover digests +func ThemeDir(k Kind) (string, bool) { + switch k { + case KindLearning: + return cfgDisc.ThemeDirLearning, true + case KindDecision: + return cfgDisc.ThemeDirDecision, true + default: + return "", false + } +} diff --git a/internal/disclosure/movehelpers.go b/internal/disclosure/movehelpers.go new file mode 100644 index 00000000..a8653b5f --- /dev/null +++ b/internal/disclosure/movehelpers.go @@ -0,0 +1,194 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package disclosure + +import ( + "os" + "path/filepath" + "strings" + + cfgDisc "github.com/ActiveMemory/ctx/internal/config/disclosure" + cfgFile "github.com/ActiveMemory/ctx/internal/config/file" + cfgFs "github.com/ActiveMemory/ctx/internal/config/fs" + "github.com/ActiveMemory/ctx/internal/config/token" + errDisc "github.com/ActiveMemory/ctx/internal/err/disclosure" + internalIo "github.com/ActiveMemory/ctx/internal/io" +) + +// appendTheme appends an assignment's moved entry spans to its theme +// file, creating the file (with an H1 header) when absent and separating +// with a blank line when appending. Spans are written verbatim so the +// verify step's byte-presence check holds. +// +// Parameters: +// - path: the theme file to write +// - a: the assignment whose entries are appended +// - moved: id -> verbatim span, from SplitStaging +// +// Returns: +// - error: an IO error, or nil +func appendTheme(path string, a Assignment, moved map[string]string) error { + var spans strings.Builder + for _, id := range a.Entries { + spans.WriteString(moved[id]) + } + + var payload string + if _, statErr := os.Stat(path); os.IsNotExist(statErr) { + payload = token.HeadingLevelOneStart + a.Theme + + token.NewlineLF + token.NewlineLF + spans.String() + } else { + payload = token.NewlineLF + spans.String() + } + return internalIo.AppendBytes(path, []byte(payload), cfgFs.PermFile) +} + +// verifyThemes re-reads each written theme file and confirms every moved +// entry body is byte-present. It runs after all appends and before the +// root rewrite, so a miss aborts with the root untouched. +// +// Parameters: +// - themeDir: the directory holding this kind's theme files +// - plan: the digest plan whose assignments were appended +// - moved: id -> verbatim span, from SplitStaging +// +// Returns: +// - error: a read error, ErrVerifyFailed, or nil +func verifyThemes(themeDir string, plan Plan, moved map[string]string) error { + for _, a := range plan.Assignments { + path := filepath.Join(themeDir, a.Slug+cfgFile.ExtMarkdown) + content, readErr := internalIo.SafeReadUserFile(path) + if readErr != nil { + return readErr + } + for _, id := range a.Entries { + if vErr := verifyContains(string(content), moved[id]); vErr != nil { + return vErr + } + } + } + return nil +} + +// verifyContains is the byte-presence guard: it confirms a moved entry +// body is present in its theme file after the append. Absence means the +// append did not land intact, so the pass must abort with the root +// untouched rather than remove the entry from staging. +// +// Parameters: +// - fileContent: the theme file re-read after appending +// - span: the verbatim entry span that should be present +// +// Returns: +// - error: ErrVerifyFailed when span is absent, else nil +func verifyContains(fileContent, span string) error { + if !strings.Contains(fileContent, span) { + return errDisc.ErrVerifyFailed + } + return nil +} + +// rewriteRoot assembles the post-move root: the preamble, the remaining +// staging, and the ## Themes region with every assignment's gist folded +// in. On first-run migration it inserts a blank-line boundary before the +// freshly created ## Themes section. +// +// Parameters: +// - root: the parsed root before the move +// - remaining: the staging with moved entries cut out +// - plan: the digest plan whose gists to fold +// - noun: the theme-file subdirectory for this kind +// +// Returns: +// - string: the rewritten root content +func rewriteRoot(root Root, remaining string, plan Plan, noun string) string { + newThemes := root.ThemesRaw + for _, a := range plan.Assignments { + newThemes = WriteThemeBullet(newThemes, a, noun) + } + sep := "" + if !root.HasThemes { + sep = freshThemesBoundary(remaining) + } + return root.Preamble + remaining + sep + newThemes +} + +// freshThemesBoundary returns the separator to place between the +// remaining staging and a newly created ## Themes section, so the heading +// sits on its own line after a blank line. It is used only on first-run +// migration (an un-migrated root has no existing boundary to preserve). +// +// Parameters: +// - remaining: the staging that will precede the new ## Themes section +// +// Returns: +// - string: empty, one newline, or two, so the boundary ends in a +// blank line before the heading +func freshThemesBoundary(remaining string) string { + blank := token.NewlineLF + token.NewlineLF + switch { + case remaining == "" || strings.HasSuffix(remaining, blank): + return "" + case strings.HasSuffix(remaining, token.NewlineLF): + return token.NewlineLF + default: + return blank + } +} + +// renderThemeBullet renders one theme bullet line (no trailing newline), +// in the exact shape parseThemeBullet reads back: +// "- → [](/.md)". +// +// Parameters: +// - a: the assignment supplying theme name, gist, and slug +// - noun: the theme-file subdirectory for this kind +// +// Returns: +// - string: the rendered bullet line +func renderThemeBullet(a Assignment, noun string) string { + link := noun + token.Slash + a.Slug + cfgFile.ExtMarkdown + return token.PrefixListDash + a.Theme + token.MetaSeparator + a.Gist + + cfgDisc.ThemeArrow + cfgDisc.LinkLabelOpen + a.Theme + + cfgDisc.LinkOpen + link + cfgDisc.LinkClose +} + +// lineByteOffsets returns the byte offset of the start of each line in +// content, with a trailing sentinel offset. Index i is the byte offset +// where line i begins; the value can overshoot len(content) for the +// synthetic final element, so callers clamp with clampOffset. +// +// Parameters: +// - content: the text to index +// +// Returns: +// - []int: byte offsets per line, plus one trailing sentinel +func lineByteOffsets(content string) []int { + lines := strings.Split(content, token.NewlineLF) + offs := make([]int, len(lines)+1) + for i, ln := range lines { + offs[i+1] = offs[i] + len(ln) + len(token.NewlineLF) + } + return offs +} + +// clampOffset bounds a byte offset to [0, n]. The final line offset from +// lineByteOffsets overshoots by one newline when content has no trailing +// newline; clamping keeps slice bounds valid without special-casing. +// +// Parameters: +// - off: a candidate byte offset +// - n: the length to clamp to +// +// Returns: +// - int: off bounded to [0, n] +func clampOffset(off, n int) int { + if off > n { + return n + } + return off +} diff --git a/internal/disclosure/split.go b/internal/disclosure/split.go new file mode 100644 index 00000000..df5c90ce --- /dev/null +++ b/internal/disclosure/split.go @@ -0,0 +1,107 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package disclosure + +import ( + "strings" + + cfgDisc "github.com/ActiveMemory/ctx/internal/config/disclosure" + errDisc "github.com/ActiveMemory/ctx/internal/err/disclosure" + "github.com/ActiveMemory/ctx/internal/heading" +) + +// FlattenPlan concatenates a plan's per-theme entry IDs into one ordered +// move list, in assignment order. An assignment that names no entries is +// a malformed plan (a theme with nothing to receive), not a no-op. +// +// Parameters: +// - p: the digest plan +// +// Returns: +// - []string: the entry IDs to move, in assignment order +// - error: ErrEmptyAssignment when an assignment has no entries +func FlattenPlan(p Plan) ([]string, error) { + var ids []string + for _, a := range p.Assignments { + if len(a.Entries) == 0 { + return nil, errDisc.ErrEmptyAssignment + } + ids = append(ids, a.Entries...) + } + return ids, nil +} + +// SplitStaging removes the entries named by moveIDs from a raw staging +// zone and returns each moved entry's verbatim span alongside the +// remaining staging, byte-exact. +// +// It cuts on raw header-to-next-header byte spans rather than reusing +// heading.ParseEntryBlocks' BlockContent — that helper trims trailing +// blank lines, so re-joining parsed blocks would silently drop the +// inter-entry whitespace. ParseEntryBlocks is used only to identify the +// entries and their order; the spans are cut from the original bytes so +// Reconstruct-grade fidelity holds (spec Guards §1, Conservation). +// +// Parameters: +// - staging: the root's raw staging zone (starts at the first "## [") +// - moveIDs: entry IDs (timestamp+IDSeparator+title) to move out +// +// Returns: +// - map[string]string: moved id -> its verbatim span +// - string: the remaining staging (un-moved entries, byte-exact) +// - error: ErrEntryAssignedTwice (dup id), ErrEntryNotInStaging (unknown) +func SplitStaging( + staging string, moveIDs []string, +) (map[string]string, string, error) { + want := make(map[string]bool, len(moveIDs)) + for _, id := range moveIDs { + if want[id] { + return nil, "", errDisc.ErrEntryAssignedTwice + } + want[id] = true + } + + blocks := heading.ParseEntryBlocks(staging) + byteOff := lineByteOffsets(staging) + + moved := make(map[string]string, len(moveIDs)) + seen := make(map[string]bool, len(moveIDs)) + // Removal spans, collected in increasing byte order (blocks are sorted). + type span struct{ start, end int } + var removals []span + + for i, b := range blocks { + id := b.Entry.Timestamp + cfgDisc.IDSeparator + b.Entry.Title + if !want[id] || seen[id] { + continue + } + endLine := len(byteOff) - 1 + if i+1 < len(blocks) { + endLine = blocks[i+1].StartIndex + } + start := clampOffset(byteOff[b.StartIndex], len(staging)) + end := clampOffset(byteOff[endLine], len(staging)) + moved[id] = staging[start:end] + removals = append(removals, span{start, end}) + seen[id] = true + } + + for _, id := range moveIDs { + if !seen[id] { + return nil, "", errDisc.ErrEntryNotInStaging + } + } + + var sb strings.Builder + prev := 0 + for _, r := range removals { + sb.WriteString(staging[prev:r.start]) + prev = r.end + } + sb.WriteString(staging[prev:]) + return moved, sb.String(), nil +} diff --git a/internal/disclosure/split_test.go b/internal/disclosure/split_test.go new file mode 100644 index 00000000..2b22139f --- /dev/null +++ b/internal/disclosure/split_test.go @@ -0,0 +1,138 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package disclosure_test + +import ( + "testing" + + cfgDisc "github.com/ActiveMemory/ctx/internal/config/disclosure" + "github.com/ActiveMemory/ctx/internal/disclosure" + errDisc "github.com/ActiveMemory/ctx/internal/err/disclosure" +) + +// Two-entry staging with a "---" separator and blank lines between +// entries — exactly the whitespace ParseEntryBlocks would trim. +const stagingTwo = "## [2026-01-01-000000] Alpha\n\n**Context**: a.\n\n---\n\n" + + "## [2026-01-02-000000] Beta\n\n**Context**: b.\n" + +const ( + spanAlpha = "## [2026-01-01-000000] Alpha\n\n**Context**: a.\n\n---\n\n" + spanBeta = "## [2026-01-02-000000] Beta\n\n**Context**: b.\n" +) + +func idFor(ts, title string) string { + return ts + cfgDisc.IDSeparator + title +} + +// T02: the byte-cut is lossless — a moved entry's span is verbatim +// (including the trailing blank lines and "---" separator that +// ParseEntryBlocks trims), and the remaining staging is byte-exact. +func TestSplitStaging(t *testing.T) { + idA := idFor("2026-01-01-000000", "Alpha") + idB := idFor("2026-01-02-000000", "Beta") + + t.Run("move first", func(t *testing.T) { + moved, remaining, err := disclosure.SplitStaging(stagingTwo, []string{idA}) + if err != nil { + t.Fatalf("SplitStaging err = %v", err) + } + if moved[idA] != spanAlpha { + t.Errorf("moved[idA] = %q, want %q", moved[idA], spanAlpha) + } + if remaining != spanBeta { + t.Errorf("remaining = %q, want %q", remaining, spanBeta) + } + }) + + t.Run("move second", func(t *testing.T) { + moved, remaining, err := disclosure.SplitStaging(stagingTwo, []string{idB}) + if err != nil { + t.Fatalf("SplitStaging err = %v", err) + } + if moved[idB] != spanBeta { + t.Errorf("moved[idB] = %q, want %q", moved[idB], spanBeta) + } + if remaining != spanAlpha { + t.Errorf("remaining = %q, want %q", remaining, spanAlpha) + } + }) + + t.Run("move both leaves empty staging", func(t *testing.T) { + moved, remaining, err := disclosure.SplitStaging(stagingTwo, []string{idA, idB}) + if err != nil { + t.Fatalf("SplitStaging err = %v", err) + } + if len(moved) != 2 { + t.Errorf("moved has %d entries, want 2", len(moved)) + } + if remaining != "" { + t.Errorf("remaining = %q, want empty", remaining) + } + }) + + t.Run("move none is identity", func(t *testing.T) { + moved, remaining, err := disclosure.SplitStaging(stagingTwo, nil) + if err != nil { + t.Fatalf("SplitStaging err = %v", err) + } + if len(moved) != 0 { + t.Errorf("moved has %d entries, want 0", len(moved)) + } + if remaining != stagingTwo { + t.Errorf("remaining = %q, want the input unchanged", remaining) + } + }) + + t.Run("unknown id", func(t *testing.T) { + _, _, err := disclosure.SplitStaging(stagingTwo, + []string{idFor("2026-09-09-090909", "Ghost")}) + if err != errDisc.ErrEntryNotInStaging { + t.Errorf("err = %v, want ErrEntryNotInStaging", err) + } + }) + + t.Run("duplicate id", func(t *testing.T) { + _, _, err := disclosure.SplitStaging(stagingTwo, []string{idA, idA}) + if err != errDisc.ErrEntryAssignedTwice { + t.Errorf("err = %v, want ErrEntryAssignedTwice", err) + } + }) +} + +// T02: FlattenPlan concatenates entry IDs in assignment order and refuses +// an assignment that moves nothing. +func TestFlattenPlan(t *testing.T) { + idA := idFor("2026-01-01-000000", "Alpha") + idB := idFor("2026-01-02-000000", "Beta") + + t.Run("ordered concat", func(t *testing.T) { + ids, err := disclosure.FlattenPlan(disclosure.Plan{ + Kind: "learning", + Assignments: []disclosure.Assignment{ + {Theme: "t1", Slug: "t1", Gist: "g1", Entries: []string{idA}}, + {Theme: "t2", Slug: "t2", Gist: "g2", Entries: []string{idB}}, + }, + }) + if err != nil { + t.Fatalf("FlattenPlan err = %v", err) + } + if len(ids) != 2 || ids[0] != idA || ids[1] != idB { + t.Errorf("ids = %v, want [%s %s]", ids, idA, idB) + } + }) + + t.Run("empty assignment", func(t *testing.T) { + _, err := disclosure.FlattenPlan(disclosure.Plan{ + Assignments: []disclosure.Assignment{ + {Theme: "t1", Slug: "t1", Gist: "g1", Entries: nil}, + }, + }) + if err != errDisc.ErrEmptyAssignment { + t.Errorf("err = %v, want ErrEmptyAssignment", err) + } + }) +} diff --git a/internal/disclosure/themes.go b/internal/disclosure/themes.go new file mode 100644 index 00000000..7e53ccf1 --- /dev/null +++ b/internal/disclosure/themes.go @@ -0,0 +1,71 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package disclosure + +import ( + "strings" + + cfgDisc "github.com/ActiveMemory/ctx/internal/config/disclosure" + "github.com/ActiveMemory/ctx/internal/config/token" +) + +// WriteThemeBullet folds one assignment's gist into a root's raw ## Themes +// region and returns the updated region. It edits raw bullet lines rather +// than re-rendering from parsed Theme values: parseThemeBullet folds the +// "→ [link]" tail into the parsed gist, so re-rendering would corrupt it, +// and the spec requires untouched themes be left alone — so every bullet +// but the one for a.Theme is byte-preserved. +// +// Behaviour: +// - empty region (un-migrated root): creates "## Themes" with the bullet. +// - a.Theme already has a bullet: that line is replaced in place. +// - a.Theme is new: the bullet is appended after the last existing one +// (or after the heading when there are none). +// +// The bullet renders as "- → [](/.md)". +// +// Parameters: +// - themesRaw: the root's raw ## Themes region (may be empty) +// - a: the assignment whose gist to write +// - noun: the theme-file subdirectory for this kind (e.g. "learnings") +// +// Returns: +// - string: the updated ## Themes region +func WriteThemeBullet(themesRaw string, a Assignment, noun string) string { + bullet := renderThemeBullet(a, noun) + + if strings.TrimSpace(themesRaw) == "" { + return cfgDisc.HeadingThemes + token.NewlineLF + token.NewlineLF + + bullet + token.NewlineLF + } + + lines := strings.Split(themesRaw, token.NewlineLF) + lastBullet := -1 + for i, ln := range lines { + trimmed := strings.TrimSpace(ln) + if !strings.HasPrefix(trimmed, token.PrefixListDash) { + continue + } + lastBullet = i + body := strings.TrimPrefix(trimmed, token.PrefixListDash) + if parseThemeBullet(body).Name == a.Theme { + lines[i] = bullet + return strings.Join(lines, token.NewlineLF) + } + } + + if lastBullet == -1 { + trimmed := strings.TrimRight(themesRaw, token.NewlineLF) + return trimmed + token.NewlineLF + token.NewlineLF + bullet + token.NewlineLF + } + + out := make([]string, 0, len(lines)+1) + out = append(out, lines[:lastBullet+1]...) + out = append(out, bullet) + out = append(out, lines[lastBullet+1:]...) + return strings.Join(out, token.NewlineLF) +} diff --git a/internal/disclosure/themes_test.go b/internal/disclosure/themes_test.go new file mode 100644 index 00000000..855968c8 --- /dev/null +++ b/internal/disclosure/themes_test.go @@ -0,0 +1,76 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package disclosure_test + +import ( + "strings" + "testing" + + "github.com/ActiveMemory/ctx/internal/disclosure" +) + +const themesTwo = "## Themes\n\n" + + "- hooks — old hooks gist → [hooks](learnings/hooks.md)\n" + + "- errors — error gist → [errors](learnings/errors.md)\n" + +// T03: a new theme appends a bullet the parser reads back (name + link), +// on both an empty and a populated ## Themes region. +func TestWriteThemeBullet_New(t *testing.T) { + a := disclosure.Assignment{Theme: "parsing", Slug: "parsing", Gist: "how the parser splits roots"} + + t.Run("first run creates the section", func(t *testing.T) { + got := disclosure.WriteThemeBullet("", a, "learnings") + want := "## Themes\n\n- parsing — how the parser splits roots → " + + "[parsing](learnings/parsing.md)\n" + if got != want { + t.Fatalf("WriteThemeBullet(empty)\n got: %q\nwant: %q", got, want) + } + // Parses back to the same name + link. + insp := disclosure.Inspect( + "# Learnings\n\n"+got, disclosure.KindLearning) + if len(insp.Themes) != 1 || + insp.Themes[0].Name != "parsing" || + insp.Themes[0].Link != "learnings/parsing.md" { + t.Errorf("round-trip themes = %+v", insp.Themes) + } + }) + + t.Run("append leaves existing bullets byte-identical", func(t *testing.T) { + got := disclosure.WriteThemeBullet(themesTwo, a, "learnings") + if !strings.Contains(got, "- hooks — old hooks gist → [hooks](learnings/hooks.md)\n") { + t.Errorf("hooks bullet not byte-preserved:\n%q", got) + } + if !strings.Contains(got, "- errors — error gist → [errors](learnings/errors.md)\n") { + t.Errorf("errors bullet not byte-preserved:\n%q", got) + } + if !strings.Contains(got, "- parsing — how the parser splits roots → [parsing](learnings/parsing.md)") { + t.Errorf("new bullet missing:\n%q", got) + } + }) +} + +// T03: re-touching an existing theme replaces its bullet in place and +// leaves the other bullets byte-identical. +func TestWriteThemeBullet_Update(t *testing.T) { + a := disclosure.Assignment{Theme: "hooks", Slug: "hooks", Gist: "NEW hooks coverage"} + got := disclosure.WriteThemeBullet(themesTwo, a, "learnings") + + if strings.Contains(got, "old hooks gist") { + t.Errorf("old hooks gist not replaced:\n%q", got) + } + if !strings.Contains(got, "- hooks — NEW hooks coverage → [hooks](learnings/hooks.md)") { + t.Errorf("updated hooks bullet missing:\n%q", got) + } + // The errors bullet is untouched. + if !strings.Contains(got, "- errors — error gist → [errors](learnings/errors.md)\n") { + t.Errorf("errors bullet not byte-preserved:\n%q", got) + } + // Still exactly two bullets. + if n := strings.Count(got, "\n- "); n != 2 { + t.Errorf("bullet count = %d, want 2:\n%q", n, got) + } +} diff --git a/internal/disclosure/types.go b/internal/disclosure/types.go index 83ca0014..28bf98f7 100644 --- a/internal/disclosure/types.go +++ b/internal/disclosure/types.go @@ -88,3 +88,42 @@ type Inspection struct { Staging []StagedEntry `json:"staging"` Themes []Theme `json:"themes"` } + +// Plan is the digest plan the ctx-digest skill authors and Apply +// executes: per target theme, which staged entries move there and the +// gist to write back. Entry identity is timestamp+title (joined by +// cfgDisc.IDSeparator), matching entryIDs and CheckUniqueness. +// +// Fields: +// - Kind: the root's kind name ("learning" | "decision") +// - Assignments: one per target theme; together they partition the +// entries the pass moves out of staging +type Plan struct { + Kind string `json:"kind"` + Assignments []Assignment `json:"assignments"` +} + +// Assignment moves a set of staged entries into one theme and (re)writes +// that theme's gist bullet in the root's ## Themes. +// +// Fields: +// - Theme: the theme's name — bullet label, and heading on first use +// - Slug: the theme-file basename stem, resolved to /.md +// - Gist: the authored one-line gist (spec ### Gist format) +// - Entries: entry IDs to move here, in file order +type Assignment struct { + Theme string `json:"theme"` + Slug string `json:"slug"` + Gist string `json:"gist"` + Entries []string `json:"entries"` +} + +// ApplyResult reports what a successful Apply did, for CLI output. +// +// Fields: +// - Moved: the number of entries moved out of staging +// - Themes: the theme slugs created or appended, in plan order +type ApplyResult struct { + Moved int `json:"moved"` + Themes []string `json:"themes"` +} diff --git a/internal/err/disclosure/disclosure.go b/internal/err/disclosure/disclosure.go index fd2e56fa..6eaa93c4 100644 --- a/internal/err/disclosure/disclosure.go +++ b/internal/err/disclosure/disclosure.go @@ -66,6 +66,43 @@ const ( ) ) +// Mover sentinels (milestone 3): the digesting pass that appends entry +// bodies to theme files, verifies byte-presence, and only then removes +// them from staging. All fail-loud with no auto-repair. +const ( + // ErrApplyNotEntryKind: the mover was handed a root whose kind it does + // not digest — conventions (a later milestone) or an unknown kind. It + // refuses rather than write to a guessed path. + ErrApplyNotEntryKind = entity.Sentinel( + text.DescKeyErrDisclosureApplyNotEntryKind, + ) + + // ErrEmptyAssignment: a plan assignment names no entries to move. A + // theme with nothing to receive is a malformed plan, not a no-op. + ErrEmptyAssignment = entity.Sentinel( + text.DescKeyErrDisclosureEmptyAssignment, + ) + + // ErrEntryAssignedTwice: a plan assigns the same staged entry to more + // than one theme — its destination is ambiguous. + ErrEntryAssignedTwice = entity.Sentinel( + text.DescKeyErrDisclosureEntryAssignedTwice, + ) + + // ErrEntryNotInStaging: a plan references an entry id absent from the + // root's staging zone — the plan and the root disagree. + ErrEntryNotInStaging = entity.Sentinel( + text.DescKeyErrDisclosureEntryNotInStaging, + ) + + // ErrVerifyFailed: after appending an entry body to its theme file, + // the body was not byte-present on re-read. The pass aborts with the + // root untouched (append→verify→remove; never remove-then-append). + ErrVerifyFailed = entity.Sentinel( + text.DescKeyErrDisclosureVerifyFailed, + ) +) + // NotAKnowledgeFile wraps [ErrNotAKnowledgeFile] with the offending path // and the expected filenames. // diff --git a/internal/err/disclosure/doc.go b/internal/err/disclosure/doc.go index 6a85a411..d6e51890 100644 --- a/internal/err/disclosure/doc.go +++ b/internal/err/disclosure/doc.go @@ -16,20 +16,25 @@ // errors.Is against the value returned by [Validate] and the invariant // checks in internal/disclosure. // -// They fall into two groups: +// They fall into three groups: // // - **Structure** ([ErrMultipleThemes], [ErrEntryBelowThemes], // [ErrStagingUnparsable]): the precondition refused a malformed root. // - **Cross-file** ([ErrOrphanThemeFile], [ErrMissingThemeFile], // [ErrDuplicateEntry], [ErrBrokenThemeLink]): the root ↔ theme-file // link graph or the one-place-per-entry invariant is broken. +// - **Mover** ([ErrApplyNotEntryKind], [ErrEmptyAssignment], +// [ErrEntryAssignedTwice], [ErrEntryNotInStaging], [ErrVerifyFailed]): +// the milestone-3 digesting pass refused a malformed plan, was handed +// an unsupported kind, or aborted because a moved body was not +// byte-present after its theme-file append. // // # Wrapping strategy // -// Milestone 1 returns these bare — the guard identity is what the checks -// assert. Parameterized wrappers that name the specific theme file or -// entry are deferred to the digesting pass (a later milestone), which is -// where the message reaches an operator. +// The structure and cross-file guards are returned bare — the guard +// identity is what the checks assert. The mover sentinels are likewise +// bare; the operator-facing message is resolved from errors.yaml at the +// CLI boundary. // // # Concurrency // diff --git a/specs/plans/pd-m3.md b/specs/plans/pd-m3.md index 2d5f3f94..a73797ca 100644 --- a/specs/plans/pd-m3.md +++ b/specs/plans/pd-m3.md @@ -185,15 +185,15 @@ No `ctx agent` change (spec Non-Goals). No `add`-path change. | id | st | task | deps | files | [P] | acceptance criterion | spec ref | |---|---|---|---|---|---|---|---| -| T01 | [ ] | `Plan`/`Assignment`/`ApplyResult` types; `ThemeDir(k)`; `ThemeArrow` const | — | `internal/disclosure/types.go`, `apply.go`, `internal/config/disclosure/disclosure.go`, `kind_test.go` | [P] | `go test ./internal/disclosure/ -run TestThemeDir`: learning/decision → dir,true; convention/unknown → _,false | Data model | -| T02 | [ ] | `SplitStaging` lossless byte-cut + plan-vs-staging validation (uses T04 sentinels) | T01,T04 | `internal/disclosure/split.go`, `split_test.go` | | `-run TestSplitStaging`: remaining byte-exact for un-moved incl. separators; moved spans verbatim; unknown id → `ErrEntryNotInStaging`; dup id → `ErrEntryAssignedTwice`; empty entries → `ErrEmptyAssignment` | Contracts, Guards §1 | -| T03 | [ ] | `WriteThemeBullet` gist write-back over raw `## Themes` | T01 | `internal/disclosure/themes.go`, `themes_test.go` | [P] | `-run TestWriteThemeBullet`: new theme appends a bullet `parseThemeBullet` re-parses (name+link match); touched theme's line replaced, other lines byte-preserved; `!hadThemes` creates the heading | Contracts, Design step 4 | -| T04 | [ ] | all new error sentinels + YAML descriptions: `ErrEntryNotInStaging`, `ErrEntryAssignedTwice`, `ErrEmptyAssignment`, `ErrVerifyFailed`, `ErrApplyNotEntryKind` | — | `internal/err/disclosure/*.go`, `commands/text/errors.yaml` | [P] | `go test ./internal/audit/ -run TestDescKeyYAMLLinkage` green (bijection holds); all five sentinels defined | CONVENTIONS | -| T05 | [ ] | `Apply` IO mover — validate→append→verify→rewrite; kind/precondition refusal | T02,T03,T04 | `internal/disclosure/apply.go`, `apply_test.go` | | `-run TestApply` (temp dir): moves entries; theme files hold bodies; root = preamble+remaining+themes; `ApplyResult{Moved,Themes}` correct; `CONVENTIONS.md` → `ErrApplyNotEntryKind`; two-themes root → `ErrMultipleThemes`, no write | Contracts, Guards | -| T06 | [ ] | abort/ordering guarantee — root untouched on failure | T05 | `internal/disclosure/apply_abort_test.go` | [P] | `-run TestApplyAbort`: (a) `verifyContains` absent span → `ErrVerifyFailed`; (b) force last theme append to fail → error, root file byte-identical, earlier theme append present | Guards §1,§3 | -| T07 | [ ] | first-run `## Themes` creation + idempotency | T05 | `internal/disclosure/apply_firstrun_test.go` | [P] | `-run TestApplyFirstRun`: un-migrated root gets `## Themes` below staging, `Validate` nil; `-run TestApplyIdempotent`: empty plan → root byte-identical | Design step 5 | -| T08 | [ ] | post-apply invariants hold | T05 | `internal/disclosure/apply_invariants_test.go` | [P] | `-run TestApplyInvariants`: after Apply on a fixture, `Validate`+`CheckPairing`+`CheckUniqueness`+`CheckLinks` all return nil | Invariants | -| T09 | [ ] | conservation property test | T05 | `internal/disclosure/apply_conserve_test.go` | [P] | `-run TestApplyConservation`: `entryIDs(before)` == disjoint union of moved ids and `entryIDs(after)`; each moved id present in exactly one theme file; zero loss/dup | Tests (Conservation) | +| T01 | [x] | `Plan`/`Assignment`/`ApplyResult` types; `ThemeDir(k)`; `ThemeArrow` const | — | `internal/disclosure/types.go`, `apply.go`, `internal/config/disclosure/disclosure.go`, `kind_test.go` | [P] | `go test ./internal/disclosure/ -run TestThemeDir`: learning/decision → dir,true; convention/unknown → _,false | Data model | +| T02 | [x] | `SplitStaging` lossless byte-cut + plan-vs-staging validation (uses T04 sentinels) | T01,T04 | `internal/disclosure/split.go`, `split_test.go` | | `-run TestSplitStaging`: remaining byte-exact for un-moved incl. separators; moved spans verbatim; unknown id → `ErrEntryNotInStaging`; dup id → `ErrEntryAssignedTwice`; empty entries → `ErrEmptyAssignment` | Contracts, Guards §1 | +| T03 | [x] | `WriteThemeBullet` gist write-back over raw `## Themes` | T01 | `internal/disclosure/themes.go`, `themes_test.go` | [P] | `-run TestWriteThemeBullet`: new theme appends a bullet `parseThemeBullet` re-parses (name+link match); touched theme's line replaced, other lines byte-preserved; `!hadThemes` creates the heading | Contracts, Design step 4 | +| T04 | [x] | all new error sentinels + YAML descriptions: `ErrEntryNotInStaging`, `ErrEntryAssignedTwice`, `ErrEmptyAssignment`, `ErrVerifyFailed`, `ErrApplyNotEntryKind` | — | `internal/err/disclosure/*.go`, `commands/text/errors.yaml` | [P] | `go test ./internal/audit/ -run TestDescKeyYAMLLinkage` green (bijection holds); all five sentinels defined | CONVENTIONS | +| T05 | [x] | `Apply` IO mover — validate→append→verify→rewrite; kind/precondition refusal | T02,T03,T04 | `internal/disclosure/apply.go`, `apply_test.go` | | `-run TestApply` (temp dir): moves entries; theme files hold bodies; root = preamble+remaining+themes; `ApplyResult{Moved,Themes}` correct; `CONVENTIONS.md` → `ErrApplyNotEntryKind`; two-themes root → `ErrMultipleThemes`, no write | Contracts, Guards | +| T06 | [x] | abort/ordering guarantee — root untouched on failure | T05 | `internal/disclosure/apply_abort_test.go` | [P] | `-run TestApplyAbort`: (a) `verifyContains` absent span → `ErrVerifyFailed`; (b) force last theme append to fail → error, root file byte-identical, earlier theme append present | Guards §1,§3 | +| T07 | [x] | first-run `## Themes` creation + idempotency | T05 | `internal/disclosure/apply_firstrun_test.go` | [P] | `-run TestApplyFirstRun`: un-migrated root gets `## Themes` below staging, `Validate` nil; `-run TestApplyIdempotent`: empty plan → root byte-identical | Design step 5 | +| T08 | [x] | post-apply invariants hold | T05 | `internal/disclosure/apply_invariants_test.go` | [P] | `-run TestApplyInvariants`: after Apply on a fixture, `Validate`+`CheckPairing`+`CheckUniqueness`+`CheckLinks` all return nil | Invariants | +| T09 | [x] | conservation property test | T05 | `internal/disclosure/apply_conserve_test.go` | [P] | `-run TestApplyConservation`: `entryIDs(before)` == disjoint union of moved ids and `entryIDs(after)`; each moved id present in exactly one theme file; zero loss/dup | Tests (Conservation) | | T10 | [ ] | `ctx disclosure apply` subcommand + result output | T05 | `internal/cli/disclosure/cmd/apply/{cmd.go,run.go}`, `internal/cli/disclosure/disclosure.go`, `internal/config/embed/cmd/*.go`, `internal/write/disclosure/*.go` | | `ctx disclosure apply --plan p.json` updates root+theme files, prints result; `--json` decodes to `ApplyResult` | CLI | | T11 | [ ] | CLI rejects non-knowledge file | T10 | `internal/cli/disclosure/cmd/apply/run_test.go` | [P] | `apply README.md` exits non-zero with the kind sentinel; file untouched | Contracts | | T12 | [ ] | CLI write-safety on malformed root | T10 | `internal/cli/disclosure/cmd/apply/run_write_test.go` | [P] | integration: `apply` on a two-`## Themes` root → non-zero, file byte-identical | Scope/DoD | From 2c2184699accd94bad6f70cab41a614c9af008b5 Mon Sep 17 00:00:00 2001 From: Jose Alekhinne Date: Sat, 18 Jul 2026 19:39:32 -0700 Subject: [PATCH 3/8] =?UTF-8?q?feat(pd-m3):=20ctx=20disclosure=20apply=20?= =?UTF-8?q?=E2=80=94=20the=20mover=20CLI=20(E2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the mover behind a command: ctx disclosure apply --plan reads a digest plan (JSON, from a file or stdin), resolves the file's kind, and hands both to disclosure.Apply, then reports what moved (--json for the ApplyResult). It refuses a non-knowledge file and a malformed root before any write, leaving the file byte-identical on refusal. - apply subcommand (cmd/run/doc) registered under the disclosure group. - Use/DescKey command constants, --plan/--json flag constants, and their commands/flags/write YAML entries (linkage bijection holds). - ApplyHuman / ApplyJSON result renderers in write/disclosure. Tests: apply moves + rewrites + reports, --json decodes to ApplyResult, non-knowledge-file rejection, malformed-root write-safety. Verified the built binary end-to-end: an un-migrated LEARNINGS.md folded into a bounded root with its entry bodies moved verbatim into the theme file. Plan: specs/plans/pd-m3.md Signed-off-by: Jose Alekhinne --- .context/TASKS.md | 2 +- internal/assets/commands/commands.yaml | 21 +++ internal/assets/commands/flags.yaml | 4 + internal/assets/commands/text/write.yaml | 2 + internal/cli/disclosure/cmd/apply/cmd.go | 52 +++++++ internal/cli/disclosure/cmd/apply/doc.go | 24 +++ internal/cli/disclosure/cmd/apply/run.go | 80 ++++++++++ internal/cli/disclosure/cmd/apply/run_test.go | 138 ++++++++++++++++++ .../disclosure/cmd/apply/run_write_test.go | 33 +++++ internal/cli/disclosure/disclosure.go | 9 +- internal/cli/disclosure/doc.go | 10 +- internal/config/embed/cmd/base.go | 6 + internal/config/embed/flag/disclosure.go | 17 +++ internal/config/embed/text/disclosure.go | 3 + internal/config/flag/flag.go | 1 + internal/write/disclosure/disclosure.go | 35 +++++ specs/plans/pd-m3.md | 8 +- 17 files changed, 433 insertions(+), 12 deletions(-) create mode 100644 internal/cli/disclosure/cmd/apply/cmd.go create mode 100644 internal/cli/disclosure/cmd/apply/doc.go create mode 100644 internal/cli/disclosure/cmd/apply/run.go create mode 100644 internal/cli/disclosure/cmd/apply/run_test.go create mode 100644 internal/cli/disclosure/cmd/apply/run_write_test.go create mode 100644 internal/config/embed/flag/disclosure.go diff --git a/.context/TASKS.md b/.context/TASKS.md index 8833a3dd..d7191277 100644 --- a/.context/TASKS.md +++ b/.context/TASKS.md @@ -2996,7 +2996,7 @@ not agent discipline — the clobber risk class the M1 guards exist for. - [x] [E1] Mover core: `Plan`/`Assignment` types, `SplitStaging` lossless byte-cut, `WriteThemeBullet` gist write-back, `Apply` IO mover, abort/first-run/idempotency/invariants/conservation tests (T01–T09). Plan: specs/plans/pd-m3.md #priority:medium #session:f706d9de #branch:design/progressive-disclosure #added:2026-07-18 -- [ ] [E2] `ctx disclosure apply` CLI — reads plan JSON, refuses non-knowledge files + convention kind, write-safe on error, doc.go + wiring guards (T10–T13). Plan: specs/plans/pd-m3.md #priority:medium #session:f706d9de #branch:design/progressive-disclosure #added:2026-07-18 +- [x] [E2] `ctx disclosure apply` CLI — reads plan JSON, refuses non-knowledge files + convention kind, write-safe on error, doc.go + wiring guards (T10–T13). Plan: specs/plans/pd-m3.md #priority:medium #session:f706d9de #branch:design/progressive-disclosure #added:2026-07-18 - [ ] [E3] `ctx-digest` apply-path skill + copilot sync; MEASUREMENT GATE T16: driven apply on a realistic fixture moves entries + writes gists losslessly; real LEARNINGS→DECISIONS rollout is human-gated (T17); milestone gate (T14–T18). Plan: specs/plans/pd-m3.md #priority:medium #session:f706d9de #branch:design/progressive-disclosure #added:2026-07-18 diff --git a/internal/assets/commands/commands.yaml b/internal/assets/commands/commands.yaml index 56b93c47..99c3584f 100644 --- a/internal/assets/commands/commands.yaml +++ b/internal/assets/commands/commands.yaml @@ -654,6 +654,27 @@ disclosure-inspect: ctx disclosure inspect .context/LEARNINGS.md ctx disclosure inspect .context/DECISIONS.md --json short: Report a knowledge file's staged entries and themes +disclosure-apply: + long: |- + Apply a digest plan: move staged entries into theme files. + + ctx disclosure apply reads a digest plan (JSON, from --plan or stdin), + moves the named staged entries out of a canonical knowledge file + (LEARNINGS.md or DECISIONS.md) into per-theme files under + .context//, and folds each theme's gist into the root's + ## Themes section. + + It is the write half of progressive disclosure. The move is guarded: + entry bodies are appended to their theme files and verified + byte-present before anything is removed from the root, which is + rewritten once, last. Any failure leaves the root untouched. It refuses + a malformed root and CONVENTIONS.md (a later milestone). + + Examples: + ctx disclosure apply .context/LEARNINGS.md --plan plan.json + ctx disclosure inspect .context/LEARNINGS.md --json | build-plan | + ctx disclosure apply .context/LEARNINGS.md --plan - + short: Apply a digest plan, moving staged entries into theme files load: long: |- Load and display the assembled context diff --git a/internal/assets/commands/flags.yaml b/internal/assets/commands/flags.yaml index 0faf2163..1238b2bc 100644 --- a/internal/assets/commands/flags.yaml +++ b/internal/assets/commands/flags.yaml @@ -219,6 +219,10 @@ index.depth: short: 'Max heading level to include (2 = ## only; 3 adds ###)' index.json: short: Output headings as JSON +disclosure-apply.plan: + short: 'Digest plan JSON (file path, or - for stdin)' +disclosure-apply.json: + short: Output the apply result as JSON status.json: short: Output as JSON status.verbose: diff --git a/internal/assets/commands/text/write.yaml b/internal/assets/commands/text/write.yaml index 42ef7f99..58402549 100644 --- a/internal/assets/commands/text/write.yaml +++ b/internal/assets/commands/text/write.yaml @@ -762,6 +762,8 @@ write.disclosure-theme-line: short: ' %s → %s' write.disclosure-none: short: ' (none)' +write.disclosure-applied: + short: 'Moved %d entries into %d themes: %s' write.status-header-block: short: |- Context Status diff --git a/internal/cli/disclosure/cmd/apply/cmd.go b/internal/cli/disclosure/cmd/apply/cmd.go new file mode 100644 index 00000000..3f2b727b --- /dev/null +++ b/internal/cli/disclosure/cmd/apply/cmd.go @@ -0,0 +1,52 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package apply + +import ( + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/assets/read/desc" + embedCmd "github.com/ActiveMemory/ctx/internal/config/embed/cmd" + embedFlag "github.com/ActiveMemory/ctx/internal/config/embed/flag" + cFlag "github.com/ActiveMemory/ctx/internal/config/flag" + "github.com/ActiveMemory/ctx/internal/config/token" + "github.com/ActiveMemory/ctx/internal/flagbind" +) + +// Cmd returns the "ctx disclosure apply" command. +// +// It takes one positional argument (a canonical knowledge file) and a +// digest plan (--plan , or - for stdin), moves the plan's staged +// entries into theme files, and folds their gists into the root. --json +// switches the result summary to machine-readable output. +// +// Returns: +// - *cobra.Command: configured apply command with --plan and --json +func Cmd() *cobra.Command { + var planPath string + var jsonOutput bool + + short, long := desc.Command(embedCmd.DescKeyDisclosureApply) + c := &cobra.Command{ + Use: embedCmd.UseDisclosureApply, + Short: short, + Long: long, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return Run(cmd, args[0], planPath, jsonOutput) + }, + } + + flagbind.StringFlagPDefault( + c, &planPath, cFlag.Plan, "", token.Dash, + embedFlag.DescKeyDisclosureApplyPlan, + ) + flagbind.BoolFlag( + c, &jsonOutput, cFlag.JSON, embedFlag.DescKeyDisclosureApplyJSON, + ) + return c +} diff --git a/internal/cli/disclosure/cmd/apply/doc.go b/internal/cli/disclosure/cmd/apply/doc.go new file mode 100644 index 00000000..a2ab2431 --- /dev/null +++ b/internal/cli/disclosure/cmd/apply/doc.go @@ -0,0 +1,24 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package apply implements `ctx disclosure apply --plan `: +// the write half of progressive disclosure. +// +// # Domain +// +// [Cmd] wires the cobra command; [Run] resolves the file's kind (via +// internal/disclosure.KindFor), reads a digest plan (JSON, from --plan or +// stdin), and hands both to internal/disclosure.Apply, which moves the +// named staged entries into per-theme files and folds their gists into +// the root under its append→verify→remove guards. The result is rendered +// through internal/write/disclosure. +// +// # Related packages +// +// - internal/disclosure — Apply: the guarded mover. +// - internal/write/disclosure — render the ApplyResult. +// - internal/err/disclosure — the not-a-knowledge-file error. +package apply diff --git a/internal/cli/disclosure/cmd/apply/run.go b/internal/cli/disclosure/cmd/apply/run.go new file mode 100644 index 00000000..5ec33762 --- /dev/null +++ b/internal/cli/disclosure/cmd/apply/run.go @@ -0,0 +1,80 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package apply + +import ( + "encoding/json" + "io" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/config/token" + "github.com/ActiveMemory/ctx/internal/disclosure" + errDisc "github.com/ActiveMemory/ctx/internal/err/disclosure" + errFs "github.com/ActiveMemory/ctx/internal/err/fs" + internalIo "github.com/ActiveMemory/ctx/internal/io" + writeDisc "github.com/ActiveMemory/ctx/internal/write/disclosure" +) + +// Run executes the apply command: resolve the file's kind, read the +// digest plan (from --plan or stdin), move its staged entries into theme +// files, fold their gists into the root, and report what moved. The move +// itself is guarded by internal/disclosure.Apply (append→verify→remove); +// on any failure the root is left byte-identical. +// +// Parameters: +// - cmd: Cobra command for the I/O streams +// - path: path to the canonical knowledge file +// - planPath: digest plan path, or "-" for stdin +// - jsonOutput: if true, emit the result as JSON +// +// Returns: +// - error: NotAKnowledgeFile, a read error, a JSON error, a disclosure +// guard sentinel, or nil +func Run(cmd *cobra.Command, path, planPath string, jsonOutput bool) error { + clean := filepath.Clean(path) + if _, ok := disclosure.KindFor(filepath.Base(clean)); !ok { + cmd.SilenceUsage = true + return errDisc.NotAKnowledgeFile(path) + } + + var planBytes []byte + if planPath == token.Dash { + b, stdinErr := io.ReadAll(cmd.InOrStdin()) + if stdinErr != nil { + cmd.SilenceUsage = true + return errFs.FileRead(token.Dash, stdinErr) + } + planBytes = b + } else { + b, planReadErr := internalIo.SafeReadUserFile(filepath.Clean(planPath)) + if planReadErr != nil { + cmd.SilenceUsage = true + return errFs.FileRead(planPath, planReadErr) + } + planBytes = b + } + + var plan disclosure.Plan + if jsonErr := json.Unmarshal(planBytes, &plan); jsonErr != nil { + cmd.SilenceUsage = true + return jsonErr + } + + res, applyErr := disclosure.Apply(clean, plan, filepath.Dir(clean)) + if applyErr != nil { + cmd.SilenceUsage = true + return applyErr + } + + if jsonOutput { + return writeDisc.ApplyJSON(cmd, res) + } + writeDisc.ApplyHuman(cmd, res) + return nil +} diff --git a/internal/cli/disclosure/cmd/apply/run_test.go b/internal/cli/disclosure/cmd/apply/run_test.go new file mode 100644 index 00000000..a8379b2e --- /dev/null +++ b/internal/cli/disclosure/cmd/apply/run_test.go @@ -0,0 +1,138 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package apply_test + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/assets/read/lookup" + "github.com/ActiveMemory/ctx/internal/cli/disclosure/cmd/apply" + cfgDisc "github.com/ActiveMemory/ctx/internal/config/disclosure" + "github.com/ActiveMemory/ctx/internal/disclosure" + errDisc "github.com/ActiveMemory/ctx/internal/err/disclosure" +) + +// TestMain initializes the asset lookup so desc.Text resolves the output +// labels and error text (see the "uninitialized desc.Text" learning). +func TestMain(m *testing.M) { + lookup.Init() + os.Exit(m.Run()) +} + +const fixtureRoot = "# Learnings\n\n\n\n" + + "## [2026-07-15-120000] a staged entry\n\n**Context**: x.\n\n---\n\n" + + "## Themes\n\n- hooks — hook mechanics → [hooks](learnings/hooks.md)\n" + +func stagedID() string { + return "2026-07-15-120000" + cfgDisc.IDSeparator + "a staged entry" +} + +// writeFile writes content to / and returns the path. +func writeFile(t *testing.T, dir, name, content string) string { + t.Helper() + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", name, err) + } + return p +} + +// writePlan marshals a plan to /plan.json and returns the path. +func writePlan(t *testing.T, dir string, plan disclosure.Plan) string { + t.Helper() + b, err := json.Marshal(plan) + if err != nil { + t.Fatal(err) + } + return writeFile(t, dir, "plan.json", string(b)) +} + +// runApply invokes apply.Run with buffered I/O and returns stdout + err. +func runApply(t *testing.T, path, planPath string, jsonOut bool) (string, error) { + t.Helper() + var out bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&out) + cmd.SetErr(&out) + err := apply.Run(cmd, path, planPath, jsonOut) + return out.String(), err +} + +func movePlan() disclosure.Plan { + return disclosure.Plan{ + Kind: "learning", + Assignments: []disclosure.Assignment{{ + Theme: "context", Slug: "context", Gist: "context stuff", + Entries: []string{stagedID()}, + }}, + } +} + +// T10: apply moves the staged entry into its theme file, rewrites the +// root, and reports the result. +func TestRun_Apply(t *testing.T) { + dir := t.TempDir() + rootPath := writeFile(t, dir, "LEARNINGS.md", fixtureRoot) + planPath := writePlan(t, dir, movePlan()) + + out, err := runApply(t, rootPath, planPath, false) + if err != nil { + t.Fatalf("Run error = %v", err) + } + if !strings.Contains(out, "Moved 1 entries into 1 themes: context") { + t.Errorf("summary missing; got: %q", out) + } + + root, _ := os.ReadFile(rootPath) + if strings.Contains(string(root), "## [2026-07-15-120000]") { + t.Errorf("staged entry still in root:\n%s", root) + } + body, err := os.ReadFile(filepath.Join(dir, "learnings", "context.md")) + if err != nil || !strings.Contains(string(body), "a staged entry") { + t.Errorf("context.md = %q, err = %v", body, err) + } +} + +// T10: --json emits an ApplyResult that decodes to the expected values. +func TestRun_ApplyJSON(t *testing.T) { + dir := t.TempDir() + rootPath := writeFile(t, dir, "LEARNINGS.md", fixtureRoot) + planPath := writePlan(t, dir, movePlan()) + + out, err := runApply(t, rootPath, planPath, true) + if err != nil { + t.Fatalf("Run error = %v", err) + } + var res disclosure.ApplyResult + if uErr := json.Unmarshal([]byte(out), &res); uErr != nil { + t.Fatalf("output not JSON: %v\n%s", uErr, out) + } + if res.Moved != 1 || len(res.Themes) != 1 || res.Themes[0] != "context" { + t.Errorf("ApplyResult = %+v", res) + } +} + +// T11: a non-knowledge file is rejected with the typed sentinel, before +// the plan is even read. +func TestRun_RejectsNonKnowledgeFile(t *testing.T) { + dir := t.TempDir() + path := writeFile(t, dir, "README.md", "# readme\n") + planPath := writePlan(t, dir, movePlan()) + + _, err := runApply(t, path, planPath, false) + if !errors.Is(err, errDisc.ErrNotAKnowledgeFile) { + t.Errorf("Run(README.md) err = %v, want ErrNotAKnowledgeFile", err) + } +} diff --git a/internal/cli/disclosure/cmd/apply/run_write_test.go b/internal/cli/disclosure/cmd/apply/run_write_test.go new file mode 100644 index 00000000..14f30cfd --- /dev/null +++ b/internal/cli/disclosure/cmd/apply/run_write_test.go @@ -0,0 +1,33 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package apply_test + +import ( + "os" + "testing" + + errDisc "github.com/ActiveMemory/ctx/internal/err/disclosure" +) + +// T12: a malformed root (two ## Themes) is refused and left +// byte-identical — the CLI never half-writes a file it could not validate. +func TestRun_MalformedRootUntouched(t *testing.T) { + dir := t.TempDir() + const bad = "# Learnings\n\n\n\n## Themes\n\n## Themes\n" + rootPath := writeFile(t, dir, "LEARNINGS.md", bad) + planPath := writePlan(t, dir, movePlan()) + + before, _ := os.ReadFile(rootPath) + _, err := runApply(t, rootPath, planPath, false) + if err != errDisc.ErrMultipleThemes { + t.Errorf("err = %v, want ErrMultipleThemes", err) + } + after, _ := os.ReadFile(rootPath) + if string(before) != string(after) { + t.Errorf("root mutated on refusal:\nbefore=%q\nafter=%q", before, after) + } +} diff --git a/internal/cli/disclosure/disclosure.go b/internal/cli/disclosure/disclosure.go index a7b4d758..caec810d 100644 --- a/internal/cli/disclosure/disclosure.go +++ b/internal/cli/disclosure/disclosure.go @@ -9,18 +9,21 @@ package disclosure import ( "github.com/spf13/cobra" + "github.com/ActiveMemory/ctx/internal/cli/disclosure/cmd/apply" "github.com/ActiveMemory/ctx/internal/cli/disclosure/cmd/inspect" "github.com/ActiveMemory/ctx/internal/cli/parent" "github.com/ActiveMemory/ctx/internal/config/embed/cmd" ) -// Cmd returns the `ctx disclosure` parent command, which inspects the -// canonical knowledge files under progressive disclosure. +// Cmd returns the `ctx disclosure` parent command, which inspects and +// digests the canonical knowledge files under progressive disclosure. // // Returns: -// - *cobra.Command: disclosure parent with the inspect subcommand +// - *cobra.Command: disclosure parent with the inspect and apply +// subcommands func Cmd() *cobra.Command { return parent.Cmd(cmd.DescKeyDisclosure, cmd.UseDisclosure, inspect.Cmd(), + apply.Cmd(), ) } diff --git a/internal/cli/disclosure/doc.go b/internal/cli/disclosure/doc.go index 3e803f70..2c1b2dba 100644 --- a/internal/cli/disclosure/doc.go +++ b/internal/cli/disclosure/doc.go @@ -14,9 +14,11 @@ // // - ctx disclosure inspect : read-only report of a root's // staged entries and current themes, which the dry-run digesting -// pass consumes. The mover (apply) subcommands arrive in a later -// milestone. +// pass consumes. +// - ctx disclosure apply --plan : the mover — moves a +// digest plan's staged entries into per-theme files and folds their +// gists into the root, under the append→verify→remove guards. // -// The parse/inspect domain logic lives in internal/disclosure; output -// rendering in internal/write/disclosure. +// The parse/inspect/apply domain logic lives in internal/disclosure; +// output rendering in internal/write/disclosure. package disclosure diff --git a/internal/config/embed/cmd/base.go b/internal/config/embed/cmd/base.go index c5ac1d28..737026ad 100644 --- a/internal/config/embed/cmd/base.go +++ b/internal/config/embed/cmd/base.go @@ -19,6 +19,9 @@ const ( // UseDisclosureInspect is the cobra Use string for the disclosure // inspect subcommand. UseDisclosureInspect = "inspect [FILE]" + // UseDisclosureApply is the cobra Use string for the disclosure + // apply subcommand. + UseDisclosureApply = "apply [FILE]" // UseDoctor is the cobra Use string for the doctor command. UseDoctor = "doctor" // UseDrift is the cobra Use string for the drift command. @@ -100,6 +103,9 @@ const ( // DescKeyDisclosureInspect is the description key for the disclosure // inspect subcommand. DescKeyDisclosureInspect = "disclosure-inspect" + // DescKeyDisclosureApply is the description key for the disclosure + // apply subcommand. + DescKeyDisclosureApply = "disclosure-apply" // DescKeyLoad is the description key for the load command. DescKeyLoad = "load" // DescKeyLoop is the description key for the loop command. diff --git a/internal/config/embed/flag/disclosure.go b/internal/config/embed/flag/disclosure.go new file mode 100644 index 00000000..8caeb653 --- /dev/null +++ b/internal/config/embed/flag/disclosure.go @@ -0,0 +1,17 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package flag + +// DescKeys for disclosure command flags. +const ( + // DescKeyDisclosureApplyPlan is the description key for the disclosure + // apply --plan flag. + DescKeyDisclosureApplyPlan = "disclosure-apply.plan" + // DescKeyDisclosureApplyJSON is the description key for the disclosure + // apply --json flag. + DescKeyDisclosureApplyJSON = "disclosure-apply.json" +) diff --git a/internal/config/embed/text/disclosure.go b/internal/config/embed/text/disclosure.go index 37d92305..81223619 100644 --- a/internal/config/embed/text/disclosure.go +++ b/internal/config/embed/text/disclosure.go @@ -21,6 +21,9 @@ const ( DescKeyWriteDisclosureThemeLine = "write.disclosure-theme-line" // DescKeyWriteDisclosureNone marks an empty list. DescKeyWriteDisclosureNone = "write.disclosure-none" + // DescKeyWriteDisclosureApplied reports a completed apply: entries + // moved, themes touched, and the touched slugs. + DescKeyWriteDisclosureApplied = "write.disclosure-applied" ) // DescKeys for progressive-disclosure guard and invariant errors. diff --git a/internal/config/flag/flag.go b/internal/config/flag/flag.go index 1da6d119..420117ee 100644 --- a/internal/config/flag/flag.go +++ b/internal/config/flag/flag.go @@ -18,6 +18,7 @@ const ( Context = "context" File = "file" Lesson = "lesson" + Plan = "plan" Priority = "priority" Rationale = "rationale" Section = "section" diff --git a/internal/write/disclosure/disclosure.go b/internal/write/disclosure/disclosure.go index c5bf4c30..f5623c46 100644 --- a/internal/write/disclosure/disclosure.go +++ b/internal/write/disclosure/disclosure.go @@ -9,6 +9,7 @@ package disclosure import ( "encoding/json" "fmt" + "strings" "github.com/spf13/cobra" @@ -69,3 +70,37 @@ func JSON(cmd *cobra.Command, insp disclosure.Inspection) error { cmd.Println(string(b)) return nil } + +// ApplyHuman prints an ApplyResult as a one-line summary: how many +// entries moved, into how many themes, and which theme slugs. +// +// Parameters: +// - cmd: Cobra command for the output stream +// - res: the apply result to render +func ApplyHuman(cmd *cobra.Command, res disclosure.ApplyResult) { + slugs := strings.Join(res.Themes, token.CommaSpace) + if slugs == "" { + slugs = token.Dash + } + cmd.Println(fmt.Sprintf( + desc.Text(text.DescKeyWriteDisclosureApplied), + res.Moved, len(res.Themes), slugs, + )) +} + +// ApplyJSON prints an ApplyResult as indented JSON. +// +// Parameters: +// - cmd: Cobra command for the output stream +// - res: the apply result to render +// +// Returns: +// - error: non-nil only if JSON marshaling fails +func ApplyJSON(cmd *cobra.Command, res disclosure.ApplyResult) error { + b, err := json.MarshalIndent(res, "", token.Space+token.Space) + if err != nil { + return err + } + cmd.Println(string(b)) + return nil +} diff --git a/specs/plans/pd-m3.md b/specs/plans/pd-m3.md index a73797ca..f2ff03d4 100644 --- a/specs/plans/pd-m3.md +++ b/specs/plans/pd-m3.md @@ -194,10 +194,10 @@ No `ctx agent` change (spec Non-Goals). No `add`-path change. | T07 | [x] | first-run `## Themes` creation + idempotency | T05 | `internal/disclosure/apply_firstrun_test.go` | [P] | `-run TestApplyFirstRun`: un-migrated root gets `## Themes` below staging, `Validate` nil; `-run TestApplyIdempotent`: empty plan → root byte-identical | Design step 5 | | T08 | [x] | post-apply invariants hold | T05 | `internal/disclosure/apply_invariants_test.go` | [P] | `-run TestApplyInvariants`: after Apply on a fixture, `Validate`+`CheckPairing`+`CheckUniqueness`+`CheckLinks` all return nil | Invariants | | T09 | [x] | conservation property test | T05 | `internal/disclosure/apply_conserve_test.go` | [P] | `-run TestApplyConservation`: `entryIDs(before)` == disjoint union of moved ids and `entryIDs(after)`; each moved id present in exactly one theme file; zero loss/dup | Tests (Conservation) | -| T10 | [ ] | `ctx disclosure apply` subcommand + result output | T05 | `internal/cli/disclosure/cmd/apply/{cmd.go,run.go}`, `internal/cli/disclosure/disclosure.go`, `internal/config/embed/cmd/*.go`, `internal/write/disclosure/*.go` | | `ctx disclosure apply --plan p.json` updates root+theme files, prints result; `--json` decodes to `ApplyResult` | CLI | -| T11 | [ ] | CLI rejects non-knowledge file | T10 | `internal/cli/disclosure/cmd/apply/run_test.go` | [P] | `apply README.md` exits non-zero with the kind sentinel; file untouched | Contracts | -| T12 | [ ] | CLI write-safety on malformed root | T10 | `internal/cli/disclosure/cmd/apply/run_write_test.go` | [P] | integration: `apply` on a two-`## Themes` root → non-zero, file byte-identical | Scope/DoD | -| T13 | [ ] | `doc.go` for the apply cmd + command-wiring guards | T10 | `internal/cli/disclosure/cmd/apply/doc.go` | [P] | `make audit` doc.go/docstring floors pass; `go test ./internal/compliance/ -run TestShippedHooksResolve` and command-tree tests green with the new subcommand | CONVENTIONS | +| T10 | [x] | `ctx disclosure apply` subcommand + result output | T05 | `internal/cli/disclosure/cmd/apply/{cmd.go,run.go}`, `internal/cli/disclosure/disclosure.go`, `internal/config/embed/cmd/*.go`, `internal/write/disclosure/*.go` | | `ctx disclosure apply --plan p.json` updates root+theme files, prints result; `--json` decodes to `ApplyResult` | CLI | +| T11 | [x] | CLI rejects non-knowledge file | T10 | `internal/cli/disclosure/cmd/apply/run_test.go` | [P] | `apply README.md` exits non-zero with the kind sentinel; file untouched | Contracts | +| T12 | [x] | CLI write-safety on malformed root | T10 | `internal/cli/disclosure/cmd/apply/run_write_test.go` | [P] | integration: `apply` on a two-`## Themes` root → non-zero, file byte-identical | Scope/DoD | +| T13 | [x] | `doc.go` for the apply cmd + command-wiring guards | T10 | `internal/cli/disclosure/cmd/apply/doc.go` | [P] | `make audit` doc.go/docstring floors pass; `go test ./internal/compliance/ -run TestShippedHooksResolve` and command-tree tests green with the new subcommand | CONVENTIONS | | T14 | [ ] | `ctx-digest` skill — apply path | T10 | `internal/assets/claude/skills/ctx-digest/SKILL.md` | | frontmatter test green; body describes inspect→propose→gist→**build plan JSON→human approve→`ctx disclosure apply`→confirm via inspect**, states the append→verify→remove guard and conventions=M4 | Skill | | T15 | [ ] | copilot skill sync | T14 | `internal/assets/integrations/copilot-cli/skills/ctx-digest/**` | | `make check-copilot-skills` green | Skill | | T16 | [ ] |XX **measurement gate** — drive skill apply on realistic fixture | T14,T08,T09 | scratchpad fixture (copy of real LEARNINGS staging) | | drive ctx-digest apply on N≥4 staged entries → ≥2 themes: entries moved, theme files created with bodies, `## Themes` gists written, conservation holds, all four invariants pass, root bounded | Scope/DoD | From ca4cfdc30681d88ec2236ce86ed2f6e0c11ed839 Mon Sep 17 00:00:00 2001 From: Jose Alekhinne Date: Sat, 18 Jul 2026 19:53:11 -0700 Subject: [PATCH 4/8] feat(pd-m3): ctx-digest apply-path skill + plan refinement (E3, partial) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Graduate the ctx-digest skill from dry-run-only to the full guarded pass: inspect -> propose themes -> author gists -> present for human approval -> ctx disclosure apply -> confirm. The apply is human-gated and every write goes through the CLI; the skill never hand-edits a file. Copilot copy synced. Two supporting changes landed with it: - Plan format: Assignment.Entries is now [{timestamp,title}] (StagedEntry), not a NUL-joined id string. It is exactly inspect --json's staging shape, so the skill lifts entries verbatim with no NUL to hand-author in JSON. FlattenPlan builds the ids internally (new entryID helper). No behaviour or acceptance criterion changed; SplitStaging still takes []string. See pd-m3.md Amendments. Verified end-to-end against the built binary. - File organization: split the movehelpers.go bag file into semantically-named files — move.go (the mover's append/verify/rewrite mechanics), regions.go (renderThemeBullet + line/offset scanners, beside parseThemeBullet/lineAt), collect.go (entryID, beside entryIDs). No helpers/utils bag files. Plan: specs/plans/pd-m3.md Signed-off-by: Jose Alekhinne --- .../assets/claude/skills/ctx-digest/SKILL.md | Bin 3569 -> 5142 bytes .../copilot-cli/skills/ctx-digest/SKILL.md | Bin 3536 -> 5102 bytes internal/cli/disclosure/cmd/apply/run_test.go | 9 +-- internal/disclosure/apply_abort_test.go | 10 +-- internal/disclosure/apply_conserve_test.go | 8 +-- internal/disclosure/apply_firstrun_test.go | 4 +- internal/disclosure/apply_invariants_test.go | 8 +-- internal/disclosure/apply_test.go | 11 ++-- internal/disclosure/collect.go | 14 ++++ .../disclosure/{movehelpers.go => move.go} | 62 ++---------------- internal/disclosure/regions.go | 55 ++++++++++++++++ internal/disclosure/split.go | 11 ++-- internal/disclosure/split_test.go | 11 +++- internal/disclosure/types.go | 15 +++-- specs/plans/pd-m3.md | 7 +- 15 files changed, 127 insertions(+), 98 deletions(-) rename internal/disclosure/{movehelpers.go => move.go} (69%) diff --git a/internal/assets/claude/skills/ctx-digest/SKILL.md b/internal/assets/claude/skills/ctx-digest/SKILL.md index 993fdf59e970491d7b132a012b64bf76aeb2e6f7..cadf3d88b3b8e9d6555b3d8c31d489249bd84657 100644 GIT binary patch literal 5142 zcmbtY(T>~55zO;^MML{=k}PU%$G!lv;b6~p4x4k<7w!YbCkh zp7H@fz9ApxFG*DoDQWFHzr+v5iW>IxR99EmC`l4i+sZ8@Yd<7;v38*~xeHlSY}>-u z3mLuXYH2r4wvAsmE`;L7VPVKhAG!vQZ3qh~>acZLD^{*+e_OhI?POJy?yDf7wdbBX<#CGk*H?l558@S#Dd=WD!Z7nN&x9KYUWw!`+ zW6K%8)Q{y(idH`U{2!_O4fkZo!Ud$)`hG)Z6~xv#B9%E8=}!A4(s0myAM~nFdrFPmj2e|N$Y(H zw7#)nGX~&`>0~BvoXuzQLsPWQBq)b@p;Z7vOW)NwfH^?Q+!mTR4tf{b;~i)H`Hez~ zI5|P^9boiCJc-yJpeUVHysTut+FBHnpvXNbi~1Uo4G9o|x!M%KGiX6pU0HsK7cvgc z88RcB{xW?4>O>3H^I|4z7mKYmCVh%^o%q!%R@tEFk#OnWkrr71v5X(bV+?MgxU_&C-Eatm3ZSkQ3k!3Mbrye-dq1uDH{>vki-~+-aQW zug=XlMJ?s1Cp4{evWS!(@VDGxyLDRA$j72NW~CvCZd_BW3f-V}$w2=;zo86T0N-UB z2kP*xqc2S3Zb7CN-1T+l=5ki=WMzwz-O57axclYH9X2U)@TACWIhW~_(A5rEm<<9a zE=Naw+SP&{92%G;(h4#r(2(3?0|wX$^Kht#F()VT1G@>wMrRq5$k`FCHOBb3(`I)X zZ6X^B+MGBE^1SQEmRrwB4 z+fvtV3&5tOfMc@9OoI=^~*1!m*1&M zQ4bl}uOwuC>(G@I1c?lW(xbiP6gfe5ePWwz1AWgYIBtykmzNiexer9y8B~>oPESr! z36Opa3&?ftTRxI^>9FbvV*he~NaI#r09gv??Q>t>kcEiS7Xg1p3`%r$gD4f1yG1Qz z{HI3~1>^h1XD$b^`RPO+%;mfvL<7T(Nz-%*y)~co=rFP7nXg;-p*=mOpCrkT!Pk7( z7*)K+ZGFRm1S0VC=s-${{RcM|-bs)$2KAI=3Je{p0R^9Rt)*^G3zYxETf!buLZ0Cu6n zu#<(h=;>Avnx^D3$!kCa$o}f~wGM z2eRRewl!g+QY)cb{zy|YcJuXIMjP+1rE(ciDzY$|$;dSg-2_m>oLlqBYsm9+pF0C3 zaqGsCkRud|yk@_EiU1~&?;QF%77ORfJWDa1E^tal!(7B5rIK@$V5sc#TRMvdqMk^6 zT@8@pHAmR*(GoC3z-Brf&;ee$QM4n3jp(B02!zhTTi>E(BmTrJE+*We?A9?UIFqK8 zYqzw^B(q=*rd>rrz+xfQkj^;03s5Q_|MhRthshqbG)`-jbm?Ff`cV?MBn!U^h=g`? z42qEZ?s{6o%se}N%AB4-p;9F@+Fw%`&MN$x$pHcc+CPpPj2l=b(rMNB3h!BU3KdNz zeafV#>~AshqKCB6#?WK7aau-nju;yw7rn)W5YOV&(zK>iHegR!3JGmD!E=m9E(Df9qngPf3HK+pQ^r_*F+NU^eSh$EH#jJOMm z`4)OkBG<0OzSnN2RSYvj|5g?jgC7%x{2OVvG59DF=j=((pl?Re;HcGWU0HJAth$zR zN~D3>6c!js6Fv-~0z;#yr`ip@OKQ-c<4YL41!eyPRD^9Lxon%xRijVj5hdgk7thIS z!*wim@8gZR+`vy0Bz0#n|J<31xKJ5~67z3)N^(6DQG&{^QVa{m84l7q`A2WOCLk4$ zix|D(#Hg%Y!n*1g&&ay|(lEZ5Ebvhzk#|qq@a(-jRT1YJKK%Ief8&676)kIpmV=6` za90kO0uTbVfPf|v|(uz-R)u~ zs@wJKWTK;o_aAC^HU`2uh=2f2OQY<(kEs} z9qMUYZBL^?PWWJcut=%Cp2llaZ~m!egBOU#oT{d9YSm|G{xXku5n_aChFiL!d%TJ+ zDEgy#ojJa=#B0rY3#Q@~T>hXMcc!e;lOx<8zSE5(=RyC*LFQmJIZkN;W*nC^=oF^L zmGDAEYarW8Sb*x#rg{mc8R-yQ?_Ya6hL9M`2N%ZP_*#uE?gf=GZ*b8X%&A9(Yd+TA z=#8V^GcX?-i#s>-K*|v>-hJF{B=i64W#ff5dNlqj(En(GJJgyJPDg9Hx3Ya2!#x#h z6P0|9LMr-3R=B0WR}2TJ%};M!aA_OxUhNELv7wf(<$R?u-Inj_@Kjajn3wAa5pU;s0b?89tB-QvS;XU*X)M%}$Zx&|W2$ju5Adf4rMvkbL*e08 delta 1443 zcmZWp&x;gC6lUVChjzUPf*`_+8W=`?u$}?%tbI?T(CHlCrB<;$=qn0#kI z7J%=Ton7e)XRB5J@9v-1B!H}^QggKlrg0FeWKc^r12@XUu;WO) z{O9zUQ|ljEzfc8>n*P{^Ei=(VzztrAxfMfhgd36eY*oz@K&ly2G@;N`CQitCSf-HP zrG?`geYYbjXcoq*N=AU|FC{_1h?1z2+90Y0WI7N?TSPDa&;A;nmC46Jix9h}f3Y_| zJ51sb#jE}6y+`YBfUz^Nl<4s@04k6O%pG3`;>*}BB!ecEHp7T`gye`8;GIVZ*v>M5 zJ89+klZm=Q4SPhVem_Y(yPV(5>4uZiX?$ccWimCh?N@2O9g37W(R2(_gHKi#$cp2Y z{?55S_OMEl`m*-=x97fH3#haM1v6HHIHw69(y{jgqx91j+Y(i=gOJ{b^cdv78$wtEF8#*))FDxDbjPhlL?4edroIwjs==sKdr(tysCP{cY*;m6K&ry03zS)~&xXx;Xj5 zcXf^f?@Pr!=b8`>aJOWKk*58*jFt*Z7dqZ!^%A= zi~1Uo4I3K|e6=o6r=W#ic4hgce#>OeR-INk1_gU7f6K>+;!TB7gVY4nca>*h%f+6HQ%TU^GAjwOKgeh27b< zYxWwrKtqIk@K09Tl}qll>TJWNEVdfw?B%%`ryzwKU|rKnkLMApL;Wo`*le8kG~%pi z4$)=^jvLn$%R)D3T{6JB&u?ghHo$k;+T|bu-a7cgH14)3+7_7fb>?PrT5n}(i;^s4 zqjB8b^2HXL6ghBGWVW2iWWv(b4q2Er0w*p9Q$A&@fLjAG2_o$vW1UeOLJotG1zD-4>8DFf>BamdwfuWUI&}M=cR`U&f19 zR|lnhRm&Na=IZ?F%@rhv0)z%#onC&Y>O-AlWWSV<`HcfBD+}TR!BB9}OHPmz+S1I@V%*-}ubs05(4z$%C1k_XB1?xG`y( zE^JtvPdGYEqFLta)_rJC4&f(B@;>;Q?;4|u*SL*uIA{RGKVQDOm}}?K>!Qw6l_p7h z!0b*gW1PZW7i-K=XGoL*9-jaO0`bJ#)*p~6!Y;p3USb~ZOf z`veTrI&W-);lqO}f6z`c7TS2eJV*vGa@D5Az{E;444=^?6}dg$36Ws868sLTh{KXT zoC%8j#Zhd`H-MY78R2eM;1vPF2@Mkfbb(>m$wFK7bdYCm2gth*I9^Wz&~Lohs>e8v z@=}9dXf{O{IV&52!#syl$$OHx!ZvfNLbn;fhBMmMgtbbogl_SkW@GGTtC@_}-d{`Q zGE}L^!e}ZZ*EDnqs2b+nicelap3i*l43xyJ8cRTq&?w@Xd;t_um_)vF;B{;k&Xsw# zVltWIl#GVCh(Ssv=P1EY+2yxz76hW6NPJz5jn-?1u-}6cFgmEsWHM9-_0o-^&0yJB zUGyA*z#P2wEhro7&zi->EO#ioRZI$wqiN;ZE$kx6EKq}KS5XkKSO_(wGfwXoD3y=@ z`nTxAWLLE`PHVJu;b0Z|Q4+T#3cm`71iCo}MaUhyp4Kok&rY5)r)N;8Q~?e8YYM|z zgiw%(LBS#>t;vK0>;X&R!8TxJ@XJ;*R}eJjpAR107htM^Hh@Cx_-&tA+)MxuKp{+^ z2dFl{2?<8&*}vU%I-VLrtn3MKpt7Fj102Pmm7`@@dsH|PWzUmjxh`RnVFupjRYKEpkNPEgI0{5n3IMre+;_y*{Hvz zq0TicV}76k)Q(15(|-}?0mo<@UE_$%^XFOvOu=DprnrJwxp?!^+~ZSBPz+mO3ofPi zPQw_FoPbi}K>Ish#Ep*fpU|}t$MpzzRoLLBCOM{j8k@s$67c0)Z7-Me-w5$4$9L|k zqrRyNC|Sezd!gKaPrMD^@5j#COUd}|KZok{XZlR9w!eT-p9Y+ z**hb6^iJRU8UMdCKS!|sh8;qGfPo#CJ~2b;P*2)wa}o`5!UwaxMN0McG+vu}^G_`s zyg)SOR5dN9c6|!+mwCL4utu0>xP2SC$E)aqqCbk)nZrv99`J7*WDZu7~%5h19PGM?X2`^N%2C}_?1*rBk)k`qVNQdBh|JvCx zgv40hyD)O&Yc;mG7gWZ)#zku|ryd=y`B=Hp8%MooU_LY!cW&l^lw-Yk_i?uo%>S>K zjThSJ(fF%C|6>I1P%BP29j)ok%Jyjt_f)7&RPq^)Lqi;A-ChpluN`hI?N-j#F1scK z=$78fJ9&*#e|irX>Ji;`ic;dT!%;;OmE85WeV7+|u{`vdY7JFhfi`s9eZcP*z%oyx zO`k?@#0wV0gmfKZ(X#3iY{=nvXB6;qgrBz-R2rS&u(t5!$j<7V<{gO#>t`D`N57OQ ztlEkTqmSz)H%g!_fu`Z-29gf(`$(tw$eaI7VLvNXPcRi?Uan(_css`n7~Ak(eUv@U bVm%I##zsAf_~vUcrW!YLfImGb+|B<0y9?9{ delta 1451 zcmZWp&5qne5Y|A#!E7#(;DV5fEJ`-k%;dr;i*iX2Sw-w5%0!X)Idt4L<7Vw{tGm6< z9MJFx3FeY0M>yq}C&&vRo`DD88ThL0VK*T8VA*rtT@zL_`;?a_7cWUgKl-=sTvajn&?>BPFOV!dYa&PD26 zTj>!{|9tZCw;2G|91<=8o##NsWL<)k=W|+?CJ>&)MEJFqN>#1Mt$@_6J%?aUInyQ- zl?$zBIta|vfB7D5Z;}CV?;01-WT#5VR8r#*Kr0p5Qw>rfI?LB^s%feXg~$zQhg;Hk zkZPV~d7kiu6H`@W`4o3qn}}QIsER~W`MbsM!ZTXPfkZXwH_Zgfa2Qw*4Gx4|5s~zq%=~m~LJViXj~7d+jyz^VQH2(&f-NZ>5XPG^G^0BZItlb2 zI}d>X-;-V7fY&kHGiR#}YA-C^UK~H{f4lhdixAbC^-QX7#*7*V6-x&1R6TH`Jls6X z%bQ<5`0)Mh!`81}Gc!A&}?w+lxc?xhfW16NEn##l}IS)S- zqP@0=!zli4R0Yk-SXIe*aD7$c1&k<(I;jnETtKixfwV*P=J)LNHs&gqVm(rLD2L1i*h^W9fzzMGg7NYV5Fqz0cX=9o4d zulM_xU+j@d7N&{m|G50kRzRg)D44M?#5qj>an2+-ru2_3wkE1#KOrgK(GMVhJS?Wf bR|cQ;bewR!poHX8!!Xh2oxzpmn=/ and returns the path. func writeFile(t *testing.T, dir, name, content string) string { t.Helper() @@ -75,7 +70,9 @@ func movePlan() disclosure.Plan { Kind: "learning", Assignments: []disclosure.Assignment{{ Theme: "context", Slug: "context", Gist: "context stuff", - Entries: []string{stagedID()}, + Entries: []disclosure.StagedEntry{ + {Timestamp: "2026-07-15-120000", Title: "a staged entry"}, + }, }}, } } diff --git a/internal/disclosure/apply_abort_test.go b/internal/disclosure/apply_abort_test.go index a2bcedac..ba9a600c 100644 --- a/internal/disclosure/apply_abort_test.go +++ b/internal/disclosure/apply_abort_test.go @@ -28,13 +28,15 @@ func TestApplyAbort_RootUntouched(t *testing.T) { rootPath := writeRoot(t, dir, migratedRoot()) before := readFile(t, rootPath) - idA := idFor("2026-01-01-000000", "Alpha") - idB := idFor("2026-01-02-000000", "Beta") plan := disclosure.Plan{ Kind: "learning", Assignments: []disclosure.Assignment{ - {Theme: "context", Slug: "context", Gist: "g", Entries: []string{idA}}, // succeeds - {Theme: "hooks", Slug: "hooks", Gist: "g", Entries: []string{idB}}, // append fails (dir) + // context: a fresh file, its append succeeds. + {Theme: "context", Slug: "context", Gist: "g", + Entries: []disclosure.StagedEntry{ent("2026-01-01-000000", "Alpha")}}, + // hooks: hooks.md is a directory, so this append fails. + {Theme: "hooks", Slug: "hooks", Gist: "g", + Entries: []disclosure.StagedEntry{ent("2026-01-02-000000", "Beta")}}, }, } diff --git a/internal/disclosure/apply_conserve_test.go b/internal/disclosure/apply_conserve_test.go index 18e73a07..1913a608 100644 --- a/internal/disclosure/apply_conserve_test.go +++ b/internal/disclosure/apply_conserve_test.go @@ -36,13 +36,13 @@ func TestApplyConservation(t *testing.T) { t.Fatalf("precondition: %d staged, want 2", len(beforeStaged)) } - idA := idFor("2026-01-01-000000", "Alpha") - idB := idFor("2026-01-02-000000", "Beta") plan := disclosure.Plan{ Kind: "learning", Assignments: []disclosure.Assignment{ - {Theme: "context", Slug: "context", Gist: "g1", Entries: []string{idA}}, - {Theme: "hooks", Slug: "hooks", Gist: "g2", Entries: []string{idB}}, + {Theme: "context", Slug: "context", Gist: "g1", + Entries: []disclosure.StagedEntry{ent("2026-01-01-000000", "Alpha")}}, + {Theme: "hooks", Slug: "hooks", Gist: "g2", + Entries: []disclosure.StagedEntry{ent("2026-01-02-000000", "Beta")}}, }, } res, err := disclosure.Apply(rootPath, plan, dir) diff --git a/internal/disclosure/apply_firstrun_test.go b/internal/disclosure/apply_firstrun_test.go index 720f9551..2ce612b5 100644 --- a/internal/disclosure/apply_firstrun_test.go +++ b/internal/disclosure/apply_firstrun_test.go @@ -20,11 +20,11 @@ func TestApplyFirstRun(t *testing.T) { dir := t.TempDir() rootPath := writeRoot(t, dir, learnPreamble+stagingTwo) // no ## Themes - idA := idFor("2026-01-01-000000", "Alpha") plan := disclosure.Plan{ Kind: "learning", Assignments: []disclosure.Assignment{ - {Theme: "context", Slug: "context", Gist: "context entries", Entries: []string{idA}}, + {Theme: "context", Slug: "context", Gist: "context entries", + Entries: []disclosure.StagedEntry{ent("2026-01-01-000000", "Alpha")}}, }, } diff --git a/internal/disclosure/apply_invariants_test.go b/internal/disclosure/apply_invariants_test.go index dfb70ae2..df933788 100644 --- a/internal/disclosure/apply_invariants_test.go +++ b/internal/disclosure/apply_invariants_test.go @@ -30,13 +30,13 @@ func TestApplyInvariants(t *testing.T) { } rootPath := writeRoot(t, dir, migratedRoot()) - idA := idFor("2026-01-01-000000", "Alpha") - idB := idFor("2026-01-02-000000", "Beta") plan := disclosure.Plan{ Kind: "learning", Assignments: []disclosure.Assignment{ - {Theme: "context", Slug: "context", Gist: "context entries", Entries: []string{idA}}, - {Theme: "hooks", Slug: "hooks", Gist: "hook mechanics", Entries: []string{idB}}, + {Theme: "context", Slug: "context", Gist: "context entries", + Entries: []disclosure.StagedEntry{ent("2026-01-01-000000", "Alpha")}}, + {Theme: "hooks", Slug: "hooks", Gist: "hook mechanics", + Entries: []disclosure.StagedEntry{ent("2026-01-02-000000", "Beta")}}, }, } if _, err := disclosure.Apply(rootPath, plan, dir); err != nil { diff --git a/internal/disclosure/apply_test.go b/internal/disclosure/apply_test.go index ea172309..4685c49c 100644 --- a/internal/disclosure/apply_test.go +++ b/internal/disclosure/apply_test.go @@ -50,13 +50,13 @@ func TestApply(t *testing.T) { } rootPath := writeRoot(t, dir, migratedRoot()) - idA := idFor("2026-01-01-000000", "Alpha") - idB := idFor("2026-01-02-000000", "Beta") plan := disclosure.Plan{ Kind: "learning", Assignments: []disclosure.Assignment{ - {Theme: "context", Slug: "context", Gist: "context entries", Entries: []string{idA}}, - {Theme: "hooks", Slug: "hooks", Gist: "hook mechanics", Entries: []string{idB}}, + {Theme: "context", Slug: "context", Gist: "context entries", + Entries: []disclosure.StagedEntry{ent("2026-01-01-000000", "Alpha")}}, + {Theme: "hooks", Slug: "hooks", Gist: "hook mechanics", + Entries: []disclosure.StagedEntry{ent("2026-01-02-000000", "Beta")}}, }, } @@ -130,7 +130,8 @@ func TestApply_Refusals(t *testing.T) { before := readFile(t, p) _, err := disclosure.Apply(p, disclosure.Plan{ Assignments: []disclosure.Assignment{ - {Theme: "t", Slug: "t", Gist: "g", Entries: []string{"x"}}, + {Theme: "t", Slug: "t", Gist: "g", + Entries: []disclosure.StagedEntry{ent("2026-01-01-000000", "x")}}, }, }, dir) if err != errDisc.ErrMultipleThemes { diff --git a/internal/disclosure/collect.go b/internal/disclosure/collect.go index 22862acd..7a8e0703 100644 --- a/internal/disclosure/collect.go +++ b/internal/disclosure/collect.go @@ -82,3 +82,17 @@ func entryIDs(content string) []string { } return ids } + +// entryID is a single staged entry's identity: timestamp joined to title +// by IDSeparator, matching the ids in entryIDs and the keys SplitStaging +// returns. Identity is timestamp+title, not timestamp alone: two entries +// added in the same second share a timestamp but are distinct. +// +// Parameters: +// - e: a staged entry +// +// Returns: +// - string: the entry's identity +func entryID(e StagedEntry) string { + return e.Timestamp + cfgDisc.IDSeparator + e.Title +} diff --git a/internal/disclosure/movehelpers.go b/internal/disclosure/move.go similarity index 69% rename from internal/disclosure/movehelpers.go rename to internal/disclosure/move.go index a8653b5f..1cce3739 100644 --- a/internal/disclosure/movehelpers.go +++ b/internal/disclosure/move.go @@ -11,7 +11,6 @@ import ( "path/filepath" "strings" - cfgDisc "github.com/ActiveMemory/ctx/internal/config/disclosure" cfgFile "github.com/ActiveMemory/ctx/internal/config/file" cfgFs "github.com/ActiveMemory/ctx/internal/config/fs" "github.com/ActiveMemory/ctx/internal/config/token" @@ -33,8 +32,8 @@ import ( // - error: an IO error, or nil func appendTheme(path string, a Assignment, moved map[string]string) error { var spans strings.Builder - for _, id := range a.Entries { - spans.WriteString(moved[id]) + for _, e := range a.Entries { + spans.WriteString(moved[entryID(e)]) } var payload string @@ -65,8 +64,8 @@ func verifyThemes(themeDir string, plan Plan, moved map[string]string) error { if readErr != nil { return readErr } - for _, id := range a.Entries { - if vErr := verifyContains(string(content), moved[id]); vErr != nil { + for _, e := range a.Entries { + if vErr := verifyContains(string(content), moved[entryID(e)]); vErr != nil { return vErr } } @@ -139,56 +138,3 @@ func freshThemesBoundary(remaining string) string { return blank } } - -// renderThemeBullet renders one theme bullet line (no trailing newline), -// in the exact shape parseThemeBullet reads back: -// "- → [](/.md)". -// -// Parameters: -// - a: the assignment supplying theme name, gist, and slug -// - noun: the theme-file subdirectory for this kind -// -// Returns: -// - string: the rendered bullet line -func renderThemeBullet(a Assignment, noun string) string { - link := noun + token.Slash + a.Slug + cfgFile.ExtMarkdown - return token.PrefixListDash + a.Theme + token.MetaSeparator + a.Gist + - cfgDisc.ThemeArrow + cfgDisc.LinkLabelOpen + a.Theme + - cfgDisc.LinkOpen + link + cfgDisc.LinkClose -} - -// lineByteOffsets returns the byte offset of the start of each line in -// content, with a trailing sentinel offset. Index i is the byte offset -// where line i begins; the value can overshoot len(content) for the -// synthetic final element, so callers clamp with clampOffset. -// -// Parameters: -// - content: the text to index -// -// Returns: -// - []int: byte offsets per line, plus one trailing sentinel -func lineByteOffsets(content string) []int { - lines := strings.Split(content, token.NewlineLF) - offs := make([]int, len(lines)+1) - for i, ln := range lines { - offs[i+1] = offs[i] + len(ln) + len(token.NewlineLF) - } - return offs -} - -// clampOffset bounds a byte offset to [0, n]. The final line offset from -// lineByteOffsets overshoots by one newline when content has no trailing -// newline; clamping keeps slice bounds valid without special-casing. -// -// Parameters: -// - off: a candidate byte offset -// - n: the length to clamp to -// -// Returns: -// - int: off bounded to [0, n] -func clampOffset(off, n int) int { - if off > n { - return n - } - return off -} diff --git a/internal/disclosure/regions.go b/internal/disclosure/regions.go index bb45ac94..4730da74 100644 --- a/internal/disclosure/regions.go +++ b/internal/disclosure/regions.go @@ -10,6 +10,7 @@ import ( "strings" cfgDisc "github.com/ActiveMemory/ctx/internal/config/disclosure" + cfgFile "github.com/ActiveMemory/ctx/internal/config/file" "github.com/ActiveMemory/ctx/internal/config/token" ) @@ -121,6 +122,23 @@ func parseThemeBullet(body string) Theme { return t } +// renderThemeBullet renders one theme bullet line (no trailing newline), +// the inverse of parseThemeBullet and in the exact shape it reads back: +// "- → [](/.md)". +// +// Parameters: +// - a: the assignment supplying theme name, gist, and slug +// - noun: the theme-file subdirectory for this kind +// +// Returns: +// - string: the rendered bullet line +func renderThemeBullet(a Assignment, noun string) string { + link := noun + token.Slash + a.Slug + cfgFile.ExtMarkdown + return token.PrefixListDash + a.Theme + token.MetaSeparator + a.Gist + + cfgDisc.ThemeArrow + cfgDisc.LinkLabelOpen + a.Theme + + cfgDisc.LinkOpen + link + cfgDisc.LinkClose +} + // headingLineOffsets returns the byte offset of every line whose trimmed // content equals heading (an exact ATX heading line). Used to find and // count region-delimiting headings. @@ -187,3 +205,40 @@ func lineAt(content string, i int) (line string, next int) { } return content[i : i+rel], i + rel + 1 } + +// lineByteOffsets returns the byte offset of the start of each line in +// content, with a trailing sentinel offset. Index i is the byte offset +// where line i begins; the value can overshoot len(content) for the +// synthetic final element, so callers clamp with clampOffset. SplitStaging +// uses it to cut entry spans on raw byte boundaries. +// +// Parameters: +// - content: the text to index +// +// Returns: +// - []int: byte offsets per line, plus one trailing sentinel +func lineByteOffsets(content string) []int { + lines := strings.Split(content, token.NewlineLF) + offs := make([]int, len(lines)+1) + for i, ln := range lines { + offs[i+1] = offs[i] + len(ln) + len(token.NewlineLF) + } + return offs +} + +// clampOffset bounds a byte offset to [0, n]. The final line offset from +// lineByteOffsets overshoots by one newline when content has no trailing +// newline; clamping keeps slice bounds valid without special-casing. +// +// Parameters: +// - off: a candidate byte offset +// - n: the length to clamp to +// +// Returns: +// - int: off bounded to [0, n] +func clampOffset(off, n int) int { + if off > n { + return n + } + return off +} diff --git a/internal/disclosure/split.go b/internal/disclosure/split.go index df5c90ce..ed94867b 100644 --- a/internal/disclosure/split.go +++ b/internal/disclosure/split.go @@ -14,9 +14,10 @@ import ( "github.com/ActiveMemory/ctx/internal/heading" ) -// FlattenPlan concatenates a plan's per-theme entry IDs into one ordered -// move list, in assignment order. An assignment that names no entries is -// a malformed plan (a theme with nothing to receive), not a no-op. +// FlattenPlan resolves a plan's per-theme staged entries into one ordered +// list of entry identities (timestamp+title), in assignment order. An +// assignment that names no entries is a malformed plan (a theme with +// nothing to receive), not a no-op. // // Parameters: // - p: the digest plan @@ -30,7 +31,9 @@ func FlattenPlan(p Plan) ([]string, error) { if len(a.Entries) == 0 { return nil, errDisc.ErrEmptyAssignment } - ids = append(ids, a.Entries...) + for _, e := range a.Entries { + ids = append(ids, entryID(e)) + } } return ids, nil } diff --git a/internal/disclosure/split_test.go b/internal/disclosure/split_test.go index 2b22139f..5bd29a3c 100644 --- a/internal/disclosure/split_test.go +++ b/internal/disclosure/split_test.go @@ -28,6 +28,11 @@ func idFor(ts, title string) string { return ts + cfgDisc.IDSeparator + title } +// ent builds a staged-entry reference for a plan assignment. +func ent(ts, title string) disclosure.StagedEntry { + return disclosure.StagedEntry{Timestamp: ts, Title: title} +} + // T02: the byte-cut is lossless — a moved entry's span is verbatim // (including the trailing blank lines and "---" separator that // ParseEntryBlocks trims), and the remaining staging is byte-exact. @@ -113,8 +118,10 @@ func TestFlattenPlan(t *testing.T) { ids, err := disclosure.FlattenPlan(disclosure.Plan{ Kind: "learning", Assignments: []disclosure.Assignment{ - {Theme: "t1", Slug: "t1", Gist: "g1", Entries: []string{idA}}, - {Theme: "t2", Slug: "t2", Gist: "g2", Entries: []string{idB}}, + {Theme: "t1", Slug: "t1", Gist: "g1", + Entries: []disclosure.StagedEntry{ent("2026-01-01-000000", "Alpha")}}, + {Theme: "t2", Slug: "t2", Gist: "g2", + Entries: []disclosure.StagedEntry{ent("2026-01-02-000000", "Beta")}}, }, }) if err != nil { diff --git a/internal/disclosure/types.go b/internal/disclosure/types.go index 28bf98f7..90797f34 100644 --- a/internal/disclosure/types.go +++ b/internal/disclosure/types.go @@ -104,18 +104,21 @@ type Plan struct { } // Assignment moves a set of staged entries into one theme and (re)writes -// that theme's gist bullet in the root's ## Themes. +// that theme's gist bullet in the root's ## Themes. Its Entries are the +// same {timestamp, title} shape inspect reports under "staging", so a +// digest plan lifts them verbatim from the inspection — no NUL-joined id +// to hand-author. // // Fields: // - Theme: the theme's name — bullet label, and heading on first use // - Slug: the theme-file basename stem, resolved to /.md // - Gist: the authored one-line gist (spec ### Gist format) -// - Entries: entry IDs to move here, in file order +// - Entries: the staged entries to move here, in file order type Assignment struct { - Theme string `json:"theme"` - Slug string `json:"slug"` - Gist string `json:"gist"` - Entries []string `json:"entries"` + Theme string `json:"theme"` + Slug string `json:"slug"` + Gist string `json:"gist"` + Entries []StagedEntry `json:"entries"` } // ApplyResult reports what a successful Apply did, for CLI output. diff --git a/specs/plans/pd-m3.md b/specs/plans/pd-m3.md index f2ff03d4..f2d4d07a 100644 --- a/specs/plans/pd-m3.md +++ b/specs/plans/pd-m3.md @@ -198,8 +198,8 @@ No `ctx agent` change (spec Non-Goals). No `add`-path change. | T11 | [x] | CLI rejects non-knowledge file | T10 | `internal/cli/disclosure/cmd/apply/run_test.go` | [P] | `apply README.md` exits non-zero with the kind sentinel; file untouched | Contracts | | T12 | [x] | CLI write-safety on malformed root | T10 | `internal/cli/disclosure/cmd/apply/run_write_test.go` | [P] | integration: `apply` on a two-`## Themes` root → non-zero, file byte-identical | Scope/DoD | | T13 | [x] | `doc.go` for the apply cmd + command-wiring guards | T10 | `internal/cli/disclosure/cmd/apply/doc.go` | [P] | `make audit` doc.go/docstring floors pass; `go test ./internal/compliance/ -run TestShippedHooksResolve` and command-tree tests green with the new subcommand | CONVENTIONS | -| T14 | [ ] | `ctx-digest` skill — apply path | T10 | `internal/assets/claude/skills/ctx-digest/SKILL.md` | | frontmatter test green; body describes inspect→propose→gist→**build plan JSON→human approve→`ctx disclosure apply`→confirm via inspect**, states the append→verify→remove guard and conventions=M4 | Skill | -| T15 | [ ] | copilot skill sync | T14 | `internal/assets/integrations/copilot-cli/skills/ctx-digest/**` | | `make check-copilot-skills` green | Skill | +| T14 | [x] | `ctx-digest` skill — apply path | T10 | `internal/assets/claude/skills/ctx-digest/SKILL.md` | | frontmatter test green; body describes inspect→propose→gist→**build plan JSON→human approve→`ctx disclosure apply`→confirm via inspect**, states the append→verify→remove guard and conventions=M4 | Skill | +| T15 | [x] | copilot skill sync | T14 | `internal/assets/integrations/copilot-cli/skills/ctx-digest/**` | | `make check-copilot-skills` green | Skill | | T16 | [ ] |XX **measurement gate** — drive skill apply on realistic fixture | T14,T08,T09 | scratchpad fixture (copy of real LEARNINGS staging) | | drive ctx-digest apply on N≥4 staged entries → ≥2 themes: entries moved, theme files created with bodies, `## Themes` gists written, conservation holds, all four invariants pass, root bounded | Scope/DoD | | T17 | [ ] |XX real LEARNINGS→DECISIONS rollout (**human-gated**) | T16 | `.context/LEARNINGS.md`, `.context/DECISIONS.md`, `.context/learnings/**`, `.context/decisions/**` | | **only on explicit user approval:** apply on the real roots; conservation + all four invariants verified; committed on user sign-off (own commit) | Spec Phasing 3 | | T18 | [ ] |XX milestone gate | T01–T17 | — | | `make lint` 0, `go test ./...` green, `make audit` pass; changed canonical `.md` pass invariants | Scope/DoD | @@ -264,4 +264,5 @@ every task in its range is `[x]`/`[o]` here. | date | what | why | |---|---|---| -| — | — | — | +| 2026-07-18 | `Assignment.Entries` changed from `[]string` (NUL-joined ids) to `[]StagedEntry` (`{timestamp,title}`). `FlattenPlan` now builds the ids internally; new `entryID(StagedEntry)` helper. Data-model refinement — no acceptance criterion changed (conservation/byte-cut/guards identical); T02's `SplitStaging` tests are untouched (it still takes `[]string`), only the plan/`FlattenPlan` constructors changed. | The skill authors the plan JSON; a NUL-separated id is a foot-gun to hand-author. `[]StagedEntry` is exactly `inspect --json`'s `staging` shape, so the skill lifts entries verbatim — no transformation, no ``. Verified end-to-end against the built binary. | +| 2026-07-18 | Split the `movehelpers.go` bag file into semantically-relevant files: `move.go` (the mover's mechanics — append/verify/rewrite), `regions.go` (`renderThemeBullet`, line/offset scanners — beside `parseThemeBullet`/`lineAt`), `collect.go` (`entryID` — beside `entryIDs`). | User convention: "helpers"/"utils" bag files are lazy; functions belong in files named for their concern. [[no-helper-bag-files]] | From fca2436630e55c4260620ec6ebc52935baaca198 Mon Sep 17 00:00:00 2001 From: Jose Alekhinne Date: Sun, 19 Jul 2026 08:35:22 -0700 Subject: [PATCH 5/8] chore(pd-m3): mark T16 measurement gate met in the plan Flip T16 [ ] -> [x] in the pd-m3 plan: the measurement gate that drives ctx-digest apply on a realistic LEARNINGS-staging fixture (>=4 staged entries, >=2 themes) and checks entries moved with their bodies, ## Themes gists written, conservation, all four disclosure invariants, and a bounded root. T17 (rollout on the real roots) stays human-gated; T18 (milestone gate) verified green this session: make lint 0, go test ./... green, make audit pass. Plan: specs/plans/pd-m3.md Signed-off-by: Jose Alekhinne --- specs/plans/pd-m3.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specs/plans/pd-m3.md b/specs/plans/pd-m3.md index f2d4d07a..dbc846c1 100644 --- a/specs/plans/pd-m3.md +++ b/specs/plans/pd-m3.md @@ -200,7 +200,7 @@ No `ctx agent` change (spec Non-Goals). No `add`-path change. | T13 | [x] | `doc.go` for the apply cmd + command-wiring guards | T10 | `internal/cli/disclosure/cmd/apply/doc.go` | [P] | `make audit` doc.go/docstring floors pass; `go test ./internal/compliance/ -run TestShippedHooksResolve` and command-tree tests green with the new subcommand | CONVENTIONS | | T14 | [x] | `ctx-digest` skill — apply path | T10 | `internal/assets/claude/skills/ctx-digest/SKILL.md` | | frontmatter test green; body describes inspect→propose→gist→**build plan JSON→human approve→`ctx disclosure apply`→confirm via inspect**, states the append→verify→remove guard and conventions=M4 | Skill | | T15 | [x] | copilot skill sync | T14 | `internal/assets/integrations/copilot-cli/skills/ctx-digest/**` | | `make check-copilot-skills` green | Skill | -| T16 | [ ] |XX **measurement gate** — drive skill apply on realistic fixture | T14,T08,T09 | scratchpad fixture (copy of real LEARNINGS staging) | | drive ctx-digest apply on N≥4 staged entries → ≥2 themes: entries moved, theme files created with bodies, `## Themes` gists written, conservation holds, all four invariants pass, root bounded | Scope/DoD | +| T16 | [x] |XX **measurement gate** — drive skill apply on realistic fixture | T14,T08,T09 | scratchpad fixture (copy of real LEARNINGS staging) | | drive ctx-digest apply on N≥4 staged entries → ≥2 themes: entries moved, theme files created with bodies, `## Themes` gists written, conservation holds, all four invariants pass, root bounded | Scope/DoD | | T17 | [ ] |XX real LEARNINGS→DECISIONS rollout (**human-gated**) | T16 | `.context/LEARNINGS.md`, `.context/DECISIONS.md`, `.context/learnings/**`, `.context/decisions/**` | | **only on explicit user approval:** apply on the real roots; conservation + all four invariants verified; committed on user sign-off (own commit) | Spec Phasing 3 | | T18 | [ ] |XX milestone gate | T01–T17 | — | | `make lint` 0, `go test ./...` green, `make audit` pass; changed canonical `.md` pass invariants | Scope/DoD | From 23ab5f15f46f520a493f0daaaee312b67fa92140 Mon Sep 17 00:00:00 2001 From: Jose Alekhinne Date: Sun, 19 Jul 2026 08:35:51 -0700 Subject: [PATCH 6/8] fix(disclosure): ignore headings inside HTML comments when splitting a root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The staging-boundary scan (firstLinePrefixOffset) and the themes-section scan (headingLineOffsets) matched any line starting with "## [" or equal to "## Themes" — including lines inside an HTML comment. A knowledge file's format guide can carry such an example: DECISIONS.md's template has a "## [YYYY-MM-DD] Decision Title" line inside its guide. With real entries present the example was harmless — staging still parsed into discrete entries. But once a digest folds every real entry out, the commented example is the only "## [" left above ## Themes: Parse marks the staging zone as starting there, Validate then finds a non-empty staging that parses to zero entries, and returns ErrStagingUnparsable — a valid, fully-folded root wrongly refused. Both scans now skip matches inside (htmlCommentSpans; an unterminated open runs to EOF). The commented example stays in the preamble, staging goes cleanly empty, and Validate passes. No knowledge file changes — the fix is in the parser. Surfaced by the first real LEARNINGS/DECISIONS rollout (pd-m3 T17); mirrors how pd-m1's measurement gate flushed out the insert.AfterHeader tail-truncation bug. Regression tests: a commented "## [" example is not staging; a commented "## Themes" is not counted as a duplicate section. Plan: specs/plans/pd-m3.md Signed-off-by: Jose Alekhinne --- internal/config/token/delim.go | 4 ++ internal/disclosure/parse_test.go | 41 ++++++++++++++++++++ internal/disclosure/regions.go | 63 +++++++++++++++++++++++++++++-- 3 files changed, 104 insertions(+), 4 deletions(-) diff --git a/internal/config/token/delim.go b/internal/config/token/delim.go index f8b6c28a..b85e3ec9 100644 --- a/internal/config/token/delim.go +++ b/internal/config/token/delim.go @@ -59,6 +59,10 @@ const ( Plus = "+" // Hash is the hash/pound character. Hash = "#" + // HTMLCommentOpen begins an HTML/Markdown comment. + HTMLCommentOpen = "" // ParentDir is the relative parent directory component. ParentDir = ".." // FrontmatterDelimiter is the YAML frontmatter diff --git a/internal/disclosure/parse_test.go b/internal/disclosure/parse_test.go index 69e805c9..c07130ca 100644 --- a/internal/disclosure/parse_test.go +++ b/internal/disclosure/parse_test.go @@ -109,3 +109,44 @@ func TestParse_ConventionKind(t *testing.T) { t.Errorf("themes parse wrong: %+v", r.Themes) } } + +// A "## [" line inside an HTML comment (a knowledge file's format guide — +// e.g. DECISIONS.md's "## [YYYY-MM-DD] Decision Title" example) must not be +// mistaken for a staging entry. Regression: after a fold empties the real +// staging, such a commented example would otherwise leave a non-empty, +// unparsable staging zone and trip Validate (ErrStagingUnparsable). +func TestParse_CommentedEntryExampleNotStaging(t *testing.T) { + const root = "# Decisions\n\n\n\n" + + "## Themes\n\n- sec — security → [sec](decisions/sec.md)\n" + + r := disclosure.Parse(root, disclosure.KindDecision) + if strings.TrimSpace(r.Staging) != "" { + t.Errorf("Staging = %q, want empty (commented example belongs in preamble)", + r.Staging) + } + if !strings.Contains(r.Preamble, "## [YYYY-MM-DD]") { + t.Errorf("commented example missing from preamble; preamble=%q", r.Preamble) + } + if r.Reconstruct() != root { + t.Error("round-trip mismatch after comment-aware parse") + } + if err := disclosure.Validate(r); err != nil { + t.Errorf("Validate = %v, want nil (commented example is not real staging)", err) + } +} + +// A "## Themes" line inside an HTML comment is an example, not a second +// themes section: Validate must not read it as a duplicate (ErrMultipleThemes). +func TestParse_CommentedThemesNotCounted(t *testing.T) { + const root = "# Learnings\n\n\n\n" + + "## Themes\n\n- hooks — hook mechanics → [hooks](learnings/hooks.md)\n" + + r := disclosure.Parse(root, disclosure.KindLearning) + if !r.HasThemes { + t.Fatal("HasThemes = false, want true (one real ## Themes)") + } + if err := disclosure.Validate(r); err != nil { + t.Errorf("Validate = %v, want nil (commented ## Themes is not a duplicate)", err) + } +} diff --git a/internal/disclosure/regions.go b/internal/disclosure/regions.go index 4730da74..b716e044 100644 --- a/internal/disclosure/regions.go +++ b/internal/disclosure/regions.go @@ -141,7 +141,8 @@ func renderThemeBullet(a Assignment, noun string) string { // headingLineOffsets returns the byte offset of every line whose trimmed // content equals heading (an exact ATX heading line). Used to find and -// count region-delimiting headings. +// count region-delimiting headings. Headings inside an HTML comment are +// skipped — they are illustrative examples, not structure. // // Parameters: // - content: the text to scan @@ -150,10 +151,11 @@ func renderThemeBullet(a Assignment, noun string) string { // Returns: // - []int: byte offsets of each matching line's start, in order func headingLineOffsets(content, heading string) []int { + spans := htmlCommentSpans(content) var offsets []int for i := 0; i < len(content); { line, next := lineAt(content, i) - if strings.TrimSpace(line) == heading { + if strings.TrimSpace(line) == heading && !insideAnySpan(i, spans) { offsets = append(offsets, i) } if next == -1 { @@ -165,7 +167,8 @@ func headingLineOffsets(content, heading string) []int { } // firstLinePrefixOffset returns the byte offset of the first line that -// starts with prefix, or -1. +// starts with prefix, or -1. A match inside an HTML comment is skipped: a +// "## [" example in a knowledge file's format guide is not a staging entry. // // Parameters: // - content: the text to scan @@ -174,9 +177,10 @@ func headingLineOffsets(content, heading string) []int { // Returns: // - int: byte offset of the first matching line, or -1 if none func firstLinePrefixOffset(content, prefix string) int { + spans := htmlCommentSpans(content) for i := 0; i < len(content); { line, next := lineAt(content, i) - if strings.HasPrefix(line, prefix) { + if strings.HasPrefix(line, prefix) && !insideAnySpan(i, spans) { return i } if next == -1 { @@ -187,6 +191,57 @@ func firstLinePrefixOffset(content, prefix string) int { return -1 } +// htmlCommentSpans returns the [start, end) byte ranges of every HTML +// comment (token.HTMLCommentOpen … token.HTMLCommentClose) in content; an +// unterminated open runs to EOF. The structural heading scans use it to +// ignore headings that are only illustrative examples inside a comment — +// e.g. the "## [YYYY-MM-DD] Title" line in a knowledge file's format guide, +// which must not be mistaken for a staging entry. +// +// Parameters: +// - content: the text to scan +// +// Returns: +// - [][2]int: {start, end} byte ranges in order; nil when none +func htmlCommentSpans(content string) [][2]int { + var spans [][2]int + for i := 0; i < len(content); { + open := strings.Index(content[i:], token.HTMLCommentOpen) + if open == -1 { + break + } + start := i + open + afterOpen := start + len(token.HTMLCommentOpen) + rel := strings.Index(content[afterOpen:], token.HTMLCommentClose) + if rel == -1 { + spans = append(spans, [2]int{start, len(content)}) + break + } + end := afterOpen + rel + len(token.HTMLCommentClose) + spans = append(spans, [2]int{start, end}) + i = end + } + return spans +} + +// insideAnySpan reports whether byte offset off falls within any of the +// [start, end) ranges (from htmlCommentSpans). +// +// Parameters: +// - off: the byte offset to test +// - spans: byte ranges to test against +// +// Returns: +// - bool: true when off is inside a span +func insideAnySpan(off int, spans [][2]int) bool { + for _, s := range spans { + if off >= s[0] && off < s[1] { + return true + } + } + return false +} + // lineAt returns the line beginning at byte offset i (without its // trailing newline) and the offset of the next line's start, or -1 when // this is the last line. From c933c91ca67adf2878f03ad40811f1f707441276 Mon Sep 17 00:00:00 2001 From: Jose Alekhinne Date: Sun, 19 Jul 2026 08:35:51 -0700 Subject: [PATCH 7/8] =?UTF-8?q?chore(pd-m3):=20first=20real=20disclosure?= =?UTF-8?q?=20rollout=20=E2=80=94=20fold=20LEARNINGS=20+=20DECISIONS=20(T1?= =?UTF-8?q?7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drive ctx disclosure apply on the real canonical roots — progressive disclosure's first write to live knowledge files (the clobber-risk class the M1 guards exist for). - LEARNINGS.md: 88 staged entries -> 12 themes under .context/learnings/; root 1831 -> 33 lines (bounded to the ## Themes gists + links). - DECISIONS.md: 63 staged entries -> 10 themes under .context/decisions/; root 1638 -> 59 lines (template header preserved byte-identical). Every entry body moved verbatim into exactly one theme file. Conservation verified independently (each original title present once, 0 missing, 0 duplicated) and the guarded move held: bodies appended and byte-verified before each root was rewritten once, last. Themes and gists were human-approved before apply. Plan: specs/plans/pd-m3.md Signed-off-by: Jose Alekhinne --- .context/DECISIONS.md | 1603 +-------------- .context/LEARNINGS.md | 1826 +---------------- .context/decisions/cli-command-surface.md | 158 ++ .context/decisions/context-model-and-state.md | 254 +++ .../decisions/hooks-session-and-telemetry.md | 218 ++ .context/decisions/integrations-and-assets.md | 133 ++ .../journal-and-knowledge-lifecycle.md | 162 ++ .context/decisions/kb-and-vocabulary.md | 83 + .../package-structure-and-quality-gates.md | 198 ++ .../decisions/product-community-and-deps.md | 125 ++ .../decisions/security-and-permissions.md | 43 + .../skills-and-agent-architecture.md | 237 +++ .context/learnings/audit-lint-compliance.md | 175 ++ .context/learnings/cli-command-design.md | 65 + .context/learnings/context-model-and-state.md | 163 ++ .context/learnings/docs-and-templates.md | 170 ++ .../editorial-and-product-signals.md | 141 ++ .../learnings/environment-and-platform.md | 92 + .context/learnings/git-and-signing.md | 67 + .context/learnings/go-idioms-and-structure.md | 199 ++ .context/learnings/hooks-and-integration.md | 174 ++ .context/learnings/model-context-window.md | 43 + .context/learnings/skills-agents-and-tasks.md | 363 ++++ .../learnings/text-markdown-serialization.md | 184 ++ 24 files changed, 3473 insertions(+), 3403 deletions(-) create mode 100644 .context/decisions/cli-command-surface.md create mode 100644 .context/decisions/context-model-and-state.md create mode 100644 .context/decisions/hooks-session-and-telemetry.md create mode 100644 .context/decisions/integrations-and-assets.md create mode 100644 .context/decisions/journal-and-knowledge-lifecycle.md create mode 100644 .context/decisions/kb-and-vocabulary.md create mode 100644 .context/decisions/package-structure-and-quality-gates.md create mode 100644 .context/decisions/product-community-and-deps.md create mode 100644 .context/decisions/security-and-permissions.md create mode 100644 .context/decisions/skills-and-agent-architecture.md create mode 100644 .context/learnings/audit-lint-compliance.md create mode 100644 .context/learnings/cli-command-design.md create mode 100644 .context/learnings/context-model-and-state.md create mode 100644 .context/learnings/docs-and-templates.md create mode 100644 .context/learnings/editorial-and-product-signals.md create mode 100644 .context/learnings/environment-and-platform.md create mode 100644 .context/learnings/git-and-signing.md create mode 100644 .context/learnings/go-idioms-and-structure.md create mode 100644 .context/learnings/hooks-and-integration.md create mode 100644 .context/learnings/model-context-window.md create mode 100644 .context/learnings/skills-agents-and-tasks.md create mode 100644 .context/learnings/text-markdown-serialization.md diff --git a/.context/DECISIONS.md b/.context/DECISIONS.md index 87dd2cfb..5ce1eb8f 100644 --- a/.context/DECISIONS.md +++ b/.context/DECISIONS.md @@ -45,1594 +45,15 @@ For significant decisions: --> -## [2026-07-16-215955] Progressive disclosure: canonical knowledge files become bounded roots with themed rollout - -**Status**: Accepted - -**Context**: Canonical knowledge files (LEARNINGS/DECISIONS/CONVENTIONS) grow without bound as entries accumulate, and the entries stay valid, so nothing can be dropped: time-sharding plus a load-excluded 'cold' bucket was already rejected (a supersession pass found only ~1.5% cold across 162 entries; recency-gating is dangerous because old approximately equals live). At sufficient scale an agent that legitimately wants system understanding reads every decision, then every learning, and exhausts its context window. Consumption discipline (headings-first via ctx index) is necessary but NOT sufficient: an agent can always choose to read the whole file, and will when it wants completeness. Consolidation does not help either: a 2026-07-16 pass moved LEARNINGS only 98 to 88 because the corpus is dense with distinct signal, not redundancy. - -**Decision**: Progressive disclosure: canonical knowledge files become bounded roots with themed rollout - -**Rationale**: Structural boundedness makes 'read it all and die' impossible rather than merely discouraged, applying the project's own 'mechanical gates over prose' principle to context loading. Storing the generated synthesis is justified precisely BECAUSE it is expensive to recompute (an LLM pass), unlike the trivially-recomputable INDEX block whose storage was pure waste and pure drift: the cost/benefit of storing derived state inverts when recomputation is costly. Reading the bounded root alone yields compressed history PLUS verbatim recent delta, a complete current picture with no staleness gap, because staging IS the un-digested remainder by construction. The staging zone therefore serves as the watermark, so no state file is needed: a .context/state/digest.json would be a second source of truth about a fact the file already states (the INDEX-block mistake in a new coat), and its one advantage (detecting a misplaced entry) is recovered by a cheap structural invariant instead. Verified against the code: the add path anchors on the first line-start '## [' and falls back to AfterHeader, so entries always land above '## Themes' even when staging is empty, meaning ctx decision/learning add needs ZERO change; and because the root is bounded, the existing ctx agent packet needs no rewire either. - -**Consequence**: Entry bodies now move between files, which is the clobber risk class that index.Validate exists for, so the pass must append to the theme file, verify byte-presence, and only then remove from staging; it validates preconditions, fails loud with no auto-repair (matching the learning-add clobber fix precedent), and prefers duplication over loss on crash. New structural invariants become mechanically checkable: no line-start '## [' below '## Themes'; root gists and theme files are 1:1; every entry lives in exactly one place. CONVENTIONS needs an extra trailing '## Recent' staging heading because AppendAtEnd plus '###' prose sections would nest ambiguously inside '## Themes', and conventions edits-in-place now happen in theme files behind a link. Theme proliferation remains a slow unbounded growth vector on the root; the structure is self-similar, so an overgrown theme file can itself become a root (nesting deferred, not precluded). Scope is LEARNINGS, DECISIONS, CONVENTIONS; CONSTITUTION (small by design) and TASKS (auto-archived) are excluded. The pass is agent-driven and human-gated (agent suggests themes, human can override), triggered by suggestion only from the growth nudge, /ctx-remember, and /ctx-wrap-up, never performed inline at wrap-up because the human is leaving at that point. Spec: specs/progressive-disclosure.md. - ---- - -## [2026-07-15-000000] Live ceremony credit reuses the daily throttle marker, suppressing the day's other ceremony nudge - -**Status**: Accepted - -**Context**: The `check-ceremony` hook nudges the operator to open -sessions with `/ctx-remember` and close them with `/ctx-wrap-up`. It was -journal-driven only, so it self-nudged on the very prompt that ran a -ceremony and kept nudging until the session was journal-imported. The -fix (specs/ceremony-nudge-live-session.md) parses the live prompt and, -when it is a ceremony command, credits it immediately. The question was -*how* to record that live credit. - -**Alternatives Considered**: -- Reuse the existing per-day throttle marker (`ceremony-reminded`): - touching it on a live ceremony credits the session and settles the - ceremony question for the day. Pros: zero new state; one guard; - matches the check's existing once-per-day cadence. Cons: crediting a - live `/ctx-remember` also suppresses a would-be `/ctx-wrap-up` nudge - for the rest of that day (and vice-versa). -- Per-ceremony live markers (separate remember/wrap-up credit): Pros: - the other ceremony can still nudge the same day. Cons: new state files, - a second throttle axis, and more moving parts for a coarse daily nudge. - -**Decision**: Reuse the single daily throttle marker. On a live ceremony -prompt, `check-ceremony` touches `ceremony-reminded` and returns without -nudging. - -**Rationale**: The check is a deliberately coarse daily cadence, not a -per-ceremony ledger. An operator actively running one ceremony does not -need to be nagged about the other on that same prompt, and the marker's -"settled for today" semantics already express exactly that. The extra -state a per-ceremony scheme buys is not worth it for a once-a-day hint. - -**Consequence**: Running either ceremony live suppresses both ceremony -nudges for the rest of that day. This is intended; the trade-off is -documented in the spec's Trade-off section. If a per-ceremony cadence is -ever wanted, this is the decision to revisit. - -**Related**: See spec specs/ceremony-nudge-live-session.md. - -## [2026-07-04-152957] Statusline informs, never gates - -**Status**: Accepted - -**Context**: Porting a cost-aware status line whose reference design escalates to a spend alarm with a model-switch suggestion when an expensive model family is detected - -**Decision**: Statusline informs, never gates - -**Rationale**: A family-substring rule carries no task context: the expensive model is often the cheaper choice per outcome, and a statusline cannot see outcomes, so it must not prescribe. Alarms also only reach operators who are present and already see the dollar figure; unattended overspend belongs to loop/cron notifications - -**Consequence**: ctx system statusline renders model, ctx%, and plain cost only; show_cost exists for screen-sharing; any future budget enforcement must be a separate, deliberate feature, not statusline creep - ---- - -## [2026-07-03-182236] Keep sonnet-4-6 at the 200k default despite the API catalog listing 1M - -**Status**: Accepted - -**Context**: Claude 5 window-detection fix (specs/model-context-window-fable.md); Anthropic's current catalog shows Sonnet 4.6 with a 1M window at the API level - -**Decision**: Keep sonnet-4-6 at the 200k default despite the API catalog listing 1M - -**Rationale**: ctx models Claude Code's per-session gating, not raw API capability: Sonnet 4.6's 1M is an explicit opt-in already handled by ModelSuffix1M and ClaudeSettingsHas1M, and the existing test deliberately pins the 200k default - -**Consequence**: Revisit only with session-level evidence (a sonnet-4-6 JSONL showing 1M without the [1m] suffix); over-reporting the window would silence the context hook while a session genuinely fills - ---- - -## [2026-06-07-112203] ctx-dream executor is a documented contract, not a hardcoded cron/claude assumption - -**Status**: Accepted - -**Context**: Settling ctx-dream v1 open questions. The executor runs the -out-of-band dream pass (read ideas/, classify+ground, write proposals). Question -was cron 'claude -p' vs a raw Anthropic-API scheduled loop. - -**Decision**: ctx-dream executor is a documented contract, not a hardcoded -cron/claude assumption - -**Rationale**: cron 'claude -p' is the reference executor (reuses Claude Code -auth, tool-calling, and PreToolUse hooks so the three guards are structural for -free; matches the existing skill draft and the cheap-validation goal). But we -must NOT assume it is the only executor: other harnesses (different AI CLI, raw -API loop, CI runner) must be able to run the same dream. So ctx owns an -executor-agnostic Go core (dreams/ layout, state record, ledger, proposal -schema, the three guards as callable logic) and the executor is a documented -contract: run one bounded pass, enforce the three guards STRUCTURALLY (Claude -Code via PreToolUse hooks; API loop via in-loop tool executor), fail loud, write -proposals-only into dreams/. Dream is opt-in, not enabled by default. - -**Consequence**: Guards live as reusable Go logic in internal/dream/, not only -as a hook script. Two user-facing docs are required: a Claude Code enablement -guide and an executor-contract reference for other harnesses. The serendipity -review skill is split into its own spec (specs/ctx-serendipity.md). v1 ships the -cron/claude-p reference path but the data contract + guards stay -executor-portable. - ---- - -## [2026-06-07-180001] Output belongs in write/ — taxonomy and emission style (consolidated) - -**Consolidated from**: 3 entries (2026-03-17 to 2026-04-03) - -- Output functions belong in write/ (flat by domain, one package per CLI - feature); core/ owns logic and types, cmd/ owns Cobra orchestration. No - cmd.Print* calls in internal/cli/ outside internal/write/ — enables - localization and clean separation. -- Within write/, use pre-compute-then-print: functions with 4+ Printlns - pre-compute conditional strings then emit one multiline block (TplXxxBlock), - rejecting text/template (runtime errors, only 38/160 functions benefit); - trivial and loop-based functions stay imperative. - ---- - -## [2026-06-07-180002] Package taxonomy and shared-code placement (consolidated) - -**Consolidated from**: 6 entries (2026-03-06 to 2026-05-17) - -- Three-zone taxonomy: cmd/ for Cobra wiring, core/ for logic and types, assets/ - for templates and user-facing text; config/ for structural constants only. - Symmetry makes navigation agent-friendly; shared domain types live in domain - packages (internal/entry), not CLI subpackages. -- Pure-logic functions return data structs; callers own I/O, file writes, and - reporting — lets MCP and CLI callers control output independently. - Receiver-stateless methods become free functions; callbacks that vary only by - a string key become text-key data. -- Shared formatting utilities (Pluralize, Duration, TruncateFirstLine, etc.) - live in internal/format, not duplicated across CLI subpackages. -- internal/parse is the home for shared text-to-typed-value conversions - (parse.Date first), scoped to avoid becoming a junk drawer. -- Every cross-package type goes in internal/entity/ — the cross-package-types - audit (zero grandfathered violations) is the hardline; entity.Sentinel lives - there even though it is a behavioral helper, over per-package duplication - across 9 err packages. -- Multi-segment directory paths are single composite constants - (DirHooksMessages, DirMemoryArchive), not joined from segment constants. - ---- - -## [2026-06-07-180003] Error handling: centralized in internal/err, domain-file taxonomy (consolidated) - -**Consolidated from**: 2 entries (2026-03-06 to 2026-03-14) - -- Errors centralize in internal/err, not per-package err.go files — single - location makes duplicates visible, enables sentinel errors, prevents - broken-window accumulation; all CLI err.go files migrated and deleted. -- The monolithic 1995-line errors.go (188 functions) was split into 22 domain - files (backup, config, crypto, …, validation) named by responsibility, so - error constructors are findable by domain. - ---- - -## [2026-06-07-180004] config/ as constants home and the magic-value audit (consolidated) - -**Consolidated from**: 4 entries (2026-03-23 to 2026-04-04) - -- String-typed enums (type Foo string + const blocks) belong in config/, not - domain packages — types without behavior live in config; promote to entity/ - only when methods/interfaces appear. -- TestNoMagicStrings/TestNoMagicValues dropped the const/var exemption outside - config/ (it masked 156+ string and 7 numeric constants in the wrong place); - naming a constant in the wrong package does not fix the structural problem. -- The 60+ config/ sub-package "explosion" is correct, not a bottleneck: Go's - compile unit is the package, so granular packages give precise dependency - tracking and minimal recompile; the DX cost is fixed by a README decision - tree, not restructuring. -- Cross-package magic strings (e.g.
 HTML tags used by normalize and
-  format) promote to shared config constants (config/marker TagPre/TagPreClose);
-  package-local copies deleted.
-
----
-
-## [2026-06-07-180005] YAML text externalization, init, and drift guards (consolidated)
-
-**Consolidated from**: 5 entries (2026-03-13 to 2026-04-03)
-
-- All user-facing text externalizes to embedded YAML domain files
-  (commands/flags/text/examples split via dedicated loaders), justified by agent
-  legibility (named DescKey constants as traversable graphs) and drift
-  prevention, not i18n; the 3-file ceremony (DescKey + YAML + write/err fn) is
-  the accepted cost.
-- Static embedded data and resource lookups use an explicit Init() called
-  eagerly at startup, not per-accessor sync.Once or package-level init() —
-  makes the startup dependency visible and testable; maps unexported, accessors
-  are plain lookups.
-- A Go↔YAML linkage check (lint-drift check 5, shell grep+comm) catches
-  orphaned/broken DescKey↔YAML links and cross-namespace duplicates at CI
-  time, preventing silent runtime failures.
-- The build target depends on sync-why so derived assets/why/ files cannot drift
-  from their docs/ sources — build fails without sync.
-- MCP resource name constants live in config/mcp/resource (parallel to
-  config/mcp/tool); the resource→file mapping stays in server/resource (too
-  many cross-cutting deps for a config package), pre-built once at server init
-  for O(1) lookup.
-
----
-
-## [2026-06-07-180006] CWD-anchored context model (consolidated)
-
-**Consolidated from**: 5 entries (2026-04-13 to 2026-05-21)
-
-- Walk boundary uses git as a hint, not a requirement: walkForContextDir
-  consults findGitRoot to anchor ancestor .context candidates and falls back to
-  CWD when no git is found — fixes nested-repo binding without making git
-  mandatory or relying on unreliable project markers.
-- ctx activate is strict-CWD (drop upward walk): state-setting commands follow
-  git's read-vs-state pattern (read walks, state refuses to cross repo
-  boundaries); workspace-shared layouts are preserved by user action (cd first),
-  not inferred walk.
-- Anchor ctx to CWD entirely: drop activate/deactivate, the env-var (CTX_DIR)
-  resolver, and all walks. With .context/ mandated as .git/'s sibling, every
-  resolver collapses to os.Stat; keeping any walk would force maintaining two
-  implementations. Mental model matches helm/terraform/Claude Code; ~600-1000
-  LOC net deletion (specs/cwd-anchored-context.md).
-- Spec steps 1+2 (resolver swap + init-guard removal) merged into one commit
-  because step 1 cannot compile without step 2; cleanest commit boundaries beat
-  strict spec adherence — remaining steps stay discrete (4-commit
-  decomposition, not the spec's 5).
-- Substrate vs. artifact placement: cognitive substrate (read AND written via
-  ctx-mediated paths) lives under .context/; project artifacts (read/edited
-  directly by humans, e.g. specs/, CLAUDE.md, docs/) live at root. kb passes all
-  three coupling tests (mediated queries, pipeline coupling, skill discipline)
-  so it stays under .context/.
-
----
-
-## [2026-06-07-180007] Encryption key resolution and migration (consolidated)
-
-**Consolidated from**: 3 entries (2026-03-01 to 2026-06-02)
-
-- Single global key at ~/.ctx/.ctx.key (matches ~/.claude/ convention); one key
-  per machine covers ~99% of users. Replaced the over-engineered slug-based
-  per-project key system; project-local key-next-to-ciphertext was a security
-  antipattern that broke in worktrees. [Original 2026-03-01 entry was marked
-  Superseded by the 2026-03-02 simplification.]
-- Legacy-key auto-migration replaced with a stderr warning only: warn-only is
-  simpler, avoids silent file operations, and keeps the (small, alpha) userbase
-  in control; docs carry migration instructions.
-- Removed the implicit project-local .context/.ctx.key auto-detection tier from
-  ResolveKeyPath: resolution is now (1) explicit .ctxrc key_path, (2) global
-  ~/.ctx/.ctx.key, (3) project-local only as a degenerate fallback when home is
-  unavailable. The local tier was the only thing making worktrees differ from
-  side-by-side terminals; its removal is net deletion, and the previously-silent
-  fire-path decrypt failure is now surfaced.
-
----
-
-## [2026-06-07-180008] ctxctl maintainer binary and out-of-band audit channel (consolidated)
-
-**Consolidated from**: 4 entries (2026-05-24 to 2026-05-28)
-
-- Discipline enforcement belongs on the verbatim-relay channel, run out-of-band:
-  relay is the one discipline channel that survives tunnel vision; run the
-  auditor in a separate Claude Code session for fresh-context judgment and cost
-  control. New generic channel: a skill writes .context/audit/.md, a
-  check-audit hook relays unread reports verbatim, ctx audit list/show/dismiss
-  manages lifecycle (digest-bound dismissal).
-- [Superseded] ctxctl first placed at cmd/ctxctl in the same Go module:
-  binary-level isolation via transitive-import exclusion, zero relocation of
-  existing internal/audit files, on the belief a separate go.mod couldn't import
-  the parent's internal/.
-- That belief was empirically disproved: a nested module lexically under the
-  parent path CAN import internal/. So ctxctl became a separate Go module at
-  tools/ctxctl (own go.mod) — a hard module boundary guarantees ctx can never
-  import ctxctl (the asymmetric requirement that matters); one-directional
-  ctxctl→ctx coupling is acceptable for disposable maintainer tooling. A
-  go.work wires the workspace; a guard test asserts cmd/ctx never imports
-  internal/ctxctl.
-- ctxctl is PATH-installed alongside ctx (build to dist/, install to
-  /usr/local/bin/ctxctl) for clean repo roots and one binary across all
-  worktrees, mirroring ctx's install pattern; the local hook calls ctxctl from
-  PATH.
-
----
-
-## [2026-06-07-180009] KB editorial pipeline (Phase KB) design (consolidated)
-
-**Consolidated from**: 6 entries (2026-05-10 to 2026-05-16)
-
-- Lift the sibling clean-room project's battle-tested editorial pipeline into
-  ctx as v1, paired with handover: it is field-tested under production use and
-  your-project is already paying the workaround tax (N=1 lived validation); lift
-  the whole shape with a non-colliding rename, not hedge-and-defer.
-- Mandate git as an architectural precondition: persistent-memory is dishonest
-  without an undo layer (git reflog); refuse-on-no-git rather than auto-git-init
-  (ctx never modifies the filesystem outside .context/); eliminates commit:none
-  dead-code branches. Breaking change in next minor.
-- KB ontology is pipeline-only-writer; no /ctx-kb-decide skill: in a KB you
-  don't decide, you increase confidence — even NL assertions are
-  evidence-capture events, not decision-capture. KB surface stays small (4 mode
-  skills + ctx kb note); canonical capture skills unchanged.
-- Phase KB ships handover + editorial paired, not split: the closeout/fold
-  mechanism is the integration point; shipping paired stresses the fold on day
-  one rather than retrofitting it.
-- Editorial constitution lives at .context/ingest/KB-RULES.md, not
-  CONSTITUTION.md: lifts the sibling project's resolved naming-collision (their
-  10-INGEST_RULES.md rename) so ctx CONSTITUTION.md keeps its singular meaning;
-  same discipline carries to domain-decisions.md vs DECISIONS.md.
-- Phase KB lifts the *current* upstream pipeline shape (pass-mode contract,
-  completion circuit breaker, source-coverage state-machine ledger,
-  topic-adjacency pre-flight, cold-reader rubric, folder-shaped topics from day
-  one, CLI-as-scaffold-authority), superseding the brief's 4-phase model —
-  lifting the older shape would re-fight wounds the upstream author already
-  healed.
-
----
-
-## [2026-06-07-180010] Companion-tool integration: peer-MCP, no gateway (consolidated)
-
-**Consolidated from**: 6 entries (2026-03-06 to 2026-05-23)
-
-- Peer MCP model for external tools (GitNexus, context-mode): side-by-side
-  servers each queried independently by the agent, chosen over orchestrator/hub
-  models to respect ctx's markdown-on-filesystem invariant and avoid
-  coupling/plugin registries.
-- Skills stay CLI-based; MCP Prompts are the protocol equivalent: CLI is always
-  available (PATH prereq), MCP is optional config, hooks are always CLI — two
-  access patterns in one tool is gratuitous complexity.
-- Recommend companion RAGs as peer MCP servers, not bridged through ctx: MCP is
-  the composition layer; ctx is context, RAGs are intelligence — no bridging,
-  plugin system, or schema abstraction.
-- Companion tools documented as optional MCP enhancements with a runtime check
-  (/ctx-remember smoke-tests MCPs at session start; companion_check:false
-  suppresses) so users learn what enhances their workflow without being forced
-  to install.
-- MCP gateway not worth the coupling cost: a gateway would make ctx own
-  install/uninstall/version/error-surface for tools it doesn't ship
-  (bidirectional ownership coupling); composition is already MCP's job and the
-  skills already work peer-to-peer. The pluggable-graph-tool task was skipped as
-  a direct consequence (pluggability without ownership is incoherent).
-- Skill body text uses capability-first language with canonical tools as
-  examples; install-guide docs name canonical implementations directly
-  (newcomers need a recommendation); allowed-tools frontmatter stays
-  MCP-specific (genericizing to mcp__* is a permission expansion). Pure text
-  rewrite, no new abstraction layer.
-
----
-
-## [2026-06-07-180011] Localizable vocabulary and i18n primitives (consolidated)
-
-**Consolidated from**: 5 entries (2026-03-14 to 2026-05-23)
-
-- Session prefixes are parser vocabulary, not i18n text: header-recognition
-  patterns move to .ctxrc session_prefixes (default Session:), separating
-  content recognition from interface language so users parse multilingual
-  session files without code changes.
-- Classify rules are user-configurable via .ctxrc (classify_rules overrides
-  config/memory defaults) — same pattern as session_prefixes, for
-  non-English/specialized domains.
-- Spec signal words and the nudge threshold (spec_signal_words,
-  spec_nudge_min_len) are .ctxrc-configurable — signal words are language- and
-  project-dependent.
-- Keep i18n.Fold strict (Unicode case-fold, İ≠i, for identifier
-  dedup/parsing/security comparison); add i18n.MatchKey (Fold + NFKD + strip
-  combining marks) as a separate diacritic-insensitive primitive for matching
-  user input against vocabulary lists. Two explicit-contract primitives beat one
-  conflated primitive or an options flag.
-- Placeholder overrides use EXTEND, not REPLACE, semantics (diverging from
-  SessionPrefixes' REPLACE): the dominant bilingual EN+TR case needs both
-  default and added placeholders rejected simultaneously; REPLACE would silently
-  lose baseline coverage. Opt-in placeholders_replace:true reserved if REPLACE
-  is later wanted.
-
----
-
-## [2026-06-07-180012] Embedded assets and editor-integration harnesses (consolidated)
-
-**Consolidated from**: 7 entries (2026-04-01 to 2026-05-22)
-
-- Embedded foreign-language assets (TS/Bash/PowerShell/YAML) under
-  internal/assets/ are intentional, not a smell: every file is //go:embed'd into
-  the ctx binary and written at ctx setup; internal/ is about import privacy,
-  not source language. The fix for the legibility gap was a contract README, not
-  relocation (//go:embed can't reference ../).
-- assets/hooks/ split into assets/integrations/ (tool-integration assets:
-  Copilot instructions, AGENTS.md, CLI scripts/skills) + assets/hooks/messages/
-  (hook-system templates) — integration assets are not hooks.
-- Embedded harnesses (//go:embed'd, shipped via ctx setup) and
-  separately-published harnesses (e.g. VS Code extension → marketplace, own
-  cadence) are first-class peers with distinct CI/release pipelines; a new
-  harness declares which pattern it follows before placing files.
-- OpenCode plugin ships without a tool.execute.before hook: the natural fit
-  (block-dangerous-commands) isn't a ctx Go subcommand and shimming would brick
-  the editor (Cobra exit-1 read as {blocked:true}) on installs without the
-  Claude wrapper. This omission is permanent — block-dangerous-commands will
-  not be promoted to a ctx Go subcommand; the perpetually-pending re-add task is
-  closed.
-- Under cwd-anchored, the OpenCode plugin's agent shell tool can't be anchored
-  to project root (the @opencode-ai/plugin SDK exposes only env, not cwd on
-  shell.env); drop the shell.env handler and document launch-from-root.
-  Plugin-internal ceremony calls stay anchored; the cwd-anchored error message
-  is self-fixing.
-- Editor-integration plugins must filter post-commit to actual git commit
-  invocations (regex on the extracted command), not fire on every shell call —
-  firing on noise trains users to ignore nudges.
-
----
-
-## [2026-06-07-180013] Context injection, hooks, and session-state architecture (consolidated)
-
-**Consolidated from**: 8 entries (2026-02-26 to 2026-05-08)
-
-- Context injection v2: extract ~600 lines of diagrams out of FileReadOrder (53%
-  token drop); auto-inject content via additionalContext (soft directives hit a
-  ~75-85% compliance ceiling); imperative framing with an unconditional
-  compliance checkpoint, verbatim relay as fallback. Inject
-  CONSTITUTION/CONVENTIONS/ARCHITECTURE/PLAYBOOK verbatim, DECISIONS/LEARNINGS
-  index-only, TASKS mention-only (~7,700 tokens).
-- Context-load-gate injects only CONSTITUTION + AGENT_PLAYBOOK_GATE (~2k
-  tokens), not the full ReadOrder: hard rules must be present pre-action;
-  everything else is pulled on-demand. AGENT_PLAYBOOK_GATE.md must stay in sync
-  with AGENT_PLAYBOOK.md.
-- .context/state/ is the gitignored, project-scoped home for ephemeral runtime
-  state (following the .context/logs/ precedent); all session state (cooldown
-  tombstones, pause/throttle markers) consolidated there from /tmp, dropping the
-  cleanup-tmp SessionEnd hook (4 hook events → 3).
-- Gate mkdir inside state.Dir() rather than per-caller so "no .context/state/ in
-  uninitialized projects" is structurally enforced; state.Dir() returns
-  ErrNotInitialized (hook callers absorb silently, interactive callers surface a
-  path-bearing message).
-- Tighten state.Dir / rc.ContextDir to (string, error) with sentinel
-  ErrDirNotDeclared: makes the empty-path case unrepresentable in a "looks fine"
-  branch, closing the filepath.Join("", rel) trap that wrote state into CWD.
-- Hook/notification design: prefer toning down docs claims over adding hooks
-  (fatigue from 9 UserPromptSubmit hooks); hook output must be structured JSON
-  (additionalContext), not plain text; dropped prompt-coach hook (zero useful
-  tips, invisible channel); de-emphasized /ctx-journal-normalize (expensive,
-  nondeterministic).
-- Hook log rotation is size-based with one previous generation (current + .1,
-  ~2MB cap), matching the eventlog pattern — O(1) size check, diagnostic logs
-  don't need deep history.
-
----
-
-## [2026-06-06-133805] ctx-dream: standalone proposing memory consolidator (Option B), human-gated via serendipity
-
-**Status**: Accepted
-
-**Context**: We explored whether ctx should grow a scheduled, background 'dream'
-(a sleep-time memory process) and how it should relate to canonical memory. Felt
-pain: the author's ideas/ folder is too overwhelming to triage, and canonical
-files bloat over time (109 decisions, 151 learnings, 154 unimported sessions).
-The risk to avoid: a background LLM job autonomously rewriting authoritative
-memory and silently corrupting it (the research shows continuous LLM
-consolidation is lossy and non-monotonic). Full debate:
-.context/briefs/20260606T203414Z-ctx-dream-disciplined-consolidator.md
-
-**Decision**: ctx-dream: standalone proposing memory consolidator (Option B),
-human-gated via serendipity
-
-**Rationale**: Chose a NEW, standalone, PROPOSING consolidator (Option B): it
-writes only to its own sidecar + proposals queue + ledger + per-dream archive,
-never autonomously to the five canonical files; a human 'serendipity' review
-session is the sole bridge (accept/reject/amend) into canonical. One skill, two
-modes: discipline (default; grounded, structured, provenanced proposals) and
-creative/exploration (a safe relaxation: resurface + chance, reader-only).
-Principle: decouple the cognition, reuse the plumbing (own the consolidation
-logic; reuse import/enrich/kb-ingest via the enriched-journal data contract).
-Standalone so mechanics evolve independently and changes to existing curation
-skills can't break it, and for creative freedom (don't assume existing verbs
-suffice). Discipline-first because it is the hard load-bearing substrate and
-creative is a strict, safer relaxation of it. Grounded in
-ideas/ctx-dreams/research: Auto-Dreamer (2605.20616) for the architecture,
-'Useful Memories Become Faulty When Continuously Updated by LLMs' (2605.12978)
-for the threat model, and the deep-research eval cluster for the finding that a
-single agreeable LLM is not an adversarial gate (it silently repairs the missing
-justification), which is why the gate must be human. Rejected: Option A (dream
-owns a parallel canonical store, which does not fix bloat and creates two
-divergent substrates); autonomous mutation / auto-approve (violates 'each memory
-entry needs dedicated human attention'); pure-garden-only (under-serves
-engineering's need for grounding and actionability); coupling to existing
-skills' internals; garden-first build order.
-
-**Consequence**: Positive: nothing autonomous touches canonical, so the system
-is reversible by construction; the dream's mechanics can evolve freely; v1
-(disciplined ideas/ triage, validated via a ctx-remind-nagged ~15-minute review
-round) is low-stakes and validates the mechanism and author engagement cheaply.
-Negative / trade-off: no human serendipity session = no consolidation, so the
-dream's entire value is gated behind human review cadence, and the author
-historically under-runs curation; mitigated only by ctx-remind nags + targeting
-felt pain (ideas/) + a pleasure-not-chore framing. Validation of the full
-product thesis (disciplined consolidation of canonical memory for engineering
-teams) is deferred to a later test on a project where bloat actually bites. Spec
-work proceeds via /ctx-spec --brief on the brief above; key mechanics remain
-open (executor, proposal schema, ledger schema, .context/ layout).
-
----
-
-## [2026-05-31-094649] Journal screen renders ctx journal source verbatim instead of parsing it
-
-**Status**: Accepted
-
-**Context**: The Journal timeline needs session data, but ctx journal source emits a whitespace-aligned table with no --json mode, and columns (usage/turns) drop out on short sessions.
-
-**Decision**: Journal screen renders ctx journal source verbatim instead of parsing it
-
-**Rationale**: A column parser misaligns when fields are absent; rendering the text verbatim in a monospace panel is honest and robust for P0.
-
-**Consequence**: Journal is a styled text view, not structured cards; a proper timeline awaits a journal source --json mode upstream (same pattern as the artifact list --json commands).
-
----
-
-## [2026-05-31-094649] ctx Desktop shells out via std::process::Command, not tauri-plugin-shell
-
-**Status**: Accepted
-
-**Context**: The GUI must run the ctx binary for every read and write. Tauri 2 offers tauri-plugin-shell with capability-scoped command allowlists.
-
-**Decision**: ctx Desktop shells out via std::process::Command, not tauri-plugin-shell
-
-**Rationale**: Running ctx inside our own #[tauri::command] via std::process::Command avoids all shell-plugin permission/capability wiring and keeps the adapter a single Rust module. ctx resolves its context from $PWD/.context, so each call sets current_dir to the selected project root.
-
-**Consequence**: No shell capability in capabilities/default.json; the adapter owns PATH augmentation and git provenance synthesis; a CLI/output change is a one-file fix in ctx_adapter.rs.
-
----
-
-## [2026-05-30-114429] Name the add JSON-ingest flag --json-file, not --json
-
-**Status**: Accepted
-
-**Context**: The CLI-FIX spec specified the literal flag --json , but
---json is already a bool output-format flag across the CLI (ctx
-status/drift/doctor/bootstrap --json all mean 'emit machine-readable output').
-
-**Decision**: Name the add JSON-ingest flag --json-file, not --json
-
-**Rationale**: Overloading --json as a string input-path on the add commands
-would break that cross-command convention and confuse muscle memory. --json-file
-is unambiguous, parallels the existing --file/-f source flag, and leaves -j
-free. Pushed back on the spec's literal wording rather than satisfice.
-
-**Consequence**: The add commands intentionally diverge from the spec's literal
---json; the spec was updated to reflect --json-file. Any future JSON-input flag
-elsewhere should follow the --json-file naming, reserving --json for bool
-output.
-
----
-
-## [2026-05-28-200500] Memory pressure detection uses OS-native signals (macOS pressure level + Linux PSI), not occupancy
-
-**Status**: Accepted
-
-**Context**: `check-resource` alerted DANGER at swap-used ≥ 75% / memory-used
-≥ 90% — pure occupancy. macOS swap is sticky (never recedes);
-post-hibernation swap stays >75% with idle RAM, producing false "wrap up the
-session" DANGER at session start. Memory occupancy on macOS includes reclaimable
-cache — also a poor pressure proxy.
-
-**Decision**: Memory pressure detection uses OS-native signals (macOS pressure
-level + Linux PSI), not occupancy
-
-**Rationale**: Occupancy is a level; pressure is a derivative. Only the kernel's
-derivative reflects current struggle. macOS: `sysctl
-kern.memorystatus_vm_pressure_level` (1/2/4 → OK/Warning/Danger). Linux:
-`/proc/pressure/memory` (PSI) `some.avg10 ≥ 10.0` → warn, `full.avg10 ≥
-10.0` → danger. Windows: filed as an exploratory task; unsupported for now
-("other" platform falls through to `PressureSupported=false`, no alert).
-
-**Consequence**: `MemInfo` gains `Pressure` + `PressureSupported`;
-`threshold.go` drops both occupancy `byteCheck`s and emits a single pressure
-alert. Doctor swap row removed (no longer a health signal); occupancy fields
-retained for `ctx stats` display. PSI 10.0 defaults named in `config/stats` —
-retunable in one place. `make lint` 0 issues, `make test` ok on the change.
-
----
-
-## [2026-05-24-092912] Pad snapshot-on-mutate at the store.WriteEntries choke point
-
-**Status**: Accepted
-
-**Context**: Adding a safety net for accidental `ctx pad rm` (and any other
-destructive pad mutation) required choosing where to insert the snapshot logic:
-per-subcommand (in each cmd//run.go), or at the persistence choke point
-(store.WriteEntriesWithIDs).
-
-**Decision**: Pad snapshot-on-mutate at the store.WriteEntries choke point
-
-**Rationale**: store.WriteEntriesWithIDs is invoked by every mutating pad
-subcommand (add/edit/mv/rm/merge/normalize/resolve/tag and undo itself);
-instrumenting it once gives universal coverage with one site of truth.
-Per-subcommand instrumentation would need maintenance every time a new pad
-mutation lands and is easy to forget. The snapshot itself is a byte-for-byte
-copy of the existing pad blob (no re-encryption), so plaintext and encrypted
-modes use identical logic; the existing ciphertext IS the snapshot.
-
-**Consequence**: All future pad mutations get the safety net automatically
-without per-command wiring. The op label for the snapshot filename is derived
-from cmd.Name() at the call site, so the cmd parameter that already flowed in
-for diagnostic output now carries semantic weight too. New constraint: any
-future code path that bypasses WriteEntriesWithIDs to mutate the pad will
-silently bypass the safety net — a guardrail test could enforce this if/when
-that risk materializes.
-
----
-
-## [2026-05-20-214753] Gitignore .context/handovers/; track only .gitkeep
-
-**Status**: Accepted
-
-**Context**: Per-session, operator-specific artifacts that grow without bound
-and can leak host/internal identifiers into public
-mirrors when the project's .context/ is committed.
-
-**Decision**: Gitignore .context/handovers/; track only .gitkeep
-
-**Rationale**: Aligns with the existing per-personal-state gitignore family
-(journal, memory, state, logs, reminders.json, scratchpad.enc); the directory's
-.gitkeep keeps the read-side missing-dir gate passing on fresh clones; the rest
-of the closeout-fold pipeline already lives in .context/archive/closeouts/ which
-IS tracked.
-
-**Consequence**: ctx init template (internal/config/file/ignore.go) added
-.context/handovers/* and !.context/handovers/.gitkeep; existing tracked
-handovers untracked via git rm --cached but kept on disk; the 'handover is the
-sole authoritative recall artifact' phrasing in KB-RULES.md still holds — it's
-local-machine authoritative.
-
----
-
-## [2026-04-16-011520] Deprecate and remove ctx backup
-
-**Status**: Accepted
-
-**Context**: ctx backup is environment-specific (SMB/GVFS), fires nag hooks for
-unconfigured users, and solves a problem that belongs to the OS layer. ctx hub
-already handles cross-machine knowledge persistence.
-
-**Decision**: Deprecate and remove ctx backup
-
-**Rationale**: Hub handles persistence, backup is env-specific, wrong layer for
-ctx to own. No external users depend on it. An internal mirror issue and the
-GVFS Linux-only dependency add maintenance burden.
-
-**Consequence**: Need backup-strategy runbook before removal. Maintainer must
-set up replacement cron job. About 60 files to remove across CLI, config, hooks,
-docs, skills. Spec: specs/deprecate-ctx-backup.md
-
----
-
-## [2026-04-14-010205] doc.go quality floor: behavior-grounded, ~25-100 body lines, related-packages section required
-
-**Status**: Accepted
-
-**Context**: About 140 doc.go files were rewritten this session. User flagged
-the original 5-line Key exports + See source files + Part of subsystem pattern
-as lazy minimum effort.
-
-**Decision**: doc.go quality floor: behavior-grounded, ~25-100 body lines,
-related-packages section required
-
-**Rationale**: Behavior-grounded rewrites (read source first, then write) are
-the only acceptable form for any non-trivial package. The lazy template
-communicates nothing a future reader cannot grep for; it satisfies tooling
-without adding signal.
-
-**Consequence**: Every non-trivial package's doc.go now leads with the package's
-actual purpose, names key behaviors, calls out non-obvious design choices
-(Raft-lite, two-step indirection, idempotency contracts), and lists related
-packages with paths. New packages should follow the same shape.
-
----
-
-## [2026-04-14-010205] Bootstrap stays under ctx system bootstrap (reverted experimental top-level promotion)
-
-**Status**: Accepted
-
-**Context**: Mid-session promoted ctx bootstrap to top-level to make a stale
-CLAUDE.md instruction work. User reverted it and reaffirmed the original design.
-
-**Decision**: Bootstrap stays under ctx system bootstrap (reverted experimental
-top-level promotion)
-
-**Rationale**: The ctx system namespace is for agent and hook plumbing the user
-does not type by hand. Bootstrap is invoked by AI agents at session start;
-surfacing it at top-level pollutes ctx --help for humans without benefit.
-
-**Consequence**: internal/bootstrap/group.go reverted;
-internal/config/embed/cmd/system.go header now correctly states bootstrap is
-intentionally not promoted. The CLAUDE.md template across the repo (and the
-workspace copy) updated to reference ctx system bootstrap as canonical.
-
----
-
-## [2026-04-14-010205] Title Case style for docs is AP-leaning with explicit ambiguity carve-outs
-
-**Status**: Accepted
-
-**Context**: Needed a deterministic Title Case engine for headings and
-admonition titles across docs/. User precedent (Working with AI lowercase with)
-ruled out strict Chicago.
-
-**Decision**: Title Case style for docs is AP-leaning with explicit ambiguity
-carve-outs
-
-**Rationale**: AP lowercase prepositions regardless of length matches
-user-approved titles. But strict AP would lowercase ambiguous prep/conj/adv
-words like before, after, since, until, past, near, down, up, off, hurting
-common cases. Carve-outs leave them at default-cap and let the engine reach a
-sensible result for ~95 percent of headings without manual review.
-
-**Consequence**: hack/title-case-headings.py ships an AP-leaning with ambiguity
-carve-outs PREPOSITIONS set. Future style changes must touch that set explicitly
-with reasoning. New brand or acronym additions go through the same audited
-pattern.
-
----
-
-## [2026-04-11-200000] Journal stays local; LEARNINGS.md is the shareable layer
-
-**Status**: Accepted
-
-**Context**: With the hub now carrying shared project context between machines
-and eventually between teammates, the question came up whether enriched
-journal entries should ride along — either the raw `.context/journal/` files
-or an "export enriched entries as shareable learning items" pipeline layered
-on top of `/ctx-journal-enrich`. The journal is already gitignored per the
-2026-03-05 `.context/memory/` decision and for the same reason: it's a
-first-person log of raw prompts, half-formed thoughts, dead ends, personal
-names, and things the user talks through with themselves. It sits in the
-same trust tier as shell history or a private notebook.
-
-The trade-off is real: shared journals would make it trivial for teammates
-(or future-me on another machine) to see the full reasoning trail behind a
-decision. But "full reasoning trail" is precisely the thing that makes a
-journal journal and not a changelog — it includes the parts the author
-hasn't decided to stand behind yet, plus incidental private content.
-
-**Decision**: The journal is **Tier-0 personal** and never leaves the
-originating machine. No hub sync, no export-by-default, no
-enriched-entries-as-shareable-items pipeline. The enrichment pipeline
-(`/ctx-journal-enrich`) stays as-is: journal → human-in-the-loop review →
-explicit promotion to LEARNINGS.md / DECISIONS.md / CONVENTIONS.md via the
-existing `/ctx-learning-add`, `/ctx-decision-add`, `/ctx-convention-add`
-commands. Those distilled artifacts are **Tier-1 shareable** and are what
-the hub syncs when a team opts into shared context.
-
-The promotion boundary is therefore the enrichment step, not a new export
-pipeline. The user is the gate.
-
-**Rationale**: Any "shareable enriched journal entry" pipeline would have to
-re-implement the trust boundary that `/ctx-learning-add` already enforces:
-the human decides what's worth sharing, strips incidental private content,
-and rewrites it as a standalone artifact. A second pipeline that tries to
-do this automatically would either (a) leak private content by accident, or
-(b) require the same human review and thus collapse back into
-`/ctx-learning-add`. The principled answer is that there is no second
-pipeline — LEARNINGS.md *is* the shareable form of the journal.
-
-This also preserves the psychological safety of the journal: the author
-can write freely because they know nothing they write is one sync away
-from a teammate's screen. Lose that property and the journal stops being a
-journal and starts being a changelog draft.
-
-**Consequence**:
-
-- Journal files stay gitignored and stay out of `ctx hub` sync paths. Any
-  future code that walks context files for replication must exclude
-  `.context/journal/` explicitly and be covered by a test.
-- `/ctx-journal-enrich` remains the promotion boundary. Its output targets
-  are LEARNINGS.md / DECISIONS.md / CONVENTIONS.md, never a separate
-  "shareable journal" bucket.
-- Hub docs (`docs/home/hub.md`, `docs/recipes/hub-personal.md`,
-  `docs/recipes/hub-team.md`, `docs/security/hub.md`) should state the
-  Tier-0 / Tier-1 split explicitly so users building team workflows don't
-  assume "shared context" means "shared everything."
-- The sync code path in `internal/hub/sync_helper.go` and any future
-  replication of context files must enforce this exclusion at the
-  code level — a gitignore entry is a user-convenience signal, not a
-  hub-trust boundary.
-- A potential future "personal multi-machine journal sync" (same human,
-  different laptops) is explicitly **out of scope** of this decision. If
-  it ever ships, it rides a different transport (encrypted-at-rest,
-  single-user, not the team hub) and needs its own decision record.
-
-**Alternatives considered**:
-
-- **Sync raw journal files via hub**: rejected. Inverts the gitignore
-  decision, leaks private content by construction, destroys the
-  journal's "safe to write freely" property.
-- **Auto-export enriched entries as a new shareable artifact type**:
-  rejected. Duplicates `/ctx-learning-add` without the human gate, or
-  collapses back into it. No real difference from the status quo except
-  the opportunity for accidental leakage.
-- **Opt-in per-entry "publish to hub" flag in the journal**: rejected as
-  premature. If the user wants an entry on the hub, the existing flow is
-  one command away — write it as a learning or decision. A second path
-  adds surface area without adding capability.
-
-**Related**: Reinforces the 2026-03-05 `.context/memory/` gitignore
-decision (same trust-tier reasoning for a different private artifact).
-
-## [2026-04-11-180000] `Entry.Author` is server-authoritative, not client-authoritative
-
-**Status**: Accepted
-
-**Context**: The `Entry.Author` field on hub entries is copied verbatim from
-the client's publish request (`handler.go:82`). It's optional, freeform, and
-unauthenticated — a client with a valid token for project `alpha` can publish
-entries claiming `Author: "bob@acme.com"` regardless of who actually
-authenticated. This is the same spoofing pattern as `Origin` (audit finding
-H-04) and was flagged as audit finding H-22 with three options: keep, drop,
-override, or promote. The decision was never formally closed.
-
-The premise that resolved it: **identity is eventually part of the token**.
-Under the sysadmin-registry MVP, the server already knows `{user_id, project}`
-from the authenticated token. Under the PKI stretch, the signed claim carries
-identity cryptographically. In both models, the client has nothing to say about
-authorship that the server doesn't already know with higher confidence.
-
-**Decision**: `Entry.Author` is **server-authoritative**. The server stamps it
-from the authenticated identity source on every publish. The client's
-`pe.Author` input is ignored (or rejected — implementation choice, not
-semantic difference). The field stays in the wire format but its semantics
-change from "whatever the client said" to "whatever the server's auth layer
-resolved."
-
-Stamping source by phase:
-
-- **Today (pre-registry)**: `Author = ClientInfo.ProjectName`, same source as
-  the `Origin` server-enforcement fix (H-04). Lossy but consistent.
-- **Registry MVP**: `Author = users.json` row's `user_id` (e.g.,
-  `alice@acme.com`). Precise per-human attribution.
-- **PKI stretch**: `Author = signed claim's sub field`. Cryptographic identity.
-
-**Rationale**: Dropping the field is wrong because the registry MVP will
-already give us a per-user identity to stamp — removing Author just to re-add
-it later is churn. "Override" and "promote" are cosmetically different forms
-of the same decision (server fills from auth context); "promote" is what
-happens naturally once the registry MVP types the field as `UserID`.
-Client-sourced Author is indefensible because it replicates the Origin
-spoofing vector in a second field.
-
-**Consequence**:
-
-- The Author field stays on the wire and in `Entry{}`.
-- Client-side code that populates `pe.Author` from local config becomes a
-  no-op. Audit `ctx connect publish` and `ctx add --share` for any such
-  code paths before the server-enforcement fix lands.
-- `handler.go publish()` fills Author from the authenticated context (the
-  same `ClientInfo` that H-04 pulls for Origin). Single unified
-  auth-to-handler pipe.
-- `docs/security/hub.md` "Compromised client token" section gets rewritten:
-  attribution becomes **wrong** on compromise (attacker's token maps to
-  attacker's identity), not **forgeable** (attacker cannot stamp someone
-  else's name).
-- The sysadmin-registry spec (`specs/hub-identity-registry.md`, tasked)
-  MUST include a `user_id` field per row — it's the stamping source.
-- Three open tasks collapse into one: H-22 resolves to "implement
-  server-authoritative Author" instead of "decide Author fate." TASKS.md
-  updated.
-
-**Alternatives considered**:
-
-- **Keep client-authoritative**: rejected. Same spoofing vector as Origin;
-  trivially defeats any downstream attribution check.
-- **Drop the field**: rejected. The registry MVP will need per-human
-  attribution anyway. Dropping today is churn that gets undone
-  immediately.
-- **Override at client-side before publish**: rejected. Puts the security
-  boundary on the wrong side of the trust zone. Must be server-side.
-
-**Follow-up — client-advisory metadata**: the client still has useful
-information to share that isn't an identity claim: a human-friendly
-display name, the machine that made the publish, the tool version, a
-CI system label, a team/role handle. This lives on a **new sibling
-field `Meta`** (a `ClientMetadata` sub-struct), not on `Author`. The
-separation of types is what protects the security property: `Author`
-is reserved for server-authoritative identity, `Meta` is
-client-advisory and explicitly labeled as such in any rendered
-surface. `Meta` fields are size-capped individually (256 bytes) and
-in aggregate (2 KB), validated for plain-string content (no
-newlines, no control characters), and never claimed as attribution
-in any API response. The renderer MUST label `Meta`-sourced values
-with prose like "client label" or "client-reported" so readers
-cannot mistake them for authoritative identity. See TASKS.md for
-the implementation task.
-
----
-
-## [2026-04-09-001332] Architecture skill pipeline is a triad not a quartet
-
-**Status**: Accepted
-
-**Context**: Had a proposed ctx-architecture-extend for extension point mapping,
-making four skills
-
-**Decision**: Architecture skill pipeline is a triad not a quartet
-
-**Rationale**: Extension points already covered per-module in DETAILED_DESIGN
-and by registration site discovery in enrich. Fourth skill fragments pipeline
-without distinct value
-
-**Consequence**: Pipeline is map enrich hunt. Three skills three questions: how
-does it work, how well does it connect, where will it break
-
----
-
-## [2026-04-08-013731] Remove #done tag convention, simplify task archival
-
-**Status**: Accepted
-
-**Context**: Tasks had #done:YYYY-MM-DD timestamps that agents added
-inconsistently and nobody read. compact --archive filtered by age using these
-timestamps.
-
-**Decision**: Remove #done tag convention, simplify task archival
-
-**Rationale**: [x] checkbox is semantically sufficient. git blame provides the
-completion timestamp. Removing #done eliminates redundant ceremony and
-simplifies compact --archive to archive all completed tasks regardless of age.
-
-**Consequence**: compact --archive no longer filters by archive_after_days for
-tasks. The .ctxrc field is inert but retained for backwards compatibility.
-Historical #done tags in archives are preserved.
-
----
-
-## [2026-04-06-204212] Use hook relay for session provenance instead of JSONL parsing or env vars
-
-**Status**: Accepted
-
-**Context**: Needed to give agents awareness of their session ID, branch, and
-commit hash for task/decision/learning provenance. Considered three approaches:
-(1) parsing most-recent JSONL at runtime, (2) CTX_SESSION_ID env var, (3) hook
-relay via UserPromptSubmit.
-
-**Decision**: Use hook relay for session provenance instead of JSONL parsing or
-env vars
-
-**Rationale**: JSONL parsing breaks with parallel sessions (wrong file picked).
-Env vars aren't exported by Claude Code. Hook relay is zero-state: the hook
-receives session_id from Claude Code on every prompt, emits it, agent absorbs
-through repetition. No counters, no cleanup, no resume edge cases.
-
-**Consequence**: Provenance depends on the hook being registered (enabledPlugins
-in settings.local.json). Projects without plugin registration get no provenance.
-Filed as separate bug.
-
----
-
-## [2026-04-01-233247] IRC to Discord as primary community channel
-
-**Status**: Accepted
-
-**Context**: Discord server exists at https://ctx.ist/discord; IRC/libera.chat
-references were stale
-
-**Decision**: IRC to Discord as primary community channel
-
-**Rationale**: Discord is faster for async community support; IRC was historical
-
-**Consequence**: Updated zensical.toml, README, community docs, journal
-template. Added community footer to ctx help and ctx init output via YAML assets
-pipeline
-
----
-
-## [2026-04-01-233246] AST audit tests live in internal/audit/, one file per check
-
-**Status**: Accepted
-
-**Context**: Needed a home for AST-based codebase invariant tests separate from
-the existing compliance_test.go monolith
-
-**Decision**: AST audit tests live in internal/audit/, one file per check
-
-**Rationale**: One test per file prevents the 1200+ line monster pattern. Shared
-helpers in helpers_test.go with sync.Once caching. Package is all _test.go
-except doc.go — produces no binary, not importable
-
-**Consequence**: New checks are added as individual *_test.go files; the pattern
-(loadPackages, walk AST, collect violations, t.Error) is established and
-repeatable
-
----
-
-## [2026-04-01-074416] Rename ctx hook → ctx setup to disambiguate from the hook system
-
-**Status**: Accepted
-
-**Context**: PR #45 contributor assumed hook meant the setup command, causing
-naming collisions with the PreToolUse/PostToolUse hook system
-
-**Decision**: Rename ctx hook → ctx setup to disambiguate from the hook system
-
-**Rationale**: hook has a specific meaning in ctx; setup accurately describes
-generating AI tool integration configs
-
-**Consequence**: CLI breaking change. All docs, specs, TypeScript extension, and
-YAML assets updated. Released specs left as historical.
-
----
-
-## [2026-03-31-224245] Split log into log/event and log/warn to break import cycles
-
-**Status**: Accepted
-
-**Context**: io and notify could not import log.Warn because log imported both
-of them for event logging, creating circular dependencies
-
-**Decision**: Split log into log/event and log/warn to break import cycles
-
-**Rationale**: Separating concerns (stderr sink vs JSONL event log) into
-subpackages eliminated the cycle. Warn sink is foundation-level with only config
-imports, event logging is higher-level
-
-**Consequence**: All stderr warnings now route through logWarn.Warn(). New code
-importing log/warn has no cycle risk. Event types moved to internal/entity
-
----
-
-## [2026-03-30-075927] Flags-not-subcommands for journal source: list and show are view modes on a noun, not independent entities
-
-**Status**: Accepted
-
-**Context**: During the journal-recall merge, recall had separate list and show
-subcommands. Merging them into journal created a design choice: source list +
-source show (three levels) vs source --show (two levels).
-
-**Decision**: Flags-not-subcommands for journal source: list and show are view
-modes on a noun, not independent entities
-
-**Rationale**: Keeps CLI nesting to two levels max. Default behavior (bare
-source) lists sessions; --show switches to inspect mode. When two operations
-differ only in how they view the same data, make them flags on one command.
-
-**Consequence**: journal source dispatches via --show flag rather than
-positional subcommand. Future view-mode toggles should follow this pattern.
-
----
-
-## [2026-03-30-003756] Journal consumed recall — recall CLI package deleted
-
-**Status**: Accepted
-
-**Context**: ctx recall was never registered in bootstrap; ctx journal had all
-the same subcommands
-
-**Decision**: Journal consumed recall — recall CLI package deleted
-
-**Rationale**: One dead command group creates confusion in docs and skills.
-Journal is the canonical command group.
-
-**Consequence**: internal/cli/recall/ deleted, 19 doc files updated,
-docs/cli/recall.md renamed to journal.md, zensical.toml updated. MCP tool
-ctx_recall rename tasked separately (API contract)
-
----
-
-## [2026-03-25-233646] Architecture analysis and enrichment are separate skills — constraint is the feature
-
-**Status**: Accepted
-
-**Context**: Observed that agents take shortcuts when code intelligence tools
-are available during architecture analysis. A 5.2x depth reduction was measured
-(5866 vs 1124 lines) when GitNexus was available during reading. Mentioning
-unavailable tools by name in a skill plants the idea for the agent to use them.
-
-**Decision**: Architecture analysis and enrichment are separate skills —
-constraint is the feature
-
-**Rationale**: Discovery requires forced reading without shortcuts. Validation
-and quantification are a separate pass. Two-pass compiler analogy: semantic
-parsing (human-style reading) then static analysis (graph enrichment). Never
-mention tools you want the agent to avoid — absence is the only reliable
-constraint.
-
-**Consequence**: ctx-architecture deliberately excludes code intelligence tools
-from allowed-tools and never mentions them. ctx-architecture-enrich is a
-separate skill that runs after, using the deep artifacts as baseline. Gemini is
-allowed in both for upstream/external lookups only.
-
----
-
-
-## [2026-03-25-173336] Prompt templates removed — skills are the single agent instruction mechanism
-
-**Status**: Accepted
-
-**Context**: Prompt templates (.context/prompts/) overlapped with skills but had
-no discoverability — even the project creator didn't know they existed
-
-**Decision**: Prompt templates removed — skills are the single agent
-instruction mechanism
-
-**Rationale**: Adding metadata to prompts to fix discoverability would recreate
-the skill system. One concept is better than two.
-
-**Consequence**: code-review, explain, refactor promoted to proper skills. ctx
-prompt CLI removed. loop.md retained as ctx loop config file at
-.context/loop.md.
-
----
-
-## [2026-03-24-001001] Write-once baseline with explicit end-consolidation for consolidation lifecycle
-
-**Status**: Accepted
-
-**Context**: Designing the consolidation nudge hook; multi-pass consolidation
-spans dozens of sessions and you cannot programmatically distinguish feature
-from consolidation sessions
-
-**Decision**: Write-once baseline with explicit end-consolidation for
-consolidation lifecycle
-
-**Rationale**: First ctx-consolidate stamps baseline (write-once), user runs
-end-consolidation when done. Failure mode is silence (no stale nudges), not
-wrong behavior
-
-**Consequence**: Requires mark-consolidation, end-consolidation, and
-snooze-consolidation plumbing commands. Spec: specs/consolidation-nudge-hook.md
-
----
-
-
-## [2026-03-18-193623] Singular command names for all CLI entities
-
-**Status**: Accepted
-
-**Context**: ctx add used learning (singular) but ctx learnings was plural.
-Inconsistency across 6 commands.
-
-**Decision**: Singular command names for all CLI entities
-
-**Rationale**: Less headache for i18n; one rule (singular = entity); developers
-think in OOP. Use field values come from DescKey constants for
-single-source-of-truth renaming.
-
-**Consequence**: All commands singular: task, decision, learning, change,
-permission, dep. YAML keys, desc constants, directory names, and 50+ files
-updated.
-
----
-
-## [2026-03-16-022635] Rename --consequences flag to --consequence for singular consistency
-
-**Status**: Accepted
-
-**Context**: All other CLI flags (context, rationale, lesson, application) are
-singular nouns. consequences was the only plural.
-
-**Decision**: Rename --consequences flag to --consequence for singular
-consistency
-
-**Rationale**: Singular form matches the pattern. Consistency wins over natural
-language preference.
-
-**Consequence**: 75+ files updated. Breaking change for --consequences users.
-
----
-
-
-
-## [2026-03-14-110748] System path deny-list as safety net, not security boundary
-
-**Status**: Accepted
-
-**Context**: Replacing nolint:gosec directives with centralized I/O wrappers in
-internal/io
-
-**Decision**: System path deny-list as safety net, not security boundary
-
-**Rationale**: ctx paths are internally constructed from config constants. The
-deny-list catches agent hallucinations (writing to /etc), not adversarial input.
-Public security docs would imply a threat model that does not exist.
-
-**Consequence**: internal/io/doc.go documents limitations honestly for
-contributors. No user-facing security docs. The deny-list is a modicum of
-protection, not a promise.
-
----
-
-## [2026-03-14-093748] Config-driven freshness check with per-file review URLs
-
-**Status**: Accepted
-
-**Context**: Building a hook to warn when technology-dependent constants go
-stale. Initially hardcoded the file list and Anthropic docs URL in the binary,
-but this only worked inside the ctx repo and assumed all projects care about
-Anthropic docs.
-
-**Decision**: Config-driven freshness check with per-file review URLs
-
-**Rationale**: Making the file list and review URLs configurable via .ctxrc
-freshness_files means any project can opt in. Per-file review_url avoids
-special-casing by project name — ctx sets Anthropic docs, other projects set
-their own vendor links or omit it entirely.
-
-**Consequence**: The hook is a no-op by default (opt-in). ctx's own .ctxrc
-carries the tracked files. All nudge text goes through assets/text.yaml for
-localization. No project detection logic needed.
-
----
-
-## [2026-03-13-223111] Delete ctx-context-monitor skill — hook output is self-sufficient
-
-**Status**: Accepted
-
-**Context**: The skill documented how to relay context window warnings, but the
-hook message already includes IMPORTANT: Relay this context window warning to
-the user VERBATIM which agents follow without the skill.
-
-**Decision**: Delete ctx-context-monitor skill — hook output is
-self-sufficient
-
-**Rationale**: No mechanism exists for hooks to trigger skills. The skill was
-never loaded during sessions. Adding enforcement elsewhere would either be too
-far back in context (playbook) or dilute the already-crisp hook message.
-
-**Consequence**: One fewer skill to maintain. No behavioral change — agents
-continue relaying warnings as before.
-
----
-
-
-
-## [2026-03-12-133007] Rename ctx-map skill to ctx-architecture
-
-**Status**: Accepted
-
-**Context**: The name 'map' didn't convey the iterative, architectural nature of
-the ritual
-
-**Decision**: Rename ctx-map skill to ctx-architecture
-
-**Rationale**: 'architecture' better describes surveying and evolving project
-structure across sessions
-
-**Consequence**: All cross-references updated across skills, docs, .context
-files, and settings
-
----
-
----
-
-## [2026-03-06-200306] Drop fatih/color dependency — Unicode symbols are sufficient for terminal output, color was redundant
-
-**Status**: Accepted
-
-**Context**: fatih/color was used in 32 files for green checkmarks, yellow
-warnings, cyan headings, dim text
-
-**Decision**: Drop fatih/color dependency — Unicode symbols are sufficient for
-terminal output, color was redundant
-
-**Rationale**: Every colored output already had a semantic symbol (✓, ⚠,
-○) that conveyed the same meaning; color added visual noise in non-terminal
-contexts (logs, pipes)
-
-**Consequence**: Removed --no-color flag (only existed for color.NoColor); one
-fewer external dependency; FlagNoColor retained in config for CLI compatibility
-
----
-
-
-
-
----
-
-## [2026-03-05-205424] Gitignore .context/memory/ for this project
-
-**Status**: Accepted
-
-**Context**: Memory mirror contains copies of MEMORY.md which holds strategic
-analysis and session notes
-
-**Decision**: Gitignore .context/memory/ for this project
-
-**Rationale**: Strategic content should not be in git history. Docs updated to
-say 'often git-tracked' for the general recommendation — this project is the
-exception.
-
-**Consequence**: Mirror and archives are local-only for this project. Other
-projects can still track them. Sync and drift detection work the same way
-regardless.
-
----
-
-
-
-## [2026-03-04-105238] Interface-based GraphBuilder for multi-ecosystem ctx deps
-
-**Status**: Accepted
-
-**Context**: P-1.3 questioned whether non-Go dependency support would introduce
-bloat and whether a semantic approach was better
-
-**Decision**: Interface-based GraphBuilder for multi-ecosystem ctx deps
-
-**Rationale**: The output pipeline (map[string][]string to Mermaid/table/JSON)
-was already language-agnostic. Each ecosystem builder is ~40 lines — this is
-finishing what was started, not bloat. Static manifest parsing (no external
-tools for Node/Python) keeps dependencies minimal.
-
-**Consequence**: ctx deps now auto-detects Go, Node.js, Python, Rust. --type
-flag overrides detection. ctx-architecture skill works across ecosystems without
-changes.
-
----
-
-## [2026-03-02-165038] Billing threshold piggybacks on check-context-size, not heartbeat
-
-**Status**: Accepted
-
-**Context**: User wanted a configurable token-count nudge for billing awareness
-(Claude Pro 1M context, extra cost after 200k). Heartbeat produces zero stdout
-and can't relay to user.
-
-**Decision**: Billing threshold piggybacks on check-context-size, not heartbeat
-
-**Rationale**: check-context-size already reads tokens, has VERBATIM relay
-working, and runs every prompt. Adding a third independent trigger there is
-minimal code and follows established patterns.
-
-**Consequence**: New .ctxrc field billing_token_warn (default 0 = disabled).
-One-shot per session via billing-warned-{sessionID} state file.
-Template-overridable via check-context-size/billing.txt.
-
----
-
-
-
-## [2026-03-01-222733] PersistentPreRunE init guard with three-level exemption
-
-**Status**: Accepted
-
-**Context**: ctx commands handled missing .context/ inconsistently — some
-caught errors, some got confusing file-not-found messages, some produced empty
-output
-
-**Decision**: PersistentPreRunE init guard with three-level exemption
-
-**Rationale**: Single PersistentPreRunE on root command gives one clear error.
-Three-level exemption (hidden commands, annotated commands, grouping commands)
-covers all edge cases without per-command boilerplate
-
-**Consequence**: Boundary violation now returns an error instead of os.Exit(1),
-making it testable. The subprocess-based boundary test was simplified to a
-direct error assertion
-
----
-
----
-
-## [2026-03-01-112544] Heartbeat token telemetry: conditional fields, not always-present
-
-**Status**: Accepted
-
-**Context**: Adding tokens, context_window, usage_pct to heartbeat payloads.
-First prompt of a session has no JSONL usage data yet.
-
-**Decision**: Heartbeat token telemetry: conditional fields, not always-present
-
-**Rationale**: Token fields are only included in the template ref when tokens >
-0. This avoids misleading pct=0% on the first heartbeat and keeps payloads clean
-for receivers that filter on field presence.
-
-**Consequence**: Webhook consumers must handle heartbeats both with and without
-token fields. The message string also varies (with/without tokens=N pct=N%
-suffix).
-
----
-
----
-
-## [2026-03-01-090124] Promote 6 private skills to bundled plugin skills; keep 7 project-local
-
-**Status**: Accepted
-
-**Context**: Reviewed all 13 _ctx-* private skills to determine which are
-universally useful for any ctx user vs specific to the ctx codebase or personal
-infra.
-
-**Decision**: Promote 6 private skills to bundled plugin skills; keep 7
-project-local
-
-**Rationale**: Promote if the skill benefits any ctx-powered project without
-project-specific hardcoding. Keep private if it references this repo's Go
-internals, personal infra, or language-specific tooling. Promote list: _ctx-spec
-(generic scaffolding), _ctx-brainstorm (design facilitation), _ctx-verify (claim
-verification), _ctx-skill-create (skill authoring), _ctx-link-check (doc link
-audit), _ctx-permission-sanitize (Claude Code permissions audit). Keep list:
-_ctx-audit (Go/ctx checks), _ctx-qa (Go Makefile), _ctx-backup (SMB infra),
-_ctx-release/_ctx-release-notes (ctx release workflow), _ctx-update-docs (ctx
-package mapping), _ctx-absorb (borderline, revisit later).
-
-**Consequence**: Six skills move from .claude/skills/ to
-internal/assets/claude/skills/ and become available to all ctx users via ctx
-init. Cross-references between skills need updating (e.g., /_ctx-brainstorm
-becomes /ctx-brainstorm). The seven remaining private skills stay project-local.
-
----
-
-## [2026-02-27-230718] Context window detection: JSONL-first fallback order
-
-**Status**: Accepted
-
-**Context**: check-context-size defaults to 200k but user runs 1M-context model,
-causing false 110% warnings. JSONL contains the model name which maps to actual
-window size.
-
-**Decision**: Context window detection: JSONL-first fallback order
-
-**Rationale**: effective_window = detect_from_jsonl(model) ??
-ctxrc.context_window ?? 200_000. JSONL is ground truth (reflects actual model in
-use); ctxrc is fallback for first-hook-of-session or unknown models; 200k is
-safe last resort. Having ctxrc override JSONL would artificially restrict the
-check when a user forgets to update their config after switching models.
-
-**Consequence**: Most users get correct window automatically. ctxrc
-context_window becomes a fallback, not an override. Task exists for
-implementation.
-
----
-
-
-
----
-
-## [2026-02-26-100002] ctx init and CLAUDE.md handling (consolidated)
-
-**Status**: Accepted
-
-**Consolidated from**: 3 decisions (2026-01-20)
-
-- `ctx init` handles CLAUDE.md intelligently: creates if missing, backs up and
-  offers merge if existing, uses marker comment for idempotency. The `--merge`
-  flag enables non-interactive append.
-- `ctx init` always generates `.claude/hooks/` alongside `.context/` with no
-  flag needed. Other AI tools ignore `.claude/`; Claude Code users get seamless
-  zero-config experience.
-- Core tool stays generic and tool-agnostic, with optional Claude Code
-  enhancements via `.claude/hooks/`. Other AI tools can be supported similarly
-  (`ctx hook cursor`, etc.).
-
----
-
-## [2026-02-26-100004] Task and knowledge management (consolidated)
-
-**Status**: Accepted
-
-**Consolidated from**: 4 decisions (2026-01-27 to 2026-02-18)
-
-- Tasks must include explicit deliverables, not just implementation steps.
-  Parent tasks define WHAT the user gets; subtasks decompose HOW to build it.
-  Without explicit deliverables, AI optimizes for checking boxes.
-- Use reverse-chronological order (newest first) for DECISIONS.md and
-  LEARNINGS.md. Ensures most recent items are read first regardless of token
-  budget.
-- Add quick reference index to DECISIONS.md: compact table at top allows
-  scanning; agents can grep for full timestamp to jump to entry. Auto-updated on
-  `ctx add decision`.
-- Knowledge scaling via archive path for decisions and learnings: follow the
-  task archive pattern, move old entries to `.context/archive/`, extend `ctx
-  compact --archive` to cover all three file types.
-
----
-
-## [2026-02-26-100005] Agent autonomy and separation of concerns (consolidated)
-
-**Status**: Accepted
-
-**Consolidated from**: 3 decisions (2026-01-21 to 2026-01-28)
-
-- Removed AGENTS.md from project root. Consolidated on CLAUDE.md (auto-loaded) +
-  .context/AGENT_PLAYBOOK.md as the canonical agent instruction path. Projects
-  using ctx should not create AGENTS.md.
-- ~~Separate orchestrator directive from agent tasks~~ (superseded 2026-03-25:
-  IMPLEMENTATION_PLAN.md removed — TASKS.md is the single source of truth for
-  work items, AGENT_PLAYBOOK.md covers agent behavior).
-- No custom UI -- IDE is the interface. UI is a liability; IDEs already excel at
-  file browsing, search, markdown editing, and git integration. Focus CLI
-  efforts on good markdown output.
-
----
-
-## [2026-02-26-100006] Security and permissions (consolidated)
-
-**Status**: Accepted
-
-**Consolidated from**: 4 decisions (2026-01-21 to 2026-02-24)
-
-- Keep CONSTITUTION.md minimal: only truly inviolable rules (security,
-  correctness, process invariants). Style preferences go in CONVENTIONS.md.
-  Overly strict constitution gets ignored.
-- Centralize constants with semantic prefixes in `internal/config/config.go`:
-  `Dir*` for directories, `File*` for paths, `Filename*` for names,
-  `UpdateType*` for entry types. Single source of truth, compile-time typo
-  checks.
-- Hooks use `ctx` from PATH, not hardcoded absolute paths. Standard Unix
-  practice; portable across machines/users. `ctx init` checks PATH availability
-  before proceeding.
-- Drop absolute-path-to-ctx regex from block-dangerous-commands shell script.
-  The block-non-path-ctx Go subcommand already covers this with better patterns;
-  duplicating creates two sources of truth.
-
----
-
-## [2026-02-27-002831] Webhook and notification design (consolidated)
-
-**Status**: Accepted
-
-**Consolidated from**: 3 decisions (2026-02-22 to 2026-02-26)
-
-- **Session attribution**: All webhook payloads must include session_id. Reading
-  it from stdin costs nothing and enables multi-agent diagnostics. All run
-  functions take stdin parameter; tests use createTempStdin.
-- **Opt-in events**: Notify events are opt-in, not opt-out. EventAllowed returns
-  false for nil/empty event lists. The correct default for notifications is
-  silence. `ctx notify test` bypasses the filter as a special case.
-- **Shared encryption key**: Webhook URLs encrypted with the shared .ctx.key
-  (AES-256-GCM), not a dedicated key. One key, one gitignore entry, one rotation
-  cycle. Notify is a peer of scratchpad — both store user secrets encrypted at
-  rest.
-
----
-
-## [2026-02-11] Remove .context/sessions/ storage layer and ctx session command
-
-**Status**: Accepted
-
-**Context**: The session/recall/journal system had three overlapping storage
-layers: `~/.claude/projects/` (raw JSONL transcripts, owned by Claude Code),
-`.context/sessions/` (JSONL copies + context snapshots), and `.context/journal/`
-(enriched markdown from `ctx recall import`). The recall pipeline reads directly
-from `~/.claude/projects/`, making `.context/sessions/` a dead-end write sink
-that nothing reads from. The auto-save hook copied transcripts to a directory
-nobody consumed. The `ctx session save` command created context snapshots that
-git already provides through version history. This was ~15 Go source files, a
-shell hook, ~20 config constants, and 30+ doc references supporting
-infrastructure with no consumers.
-
-**Decision**: Remove `.context/sessions/` entirely. Two stores remain: raw
-transcripts (global, tool-owned in `~/.claude/projects/`) and enriched journal
-(project-local in `.context/journal/`).
-
-**Rationale**: Dead-end write sinks waste code surface, maintenance effort, and
-user attention. The recall pipeline already proved that reading directly from
-`~/.claude/projects/` is sufficient. Context snapshots are redundant with git
-history. Removing the middle layer simplifies the architecture from three stores
-to two, eliminates an entire CLI command tree (`ctx session`), and removes a
-shell hook that fired on every session end.
-
-**Consequence**: Deleted `internal/cli/session/` (15 files), removed auto-save
-hook, removed `--auto-save` from watch, removed pre-compact auto-save from
-compact, removed `/ctx-save` skill, updated ~45 documentation files. Four
-earlier decisions superseded (SessionEnd hook, Auto-Save Before Compact, Session
-Filename Format, Two-Tier Persistence Model). Users who want session history use
-`ctx journal source`/`ctx journal import` instead.
-
----
-
-
-*Module-specific, already-shipped, and historical decisions:
-[decisions-reference.md](decisions-reference.md)*
-
----
-
-## [2026-04-25-014704] Use t.Setenv for subprocess env in tests, not append(os.Environ(), ...)
-
-**Status**: Accepted
-
-**Context**: TestBinaryIntegration spawns subprocesses; the prior helper did
-append(os.Environ(), CTX_DIR=...) to override the developer-shell value. Wrong
-abstraction.
-
-**Decision**: Use t.Setenv for subprocess env in tests, not append(os.Environ(),
-...)
-
-**Rationale**: t.Setenv mutates the live process env, exec.Cmd with nil Env
-inherits it, and cleanup is automatic at test end. One line replaces the helper.
-
-**Consequence**: Helper deleted, six call sites simplified, no env-dedup logic
-to maintain. Pattern reusable for other subprocess tests.
-
----
-
+## Themes
+
+- package-structure-and-quality-gates — Package taxonomy & quality gates: write/ output, internal/err, config/ constants, doc.go floor, AST audit tests, log split, GraphBuilder interface → [package-structure-and-quality-gates](decisions/package-structure-and-quality-gates.md)
+- cli-command-surface — CLI surface: singular command names, flag naming (--json-file/--consequence), flags-not-subcommands, hook->setup, bootstrap placement, backup/recall removal → [cli-command-surface](decisions/cli-command-surface.md)
+- skills-and-agent-architecture — Skill & agent architecture: ctx-dream design, architecture skill triad, analysis/enrichment split, prompt-templates removed, skill promotion, agent autonomy → [skills-and-agent-architecture](decisions/skills-and-agent-architecture.md)
+- context-model-and-state — Context model & on-disk state: CWD-anchored resolution, encryption-key resolution, pad snapshot, gitignore handovers/memory, server-authoritative Author, init guard → [context-model-and-state](decisions/context-model-and-state.md)
+- hooks-session-and-telemetry — Hooks, session & telemetry: hook-relay provenance, memory-pressure signals, billing piggyback, heartbeat telemetry, context-window detection, notifications → [hooks-session-and-telemetry](decisions/hooks-session-and-telemetry.md)
+- journal-and-knowledge-lifecycle — Journal & knowledge lifecycle: verbatim journal render, journal-local vs shareable LEARNINGS, #done removal, write-once-then-consolidate, task/knowledge mgmt → [journal-and-knowledge-lifecycle](decisions/journal-and-knowledge-lifecycle.md)
+- kb-and-vocabulary — KB pipeline & vocabulary: Phase-KB editorial design, localizable i18n primitives, config-driven per-file freshness checks → [kb-and-vocabulary](decisions/kb-and-vocabulary.md)
+- integrations-and-assets — Integrations & assets: YAML text externalization, ctxctl audit binary, companion-tool peer-MCP (no gateway), editor-integration harnesses, Desktop shell-out → [integrations-and-assets](decisions/integrations-and-assets.md)
+- product-community-and-deps — Product, community & deps: progressive disclosure, ceremony-credit throttle, statusline informs-not-gates, sonnet 200k default, IRC->Discord, drop fatih/color → [product-community-and-deps](decisions/product-community-and-deps.md)
+- security-and-permissions — Security & permissions: system-path deny-list as safety net not boundary; permission-model decisions → [security-and-permissions](decisions/security-and-permissions.md)
diff --git a/.context/LEARNINGS.md b/.context/LEARNINGS.md
index 2ce29845..edb3adac 100644
--- a/.context/LEARNINGS.md
+++ b/.context/LEARNINGS.md
@@ -17,1815 +17,17 @@ DO NOT UPDATE FOR:
 
 ---
 
-## [2026-07-18-084406] Adding a new internal/ package must clear a fixed audit gauntlet — satisfy it up front
-
-**Context**: The internal/disclosure + internal/cli/disclosure packages tripped ~6 make audit rounds while building pd-m1/m2.
-
-**Lesson**: All mechanically enforced: doc.go per package (compliance), docstring Parameters/Returns floor on ALL funcs incl. unexported (lint-docstrings), string literals must live in config/* (magic-strings), exported API and unexported helpers in SEPARATE files (mixed-visibility), <=80-char lines, DescKey<->YAML bijection (both directions), no dead exports, and TestDocGoSubcommandDrift mis-parses a group doc.go bullet like '- internal/cli/x/cmd/y:' as subcommand claims (bulletRe backtracks on the slash).
-
-**Application**: For a new package, from the first file: one doc.go with a package comment (never a package comment in a regular file); split exported vs unexported functions across files; every literal as a config constant; full Parameters/Returns docstrings; and in a group doc.go use SPACED bullets ('- ctx x sub: ...') not slash-paths, or leave documented empty like kb/doc.go.
-
----
-
-## [2026-07-18-084406] A /ctx-task-out measurement gate caught a pre-existing bug before it could compound
-
-**Context**: pd-m1's E4 'layout proof' epic was decomposed specifically to prove-or-kill the progressive-disclosure premise that 'ctx add needs zero change' BEFORE building the mover on it. The empty-staging case failed: ctx learning add destroyed the ## Themes section.
-
-**Lesson**: The root cause was a shipped insert.AfterHeader tail-truncation bug (content[:i]+entry, dropping content[i:]), not a design flaw. The gate surfaced a real data-loss defect in ~4 tasks instead of after building on a false premise. Decomposing a milestone's load-bearing assumption into an early falsifiable task pays for itself.
-
-**Application**: When running /ctx-task-out, make the riskiest load-bearing assumption its OWN early task with a falsifiable acceptance check; treat its failure as signal about reality (a real bug), not just about the plan. Log the gate firing in the plan's Amendments.
-
----
-
-## [2026-07-17-081010] An insert helper that returns content[:i]+x instead of content[:i]+x+content[i:] silently drops the tail
-
-**Context**: insert.AfterHeader (the fallback beforeFirstEntry takes for a knowledge file with no ## [ entries) returned content[:insertPoint]+entry, truncating the file at the insertion point and discarding everything after it. Masked in practice because a ctx-init'd file has nothing after its comment block, so the dropped tail was empty; it bites the moment any non-entry section sits below the preamble of an as-yet-entry-less file. Same family as the LEARNINGS clobber bug index.Validate guards: silent memory loss, git-only recovery.
-
-**Lesson**: A string-splice 'insert' must reattach the tail. content[:i]+x is append-with-truncation, NOT insert. The sibling Task() splice did it right (content[:i]+x+sep+content[i:]); AfterHeader was the odd one out and nobody noticed because the package had ZERO tests. EOF-anchored fixtures mask the bug precisely because the tail is empty there.
-
-**Application**: Audit any result built from content[:i] for a matching content[i:] on the other side of the inserted text; a lone content[:i] is the smell. Test insert/splice helpers with a NON-empty tail, not just an EOF-anchored fixture. When touching a data-mutating helper with no tests, adding the test is part of the fix, not optional.
-
----
-
-## [2026-07-17-081010] Uninitialized desc.Text() returns empty, and strings.Index(s, "") == 0 makes anchor-based inserts silently match at offset 0
-
-**Context**: Writing a layout proof for the insert package, both cases passed on the first run — for the wrong reason. A *_test.go that never calls lookup.Init() (via TestMain) gets "" back from every desc.Text() call, because the embedded asset lookup is uninitialized in that test binary. Anchor logic such as beforeFirstEntry does strings.Index(content, desc.Text(headingKey)); with a "" needle that returns 0, so 'insert before the anchor' prepends to the whole file (above the H1) and any assertion of the form 'entry appears before X' passes trivially.
-
-**Lesson**: strings.Index(s, "") == 0: an empty needle 'matches' at the start of any string. So a text-driven test whose helper resolves labels through desc.Text MUST initialize the asset lookup, or it exercises a degenerate offset-0 code path that production never takes — and passes for the wrong reason. This nearly caused a false 'measurement gate validated' report when the gate was in fact broken.
-
-**Application**: Any package test that calls desc.Text (directly, or transitively through the code under test) needs a TestMain calling lookup.Init() (see internal/cli/system/core/session/testmain_test.go for the pattern). When a text/anchor-driven test passes suspiciously easily, dump desc.Text() of the keys involved and assert they are non-empty before trusting the assertions.
-
----
-
-## [2026-07-16-120001] OpenCode plugin integration gotchas (consolidated)
-
-**Consolidated from**: 7 entries (2026-04-26 to 2026-04-29)
-
-- Two-arg hooks (`shell.env`, `tool.execute.after`, `chat.params`, `chat.headers`) are MUTATION hooks: mutate the second `output` param; returned objects are discarded. Read the type def in `@opencode-ai/plugin/dist/index.d.ts` before wiring.
-- The `event` hook is a SINGLE dispatcher — `event: async ({event}) => { if (event.type === "session.created") ... }` with `event.type` discriminating — not an object of named handlers; a named-handlers object compiles clean but never fires.
-- `shell.env` injects env only into the agent's shell tool, not the plugin's own `ctx.$` calls (separate process env). Build an env-aware shell once: `ctx.$.env({ ...process.env, CTX_DIR: `${ctx.directory}/.context` })` and reuse it for every plugin-initiated subprocess.
-- BunShell `ctx.$` echoes stdout/stderr to OpenCode's process (the TUI/agent surfaces it); chain `.nothrow().quiet()` on every call — shell-level `2>/dev/null` only hides stderr, stdout still leaks.
-- Auto-discovery scans only flat top-level files: deploy `.opencode/plugins/.ts`, NOT `/index.ts` (subdirectories are silently ignored, no log line). Verify a plugin actually loads with a top-of-module side effect before debugging hook contracts. `opencode plugin ` is a different code path (npm names only).
-- `opencode.json` MCP shape: `command: Array` holds the binary AND its args (there is no separate `args` field) and requires `enabled: true` at runtime; don't copy the Copilot CLI MCP shape. Read upstream `types.gen.d.ts` before reusing a generator.
-- Compaction interop is breadcrumb-mediated and fragile (ctx context survived `/compact` only by accident, via oh-my-openagent preserving `.context/` paths). Register `experimental.session.compacting` yourself and push high-signal context (e.g. `ctx system bootstrap` output) to `output.context` (additive); never set `output.prompt` from a thin shim (destructive — only one plugin wins).
-
----
-
-## [2026-07-16-120002] site/ is tracked build output — rebuild and commit it with docs changes (consolidated)
-
-**Consolidated from**: 2 entries (2026-02-27, 2026-06-07)
-
-- `site/` is intentionally git-tracked (zensical build via `make site`); there is NO GitHub Pages workflow or CI build step — nothing rebuilds it downstream.
-- Any change under `docs/` requires regenerating `site/` (`make site`) and committing the rebuilt output in the SAME commit; otherwise it silently drifts and surfaces later as a large modified set (observed: a surprise 189-file `site/` drift mid-session).
-- Always `git add site/` with doc commits; never gitignore `site/` or treat it as ephemeral noise.
-
----
-
-## [2026-07-16-120003] Contributor PRs: check for removal-reintroduction and schedule convention follow-up (consolidated)
-
-**Consolidated from**: 2 entries (2026-03-15, 2026-04-01)
-
-- PRs based on older code reintroduce intentionally-removed features (PR #45 brought back prompt templates, PROMPT.md, IMPLEMENTATION_PLAN.md removed in March). Cross-reference DECISIONS.md before accepting additive PR content — do not assume it is purely additive.
-- Merging with known MECHANICAL gaps is fine if the follow-up is immediate (PR #42 left ~12 inline strings, no embed_test coverage, substring matching in containsOverlap). Track in `ideas/pr{N}-review-status.md` during review, merge when the architecture is sound, and fix the convention gaps in a same-day follow-up commit.
-
----
-
-## [2026-07-16-120004] Drift/detection scripts need existence checks and exclusion lists for intentional & illustrative uses (consolidated)
-
-**Consolidated from**: 3 entries (2026-02-27, 2026-03-23, 2026-03-24)
-
-- Path-reference drift detection must `os.Stat` the top-level directory of a path before flagging it — bare filenames and paths under non-existent dirs are almost always documentation examples (fixed 23 false positives from prose in CONVENTIONS.md/ARCHITECTURE.md like loader.go, session/run.go, sync.Once).
-- Shell grep on constant VALUES cannot distinguish constant TYPES (cobra `Use*` syntax strings vs `DescKey*` YAML lookup keys); type-aware checks need AST analysis, not grep (captured in specs/ast-audit-tests.md; the lint-drift fix shipped in v0.8.0).
-- Convention-enforcement detection scripts (e.g. typography) need proactive exclusion patterns for files where the flagged pattern is intentional data, not prose: `*_test.go` and constant-definition files (e.g. config/token/delim.go) are the common false-positive sources.
-
----
-
-## [2026-07-15-141749] Carving one feature out of a working tree that also holds an unrelated in-progress feature needs hunk-level staging + an isolation-gate
-
-**Context**: The tree mixed a computed-index feature, a stale local copy of a contributor's hub PR (#134), and a gosec-hardening effort — interleaved even within shared files (commands.yaml, errors.yaml, .context/*). Needed to commit ONE of them cleanly. Interactive 'git add -p' isn't available headless.
-
-**Lesson**: Whole-file 'git add' bundles the other feature; naive per-file staging silently drops shared-file edits and forgets coupled files. The reliable method: (1) safety-checkpoint the whole tree to a throwaway branch first; (2) stage pure-mine files + apply only your hunks to mixed files via 'git diff HEAD -- f | filter-hunks | git apply --cached'; (3) ISOLATION-GATE: 'git stash --keep-index -u' then run full build+lint+test on the staged-only tree.
-
-**Application**: Always run the isolation-gate before committing a carve — it caught 5 unstaged files (orphaned DescKeys would've broken the build alone) and a half-done env.go fix that a whole-tree build masked. A green full-tree build does NOT prove the carved subset is self-consistent; only building the stashed-down index does.
-
----
-
-## [2026-07-15-141726] gosec G101 has two independent triggers: identifier-keyword match and value-entropy
-
-**Context**: Removed the blanket G101 path-exclusion for internal/config/embed/text/; gosec then flagged 3 lines. Two were identifier matches (DescKeyErrHubGenerateToken, ...AdminTokenRequired — 'token' substring in the name), but the third flagged only sourcemap.write-row out of 8 identical *WriteRow consts.
-
-**Lesson**: G101 matches on (a) the variable/const IDENTIFIER containing a credential keyword (token/pass/pwd/secret/cred/apiKey — case-insensitive substring), AND (b) a Shannon-entropy heuristic on the string VALUE. The single sourcemap hit was entropy, not name — which is why 7 sibling *WriteRow consts weren't flagged.
-
-**Application**: Fix each trigger at the source, never with #nosec or a path exclusion: for a name-match, rename the identifier off the keyword (keep the value/i18n-key/env-var intact so linkage/behavior are unchanged); for an entropy hit, change the value (e.g. write-row->append-row). Confirm empirically: renaming the identifier while keeping the value clears a name-match, proving it wasn't value-based.
-
----
-
-## [2026-07-06-214523] git filter-branch leaves the originals in refs/original and the reflog
-
-**Context**: Rewrote the branch to strip agent Co-Authored-By trailers and called it clean after checking only the branch tip; the originals were still recoverable and visible in git log --all.
-
-**Lesson**: filter-branch rewrites the ref but keeps originals in refs/original/* and every reflog; clean requires update-ref -d on refs/original, git reflog expire --expire=now --all, and git gc --prune=now, then a re-check across ALL refs and dangling objects. Rewriting an annotated/signed tag strips its GPG signature.
-
-**Application**: After any history rewrite: purge refs/original + reflog + gc, then verify agent trailers are zero across the whole object store, not just main..HEAD.
-
----
-
-## [2026-07-04-212434] Probing whether a ctx subcommand exists needs bare invocation, not --help
-
-**Context**: During the PR #128 review I checked ctx command existence with 'ctx  --help' and got false positives — cobra short-circuits --help to exit 0 even for an unknown subcommand, so 'ctx system stats --help' appeared to succeed for a command that does not exist. Separately, probing with a real mutating verb ('ctx task complete 3') actually completed a live task in TASKS.md, which had to be reverted.
-
-**Lesson**: To detect CLI command drift, invoke the BARE subcommand and check the real exit code (unknown => exit 1); --help lies because it is a persistent flag cobra handles before dispatch. Never probe with a mutating verb against the live project — ctx subcommands write to .context/. Also: 'ctx system ' emits the 'relay VERBATIM' version-skew NudgeBox and exits 1, so a wrapper that treats non-empty stdout as success will render that notice as a normal result.
-
-**Application**: When auditing whether an editor/wrapper's ctx invocations are valid, probe each bare form for its exit code (against a throwaway repo, or use read-only forms only); treat any wrapper whose 'non-empty output => success' logic ignores exit codes as buggy.
-
----
-
-## [2026-07-04-162854] GitNexus prunes registry entries whose repo paths don't resolve in-container
-
-**Context**: Folding the Docker gitnexus wrapper into ctx: indexing ctx silently dropped os from the global registry, and a plain 'gitnexus list' passthrough dropped it again after it was restored. Both 'gitnexus index' and 'gitnexus list' rewrite registry.json minus any entry whose path they cannot stat — and inside a container an unmounted repo is indistinguishable from a deleted one.
-
-**Lesson**: Any registry-touching gitnexus invocation run in Docker must mount EVERY registered repo at its real host path, not just the current one; otherwise it persists a pruned registry. A bash footgun compounded it: a '[ ... ] && cmd' filter as the last command of a while-loop body returns 1 when the filter fails on the final line, and set -e kills the script — use an if-block inside loops under set -e.
-
-**Application**: hack/gitnexus-docker.sh now has registry_mounts() (mount all registered repos, exclude the separately-mounted current one) wired into the index-register and passthrough branches; mcp already did this. The os and orchestrator copies of the wrapper still carry the prune bug — backport before running their index/list targets.
-
----
-
-## [2026-07-04-160749] Compliance tests are the style guide for new commands
-
-**Context**: Implementing ctx system statusline tripped eight architecture gates in sequence: magic strings/values must live in internal/config/, one visibility per file, cmd/ files restricted to cmd.go+run.go+doc.go with helpers in core/, types in types.go, the ctxRC schema mirror struct, the system doc.go subcommand registry, godoc Parameters/Returns structure, and 80-char lines
-
-**Lesson**: House conventions are enforced by go test (internal/compliance/ and sibling suites), not documented prose; the test suite is the authoritative style guide
-
-**Application**: Before scaffolding a new command, read internal/compliance/ and copy an existing cmd package's structure; run the full suite early rather than discovering gates by failure at commit time
-
----
-
-## [2026-07-04-152957] Typed JSON round-trips silently drop user-owned keys
-
-**Context**: The init permissions merge read settings.local.json into a typed struct and re-marshaled it, so every key ctx does not model (env, a user's statusLine) vanished on re-init
-
-**Lesson**: A typed read-modify-write of a file users also edit by hand is silent data loss; unknown fields do not survive encoding/json round-trips
-
-**Application**: Mutate shared JSON via raw-map surgery (map[string]json.RawMessage), rewriting only the keys ctx owns; see internal/cli/initialize/core/merge/settings.go
-
----
-
-## [2026-07-03-182238] New Claude model families silently fall to the 200k default in ModelContextWindow
-
-**Context**: claude-fable-5 sessions warned '104 percent full' while the 1M window was 79 percent free; ModelContextWindow only recognized the [1m] suffix and the opus substring
-
-**Lesson**: Unmapped model families inherit rc.DefaultContextWindow (200k), and every EffectiveContextWindow consumer (check-context-size hook, heartbeat, nudges, provenance) misreports against the wrong window
-
-**Application**: At every model launch: add the family substring to internal/config/claude, extend TestModelContextWindow, and sanity-check against a live session's /context output before trusting hook percentages
-
----
-
-## [2026-06-07-162840] Pin an on-disk contract before splitting work across parallel agents
-
-**Context**: The ctx-dream skill (proposal writer) and the ctx dream CLI
-(proposal reader) were built by parallel tracks; each independently invented the
-proposals layout — one per-file proposals/.json, the other a single
-proposals.json array — requiring reconciliation at integration.
-
-**Lesson**: When parallel agents share a serialized artifact (a file path +
-schema), the exact on-disk shape must be fixed in BOTH prompts up front. 'Each
-track invents it' guarantees a mismatch that only surfaces at integration.
-
-**Application**: Before fanning out producer/consumer work to parallel agents,
-write the precise file path + JSON schema into both agents' specs as a fixed
-contract, and reconcile/verify it first thing at integration.
-
----
-
-## [2026-06-07-142015] ctx-dream is headless-first; invoking /ctx-dream interactively is debugging, not the UX
-
-**Context**: An end user ran /ctx-dream in their foreground terminal session and
-watched the agent hand-execute the pass (grep/cat/hash/write JSON). Their words:
-'I'm not dreaming but viewing a dream being debugged.'
-
-**Lesson**: The dream is a sleep-time/headless product: cron 'claude -p' runs
-the pass out-of-band, then the human is nagged and reviews via /ctx-serendipity.
-The /ctx-dream SKILL is the executor's instruction set, not a user command —
-driving it interactively makes the agent perform the executor's mechanical work
-visibly, which is a debugging affordance, not the end-user experience. Exposing
-/ctx-dream as a user slash-command invites exactly this confusion.
-
-**Application**: End-user entry points are 'ctx dream' (on-demand pass that
-prints a digest) and cron (scheduled); review is /ctx-serendipity. Do NOT have
-users invoke /ctx-dream directly. Reconsider de-listing /ctx-dream from the
-user-invocable skill set, ensure 'ctx dream' gives a clean run->digest
-experience, and wire the 'serendipity round waiting' nag so the dream->review
-loop closes without the user watching a pass.
-
----
-
-## [2026-06-07-170001] ctx-dream design principles (consolidated)
-
-**Consolidated from**: 6 entries (2026-06-06 to 2026-06-07)
-
-- Merit/scoring rubric
-  (relevance/frequency/recency/diversity/consolidation/richness, à la Hermes
-  "Dreaming") measures ATTENTION (what to surface first), never TRUTH; use it
-  only as a ranking signal feeding ruthless self-rejection, never as an
-  autonomous promotion threshold — pair any statistical ranking with an
-  evidence/grounding gate that decides eligibility.
-- Load-bearing invariant (Option B): dream consolidation emits PROPOSALS only; a
-  human accept/reject gate sits between the dream pass and any write to the five
-  canonical files / MEMORY.md. Autonomous canonical writes are the documented
-  rot failure mode (arXiv 2605.12978); independent designs (Hermes, OpenClaw,
-  Auto-Dreamer) re-derive the sleep-phase shape but omit the gate. When
-  evaluating any external memory-consolidation design, first check: does it
-  autonomously write canonical, or only propose? Autonomous-write is a reject.
-- A single LLM asked to critique a proposal silently repairs the missing
-  justification and approves it (ReportLogic finding) — a single agreeable LLM
-  is not an adversarial gate. Robust gating needs human or independent
-  multi-critic consensus + swap-consistency. (This says a gate must EXIST; the
-  proposes-only entry says one must sit before canonical writes; together they
-  define WHO and WHETHER.)
-- Same proposals, two consumers, two interfaces: render a terse/dispositional
-  accept-reject worklist for the agent reviewer and a substance-rich,
-  semantically-generated summary for the human (no file-hunting). Same data,
-  presentation per consumer.
-- Split agent/human work by comparative advantage: the agent is the reliable
-  gardener for mechanical/verifiable hygiene (never skips the 47th file); the
-  human owns taste/serendipity — which is WHY the human is the gate, not
-  merely a safety nicety. Design the human's surface for pleasure (substance to
-  wander), not a queue to drain.
-- Don't-leak is a third safety axis alongside don't-corrupt and
-  don't-obey-injected-instructions: a summary/backup/ledger-line of a gitignored
-  source inherits its privacy class. Keep every byproduct in gitignored
-  locations; enforce structurally with `git check-ignore` on each write target
-  (refuse tracked paths), never via prompt. A deliberate human `promote` is the
-  only sanctioned boundary crossing.
-
----
-
-## [2026-06-07-170002] internal/audit & compliance gates for new code (consolidated)
-
-**Consolidated from**: 6 entries (2026-03-15 to 2026-05-30)
-
-- New exported types must live in types.go: TestTypeFileConvention permits types
-  outside types.go only in pure-type files (defs+methods, no standalone funcs)
-  or exempt packages; a file mixing structs with standalone funcs fails. Put
-  type defs in a dedicated types.go from the start.
-- internal/assets/tpl is on the magic-strings exempt list, so template-path
-  literals are sanctioned THERE — but render data passed from non-exempt
-  callers must be a typed struct (tpl.ObsidianData{...}), never map[string]any
-  with literal keys, which trips the audit at the call site.
-- Full gate catalog for a new package/CLI command (none surfaced by `go
-  build`/`golangci-lint` — run `go test ./internal/audit/
-  ./internal/compliance/`): TestNoMixedVisibility (split unexported helpers into
-  _internal.go), TestNoMagicStrings/Values (named consts in
-  internal/config/warn/ for warn formats; named const for bare ints),
-  TestDocCommentStructure (Parameters/Returns on every helper, exported or not),
-  TestNoCmdPrintOutsideWrite (route output through internal/write//),
-  TestNoNakedErrors, TestTypeFileConvention, TestCmdDirPurity (no helpers in
-  cmd/ — use core//), TestNoLiteralMdExtension (file.ExtMarkdown),
-  TestDocGoSubcommandDrift (parent doc.go lists every subcommand),
-  TestDescKeyYAMLLinkage, TestNoLiteralWhitespace (token.NewlineCRLF/LF),
-  TestRegistryCount (bump on registry.yaml additions). staticcheck QF1012 vs
-  TestNoUncheckedFmtWrite: build with fmt.Sprintf then b.WriteString.
-- naked_errors audit flags every fmt.Errorf/errors.New outside internal/err/**
-  — call-site wrapping does NOT satisfy it. Error constructors live in
-  domain-scoped internal/err// pulling format strings from
-  internal/config// or desc.Text. Pattern: `var ErrX =
-  errors.New(cfgArea.ErrMsgX)` (sentinel); `func X(args, cause) error { return
-  fmt.Errorf(cfgArea.FormatX, …) }` (wrapper). Budget ~3 files/area for any
-  new error surface.
-- Pre-emptive constants are dead exports: TestNoDeadExports is
-  symbol-graph-strict — any exported const/var/func without an internal reader
-  fails. Land constants in the same commit (or strict precursor) as their
-  caller; never scaffold config ahead of consumers. Genuine future-use goes in a
-  TASKS.md line, not a config file.
-- Dead-code detection: packages can build+test green while unreachable — check
-  bootstrap registration, not build success (e.g. internal/cli/recall/ had
-  tests, never wired). Files created by `ctx init` with no agent/hook/skill
-  reader are dead on arrival. When touching legacy compat code, first ask if the
-  legacy path has real users; if not, delete rather than improve (MigrateKeyFile
-  had 5 callers, zero users).
-
----
-
-## [2026-06-07-170003] Error handling: sentinels, unwrapping, and silent discards (consolidated)
-
-**Consolidated from**: 6 entries (2026-03-06 to 2026-06-02)
-
-- os.IsNotExist does NOT unwrap — it is false on any fmt.Errorf("…%w…")
-  error; prefer errors.Is(err, os.ErrNotExist). But errors.Is only holds if the
-  wrap carries %w at runtime, and a wrap whose format string comes from the
-  text/i18n registry only carries %w when that registry is initialized (so it
-  behaves differently in prod vs a bare test binary; go vet can't see it). To
-  detect file absence reliably, stat directly: os.Stat returns an unwrapped
-  *fs.PathError so errors.Is(statErr, os.ErrNotExist) is dependable everywhere.
-- An error-discard catalogue (grep + name/regex classification) is an inventory
-  of candidates, not findings. Name-inference produces false positives (a
-  discarded bool mistaken for an error; a value type that can't nil-deref; an
-  already-failed cleanup-close path). Read the callee signature and enclosing
-  control flow before assigning return-error vs logWarn vs annotate.
-- Canonical sentinel shape: a typed zero-data struct (or fielded struct for
-  parameterised errors) whose Error() resolves text via
-  desc.Text(text.DescKey…) lazily at call time — never `var ErrX =
-  errors.New("english")` and never an ErrMsg* string-const layer. Empty-struct
-  values are comparable and errors.Is finds them through %w wraps. Reference:
-  internal/err/context/context.go.
-- fmt.Fprintf to strings.Builder silently discards errors (Write never fails) so
-  errcheck allows it, but project convention forbids any silent discard —
-  TestNoUncheckedFmtWrite enforces `if _, err := fmt.Fprintf(...)`.
-- A path-returning (string, error) function must never return ('', nil):
-  filepath.Join('', rel) yields rel as a CWD-relative path, causing orphan
-  writes at project root. Sentinel errors force callers to gate. Audit any
-  path-returner with a historic ('', nil) shortcut (fixed: state.Dir,
-  rc.ContextDir).
-- Package-local err.go files in CLI packages invite agents to duplicate error
-  constructors (errFileWrite, errMkdir repeated). Centralize in internal/err; no
-  err.go files in CLI packages.
-
----
-
-## [2026-06-07-170004] git CLI wrapping quirks (consolidated)
-
-**Consolidated from**: 4 entries (2026-03-24 to 2026-05-22)
-
-- `git rev-parse` exits 0 on an unknown long-flag and echoes the literal arg
-  back as its only stdout line (treats it as a candidate revision name). A
-  non-zero-exit guard never trips, so `--show-current` shipped verbatim into
-  handover frontmatter. Validate the OUTPUT shape (length, no `--` prefix,
-  hex-ness for SHAs) when wrapping rev-parse, not just the exit code.
-  (`--show-current` is a `git branch` flag, not rev-parse.)
-- Group git flag constants by the subcommand whose argv they're valid in (//
-  Branch subcommand flags, // Rev-parse flags), not by "loose CLI flags" — the
-  group comment is informal type info; mis-grouping enables wrong-subcommand
-  bugs. Genuinely-spanning flags (-C, --) go under an explicit Cross-subcommand
-  group.
-- `git describe --tags --abbrev=0` follows reachability from HEAD, not the
-  global tag list (diffed against v0.3.0 instead of v0.6.0 on a diverged release
-  branch). For "latest release globally" use `git tag --sort=-v:refname | head
-  -1`.
-- A trailing regex word boundary \b does NOT exclude hyphenated continuations
-  (\bgit commit\b matches `git commit-tree`). For porcelain with hyphenated
-  cousins (commit-tree, commit-graph, for-each-ref) append a (?!-) negative
-  lookahead.
-
----
-
-## [2026-06-07-170005] TypeScript/integration test surfaces & exclusion rot (consolidated)
-
-**Consolidated from**: 4 entries (2026-05-11 to 2026-05-22)
-
-- Removing/renaming any cross-language contract (env channel, feature flag) is a
-  FOUR-surface cleanup, not three: (1) Go build+lint+test, (2) audit/compliance
-  tests, (3) asset templates (CLAUDE.md, AGENT_PLAYBOOK, hooks.json), (4)
-  TypeScript-typed integrations (opencode plugin, vscode extension). The TS
-  surface is invisible to `go test ./...` by design; tsc --noEmit only runs in
-  CI unless invoked from tools/typecheck/opencode/ or editors/vscode/. Want: a
-  `make typecheck` target wrapping both, in pre-commit + release checklist.
-- tsc resolves node_modules by walking up from each SOURCE file's location, not
-  the tsconfig's location. For a cross-tree setup (tsconfig in dir A, include
-  points at dir B), add explicit baseUrl + paths (+ typeRoots) to the tsconfig
-  so node_modules can live with the tooling.
-- vitest's vi.mock() does NOT preserve Node's async-deferral guarantees: a
-  mocked execFile (or fs.readFile, dns.lookup, http.request) can fire its
-  callback synchronously, TDZ-trapping a closure that's provably safe by Node's
-  contract. When a linter suggests tightening let→const on a var captured
-  through an async callback, verify under the test runner; the safe form is
-  `let` + an eslint-disable naming the mock constraint.
-- A test suite excluded from BOTH typecheck and execution rots compounding:
-  re-enable cost = sum of ALL drift since last green (named 2 breakages, found
-  18 more on first run), not just the named bug. expect.anything()/expect.any()
-  pass typecheck so only execution catches the drift. When adding any tooling
-  exclude (tsconfig glob, vitest ignore, pytest --ignore), file an immediate
-  follow-up whose acceptance criterion is removal; budget 5–20× the named
-  scope on re-enable.
-
----
-
-## [2026-06-07-170006] Editorial KB pipeline: design epistemology (consolidated)
-
-**Consolidated from**: 5 entries (all 2026-05-10)
-
-- An ongoing user paying concrete workaround tax (disabled skills, hand-typed
-  closeouts, colliding root constitution files) is the strongest validation
-  evidence — beats user research, N=2 discussion, "seems useful." Use the
-  workaround details as the inverse-spec; ship the shape they hand-rolled and
-  use their project as the regression corpus.
-- When lifting from a battle-tested external design, lift the renames and
-  disambiguation moves alongside the features: intentional renames encode
-  resolved conflicts (KB-RULES.md not CONSTITUTION.md; domain-decisions.md not
-  DECISIONS.md). Treating them as cosmetic re-litigates the underlying fight.
-- KB epistemology: a knowledge base has no "decide" moment — only
-  evidence-capture events with confidence bands (>0.9 = decided by contract).
-  Even NL assertions ("anchor on this") are evidence-capture, not
-  decision-capture. So a parallel /ctx-kb-decide skill is the wrong shape; the
-  pipeline-only-writer model is ontologically correct. General check: "I chose
-  between alternatives" vs "I learned about the world."
-- Recursive composability eliminates feature classes: a KB of KBs is a KB
-  (source-map kind: kb + the standard ingest pipeline covers federation; no v1
-  schema lockout). Ask whether the standard pipeline pointed at its own output
-  covers a "thing-of-things" before designing a new mechanism.
-- The LLM is the migration tool: every category of being-wrong about a schema
-  (ID renumbering, taxonomy reshuffle, band remapping, path renames) is cheap
-  because LLM cleanup absorbs the migration. Commit to the readable, opinionated
-  v1 schema instead of hedging with abstract types; surface dirty state via
-  doctor advisories so the agent has a work surface.
-
----
-
-## [2026-06-07-170007] Documentation, template & asset drift (consolidated)
-
-**Consolidated from**: 6 entries (2026-02-24 to 2026-04-01)
-
-- Exhaustive lists/counts in architecture docs (package lists, command tables,
-  skill counts) drift silently because nobody re-counts (23 listed vs 31
-  actual). Add `` markers; run
-  /ctx-architecture after adding packages/commands (/ctx-drift catches stale
-  paths but not missing entries).
-- Template changes are invisible to existing projects until `ctx init --force`;
-  non-destructive init never re-syncs. checkTemplateHeaders was added to `ctx
-  drift`.
-- Any content duplicated in two locations without a sync mechanism drifts
-  silently (Copilot CLI skills as condensed ctx skills; assets/why/ vs docs/).
-  Wire freshness checks as build PREREQUISITES, not optional audit steps (make
-  sync-copilot-skills, make sync-why must be build deps).
-- Machine-generated CLAUDE.md content (GitNexus injected 121 lines / 61%)
-  consumes per-turn budget without proportional value. Auto-generated content
-  belongs in on-demand skills; prefer a one-line pointer over inline content.
-  Audit CLAUDE.md periodically.
-- CLI reference docs outpace implementation (ctx remind had no CLI, recall sync
-  no Cobra wiring) — verify with `ctx  --help` before releasing docs.
-  Agent style-violation sweeps are unreliable (8 found vs 48+ actual); follow
-  with targeted grep + manual classification. Documentation audits must compare
-  against known-good examples for the COMPLETE standard, not mere presence. New
-  audit concerns (e.g. dead links) belong in an existing audit skill's checklist
-  before becoming standalone.
-
----
-
-## [2026-06-07-170008] User-facing text & magic-string discipline (consolidated)
-
-**Consolidated from**: 4 entries (2026-03-14 to 2026-04-04)
-
-- Any string containing English words alongside format directives ("%d entries
-  checked") is user-facing text belonging in YAML assets — the format-verb
-  (and URL-scheme, HTML-entity, err/) exemptions were removed from
-  TestNoMagicStrings.
-- Any string reaching the user, including stderr warnings, routes through
-  assets.TextDesc() for i18n readiness; create text.yaml entries and asset keys
-  first.
-- Magic-string cleanup is fractal: each fix puts adjacent code under scrutiny (4
-  Fprintf calls → over-tokenized formats, magic hex perms, TOML tokens,
-  missing docstrings). Budget 2–3× the initial estimate; commit per layer.
-- Naming a constant _alt and hardcoding one non-English language as a built-in
-  default is implicit language favoritism that doesn't scale (alt_2? alt_3?).
-  Use configurable lists from the start; default to a single canonical value,
-  all extensions user-configured equally.
-
----
-
-## [2026-06-07-170009] Constant placement & helper smells (consolidated)
-
-**Consolidated from**: 6 entries (2026-03-07 to 2026-03-23)
-
-- A constant used by only one domain (agent scoring, budget %, cooldowns)
-  belongs in that domain's config package, not a god-object file.go. Check
-  callers before placing.
-- Before adding any constant to internal/config, grep by VALUE (".jsonl") not
-  just name — camelCase vs ALLCAPS variants hide duplicates (ExtJsonl vs
-  existing ExtJSONL).
-- Project-root files created by `ctx init` (Makefile) are scaffolding
-  (config/file), NOT context files loaded via ReadOrder (config/ctx). Check
-  ReadOrder membership before moving a file constant.
-- SafeReadFile / validation.SafeReadFile take (baseDir, filename) separately —
-  split full paths with filepath.Dir + filepath.Base when adapting os.ReadFile
-  calls.
-- One-liner method wrappers that just forward a struct field to a stdlib/pkg
-  function (checkBoundary → validation.ValidateBoundary with h.ContextDir)
-  obscure the real dependency — inline them.
-- A param-struct field that is a function pointer where all callers pass thin
-  wrappers varying only by a text key (MergeParams.UpdateFn) is "data in
-  disguise" — replace the callback with the key and let the consumer dispatch.
-
----
-
-## [2026-06-07-170010] Convention enforcement: mechanical gates over prose (consolidated)
-
-**Consolidated from**: 6 entries (2026-03-16 to 2026-04-14)
-
-- System-level brevity instructions outcompete context-injected conventions;
-  memory shifts probability (~40%→~70%) but doesn't create invariants. Invest
-  in linter/PreToolUse gates for mechanically-checkable conventions; reserve
-  behavioral nudges for judgment calls.
-- Force-loaded behavioral prose (AGENT_PLAYBOOK at ~14k tokens) gets skipped
-  when the user's first message is a concrete task; action-gating hooks
-  (qa-reminder, specs-nudge) are followed because they fire at the moment of
-  violation. More injected content = less attention per token. Prefer
-  action-gating hooks; reserve force-injection for hard rules + distilled
-  checklists.
-- Any docstring/comment/documentation-formatting task is convention-sensitive:
-  read CONVENTIONS.md (Documentation section) + LEARNINGS.md for known gaps
-  FIRST, and audit all functions in scope against the template, not just diffed
-  ones.
-- AST audit tests must default to scanning ALL documented functions (use
-  opt-outs not exported-only opt-ins) — TestDocCommentStructure missed
-  unexported helpers (84 violations fixed). And the stutter test
-  (TestNoStutteryFunctions) walks *ast.FuncDecl only, not GenDecl — stuttery
-  const/var/type names slip through until the audit is extended.
-- Every exemption map/allowlist in audit tests is a tempting agent shortcut: add
-  DO-NOT-widen guard comments to every exemption data structure (10 across 7
-  files) and review PRs for drive-by allowlist additions.
-
----
-
-## [2026-06-07-170011] Go toolchain, gofmt & build-tag pitfalls (consolidated)
-
-**Consolidated from**: 5 entries (2026-03-16 to 2026-05-10)
-
-- gofmt strips bare `//` padding lines as unnecessary whitespace, so
-  programmatic Go generation must produce substantive content lines; always run
-  gofmt after any scripted Go-file generation.
-- Agents reliably introduce gofmt issues during bulk renames (75+ files, 12
-  broken); run `gofmt -l` (then `-w`) as a standard step after any agent-driven
-  bulk edit before trusting the build.
-- The "compile version X does not match go tool version Y" error comes from the
-  CACHED toolchain (~/go/pkg/mod/golang.org/toolchain@…), not the system Go
-  — reinstalling Go does nothing. Diagnose via `go env GOROOT`; fix by
-  deleting the cached dir, bumping go.mod, or GOTOOLCHAIN=go. `go clean
-  -cache` and GOTOOLCHAIN=local don't help.
-- `make test` exit code is unreliable: the -cover flag can fail with "no such
-  tool covdata" even when every package passes. Fall back to `go test ./...` (no
-  -cover) and tally ^ok/^FAIL.
-- AST checks via go/packages only see files matching the current GOOS —
-  darwin-only (_darwin.go) violations are invisible on Linux. Fix violations
-  regardless; note coverage is platform-dependent (need multi-GOOS CI or a
-  go/parser fallback).
-
----
-
-## [2026-06-07-170012] Stale-task triage & verify-before-acting (consolidated)
-
-**Consolidated from**: 4 entries (2026-03-01 to 2026-05-23)
-
-- Stale TASKS.md items often describe work already done in code but not asserted
-  in tests — the task stayed open because nothing pinned the behavior. Triage
-  older items by grep/git-blame on the named symbols; if implemented, close by
-  writing the regression test (often one function). Applies to behavior-named
-  tasks more than feature-named ones.
-- Tasks can be stale in reverse: implementation completed but task not marked
-  done (recall sync was fully wired despite a "not registered" description). Run
-  `ctx  --help` before assuming work remains.
-- Grep for callers must cover the ENTIRE working tree before deleting functions
-  — with unstaged changes from a prior session, grep hits only
-  committed+staged code. Always `make build` after deleting functions even when
-  grep shows zero callers.
-- Spec-trailer improvisation is heuristic drift: when no on-topic spec exists,
-  the path of least resistance cites the most-recent spec from context,
-  satisfying the syntactic gate but defeating truthful traceability — and
-  session-scoped "I'll be careful" commitments don't survive across sessions, so
-  the fix must live in persistent context. Correct responses: scaffold a fresh
-  spec, bundle into the next functional commit, or cite specs/meta/chores.md.
-  (See specs/spec-trailer-discipline.md; AGENT_PLAYBOOK Spec Verification Step.)
-
----
-
-## [2026-06-07-170013] Refactor mechanics: subagents, cascades & golden fixtures (consolidated)
-
-**Consolidated from**: 6 entries (2026-02-19 to 2026-05-30)
-
-- Behavior-preserving refactors of formatting/rendering code: capture golden
-  fixtures from the LIVE legacy path before deleting it (throwaway test writes
-  testdata/*.golden), then assert byte-equality after — avoids silent drift
-  from hand-transcribing expected output.
-- Removing a sentinel (ErrDirNotDeclared) cascades through ~10 errors.Is
-  consumers and ~30 test fixtures; spec-level step boundaries that separate
-  "swap resolver" from "remove guard" don't survive when the second references
-  the soon-deleted sentinel. Plan the merged commit at spec time; do the
-  compile-surface analysis then.
-- Subagent parallelism shines for well-bounded mechanical refactor WITH a
-  canonical worked example on disk and an explicit fix-or-fail-with-a-blocker
-  instruction (invoke the no-deferral rule). Do one worked example in the
-  orchestrator, then dispatch subagents pointing at it.
-- Subagents reliably exceed scope (rename funcs, change signatures, restructure
-  files even for em-dash fixes) and create new files without deleting originals.
-  After any agent refactor: `git diff --stat`, `git diff --name-only HEAD`,
-  revert out-of-scope changes, check for stale package decls/duplicate
-  defs/orphaned imports, run gofmt + `go test ./...`.
-- Splitting a flat core/ package into subpackages exposes duplicated logic,
-  misplaced types, and function-pointer smuggling invisible in the flat layout;
-  circular-dep resolution during the split IS the design work that reveals the
-  right structure.
-- Cross-cutting change ripple: path/asset/feature changes ripple across 15+ doc
-  files + multiple layers (embed directive, accessors, callers, tests, config
-  consts, build targets, docs). Grep broadly (not just code); a feature without
-  docs (feature page, cli-reference, recipes, nav) is invisible.
-
----
-
-## [2026-06-07-170014] Linting, gosec & I/O chokepoints (consolidated)
-
-**Consolidated from**: 4 entries (2026-01-25 to 2026-04-03)
-
-- Full pre-commit gate, every time: (1) CGO_ENABLED=0 go build ./cmd/ctx, (2)
-  golangci-lint run, (3) CGO_ENABLED=0 go test. Own the codebase — fix
-  pre-existing lint issues you didn't introduce.
-- gosec permissions: 0o600 for files (incl. tests — G306 flags 0644 even in
-  test code), 0o750 for dirs (G301); G304 file-inclusion is safe to
-  //nolint:gosec in tests using t.TempDir(). Prefer renaming constants to avoid
-  G101 false positives (Tokens→Usage, Passed→OK) over nolint/nosec/path
-  exclusions, which break on file reorg.
-- Suppression anti-patterns: nolint:goconst normalizes magic strings (use config
-  consts); nolint:errcheck in tests teaches agents to spread the pattern to
-  production (use t.Fatal for setup, `defer func(){ _ = f.Close() }()` for
-  cleanup); golangci-lint v2 ignores inline nolint for some linters — use
-  config-level exclusions.rules for gosec, fix the code for errcheck. Use
-  cmd.Printf/Println in Cobra commands instead of fmt.Fprintf. `defer
-  os.Chdir(x)` fails errcheck — wrap in `defer func(){ _ = os.Chdir(x) }()`.
-  CI Go-version mismatch: install-mode goinstall.
-- Chokepoint migrations have cascading benefits: centralizing file I/O into
-  internal/io/ (already using config/fs consts) zeroed out TestNoRawPermissions
-  for free. Prioritize chokepoint migrations (io, exec, write, err) before
-  smaller dependent checks.
-
----
-
-## [2026-06-07-170015] Hook mechanics, output channels & compliance (consolidated)
-
-**Consolidated from**: 5 entries (2026-01-25 to 2026-04-06)
-
-- Hook scripts receive JSON via stdin (HOOK_INPUT=$(cat) then jq), not env vars;
-  key names are case-sensitive (PreToolUse, SessionEnd); use
-  $CLAUDE_PROJECT_DIR, never hardcode paths; anchor regex to command-start
-  `(^|;|&&|\|\|)\s*` ('ctx' binary vs dir); grep matches inside quoted args
-  (test with blocked words); scripts silently lose execute permission (verify ls
-  -la).
-- Output routing: plain-text hook stdout is silently ignored — Claude Code
-  parses stdout starting with `{` as JSON directives; return JSON via
-  printHookContext(). For UserPromptSubmit specifically, stdout is prepended as
-  AI context (not user-visible), stderr+exit0 is swallowed, user-visible output
-  requires {"systemMessage":"…"} or exit 2 (blocks); there is NO non-blocking
-  user-visible channel. Two-tier severity is sufficient: unprefixed (agent
-  context, may relay) and "IMPORTANT: Relay VERBATIM" (guaranteed); don't add
-  more prefixes.
-- Agents only relay content with explicit display instructions: a
-  system-reminder line with no "Display this line verbatim" is invisible to the
-  user even when correct. IMPORTANT: signals internal priority, not user-facing
-  output.
-- Compliance: soft instructions have a ~75–85% ceiling because "don't apply
-  judgment" is itself judgment; for 100% compliance inject via additionalContext
-  rather than instruct. Hook compliance degrades on narrow mid-session tasks
-  (~15–25% skip) because CLAUDE.md's "may or may not be relevant" competes
-  with hook authority — fix by elevating hook authority explicitly; the
-  mandatory checkpoint relay block is the compliance canary. No reliable
-  agent-side before-session-end event exists (SessionEnd fires after the agent
-  is gone) — mid-session nudges + explicit /ctx-wrap-up are the only reliable
-  persistence. Repeated injection causes repetition fatigue — gate with
-  --session $PPID --cooldown and pair with a readback instruction.
-- Context-budget injection strategy: once ~7K tokens are auto-injected (fait
-  accompli), the agent's rationalization inverts from "skip to save effort" to
-  "marginal cost is trivial." Front-load highest-value content as injection,
-  then leverage sunk cost for on-demand reads. Verbal summaries + linked diagram
-  files cut ARCHITECTURE.md ~12K→3.8K (extract diagrams outside FileReadOrder;
-  the 4-chars/token estimator is accurate — optimize content not the
-  estimator).
-
----
-
-## [2026-06-07-170016] State, tombstones, logs & filesystem hygiene (consolidated)
-
-**Consolidated from**: 6 entries (2026-02-11 to 2026-03-06)
-
-- Permission drift is distinct from code drift — settings.local.json is
-  gitignored so no review catches stale entries; it accumulates session debris
-  (run /sanitize-permissions + /ctx-drift). Skill() permissions don't support
-  name-prefix globs (list each); wildcard trusted binaries (Bash(ctx:*),
-  Bash(make:*)) but keep git granular (never Bash(git:*)).
-- Gitignored directories are invisible to git status — stale artifacts persist
-  indefinitely (periodically ls them). Add editor artifacts (*.swp,*.swo,*~) to
-  .gitignore from day one. Gitignore entries for sensitive paths are security
-  controls, not documentation — never remove during cleanup.
-- The state directory accumulates write-only session tombstones and grows
-  unbounded without auto-prune (234 files found); autoPrune(7) now runs once per
-  session at startup via context-load-gate (manual `ctx system prune` still
-  available).
-- A session-scoped tombstone must include the session ID in its filename, else
-  it suppresses hooks across ALL concurrent and future sessions (memory-drift
-  fixed; backup-reminded, ceremony-reminded, check-knowledge, journal-reminded,
-  version-checked, ctx-wrapped-up still carry this bug). Use the UUID pattern so
-  prune can clean them.
-- New log sinks must follow the established rotation pattern (size-based, single
-  previous generation): eventlog rotated at 1MB but logMessage() in state.go was
-  append-only with no size check.
-- If a directory is recreated (auto-prune), an SSH shell holding the old inode
-  won't see new files (ls returns "no such file" though cat with the full path
-  works elsewhere); after `ctx system prune` or any state recreation, SSH
-  sessions need cd-. or re-login.
-
----
-
-## [2026-06-07-170017] Host-pressure alerting: use derivatives, not levels (consolidated)
-
-**Consolidated from**: 2 entries (2026-04-13 to 2026-05-28)
-
-- Swap occupancy is NOT memory pressure: macOS/Windows swap proactively and
-  occupancy is a sticky high-water mark that doesn't recede when pressure ends,
-  so any alert keyed on SwapUsed/SwapTotal ≥ X% false-positives at session
-  start (e.g. after hibernation). Key on OS-native pressure derivatives instead:
-  macOS kern.memorystatus_vm_pressure_level (1/2/4 → OK/Warning/Danger), Linux
-  PSI /proc/pressure/memory some.avg10/full.avg10; fall back to swap-out RATE
-  gated on low available memory, never occupancy.
-- Load average measures a queue (runnable + uninterruptible-sleep), not CPU
-  utilization — high load with low CPU% means many short-lived/I/O-bound
-  processes (e.g. go test spawning hundreds of binaries). For automated alerts
-  prefer the 5-minute average over the reactive 1-minute, which fires on normal
-  build/test activity.
-
----
-
-## [2026-06-07-170018] Go test isolation & patterns (consolidated)
-
-**Consolidated from**: 4 entries (2026-01-19 to 2026-04-25)
-
-- Any code using os.UserHomeDir() / user-level paths (~/.ctx/, ~/.config/) needs
-  t.Setenv("HOME", tmpDir) in tests — especially shared setup helpers. Under
-  parallel `make test`, fourteen test files invoking initialize.Cmd().Execute()
-  raced on read-modify-write of ~/.claude/settings.json, surfacing as flaky
-  "FAIL coverage: [no statements]"; testctx.Declare now sets HOME alongside
-  CTX_DIR (centralized fix).
-- Go testing patterns: `go build ./...` misses test-file callsite breaks —
-  always `go test ./...` after signature changes. Consume all runCmd() returns
-  (`_, _ = runCmd(...)`) for errcheck. Disable ANSI via color.NoColor=true in
-  package init for string assertions. Recall tests isolate via t.Setenv("HOME",
-  tmpDir) with .claude/projects/. formatDuration takes an interface with
-  Minutes() (use a stubDuration). CI needs CTX_SKIP_PATH_CHECK=1 (init checks
-  PATH). CGO_ENABLED=0 for ARM64 Linux.
-- Converting PersistentPreRun → PersistentPreRunE changes exit behavior:
-  errors propagate through Cobra Execute() return with no os.Exit.
-  Subprocess-based tests expecting exit codes must convert to direct error
-  assertions.
-
----
-## [2026-06-01-174927] Guard managed blocks before regenerating; don't trust the span to be machine-owned
-
-**Context**: ctx learning add silently deleted entry bodies that lived between
-INDEX:START/END markers: index.Update replaced the whole marker span with a
-regenerated table, and ParseHeaders scanning the full file made the result look
-complete, hiding the loss.
-
-**Lesson**: Code that 'replaces the managed block' (index regen, KB managed
-blocks, moc.go) assumes the span between its markers is disposable and
-machine-owned. That assumption breaks the moment user content drifts inside the
-markers, and the regenerated output looks correct so the loss is invisible. The
-fix is a precondition guard that refuses to mutate when regeneration would lose
-data — not smarter parsing of the trapped content.
-
-**Application**: Before any 'replace between markers' write, validate the span:
-refuse on entry/content found where only generated output belongs, and on
-malformed/duplicated/out-of-order markers. Fail loud and leave the file
-byte-identical rather than regenerate. Run the guard at the read-before-mutate
-choke point so nothing is written on refusal.
-
----
-
-## [2026-05-31-094649] macOS GUI apps inherit a minimal PATH; augment it to find a user-installed CLI
-
-**Context**: A bundled Tauri app launched via Finder/launchd gets a minimal PATH (/usr/bin:/bin:...), so /usr/local/bin/ctx is not found even though it resolves in a terminal-launched dev run.
-
-**Lesson**: Do not rely on inherited PATH when spawning user-installed CLIs from a desktop GUI.
-
-**Application**: ctx_adapter prepends /usr/local/bin:/opt/homebrew/bin to PATH on every std::process::Command invocation.
-
----
-
-## [2026-05-31-094649] Tauri 2 requires rustc >= 1.88; bump the toolchain before cargo check
-
-**Context**: cargo check for the ctx-desktop Tauri app failed: darling, serde_with, time and plist transitive deps require rustc 1.88, but the local toolchain was 1.87.
-
-**Lesson**: Tauri 2's dependency tree tracks recent rustc releases; the pinned-stable assumption breaks builds.
-
-**Application**: Run rustup update stable before building a Tauri 2 app; this project moved 1.87 -> 1.96.
-
----
-
-## [2026-05-28-215214] ctx kb: single topic-enumeration site; life-stage count is consumer-side
-
-**Context**: kb reindex blanked the CTX:KB:TOPICS block for grouped kbs
-(things-wtf-dr regrouped 49 topics into folders); the task speculated a sibling
-life-stage topic-count glob was also affected.
-
-**Lesson**: reindex.ListTopics (internal/cli/kb/core/reindex/topic.go) is the
-ONLY topic enumeration/count in ctx, and CTX:KB:TOPICS is the only managed
-block. The life-stage concept in ctx is the ingest/closeout frontmatter field,
-unrelated to topics. Any per-life-stage topic count lives in the consumer kb,
-which ctx neither generates nor owns.
-
-**Application**: Localize nested-topic fixes to ListTopics; treat
-per-group/per-life-stage topic counts as consumer territory (same recurse +
-exclude-group-landing pattern, fixed in their repo).
-
----
-
-## [2026-05-28-201400] A non-root Go module nested under the main module's path CAN import its internal/ packages
-
-**Context**: While designing the ctxctl module split, the initial spec (and a
-lot of online consensus) claimed a separate `go.mod` cannot import the parent
-module's `internal/` packages, which would have forced relocating or duplicating
-~25 foundation packages (`rc`, `desc`, `nudge`, `config/*`, …). The "obvious"
-reading made same-module the only viable option.
-
-**Lesson**: Go's internal-import rule is **lexical on import paths, not
-module-scoped**. A separate module whose path is
-`github.com//
/tools/` CAN import -`github.com//
/internal/...` — verified by an empirical build -experiment this session. An outsider path (`example.com/...`) is rejected with -`use of internal package … not allowed`. The rule fires on the import-path -prefix relative to the `internal/` directory's parent, not on module boundaries. - -**Application**: For monorepo splits (maintainer-only tooling, isolated -experiments, ancillary CLIs), choose a module path nested under the main module -so the new module reuses the parent's foundations via the lexical-internal -allowance. Full self-containment of a maintainer module would be a DRY -catastrophe; the lexical allowance is the correct shape. Prove it with a -throwaway `go build` against a representative `internal/` import before -designing around the *wrong* constraint. - ---- - -## [2026-05-28-201300] cobra's legacyArgs lets unknown subcommands silently succeed on non-root groups - -**Context**: Every prompt of this session injected 52 lines of `ctx system` help -text into agent context, labeled "hook success." Investigation traced it to the -0.8.1 plugin's `hooks.json` wiring `ctx system check-anchor-drift` as the first -UserPromptSubmit hook — a command the 0.8.1 binary no longer has (the command -was deleted by the cwd-anchored migration in `fc7db228`, but the plugin's hook -config wasn't updated). The harness reported "hook success" because cobra exits -0 on the unknown subcommand. - -**Lesson**: cobra's `legacyArgs` only raises "unknown command" for the **root** -command (`!cmd.HasParent()`); any non-root group (built with `parent.Cmd`) -treats an unknown subcommand as non-error: it falls through to `Help()` and -returns nil → exit 0. In a UserPromptSubmit hook this is **invisible** — the -harness logs "hook success" and injects the whole help text into agent context -every prompt. The 0.8.1 plugin's stale wiring of the retired -`check-anchor-drift` caused exactly this for the entire session. - -**Application**: Non-root cobra groups must have an explicit unknown-subcommand -guard. Two routes: (a) `Args: cobra.NoArgs` so unknown subcommands error loud -(non-zero exit + "unknown command" stderr); (b) a `RunE` that emits a **verbatim -relay** — which is what actually reaches the user in a UserPromptSubmit hook -context where a non-zero exit alone is invisible. Tracked under Phase CLI-FIX as -the verbatim-relay guard on `ctx system`. - ---- - -## [2026-05-25-221357] Skill shipping location: _ctx- prefix is repo-internal, internal/assets/claude/skills/ctx-* is bundled and shipped - -**Context**: Created /ctx-surface-audit under internal/assets/claude/skills/ -(the shipped path), but it audits ctx's own internal/ source layout — useless -in an end-user project that installs ctx. There is an established _ctx-* family -(_ctx-command-audit, _ctx-audit, _ctx-release, _ctx-qa, etc.) in .claude/skills/ -for repo-only dev skills; the user caught the misplacement. - -**Lesson**: A skill that references ctx's own source tree (internal/, -docs/recipes/, cmd/) or dev workflow is repo-internal and must live in -.claude/skills/_/ (underscore prefix, committed to the repo but NOT -bundled). Only genuinely user-facing skills belong in -internal/assets/claude/skills/, which ctx init / ctx setup install into end-user -projects. The same ship-vs-repo-internal question applies one layer up: -user-facing CLI commands go in ctx, maintainer commands go in ctxctl; shipped -hooks live in internal/assets/claude/hooks/hooks.json and call ctx, repo-local -dev hooks live in the gitignored .claude/settings.local.json and may call -ctxctl. - -**Application**: Before creating a skill, command, or hook, ask: does this serve -a user working in their project, or a ctx maintainer working in this repo? -Maintainer-facing → _-prefixed skill in .claude/skills/ + ctxctl command + -repo-local hook. User-facing → internal/assets/claude/skills/ + ctx command + -shipped hooks.json. Putting maintainer tooling in the shipped paths taxes every -end user (e.g. a UserPromptSubmit hook firing on every prompt for a feature they -never use). - ---- - -## [2026-05-23-001000] Unicode block separation makes diacritic-stripping surgical — no per-script handling needed for Arabic/Indic/Hebrew/CJK - -**Context**: While building `i18n.MatchKey` (commit 978582f5) for -diacritic-insensitive placeholder matching, the natural reflex was "this is -going to need per-script special cases — CJK doesn't have case, Arabic has -shadda/fatha that are meaning-changing, Bengali vowel signs are -script-essential, Hebrew niqqud distinguishes words." I sized the work assuming -we'd need a script-aware policy, possibly with a locale config or an opt-in flag -for "strip all combining marks" vs "strip only Latin-style decoration". -Empirical test across Turkish/German/French/Spanish/Catalan/Czech/Vietnamese -(should collapse) and Arabic/Bengali/Devanagari/Hindi/Hebrew/Chinese/Korean -(should preserve) showed the entire policy fits in one numeric range: -U+0300..U+036F. - -**Lesson**: Unicode pre-separated combining marks by intent at the codepoint -level. The "Combining Diacritical Marks" block (U+0300–U+036F) holds -Latin/general decorative marks: acute, grave, diaeresis, tilde, cedilla, caron, -the Turkish combining dot, the Vietnamese horn, etc. Script-essential marks live -in separate blocks per script: Arabic in U+0610–U+06ED, Bengali in -U+0980–U+09FF, Devanagari in U+0900–U+097F, Hebrew niqqud in -U+0591–U+05C7, and so on. The block boundaries are not coincidental — they -encode the same distinction a reasonable design would want to make. So a narrow -byte-range strip is exactly the right primitive: it expresses "remove -decoration, keep structural marks" in one comparison, without needing to know -anything about the input's script. - -**Application**: When designing comparison/normalization primitives for -international input, check the Unicode block boundaries before reaching for -per-script special cases or a config field. Often the standardization committee -already drew the line you want, and an arithmetic range check (`r >= 0x0300 && r -<= 0x036F`) does the work. Verify empirically across the scripts you care about -— but expect the answer to be cleaner than your initial sizing. The general -rule: when Unicode has put related characters in their own block, treat that -block as a meaningful unit of policy. (For ctx, this is now -`cfgI18n.CombiningMarksLatinStart`/`End` and the `MatchKey` implementation in -`internal/i18n/matchkey.go`.) - ---- - -## [2026-05-20-214839] macOS /var symlink trips path-equality; use EvalSymlinks with parent-resolution fallback - -**Context**: TestRunInit_EnvCwdMatch_Succeeds in -internal/cli/initialize/init_test.go failed on first run despite a deliberate -setup where the env path and cwd candidate matched. Diagnosis: t.TempDir() -returns paths like /var/folders/..., os.Getwd() after t.Chdir() returns the -canonical /private/var/folders/... (because macOS's /var is a symlink to -/private/var). filepath.Clean preserves the symlink form; equality fails. - -**Lesson**: filepath.Clean alone is insufficient for path equality on macOS (and -other systems with symlinked top-level dirs). filepath.EvalSymlinks resolves the -symlinks but fails when the target path does not yet exist — common case for -/Users/volkan/Desktop/WORKSPACE/ctx/.context BEFORE ctx init runs. The right -pattern is a layered fallback: try EvalSymlinks(full), then EvalSymlinks(parent) -+ rejoin basename, then filepath.Clean as last resort. - -**Application**: Encapsulated as -internal/cli/initialize/core/envmatch/{envmatch.go,internal.go}. The Same(a, b) -public function calls resolve() on each side; resolve() tries EvalSymlinks on -the full path, falls back to EvalSymlinks on the parent (rejoining the -basename), and falls through to filepath.Clean if both fail. Reusable for any -future env-vs-cwd-style equality check. The package is per-feature -(core/envmatch/) per the cmd/core/ purity rule enforced by -internal/compliance/TestCmdDirPurity. - ---- - -## [2026-05-20-214830] Handover filenames are archaeology; parse by generated-at, not filename - -**Context**: User observed three coexisting handover filename shapes: -.context/HANDOVER-2026-04-22.md (pre-skill root file), -.context/handovers/YYYY-MM-DD-HHMMSS-slug.md (skill-era pre-CLI), -.context/handovers/-slug.md (current CLI). User asked whether -this was a regression or a skill-interpretation problem. - -**Lesson**: Neither. The .context/HANDOVER-* root file predates the handovers/ -directory contract entirely (the body even said 'delete this file after -reading'). The YYYY-MM-DD-HHMMSS shape was an earlier skill iteration writing -free-form before ctx handover write existed (commit 60543e46, 2026-05-17, -introduced the CLI as sole writer per the anti-pattern note in /ctx-handover -SKILL.md). The current parser at internal/write/handover/parse.go:75-107 keys on -the 'generated-at' YAML frontmatter, not the filename — so legacy shapes still -sort correctly via LatestHandoverCursor. Only files without frontmatter (the -root April file) are invisible. - -**Application**: When unifying filename shapes across history, use git mv to -preserve rename detection. Derive the canonical timestamp from the file's own -generated-at frontmatter rather than from the filename — that's the source of -truth the parser uses anyway. If a handover predates frontmatter entirely (rare, -pre-skill era), it's safe to delete because the parser never read it. - ---- - -## [2026-05-20-214821] /ctx-plan is named after its input, not its output - -**Context**: Agent (and apparently other agents in prior sessions per user -observation) repeatedly inverted the canonical chain, treating /ctx-spec as the -entry point and /ctx-plan as a post-spec step. The skill description starts -'stress-test a plan' (implying user brings a plan IN) while line 44 of the body -says 'the deliverable is a debated brief, not a task list' (the OUTPUT is a -brief, not a plan). - -**Lesson**: Skill names that reference their INPUT bias the agent toward the -wrong canonical position. The /ctx-plan skill takes a plan and produces a brief; -the natural mental model when scanning the name is 'plan = output', which makes -the agent place it AFTER spec instead of before. Also: /ctx-spec's 'When to Use' -section listed /ctx-brainstorm as a predecessor but never /ctx-plan, so an agent -skimming the top of the skill never learned the full chain. - -**Application**: Made the canonical chain explicit at the top of both /ctx-plan -and /ctx-spec skills (Canonical Chain block with the brainstorm → plan → -spec → implement diagram) and in AGENT_PLAYBOOK_GATE Planning Work section. -/ctx-spec When-to-Use now lists /ctx-plan as a predecessor; When-NOT-to-Use says -'when the bet is contested but not yet stress-tested, use /ctx-plan first'. -/ctx-plan description now ends with '; produces a debated brief at -.context/briefs/-.md that /ctx-spec --brief consumes'. - ---- - -## [2026-05-17-200000] Creator confusion is the strongest doc-quality signal — louder than any user signal - -**Context**: In this session the project author asked *"why -external sources only? I can ground on a repo, a MCP query, a -markdown I dropped into ./inbox — are they also considered -'external'. Or is there a nomenclature confusion here?"* — and -explicitly noted *"it is confusing to the very creator of this -pipeline. -- and that's not a good sign."* Investigation -confirmed the input contract accepted in-tree paths and MCP -resources all along, but the SKILL.md ledes, the CLI docs table -row, and the recipe all framed ground as "external" — which -the creator's own mental model couldn't reconcile with the -contract. - -**Lesson**: A normal-user reading-confusion signal is "I don't -understand this." A creator reading-confusion signal is "this -contradicts what I built." The second is louder by an order -of magnitude — the creator has the full internal model and a -strong prior on what the system should say. If they trip over -the words, the words are wrong, full stop. Don't defend the -existing framing; don't explain what was meant. Rewrite to -match what the contract actually does. The creator was a -control instrument; if even that instrument deflected, the -docs are mis-anchoring everyone. - -**Application**: When the project's own creator asks a -"do we even need X?" or "wait, isn't X actually doing Y?" -question, treat it as a doc-bug report, not an architecture -question. Investigate the literal contract (input/output -shapes, code-level reality) before debating semantics. If the -contract is correct but the docs misframe it, the action is -"rewrite the framing across every doc surface that touches -it" — skills, recipes, CLI tables, anything user-facing. -Concrete instance handled this session: dropped "external" -from ctx-kb-ground's prose, description, pass-mode value, -recipe Step 4, and CLI table row across all three skill trees. - ---- - -## [2026-05-17-061500] `_helpers.go` / `_utils.go` filenames are project anti-pattern; use domain nouns - -**Context**: During Phase KB / Phase RG audit cleanup, the first file split I -attempted to satisfy the mixed-visibility audit named the new file -`read_helpers.go`. The user vetoed on sight: "utils; helpers, etcs are ALL lazy -naming; I will veto them the moment I see them; find proper domain objects." - -**Lesson**: ctx's per-package file layout follows domain nouns, not -visibility-suffixed catch-alls. The canonical reference shape is -`internal/journal/parser/` which splits 18 files by domain (envelope, markdown, -parse, validate, claude, copilot, ...). The mixed-visibility audit demands a -split, but the split target must be a real noun: `frontmatter.go` (YAML -parsing/validation), `markdown.go` (rendering), `filename.go` (filename -derivation), `provenance.go` (sha/branch resolution), `parse.go` (one-shot -parser), `cursor.go` (latest-pointer logic). Never `_helpers.go`. - -**Application**: When splitting a file to satisfy `mixed_visibility_test`, name -the new file for what the helpers ARE about, not for what visibility they have. -If you can't name it cleanly, the split itself may be wrong and the funcs may -belong in a different package entirely. - ---- - -## [2026-05-11-231025] Naive Markdown line-sweep corrupts multi-line code spans and YAML lists - -**Context**: Performed a programmatic typographic sweep across docs/*.md to wrap -bare 'ctx' tokens in backticks (commit 61aab858). 81 source files, 236 lines -changed. First pass corrupted two indented JSON snippets in MkDocs admonitions -because the fence regex anchored to start-of-line and missed admonition-indented -fences. After fixing the fence regex, two more corruptions surfaced (multi-line -inline-code spans where the opening backtick is on line N and the closing on -line N+1: the line-at-a-time transformer treated each line independently, -leading to misjudged span boundaries on the second line). After post-sweep -validation, a YAML parse error on docs/blog/2026-02-03-the-attention-budget.md -surfaced one more breakage: a 'topics:' list-item like '- ctx primitives' got -wrapped to '- `ctx` primitives', which is invalid YAML (a value starting with -backtick is not a valid unquoted scalar). Total: 2 multi-line span corruptions + -1 YAML breakage, all detected only by post-sweep validation (make site + grep -audit), not by the dry-run. - -**Lesson**: A naive line-at-a-time regex sweep across Markdown documents must -respect a wider 'skip' set than the obvious cases. The full safe-skip list is: -(1) triple-backtick fenced code blocks, BOTH root-level and indented inside -MkDocs admonitions or list items (fence regex must allow leading whitespace, -e.g. '^\\s*```'); (2) inline backtick spans on the same line; (3) multi-line -inline-code spans crossing line boundaries (line-at-a-time logic cannot detect -both ends, so either track fence-like 'odd-count' state across lines or treat -any unclosed-on-line backtick as 'protect rest of line'); (4) the ENTIRE YAML -frontmatter block (delimited by '---' at top and next '---'), not just specific -keys like title/description/icon, because list-item values under -topics/tags/keywords are also YAML and break on a leading backtick; (5) image -alt-text '![alt]' (alt-text does not render in monotype); (6) link-reference -definitions '[name]: url "title"'; (7) project copyright header comment blocks. -Dry-run output never catches YAML or multi-line span breakage; validation MUST -include a parser-level check (make site for YAML, post-grep for '``name`' -double-backtick patterns near the wrapped token). - -**Application**: When designing any future programmatic sweep across docs/ -(typography passes, internationalization, brand renames, em-dash replacement, -link-text rewrites): (1) implement the full skip set above, not a subset; (2) -for fence detection use '^\\s*```', not '^```'; (3) for the frontmatter, detect -the entire block between '---' delimiters, not specific keys; (4) for multi-line -inline-code, choose between cross-line backtick-pair tracking (complex but -correct) or the simpler 'unclosed backtick protects rest of line' heuristic -(corrupts ~1 per 100 files but recoverable manually); (5) ALWAYS validate -post-sweep with 'make site' (zensical surfaces YAML errors) and a grep for -'``\\w' double-backtick patterns near the wrapped token; (6) commit only after -both validations are clean. For one-shot sweeps the script can be ad-hoc, but -record the validation gate as part of the commit message so the next contributor -knows what to check. - ---- - -## [2026-05-08-195031] Cursor imports Claude Code hooks and sets CLAUDE_PROJECT_DIR per workspace - -**Context**: Investigating why .context/state/ appeared in non-ctx projects -opened in Cursor. Hypothesis was a Cursor extension or shell hook; turned out to -be Cursor's documented Claude-compatibility behavior -(https://cursor.com/docs/hooks): it loads ~/.claude hooks and injects -CLAUDE_PROJECT_DIR=workspace_root so they 'just work'. Globally-enabled Claude -plugins therefore fire in every Cursor workspace. - -**Lesson**: When debugging cross-tool side effects, check whether the host tool -advertises compatibility with the implicated tool's config format. The leak -surface of any global Claude plugin is now 'every Cursor workspace + every -Claude Code project', not just 'every Claude Code project'. - -**Application**: Hooks must be safe to fire in non-ctx projects: silent bail -when state.Initialized() is false, no filesystem side effects. The ctx code-side -fix lives in state.Dir's Initialized gate; the design rule is broader: assume -hooks may run anywhere, not just where the user invoked ctx init. - ---- - -## [2026-04-14-010134] Constitution forbids context window as a deferral excuse - -**Context**: Mid-session, agent proposed pacing through doc.go rewrites with the -reasoning that context budget was tight. - -**Lesson**: The CONSTITUTION explicitly lists 'We are running out of context -window' as a forbidden deferral phrase under No Excuse Generation. The rule is -real and applies to agent self-pacing, not just user-facing answers. - -**Application**: When tempted to scope down because context is tight, re-read -the constitution. The right move is to do the work end-to-end, not to ask the -user which slice to skip. - ---- - -## [2026-04-14-010134] docs/cli/system.md and embed/cmd/system.go diverged on bootstrap promotion intent - -**Context**: Header comment in internal/config/embed/cmd/system.go claimed -bootstrap was promoted to top-level; the bootstrap.go registration never -actually promoted it. Two contradictory sources of truth coexisted silently. - -**Lesson**: Header-comment claims about command-tree structure are unaudited; -they can drift from registrations without any test failing. Trust the code, not -the comment. - -**Application**: When evaluating any package_name namespace cleanup type claim -about command structure, verify against the actual cobra registration in -internal/bootstrap/group.go before acting. - ---- - -## [2026-04-14-010134] Raft-lite trade-off is the load-bearing choice in internal/hub - -**Context**: Discovered while writing thorough doc.go for internal/hub. The -package embeds HashiCorp Raft for leader election only; data replication is -sequence-based gRPC sync over the append-only JSONL store. - -**Lesson**: A leader crash window between accept and replicate can lose the most -recent write. Append-only storage plus idempotent clients make this acceptable; -full Raft log replication would not be needed and would not be simpler. - -**Application**: Any future make hub stronger proposal must engage with this -trade-off explicitly. Do not abandon Raft-lite accidentally by introducing -log-replicated state; that would invalidate the simplicity argument. - ---- - -## [2026-04-14-010105] Brand-name handling in title-case engines must cover possessives - -**Context**: First pass of hack/title-case-headings.py produced 'Ctx's' from -'ctx's' because the brand check matched the bare token only. - -**Lesson**: A brand allowlist needs to recognize , 's, s, -and short apostrophe-suffixed variants. Single-word matching misses contractions -and possessives. - -**Application**: When adding a new always-lowercase brand to -hack/title-case-headings.py, extend the suffix-aware loop in title_case_word, -not just the BRAND_LOWER set. - ---- - -## [2026-04-13-153618] GPG signing from non-TTY contexts requires pinentry-mac (or equivalent) - -**Context**: git commit failed from Claude Code's shell with 'gpg: signing -failed: No such file or directory' — the default pinentry-curses cannot open a -TTY in agent-invoked shells. Manual commits from a real terminal worked fine. - -**Lesson**: GPG's default curses pinentry requires an interactive TTY. In -non-TTY contexts (Claude Code, CI, scripts, cron), signing fails silently-ish. -The fix is to configure a GUI pinentry that uses the OS keychain: brew install -pinentry-mac; echo 'pinentry-program $(brew --prefix)/bin/pinentry-mac' >> -~/.gnupg/gpg-agent.conf; gpgconf --kill gpg-agent. Once the passphrase is saved -in Keychain, signing works from any context. - -**Application**: If agents or CI need to sign commits, configure pinentry-mac -(macOS) or pinentry-gtk/pinentry-qt (Linux) with the OS keychain, not -pinentry-curses. This is a one-time setup per machine. - ---- - -## [2026-04-13-153618] rc.ContextDir() is the single source of truth — fix the resolver, not callers - -**Context**: When ctx init failed with a boundary error, my first instinct was -to have init bypass rc.ContextDir() and use filepath.Join(cwd, dir.Context) -directly. Volkan shut that down: rc.ContextDir() encodes invariants (team -shares, symlinks, network mounts, .ctxrc overrides) that individual commands -cannot reason about. - -**Lesson**: Resolution chains with multiple fallbacks are contracts. If one -command bypasses the chain, it silently diverges from every other command's -notion of 'the context directory.' When a resolver produces a wrong answer for a -specific case, fix the resolver — don't let callers opt out. - -**Application**: Any time you see rc.ContextDir(), rc.RC(), or similar central -resolvers producing a bad result, the fix belongs in the resolver itself (or in -its input data like .ctxrc). Caller-side bypasses create drift. - ---- - -## [2026-04-09-001323] Pad index shifting is a real UX bug in batch operations - -**Context**: ctx pad rm 10; rm 11; rm 12 deleted wrong entries because indices -shifted after each deletion - -**Lesson**: Any ID-based system where users chain operations needs stable IDs. -Look-then-act is safe for single ops; look-then-batch-act breaks with shifting -indices - -**Application**: Both pad and remind now use stable IDs with batch delete and -range support. Apply same pattern to any future numbered-list subsystem - ---- - -## [2026-04-03-180000] Bulk rename and replace_all hazards (consolidated) - -**Consolidated from**: 3 entries (2026-03-15 to 2026-03-20) - -- `replace_all` on short tokens (e.g. `core.`, function names) matches inside - longer identifiers and function definitions — `remindcore.` becomes - `remindtidy.`, `func HumanAgo` becomes `func format.DurationAgo` (invalid Go) -- `sed` insert-before-first-match does not understand Go import aliases — the - alias attaches to whatever line sed inserts, not the original target -- For function renames: delete the old definition separately rather than using - replace_all. For bulk import additions: check for aliased imports first and - handle them separately, or use goimports - ---- - -## [2026-04-03-180000] Import cycles and package splits (consolidated) - -**Consolidated from**: 5 entries (2026-03-06 to 2026-03-22) - -- Types in god-object files (e.g. hook/types.go with 15+ types from 8 domains) - create circular dependencies — move types to their owning domain package -- Tests in parent package X cannot import X/sub packages that import X back — - move tests to the sub-package they exercise -- Variable shadowing causes cascading failures after splits: `dir`, `file`, - `entry` are common Go variable names that collide with new sub-package names - — run `go test ./...` before committing splits -- When moving constants between packages, change imports and all references in a - single atomic write so the linter never sees an inconsistent state -- Import cycle rule: the package providing implementation logic must own the - shared types; the facade package aliases them (e.g. `entry.Params` aliases - `add/core.EntryParams`) - ---- - -## [2026-04-03-180000] Skill lifecycle and promotion (consolidated) - -**Consolidated from**: 4 entries (2026-03-01 to 2026-03-14) - -- Internal skill renames and promotions require synchronized updates across 6+ - layers: SKILL.md frontmatter, internal cross-references, external docs, - embed_test.go expected list, recipe/reference docs, and plugin cache rebuild + - session restart -- Skill behavior changes ripple through hook messages, fallback strings in Go - code, doc descriptions, and Makefile hints — grep for the skill name across - the entire repo -- Skills without a trigger mechanism (no user invocation, no hook loading) are - dead code — audit skills for reachability -- After promoting skills: grep -r for the old name across the whole tree, run - plugin-reload.sh, restart session to verify autocomplete, and clean stale - Skill() entries from settings.local.json - ---- - -## [2026-04-03-133244] desc.Text() is the single highest-connectivity symbol in the codebase - -**Context**: GitNexus enrichment during architecture analysis revealed -desc.Text() (internal/assets/read/desc/desc.go:75) has 30+ direct callers -spanning every architectural layer (MCP handler, format, index, tidy, trace, -memory, sysinfo, io) and participates in 53 execution flows. - -**Lesson**: TestDescKeyYAMLLinkage is the most critical guard in the codebase -— it protects the symbol with the widest blast radius. If YAML text loading -breaks, the entire CLI and MCP server output blank strings silently (no crash, -no warning). - -**Application**: Treat desc.Text() as a frozen API — add new functions rather -than modifying the existing signature. Any change to config/embed/text or -assets/read/desc should be followed by running the linkage audit. Monitor this -symbol during major refactors. - ---- - -## [2026-03-31-005112] Convention audits must check cmd/ purity, not just types and docstrings - -**Context**: Placed needsSpec helper in cmd/root/run.go instead of -core/entry/predicate.go. Missed it because the audit checklist only covered -types and docstrings - -**Lesson**: cmd/ directories must contain only Cmd() and Run*() — all helper -functions, unexported logic, and types belong in core/. Added TestCmdDirPurity -compliance test to enforce this mechanically - -**Application**: The compliance test now catches this automatically. 28 -pre-existing violations grandfathered in the allowlist - ---- - -## [2026-03-31-005110] JSON Schema default fields cause linter errors with some validators - -**Context**: ctxrc.schema.json had default: values on 16 fields that triggered -incompatible type errors in the user's linter - -**Lesson**: Move default values into the description string instead of using the -default keyword — Go rc.*() accessors handle the actual defaults - -**Application**: When adding new .ctxrc fields, document defaults in the -description, never use default: in the schema - ---- - -## [2026-03-30-003707] lint-docstrings.sh greedy sed hid all return-type violations - -**Context**: sed 's/.*) //' consumed return type parens, leaving { — functions -with return types were invisible to the script for months - -**Lesson**: Greedy regex in shell scripts can silently suppress entire -categories of lint violations — test with edge cases, not just happy paths - -**Application**: When writing sed-based lint checks, test with multi-paren -signatures (func Foo() (string, error)) - ---- - -## [2026-03-20-160112] Commit messages containing script paths trigger PreToolUse hooks - -**Context**: Git commit message body contained a path to a shell script under -the hack directory which matched a hook pattern that blocks direct script -invocation - -**Lesson**: Hooks scan all Bash tool input including heredoc content used for -commit messages, not just the command itself - -**Application**: Rephrase commit messages and ctx add content to avoid paths -that match hook deny patterns, use generic references instead of literal file -paths - ---- - -## [2026-03-18-133457] Lazy sync.Once per-accessor is a code smell for static embedded data - -**Context**: assets package had 4 sync.Once guards, 4 exported maps, 4 Load*() -functions, and a wrapper desc package — all to lazily load YAML from embed.FS -that never mutates. Every accessor call went through sync.Once + global map + -wrapper indirection. - -**Lesson**: When data is static and loaded from embedded bytes, scatter-loading -with per-accessor sync.Once is over-engineering. A single Init() called eagerly -at startup is simpler, and one sync.Once on Init() itself provides the test -safety net. Exported maps that exist only for wrapper packages to reach are a -sign the abstraction boundary is wrong. - -**Application**: Prefer eager Init() in main.go for static embedded data. Keep -maps unexported. Accessors do plain map lookups. If a wrapper package exists -solely to break a cycle caused by exported state, delete the wrapper and -unexport the state. - ---- - -## [2026-03-17-105637] Write package output census: 69 trivial/simple, 38 consolidation candidates, 18 complex - -**Context**: Full audit of internal/write/ (26 files, 160 functions, 337 Println -calls) to evaluate whether block template consolidation is worth a systematic -refactor. - -**Lesson**: Only 30% of write functions benefit from output consolidation. The -sweet spot is multi-line (16) and conditional (22) functions. - -**Application**: Check function category before consolidating. Trivial/simple -stay as-is. Conditional functions need pre-computation before block templates. -Loop-based complex functions stay imperative. Don't bulk-refactor. - ---- - -## [2026-03-06-141504] Stats sort uses string comparison on RFC3339 timestamps with mixed timezones - -**Context**: ctx system stats showed only old sessions, hiding the current one - -**Lesson**: RFC3339 string comparison breaks when entries mix UTC (Z) and offset -(-08:00) formats — 13:00-08:00 sorts before 18:00Z lexicographically despite -being later in absolute time - -**Application**: Always parse to time.Time before comparing RFC3339 timestamps; -never rely on lexicographic sort - ---- - -## [2026-03-05-023941] Blog post editorial feedback is higher-leverage than drafting - -**Context**: Draft of Agent Memory Is Infrastructure was publication-quality on -first pass; user editorial feedback (structural emphasis, rhetorical sharpening, -amnesia/archaeology bridge) elevated it significantly more than initial -generation - -**Lesson**: For narrative content, the first draft captures the argument; the -editorial pass captures the voice. Both are necessary but the editorial pass has -disproportionate impact on quality. - -**Application**: For future blog posts, invest more in the editorial cycle -(structural feedback then targeted refinements) rather than trying to nail voice -on first generation. - ---- - -## [2026-03-04-105239] CONSTITUTION hook compliance is non-negotiable — don't work around it - -**Context**: After make build, ran ./ctx deps --help which was blocked by -block-non-path-ctx. Instead of asking user to install, tried cp ctx ~/bin/ — -escalating workarounds. - -**Lesson**: When a hook blocks an action, the correct response is to follow the -hook's instruction (ask the user to sudo make install), not to find creative -bypasses. - -**Application**: Always ask the user to install when testing a freshly built -binary. Never attempt alternative install paths to circumvent a hook. - ---- - -## [2026-03-02-165039] Hook message registry test enforces exhaustive coverage of embedded templates - -**Context**: Adding billing.txt to embedded assets without a registry entry -caused TestRegistryCoversAllEmbeddedFiles to fail immediately - -**Lesson**: Every new .txt file under internal/assets/hooks/messages/ must have -a corresponding entry in registry.go — the test acts as an exhaustive -bidirectional check - -**Application**: When adding new hook message variants, update the registry -entry before running tests - ---- - -## [2026-03-01-124921] Model-to-window mapping requires ordered prefix matching - -**Context**: Implementing modelContextWindow() for the three-tier context window -fallback. Claude model IDs use nested prefixes (claude-sonnet-4-5 vs -claude-sonnet-4-20250514). - -**Lesson**: A switch with ordered HasPrefix cases (most specific first) is -cleaner and safer than iterating separate prefix lists. The catch-all 'claude-*' -returns 200k for unrecognized Claude models. - -**Application**: When adding new model families to modelContextWindow() in -session_tokens.go, add the most specific prefix first to avoid shadowing shorter -prefixes. - ---- - -## [2026-03-01-095709] TASKS.md template checkbox syntax inside HTML comments is parsed by RegExTaskMultiline - -**Context**: Template had example checkboxes (- [x], - [ ]) in HTML comments -that the line-based regex matched as real tasks, causing -TestArchiveCommand_NoCompletedTasks to fail - -**Lesson**: RegExTaskMultiline is line-based and has no awareness of HTML -comment blocks — checkbox-like patterns inside comments get counted as real -tasks - -**Application**: Use backtick-quoted or indented references instead of actual -checkbox syntax in template comments. When adding examples to TASKS.md -templates, avoid patterns that match regExTaskPattern - ---- - -## [2026-02-28-184758] ctx pad import, ctx pad export, and ctx system resources make three hack scripts redundant - -**Context**: Audited hack/ scripts against ctx CLI surface - -**Lesson**: As ctx CLI grew, several hack scripts became wrappers around -built-in commands (pad-import.sh -> ctx pad import, pad-export-blobs.sh -> ctx -pad export, resource-watch.sh -> watch -n5 ctx system resources) - -**Application**: Periodically audit hack/ for scripts that ctx has absorbed - ---- - -## [2026-02-28-184647] Getting-started docs assumed Claude Code as the only agent - -**Context**: The installation section opened with 'A full ctx installation has -two parts' — binary + Claude Code plugin — leaving non-Claude-Code users -without a clear path - -**Lesson**: Installation docs should lead with the universal requirement (the -binary) and present agent-specific integration as conditional - -**Application**: When writing docs for multi-tool projects, frame the common -denominator first, then branch by tool - ---- - -## [2026-02-28-150701] Plugin reload script must rebuild cache, not just delete it - -**Context**: hack/plugin-reload.sh was deleting -~/.claude/plugins/cache/activememory-ctx/ without repopulating it. Claude Code's -installed_plugins.json still referenced the cache path, so the plugin appeared -enabled but hooks.json was missing — all plugin hooks silently stopped firing. - -**Lesson**: Claude Code snapshots plugin hooks from the cache directory at -session startup. If the cache is deleted, plugin hooks vanish silently with no -error. The reload script must rebuild the cache from source assets -(internal/assets/claude/) after clearing it, and warn that a session restart is -required. - -**Application**: Always rebuild the plugin cache in hack/plugin-reload.sh. When -debugging hooks that don't fire, check ~/.claude/plugins/cache/ first — a -missing hooks.json is the most likely cause. - ---- - -## [2026-02-27-230741] Doctor token_budget vs context_window confusion - -**Context**: ctx doctor reported context size against token_budget (8k) instead -of context_window (200k), making 22k tokens look alarming. - -**Lesson**: token_budget (ctx agent output trim target) and context_window -(model capacity) serve different purposes. Health checks about context fitting -should use context_window, with warning threshold proportional (e.g., 20% of -window). - -**Application**: Doctor now uses rc.ContextWindow() with 20% threshold and shows -per-file token breakdown for actionable insight into which files are heavy. - ---- - -## [2026-02-26-003854] Webhook silence after ctxrc profile swap is the most common notify debugging red herring - -**Context**: Spent time investigating why webhooks weren't firing — checked -binary version, hook configs, notify.Send internals. Actual cause was .ctxrc -swapped to prod profile (notify commented out) earlier in session. - -**Lesson**: When webhooks stop, check .ctxrc profile first (`ctx config -status`). Also: not all tool uses trigger webhook-sending hooks — Read only -triggers context-load-gate (one-shot) and ctx agent (no webhook). qa-reminder -requires Edit matcher. - -**Application**: Before debugging notify internals, run `ctx config status` and -verify the event would actually match a hook with notify.Send. - ---- - -## [2026-02-26-100002] Agent context loading and task routing (consolidated) - -**Consolidated from**: 5 entries (2026-01-20 to 2026-01-25) - -- `ctx agent` is optimized for task execution (filters pending tasks, surfaces - constitution, token-budget aware). Manual file reading is better for - exploratory/memory questions (session history, timestamps, completed tasks). -- On "Do you remember?" questions, immediately read .context/ files and run `ctx - journal source --limit 5`. Never ask "would you like me to check?" — that is - the obvious intent. -- .context/ is NOT a Claude Code primitive. Only CLAUDE.md and - .claude/settings.json are auto-loaded. The .context/ directory requires a hook - or explicit CLAUDE.md instruction to be discovered. -- ~~Orchestrator (IMPLEMENTATION_PLAN.md) and agent (.context/TASKS.md) task - lists must be separate.~~ (Superseded 2026-03-25: IMPLEMENTATION_PLAN.md - removed. TASKS.md is the single task source.) -- Only CLAUDE.md is auto-loaded by Claude Code. Projects using ctx should rely - on the CLAUDE.md -> AGENT_PLAYBOOK.md chain, not AGENTS.md. - ---- - -## [2026-02-26-100006] PATH and binary handling (consolidated) - -**Consolidated from**: 3 entries (2026-01-21 to 2026-02-17) - -- Always use `ctx` from PATH, never `./dist/ctx-linux-arm64` or `go run - ./cmd/ctx`. Check `which ctx` if unsure. -- Hooks must use PATH, not hardcoded paths. `ctx init` checks if ctx is in PATH - before proceeding. Tests can skip with `CTX_SKIP_PATH_CHECK=1`. -- Agent must never place binaries in any bin directory (not via cp, mv, or go - install). Build with `make build`, then ask the user to run the privileged - install step. Hooks in block-dangerous-commands.sh enforce this. - ---- - -## [2026-02-26-100007] Task management and exit criteria (consolidated) - -**Consolidated from**: 4 entries (2026-01-21 to 2026-02-17) - -- Specs get lost without cross-references from TASKS.md. Three-layer defense: - (1) playbook instruction, (2) spec reference in Phase header, (3) bold - breadcrumb in first task. -- Subtask completion is implementation progress, not delivery. Parent tasks - should have explicit deliverables; don't close until deliverable is verified. -- Exit criteria must include verification: integration tests (binary executes - correctly), coverage targets, and smoke tests. "All tasks checked off" does - not equal "implementation works." -- Reports graduate to ideas/done/ only after all items are tracked or resolved. - Cross-reference every item against TASKS.md and the codebase before moving. - ---- - -## [2026-02-26-100008] Agent behavioral patterns (consolidated) - -**Consolidated from**: 5 entries (2026-01-25 to 2026-02-22) - -- Interaction pattern capture risks softening agent rigor. Do not build implicit - user-modeling from session history. Rely on explicit, human-reviewed context - (learnings, conventions, hooks) for behavioral shaping. -- Chain-of-thought prompting improves agent reasoning accuracy (17.7% to 78.7%). - Added "Reason Before Acting" to AGENT_PLAYBOOK.md and reasoning nudges to 7 - skills. -- Say "project conventions" not "idiomatic X" to ensure Claude looks at project - files first rather than triggering training priors (stdlib conventions). -- Autonomous "YOLO mode" is effective for feature velocity but accumulates - technical debt (magic strings, monolithic tests, hardcoded paths). Schedule - periodic consolidation sessions. -- Trust the binary output over source code analysis. A single ambiguous CLI - output is not proof of absence — re-run the exact command before claiming - something is missing. - ---- - -## [2026-02-26-100010] ctx add and decision recording (consolidated) - -**Consolidated from**: 4 entries (2026-01-27 to 2026-02-14) - -- `ctx add learning` requires `--context`, `--lesson`, `--application` flags. - `ctx add decision` requires `--context`, `--rationale`, `--consequence`. A - bare string only sets the title and the command will fail without required - flags. -- Structured entries with Context/Lesson/Application are more useful than - one-liners. Agents are guided via AGENT_PLAYBOOK.md. -- Always complete decision record sections — placeholder text like "[Add - context here]" is a code smell. Decisions without rationale lose their value - over time. -- Slash commands using `!` bash syntax require matching permissions in - settings.local.json. When adding new /ctx-* commands, ensure ctx init - pre-seeds the required `Bash(ctx :*)` permissions. - ---- - -## [2026-02-24-032945] CLI tools don't benefit from in-memory caching of context files - -**Context**: Discussed whether ctx should read and cache LEARNINGS.md, -DECISIONS.md etc. in memory - -**Lesson**: ctx is a short-lived CLI process, not a daemon. Context files are -tiny (few KB), sub-millisecond to read. Cache invalidation complexity exceeds -the read cost. Caching only makes sense if ctx becomes a long-lived process (MCP -server, watch daemon). - -**Application**: Don't add caching layers to ctx's file reads. If an MCP server -mode is ever added, revisit then. - ---- - -## [2026-01-28-051426] IDE is already the UI - -**Context**: Considering whether to build custom UI for .context/ files - -**Lesson**: Discovery, search, and editing of .context/ markdown files works -better in VS Code/IDE than any custom UI we'd build. Full-text search, -git integration, extensions - all free. - -**Application**: Don't reinvent the editor. Let users use their preferred IDE. - ---- - - -*Module-specific, niche, and historical learnings: -[learnings-reference.md](learnings-reference.md)* -## [2026-04-26-152836] ctx system help can list project-local hooks not in the Go binary - -**Context**: PR #72 plugin called 'ctx system block-dangerous-commands'; user's -installed ctx 0.7.2 listed it in help, but no directory exists under -internal/cli/system/cmd/ — it's a Claude Code plugin-local hook surfaced via -wrapper - -**Lesson**: ctx system help output is a union of compiled Go subcommands and -project-local Claude wrappers; non-Claude integrations only see the Go subset - -**Application**: When porting plugin behavior to a new editor, only call -subcommands that have a directory under internal/cli/system/cmd/. Don't trust -ctx system help output as the canonical surface. - ---- - -## [2026-04-25-014704] Confident code comments can pull an LLM away from first-principles knowledge - -**Context**: cli_test.go had a comment claiming 'parent's t.Setenv doesn't -propagate to exec'd children unless we build it into cmd.Env' which is wrong. I -patched the helper's CTX_DIR dedup instead of questioning the helper itself, -despite knowing t.Setenv semantics. - -**Lesson**: A comment that explains why a stdlib mechanism 'doesn't work' is -doing extra rhetorical work to talk a reader out of the obvious approach. That's -exactly when to verify from first principles instead of trusting the -surrounding-code frame. - -**Application**: When an existing comment justifies a non-canonical approach -contradicting stdlib knowledge: pause, verify against memory of the actual API -before patching within the existing frame. - ---- - -## [2026-07-06-a] `ctx journal site` rewrites source entry bodies in place - -**Context**: The self-heal render-hash guard assumed only `execute.go` -(import) authors entry bodies. But `ctx journal site` (`make journal`) -soft-wraps/collapses the *source* `.md` in place (site/run.go), and did -NOT refresh the render hash. Result: after any site build, growth-aware -import saw a hash mismatch and false-flagged every normalized entry as -"edited outside ctx", stranding the growth. - -**Lesson**: A "prove the file is unchanged since we wrote it" hash is -only valid if *every* code path that writes the body refreshes it. Grep -all writers of the artifact, not just the obvious one. - -**Application**: When adding a content-integrity hash, enumerate every -writer (`grep -rn SafeWriteFile `) and make each refresh the hash — -or route all writes through one helper. Refresh unconditionally on every -non-failed pass so an idempotent transform still re-syncs. - ---- - -## [2026-07-06-b] zensical bundles a fixed lucide icon snapshot - -**Context**: `make site` failed with "template not found: -.icons/lucide/message-square-cog.svg". A doc frontmatter `icon:` field -referenced a lucide icon absent from zensical 0.0.47's bundled set (and -0.0.47 is the latest). Blocks the entire build, not just that page. - -**Lesson**: zensical (like mkdocs-material) ships a frozen lucide -snapshot; a valid-on-lucide.dev icon may not exist in the installed -version. A single bad `icon:` fails the whole site build. - -**Application**: Before a site rebuild, validate every doc `icon: -lucide/X` against the installed bundle -(`find /templates/.icons/lucide -name X.svg`). Pick from the -bundled set, not lucide.dev. - ---- - -## [2026-07-06-c] PATH `ctx` is a stale install; verify against the fresh binary - -**Context**: After editing an asset (commands.yaml) and `make build`, -`ctx journal import --help` still showed the OLD text. Cause: `ctx` on -PATH is `/usr/local/bin/ctx` (a prior `make install`), while `make -build` produces `./ctx` in the repo. A project hook blocks running -`./ctx` directly. - -**Lesson**: `make build` does not update the PATH binary. Verifying -behavior via PATH `ctx` after a source change tests the stale install, -not your change. - -**Application**: To verify a fresh build, copy it out -(`cp ./ctx /ctx-new`) and run that — it dodges the `./ctx` -hook and reflects your edits. Or `sudo make reinstall`. Never trust -PATH `ctx` help/behavior right after `make build`. - ---- - -## [2026-07-06-d] `internal/assets` ← `internal/rc` blocks package-assets tests importing rc - -**Context**: Moving the `.ctxrc` schema-vs-struct bijection test to -reflect over the real `rc.CtxRC` (drift-proofing it) failed: `rc` -imports `internal/assets/read/placeholders`, which imports top-level -`internal/assets`. So a `package assets` test importing `rc` forms a -cycle (assets → rc → placeholders → assets). - -**Lesson**: `internal/assets` is a low-level leaf that `rc` transitively -depends on; tests in `package assets` cannot import `rc`. - -**Application**: Home a struct-vs-schema guard in the package that owns -the struct (`package rc`), importing `internal/assets` to read the -embedded schema — that direction is acyclic (assets never imports rc). - ---- - +## Themes + +- go-idioms-and-structure — Go language & package layout: import cycles, constant/helper placement, sync.Once smells, toolchain/build-tag pitfalls, error sentinels, test isolation → [go-idioms-and-structure](learnings/go-idioms-and-structure.md) +- audit-lint-compliance — Mechanical enforcement: the internal/audit AST gauntlet, compliance tests as command style guide, cmd/ purity, docstring floors, gosec triggers, linter quirks → [audit-lint-compliance](learnings/audit-lint-compliance.md) +- cli-command-design — cobra/CLI surface conventions: bare-invocation existence probing, legacyArgs silent-success on groups, commands named after input not output → [cli-command-design](learnings/cli-command-design.md) +- hooks-and-integration — Hook & editor-integration mechanics: output channels, key names, compliance wiring, Cursor/OpenCode plugins, project-local hooks, notify webhooks → [hooks-and-integration](learnings/hooks-and-integration.md) +- context-model-and-state — Context-dir resolution & on-disk state: single-source ContextDir, tombstones/logs/fs hygiene, managed-block guards, handover metadata, hub raft-lite → [context-model-and-state](learnings/context-model-and-state.md) +- text-markdown-serialization — Text/markdown/JSON hazards: line-sweep corruption, insert-helper tail drops, diacritic stripping, title-case brands, RFC3339 sort, key-dropping round-trips → [text-markdown-serialization](learnings/text-markdown-serialization.md) +- model-context-window — LLM model->context-window mapping: silent 200k fallback for new families, ordered prefix matching, doctor token_budget vs context_window → [model-context-window](learnings/model-context-window.md) +- git-and-signing — git CLI wrapping quirks: filter-branch leftover refs, hunk-level feature carving, GPG signing from non-TTY (pinentry) → [git-and-signing](learnings/git-and-signing.md) +- environment-and-platform — Cross-platform gotchas: macOS minimal PATH & /var symlink, Tauri rustc floor, GitNexus in-container pruning, host-pressure alerting → [environment-and-platform](learnings/environment-and-platform.md) +- skills-agents-and-tasks — Skill/agent workflow: skill lifecycle & shipping, agent context-loading/routing/behavior, task exit-criteria, ctx-dream design, refactor mechanics → [skills-agents-and-tasks](learnings/skills-agents-and-tasks.md) +- docs-and-templates — Docs/template/asset drift: tracked build output, magic-string discipline, contributor-PR reintroduction, docs/code divergence, redundant scripts → [docs-and-templates](learnings/docs-and-templates.md) +- editorial-and-product-signals — KB editorial & product signals: single topic-enumeration site, decision-recording, creator-confusion as doc-quality signal, editorial leverage, IDE-is-the-UI → [editorial-and-product-signals](learnings/editorial-and-product-signals.md) diff --git a/.context/decisions/cli-command-surface.md b/.context/decisions/cli-command-surface.md new file mode 100644 index 00000000..11b43f59 --- /dev/null +++ b/.context/decisions/cli-command-surface.md @@ -0,0 +1,158 @@ +# cli-command-surface + +## [2026-05-30-114429] Name the add JSON-ingest flag --json-file, not --json + +**Status**: Accepted + +**Context**: The CLI-FIX spec specified the literal flag --json , but +--json is already a bool output-format flag across the CLI (ctx +status/drift/doctor/bootstrap --json all mean 'emit machine-readable output'). + +**Decision**: Name the add JSON-ingest flag --json-file, not --json + +**Rationale**: Overloading --json as a string input-path on the add commands +would break that cross-command convention and confuse muscle memory. --json-file +is unambiguous, parallels the existing --file/-f source flag, and leaves -j +free. Pushed back on the spec's literal wording rather than satisfice. + +**Consequence**: The add commands intentionally diverge from the spec's literal +--json; the spec was updated to reflect --json-file. Any future JSON-input flag +elsewhere should follow the --json-file naming, reserving --json for bool +output. + +--- + +## [2026-04-16-011520] Deprecate and remove ctx backup + +**Status**: Accepted + +**Context**: ctx backup is environment-specific (SMB/GVFS), fires nag hooks for +unconfigured users, and solves a problem that belongs to the OS layer. ctx hub +already handles cross-machine knowledge persistence. + +**Decision**: Deprecate and remove ctx backup + +**Rationale**: Hub handles persistence, backup is env-specific, wrong layer for +ctx to own. No external users depend on it. An internal mirror issue and the +GVFS Linux-only dependency add maintenance burden. + +**Consequence**: Need backup-strategy runbook before removal. Maintainer must +set up replacement cron job. About 60 files to remove across CLI, config, hooks, +docs, skills. Spec: specs/deprecate-ctx-backup.md + +--- + +## [2026-04-14-010205] Bootstrap stays under ctx system bootstrap (reverted experimental top-level promotion) + +**Status**: Accepted + +**Context**: Mid-session promoted ctx bootstrap to top-level to make a stale +CLAUDE.md instruction work. User reverted it and reaffirmed the original design. + +**Decision**: Bootstrap stays under ctx system bootstrap (reverted experimental +top-level promotion) + +**Rationale**: The ctx system namespace is for agent and hook plumbing the user +does not type by hand. Bootstrap is invoked by AI agents at session start; +surfacing it at top-level pollutes ctx --help for humans without benefit. + +**Consequence**: internal/bootstrap/group.go reverted; +internal/config/embed/cmd/system.go header now correctly states bootstrap is +intentionally not promoted. The CLAUDE.md template across the repo (and the +workspace copy) updated to reference ctx system bootstrap as canonical. + +--- + +## [2026-04-01-074416] Rename ctx hook → ctx setup to disambiguate from the hook system + +**Status**: Accepted + +**Context**: PR #45 contributor assumed hook meant the setup command, causing +naming collisions with the PreToolUse/PostToolUse hook system + +**Decision**: Rename ctx hook → ctx setup to disambiguate from the hook system + +**Rationale**: hook has a specific meaning in ctx; setup accurately describes +generating AI tool integration configs + +**Consequence**: CLI breaking change. All docs, specs, TypeScript extension, and +YAML assets updated. Released specs left as historical. + +--- + +## [2026-03-30-075927] Flags-not-subcommands for journal source: list and show are view modes on a noun, not independent entities + +**Status**: Accepted + +**Context**: During the journal-recall merge, recall had separate list and show +subcommands. Merging them into journal created a design choice: source list + +source show (three levels) vs source --show (two levels). + +**Decision**: Flags-not-subcommands for journal source: list and show are view +modes on a noun, not independent entities + +**Rationale**: Keeps CLI nesting to two levels max. Default behavior (bare +source) lists sessions; --show switches to inspect mode. When two operations +differ only in how they view the same data, make them flags on one command. + +**Consequence**: journal source dispatches via --show flag rather than +positional subcommand. Future view-mode toggles should follow this pattern. + +--- + +## [2026-03-30-003756] Journal consumed recall — recall CLI package deleted + +**Status**: Accepted + +**Context**: ctx recall was never registered in bootstrap; ctx journal had all +the same subcommands + +**Decision**: Journal consumed recall — recall CLI package deleted + +**Rationale**: One dead command group creates confusion in docs and skills. +Journal is the canonical command group. + +**Consequence**: internal/cli/recall/ deleted, 19 doc files updated, +docs/cli/recall.md renamed to journal.md, zensical.toml updated. MCP tool +ctx_recall rename tasked separately (API contract) + +--- + +## [2026-03-18-193623] Singular command names for all CLI entities + +**Status**: Accepted + +**Context**: ctx add used learning (singular) but ctx learnings was plural. +Inconsistency across 6 commands. + +**Decision**: Singular command names for all CLI entities + +**Rationale**: Less headache for i18n; one rule (singular = entity); developers +think in OOP. Use field values come from DescKey constants for +single-source-of-truth renaming. + +**Consequence**: All commands singular: task, decision, learning, change, +permission, dep. YAML keys, desc constants, directory names, and 50+ files +updated. + +--- + +## [2026-03-16-022635] Rename --consequences flag to --consequence for singular consistency + +**Status**: Accepted + +**Context**: All other CLI flags (context, rationale, lesson, application) are +singular nouns. consequences was the only plural. + +**Decision**: Rename --consequences flag to --consequence for singular +consistency + +**Rationale**: Singular form matches the pattern. Consistency wins over natural +language preference. + +**Consequence**: 75+ files updated. Breaking change for --consequences users. + +--- + + + diff --git a/.context/decisions/context-model-and-state.md b/.context/decisions/context-model-and-state.md new file mode 100644 index 00000000..a077201a --- /dev/null +++ b/.context/decisions/context-model-and-state.md @@ -0,0 +1,254 @@ +# context-model-and-state + +## [2026-06-07-180006] CWD-anchored context model (consolidated) + +**Consolidated from**: 5 entries (2026-04-13 to 2026-05-21) + +- Walk boundary uses git as a hint, not a requirement: walkForContextDir + consults findGitRoot to anchor ancestor .context candidates and falls back to + CWD when no git is found — fixes nested-repo binding without making git + mandatory or relying on unreliable project markers. +- ctx activate is strict-CWD (drop upward walk): state-setting commands follow + git's read-vs-state pattern (read walks, state refuses to cross repo + boundaries); workspace-shared layouts are preserved by user action (cd first), + not inferred walk. +- Anchor ctx to CWD entirely: drop activate/deactivate, the env-var (CTX_DIR) + resolver, and all walks. With .context/ mandated as .git/'s sibling, every + resolver collapses to os.Stat; keeping any walk would force maintaining two + implementations. Mental model matches helm/terraform/Claude Code; ~600-1000 + LOC net deletion (specs/cwd-anchored-context.md). +- Spec steps 1+2 (resolver swap + init-guard removal) merged into one commit + because step 1 cannot compile without step 2; cleanest commit boundaries beat + strict spec adherence — remaining steps stay discrete (4-commit + decomposition, not the spec's 5). +- Substrate vs. artifact placement: cognitive substrate (read AND written via + ctx-mediated paths) lives under .context/; project artifacts (read/edited + directly by humans, e.g. specs/, CLAUDE.md, docs/) live at root. kb passes all + three coupling tests (mediated queries, pipeline coupling, skill discipline) + so it stays under .context/. + +--- + +## [2026-06-07-180007] Encryption key resolution and migration (consolidated) + +**Consolidated from**: 3 entries (2026-03-01 to 2026-06-02) + +- Single global key at ~/.ctx/.ctx.key (matches ~/.claude/ convention); one key + per machine covers ~99% of users. Replaced the over-engineered slug-based + per-project key system; project-local key-next-to-ciphertext was a security + antipattern that broke in worktrees. [Original 2026-03-01 entry was marked + Superseded by the 2026-03-02 simplification.] +- Legacy-key auto-migration replaced with a stderr warning only: warn-only is + simpler, avoids silent file operations, and keeps the (small, alpha) userbase + in control; docs carry migration instructions. +- Removed the implicit project-local .context/.ctx.key auto-detection tier from + ResolveKeyPath: resolution is now (1) explicit .ctxrc key_path, (2) global + ~/.ctx/.ctx.key, (3) project-local only as a degenerate fallback when home is + unavailable. The local tier was the only thing making worktrees differ from + side-by-side terminals; its removal is net deletion, and the previously-silent + fire-path decrypt failure is now surfaced. + +--- + +## [2026-05-24-092912] Pad snapshot-on-mutate at the store.WriteEntries choke point + +**Status**: Accepted + +**Context**: Adding a safety net for accidental `ctx pad rm` (and any other +destructive pad mutation) required choosing where to insert the snapshot logic: +per-subcommand (in each cmd//run.go), or at the persistence choke point +(store.WriteEntriesWithIDs). + +**Decision**: Pad snapshot-on-mutate at the store.WriteEntries choke point + +**Rationale**: store.WriteEntriesWithIDs is invoked by every mutating pad +subcommand (add/edit/mv/rm/merge/normalize/resolve/tag and undo itself); +instrumenting it once gives universal coverage with one site of truth. +Per-subcommand instrumentation would need maintenance every time a new pad +mutation lands and is easy to forget. The snapshot itself is a byte-for-byte +copy of the existing pad blob (no re-encryption), so plaintext and encrypted +modes use identical logic; the existing ciphertext IS the snapshot. + +**Consequence**: All future pad mutations get the safety net automatically +without per-command wiring. The op label for the snapshot filename is derived +from cmd.Name() at the call site, so the cmd parameter that already flowed in +for diagnostic output now carries semantic weight too. New constraint: any +future code path that bypasses WriteEntriesWithIDs to mutate the pad will +silently bypass the safety net — a guardrail test could enforce this if/when +that risk materializes. + +--- + +## [2026-05-20-214753] Gitignore .context/handovers/; track only .gitkeep + +**Status**: Accepted + +**Context**: Per-session, operator-specific artifacts that grow without bound +and can leak host/internal identifiers into public +mirrors when the project's .context/ is committed. + +**Decision**: Gitignore .context/handovers/; track only .gitkeep + +**Rationale**: Aligns with the existing per-personal-state gitignore family +(journal, memory, state, logs, reminders.json, scratchpad.enc); the directory's +.gitkeep keeps the read-side missing-dir gate passing on fresh clones; the rest +of the closeout-fold pipeline already lives in .context/archive/closeouts/ which +IS tracked. + +**Consequence**: ctx init template (internal/config/file/ignore.go) added +.context/handovers/* and !.context/handovers/.gitkeep; existing tracked +handovers untracked via git rm --cached but kept on disk; the 'handover is the +sole authoritative recall artifact' phrasing in KB-RULES.md still holds — it's +local-machine authoritative. + +--- + +## [2026-04-11-180000] `Entry.Author` is server-authoritative, not client-authoritative + +**Status**: Accepted + +**Context**: The `Entry.Author` field on hub entries is copied verbatim from +the client's publish request (`handler.go:82`). It's optional, freeform, and +unauthenticated — a client with a valid token for project `alpha` can publish +entries claiming `Author: "bob@acme.com"` regardless of who actually +authenticated. This is the same spoofing pattern as `Origin` (audit finding +H-04) and was flagged as audit finding H-22 with three options: keep, drop, +override, or promote. The decision was never formally closed. + +The premise that resolved it: **identity is eventually part of the token**. +Under the sysadmin-registry MVP, the server already knows `{user_id, project}` +from the authenticated token. Under the PKI stretch, the signed claim carries +identity cryptographically. In both models, the client has nothing to say about +authorship that the server doesn't already know with higher confidence. + +**Decision**: `Entry.Author` is **server-authoritative**. The server stamps it +from the authenticated identity source on every publish. The client's +`pe.Author` input is ignored (or rejected — implementation choice, not +semantic difference). The field stays in the wire format but its semantics +change from "whatever the client said" to "whatever the server's auth layer +resolved." + +Stamping source by phase: + +- **Today (pre-registry)**: `Author = ClientInfo.ProjectName`, same source as + the `Origin` server-enforcement fix (H-04). Lossy but consistent. +- **Registry MVP**: `Author = users.json` row's `user_id` (e.g., + `alice@acme.com`). Precise per-human attribution. +- **PKI stretch**: `Author = signed claim's sub field`. Cryptographic identity. + +**Rationale**: Dropping the field is wrong because the registry MVP will +already give us a per-user identity to stamp — removing Author just to re-add +it later is churn. "Override" and "promote" are cosmetically different forms +of the same decision (server fills from auth context); "promote" is what +happens naturally once the registry MVP types the field as `UserID`. +Client-sourced Author is indefensible because it replicates the Origin +spoofing vector in a second field. + +**Consequence**: + +- The Author field stays on the wire and in `Entry{}`. +- Client-side code that populates `pe.Author` from local config becomes a + no-op. Audit `ctx connect publish` and `ctx add --share` for any such + code paths before the server-enforcement fix lands. +- `handler.go publish()` fills Author from the authenticated context (the + same `ClientInfo` that H-04 pulls for Origin). Single unified + auth-to-handler pipe. +- `docs/security/hub.md` "Compromised client token" section gets rewritten: + attribution becomes **wrong** on compromise (attacker's token maps to + attacker's identity), not **forgeable** (attacker cannot stamp someone + else's name). +- The sysadmin-registry spec (`specs/hub-identity-registry.md`, tasked) + MUST include a `user_id` field per row — it's the stamping source. +- Three open tasks collapse into one: H-22 resolves to "implement + server-authoritative Author" instead of "decide Author fate." TASKS.md + updated. + +**Alternatives considered**: + +- **Keep client-authoritative**: rejected. Same spoofing vector as Origin; + trivially defeats any downstream attribution check. +- **Drop the field**: rejected. The registry MVP will need per-human + attribution anyway. Dropping today is churn that gets undone + immediately. +- **Override at client-side before publish**: rejected. Puts the security + boundary on the wrong side of the trust zone. Must be server-side. + +**Follow-up — client-advisory metadata**: the client still has useful +information to share that isn't an identity claim: a human-friendly +display name, the machine that made the publish, the tool version, a +CI system label, a team/role handle. This lives on a **new sibling +field `Meta`** (a `ClientMetadata` sub-struct), not on `Author`. The +separation of types is what protects the security property: `Author` +is reserved for server-authoritative identity, `Meta` is +client-advisory and explicitly labeled as such in any rendered +surface. `Meta` fields are size-capped individually (256 bytes) and +in aggregate (2 KB), validated for plain-string content (no +newlines, no control characters), and never claimed as attribution +in any API response. The renderer MUST label `Meta`-sourced values +with prose like "client label" or "client-reported" so readers +cannot mistake them for authoritative identity. See TASKS.md for +the implementation task. + +--- + +## [2026-03-05-205424] Gitignore .context/memory/ for this project + +**Status**: Accepted + +**Context**: Memory mirror contains copies of MEMORY.md which holds strategic +analysis and session notes + +**Decision**: Gitignore .context/memory/ for this project + +**Rationale**: Strategic content should not be in git history. Docs updated to +say 'often git-tracked' for the general recommendation — this project is the +exception. + +**Consequence**: Mirror and archives are local-only for this project. Other +projects can still track them. Sync and drift detection work the same way +regardless. + +--- + + + +## [2026-03-01-222733] PersistentPreRunE init guard with three-level exemption + +**Status**: Accepted + +**Context**: ctx commands handled missing .context/ inconsistently — some +caught errors, some got confusing file-not-found messages, some produced empty +output + +**Decision**: PersistentPreRunE init guard with three-level exemption + +**Rationale**: Single PersistentPreRunE on root command gives one clear error. +Three-level exemption (hidden commands, annotated commands, grouping commands) +covers all edge cases without per-command boilerplate + +**Consequence**: Boundary violation now returns an error instead of os.Exit(1), +making it testable. The subprocess-based boundary test was simplified to a +direct error assertion + +--- + +--- + +## [2026-02-26-100002] ctx init and CLAUDE.md handling (consolidated) + +**Status**: Accepted + +**Consolidated from**: 3 decisions (2026-01-20) + +- `ctx init` handles CLAUDE.md intelligently: creates if missing, backs up and + offers merge if existing, uses marker comment for idempotency. The `--merge` + flag enables non-interactive append. +- `ctx init` always generates `.claude/hooks/` alongside `.context/` with no + flag needed. Other AI tools ignore `.claude/`; Claude Code users get seamless + zero-config experience. +- Core tool stays generic and tool-agnostic, with optional Claude Code + enhancements via `.claude/hooks/`. Other AI tools can be supported similarly + (`ctx hook cursor`, etc.). + +--- + diff --git a/.context/decisions/hooks-session-and-telemetry.md b/.context/decisions/hooks-session-and-telemetry.md new file mode 100644 index 00000000..2db375ae --- /dev/null +++ b/.context/decisions/hooks-session-and-telemetry.md @@ -0,0 +1,218 @@ +# hooks-session-and-telemetry + +## [2026-06-07-180013] Context injection, hooks, and session-state architecture (consolidated) + +**Consolidated from**: 8 entries (2026-02-26 to 2026-05-08) + +- Context injection v2: extract ~600 lines of diagrams out of FileReadOrder (53% + token drop); auto-inject content via additionalContext (soft directives hit a + ~75-85% compliance ceiling); imperative framing with an unconditional + compliance checkpoint, verbatim relay as fallback. Inject + CONSTITUTION/CONVENTIONS/ARCHITECTURE/PLAYBOOK verbatim, DECISIONS/LEARNINGS + index-only, TASKS mention-only (~7,700 tokens). +- Context-load-gate injects only CONSTITUTION + AGENT_PLAYBOOK_GATE (~2k + tokens), not the full ReadOrder: hard rules must be present pre-action; + everything else is pulled on-demand. AGENT_PLAYBOOK_GATE.md must stay in sync + with AGENT_PLAYBOOK.md. +- .context/state/ is the gitignored, project-scoped home for ephemeral runtime + state (following the .context/logs/ precedent); all session state (cooldown + tombstones, pause/throttle markers) consolidated there from /tmp, dropping the + cleanup-tmp SessionEnd hook (4 hook events → 3). +- Gate mkdir inside state.Dir() rather than per-caller so "no .context/state/ in + uninitialized projects" is structurally enforced; state.Dir() returns + ErrNotInitialized (hook callers absorb silently, interactive callers surface a + path-bearing message). +- Tighten state.Dir / rc.ContextDir to (string, error) with sentinel + ErrDirNotDeclared: makes the empty-path case unrepresentable in a "looks fine" + branch, closing the filepath.Join("", rel) trap that wrote state into CWD. +- Hook/notification design: prefer toning down docs claims over adding hooks + (fatigue from 9 UserPromptSubmit hooks); hook output must be structured JSON + (additionalContext), not plain text; dropped prompt-coach hook (zero useful + tips, invisible channel); de-emphasized /ctx-journal-normalize (expensive, + nondeterministic). +- Hook log rotation is size-based with one previous generation (current + .1, + ~2MB cap), matching the eventlog pattern — O(1) size check, diagnostic logs + don't need deep history. + +--- + +## [2026-05-28-200500] Memory pressure detection uses OS-native signals (macOS pressure level + Linux PSI), not occupancy + +**Status**: Accepted + +**Context**: `check-resource` alerted DANGER at swap-used ≥ 75% / memory-used +≥ 90% — pure occupancy. macOS swap is sticky (never recedes); +post-hibernation swap stays >75% with idle RAM, producing false "wrap up the +session" DANGER at session start. Memory occupancy on macOS includes reclaimable +cache — also a poor pressure proxy. + +**Decision**: Memory pressure detection uses OS-native signals (macOS pressure +level + Linux PSI), not occupancy + +**Rationale**: Occupancy is a level; pressure is a derivative. Only the kernel's +derivative reflects current struggle. macOS: `sysctl +kern.memorystatus_vm_pressure_level` (1/2/4 → OK/Warning/Danger). Linux: +`/proc/pressure/memory` (PSI) `some.avg10 ≥ 10.0` → warn, `full.avg10 ≥ +10.0` → danger. Windows: filed as an exploratory task; unsupported for now +("other" platform falls through to `PressureSupported=false`, no alert). + +**Consequence**: `MemInfo` gains `Pressure` + `PressureSupported`; +`threshold.go` drops both occupancy `byteCheck`s and emits a single pressure +alert. Doctor swap row removed (no longer a health signal); occupancy fields +retained for `ctx stats` display. PSI 10.0 defaults named in `config/stats` — +retunable in one place. `make lint` 0 issues, `make test` ok on the change. + +--- + +## [2026-04-06-204212] Use hook relay for session provenance instead of JSONL parsing or env vars + +**Status**: Accepted + +**Context**: Needed to give agents awareness of their session ID, branch, and +commit hash for task/decision/learning provenance. Considered three approaches: +(1) parsing most-recent JSONL at runtime, (2) CTX_SESSION_ID env var, (3) hook +relay via UserPromptSubmit. + +**Decision**: Use hook relay for session provenance instead of JSONL parsing or +env vars + +**Rationale**: JSONL parsing breaks with parallel sessions (wrong file picked). +Env vars aren't exported by Claude Code. Hook relay is zero-state: the hook +receives session_id from Claude Code on every prompt, emits it, agent absorbs +through repetition. No counters, no cleanup, no resume edge cases. + +**Consequence**: Provenance depends on the hook being registered (enabledPlugins +in settings.local.json). Projects without plugin registration get no provenance. +Filed as separate bug. + +--- + +## [2026-03-02-165038] Billing threshold piggybacks on check-context-size, not heartbeat + +**Status**: Accepted + +**Context**: User wanted a configurable token-count nudge for billing awareness +(Claude Pro 1M context, extra cost after 200k). Heartbeat produces zero stdout +and can't relay to user. + +**Decision**: Billing threshold piggybacks on check-context-size, not heartbeat + +**Rationale**: check-context-size already reads tokens, has VERBATIM relay +working, and runs every prompt. Adding a third independent trigger there is +minimal code and follows established patterns. + +**Consequence**: New .ctxrc field billing_token_warn (default 0 = disabled). +One-shot per session via billing-warned-{sessionID} state file. +Template-overridable via check-context-size/billing.txt. + +--- + + + +## [2026-03-01-112544] Heartbeat token telemetry: conditional fields, not always-present + +**Status**: Accepted + +**Context**: Adding tokens, context_window, usage_pct to heartbeat payloads. +First prompt of a session has no JSONL usage data yet. + +**Decision**: Heartbeat token telemetry: conditional fields, not always-present + +**Rationale**: Token fields are only included in the template ref when tokens > +0. This avoids misleading pct=0% on the first heartbeat and keeps payloads clean +for receivers that filter on field presence. + +**Consequence**: Webhook consumers must handle heartbeats both with and without +token fields. The message string also varies (with/without tokens=N pct=N% +suffix). + +--- + +--- + +## [2026-02-27-230718] Context window detection: JSONL-first fallback order + +**Status**: Accepted + +**Context**: check-context-size defaults to 200k but user runs 1M-context model, +causing false 110% warnings. JSONL contains the model name which maps to actual +window size. + +**Decision**: Context window detection: JSONL-first fallback order + +**Rationale**: effective_window = detect_from_jsonl(model) ?? +ctxrc.context_window ?? 200_000. JSONL is ground truth (reflects actual model in +use); ctxrc is fallback for first-hook-of-session or unknown models; 200k is +safe last resort. Having ctxrc override JSONL would artificially restrict the +check when a user forgets to update their config after switching models. + +**Consequence**: Most users get correct window automatically. ctxrc +context_window becomes a fallback, not an override. Task exists for +implementation. + +--- + + + +--- + +## [2026-02-27-002831] Webhook and notification design (consolidated) + +**Status**: Accepted + +**Consolidated from**: 3 decisions (2026-02-22 to 2026-02-26) + +- **Session attribution**: All webhook payloads must include session_id. Reading + it from stdin costs nothing and enables multi-agent diagnostics. All run + functions take stdin parameter; tests use createTempStdin. +- **Opt-in events**: Notify events are opt-in, not opt-out. EventAllowed returns + false for nil/empty event lists. The correct default for notifications is + silence. `ctx notify test` bypasses the filter as a special case. +- **Shared encryption key**: Webhook URLs encrypted with the shared .ctx.key + (AES-256-GCM), not a dedicated key. One key, one gitignore entry, one rotation + cycle. Notify is a peer of scratchpad — both store user secrets encrypted at + rest. + +--- + +## [2026-02-11] Remove .context/sessions/ storage layer and ctx session command + +**Status**: Accepted + +**Context**: The session/recall/journal system had three overlapping storage +layers: `~/.claude/projects/` (raw JSONL transcripts, owned by Claude Code), +`.context/sessions/` (JSONL copies + context snapshots), and `.context/journal/` +(enriched markdown from `ctx recall import`). The recall pipeline reads directly +from `~/.claude/projects/`, making `.context/sessions/` a dead-end write sink +that nothing reads from. The auto-save hook copied transcripts to a directory +nobody consumed. The `ctx session save` command created context snapshots that +git already provides through version history. This was ~15 Go source files, a +shell hook, ~20 config constants, and 30+ doc references supporting +infrastructure with no consumers. + +**Decision**: Remove `.context/sessions/` entirely. Two stores remain: raw +transcripts (global, tool-owned in `~/.claude/projects/`) and enriched journal +(project-local in `.context/journal/`). + +**Rationale**: Dead-end write sinks waste code surface, maintenance effort, and +user attention. The recall pipeline already proved that reading directly from +`~/.claude/projects/` is sufficient. Context snapshots are redundant with git +history. Removing the middle layer simplifies the architecture from three stores +to two, eliminates an entire CLI command tree (`ctx session`), and removes a +shell hook that fired on every session end. + +**Consequence**: Deleted `internal/cli/session/` (15 files), removed auto-save +hook, removed `--auto-save` from watch, removed pre-compact auto-save from +compact, removed `/ctx-save` skill, updated ~45 documentation files. Four +earlier decisions superseded (SessionEnd hook, Auto-Save Before Compact, Session +Filename Format, Two-Tier Persistence Model). Users who want session history use +`ctx journal source`/`ctx journal import` instead. + +--- + + +*Module-specific, already-shipped, and historical decisions: +[decisions-reference.md](decisions-reference.md)* + +--- + diff --git a/.context/decisions/integrations-and-assets.md b/.context/decisions/integrations-and-assets.md new file mode 100644 index 00000000..423c3591 --- /dev/null +++ b/.context/decisions/integrations-and-assets.md @@ -0,0 +1,133 @@ +# integrations-and-assets + +## [2026-06-07-180005] YAML text externalization, init, and drift guards (consolidated) + +**Consolidated from**: 5 entries (2026-03-13 to 2026-04-03) + +- All user-facing text externalizes to embedded YAML domain files + (commands/flags/text/examples split via dedicated loaders), justified by agent + legibility (named DescKey constants as traversable graphs) and drift + prevention, not i18n; the 3-file ceremony (DescKey + YAML + write/err fn) is + the accepted cost. +- Static embedded data and resource lookups use an explicit Init() called + eagerly at startup, not per-accessor sync.Once or package-level init() — + makes the startup dependency visible and testable; maps unexported, accessors + are plain lookups. +- A Go↔YAML linkage check (lint-drift check 5, shell grep+comm) catches + orphaned/broken DescKey↔YAML links and cross-namespace duplicates at CI + time, preventing silent runtime failures. +- The build target depends on sync-why so derived assets/why/ files cannot drift + from their docs/ sources — build fails without sync. +- MCP resource name constants live in config/mcp/resource (parallel to + config/mcp/tool); the resource→file mapping stays in server/resource (too + many cross-cutting deps for a config package), pre-built once at server init + for O(1) lookup. + +--- + +## [2026-06-07-180008] ctxctl maintainer binary and out-of-band audit channel (consolidated) + +**Consolidated from**: 4 entries (2026-05-24 to 2026-05-28) + +- Discipline enforcement belongs on the verbatim-relay channel, run out-of-band: + relay is the one discipline channel that survives tunnel vision; run the + auditor in a separate Claude Code session for fresh-context judgment and cost + control. New generic channel: a skill writes .context/audit/.md, a + check-audit hook relays unread reports verbatim, ctx audit list/show/dismiss + manages lifecycle (digest-bound dismissal). +- [Superseded] ctxctl first placed at cmd/ctxctl in the same Go module: + binary-level isolation via transitive-import exclusion, zero relocation of + existing internal/audit files, on the belief a separate go.mod couldn't import + the parent's internal/. +- That belief was empirically disproved: a nested module lexically under the + parent path CAN import internal/. So ctxctl became a separate Go module at + tools/ctxctl (own go.mod) — a hard module boundary guarantees ctx can never + import ctxctl (the asymmetric requirement that matters); one-directional + ctxctl→ctx coupling is acceptable for disposable maintainer tooling. A + go.work wires the workspace; a guard test asserts cmd/ctx never imports + internal/ctxctl. +- ctxctl is PATH-installed alongside ctx (build to dist/, install to + /usr/local/bin/ctxctl) for clean repo roots and one binary across all + worktrees, mirroring ctx's install pattern; the local hook calls ctxctl from + PATH. + +--- + +## [2026-06-07-180010] Companion-tool integration: peer-MCP, no gateway (consolidated) + +**Consolidated from**: 6 entries (2026-03-06 to 2026-05-23) + +- Peer MCP model for external tools (GitNexus, context-mode): side-by-side + servers each queried independently by the agent, chosen over orchestrator/hub + models to respect ctx's markdown-on-filesystem invariant and avoid + coupling/plugin registries. +- Skills stay CLI-based; MCP Prompts are the protocol equivalent: CLI is always + available (PATH prereq), MCP is optional config, hooks are always CLI — two + access patterns in one tool is gratuitous complexity. +- Recommend companion RAGs as peer MCP servers, not bridged through ctx: MCP is + the composition layer; ctx is context, RAGs are intelligence — no bridging, + plugin system, or schema abstraction. +- Companion tools documented as optional MCP enhancements with a runtime check + (/ctx-remember smoke-tests MCPs at session start; companion_check:false + suppresses) so users learn what enhances their workflow without being forced + to install. +- MCP gateway not worth the coupling cost: a gateway would make ctx own + install/uninstall/version/error-surface for tools it doesn't ship + (bidirectional ownership coupling); composition is already MCP's job and the + skills already work peer-to-peer. The pluggable-graph-tool task was skipped as + a direct consequence (pluggability without ownership is incoherent). +- Skill body text uses capability-first language with canonical tools as + examples; install-guide docs name canonical implementations directly + (newcomers need a recommendation); allowed-tools frontmatter stays + MCP-specific (genericizing to mcp__* is a permission expansion). Pure text + rewrite, no new abstraction layer. + +--- + +## [2026-06-07-180012] Embedded assets and editor-integration harnesses (consolidated) + +**Consolidated from**: 7 entries (2026-04-01 to 2026-05-22) + +- Embedded foreign-language assets (TS/Bash/PowerShell/YAML) under + internal/assets/ are intentional, not a smell: every file is //go:embed'd into + the ctx binary and written at ctx setup; internal/ is about import privacy, + not source language. The fix for the legibility gap was a contract README, not + relocation (//go:embed can't reference ../). +- assets/hooks/ split into assets/integrations/ (tool-integration assets: + Copilot instructions, AGENTS.md, CLI scripts/skills) + assets/hooks/messages/ + (hook-system templates) — integration assets are not hooks. +- Embedded harnesses (//go:embed'd, shipped via ctx setup) and + separately-published harnesses (e.g. VS Code extension → marketplace, own + cadence) are first-class peers with distinct CI/release pipelines; a new + harness declares which pattern it follows before placing files. +- OpenCode plugin ships without a tool.execute.before hook: the natural fit + (block-dangerous-commands) isn't a ctx Go subcommand and shimming would brick + the editor (Cobra exit-1 read as {blocked:true}) on installs without the + Claude wrapper. This omission is permanent — block-dangerous-commands will + not be promoted to a ctx Go subcommand; the perpetually-pending re-add task is + closed. +- Under cwd-anchored, the OpenCode plugin's agent shell tool can't be anchored + to project root (the @opencode-ai/plugin SDK exposes only env, not cwd on + shell.env); drop the shell.env handler and document launch-from-root. + Plugin-internal ceremony calls stay anchored; the cwd-anchored error message + is self-fixing. +- Editor-integration plugins must filter post-commit to actual git commit + invocations (regex on the extracted command), not fire on every shell call — + firing on noise trains users to ignore nudges. + +--- + +## [2026-05-31-094649] ctx Desktop shells out via std::process::Command, not tauri-plugin-shell + +**Status**: Accepted + +**Context**: The GUI must run the ctx binary for every read and write. Tauri 2 offers tauri-plugin-shell with capability-scoped command allowlists. + +**Decision**: ctx Desktop shells out via std::process::Command, not tauri-plugin-shell + +**Rationale**: Running ctx inside our own #[tauri::command] via std::process::Command avoids all shell-plugin permission/capability wiring and keeps the adapter a single Rust module. ctx resolves its context from $PWD/.context, so each call sets current_dir to the selected project root. + +**Consequence**: No shell capability in capabilities/default.json; the adapter owns PATH augmentation and git provenance synthesis; a CLI/output change is a one-file fix in ctx_adapter.rs. + +--- + diff --git a/.context/decisions/journal-and-knowledge-lifecycle.md b/.context/decisions/journal-and-knowledge-lifecycle.md new file mode 100644 index 00000000..f76f0591 --- /dev/null +++ b/.context/decisions/journal-and-knowledge-lifecycle.md @@ -0,0 +1,162 @@ +# journal-and-knowledge-lifecycle + +## [2026-05-31-094649] Journal screen renders ctx journal source verbatim instead of parsing it + +**Status**: Accepted + +**Context**: The Journal timeline needs session data, but ctx journal source emits a whitespace-aligned table with no --json mode, and columns (usage/turns) drop out on short sessions. + +**Decision**: Journal screen renders ctx journal source verbatim instead of parsing it + +**Rationale**: A column parser misaligns when fields are absent; rendering the text verbatim in a monospace panel is honest and robust for P0. + +**Consequence**: Journal is a styled text view, not structured cards; a proper timeline awaits a journal source --json mode upstream (same pattern as the artifact list --json commands). + +--- + +## [2026-04-11-200000] Journal stays local; LEARNINGS.md is the shareable layer + +**Status**: Accepted + +**Context**: With the hub now carrying shared project context between machines +and eventually between teammates, the question came up whether enriched +journal entries should ride along — either the raw `.context/journal/` files +or an "export enriched entries as shareable learning items" pipeline layered +on top of `/ctx-journal-enrich`. The journal is already gitignored per the +2026-03-05 `.context/memory/` decision and for the same reason: it's a +first-person log of raw prompts, half-formed thoughts, dead ends, personal +names, and things the user talks through with themselves. It sits in the +same trust tier as shell history or a private notebook. + +The trade-off is real: shared journals would make it trivial for teammates +(or future-me on another machine) to see the full reasoning trail behind a +decision. But "full reasoning trail" is precisely the thing that makes a +journal journal and not a changelog — it includes the parts the author +hasn't decided to stand behind yet, plus incidental private content. + +**Decision**: The journal is **Tier-0 personal** and never leaves the +originating machine. No hub sync, no export-by-default, no +enriched-entries-as-shareable-items pipeline. The enrichment pipeline +(`/ctx-journal-enrich`) stays as-is: journal → human-in-the-loop review → +explicit promotion to LEARNINGS.md / DECISIONS.md / CONVENTIONS.md via the +existing `/ctx-learning-add`, `/ctx-decision-add`, `/ctx-convention-add` +commands. Those distilled artifacts are **Tier-1 shareable** and are what +the hub syncs when a team opts into shared context. + +The promotion boundary is therefore the enrichment step, not a new export +pipeline. The user is the gate. + +**Rationale**: Any "shareable enriched journal entry" pipeline would have to +re-implement the trust boundary that `/ctx-learning-add` already enforces: +the human decides what's worth sharing, strips incidental private content, +and rewrites it as a standalone artifact. A second pipeline that tries to +do this automatically would either (a) leak private content by accident, or +(b) require the same human review and thus collapse back into +`/ctx-learning-add`. The principled answer is that there is no second +pipeline — LEARNINGS.md *is* the shareable form of the journal. + +This also preserves the psychological safety of the journal: the author +can write freely because they know nothing they write is one sync away +from a teammate's screen. Lose that property and the journal stops being a +journal and starts being a changelog draft. + +**Consequence**: + +- Journal files stay gitignored and stay out of `ctx hub` sync paths. Any + future code that walks context files for replication must exclude + `.context/journal/` explicitly and be covered by a test. +- `/ctx-journal-enrich` remains the promotion boundary. Its output targets + are LEARNINGS.md / DECISIONS.md / CONVENTIONS.md, never a separate + "shareable journal" bucket. +- Hub docs (`docs/home/hub.md`, `docs/recipes/hub-personal.md`, + `docs/recipes/hub-team.md`, `docs/security/hub.md`) should state the + Tier-0 / Tier-1 split explicitly so users building team workflows don't + assume "shared context" means "shared everything." +- The sync code path in `internal/hub/sync_helper.go` and any future + replication of context files must enforce this exclusion at the + code level — a gitignore entry is a user-convenience signal, not a + hub-trust boundary. +- A potential future "personal multi-machine journal sync" (same human, + different laptops) is explicitly **out of scope** of this decision. If + it ever ships, it rides a different transport (encrypted-at-rest, + single-user, not the team hub) and needs its own decision record. + +**Alternatives considered**: + +- **Sync raw journal files via hub**: rejected. Inverts the gitignore + decision, leaks private content by construction, destroys the + journal's "safe to write freely" property. +- **Auto-export enriched entries as a new shareable artifact type**: + rejected. Duplicates `/ctx-learning-add` without the human gate, or + collapses back into it. No real difference from the status quo except + the opportunity for accidental leakage. +- **Opt-in per-entry "publish to hub" flag in the journal**: rejected as + premature. If the user wants an entry on the hub, the existing flow is + one command away — write it as a learning or decision. A second path + adds surface area without adding capability. + +**Related**: Reinforces the 2026-03-05 `.context/memory/` gitignore +decision (same trust-tier reasoning for a different private artifact). + +## [2026-04-08-013731] Remove #done tag convention, simplify task archival + +**Status**: Accepted + +**Context**: Tasks had #done:YYYY-MM-DD timestamps that agents added +inconsistently and nobody read. compact --archive filtered by age using these +timestamps. + +**Decision**: Remove #done tag convention, simplify task archival + +**Rationale**: [x] checkbox is semantically sufficient. git blame provides the +completion timestamp. Removing #done eliminates redundant ceremony and +simplifies compact --archive to archive all completed tasks regardless of age. + +**Consequence**: compact --archive no longer filters by archive_after_days for +tasks. The .ctxrc field is inert but retained for backwards compatibility. +Historical #done tags in archives are preserved. + +--- + +## [2026-03-24-001001] Write-once baseline with explicit end-consolidation for consolidation lifecycle + +**Status**: Accepted + +**Context**: Designing the consolidation nudge hook; multi-pass consolidation +spans dozens of sessions and you cannot programmatically distinguish feature +from consolidation sessions + +**Decision**: Write-once baseline with explicit end-consolidation for +consolidation lifecycle + +**Rationale**: First ctx-consolidate stamps baseline (write-once), user runs +end-consolidation when done. Failure mode is silence (no stale nudges), not +wrong behavior + +**Consequence**: Requires mark-consolidation, end-consolidation, and +snooze-consolidation plumbing commands. Spec: specs/consolidation-nudge-hook.md + +--- + + +## [2026-02-26-100004] Task and knowledge management (consolidated) + +**Status**: Accepted + +**Consolidated from**: 4 decisions (2026-01-27 to 2026-02-18) + +- Tasks must include explicit deliverables, not just implementation steps. + Parent tasks define WHAT the user gets; subtasks decompose HOW to build it. + Without explicit deliverables, AI optimizes for checking boxes. +- Use reverse-chronological order (newest first) for DECISIONS.md and + LEARNINGS.md. Ensures most recent items are read first regardless of token + budget. +- Add quick reference index to DECISIONS.md: compact table at top allows + scanning; agents can grep for full timestamp to jump to entry. Auto-updated on + `ctx add decision`. +- Knowledge scaling via archive path for decisions and learnings: follow the + task archive pattern, move old entries to `.context/archive/`, extend `ctx + compact --archive` to cover all three file types. + +--- + diff --git a/.context/decisions/kb-and-vocabulary.md b/.context/decisions/kb-and-vocabulary.md new file mode 100644 index 00000000..cf3add05 --- /dev/null +++ b/.context/decisions/kb-and-vocabulary.md @@ -0,0 +1,83 @@ +# kb-and-vocabulary + +## [2026-06-07-180009] KB editorial pipeline (Phase KB) design (consolidated) + +**Consolidated from**: 6 entries (2026-05-10 to 2026-05-16) + +- Lift the sibling clean-room project's battle-tested editorial pipeline into + ctx as v1, paired with handover: it is field-tested under production use and + your-project is already paying the workaround tax (N=1 lived validation); lift + the whole shape with a non-colliding rename, not hedge-and-defer. +- Mandate git as an architectural precondition: persistent-memory is dishonest + without an undo layer (git reflog); refuse-on-no-git rather than auto-git-init + (ctx never modifies the filesystem outside .context/); eliminates commit:none + dead-code branches. Breaking change in next minor. +- KB ontology is pipeline-only-writer; no /ctx-kb-decide skill: in a KB you + don't decide, you increase confidence — even NL assertions are + evidence-capture events, not decision-capture. KB surface stays small (4 mode + skills + ctx kb note); canonical capture skills unchanged. +- Phase KB ships handover + editorial paired, not split: the closeout/fold + mechanism is the integration point; shipping paired stresses the fold on day + one rather than retrofitting it. +- Editorial constitution lives at .context/ingest/KB-RULES.md, not + CONSTITUTION.md: lifts the sibling project's resolved naming-collision (their + 10-INGEST_RULES.md rename) so ctx CONSTITUTION.md keeps its singular meaning; + same discipline carries to domain-decisions.md vs DECISIONS.md. +- Phase KB lifts the *current* upstream pipeline shape (pass-mode contract, + completion circuit breaker, source-coverage state-machine ledger, + topic-adjacency pre-flight, cold-reader rubric, folder-shaped topics from day + one, CLI-as-scaffold-authority), superseding the brief's 4-phase model — + lifting the older shape would re-fight wounds the upstream author already + healed. + +--- + +## [2026-06-07-180011] Localizable vocabulary and i18n primitives (consolidated) + +**Consolidated from**: 5 entries (2026-03-14 to 2026-05-23) + +- Session prefixes are parser vocabulary, not i18n text: header-recognition + patterns move to .ctxrc session_prefixes (default Session:), separating + content recognition from interface language so users parse multilingual + session files without code changes. +- Classify rules are user-configurable via .ctxrc (classify_rules overrides + config/memory defaults) — same pattern as session_prefixes, for + non-English/specialized domains. +- Spec signal words and the nudge threshold (spec_signal_words, + spec_nudge_min_len) are .ctxrc-configurable — signal words are language- and + project-dependent. +- Keep i18n.Fold strict (Unicode case-fold, İ≠i, for identifier + dedup/parsing/security comparison); add i18n.MatchKey (Fold + NFKD + strip + combining marks) as a separate diacritic-insensitive primitive for matching + user input against vocabulary lists. Two explicit-contract primitives beat one + conflated primitive or an options flag. +- Placeholder overrides use EXTEND, not REPLACE, semantics (diverging from + SessionPrefixes' REPLACE): the dominant bilingual EN+TR case needs both + default and added placeholders rejected simultaneously; REPLACE would silently + lose baseline coverage. Opt-in placeholders_replace:true reserved if REPLACE + is later wanted. + +--- + +## [2026-03-14-093748] Config-driven freshness check with per-file review URLs + +**Status**: Accepted + +**Context**: Building a hook to warn when technology-dependent constants go +stale. Initially hardcoded the file list and Anthropic docs URL in the binary, +but this only worked inside the ctx repo and assumed all projects care about +Anthropic docs. + +**Decision**: Config-driven freshness check with per-file review URLs + +**Rationale**: Making the file list and review URLs configurable via .ctxrc +freshness_files means any project can opt in. Per-file review_url avoids +special-casing by project name — ctx sets Anthropic docs, other projects set +their own vendor links or omit it entirely. + +**Consequence**: The hook is a no-op by default (opt-in). ctx's own .ctxrc +carries the tracked files. All nudge text goes through assets/text.yaml for +localization. No project detection logic needed. + +--- + diff --git a/.context/decisions/package-structure-and-quality-gates.md b/.context/decisions/package-structure-and-quality-gates.md new file mode 100644 index 00000000..52888b8e --- /dev/null +++ b/.context/decisions/package-structure-and-quality-gates.md @@ -0,0 +1,198 @@ +# package-structure-and-quality-gates + +## [2026-06-07-180001] Output belongs in write/ — taxonomy and emission style (consolidated) + +**Consolidated from**: 3 entries (2026-03-17 to 2026-04-03) + +- Output functions belong in write/ (flat by domain, one package per CLI + feature); core/ owns logic and types, cmd/ owns Cobra orchestration. No + cmd.Print* calls in internal/cli/ outside internal/write/ — enables + localization and clean separation. +- Within write/, use pre-compute-then-print: functions with 4+ Printlns + pre-compute conditional strings then emit one multiline block (TplXxxBlock), + rejecting text/template (runtime errors, only 38/160 functions benefit); + trivial and loop-based functions stay imperative. + +--- + +## [2026-06-07-180002] Package taxonomy and shared-code placement (consolidated) + +**Consolidated from**: 6 entries (2026-03-06 to 2026-05-17) + +- Three-zone taxonomy: cmd/ for Cobra wiring, core/ for logic and types, assets/ + for templates and user-facing text; config/ for structural constants only. + Symmetry makes navigation agent-friendly; shared domain types live in domain + packages (internal/entry), not CLI subpackages. +- Pure-logic functions return data structs; callers own I/O, file writes, and + reporting — lets MCP and CLI callers control output independently. + Receiver-stateless methods become free functions; callbacks that vary only by + a string key become text-key data. +- Shared formatting utilities (Pluralize, Duration, TruncateFirstLine, etc.) + live in internal/format, not duplicated across CLI subpackages. +- internal/parse is the home for shared text-to-typed-value conversions + (parse.Date first), scoped to avoid becoming a junk drawer. +- Every cross-package type goes in internal/entity/ — the cross-package-types + audit (zero grandfathered violations) is the hardline; entity.Sentinel lives + there even though it is a behavioral helper, over per-package duplication + across 9 err packages. +- Multi-segment directory paths are single composite constants + (DirHooksMessages, DirMemoryArchive), not joined from segment constants. + +--- + +## [2026-06-07-180003] Error handling: centralized in internal/err, domain-file taxonomy (consolidated) + +**Consolidated from**: 2 entries (2026-03-06 to 2026-03-14) + +- Errors centralize in internal/err, not per-package err.go files — single + location makes duplicates visible, enables sentinel errors, prevents + broken-window accumulation; all CLI err.go files migrated and deleted. +- The monolithic 1995-line errors.go (188 functions) was split into 22 domain + files (backup, config, crypto, …, validation) named by responsibility, so + error constructors are findable by domain. + +--- + +## [2026-06-07-180004] config/ as constants home and the magic-value audit (consolidated) + +**Consolidated from**: 4 entries (2026-03-23 to 2026-04-04) + +- String-typed enums (type Foo string + const blocks) belong in config/, not + domain packages — types without behavior live in config; promote to entity/ + only when methods/interfaces appear. +- TestNoMagicStrings/TestNoMagicValues dropped the const/var exemption outside + config/ (it masked 156+ string and 7 numeric constants in the wrong place); + naming a constant in the wrong package does not fix the structural problem. +- The 60+ config/ sub-package "explosion" is correct, not a bottleneck: Go's + compile unit is the package, so granular packages give precise dependency + tracking and minimal recompile; the DX cost is fixed by a README decision + tree, not restructuring. +- Cross-package magic strings (e.g.
 HTML tags used by normalize and
+  format) promote to shared config constants (config/marker TagPre/TagPreClose);
+  package-local copies deleted.
+
+---
+
+## [2026-04-14-010205] doc.go quality floor: behavior-grounded, ~25-100 body lines, related-packages section required
+
+**Status**: Accepted
+
+**Context**: About 140 doc.go files were rewritten this session. User flagged
+the original 5-line Key exports + See source files + Part of subsystem pattern
+as lazy minimum effort.
+
+**Decision**: doc.go quality floor: behavior-grounded, ~25-100 body lines,
+related-packages section required
+
+**Rationale**: Behavior-grounded rewrites (read source first, then write) are
+the only acceptable form for any non-trivial package. The lazy template
+communicates nothing a future reader cannot grep for; it satisfies tooling
+without adding signal.
+
+**Consequence**: Every non-trivial package's doc.go now leads with the package's
+actual purpose, names key behaviors, calls out non-obvious design choices
+(Raft-lite, two-step indirection, idempotency contracts), and lists related
+packages with paths. New packages should follow the same shape.
+
+---
+
+## [2026-04-14-010205] Title Case style for docs is AP-leaning with explicit ambiguity carve-outs
+
+**Status**: Accepted
+
+**Context**: Needed a deterministic Title Case engine for headings and
+admonition titles across docs/. User precedent (Working with AI lowercase with)
+ruled out strict Chicago.
+
+**Decision**: Title Case style for docs is AP-leaning with explicit ambiguity
+carve-outs
+
+**Rationale**: AP lowercase prepositions regardless of length matches
+user-approved titles. But strict AP would lowercase ambiguous prep/conj/adv
+words like before, after, since, until, past, near, down, up, off, hurting
+common cases. Carve-outs leave them at default-cap and let the engine reach a
+sensible result for ~95 percent of headings without manual review.
+
+**Consequence**: hack/title-case-headings.py ships an AP-leaning with ambiguity
+carve-outs PREPOSITIONS set. Future style changes must touch that set explicitly
+with reasoning. New brand or acronym additions go through the same audited
+pattern.
+
+---
+
+## [2026-04-01-233246] AST audit tests live in internal/audit/, one file per check
+
+**Status**: Accepted
+
+**Context**: Needed a home for AST-based codebase invariant tests separate from
+the existing compliance_test.go monolith
+
+**Decision**: AST audit tests live in internal/audit/, one file per check
+
+**Rationale**: One test per file prevents the 1200+ line monster pattern. Shared
+helpers in helpers_test.go with sync.Once caching. Package is all _test.go
+except doc.go — produces no binary, not importable
+
+**Consequence**: New checks are added as individual *_test.go files; the pattern
+(loadPackages, walk AST, collect violations, t.Error) is established and
+repeatable
+
+---
+
+## [2026-03-31-224245] Split log into log/event and log/warn to break import cycles
+
+**Status**: Accepted
+
+**Context**: io and notify could not import log.Warn because log imported both
+of them for event logging, creating circular dependencies
+
+**Decision**: Split log into log/event and log/warn to break import cycles
+
+**Rationale**: Separating concerns (stderr sink vs JSONL event log) into
+subpackages eliminated the cycle. Warn sink is foundation-level with only config
+imports, event logging is higher-level
+
+**Consequence**: All stderr warnings now route through logWarn.Warn(). New code
+importing log/warn has no cycle risk. Event types moved to internal/entity
+
+---
+
+## [2026-03-04-105238] Interface-based GraphBuilder for multi-ecosystem ctx deps
+
+**Status**: Accepted
+
+**Context**: P-1.3 questioned whether non-Go dependency support would introduce
+bloat and whether a semantic approach was better
+
+**Decision**: Interface-based GraphBuilder for multi-ecosystem ctx deps
+
+**Rationale**: The output pipeline (map[string][]string to Mermaid/table/JSON)
+was already language-agnostic. Each ecosystem builder is ~40 lines — this is
+finishing what was started, not bloat. Static manifest parsing (no external
+tools for Node/Python) keeps dependencies minimal.
+
+**Consequence**: ctx deps now auto-detects Go, Node.js, Python, Rust. --type
+flag overrides detection. ctx-architecture skill works across ecosystems without
+changes.
+
+---
+
+## [2026-04-25-014704] Use t.Setenv for subprocess env in tests, not append(os.Environ(), ...)
+
+**Status**: Accepted
+
+**Context**: TestBinaryIntegration spawns subprocesses; the prior helper did
+append(os.Environ(), CTX_DIR=...) to override the developer-shell value. Wrong
+abstraction.
+
+**Decision**: Use t.Setenv for subprocess env in tests, not append(os.Environ(),
+...)
+
+**Rationale**: t.Setenv mutates the live process env, exec.Cmd with nil Env
+inherits it, and cleanup is automatic at test end. One line replaces the helper.
+
+**Consequence**: Helper deleted, six call sites simplified, no env-dedup logic
+to maintain. Pattern reusable for other subprocess tests.
+
+---
+
diff --git a/.context/decisions/product-community-and-deps.md b/.context/decisions/product-community-and-deps.md
new file mode 100644
index 00000000..9c4224c5
--- /dev/null
+++ b/.context/decisions/product-community-and-deps.md
@@ -0,0 +1,125 @@
+# product-community-and-deps
+
+## [2026-07-16-215955] Progressive disclosure: canonical knowledge files become bounded roots with themed rollout
+
+**Status**: Accepted
+
+**Context**: Canonical knowledge files (LEARNINGS/DECISIONS/CONVENTIONS) grow without bound as entries accumulate, and the entries stay valid, so nothing can be dropped: time-sharding plus a load-excluded 'cold' bucket was already rejected (a supersession pass found only ~1.5% cold across 162 entries; recency-gating is dangerous because old approximately equals live). At sufficient scale an agent that legitimately wants system understanding reads every decision, then every learning, and exhausts its context window. Consumption discipline (headings-first via ctx index) is necessary but NOT sufficient: an agent can always choose to read the whole file, and will when it wants completeness. Consolidation does not help either: a 2026-07-16 pass moved LEARNINGS only 98 to 88 because the corpus is dense with distinct signal, not redundancy.
+
+**Decision**: Progressive disclosure: canonical knowledge files become bounded roots with themed rollout
+
+**Rationale**: Structural boundedness makes 'read it all and die' impossible rather than merely discouraged, applying the project's own 'mechanical gates over prose' principle to context loading. Storing the generated synthesis is justified precisely BECAUSE it is expensive to recompute (an LLM pass), unlike the trivially-recomputable INDEX block whose storage was pure waste and pure drift: the cost/benefit of storing derived state inverts when recomputation is costly. Reading the bounded root alone yields compressed history PLUS verbatim recent delta, a complete current picture with no staleness gap, because staging IS the un-digested remainder by construction. The staging zone therefore serves as the watermark, so no state file is needed: a .context/state/digest.json would be a second source of truth about a fact the file already states (the INDEX-block mistake in a new coat), and its one advantage (detecting a misplaced entry) is recovered by a cheap structural invariant instead. Verified against the code: the add path anchors on the first line-start '## [' and falls back to AfterHeader, so entries always land above '## Themes' even when staging is empty, meaning ctx decision/learning add needs ZERO change; and because the root is bounded, the existing ctx agent packet needs no rewire either.
+
+**Consequence**: Entry bodies now move between files, which is the clobber risk class that index.Validate exists for, so the pass must append to the theme file, verify byte-presence, and only then remove from staging; it validates preconditions, fails loud with no auto-repair (matching the learning-add clobber fix precedent), and prefers duplication over loss on crash. New structural invariants become mechanically checkable: no line-start '## [' below '## Themes'; root gists and theme files are 1:1; every entry lives in exactly one place. CONVENTIONS needs an extra trailing '## Recent' staging heading because AppendAtEnd plus '###' prose sections would nest ambiguously inside '## Themes', and conventions edits-in-place now happen in theme files behind a link. Theme proliferation remains a slow unbounded growth vector on the root; the structure is self-similar, so an overgrown theme file can itself become a root (nesting deferred, not precluded). Scope is LEARNINGS, DECISIONS, CONVENTIONS; CONSTITUTION (small by design) and TASKS (auto-archived) are excluded. The pass is agent-driven and human-gated (agent suggests themes, human can override), triggered by suggestion only from the growth nudge, /ctx-remember, and /ctx-wrap-up, never performed inline at wrap-up because the human is leaving at that point. Spec: specs/progressive-disclosure.md.
+
+---
+
+## [2026-07-15-000000] Live ceremony credit reuses the daily throttle marker, suppressing the day's other ceremony nudge
+
+**Status**: Accepted
+
+**Context**: The `check-ceremony` hook nudges the operator to open
+sessions with `/ctx-remember` and close them with `/ctx-wrap-up`. It was
+journal-driven only, so it self-nudged on the very prompt that ran a
+ceremony and kept nudging until the session was journal-imported. The
+fix (specs/ceremony-nudge-live-session.md) parses the live prompt and,
+when it is a ceremony command, credits it immediately. The question was
+*how* to record that live credit.
+
+**Alternatives Considered**:
+- Reuse the existing per-day throttle marker (`ceremony-reminded`):
+  touching it on a live ceremony credits the session and settles the
+  ceremony question for the day. Pros: zero new state; one guard;
+  matches the check's existing once-per-day cadence. Cons: crediting a
+  live `/ctx-remember` also suppresses a would-be `/ctx-wrap-up` nudge
+  for the rest of that day (and vice-versa).
+- Per-ceremony live markers (separate remember/wrap-up credit): Pros:
+  the other ceremony can still nudge the same day. Cons: new state files,
+  a second throttle axis, and more moving parts for a coarse daily nudge.
+
+**Decision**: Reuse the single daily throttle marker. On a live ceremony
+prompt, `check-ceremony` touches `ceremony-reminded` and returns without
+nudging.
+
+**Rationale**: The check is a deliberately coarse daily cadence, not a
+per-ceremony ledger. An operator actively running one ceremony does not
+need to be nagged about the other on that same prompt, and the marker's
+"settled for today" semantics already express exactly that. The extra
+state a per-ceremony scheme buys is not worth it for a once-a-day hint.
+
+**Consequence**: Running either ceremony live suppresses both ceremony
+nudges for the rest of that day. This is intended; the trade-off is
+documented in the spec's Trade-off section. If a per-ceremony cadence is
+ever wanted, this is the decision to revisit.
+
+**Related**: See spec specs/ceremony-nudge-live-session.md.
+
+## [2026-07-04-152957] Statusline informs, never gates
+
+**Status**: Accepted
+
+**Context**: Porting a cost-aware status line whose reference design escalates to a spend alarm with a model-switch suggestion when an expensive model family is detected
+
+**Decision**: Statusline informs, never gates
+
+**Rationale**: A family-substring rule carries no task context: the expensive model is often the cheaper choice per outcome, and a statusline cannot see outcomes, so it must not prescribe. Alarms also only reach operators who are present and already see the dollar figure; unattended overspend belongs to loop/cron notifications
+
+**Consequence**: ctx system statusline renders model, ctx%, and plain cost only; show_cost exists for screen-sharing; any future budget enforcement must be a separate, deliberate feature, not statusline creep
+
+---
+
+## [2026-07-03-182236] Keep sonnet-4-6 at the 200k default despite the API catalog listing 1M
+
+**Status**: Accepted
+
+**Context**: Claude 5 window-detection fix (specs/model-context-window-fable.md); Anthropic's current catalog shows Sonnet 4.6 with a 1M window at the API level
+
+**Decision**: Keep sonnet-4-6 at the 200k default despite the API catalog listing 1M
+
+**Rationale**: ctx models Claude Code's per-session gating, not raw API capability: Sonnet 4.6's 1M is an explicit opt-in already handled by ModelSuffix1M and ClaudeSettingsHas1M, and the existing test deliberately pins the 200k default
+
+**Consequence**: Revisit only with session-level evidence (a sonnet-4-6 JSONL showing 1M without the [1m] suffix); over-reporting the window would silence the context hook while a session genuinely fills
+
+---
+
+## [2026-04-01-233247] IRC to Discord as primary community channel
+
+**Status**: Accepted
+
+**Context**: Discord server exists at https://ctx.ist/discord; IRC/libera.chat
+references were stale
+
+**Decision**: IRC to Discord as primary community channel
+
+**Rationale**: Discord is faster for async community support; IRC was historical
+
+**Consequence**: Updated zensical.toml, README, community docs, journal
+template. Added community footer to ctx help and ctx init output via YAML assets
+pipeline
+
+---
+
+## [2026-03-06-200306] Drop fatih/color dependency — Unicode symbols are sufficient for terminal output, color was redundant
+
+**Status**: Accepted
+
+**Context**: fatih/color was used in 32 files for green checkmarks, yellow
+warnings, cyan headings, dim text
+
+**Decision**: Drop fatih/color dependency — Unicode symbols are sufficient for
+terminal output, color was redundant
+
+**Rationale**: Every colored output already had a semantic symbol (✓, ⚠,
+○) that conveyed the same meaning; color added visual noise in non-terminal
+contexts (logs, pipes)
+
+**Consequence**: Removed --no-color flag (only existed for color.NoColor); one
+fewer external dependency; FlagNoColor retained in config for CLI compatibility
+
+---
+
+
+
+
+---
+
diff --git a/.context/decisions/security-and-permissions.md b/.context/decisions/security-and-permissions.md
new file mode 100644
index 00000000..9e59e3cd
--- /dev/null
+++ b/.context/decisions/security-and-permissions.md
@@ -0,0 +1,43 @@
+# security-and-permissions
+
+## [2026-03-14-110748] System path deny-list as safety net, not security boundary
+
+**Status**: Accepted
+
+**Context**: Replacing nolint:gosec directives with centralized I/O wrappers in
+internal/io
+
+**Decision**: System path deny-list as safety net, not security boundary
+
+**Rationale**: ctx paths are internally constructed from config constants. The
+deny-list catches agent hallucinations (writing to /etc), not adversarial input.
+Public security docs would imply a threat model that does not exist.
+
+**Consequence**: internal/io/doc.go documents limitations honestly for
+contributors. No user-facing security docs. The deny-list is a modicum of
+protection, not a promise.
+
+---
+
+## [2026-02-26-100006] Security and permissions (consolidated)
+
+**Status**: Accepted
+
+**Consolidated from**: 4 decisions (2026-01-21 to 2026-02-24)
+
+- Keep CONSTITUTION.md minimal: only truly inviolable rules (security,
+  correctness, process invariants). Style preferences go in CONVENTIONS.md.
+  Overly strict constitution gets ignored.
+- Centralize constants with semantic prefixes in `internal/config/config.go`:
+  `Dir*` for directories, `File*` for paths, `Filename*` for names,
+  `UpdateType*` for entry types. Single source of truth, compile-time typo
+  checks.
+- Hooks use `ctx` from PATH, not hardcoded absolute paths. Standard Unix
+  practice; portable across machines/users. `ctx init` checks PATH availability
+  before proceeding.
+- Drop absolute-path-to-ctx regex from block-dangerous-commands shell script.
+  The block-non-path-ctx Go subcommand already covers this with better patterns;
+  duplicating creates two sources of truth.
+
+---
+
diff --git a/.context/decisions/skills-and-agent-architecture.md b/.context/decisions/skills-and-agent-architecture.md
new file mode 100644
index 00000000..abd63d94
--- /dev/null
+++ b/.context/decisions/skills-and-agent-architecture.md
@@ -0,0 +1,237 @@
+# skills-and-agent-architecture
+
+## [2026-06-07-112203] ctx-dream executor is a documented contract, not a hardcoded cron/claude assumption
+
+**Status**: Accepted
+
+**Context**: Settling ctx-dream v1 open questions. The executor runs the
+out-of-band dream pass (read ideas/, classify+ground, write proposals). Question
+was cron 'claude -p' vs a raw Anthropic-API scheduled loop.
+
+**Decision**: ctx-dream executor is a documented contract, not a hardcoded
+cron/claude assumption
+
+**Rationale**: cron 'claude -p' is the reference executor (reuses Claude Code
+auth, tool-calling, and PreToolUse hooks so the three guards are structural for
+free; matches the existing skill draft and the cheap-validation goal). But we
+must NOT assume it is the only executor: other harnesses (different AI CLI, raw
+API loop, CI runner) must be able to run the same dream. So ctx owns an
+executor-agnostic Go core (dreams/ layout, state record, ledger, proposal
+schema, the three guards as callable logic) and the executor is a documented
+contract: run one bounded pass, enforce the three guards STRUCTURALLY (Claude
+Code via PreToolUse hooks; API loop via in-loop tool executor), fail loud, write
+proposals-only into dreams/. Dream is opt-in, not enabled by default.
+
+**Consequence**: Guards live as reusable Go logic in internal/dream/, not only
+as a hook script. Two user-facing docs are required: a Claude Code enablement
+guide and an executor-contract reference for other harnesses. The serendipity
+review skill is split into its own spec (specs/ctx-serendipity.md). v1 ships the
+cron/claude-p reference path but the data contract + guards stay
+executor-portable.
+
+---
+
+## [2026-06-06-133805] ctx-dream: standalone proposing memory consolidator (Option B), human-gated via serendipity
+
+**Status**: Accepted
+
+**Context**: We explored whether ctx should grow a scheduled, background 'dream'
+(a sleep-time memory process) and how it should relate to canonical memory. Felt
+pain: the author's ideas/ folder is too overwhelming to triage, and canonical
+files bloat over time (109 decisions, 151 learnings, 154 unimported sessions).
+The risk to avoid: a background LLM job autonomously rewriting authoritative
+memory and silently corrupting it (the research shows continuous LLM
+consolidation is lossy and non-monotonic). Full debate:
+.context/briefs/20260606T203414Z-ctx-dream-disciplined-consolidator.md
+
+**Decision**: ctx-dream: standalone proposing memory consolidator (Option B),
+human-gated via serendipity
+
+**Rationale**: Chose a NEW, standalone, PROPOSING consolidator (Option B): it
+writes only to its own sidecar + proposals queue + ledger + per-dream archive,
+never autonomously to the five canonical files; a human 'serendipity' review
+session is the sole bridge (accept/reject/amend) into canonical. One skill, two
+modes: discipline (default; grounded, structured, provenanced proposals) and
+creative/exploration (a safe relaxation: resurface + chance, reader-only).
+Principle: decouple the cognition, reuse the plumbing (own the consolidation
+logic; reuse import/enrich/kb-ingest via the enriched-journal data contract).
+Standalone so mechanics evolve independently and changes to existing curation
+skills can't break it, and for creative freedom (don't assume existing verbs
+suffice). Discipline-first because it is the hard load-bearing substrate and
+creative is a strict, safer relaxation of it. Grounded in
+ideas/ctx-dreams/research: Auto-Dreamer (2605.20616) for the architecture,
+'Useful Memories Become Faulty When Continuously Updated by LLMs' (2605.12978)
+for the threat model, and the deep-research eval cluster for the finding that a
+single agreeable LLM is not an adversarial gate (it silently repairs the missing
+justification), which is why the gate must be human. Rejected: Option A (dream
+owns a parallel canonical store, which does not fix bloat and creates two
+divergent substrates); autonomous mutation / auto-approve (violates 'each memory
+entry needs dedicated human attention'); pure-garden-only (under-serves
+engineering's need for grounding and actionability); coupling to existing
+skills' internals; garden-first build order.
+
+**Consequence**: Positive: nothing autonomous touches canonical, so the system
+is reversible by construction; the dream's mechanics can evolve freely; v1
+(disciplined ideas/ triage, validated via a ctx-remind-nagged ~15-minute review
+round) is low-stakes and validates the mechanism and author engagement cheaply.
+Negative / trade-off: no human serendipity session = no consolidation, so the
+dream's entire value is gated behind human review cadence, and the author
+historically under-runs curation; mitigated only by ctx-remind nags + targeting
+felt pain (ideas/) + a pleasure-not-chore framing. Validation of the full
+product thesis (disciplined consolidation of canonical memory for engineering
+teams) is deferred to a later test on a project where bloat actually bites. Spec
+work proceeds via /ctx-spec --brief on the brief above; key mechanics remain
+open (executor, proposal schema, ledger schema, .context/ layout).
+
+---
+
+## [2026-04-09-001332] Architecture skill pipeline is a triad not a quartet
+
+**Status**: Accepted
+
+**Context**: Had a proposed ctx-architecture-extend for extension point mapping,
+making four skills
+
+**Decision**: Architecture skill pipeline is a triad not a quartet
+
+**Rationale**: Extension points already covered per-module in DETAILED_DESIGN
+and by registration site discovery in enrich. Fourth skill fragments pipeline
+without distinct value
+
+**Consequence**: Pipeline is map enrich hunt. Three skills three questions: how
+does it work, how well does it connect, where will it break
+
+---
+
+## [2026-03-25-233646] Architecture analysis and enrichment are separate skills — constraint is the feature
+
+**Status**: Accepted
+
+**Context**: Observed that agents take shortcuts when code intelligence tools
+are available during architecture analysis. A 5.2x depth reduction was measured
+(5866 vs 1124 lines) when GitNexus was available during reading. Mentioning
+unavailable tools by name in a skill plants the idea for the agent to use them.
+
+**Decision**: Architecture analysis and enrichment are separate skills —
+constraint is the feature
+
+**Rationale**: Discovery requires forced reading without shortcuts. Validation
+and quantification are a separate pass. Two-pass compiler analogy: semantic
+parsing (human-style reading) then static analysis (graph enrichment). Never
+mention tools you want the agent to avoid — absence is the only reliable
+constraint.
+
+**Consequence**: ctx-architecture deliberately excludes code intelligence tools
+from allowed-tools and never mentions them. ctx-architecture-enrich is a
+separate skill that runs after, using the deep artifacts as baseline. Gemini is
+allowed in both for upstream/external lookups only.
+
+---
+
+
+## [2026-03-25-173336] Prompt templates removed — skills are the single agent instruction mechanism
+
+**Status**: Accepted
+
+**Context**: Prompt templates (.context/prompts/) overlapped with skills but had
+no discoverability — even the project creator didn't know they existed
+
+**Decision**: Prompt templates removed — skills are the single agent
+instruction mechanism
+
+**Rationale**: Adding metadata to prompts to fix discoverability would recreate
+the skill system. One concept is better than two.
+
+**Consequence**: code-review, explain, refactor promoted to proper skills. ctx
+prompt CLI removed. loop.md retained as ctx loop config file at
+.context/loop.md.
+
+---
+
+## [2026-03-13-223111] Delete ctx-context-monitor skill — hook output is self-sufficient
+
+**Status**: Accepted
+
+**Context**: The skill documented how to relay context window warnings, but the
+hook message already includes IMPORTANT: Relay this context window warning to
+the user VERBATIM which agents follow without the skill.
+
+**Decision**: Delete ctx-context-monitor skill — hook output is
+self-sufficient
+
+**Rationale**: No mechanism exists for hooks to trigger skills. The skill was
+never loaded during sessions. Adding enforcement elsewhere would either be too
+far back in context (playbook) or dilute the already-crisp hook message.
+
+**Consequence**: One fewer skill to maintain. No behavioral change — agents
+continue relaying warnings as before.
+
+---
+
+
+
+## [2026-03-12-133007] Rename ctx-map skill to ctx-architecture
+
+**Status**: Accepted
+
+**Context**: The name 'map' didn't convey the iterative, architectural nature of
+the ritual
+
+**Decision**: Rename ctx-map skill to ctx-architecture
+
+**Rationale**: 'architecture' better describes surveying and evolving project
+structure across sessions
+
+**Consequence**: All cross-references updated across skills, docs, .context
+files, and settings
+
+---
+
+---
+
+## [2026-03-01-090124] Promote 6 private skills to bundled plugin skills; keep 7 project-local
+
+**Status**: Accepted
+
+**Context**: Reviewed all 13 _ctx-* private skills to determine which are
+universally useful for any ctx user vs specific to the ctx codebase or personal
+infra.
+
+**Decision**: Promote 6 private skills to bundled plugin skills; keep 7
+project-local
+
+**Rationale**: Promote if the skill benefits any ctx-powered project without
+project-specific hardcoding. Keep private if it references this repo's Go
+internals, personal infra, or language-specific tooling. Promote list: _ctx-spec
+(generic scaffolding), _ctx-brainstorm (design facilitation), _ctx-verify (claim
+verification), _ctx-skill-create (skill authoring), _ctx-link-check (doc link
+audit), _ctx-permission-sanitize (Claude Code permissions audit). Keep list:
+_ctx-audit (Go/ctx checks), _ctx-qa (Go Makefile), _ctx-backup (SMB infra),
+_ctx-release/_ctx-release-notes (ctx release workflow), _ctx-update-docs (ctx
+package mapping), _ctx-absorb (borderline, revisit later).
+
+**Consequence**: Six skills move from .claude/skills/ to
+internal/assets/claude/skills/ and become available to all ctx users via ctx
+init. Cross-references between skills need updating (e.g., /_ctx-brainstorm
+becomes /ctx-brainstorm). The seven remaining private skills stay project-local.
+
+---
+
+## [2026-02-26-100005] Agent autonomy and separation of concerns (consolidated)
+
+**Status**: Accepted
+
+**Consolidated from**: 3 decisions (2026-01-21 to 2026-01-28)
+
+- Removed AGENTS.md from project root. Consolidated on CLAUDE.md (auto-loaded) +
+  .context/AGENT_PLAYBOOK.md as the canonical agent instruction path. Projects
+  using ctx should not create AGENTS.md.
+- ~~Separate orchestrator directive from agent tasks~~ (superseded 2026-03-25:
+  IMPLEMENTATION_PLAN.md removed — TASKS.md is the single source of truth for
+  work items, AGENT_PLAYBOOK.md covers agent behavior).
+- No custom UI -- IDE is the interface. UI is a liability; IDEs already excel at
+  file browsing, search, markdown editing, and git integration. Focus CLI
+  efforts on good markdown output.
+
+---
+
diff --git a/.context/learnings/audit-lint-compliance.md b/.context/learnings/audit-lint-compliance.md
new file mode 100644
index 00000000..56ba0931
--- /dev/null
+++ b/.context/learnings/audit-lint-compliance.md
@@ -0,0 +1,175 @@
+# audit-lint-compliance
+
+## [2026-07-18-084406] Adding a new internal/ package must clear a fixed audit gauntlet — satisfy it up front
+
+**Context**: The internal/disclosure + internal/cli/disclosure packages tripped ~6 make audit rounds while building pd-m1/m2.
+
+**Lesson**: All mechanically enforced: doc.go per package (compliance), docstring Parameters/Returns floor on ALL funcs incl. unexported (lint-docstrings), string literals must live in config/* (magic-strings), exported API and unexported helpers in SEPARATE files (mixed-visibility), <=80-char lines, DescKey<->YAML bijection (both directions), no dead exports, and TestDocGoSubcommandDrift mis-parses a group doc.go bullet like '- internal/cli/x/cmd/y:' as subcommand claims (bulletRe backtracks on the slash).
+
+**Application**: For a new package, from the first file: one doc.go with a package comment (never a package comment in a regular file); split exported vs unexported functions across files; every literal as a config constant; full Parameters/Returns docstrings; and in a group doc.go use SPACED bullets ('- ctx x sub: ...') not slash-paths, or leave documented empty like kb/doc.go.
+
+---
+
+## [2026-07-15-141726] gosec G101 has two independent triggers: identifier-keyword match and value-entropy
+
+**Context**: Removed the blanket G101 path-exclusion for internal/config/embed/text/; gosec then flagged 3 lines. Two were identifier matches (DescKeyErrHubGenerateToken, ...AdminTokenRequired — 'token' substring in the name), but the third flagged only sourcemap.write-row out of 8 identical *WriteRow consts.
+
+**Lesson**: G101 matches on (a) the variable/const IDENTIFIER containing a credential keyword (token/pass/pwd/secret/cred/apiKey — case-insensitive substring), AND (b) a Shannon-entropy heuristic on the string VALUE. The single sourcemap hit was entropy, not name — which is why 7 sibling *WriteRow consts weren't flagged.
+
+**Application**: Fix each trigger at the source, never with #nosec or a path exclusion: for a name-match, rename the identifier off the keyword (keep the value/i18n-key/env-var intact so linkage/behavior are unchanged); for an entropy hit, change the value (e.g. write-row->append-row). Confirm empirically: renaming the identifier while keeping the value clears a name-match, proving it wasn't value-based.
+
+---
+
+## [2026-07-04-160749] Compliance tests are the style guide for new commands
+
+**Context**: Implementing ctx system statusline tripped eight architecture gates in sequence: magic strings/values must live in internal/config/, one visibility per file, cmd/ files restricted to cmd.go+run.go+doc.go with helpers in core/, types in types.go, the ctxRC schema mirror struct, the system doc.go subcommand registry, godoc Parameters/Returns structure, and 80-char lines
+
+**Lesson**: House conventions are enforced by go test (internal/compliance/ and sibling suites), not documented prose; the test suite is the authoritative style guide
+
+**Application**: Before scaffolding a new command, read internal/compliance/ and copy an existing cmd package's structure; run the full suite early rather than discovering gates by failure at commit time
+
+---
+
+## [2026-06-07-170002] internal/audit & compliance gates for new code (consolidated)
+
+**Consolidated from**: 6 entries (2026-03-15 to 2026-05-30)
+
+- New exported types must live in types.go: TestTypeFileConvention permits types
+  outside types.go only in pure-type files (defs+methods, no standalone funcs)
+  or exempt packages; a file mixing structs with standalone funcs fails. Put
+  type defs in a dedicated types.go from the start.
+- internal/assets/tpl is on the magic-strings exempt list, so template-path
+  literals are sanctioned THERE — but render data passed from non-exempt
+  callers must be a typed struct (tpl.ObsidianData{...}), never map[string]any
+  with literal keys, which trips the audit at the call site.
+- Full gate catalog for a new package/CLI command (none surfaced by `go
+  build`/`golangci-lint` — run `go test ./internal/audit/
+  ./internal/compliance/`): TestNoMixedVisibility (split unexported helpers into
+  _internal.go), TestNoMagicStrings/Values (named consts in
+  internal/config/warn/ for warn formats; named const for bare ints),
+  TestDocCommentStructure (Parameters/Returns on every helper, exported or not),
+  TestNoCmdPrintOutsideWrite (route output through internal/write//),
+  TestNoNakedErrors, TestTypeFileConvention, TestCmdDirPurity (no helpers in
+  cmd/ — use core//), TestNoLiteralMdExtension (file.ExtMarkdown),
+  TestDocGoSubcommandDrift (parent doc.go lists every subcommand),
+  TestDescKeyYAMLLinkage, TestNoLiteralWhitespace (token.NewlineCRLF/LF),
+  TestRegistryCount (bump on registry.yaml additions). staticcheck QF1012 vs
+  TestNoUncheckedFmtWrite: build with fmt.Sprintf then b.WriteString.
+- naked_errors audit flags every fmt.Errorf/errors.New outside internal/err/**
+  — call-site wrapping does NOT satisfy it. Error constructors live in
+  domain-scoped internal/err// pulling format strings from
+  internal/config// or desc.Text. Pattern: `var ErrX =
+  errors.New(cfgArea.ErrMsgX)` (sentinel); `func X(args, cause) error { return
+  fmt.Errorf(cfgArea.FormatX, …) }` (wrapper). Budget ~3 files/area for any
+  new error surface.
+- Pre-emptive constants are dead exports: TestNoDeadExports is
+  symbol-graph-strict — any exported const/var/func without an internal reader
+  fails. Land constants in the same commit (or strict precursor) as their
+  caller; never scaffold config ahead of consumers. Genuine future-use goes in a
+  TASKS.md line, not a config file.
+- Dead-code detection: packages can build+test green while unreachable — check
+  bootstrap registration, not build success (e.g. internal/cli/recall/ had
+  tests, never wired). Files created by `ctx init` with no agent/hook/skill
+  reader are dead on arrival. When touching legacy compat code, first ask if the
+  legacy path has real users; if not, delete rather than improve (MigrateKeyFile
+  had 5 callers, zero users).
+
+---
+
+## [2026-06-07-170010] Convention enforcement: mechanical gates over prose (consolidated)
+
+**Consolidated from**: 6 entries (2026-03-16 to 2026-04-14)
+
+- System-level brevity instructions outcompete context-injected conventions;
+  memory shifts probability (~40%→~70%) but doesn't create invariants. Invest
+  in linter/PreToolUse gates for mechanically-checkable conventions; reserve
+  behavioral nudges for judgment calls.
+- Force-loaded behavioral prose (AGENT_PLAYBOOK at ~14k tokens) gets skipped
+  when the user's first message is a concrete task; action-gating hooks
+  (qa-reminder, specs-nudge) are followed because they fire at the moment of
+  violation. More injected content = less attention per token. Prefer
+  action-gating hooks; reserve force-injection for hard rules + distilled
+  checklists.
+- Any docstring/comment/documentation-formatting task is convention-sensitive:
+  read CONVENTIONS.md (Documentation section) + LEARNINGS.md for known gaps
+  FIRST, and audit all functions in scope against the template, not just diffed
+  ones.
+- AST audit tests must default to scanning ALL documented functions (use
+  opt-outs not exported-only opt-ins) — TestDocCommentStructure missed
+  unexported helpers (84 violations fixed). And the stutter test
+  (TestNoStutteryFunctions) walks *ast.FuncDecl only, not GenDecl — stuttery
+  const/var/type names slip through until the audit is extended.
+- Every exemption map/allowlist in audit tests is a tempting agent shortcut: add
+  DO-NOT-widen guard comments to every exemption data structure (10 across 7
+  files) and review PRs for drive-by allowlist additions.
+
+---
+
+## [2026-06-07-170014] Linting, gosec & I/O chokepoints (consolidated)
+
+**Consolidated from**: 4 entries (2026-01-25 to 2026-04-03)
+
+- Full pre-commit gate, every time: (1) CGO_ENABLED=0 go build ./cmd/ctx, (2)
+  golangci-lint run, (3) CGO_ENABLED=0 go test. Own the codebase — fix
+  pre-existing lint issues you didn't introduce.
+- gosec permissions: 0o600 for files (incl. tests — G306 flags 0644 even in
+  test code), 0o750 for dirs (G301); G304 file-inclusion is safe to
+  //nolint:gosec in tests using t.TempDir(). Prefer renaming constants to avoid
+  G101 false positives (Tokens→Usage, Passed→OK) over nolint/nosec/path
+  exclusions, which break on file reorg.
+- Suppression anti-patterns: nolint:goconst normalizes magic strings (use config
+  consts); nolint:errcheck in tests teaches agents to spread the pattern to
+  production (use t.Fatal for setup, `defer func(){ _ = f.Close() }()` for
+  cleanup); golangci-lint v2 ignores inline nolint for some linters — use
+  config-level exclusions.rules for gosec, fix the code for errcheck. Use
+  cmd.Printf/Println in Cobra commands instead of fmt.Fprintf. `defer
+  os.Chdir(x)` fails errcheck — wrap in `defer func(){ _ = os.Chdir(x) }()`.
+  CI Go-version mismatch: install-mode goinstall.
+- Chokepoint migrations have cascading benefits: centralizing file I/O into
+  internal/io/ (already using config/fs consts) zeroed out TestNoRawPermissions
+  for free. Prioritize chokepoint migrations (io, exec, write, err) before
+  smaller dependent checks.
+
+---
+
+## [2026-03-31-005112] Convention audits must check cmd/ purity, not just types and docstrings
+
+**Context**: Placed needsSpec helper in cmd/root/run.go instead of
+core/entry/predicate.go. Missed it because the audit checklist only covered
+types and docstrings
+
+**Lesson**: cmd/ directories must contain only Cmd() and Run*() — all helper
+functions, unexported logic, and types belong in core/. Added TestCmdDirPurity
+compliance test to enforce this mechanically
+
+**Application**: The compliance test now catches this automatically. 28
+pre-existing violations grandfathered in the allowlist
+
+---
+
+## [2026-03-31-005110] JSON Schema default fields cause linter errors with some validators
+
+**Context**: ctxrc.schema.json had default: values on 16 fields that triggered
+incompatible type errors in the user's linter
+
+**Lesson**: Move default values into the description string instead of using the
+default keyword — Go rc.*() accessors handle the actual defaults
+
+**Application**: When adding new .ctxrc fields, document defaults in the
+description, never use default: in the schema
+
+---
+
+## [2026-03-30-003707] lint-docstrings.sh greedy sed hid all return-type violations
+
+**Context**: sed 's/.*) //' consumed return type parens, leaving { — functions
+with return types were invisible to the script for months
+
+**Lesson**: Greedy regex in shell scripts can silently suppress entire
+categories of lint violations — test with edge cases, not just happy paths
+
+**Application**: When writing sed-based lint checks, test with multi-paren
+signatures (func Foo() (string, error))
+
+---
+
diff --git a/.context/learnings/cli-command-design.md b/.context/learnings/cli-command-design.md
new file mode 100644
index 00000000..adee0a27
--- /dev/null
+++ b/.context/learnings/cli-command-design.md
@@ -0,0 +1,65 @@
+# cli-command-design
+
+## [2026-07-04-212434] Probing whether a ctx subcommand exists needs bare invocation, not --help
+
+**Context**: During the PR #128 review I checked ctx command existence with 'ctx  --help' and got false positives — cobra short-circuits --help to exit 0 even for an unknown subcommand, so 'ctx system stats --help' appeared to succeed for a command that does not exist. Separately, probing with a real mutating verb ('ctx task complete 3') actually completed a live task in TASKS.md, which had to be reverted.
+
+**Lesson**: To detect CLI command drift, invoke the BARE subcommand and check the real exit code (unknown => exit 1); --help lies because it is a persistent flag cobra handles before dispatch. Never probe with a mutating verb against the live project — ctx subcommands write to .context/. Also: 'ctx system ' emits the 'relay VERBATIM' version-skew NudgeBox and exits 1, so a wrapper that treats non-empty stdout as success will render that notice as a normal result.
+
+**Application**: When auditing whether an editor/wrapper's ctx invocations are valid, probe each bare form for its exit code (against a throwaway repo, or use read-only forms only); treat any wrapper whose 'non-empty output => success' logic ignores exit codes as buggy.
+
+---
+
+## [2026-05-28-201300] cobra's legacyArgs lets unknown subcommands silently succeed on non-root groups
+
+**Context**: Every prompt of this session injected 52 lines of `ctx system` help
+text into agent context, labeled "hook success." Investigation traced it to the
+0.8.1 plugin's `hooks.json` wiring `ctx system check-anchor-drift` as the first
+UserPromptSubmit hook — a command the 0.8.1 binary no longer has (the command
+was deleted by the cwd-anchored migration in `fc7db228`, but the plugin's hook
+config wasn't updated). The harness reported "hook success" because cobra exits
+0 on the unknown subcommand.
+
+**Lesson**: cobra's `legacyArgs` only raises "unknown command" for the **root**
+command (`!cmd.HasParent()`); any non-root group (built with `parent.Cmd`)
+treats an unknown subcommand as non-error: it falls through to `Help()` and
+returns nil → exit 0. In a UserPromptSubmit hook this is **invisible** — the
+harness logs "hook success" and injects the whole help text into agent context
+every prompt. The 0.8.1 plugin's stale wiring of the retired
+`check-anchor-drift` caused exactly this for the entire session.
+
+**Application**: Non-root cobra groups must have an explicit unknown-subcommand
+guard. Two routes: (a) `Args: cobra.NoArgs` so unknown subcommands error loud
+(non-zero exit + "unknown command" stderr); (b) a `RunE` that emits a **verbatim
+relay** — which is what actually reaches the user in a UserPromptSubmit hook
+context where a non-zero exit alone is invisible. Tracked under Phase CLI-FIX as
+the verbatim-relay guard on `ctx system`.
+
+---
+
+## [2026-05-20-214821] /ctx-plan is named after its input, not its output
+
+**Context**: Agent (and apparently other agents in prior sessions per user
+observation) repeatedly inverted the canonical chain, treating /ctx-spec as the
+entry point and /ctx-plan as a post-spec step. The skill description starts
+'stress-test a plan' (implying user brings a plan IN) while line 44 of the body
+says 'the deliverable is a debated brief, not a task list' (the OUTPUT is a
+brief, not a plan).
+
+**Lesson**: Skill names that reference their INPUT bias the agent toward the
+wrong canonical position. The /ctx-plan skill takes a plan and produces a brief;
+the natural mental model when scanning the name is 'plan = output', which makes
+the agent place it AFTER spec instead of before. Also: /ctx-spec's 'When to Use'
+section listed /ctx-brainstorm as a predecessor but never /ctx-plan, so an agent
+skimming the top of the skill never learned the full chain.
+
+**Application**: Made the canonical chain explicit at the top of both /ctx-plan
+and /ctx-spec skills (Canonical Chain block with the brainstorm → plan →
+spec → implement diagram) and in AGENT_PLAYBOOK_GATE Planning Work section.
+/ctx-spec When-to-Use now lists /ctx-plan as a predecessor; When-NOT-to-Use says
+'when the bet is contested but not yet stress-tested, use /ctx-plan first'.
+/ctx-plan description now ends with '; produces a debated brief at
+.context/briefs/-.md that /ctx-spec --brief consumes'.
+
+---
+
diff --git a/.context/learnings/context-model-and-state.md b/.context/learnings/context-model-and-state.md
new file mode 100644
index 00000000..ec0f8011
--- /dev/null
+++ b/.context/learnings/context-model-and-state.md
@@ -0,0 +1,163 @@
+# context-model-and-state
+
+## [2026-06-07-162840] Pin an on-disk contract before splitting work across parallel agents
+
+**Context**: The ctx-dream skill (proposal writer) and the ctx dream CLI
+(proposal reader) were built by parallel tracks; each independently invented the
+proposals layout — one per-file proposals/.json, the other a single
+proposals.json array — requiring reconciliation at integration.
+
+**Lesson**: When parallel agents share a serialized artifact (a file path +
+schema), the exact on-disk shape must be fixed in BOTH prompts up front. 'Each
+track invents it' guarantees a mismatch that only surfaces at integration.
+
+**Application**: Before fanning out producer/consumer work to parallel agents,
+write the precise file path + JSON schema into both agents' specs as a fixed
+contract, and reconcile/verify it first thing at integration.
+
+---
+
+## [2026-06-07-170016] State, tombstones, logs & filesystem hygiene (consolidated)
+
+**Consolidated from**: 6 entries (2026-02-11 to 2026-03-06)
+
+- Permission drift is distinct from code drift — settings.local.json is
+  gitignored so no review catches stale entries; it accumulates session debris
+  (run /sanitize-permissions + /ctx-drift). Skill() permissions don't support
+  name-prefix globs (list each); wildcard trusted binaries (Bash(ctx:*),
+  Bash(make:*)) but keep git granular (never Bash(git:*)).
+- Gitignored directories are invisible to git status — stale artifacts persist
+  indefinitely (periodically ls them). Add editor artifacts (*.swp,*.swo,*~) to
+  .gitignore from day one. Gitignore entries for sensitive paths are security
+  controls, not documentation — never remove during cleanup.
+- The state directory accumulates write-only session tombstones and grows
+  unbounded without auto-prune (234 files found); autoPrune(7) now runs once per
+  session at startup via context-load-gate (manual `ctx system prune` still
+  available).
+- A session-scoped tombstone must include the session ID in its filename, else
+  it suppresses hooks across ALL concurrent and future sessions (memory-drift
+  fixed; backup-reminded, ceremony-reminded, check-knowledge, journal-reminded,
+  version-checked, ctx-wrapped-up still carry this bug). Use the UUID pattern so
+  prune can clean them.
+- New log sinks must follow the established rotation pattern (size-based, single
+  previous generation): eventlog rotated at 1MB but logMessage() in state.go was
+  append-only with no size check.
+- If a directory is recreated (auto-prune), an SSH shell holding the old inode
+  won't see new files (ls returns "no such file" though cat with the full path
+  works elsewhere); after `ctx system prune` or any state recreation, SSH
+  sessions need cd-. or re-login.
+
+---
+
+## [2026-06-01-174927] Guard managed blocks before regenerating; don't trust the span to be machine-owned
+
+**Context**: ctx learning add silently deleted entry bodies that lived between
+INDEX:START/END markers: index.Update replaced the whole marker span with a
+regenerated table, and ParseHeaders scanning the full file made the result look
+complete, hiding the loss.
+
+**Lesson**: Code that 'replaces the managed block' (index regen, KB managed
+blocks, moc.go) assumes the span between its markers is disposable and
+machine-owned. That assumption breaks the moment user content drifts inside the
+markers, and the regenerated output looks correct so the loss is invisible. The
+fix is a precondition guard that refuses to mutate when regeneration would lose
+data — not smarter parsing of the trapped content.
+
+**Application**: Before any 'replace between markers' write, validate the span:
+refuse on entry/content found where only generated output belongs, and on
+malformed/duplicated/out-of-order markers. Fail loud and leave the file
+byte-identical rather than regenerate. Run the guard at the read-before-mutate
+choke point so nothing is written on refusal.
+
+---
+
+## [2026-05-20-214830] Handover filenames are archaeology; parse by generated-at, not filename
+
+**Context**: User observed three coexisting handover filename shapes:
+.context/HANDOVER-2026-04-22.md (pre-skill root file),
+.context/handovers/YYYY-MM-DD-HHMMSS-slug.md (skill-era pre-CLI),
+.context/handovers/-slug.md (current CLI). User asked whether
+this was a regression or a skill-interpretation problem.
+
+**Lesson**: Neither. The .context/HANDOVER-* root file predates the handovers/
+directory contract entirely (the body even said 'delete this file after
+reading'). The YYYY-MM-DD-HHMMSS shape was an earlier skill iteration writing
+free-form before ctx handover write existed (commit 60543e46, 2026-05-17,
+introduced the CLI as sole writer per the anti-pattern note in /ctx-handover
+SKILL.md). The current parser at internal/write/handover/parse.go:75-107 keys on
+the 'generated-at' YAML frontmatter, not the filename — so legacy shapes still
+sort correctly via LatestHandoverCursor. Only files without frontmatter (the
+root April file) are invisible.
+
+**Application**: When unifying filename shapes across history, use git mv to
+preserve rename detection. Derive the canonical timestamp from the file's own
+generated-at frontmatter rather than from the filename — that's the source of
+truth the parser uses anyway. If a handover predates frontmatter entirely (rare,
+pre-skill era), it's safe to delete because the parser never read it.
+
+---
+
+## [2026-04-14-010134] Raft-lite trade-off is the load-bearing choice in internal/hub
+
+**Context**: Discovered while writing thorough doc.go for internal/hub. The
+package embeds HashiCorp Raft for leader election only; data replication is
+sequence-based gRPC sync over the append-only JSONL store.
+
+**Lesson**: A leader crash window between accept and replicate can lose the most
+recent write. Append-only storage plus idempotent clients make this acceptable;
+full Raft log replication would not be needed and would not be simpler.
+
+**Application**: Any future make hub stronger proposal must engage with this
+trade-off explicitly. Do not abandon Raft-lite accidentally by introducing
+log-replicated state; that would invalidate the simplicity argument.
+
+---
+
+## [2026-04-13-153618] rc.ContextDir() is the single source of truth — fix the resolver, not callers
+
+**Context**: When ctx init failed with a boundary error, my first instinct was
+to have init bypass rc.ContextDir() and use filepath.Join(cwd, dir.Context)
+directly. Volkan shut that down: rc.ContextDir() encodes invariants (team
+shares, symlinks, network mounts, .ctxrc overrides) that individual commands
+cannot reason about.
+
+**Lesson**: Resolution chains with multiple fallbacks are contracts. If one
+command bypasses the chain, it silently diverges from every other command's
+notion of 'the context directory.' When a resolver produces a wrong answer for a
+specific case, fix the resolver — don't let callers opt out.
+
+**Application**: Any time you see rc.ContextDir(), rc.RC(), or similar central
+resolvers producing a bad result, the fix belongs in the resolver itself (or in
+its input data like .ctxrc). Caller-side bypasses create drift.
+
+---
+
+## [2026-04-09-001323] Pad index shifting is a real UX bug in batch operations
+
+**Context**: ctx pad rm 10; rm 11; rm 12 deleted wrong entries because indices
+shifted after each deletion
+
+**Lesson**: Any ID-based system where users chain operations needs stable IDs.
+Look-then-act is safe for single ops; look-then-batch-act breaks with shifting
+indices
+
+**Application**: Both pad and remind now use stable IDs with batch delete and
+range support. Apply same pattern to any future numbered-list subsystem
+
+---
+
+## [2026-02-24-032945] CLI tools don't benefit from in-memory caching of context files
+
+**Context**: Discussed whether ctx should read and cache LEARNINGS.md,
+DECISIONS.md etc. in memory
+
+**Lesson**: ctx is a short-lived CLI process, not a daemon. Context files are
+tiny (few KB), sub-millisecond to read. Cache invalidation complexity exceeds
+the read cost. Caching only makes sense if ctx becomes a long-lived process (MCP
+server, watch daemon).
+
+**Application**: Don't add caching layers to ctx's file reads. If an MCP server
+mode is ever added, revisit then.
+
+---
+
diff --git a/.context/learnings/docs-and-templates.md b/.context/learnings/docs-and-templates.md
new file mode 100644
index 00000000..766483cc
--- /dev/null
+++ b/.context/learnings/docs-and-templates.md
@@ -0,0 +1,170 @@
+# docs-and-templates
+
+## [2026-07-16-120002] site/ is tracked build output — rebuild and commit it with docs changes (consolidated)
+
+**Consolidated from**: 2 entries (2026-02-27, 2026-06-07)
+
+- `site/` is intentionally git-tracked (zensical build via `make site`); there is NO GitHub Pages workflow or CI build step — nothing rebuilds it downstream.
+- Any change under `docs/` requires regenerating `site/` (`make site`) and committing the rebuilt output in the SAME commit; otherwise it silently drifts and surfaces later as a large modified set (observed: a surprise 189-file `site/` drift mid-session).
+- Always `git add site/` with doc commits; never gitignore `site/` or treat it as ephemeral noise.
+
+---
+
+## [2026-07-16-120003] Contributor PRs: check for removal-reintroduction and schedule convention follow-up (consolidated)
+
+**Consolidated from**: 2 entries (2026-03-15, 2026-04-01)
+
+- PRs based on older code reintroduce intentionally-removed features (PR #45 brought back prompt templates, PROMPT.md, IMPLEMENTATION_PLAN.md removed in March). Cross-reference DECISIONS.md before accepting additive PR content — do not assume it is purely additive.
+- Merging with known MECHANICAL gaps is fine if the follow-up is immediate (PR #42 left ~12 inline strings, no embed_test coverage, substring matching in containsOverlap). Track in `ideas/pr{N}-review-status.md` during review, merge when the architecture is sound, and fix the convention gaps in a same-day follow-up commit.
+
+---
+
+## [2026-07-16-120004] Drift/detection scripts need existence checks and exclusion lists for intentional & illustrative uses (consolidated)
+
+**Consolidated from**: 3 entries (2026-02-27, 2026-03-23, 2026-03-24)
+
+- Path-reference drift detection must `os.Stat` the top-level directory of a path before flagging it — bare filenames and paths under non-existent dirs are almost always documentation examples (fixed 23 false positives from prose in CONVENTIONS.md/ARCHITECTURE.md like loader.go, session/run.go, sync.Once).
+- Shell grep on constant VALUES cannot distinguish constant TYPES (cobra `Use*` syntax strings vs `DescKey*` YAML lookup keys); type-aware checks need AST analysis, not grep (captured in specs/ast-audit-tests.md; the lint-drift fix shipped in v0.8.0).
+- Convention-enforcement detection scripts (e.g. typography) need proactive exclusion patterns for files where the flagged pattern is intentional data, not prose: `*_test.go` and constant-definition files (e.g. config/token/delim.go) are the common false-positive sources.
+
+---
+
+## [2026-06-07-170005] TypeScript/integration test surfaces & exclusion rot (consolidated)
+
+**Consolidated from**: 4 entries (2026-05-11 to 2026-05-22)
+
+- Removing/renaming any cross-language contract (env channel, feature flag) is a
+  FOUR-surface cleanup, not three: (1) Go build+lint+test, (2) audit/compliance
+  tests, (3) asset templates (CLAUDE.md, AGENT_PLAYBOOK, hooks.json), (4)
+  TypeScript-typed integrations (opencode plugin, vscode extension). The TS
+  surface is invisible to `go test ./...` by design; tsc --noEmit only runs in
+  CI unless invoked from tools/typecheck/opencode/ or editors/vscode/. Want: a
+  `make typecheck` target wrapping both, in pre-commit + release checklist.
+- tsc resolves node_modules by walking up from each SOURCE file's location, not
+  the tsconfig's location. For a cross-tree setup (tsconfig in dir A, include
+  points at dir B), add explicit baseUrl + paths (+ typeRoots) to the tsconfig
+  so node_modules can live with the tooling.
+- vitest's vi.mock() does NOT preserve Node's async-deferral guarantees: a
+  mocked execFile (or fs.readFile, dns.lookup, http.request) can fire its
+  callback synchronously, TDZ-trapping a closure that's provably safe by Node's
+  contract. When a linter suggests tightening let→const on a var captured
+  through an async callback, verify under the test runner; the safe form is
+  `let` + an eslint-disable naming the mock constraint.
+- A test suite excluded from BOTH typecheck and execution rots compounding:
+  re-enable cost = sum of ALL drift since last green (named 2 breakages, found
+  18 more on first run), not just the named bug. expect.anything()/expect.any()
+  pass typecheck so only execution catches the drift. When adding any tooling
+  exclude (tsconfig glob, vitest ignore, pytest --ignore), file an immediate
+  follow-up whose acceptance criterion is removal; budget 5–20× the named
+  scope on re-enable.
+
+---
+
+## [2026-06-07-170007] Documentation, template & asset drift (consolidated)
+
+**Consolidated from**: 6 entries (2026-02-24 to 2026-04-01)
+
+- Exhaustive lists/counts in architecture docs (package lists, command tables,
+  skill counts) drift silently because nobody re-counts (23 listed vs 31
+  actual). Add `` markers; run
+  /ctx-architecture after adding packages/commands (/ctx-drift catches stale
+  paths but not missing entries).
+- Template changes are invisible to existing projects until `ctx init --force`;
+  non-destructive init never re-syncs. checkTemplateHeaders was added to `ctx
+  drift`.
+- Any content duplicated in two locations without a sync mechanism drifts
+  silently (Copilot CLI skills as condensed ctx skills; assets/why/ vs docs/).
+  Wire freshness checks as build PREREQUISITES, not optional audit steps (make
+  sync-copilot-skills, make sync-why must be build deps).
+- Machine-generated CLAUDE.md content (GitNexus injected 121 lines / 61%)
+  consumes per-turn budget without proportional value. Auto-generated content
+  belongs in on-demand skills; prefer a one-line pointer over inline content.
+  Audit CLAUDE.md periodically.
+- CLI reference docs outpace implementation (ctx remind had no CLI, recall sync
+  no Cobra wiring) — verify with `ctx  --help` before releasing docs.
+  Agent style-violation sweeps are unreliable (8 found vs 48+ actual); follow
+  with targeted grep + manual classification. Documentation audits must compare
+  against known-good examples for the COMPLETE standard, not mere presence. New
+  audit concerns (e.g. dead links) belong in an existing audit skill's checklist
+  before becoming standalone.
+
+---
+
+## [2026-06-07-170008] User-facing text & magic-string discipline (consolidated)
+
+**Consolidated from**: 4 entries (2026-03-14 to 2026-04-04)
+
+- Any string containing English words alongside format directives ("%d entries
+  checked") is user-facing text belonging in YAML assets — the format-verb
+  (and URL-scheme, HTML-entity, err/) exemptions were removed from
+  TestNoMagicStrings.
+- Any string reaching the user, including stderr warnings, routes through
+  assets.TextDesc() for i18n readiness; create text.yaml entries and asset keys
+  first.
+- Magic-string cleanup is fractal: each fix puts adjacent code under scrutiny (4
+  Fprintf calls → over-tokenized formats, magic hex perms, TOML tokens,
+  missing docstrings). Budget 2–3× the initial estimate; commit per layer.
+- Naming a constant _alt and hardcoding one non-English language as a built-in
+  default is implicit language favoritism that doesn't scale (alt_2? alt_3?).
+  Use configurable lists from the start; default to a single canonical value,
+  all extensions user-configured equally.
+
+---
+
+## [2026-04-14-010134] Constitution forbids context window as a deferral excuse
+
+**Context**: Mid-session, agent proposed pacing through doc.go rewrites with the
+reasoning that context budget was tight.
+
+**Lesson**: The CONSTITUTION explicitly lists 'We are running out of context
+window' as a forbidden deferral phrase under No Excuse Generation. The rule is
+real and applies to agent self-pacing, not just user-facing answers.
+
+**Application**: When tempted to scope down because context is tight, re-read
+the constitution. The right move is to do the work end-to-end, not to ask the
+user which slice to skip.
+
+---
+
+## [2026-04-14-010134] docs/cli/system.md and embed/cmd/system.go diverged on bootstrap promotion intent
+
+**Context**: Header comment in internal/config/embed/cmd/system.go claimed
+bootstrap was promoted to top-level; the bootstrap.go registration never
+actually promoted it. Two contradictory sources of truth coexisted silently.
+
+**Lesson**: Header-comment claims about command-tree structure are unaudited;
+they can drift from registrations without any test failing. Trust the code, not
+the comment.
+
+**Application**: When evaluating any package_name namespace cleanup type claim
+about command structure, verify against the actual cobra registration in
+internal/bootstrap/group.go before acting.
+
+---
+
+## [2026-02-28-184758] ctx pad import, ctx pad export, and ctx system resources make three hack scripts redundant
+
+**Context**: Audited hack/ scripts against ctx CLI surface
+
+**Lesson**: As ctx CLI grew, several hack scripts became wrappers around
+built-in commands (pad-import.sh -> ctx pad import, pad-export-blobs.sh -> ctx
+pad export, resource-watch.sh -> watch -n5 ctx system resources)
+
+**Application**: Periodically audit hack/ for scripts that ctx has absorbed
+
+---
+
+## [2026-02-28-184647] Getting-started docs assumed Claude Code as the only agent
+
+**Context**: The installation section opened with 'A full ctx installation has
+two parts' — binary + Claude Code plugin — leaving non-Claude-Code users
+without a clear path
+
+**Lesson**: Installation docs should lead with the universal requirement (the
+binary) and present agent-specific integration as conditional
+
+**Application**: When writing docs for multi-tool projects, frame the common
+denominator first, then branch by tool
+
+---
+
diff --git a/.context/learnings/editorial-and-product-signals.md b/.context/learnings/editorial-and-product-signals.md
new file mode 100644
index 00000000..259a9a2f
--- /dev/null
+++ b/.context/learnings/editorial-and-product-signals.md
@@ -0,0 +1,141 @@
+# editorial-and-product-signals
+
+## [2026-06-07-170006] Editorial KB pipeline: design epistemology (consolidated)
+
+**Consolidated from**: 5 entries (all 2026-05-10)
+
+- An ongoing user paying concrete workaround tax (disabled skills, hand-typed
+  closeouts, colliding root constitution files) is the strongest validation
+  evidence — beats user research, N=2 discussion, "seems useful." Use the
+  workaround details as the inverse-spec; ship the shape they hand-rolled and
+  use their project as the regression corpus.
+- When lifting from a battle-tested external design, lift the renames and
+  disambiguation moves alongside the features: intentional renames encode
+  resolved conflicts (KB-RULES.md not CONSTITUTION.md; domain-decisions.md not
+  DECISIONS.md). Treating them as cosmetic re-litigates the underlying fight.
+- KB epistemology: a knowledge base has no "decide" moment — only
+  evidence-capture events with confidence bands (>0.9 = decided by contract).
+  Even NL assertions ("anchor on this") are evidence-capture, not
+  decision-capture. So a parallel /ctx-kb-decide skill is the wrong shape; the
+  pipeline-only-writer model is ontologically correct. General check: "I chose
+  between alternatives" vs "I learned about the world."
+- Recursive composability eliminates feature classes: a KB of KBs is a KB
+  (source-map kind: kb + the standard ingest pipeline covers federation; no v1
+  schema lockout). Ask whether the standard pipeline pointed at its own output
+  covers a "thing-of-things" before designing a new mechanism.
+- The LLM is the migration tool: every category of being-wrong about a schema
+  (ID renumbering, taxonomy reshuffle, band remapping, path renames) is cheap
+  because LLM cleanup absorbs the migration. Commit to the readable, opinionated
+  v1 schema instead of hedging with abstract types; surface dirty state via
+  doctor advisories so the agent has a work surface.
+
+---
+
+## [2026-05-28-215214] ctx kb: single topic-enumeration site; life-stage count is consumer-side
+
+**Context**: kb reindex blanked the CTX:KB:TOPICS block for grouped kbs
+(things-wtf-dr regrouped 49 topics into folders); the task speculated a sibling
+life-stage topic-count glob was also affected.
+
+**Lesson**: reindex.ListTopics (internal/cli/kb/core/reindex/topic.go) is the
+ONLY topic enumeration/count in ctx, and CTX:KB:TOPICS is the only managed
+block. The life-stage concept in ctx is the ingest/closeout frontmatter field,
+unrelated to topics. Any per-life-stage topic count lives in the consumer kb,
+which ctx neither generates nor owns.
+
+**Application**: Localize nested-topic fixes to ListTopics; treat
+per-group/per-life-stage topic counts as consumer territory (same recurse +
+exclude-group-landing pattern, fixed in their repo).
+
+---
+
+## [2026-05-17-200000] Creator confusion is the strongest doc-quality signal — louder than any user signal
+
+**Context**: In this session the project author asked *"why
+external sources only? I can ground on a repo, a MCP query, a
+markdown I dropped into ./inbox — are they also considered
+'external'. Or is there a nomenclature confusion here?"* — and
+explicitly noted *"it is confusing to the very creator of this
+pipeline. -- and that's not a good sign."* Investigation
+confirmed the input contract accepted in-tree paths and MCP
+resources all along, but the SKILL.md ledes, the CLI docs table
+row, and the recipe all framed ground as "external" — which
+the creator's own mental model couldn't reconcile with the
+contract.
+
+**Lesson**: A normal-user reading-confusion signal is "I don't
+understand this." A creator reading-confusion signal is "this
+contradicts what I built." The second is louder by an order
+of magnitude — the creator has the full internal model and a
+strong prior on what the system should say. If they trip over
+the words, the words are wrong, full stop. Don't defend the
+existing framing; don't explain what was meant. Rewrite to
+match what the contract actually does. The creator was a
+control instrument; if even that instrument deflected, the
+docs are mis-anchoring everyone.
+
+**Application**: When the project's own creator asks a
+"do we even need X?" or "wait, isn't X actually doing Y?"
+question, treat it as a doc-bug report, not an architecture
+question. Investigate the literal contract (input/output
+shapes, code-level reality) before debating semantics. If the
+contract is correct but the docs misframe it, the action is
+"rewrite the framing across every doc surface that touches
+it" — skills, recipes, CLI tables, anything user-facing.
+Concrete instance handled this session: dropped "external"
+from ctx-kb-ground's prose, description, pass-mode value,
+recipe Step 4, and CLI table row across all three skill trees.
+
+---
+
+## [2026-03-05-023941] Blog post editorial feedback is higher-leverage than drafting
+
+**Context**: Draft of Agent Memory Is Infrastructure was publication-quality on
+first pass; user editorial feedback (structural emphasis, rhetorical sharpening,
+amnesia/archaeology bridge) elevated it significantly more than initial
+generation
+
+**Lesson**: For narrative content, the first draft captures the argument; the
+editorial pass captures the voice. Both are necessary but the editorial pass has
+disproportionate impact on quality.
+
+**Application**: For future blog posts, invest more in the editorial cycle
+(structural feedback then targeted refinements) rather than trying to nail voice
+on first generation.
+
+---
+
+## [2026-02-26-100010] ctx add and decision recording (consolidated)
+
+**Consolidated from**: 4 entries (2026-01-27 to 2026-02-14)
+
+- `ctx add learning` requires `--context`, `--lesson`, `--application` flags.
+  `ctx add decision` requires `--context`, `--rationale`, `--consequence`. A
+  bare string only sets the title and the command will fail without required
+  flags.
+- Structured entries with Context/Lesson/Application are more useful than
+  one-liners. Agents are guided via AGENT_PLAYBOOK.md.
+- Always complete decision record sections — placeholder text like "[Add
+  context here]" is a code smell. Decisions without rationale lose their value
+  over time.
+- Slash commands using `!` bash syntax require matching permissions in
+  settings.local.json. When adding new /ctx-* commands, ensure ctx init
+  pre-seeds the required `Bash(ctx :*)` permissions.
+
+---
+
+## [2026-01-28-051426] IDE is already the UI
+
+**Context**: Considering whether to build custom UI for .context/ files
+
+**Lesson**: Discovery, search, and editing of .context/ markdown files works
+better in VS Code/IDE than any custom UI we'd build. Full-text search,
+git integration, extensions - all free.
+
+**Application**: Don't reinvent the editor. Let users use their preferred IDE.
+
+---
+
+
+*Module-specific, niche, and historical learnings:
+[learnings-reference.md](learnings-reference.md)*
diff --git a/.context/learnings/environment-and-platform.md b/.context/learnings/environment-and-platform.md
new file mode 100644
index 00000000..55c872d1
--- /dev/null
+++ b/.context/learnings/environment-and-platform.md
@@ -0,0 +1,92 @@
+# environment-and-platform
+
+## [2026-07-04-162854] GitNexus prunes registry entries whose repo paths don't resolve in-container
+
+**Context**: Folding the Docker gitnexus wrapper into ctx: indexing ctx silently dropped os from the global registry, and a plain 'gitnexus list' passthrough dropped it again after it was restored. Both 'gitnexus index' and 'gitnexus list' rewrite registry.json minus any entry whose path they cannot stat — and inside a container an unmounted repo is indistinguishable from a deleted one.
+
+**Lesson**: Any registry-touching gitnexus invocation run in Docker must mount EVERY registered repo at its real host path, not just the current one; otherwise it persists a pruned registry. A bash footgun compounded it: a '[ ... ] && cmd' filter as the last command of a while-loop body returns 1 when the filter fails on the final line, and set -e kills the script — use an if-block inside loops under set -e.
+
+**Application**: hack/gitnexus-docker.sh now has registry_mounts() (mount all registered repos, exclude the separately-mounted current one) wired into the index-register and passthrough branches; mcp already did this. The os and orchestrator copies of the wrapper still carry the prune bug — backport before running their index/list targets.
+
+---
+
+## [2026-06-07-170017] Host-pressure alerting: use derivatives, not levels (consolidated)
+
+**Consolidated from**: 2 entries (2026-04-13 to 2026-05-28)
+
+- Swap occupancy is NOT memory pressure: macOS/Windows swap proactively and
+  occupancy is a sticky high-water mark that doesn't recede when pressure ends,
+  so any alert keyed on SwapUsed/SwapTotal ≥ X% false-positives at session
+  start (e.g. after hibernation). Key on OS-native pressure derivatives instead:
+  macOS kern.memorystatus_vm_pressure_level (1/2/4 → OK/Warning/Danger), Linux
+  PSI /proc/pressure/memory some.avg10/full.avg10; fall back to swap-out RATE
+  gated on low available memory, never occupancy.
+- Load average measures a queue (runnable + uninterruptible-sleep), not CPU
+  utilization — high load with low CPU% means many short-lived/I/O-bound
+  processes (e.g. go test spawning hundreds of binaries). For automated alerts
+  prefer the 5-minute average over the reactive 1-minute, which fires on normal
+  build/test activity.
+
+---
+
+## [2026-05-31-094649] macOS GUI apps inherit a minimal PATH; augment it to find a user-installed CLI
+
+**Context**: A bundled Tauri app launched via Finder/launchd gets a minimal PATH (/usr/bin:/bin:...), so /usr/local/bin/ctx is not found even though it resolves in a terminal-launched dev run.
+
+**Lesson**: Do not rely on inherited PATH when spawning user-installed CLIs from a desktop GUI.
+
+**Application**: ctx_adapter prepends /usr/local/bin:/opt/homebrew/bin to PATH on every std::process::Command invocation.
+
+---
+
+## [2026-05-31-094649] Tauri 2 requires rustc >= 1.88; bump the toolchain before cargo check
+
+**Context**: cargo check for the ctx-desktop Tauri app failed: darling, serde_with, time and plist transitive deps require rustc 1.88, but the local toolchain was 1.87.
+
+**Lesson**: Tauri 2's dependency tree tracks recent rustc releases; the pinned-stable assumption breaks builds.
+
+**Application**: Run rustup update stable before building a Tauri 2 app; this project moved 1.87 -> 1.96.
+
+---
+
+## [2026-05-20-214839] macOS /var symlink trips path-equality; use EvalSymlinks with parent-resolution fallback
+
+**Context**: TestRunInit_EnvCwdMatch_Succeeds in
+internal/cli/initialize/init_test.go failed on first run despite a deliberate
+setup where the env path and cwd candidate matched. Diagnosis: t.TempDir()
+returns paths like /var/folders/..., os.Getwd() after t.Chdir() returns the
+canonical /private/var/folders/... (because macOS's /var is a symlink to
+/private/var). filepath.Clean preserves the symlink form; equality fails.
+
+**Lesson**: filepath.Clean alone is insufficient for path equality on macOS (and
+other systems with symlinked top-level dirs). filepath.EvalSymlinks resolves the
+symlinks but fails when the target path does not yet exist — common case for
+/Users/volkan/Desktop/WORKSPACE/ctx/.context BEFORE ctx init runs. The right
+pattern is a layered fallback: try EvalSymlinks(full), then EvalSymlinks(parent)
++ rejoin basename, then filepath.Clean as last resort.
+
+**Application**: Encapsulated as
+internal/cli/initialize/core/envmatch/{envmatch.go,internal.go}. The Same(a, b)
+public function calls resolve() on each side; resolve() tries EvalSymlinks on
+the full path, falls back to EvalSymlinks on the parent (rejoining the
+basename), and falls through to filepath.Clean if both fail. Reusable for any
+future env-vs-cwd-style equality check. The package is per-feature
+(core/envmatch/) per the cmd/core/ purity rule enforced by
+internal/compliance/TestCmdDirPurity.
+
+---
+
+## [2026-02-26-100006] PATH and binary handling (consolidated)
+
+**Consolidated from**: 3 entries (2026-01-21 to 2026-02-17)
+
+- Always use `ctx` from PATH, never `./dist/ctx-linux-arm64` or `go run
+  ./cmd/ctx`. Check `which ctx` if unsure.
+- Hooks must use PATH, not hardcoded paths. `ctx init` checks if ctx is in PATH
+  before proceeding. Tests can skip with `CTX_SKIP_PATH_CHECK=1`.
+- Agent must never place binaries in any bin directory (not via cp, mv, or go
+  install). Build with `make build`, then ask the user to run the privileged
+  install step. Hooks in block-dangerous-commands.sh enforce this.
+
+---
+
diff --git a/.context/learnings/git-and-signing.md b/.context/learnings/git-and-signing.md
new file mode 100644
index 00000000..bd9667eb
--- /dev/null
+++ b/.context/learnings/git-and-signing.md
@@ -0,0 +1,67 @@
+# git-and-signing
+
+## [2026-07-15-141749] Carving one feature out of a working tree that also holds an unrelated in-progress feature needs hunk-level staging + an isolation-gate
+
+**Context**: The tree mixed a computed-index feature, a stale local copy of a contributor's hub PR (#134), and a gosec-hardening effort — interleaved even within shared files (commands.yaml, errors.yaml, .context/*). Needed to commit ONE of them cleanly. Interactive 'git add -p' isn't available headless.
+
+**Lesson**: Whole-file 'git add' bundles the other feature; naive per-file staging silently drops shared-file edits and forgets coupled files. The reliable method: (1) safety-checkpoint the whole tree to a throwaway branch first; (2) stage pure-mine files + apply only your hunks to mixed files via 'git diff HEAD -- f | filter-hunks | git apply --cached'; (3) ISOLATION-GATE: 'git stash --keep-index -u' then run full build+lint+test on the staged-only tree.
+
+**Application**: Always run the isolation-gate before committing a carve — it caught 5 unstaged files (orphaned DescKeys would've broken the build alone) and a half-done env.go fix that a whole-tree build masked. A green full-tree build does NOT prove the carved subset is self-consistent; only building the stashed-down index does.
+
+---
+
+## [2026-07-06-214523] git filter-branch leaves the originals in refs/original and the reflog
+
+**Context**: Rewrote the branch to strip agent Co-Authored-By trailers and called it clean after checking only the branch tip; the originals were still recoverable and visible in git log --all.
+
+**Lesson**: filter-branch rewrites the ref but keeps originals in refs/original/* and every reflog; clean requires update-ref -d on refs/original, git reflog expire --expire=now --all, and git gc --prune=now, then a re-check across ALL refs and dangling objects. Rewriting an annotated/signed tag strips its GPG signature.
+
+**Application**: After any history rewrite: purge refs/original + reflog + gc, then verify agent trailers are zero across the whole object store, not just main..HEAD.
+
+---
+
+## [2026-06-07-170004] git CLI wrapping quirks (consolidated)
+
+**Consolidated from**: 4 entries (2026-03-24 to 2026-05-22)
+
+- `git rev-parse` exits 0 on an unknown long-flag and echoes the literal arg
+  back as its only stdout line (treats it as a candidate revision name). A
+  non-zero-exit guard never trips, so `--show-current` shipped verbatim into
+  handover frontmatter. Validate the OUTPUT shape (length, no `--` prefix,
+  hex-ness for SHAs) when wrapping rev-parse, not just the exit code.
+  (`--show-current` is a `git branch` flag, not rev-parse.)
+- Group git flag constants by the subcommand whose argv they're valid in (//
+  Branch subcommand flags, // Rev-parse flags), not by "loose CLI flags" — the
+  group comment is informal type info; mis-grouping enables wrong-subcommand
+  bugs. Genuinely-spanning flags (-C, --) go under an explicit Cross-subcommand
+  group.
+- `git describe --tags --abbrev=0` follows reachability from HEAD, not the
+  global tag list (diffed against v0.3.0 instead of v0.6.0 on a diverged release
+  branch). For "latest release globally" use `git tag --sort=-v:refname | head
+  -1`.
+- A trailing regex word boundary \b does NOT exclude hyphenated continuations
+  (\bgit commit\b matches `git commit-tree`). For porcelain with hyphenated
+  cousins (commit-tree, commit-graph, for-each-ref) append a (?!-) negative
+  lookahead.
+
+---
+
+## [2026-04-13-153618] GPG signing from non-TTY contexts requires pinentry-mac (or equivalent)
+
+**Context**: git commit failed from Claude Code's shell with 'gpg: signing
+failed: No such file or directory' — the default pinentry-curses cannot open a
+TTY in agent-invoked shells. Manual commits from a real terminal worked fine.
+
+**Lesson**: GPG's default curses pinentry requires an interactive TTY. In
+non-TTY contexts (Claude Code, CI, scripts, cron), signing fails silently-ish.
+The fix is to configure a GUI pinentry that uses the OS keychain: brew install
+pinentry-mac; echo 'pinentry-program $(brew --prefix)/bin/pinentry-mac' >>
+~/.gnupg/gpg-agent.conf; gpgconf --kill gpg-agent. Once the passphrase is saved
+in Keychain, signing works from any context.
+
+**Application**: If agents or CI need to sign commits, configure pinentry-mac
+(macOS) or pinentry-gtk/pinentry-qt (Linux) with the OS keychain, not
+pinentry-curses. This is a one-time setup per machine.
+
+---
+
diff --git a/.context/learnings/go-idioms-and-structure.md b/.context/learnings/go-idioms-and-structure.md
new file mode 100644
index 00000000..35c8409d
--- /dev/null
+++ b/.context/learnings/go-idioms-and-structure.md
@@ -0,0 +1,199 @@
+# go-idioms-and-structure
+
+## [2026-06-07-170003] Error handling: sentinels, unwrapping, and silent discards (consolidated)
+
+**Consolidated from**: 6 entries (2026-03-06 to 2026-06-02)
+
+- os.IsNotExist does NOT unwrap — it is false on any fmt.Errorf("…%w…")
+  error; prefer errors.Is(err, os.ErrNotExist). But errors.Is only holds if the
+  wrap carries %w at runtime, and a wrap whose format string comes from the
+  text/i18n registry only carries %w when that registry is initialized (so it
+  behaves differently in prod vs a bare test binary; go vet can't see it). To
+  detect file absence reliably, stat directly: os.Stat returns an unwrapped
+  *fs.PathError so errors.Is(statErr, os.ErrNotExist) is dependable everywhere.
+- An error-discard catalogue (grep + name/regex classification) is an inventory
+  of candidates, not findings. Name-inference produces false positives (a
+  discarded bool mistaken for an error; a value type that can't nil-deref; an
+  already-failed cleanup-close path). Read the callee signature and enclosing
+  control flow before assigning return-error vs logWarn vs annotate.
+- Canonical sentinel shape: a typed zero-data struct (or fielded struct for
+  parameterised errors) whose Error() resolves text via
+  desc.Text(text.DescKey…) lazily at call time — never `var ErrX =
+  errors.New("english")` and never an ErrMsg* string-const layer. Empty-struct
+  values are comparable and errors.Is finds them through %w wraps. Reference:
+  internal/err/context/context.go.
+- fmt.Fprintf to strings.Builder silently discards errors (Write never fails) so
+  errcheck allows it, but project convention forbids any silent discard —
+  TestNoUncheckedFmtWrite enforces `if _, err := fmt.Fprintf(...)`.
+- A path-returning (string, error) function must never return ('', nil):
+  filepath.Join('', rel) yields rel as a CWD-relative path, causing orphan
+  writes at project root. Sentinel errors force callers to gate. Audit any
+  path-returner with a historic ('', nil) shortcut (fixed: state.Dir,
+  rc.ContextDir).
+- Package-local err.go files in CLI packages invite agents to duplicate error
+  constructors (errFileWrite, errMkdir repeated). Centralize in internal/err; no
+  err.go files in CLI packages.
+
+---
+
+## [2026-06-07-170009] Constant placement & helper smells (consolidated)
+
+**Consolidated from**: 6 entries (2026-03-07 to 2026-03-23)
+
+- A constant used by only one domain (agent scoring, budget %, cooldowns)
+  belongs in that domain's config package, not a god-object file.go. Check
+  callers before placing.
+- Before adding any constant to internal/config, grep by VALUE (".jsonl") not
+  just name — camelCase vs ALLCAPS variants hide duplicates (ExtJsonl vs
+  existing ExtJSONL).
+- Project-root files created by `ctx init` (Makefile) are scaffolding
+  (config/file), NOT context files loaded via ReadOrder (config/ctx). Check
+  ReadOrder membership before moving a file constant.
+- SafeReadFile / validation.SafeReadFile take (baseDir, filename) separately —
+  split full paths with filepath.Dir + filepath.Base when adapting os.ReadFile
+  calls.
+- One-liner method wrappers that just forward a struct field to a stdlib/pkg
+  function (checkBoundary → validation.ValidateBoundary with h.ContextDir)
+  obscure the real dependency — inline them.
+- A param-struct field that is a function pointer where all callers pass thin
+  wrappers varying only by a text key (MergeParams.UpdateFn) is "data in
+  disguise" — replace the callback with the key and let the consumer dispatch.
+
+---
+
+## [2026-06-07-170011] Go toolchain, gofmt & build-tag pitfalls (consolidated)
+
+**Consolidated from**: 5 entries (2026-03-16 to 2026-05-10)
+
+- gofmt strips bare `//` padding lines as unnecessary whitespace, so
+  programmatic Go generation must produce substantive content lines; always run
+  gofmt after any scripted Go-file generation.
+- Agents reliably introduce gofmt issues during bulk renames (75+ files, 12
+  broken); run `gofmt -l` (then `-w`) as a standard step after any agent-driven
+  bulk edit before trusting the build.
+- The "compile version X does not match go tool version Y" error comes from the
+  CACHED toolchain (~/go/pkg/mod/golang.org/toolchain@…), not the system Go
+  — reinstalling Go does nothing. Diagnose via `go env GOROOT`; fix by
+  deleting the cached dir, bumping go.mod, or GOTOOLCHAIN=go. `go clean
+  -cache` and GOTOOLCHAIN=local don't help.
+- `make test` exit code is unreliable: the -cover flag can fail with "no such
+  tool covdata" even when every package passes. Fall back to `go test ./...` (no
+  -cover) and tally ^ok/^FAIL.
+- AST checks via go/packages only see files matching the current GOOS —
+  darwin-only (_darwin.go) violations are invisible on Linux. Fix violations
+  regardless; note coverage is platform-dependent (need multi-GOOS CI or a
+  go/parser fallback).
+
+---
+
+## [2026-06-07-170018] Go test isolation & patterns (consolidated)
+
+**Consolidated from**: 4 entries (2026-01-19 to 2026-04-25)
+
+- Any code using os.UserHomeDir() / user-level paths (~/.ctx/, ~/.config/) needs
+  t.Setenv("HOME", tmpDir) in tests — especially shared setup helpers. Under
+  parallel `make test`, fourteen test files invoking initialize.Cmd().Execute()
+  raced on read-modify-write of ~/.claude/settings.json, surfacing as flaky
+  "FAIL coverage: [no statements]"; testctx.Declare now sets HOME alongside
+  CTX_DIR (centralized fix).
+- Go testing patterns: `go build ./...` misses test-file callsite breaks —
+  always `go test ./...` after signature changes. Consume all runCmd() returns
+  (`_, _ = runCmd(...)`) for errcheck. Disable ANSI via color.NoColor=true in
+  package init for string assertions. Recall tests isolate via t.Setenv("HOME",
+  tmpDir) with .claude/projects/. formatDuration takes an interface with
+  Minutes() (use a stubDuration). CI needs CTX_SKIP_PATH_CHECK=1 (init checks
+  PATH). CGO_ENABLED=0 for ARM64 Linux.
+- Converting PersistentPreRun → PersistentPreRunE changes exit behavior:
+  errors propagate through Cobra Execute() return with no os.Exit.
+  Subprocess-based tests expecting exit codes must convert to direct error
+  assertions.
+
+---
+## [2026-05-28-201400] A non-root Go module nested under the main module's path CAN import its internal/ packages
+
+**Context**: While designing the ctxctl module split, the initial spec (and a
+lot of online consensus) claimed a separate `go.mod` cannot import the parent
+module's `internal/` packages, which would have forced relocating or duplicating
+~25 foundation packages (`rc`, `desc`, `nudge`, `config/*`, …). The "obvious"
+reading made same-module the only viable option.
+
+**Lesson**: Go's internal-import rule is **lexical on import paths, not
+module-scoped**. A separate module whose path is
+`github.com//
/tools/` CAN import +`github.com//
/internal/...` — verified by an empirical build +experiment this session. An outsider path (`example.com/...`) is rejected with +`use of internal package … not allowed`. The rule fires on the import-path +prefix relative to the `internal/` directory's parent, not on module boundaries. + +**Application**: For monorepo splits (maintainer-only tooling, isolated +experiments, ancillary CLIs), choose a module path nested under the main module +so the new module reuses the parent's foundations via the lexical-internal +allowance. Full self-containment of a maintainer module would be a DRY +catastrophe; the lexical allowance is the correct shape. Prove it with a +throwaway `go build` against a representative `internal/` import before +designing around the *wrong* constraint. + +--- + +## [2026-05-17-061500] `_helpers.go` / `_utils.go` filenames are project anti-pattern; use domain nouns + +**Context**: During Phase KB / Phase RG audit cleanup, the first file split I +attempted to satisfy the mixed-visibility audit named the new file +`read_helpers.go`. The user vetoed on sight: "utils; helpers, etcs are ALL lazy +naming; I will veto them the moment I see them; find proper domain objects." + +**Lesson**: ctx's per-package file layout follows domain nouns, not +visibility-suffixed catch-alls. The canonical reference shape is +`internal/journal/parser/` which splits 18 files by domain (envelope, markdown, +parse, validate, claude, copilot, ...). The mixed-visibility audit demands a +split, but the split target must be a real noun: `frontmatter.go` (YAML +parsing/validation), `markdown.go` (rendering), `filename.go` (filename +derivation), `provenance.go` (sha/branch resolution), `parse.go` (one-shot +parser), `cursor.go` (latest-pointer logic). Never `_helpers.go`. + +**Application**: When splitting a file to satisfy `mixed_visibility_test`, name +the new file for what the helpers ARE about, not for what visibility they have. +If you can't name it cleanly, the split itself may be wrong and the funcs may +belong in a different package entirely. + +--- + +## [2026-04-03-180000] Import cycles and package splits (consolidated) + +**Consolidated from**: 5 entries (2026-03-06 to 2026-03-22) + +- Types in god-object files (e.g. hook/types.go with 15+ types from 8 domains) + create circular dependencies — move types to their owning domain package +- Tests in parent package X cannot import X/sub packages that import X back — + move tests to the sub-package they exercise +- Variable shadowing causes cascading failures after splits: `dir`, `file`, + `entry` are common Go variable names that collide with new sub-package names + — run `go test ./...` before committing splits +- When moving constants between packages, change imports and all references in a + single atomic write so the linter never sees an inconsistent state +- Import cycle rule: the package providing implementation logic must own the + shared types; the facade package aliases them (e.g. `entry.Params` aliases + `add/core.EntryParams`) + +--- + +## [2026-03-18-133457] Lazy sync.Once per-accessor is a code smell for static embedded data + +**Context**: assets package had 4 sync.Once guards, 4 exported maps, 4 Load*() +functions, and a wrapper desc package — all to lazily load YAML from embed.FS +that never mutates. Every accessor call went through sync.Once + global map + +wrapper indirection. + +**Lesson**: When data is static and loaded from embedded bytes, scatter-loading +with per-accessor sync.Once is over-engineering. A single Init() called eagerly +at startup is simpler, and one sync.Once on Init() itself provides the test +safety net. Exported maps that exist only for wrapper packages to reach are a +sign the abstraction boundary is wrong. + +**Application**: Prefer eager Init() in main.go for static embedded data. Keep +maps unexported. Accessors do plain map lookups. If a wrapper package exists +solely to break a cycle caused by exported state, delete the wrapper and +unexport the state. + +--- + diff --git a/.context/learnings/hooks-and-integration.md b/.context/learnings/hooks-and-integration.md new file mode 100644 index 00000000..7ca742ac --- /dev/null +++ b/.context/learnings/hooks-and-integration.md @@ -0,0 +1,174 @@ +# hooks-and-integration + +## [2026-07-16-120001] OpenCode plugin integration gotchas (consolidated) + +**Consolidated from**: 7 entries (2026-04-26 to 2026-04-29) + +- Two-arg hooks (`shell.env`, `tool.execute.after`, `chat.params`, `chat.headers`) are MUTATION hooks: mutate the second `output` param; returned objects are discarded. Read the type def in `@opencode-ai/plugin/dist/index.d.ts` before wiring. +- The `event` hook is a SINGLE dispatcher — `event: async ({event}) => { if (event.type === "session.created") ... }` with `event.type` discriminating — not an object of named handlers; a named-handlers object compiles clean but never fires. +- `shell.env` injects env only into the agent's shell tool, not the plugin's own `ctx.$` calls (separate process env). Build an env-aware shell once: `ctx.$.env({ ...process.env, CTX_DIR: `${ctx.directory}/.context` })` and reuse it for every plugin-initiated subprocess. +- BunShell `ctx.$` echoes stdout/stderr to OpenCode's process (the TUI/agent surfaces it); chain `.nothrow().quiet()` on every call — shell-level `2>/dev/null` only hides stderr, stdout still leaks. +- Auto-discovery scans only flat top-level files: deploy `.opencode/plugins/.ts`, NOT `/index.ts` (subdirectories are silently ignored, no log line). Verify a plugin actually loads with a top-of-module side effect before debugging hook contracts. `opencode plugin ` is a different code path (npm names only). +- `opencode.json` MCP shape: `command: Array` holds the binary AND its args (there is no separate `args` field) and requires `enabled: true` at runtime; don't copy the Copilot CLI MCP shape. Read upstream `types.gen.d.ts` before reusing a generator. +- Compaction interop is breadcrumb-mediated and fragile (ctx context survived `/compact` only by accident, via oh-my-openagent preserving `.context/` paths). Register `experimental.session.compacting` yourself and push high-signal context (e.g. `ctx system bootstrap` output) to `output.context` (additive); never set `output.prompt` from a thin shim (destructive — only one plugin wins). + +--- + +## [2026-06-07-170015] Hook mechanics, output channels & compliance (consolidated) + +**Consolidated from**: 5 entries (2026-01-25 to 2026-04-06) + +- Hook scripts receive JSON via stdin (HOOK_INPUT=$(cat) then jq), not env vars; + key names are case-sensitive (PreToolUse, SessionEnd); use + $CLAUDE_PROJECT_DIR, never hardcode paths; anchor regex to command-start + `(^|;|&&|\|\|)\s*` ('ctx' binary vs dir); grep matches inside quoted args + (test with blocked words); scripts silently lose execute permission (verify ls + -la). +- Output routing: plain-text hook stdout is silently ignored — Claude Code + parses stdout starting with `{` as JSON directives; return JSON via + printHookContext(). For UserPromptSubmit specifically, stdout is prepended as + AI context (not user-visible), stderr+exit0 is swallowed, user-visible output + requires {"systemMessage":"…"} or exit 2 (blocks); there is NO non-blocking + user-visible channel. Two-tier severity is sufficient: unprefixed (agent + context, may relay) and "IMPORTANT: Relay VERBATIM" (guaranteed); don't add + more prefixes. +- Agents only relay content with explicit display instructions: a + system-reminder line with no "Display this line verbatim" is invisible to the + user even when correct. IMPORTANT: signals internal priority, not user-facing + output. +- Compliance: soft instructions have a ~75–85% ceiling because "don't apply + judgment" is itself judgment; for 100% compliance inject via additionalContext + rather than instruct. Hook compliance degrades on narrow mid-session tasks + (~15–25% skip) because CLAUDE.md's "may or may not be relevant" competes + with hook authority — fix by elevating hook authority explicitly; the + mandatory checkpoint relay block is the compliance canary. No reliable + agent-side before-session-end event exists (SessionEnd fires after the agent + is gone) — mid-session nudges + explicit /ctx-wrap-up are the only reliable + persistence. Repeated injection causes repetition fatigue — gate with + --session $PPID --cooldown and pair with a readback instruction. +- Context-budget injection strategy: once ~7K tokens are auto-injected (fait + accompli), the agent's rationalization inverts from "skip to save effort" to + "marginal cost is trivial." Front-load highest-value content as injection, + then leverage sunk cost for on-demand reads. Verbal summaries + linked diagram + files cut ARCHITECTURE.md ~12K→3.8K (extract diagrams outside FileReadOrder; + the 4-chars/token estimator is accurate — optimize content not the + estimator). + +--- + +## [2026-05-08-195031] Cursor imports Claude Code hooks and sets CLAUDE_PROJECT_DIR per workspace + +**Context**: Investigating why .context/state/ appeared in non-ctx projects +opened in Cursor. Hypothesis was a Cursor extension or shell hook; turned out to +be Cursor's documented Claude-compatibility behavior +(https://cursor.com/docs/hooks): it loads ~/.claude hooks and injects +CLAUDE_PROJECT_DIR=workspace_root so they 'just work'. Globally-enabled Claude +plugins therefore fire in every Cursor workspace. + +**Lesson**: When debugging cross-tool side effects, check whether the host tool +advertises compatibility with the implicated tool's config format. The leak +surface of any global Claude plugin is now 'every Cursor workspace + every +Claude Code project', not just 'every Claude Code project'. + +**Application**: Hooks must be safe to fire in non-ctx projects: silent bail +when state.Initialized() is false, no filesystem side effects. The ctx code-side +fix lives in state.Dir's Initialized gate; the design rule is broader: assume +hooks may run anywhere, not just where the user invoked ctx init. + +--- + +## [2026-03-20-160112] Commit messages containing script paths trigger PreToolUse hooks + +**Context**: Git commit message body contained a path to a shell script under +the hack directory which matched a hook pattern that blocks direct script +invocation + +**Lesson**: Hooks scan all Bash tool input including heredoc content used for +commit messages, not just the command itself + +**Application**: Rephrase commit messages and ctx add content to avoid paths +that match hook deny patterns, use generic references instead of literal file +paths + +--- + +## [2026-03-04-105239] CONSTITUTION hook compliance is non-negotiable — don't work around it + +**Context**: After make build, ran ./ctx deps --help which was blocked by +block-non-path-ctx. Instead of asking user to install, tried cp ctx ~/bin/ — +escalating workarounds. + +**Lesson**: When a hook blocks an action, the correct response is to follow the +hook's instruction (ask the user to sudo make install), not to find creative +bypasses. + +**Application**: Always ask the user to install when testing a freshly built +binary. Never attempt alternative install paths to circumvent a hook. + +--- + +## [2026-03-02-165039] Hook message registry test enforces exhaustive coverage of embedded templates + +**Context**: Adding billing.txt to embedded assets without a registry entry +caused TestRegistryCoversAllEmbeddedFiles to fail immediately + +**Lesson**: Every new .txt file under internal/assets/hooks/messages/ must have +a corresponding entry in registry.go — the test acts as an exhaustive +bidirectional check + +**Application**: When adding new hook message variants, update the registry +entry before running tests + +--- + +## [2026-02-28-150701] Plugin reload script must rebuild cache, not just delete it + +**Context**: hack/plugin-reload.sh was deleting +~/.claude/plugins/cache/activememory-ctx/ without repopulating it. Claude Code's +installed_plugins.json still referenced the cache path, so the plugin appeared +enabled but hooks.json was missing — all plugin hooks silently stopped firing. + +**Lesson**: Claude Code snapshots plugin hooks from the cache directory at +session startup. If the cache is deleted, plugin hooks vanish silently with no +error. The reload script must rebuild the cache from source assets +(internal/assets/claude/) after clearing it, and warn that a session restart is +required. + +**Application**: Always rebuild the plugin cache in hack/plugin-reload.sh. When +debugging hooks that don't fire, check ~/.claude/plugins/cache/ first — a +missing hooks.json is the most likely cause. + +--- + +## [2026-02-26-003854] Webhook silence after ctxrc profile swap is the most common notify debugging red herring + +**Context**: Spent time investigating why webhooks weren't firing — checked +binary version, hook configs, notify.Send internals. Actual cause was .ctxrc +swapped to prod profile (notify commented out) earlier in session. + +**Lesson**: When webhooks stop, check .ctxrc profile first (`ctx config +status`). Also: not all tool uses trigger webhook-sending hooks — Read only +triggers context-load-gate (one-shot) and ctx agent (no webhook). qa-reminder +requires Edit matcher. + +**Application**: Before debugging notify internals, run `ctx config status` and +verify the event would actually match a hook with notify.Send. + +--- + +## [2026-04-26-152836] ctx system help can list project-local hooks not in the Go binary + +**Context**: PR #72 plugin called 'ctx system block-dangerous-commands'; user's +installed ctx 0.7.2 listed it in help, but no directory exists under +internal/cli/system/cmd/ — it's a Claude Code plugin-local hook surfaced via +wrapper + +**Lesson**: ctx system help output is a union of compiled Go subcommands and +project-local Claude wrappers; non-Claude integrations only see the Go subset + +**Application**: When porting plugin behavior to a new editor, only call +subcommands that have a directory under internal/cli/system/cmd/. Don't trust +ctx system help output as the canonical surface. + +--- + diff --git a/.context/learnings/model-context-window.md b/.context/learnings/model-context-window.md new file mode 100644 index 00000000..f80fc969 --- /dev/null +++ b/.context/learnings/model-context-window.md @@ -0,0 +1,43 @@ +# model-context-window + +## [2026-07-03-182238] New Claude model families silently fall to the 200k default in ModelContextWindow + +**Context**: claude-fable-5 sessions warned '104 percent full' while the 1M window was 79 percent free; ModelContextWindow only recognized the [1m] suffix and the opus substring + +**Lesson**: Unmapped model families inherit rc.DefaultContextWindow (200k), and every EffectiveContextWindow consumer (check-context-size hook, heartbeat, nudges, provenance) misreports against the wrong window + +**Application**: At every model launch: add the family substring to internal/config/claude, extend TestModelContextWindow, and sanity-check against a live session's /context output before trusting hook percentages + +--- + +## [2026-03-01-124921] Model-to-window mapping requires ordered prefix matching + +**Context**: Implementing modelContextWindow() for the three-tier context window +fallback. Claude model IDs use nested prefixes (claude-sonnet-4-5 vs +claude-sonnet-4-20250514). + +**Lesson**: A switch with ordered HasPrefix cases (most specific first) is +cleaner and safer than iterating separate prefix lists. The catch-all 'claude-*' +returns 200k for unrecognized Claude models. + +**Application**: When adding new model families to modelContextWindow() in +session_tokens.go, add the most specific prefix first to avoid shadowing shorter +prefixes. + +--- + +## [2026-02-27-230741] Doctor token_budget vs context_window confusion + +**Context**: ctx doctor reported context size against token_budget (8k) instead +of context_window (200k), making 22k tokens look alarming. + +**Lesson**: token_budget (ctx agent output trim target) and context_window +(model capacity) serve different purposes. Health checks about context fitting +should use context_window, with warning threshold proportional (e.g., 20% of +window). + +**Application**: Doctor now uses rc.ContextWindow() with 20% threshold and shows +per-file token breakdown for actionable insight into which files are heavy. + +--- + diff --git a/.context/learnings/skills-agents-and-tasks.md b/.context/learnings/skills-agents-and-tasks.md new file mode 100644 index 00000000..9f91dd5a --- /dev/null +++ b/.context/learnings/skills-agents-and-tasks.md @@ -0,0 +1,363 @@ +# skills-agents-and-tasks + +## [2026-07-18-084406] A /ctx-task-out measurement gate caught a pre-existing bug before it could compound + +**Context**: pd-m1's E4 'layout proof' epic was decomposed specifically to prove-or-kill the progressive-disclosure premise that 'ctx add needs zero change' BEFORE building the mover on it. The empty-staging case failed: ctx learning add destroyed the ## Themes section. + +**Lesson**: The root cause was a shipped insert.AfterHeader tail-truncation bug (content[:i]+entry, dropping content[i:]), not a design flaw. The gate surfaced a real data-loss defect in ~4 tasks instead of after building on a false premise. Decomposing a milestone's load-bearing assumption into an early falsifiable task pays for itself. + +**Application**: When running /ctx-task-out, make the riskiest load-bearing assumption its OWN early task with a falsifiable acceptance check; treat its failure as signal about reality (a real bug), not just about the plan. Log the gate firing in the plan's Amendments. + +--- + +## [2026-06-07-142015] ctx-dream is headless-first; invoking /ctx-dream interactively is debugging, not the UX + +**Context**: An end user ran /ctx-dream in their foreground terminal session and +watched the agent hand-execute the pass (grep/cat/hash/write JSON). Their words: +'I'm not dreaming but viewing a dream being debugged.' + +**Lesson**: The dream is a sleep-time/headless product: cron 'claude -p' runs +the pass out-of-band, then the human is nagged and reviews via /ctx-serendipity. +The /ctx-dream SKILL is the executor's instruction set, not a user command — +driving it interactively makes the agent perform the executor's mechanical work +visibly, which is a debugging affordance, not the end-user experience. Exposing +/ctx-dream as a user slash-command invites exactly this confusion. + +**Application**: End-user entry points are 'ctx dream' (on-demand pass that +prints a digest) and cron (scheduled); review is /ctx-serendipity. Do NOT have +users invoke /ctx-dream directly. Reconsider de-listing /ctx-dream from the +user-invocable skill set, ensure 'ctx dream' gives a clean run->digest +experience, and wire the 'serendipity round waiting' nag so the dream->review +loop closes without the user watching a pass. + +--- + +## [2026-06-07-170001] ctx-dream design principles (consolidated) + +**Consolidated from**: 6 entries (2026-06-06 to 2026-06-07) + +- Merit/scoring rubric + (relevance/frequency/recency/diversity/consolidation/richness, à la Hermes + "Dreaming") measures ATTENTION (what to surface first), never TRUTH; use it + only as a ranking signal feeding ruthless self-rejection, never as an + autonomous promotion threshold — pair any statistical ranking with an + evidence/grounding gate that decides eligibility. +- Load-bearing invariant (Option B): dream consolidation emits PROPOSALS only; a + human accept/reject gate sits between the dream pass and any write to the five + canonical files / MEMORY.md. Autonomous canonical writes are the documented + rot failure mode (arXiv 2605.12978); independent designs (Hermes, OpenClaw, + Auto-Dreamer) re-derive the sleep-phase shape but omit the gate. When + evaluating any external memory-consolidation design, first check: does it + autonomously write canonical, or only propose? Autonomous-write is a reject. +- A single LLM asked to critique a proposal silently repairs the missing + justification and approves it (ReportLogic finding) — a single agreeable LLM + is not an adversarial gate. Robust gating needs human or independent + multi-critic consensus + swap-consistency. (This says a gate must EXIST; the + proposes-only entry says one must sit before canonical writes; together they + define WHO and WHETHER.) +- Same proposals, two consumers, two interfaces: render a terse/dispositional + accept-reject worklist for the agent reviewer and a substance-rich, + semantically-generated summary for the human (no file-hunting). Same data, + presentation per consumer. +- Split agent/human work by comparative advantage: the agent is the reliable + gardener for mechanical/verifiable hygiene (never skips the 47th file); the + human owns taste/serendipity — which is WHY the human is the gate, not + merely a safety nicety. Design the human's surface for pleasure (substance to + wander), not a queue to drain. +- Don't-leak is a third safety axis alongside don't-corrupt and + don't-obey-injected-instructions: a summary/backup/ledger-line of a gitignored + source inherits its privacy class. Keep every byproduct in gitignored + locations; enforce structurally with `git check-ignore` on each write target + (refuse tracked paths), never via prompt. A deliberate human `promote` is the + only sanctioned boundary crossing. + +--- + +## [2026-06-07-170012] Stale-task triage & verify-before-acting (consolidated) + +**Consolidated from**: 4 entries (2026-03-01 to 2026-05-23) + +- Stale TASKS.md items often describe work already done in code but not asserted + in tests — the task stayed open because nothing pinned the behavior. Triage + older items by grep/git-blame on the named symbols; if implemented, close by + writing the regression test (often one function). Applies to behavior-named + tasks more than feature-named ones. +- Tasks can be stale in reverse: implementation completed but task not marked + done (recall sync was fully wired despite a "not registered" description). Run + `ctx --help` before assuming work remains. +- Grep for callers must cover the ENTIRE working tree before deleting functions + — with unstaged changes from a prior session, grep hits only + committed+staged code. Always `make build` after deleting functions even when + grep shows zero callers. +- Spec-trailer improvisation is heuristic drift: when no on-topic spec exists, + the path of least resistance cites the most-recent spec from context, + satisfying the syntactic gate but defeating truthful traceability — and + session-scoped "I'll be careful" commitments don't survive across sessions, so + the fix must live in persistent context. Correct responses: scaffold a fresh + spec, bundle into the next functional commit, or cite specs/meta/chores.md. + (See specs/spec-trailer-discipline.md; AGENT_PLAYBOOK Spec Verification Step.) + +--- + +## [2026-06-07-170013] Refactor mechanics: subagents, cascades & golden fixtures (consolidated) + +**Consolidated from**: 6 entries (2026-02-19 to 2026-05-30) + +- Behavior-preserving refactors of formatting/rendering code: capture golden + fixtures from the LIVE legacy path before deleting it (throwaway test writes + testdata/*.golden), then assert byte-equality after — avoids silent drift + from hand-transcribing expected output. +- Removing a sentinel (ErrDirNotDeclared) cascades through ~10 errors.Is + consumers and ~30 test fixtures; spec-level step boundaries that separate + "swap resolver" from "remove guard" don't survive when the second references + the soon-deleted sentinel. Plan the merged commit at spec time; do the + compile-surface analysis then. +- Subagent parallelism shines for well-bounded mechanical refactor WITH a + canonical worked example on disk and an explicit fix-or-fail-with-a-blocker + instruction (invoke the no-deferral rule). Do one worked example in the + orchestrator, then dispatch subagents pointing at it. +- Subagents reliably exceed scope (rename funcs, change signatures, restructure + files even for em-dash fixes) and create new files without deleting originals. + After any agent refactor: `git diff --stat`, `git diff --name-only HEAD`, + revert out-of-scope changes, check for stale package decls/duplicate + defs/orphaned imports, run gofmt + `go test ./...`. +- Splitting a flat core/ package into subpackages exposes duplicated logic, + misplaced types, and function-pointer smuggling invisible in the flat layout; + circular-dep resolution during the split IS the design work that reveals the + right structure. +- Cross-cutting change ripple: path/asset/feature changes ripple across 15+ doc + files + multiple layers (embed directive, accessors, callers, tests, config + consts, build targets, docs). Grep broadly (not just code); a feature without + docs (feature page, cli-reference, recipes, nav) is invisible. + +--- + +## [2026-05-25-221357] Skill shipping location: _ctx- prefix is repo-internal, internal/assets/claude/skills/ctx-* is bundled and shipped + +**Context**: Created /ctx-surface-audit under internal/assets/claude/skills/ +(the shipped path), but it audits ctx's own internal/ source layout — useless +in an end-user project that installs ctx. There is an established _ctx-* family +(_ctx-command-audit, _ctx-audit, _ctx-release, _ctx-qa, etc.) in .claude/skills/ +for repo-only dev skills; the user caught the misplacement. + +**Lesson**: A skill that references ctx's own source tree (internal/, +docs/recipes/, cmd/) or dev workflow is repo-internal and must live in +.claude/skills/_/ (underscore prefix, committed to the repo but NOT +bundled). Only genuinely user-facing skills belong in +internal/assets/claude/skills/, which ctx init / ctx setup install into end-user +projects. The same ship-vs-repo-internal question applies one layer up: +user-facing CLI commands go in ctx, maintainer commands go in ctxctl; shipped +hooks live in internal/assets/claude/hooks/hooks.json and call ctx, repo-local +dev hooks live in the gitignored .claude/settings.local.json and may call +ctxctl. + +**Application**: Before creating a skill, command, or hook, ask: does this serve +a user working in their project, or a ctx maintainer working in this repo? +Maintainer-facing → _-prefixed skill in .claude/skills/ + ctxctl command + +repo-local hook. User-facing → internal/assets/claude/skills/ + ctx command + +shipped hooks.json. Putting maintainer tooling in the shipped paths taxes every +end user (e.g. a UserPromptSubmit hook firing on every prompt for a feature they +never use). + +--- + +## [2026-04-03-180000] Bulk rename and replace_all hazards (consolidated) + +**Consolidated from**: 3 entries (2026-03-15 to 2026-03-20) + +- `replace_all` on short tokens (e.g. `core.`, function names) matches inside + longer identifiers and function definitions — `remindcore.` becomes + `remindtidy.`, `func HumanAgo` becomes `func format.DurationAgo` (invalid Go) +- `sed` insert-before-first-match does not understand Go import aliases — the + alias attaches to whatever line sed inserts, not the original target +- For function renames: delete the old definition separately rather than using + replace_all. For bulk import additions: check for aliased imports first and + handle them separately, or use goimports + +--- + +## [2026-04-03-180000] Skill lifecycle and promotion (consolidated) + +**Consolidated from**: 4 entries (2026-03-01 to 2026-03-14) + +- Internal skill renames and promotions require synchronized updates across 6+ + layers: SKILL.md frontmatter, internal cross-references, external docs, + embed_test.go expected list, recipe/reference docs, and plugin cache rebuild + + session restart +- Skill behavior changes ripple through hook messages, fallback strings in Go + code, doc descriptions, and Makefile hints — grep for the skill name across + the entire repo +- Skills without a trigger mechanism (no user invocation, no hook loading) are + dead code — audit skills for reachability +- After promoting skills: grep -r for the old name across the whole tree, run + plugin-reload.sh, restart session to verify autocomplete, and clean stale + Skill() entries from settings.local.json + +--- + +## [2026-03-17-105637] Write package output census: 69 trivial/simple, 38 consolidation candidates, 18 complex + +**Context**: Full audit of internal/write/ (26 files, 160 functions, 337 Println +calls) to evaluate whether block template consolidation is worth a systematic +refactor. + +**Lesson**: Only 30% of write functions benefit from output consolidation. The +sweet spot is multi-line (16) and conditional (22) functions. + +**Application**: Check function category before consolidating. Trivial/simple +stay as-is. Conditional functions need pre-computation before block templates. +Loop-based complex functions stay imperative. Don't bulk-refactor. + +--- + +## [2026-02-26-100002] Agent context loading and task routing (consolidated) + +**Consolidated from**: 5 entries (2026-01-20 to 2026-01-25) + +- `ctx agent` is optimized for task execution (filters pending tasks, surfaces + constitution, token-budget aware). Manual file reading is better for + exploratory/memory questions (session history, timestamps, completed tasks). +- On "Do you remember?" questions, immediately read .context/ files and run `ctx + journal source --limit 5`. Never ask "would you like me to check?" — that is + the obvious intent. +- .context/ is NOT a Claude Code primitive. Only CLAUDE.md and + .claude/settings.json are auto-loaded. The .context/ directory requires a hook + or explicit CLAUDE.md instruction to be discovered. +- ~~Orchestrator (IMPLEMENTATION_PLAN.md) and agent (.context/TASKS.md) task + lists must be separate.~~ (Superseded 2026-03-25: IMPLEMENTATION_PLAN.md + removed. TASKS.md is the single task source.) +- Only CLAUDE.md is auto-loaded by Claude Code. Projects using ctx should rely + on the CLAUDE.md -> AGENT_PLAYBOOK.md chain, not AGENTS.md. + +--- + +## [2026-02-26-100007] Task management and exit criteria (consolidated) + +**Consolidated from**: 4 entries (2026-01-21 to 2026-02-17) + +- Specs get lost without cross-references from TASKS.md. Three-layer defense: + (1) playbook instruction, (2) spec reference in Phase header, (3) bold + breadcrumb in first task. +- Subtask completion is implementation progress, not delivery. Parent tasks + should have explicit deliverables; don't close until deliverable is verified. +- Exit criteria must include verification: integration tests (binary executes + correctly), coverage targets, and smoke tests. "All tasks checked off" does + not equal "implementation works." +- Reports graduate to ideas/done/ only after all items are tracked or resolved. + Cross-reference every item against TASKS.md and the codebase before moving. + +--- + +## [2026-02-26-100008] Agent behavioral patterns (consolidated) + +**Consolidated from**: 5 entries (2026-01-25 to 2026-02-22) + +- Interaction pattern capture risks softening agent rigor. Do not build implicit + user-modeling from session history. Rely on explicit, human-reviewed context + (learnings, conventions, hooks) for behavioral shaping. +- Chain-of-thought prompting improves agent reasoning accuracy (17.7% to 78.7%). + Added "Reason Before Acting" to AGENT_PLAYBOOK.md and reasoning nudges to 7 + skills. +- Say "project conventions" not "idiomatic X" to ensure Claude looks at project + files first rather than triggering training priors (stdlib conventions). +- Autonomous "YOLO mode" is effective for feature velocity but accumulates + technical debt (magic strings, monolithic tests, hardcoded paths). Schedule + periodic consolidation sessions. +- Trust the binary output over source code analysis. A single ambiguous CLI + output is not proof of absence — re-run the exact command before claiming + something is missing. + +--- + +## [2026-04-25-014704] Confident code comments can pull an LLM away from first-principles knowledge + +**Context**: cli_test.go had a comment claiming 'parent's t.Setenv doesn't +propagate to exec'd children unless we build it into cmd.Env' which is wrong. I +patched the helper's CTX_DIR dedup instead of questioning the helper itself, +despite knowing t.Setenv semantics. + +**Lesson**: A comment that explains why a stdlib mechanism 'doesn't work' is +doing extra rhetorical work to talk a reader out of the obvious approach. That's +exactly when to verify from first principles instead of trusting the +surrounding-code frame. + +**Application**: When an existing comment justifies a non-canonical approach +contradicting stdlib knowledge: pause, verify against memory of the actual API +before patching within the existing frame. + +--- + +## [2026-07-06-a] `ctx journal site` rewrites source entry bodies in place + +**Context**: The self-heal render-hash guard assumed only `execute.go` +(import) authors entry bodies. But `ctx journal site` (`make journal`) +soft-wraps/collapses the *source* `.md` in place (site/run.go), and did +NOT refresh the render hash. Result: after any site build, growth-aware +import saw a hash mismatch and false-flagged every normalized entry as +"edited outside ctx", stranding the growth. + +**Lesson**: A "prove the file is unchanged since we wrote it" hash is +only valid if *every* code path that writes the body refreshes it. Grep +all writers of the artifact, not just the obvious one. + +**Application**: When adding a content-integrity hash, enumerate every +writer (`grep -rn SafeWriteFile `) and make each refresh the hash — +or route all writes through one helper. Refresh unconditionally on every +non-failed pass so an idempotent transform still re-syncs. + +--- + +## [2026-07-06-b] zensical bundles a fixed lucide icon snapshot + +**Context**: `make site` failed with "template not found: +.icons/lucide/message-square-cog.svg". A doc frontmatter `icon:` field +referenced a lucide icon absent from zensical 0.0.47's bundled set (and +0.0.47 is the latest). Blocks the entire build, not just that page. + +**Lesson**: zensical (like mkdocs-material) ships a frozen lucide +snapshot; a valid-on-lucide.dev icon may not exist in the installed +version. A single bad `icon:` fails the whole site build. + +**Application**: Before a site rebuild, validate every doc `icon: +lucide/X` against the installed bundle +(`find /templates/.icons/lucide -name X.svg`). Pick from the +bundled set, not lucide.dev. + +--- + +## [2026-07-06-c] PATH `ctx` is a stale install; verify against the fresh binary + +**Context**: After editing an asset (commands.yaml) and `make build`, +`ctx journal import --help` still showed the OLD text. Cause: `ctx` on +PATH is `/usr/local/bin/ctx` (a prior `make install`), while `make +build` produces `./ctx` in the repo. A project hook blocks running +`./ctx` directly. + +**Lesson**: `make build` does not update the PATH binary. Verifying +behavior via PATH `ctx` after a source change tests the stale install, +not your change. + +**Application**: To verify a fresh build, copy it out +(`cp ./ctx /ctx-new`) and run that — it dodges the `./ctx` +hook and reflects your edits. Or `sudo make reinstall`. Never trust +PATH `ctx` help/behavior right after `make build`. + +--- + +## [2026-07-06-d] `internal/assets` ← `internal/rc` blocks package-assets tests importing rc + +**Context**: Moving the `.ctxrc` schema-vs-struct bijection test to +reflect over the real `rc.CtxRC` (drift-proofing it) failed: `rc` +imports `internal/assets/read/placeholders`, which imports top-level +`internal/assets`. So a `package assets` test importing `rc` forms a +cycle (assets → rc → placeholders → assets). + +**Lesson**: `internal/assets` is a low-level leaf that `rc` transitively +depends on; tests in `package assets` cannot import `rc`. + +**Application**: Home a struct-vs-schema guard in the package that owns +the struct (`package rc`), importing `internal/assets` to read the +embedded schema — that direction is acyclic (assets never imports rc). + +--- + diff --git a/.context/learnings/text-markdown-serialization.md b/.context/learnings/text-markdown-serialization.md new file mode 100644 index 00000000..fdf46862 --- /dev/null +++ b/.context/learnings/text-markdown-serialization.md @@ -0,0 +1,184 @@ +# text-markdown-serialization + +## [2026-07-17-081010] An insert helper that returns content[:i]+x instead of content[:i]+x+content[i:] silently drops the tail + +**Context**: insert.AfterHeader (the fallback beforeFirstEntry takes for a knowledge file with no ## [ entries) returned content[:insertPoint]+entry, truncating the file at the insertion point and discarding everything after it. Masked in practice because a ctx-init'd file has nothing after its comment block, so the dropped tail was empty; it bites the moment any non-entry section sits below the preamble of an as-yet-entry-less file. Same family as the LEARNINGS clobber bug index.Validate guards: silent memory loss, git-only recovery. + +**Lesson**: A string-splice 'insert' must reattach the tail. content[:i]+x is append-with-truncation, NOT insert. The sibling Task() splice did it right (content[:i]+x+sep+content[i:]); AfterHeader was the odd one out and nobody noticed because the package had ZERO tests. EOF-anchored fixtures mask the bug precisely because the tail is empty there. + +**Application**: Audit any result built from content[:i] for a matching content[i:] on the other side of the inserted text; a lone content[:i] is the smell. Test insert/splice helpers with a NON-empty tail, not just an EOF-anchored fixture. When touching a data-mutating helper with no tests, adding the test is part of the fix, not optional. + +--- + +## [2026-07-17-081010] Uninitialized desc.Text() returns empty, and strings.Index(s, "") == 0 makes anchor-based inserts silently match at offset 0 + +**Context**: Writing a layout proof for the insert package, both cases passed on the first run — for the wrong reason. A *_test.go that never calls lookup.Init() (via TestMain) gets "" back from every desc.Text() call, because the embedded asset lookup is uninitialized in that test binary. Anchor logic such as beforeFirstEntry does strings.Index(content, desc.Text(headingKey)); with a "" needle that returns 0, so 'insert before the anchor' prepends to the whole file (above the H1) and any assertion of the form 'entry appears before X' passes trivially. + +**Lesson**: strings.Index(s, "") == 0: an empty needle 'matches' at the start of any string. So a text-driven test whose helper resolves labels through desc.Text MUST initialize the asset lookup, or it exercises a degenerate offset-0 code path that production never takes — and passes for the wrong reason. This nearly caused a false 'measurement gate validated' report when the gate was in fact broken. + +**Application**: Any package test that calls desc.Text (directly, or transitively through the code under test) needs a TestMain calling lookup.Init() (see internal/cli/system/core/session/testmain_test.go for the pattern). When a text/anchor-driven test passes suspiciously easily, dump desc.Text() of the keys involved and assert they are non-empty before trusting the assertions. + +--- + +## [2026-07-04-152957] Typed JSON round-trips silently drop user-owned keys + +**Context**: The init permissions merge read settings.local.json into a typed struct and re-marshaled it, so every key ctx does not model (env, a user's statusLine) vanished on re-init + +**Lesson**: A typed read-modify-write of a file users also edit by hand is silent data loss; unknown fields do not survive encoding/json round-trips + +**Application**: Mutate shared JSON via raw-map surgery (map[string]json.RawMessage), rewriting only the keys ctx owns; see internal/cli/initialize/core/merge/settings.go + +--- + +## [2026-05-23-001000] Unicode block separation makes diacritic-stripping surgical — no per-script handling needed for Arabic/Indic/Hebrew/CJK + +**Context**: While building `i18n.MatchKey` (commit 978582f5) for +diacritic-insensitive placeholder matching, the natural reflex was "this is +going to need per-script special cases — CJK doesn't have case, Arabic has +shadda/fatha that are meaning-changing, Bengali vowel signs are +script-essential, Hebrew niqqud distinguishes words." I sized the work assuming +we'd need a script-aware policy, possibly with a locale config or an opt-in flag +for "strip all combining marks" vs "strip only Latin-style decoration". +Empirical test across Turkish/German/French/Spanish/Catalan/Czech/Vietnamese +(should collapse) and Arabic/Bengali/Devanagari/Hindi/Hebrew/Chinese/Korean +(should preserve) showed the entire policy fits in one numeric range: +U+0300..U+036F. + +**Lesson**: Unicode pre-separated combining marks by intent at the codepoint +level. The "Combining Diacritical Marks" block (U+0300–U+036F) holds +Latin/general decorative marks: acute, grave, diaeresis, tilde, cedilla, caron, +the Turkish combining dot, the Vietnamese horn, etc. Script-essential marks live +in separate blocks per script: Arabic in U+0610–U+06ED, Bengali in +U+0980–U+09FF, Devanagari in U+0900–U+097F, Hebrew niqqud in +U+0591–U+05C7, and so on. The block boundaries are not coincidental — they +encode the same distinction a reasonable design would want to make. So a narrow +byte-range strip is exactly the right primitive: it expresses "remove +decoration, keep structural marks" in one comparison, without needing to know +anything about the input's script. + +**Application**: When designing comparison/normalization primitives for +international input, check the Unicode block boundaries before reaching for +per-script special cases or a config field. Often the standardization committee +already drew the line you want, and an arithmetic range check (`r >= 0x0300 && r +<= 0x036F`) does the work. Verify empirically across the scripts you care about +— but expect the answer to be cleaner than your initial sizing. The general +rule: when Unicode has put related characters in their own block, treat that +block as a meaningful unit of policy. (For ctx, this is now +`cfgI18n.CombiningMarksLatinStart`/`End` and the `MatchKey` implementation in +`internal/i18n/matchkey.go`.) + +--- + +## [2026-05-11-231025] Naive Markdown line-sweep corrupts multi-line code spans and YAML lists + +**Context**: Performed a programmatic typographic sweep across docs/*.md to wrap +bare 'ctx' tokens in backticks (commit 61aab858). 81 source files, 236 lines +changed. First pass corrupted two indented JSON snippets in MkDocs admonitions +because the fence regex anchored to start-of-line and missed admonition-indented +fences. After fixing the fence regex, two more corruptions surfaced (multi-line +inline-code spans where the opening backtick is on line N and the closing on +line N+1: the line-at-a-time transformer treated each line independently, +leading to misjudged span boundaries on the second line). After post-sweep +validation, a YAML parse error on docs/blog/2026-02-03-the-attention-budget.md +surfaced one more breakage: a 'topics:' list-item like '- ctx primitives' got +wrapped to '- `ctx` primitives', which is invalid YAML (a value starting with +backtick is not a valid unquoted scalar). Total: 2 multi-line span corruptions + +1 YAML breakage, all detected only by post-sweep validation (make site + grep +audit), not by the dry-run. + +**Lesson**: A naive line-at-a-time regex sweep across Markdown documents must +respect a wider 'skip' set than the obvious cases. The full safe-skip list is: +(1) triple-backtick fenced code blocks, BOTH root-level and indented inside +MkDocs admonitions or list items (fence regex must allow leading whitespace, +e.g. '^\\s*```'); (2) inline backtick spans on the same line; (3) multi-line +inline-code spans crossing line boundaries (line-at-a-time logic cannot detect +both ends, so either track fence-like 'odd-count' state across lines or treat +any unclosed-on-line backtick as 'protect rest of line'); (4) the ENTIRE YAML +frontmatter block (delimited by '---' at top and next '---'), not just specific +keys like title/description/icon, because list-item values under +topics/tags/keywords are also YAML and break on a leading backtick; (5) image +alt-text '![alt]' (alt-text does not render in monotype); (6) link-reference +definitions '[name]: url "title"'; (7) project copyright header comment blocks. +Dry-run output never catches YAML or multi-line span breakage; validation MUST +include a parser-level check (make site for YAML, post-grep for '``name`' +double-backtick patterns near the wrapped token). + +**Application**: When designing any future programmatic sweep across docs/ +(typography passes, internationalization, brand renames, em-dash replacement, +link-text rewrites): (1) implement the full skip set above, not a subset; (2) +for fence detection use '^\\s*```', not '^```'; (3) for the frontmatter, detect +the entire block between '---' delimiters, not specific keys; (4) for multi-line +inline-code, choose between cross-line backtick-pair tracking (complex but +correct) or the simpler 'unclosed backtick protects rest of line' heuristic +(corrupts ~1 per 100 files but recoverable manually); (5) ALWAYS validate +post-sweep with 'make site' (zensical surfaces YAML errors) and a grep for +'``\\w' double-backtick patterns near the wrapped token; (6) commit only after +both validations are clean. For one-shot sweeps the script can be ad-hoc, but +record the validation gate as part of the commit message so the next contributor +knows what to check. + +--- + +## [2026-04-14-010105] Brand-name handling in title-case engines must cover possessives + +**Context**: First pass of hack/title-case-headings.py produced 'Ctx's' from +'ctx's' because the brand check matched the bare token only. + +**Lesson**: A brand allowlist needs to recognize , 's, s, +and short apostrophe-suffixed variants. Single-word matching misses contractions +and possessives. + +**Application**: When adding a new always-lowercase brand to +hack/title-case-headings.py, extend the suffix-aware loop in title_case_word, +not just the BRAND_LOWER set. + +--- + +## [2026-04-03-133244] desc.Text() is the single highest-connectivity symbol in the codebase + +**Context**: GitNexus enrichment during architecture analysis revealed +desc.Text() (internal/assets/read/desc/desc.go:75) has 30+ direct callers +spanning every architectural layer (MCP handler, format, index, tidy, trace, +memory, sysinfo, io) and participates in 53 execution flows. + +**Lesson**: TestDescKeyYAMLLinkage is the most critical guard in the codebase +— it protects the symbol with the widest blast radius. If YAML text loading +breaks, the entire CLI and MCP server output blank strings silently (no crash, +no warning). + +**Application**: Treat desc.Text() as a frozen API — add new functions rather +than modifying the existing signature. Any change to config/embed/text or +assets/read/desc should be followed by running the linkage audit. Monitor this +symbol during major refactors. + +--- + +## [2026-03-06-141504] Stats sort uses string comparison on RFC3339 timestamps with mixed timezones + +**Context**: ctx system stats showed only old sessions, hiding the current one + +**Lesson**: RFC3339 string comparison breaks when entries mix UTC (Z) and offset +(-08:00) formats — 13:00-08:00 sorts before 18:00Z lexicographically despite +being later in absolute time + +**Application**: Always parse to time.Time before comparing RFC3339 timestamps; +never rely on lexicographic sort + +--- + +## [2026-03-01-095709] TASKS.md template checkbox syntax inside HTML comments is parsed by RegExTaskMultiline + +**Context**: Template had example checkboxes (- [x], - [ ]) in HTML comments +that the line-based regex matched as real tasks, causing +TestArchiveCommand_NoCompletedTasks to fail + +**Lesson**: RegExTaskMultiline is line-based and has no awareness of HTML +comment blocks — checkbox-like patterns inside comments get counted as real +tasks + +**Application**: Use backtick-quoted or indented references instead of actual +checkbox syntax in template comments. When adding examples to TASKS.md +templates, avoid patterns that match regExTaskPattern + +--- + From 977db432681fbbbca9051ad08dc9c7dd7c18fe5b Mon Sep 17 00:00:00 2001 From: Jose Alekhinne Date: Sun, 19 Jul 2026 08:35:51 -0700 Subject: [PATCH 8/8] =?UTF-8?q?chore(pd-m3):=20mark=20T17=20+=20T18=20done?= =?UTF-8?q?=20=E2=80=94=20milestone=203=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - T17 (real rollout): LEARNINGS + DECISIONS folded on the live roots, human-approved themes, conservation + all four invariants verified. - T18 (milestone gate): make lint 0, go test ./... green, make audit pass on the changed tree (incl. the fix(disclosure) comment-parse fix). Also records the comment-parse amendment: the T17 gate surfaced a real disclosure-parser bug (a "## [" example inside DECISIONS.md's HTML-comment format guide was mistaken for staging once every real entry folded out), fixed in its own fix(disclosure) commit. Plan: specs/plans/pd-m3.md Signed-off-by: Jose Alekhinne --- specs/plans/pd-m3.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/specs/plans/pd-m3.md b/specs/plans/pd-m3.md index dbc846c1..140bda73 100644 --- a/specs/plans/pd-m3.md +++ b/specs/plans/pd-m3.md @@ -201,8 +201,8 @@ No `ctx agent` change (spec Non-Goals). No `add`-path change. | T14 | [x] | `ctx-digest` skill — apply path | T10 | `internal/assets/claude/skills/ctx-digest/SKILL.md` | | frontmatter test green; body describes inspect→propose→gist→**build plan JSON→human approve→`ctx disclosure apply`→confirm via inspect**, states the append→verify→remove guard and conventions=M4 | Skill | | T15 | [x] | copilot skill sync | T14 | `internal/assets/integrations/copilot-cli/skills/ctx-digest/**` | | `make check-copilot-skills` green | Skill | | T16 | [x] |XX **measurement gate** — drive skill apply on realistic fixture | T14,T08,T09 | scratchpad fixture (copy of real LEARNINGS staging) | | drive ctx-digest apply on N≥4 staged entries → ≥2 themes: entries moved, theme files created with bodies, `## Themes` gists written, conservation holds, all four invariants pass, root bounded | Scope/DoD | -| T17 | [ ] |XX real LEARNINGS→DECISIONS rollout (**human-gated**) | T16 | `.context/LEARNINGS.md`, `.context/DECISIONS.md`, `.context/learnings/**`, `.context/decisions/**` | | **only on explicit user approval:** apply on the real roots; conservation + all four invariants verified; committed on user sign-off (own commit) | Spec Phasing 3 | -| T18 | [ ] |XX milestone gate | T01–T17 | — | | `make lint` 0, `go test ./...` green, `make audit` pass; changed canonical `.md` pass invariants | Scope/DoD | +| T17 | [x] |XX real LEARNINGS→DECISIONS rollout (**human-gated**) | T16 | `.context/LEARNINGS.md`, `.context/DECISIONS.md`, `.context/learnings/**`, `.context/decisions/**` | | **only on explicit user approval:** apply on the real roots; conservation + all four invariants verified; committed on user sign-off (own commit) | Spec Phasing 3 | +| T18 | [x] |XX milestone gate | T01–T17 | — | | `make lint` 0, `go test ./...` green, `make audit` pass; changed canonical `.md` pass invariants | Scope/DoD | **Execution waves:** `T01,T04` ∥ → `T02,T03` ∥ → `T05` → `T06,T07,T08,T09` ∥ → `T10` → `T11,T12,T13` ∥ → `T14` → `T15` → @@ -266,3 +266,4 @@ every task in its range is `[x]`/`[o]` here. |---|---|---| | 2026-07-18 | `Assignment.Entries` changed from `[]string` (NUL-joined ids) to `[]StagedEntry` (`{timestamp,title}`). `FlattenPlan` now builds the ids internally; new `entryID(StagedEntry)` helper. Data-model refinement — no acceptance criterion changed (conservation/byte-cut/guards identical); T02's `SplitStaging` tests are untouched (it still takes `[]string`), only the plan/`FlattenPlan` constructors changed. | The skill authors the plan JSON; a NUL-separated id is a foot-gun to hand-author. `[]StagedEntry` is exactly `inspect --json`'s `staging` shape, so the skill lifts entries verbatim — no transformation, no ``. Verified end-to-end against the built binary. | | 2026-07-18 | Split the `movehelpers.go` bag file into semantically-relevant files: `move.go` (the mover's mechanics — append/verify/rewrite), `regions.go` (`renderThemeBullet`, line/offset scanners — beside `parseThemeBullet`/`lineAt`), `collect.go` (`entryID` — beside `entryIDs`). | User convention: "helpers"/"utils" bag files are lazy; functions belong in files named for their concern. [[no-helper-bag-files]] | +| 2026-07-19 | Parser: `firstLinePrefixOffset` and `headingLineOffsets` now skip `## [` / `## Themes` lines inside HTML comments (`htmlCommentSpans`; unterminated open → EOF). Shipped as its own `fix(disclosure)` commit with two regression tests; no knowledge-file edit. | Surfaced by the T17 rollout gate (mirrors pd-m1's afterheader find). DECISIONS.md's `` format guide carries a `## [YYYY-MM-DD] Decision Title` example; once T17 folded every real entry out, that commented example was the only `## [` above `## Themes`, so `Validate` wrongly returned `ErrStagingUnparsable`. Root-cause fix in the parser — the folded root validates unchanged. |