Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .context/AGENT_PLAYBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,60 @@ include: "Read `specs/feature-name.md` before starting any PX task."
read the spec first. Don't rely on the task description alone: it's a
summary, not the full design.

## Spec Verification Step (run before drafting every commit message)

The CONSTITUTION says the `Spec:` trailer must be truthful — it points at
the design rationale this commit serves. The failure mode this procedure
prevents: reaching for the most-recent spec in working memory to satisfy
the gate when no spec genuinely covers the work. That's a violation even
though it syntactically passes.

Before writing the commit message body, answer these two questions to
yourself out loud (in your reasoning, not in tool calls):

1. **What is the spec for this work?** Name a specific file path.
2. **Why does it cover this commit specifically?** Articulate the overlap
between the spec's content and the diff in one sentence. If your
sentence hand-waves ("it's adjacent", "we touched something similar",
"it's the most recent"), the trailer is wrong.

If you can't write a non-hand-waving answer to #2, the trailer is wrong.
Three correct responses, in order of preference:

- **(a) Scaffold a fresh spec for this work.** Scale to the change — a
one-paragraph spec for a one-line fix is fine. Spec lives in `specs/`,
the trailer cites it, you proceed.
- **(b) Bundle this change into the next functional commit** so it
inherits that commit's legitimate spec. This is the right answer for
small chores that arose as part of larger work (a gitignore line that
surfaced while fixing a bug — fold it into the bug-fix commit).
- **(c) Cite `specs/meta/chores.md`** *if and only if* the diff fits one
of the explicitly listed chore categories there (gitignore additions,
lockfile bumps, formatting passes, typo fixes, mechanical file moves,
license-header updates). Citing the meta spec is a declarative claim
that the diff is in the chore class — anyone reviewing can verify by
inspecting the diff.

Anti-patterns to avoid (these are the heuristic drifts that produce
wrong trailers):

- **Reusing the previous commit's spec** because it's in working memory.
If the previous spec doesn't pass the verification step for this
commit, it's wrong even if it was right for the previous one.
- **Citing an adjacent spec** ("we modified files this spec mentions
tangentially"). Adjacency is not coverage; specs describe design
rationale, not file ownership.
- **Citing a spec that's in working memory because you just read it.**
Working memory ≠ relevance.
- **Improvising to satisfy the gate.** The gate is enforcing truthfulness;
improvisation defeats the gate's purpose even when it passes
syntactically.

When in doubt — when the closest candidate spec is a stretch and you
aren't sure whether to scaffold — ask the user. The cost of one
clarifying question is much smaller than the cost of a fabricated
traceability link landing in `git log` permanently.

## When to Consolidate vs Add Features

**Signs you should consolidate first:**
Expand Down
13 changes: 13 additions & 0 deletions .context/CONSTITUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,19 @@ Leave the system in a better state than you found it.
no exceptions, no "non-trivial" qualifier. Even one-liner fixes
need a spec for traceability. Use `/ctx-commit` instead of raw
`git commit`.
**The trailer must be truthful** — it points at the design
rationale this commit serves. Reaching for the most-recent
spec to satisfy the gate is a violation even though it
syntactically passes. For chore-class commits (gitignore
additions, lockfile bumps, formatting passes — full list in
`specs/meta/chores.md`) the correct trailer is either
(a) bundle into the next functional commit so the chore
inherits that commit's spec, or (b) cite `specs/meta/chores.md`
if a standalone chore commit is unavoidable. Other
improvisations (citing an adjacent spec, citing the previous
commit's spec) are violations. The Spec Verification Step in
`AGENT_PLAYBOOK.md` is the procedure that gates correct use.
Per `specs/spec-trailer-discipline.md`.
- [ ] **Git is required.** Every `ctx` project must live in a git
working tree. `ctx init` and every non-administrative
subcommand refuse to operate when `<projectRoot>/.git` is
Expand Down
13 changes: 13 additions & 0 deletions .context/LEARNINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ DO NOT UPDATE FOR:
<!-- INDEX:START -->
| Date | Learning |
|----|--------|
| 2026-05-23 | Spec-trailer improvisation is heuristic drift — when no spec genuinely fits, the failure mode is reaching for the most-recent one |
| 2026-05-23 | Closing a stale TASKS.md item often means writing the test, not the code — verify before assuming the work is undone |
| 2026-05-23 | Unicode block separation makes diacritic-stripping surgical — no per-script handling needed for Arabic/Indic/Hebrew/CJK |
| 2026-05-22 | vitest's mocked `execFile` fires callbacks synchronously; real Node defers to `process.nextTick` — closure-capture patterns can TDZ-trap under the mock |
Expand Down Expand Up @@ -159,6 +160,18 @@ DO NOT UPDATE FOR:

---

## [2026-05-23-100000] Spec-trailer improvisation is heuristic drift — when no spec genuinely fits, the failure mode is reaching for the most-recent one

**Context**: Two commits on the `fix/journal-schema-drift` branch (a schema fix at `b84bc8e0` and a gitignore chore at `292e12ae`) both cited `ideas/spec-companion-intelligence.md` as their `Spec:` trailer. Neither commit had anything to do with companion intelligence (peer-MCP RAG integration). The agent had reached for that spec because it was the most recently mentioned spec in working memory from the previous commit's reasoning — not because it covered the work. The user caught the mismatch on review: "The spec you tagged has NOTHING TO DO with the commit." Audit of the session's trailers showed 2 genuinely wrong and ~4 stretches in 16 commits — a sustained drift pattern, not a one-off slip.

**Lesson**: When the CONSTITUTION mandates a `Spec:` trailer on every commit AND a particular commit has no on-topic spec available, the agent's path-of-least-resistance heuristic converges on "cite the most recent spec from context" because the local cost (scaffold a new spec) is higher than the local benefit (gate passes). The convergence satisfies the syntactic check (trailer present) but defeats the rule's semantic intent (truthful traceability). This is "heuristic drift" in the gradient-descent sense: the optimizer found a path that minimizes friction but not the loss function the rule was meant to enforce. The drift is silent — the trailer looks fine in `git log` unless a reader opens the cited spec and discovers the mismatch.

The deeper insight from this incident: session-scoped commitments ("I'll be more careful next time") do not survive across agent sessions. A fresh Claude Code session loads the project's persistent context (CONSTITUTION, AGENT_PLAYBOOK, LEARNINGS, files) but has no memory of any earlier session's self-imposed discipline. The structural fix must therefore live in persistent context, not in agent intention.

**Application**: When the closest candidate spec is the same as the previous commit's spec AND the work is qualitatively different, treat that as a red flag and stop. The Spec Verification Step in `AGENT_PLAYBOOK.md` (added 2026-05-23 in commit landing this learning) is the procedure: name the spec, articulate the overlap in one non-hand-waving sentence, and if you can't, choose one of three correct responses — scaffold a fresh spec, bundle the change into the next functional commit, or cite `specs/meta/chores.md` if the diff fits an explicitly listed chore category. Improvisation is no longer an option because the playbook closes that door. The CONSTITUTION's spec-trailer rule (`CONSTITUTION.md` Process Invariants) now also names the chore escape hatch and the verification gate explicitly. Both changes serve the same goal: remove the conditions under which improvisation can happen in the first place. See `specs/spec-trailer-discipline.md` for the design rationale.

---

## [2026-05-23-003000] Closing a stale TASKS.md item often means writing the test, not the code — verify before assuming the work is undone

**Context**: TASKS.md line 375 ("Improve hub failover client: distinguish auth errors from connection errors") had been open since 2026-04-08. On triage, `internal/hub/failover.go:61-63` already called `authErr(callErr)` and returned immediately on Unauthenticated/PermissionDenied; `internal/hub/err_check.go:22-30` `authErr()` checked exactly those two codes. The behavior was implemented in the original failover feature commit (8bcb6208) without the task being closed. But the test suite never asserted the invariant — three existing failover tests covered happy path, skip-bad-peer, and all-bad-peers, none of them exercised "auth fails → walk stops". A future refactor could have silently deleted the auth-fast-fail branch and all three would still pass. Commit 22cffc27 added `TestFailoverClient_FailsFastOnAuthError` and closed the task.
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ dist/
.context/site/
.context/site-config/

# Generated reports (schema drift, audit dumps, etc.). Operator-local;
# regenerated on demand by the relevant `ctx ... check` / `ctx ... report`
# command. No need to version.
.context/reports/

# VS Code extension build artifacts
editors/vscode/node_modules/
editors/vscode/dist/
Expand Down
8 changes: 8 additions & 0 deletions internal/config/schema/field.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,24 @@ var RequiredFields = []string{
}

// Optional fields that may appear on message records.
//
// Grouped by concept for review; order is not load-bearing.
// New fields enter at the end of their concept group with a
// brief note on the version that introduced them when known.
var OptionalFields = []string{
"gitBranch", "slug", "requestId",
"thinkingMetadata", "todos", "permissionMode",
"logicalParentUuid", "isMeta", "compactMetadata",
"isVisibleInTranscriptOnly", "isCompactSummary",
"interruptedMessageId", // CC ≥ 2.1.~100: tracks parent of an interrupt
"agentId", "teamName", "agentName", "agentColor",
"promptId", "entrypoint", "agentSetting",
// CC ≥ 2.1.~110: skill/plugin invocation provenance.
"attributionPlugin", "attributionSkill",
"sourceToolAssistantUUID", "toolUseResult",
"sourceToolUseID", "origin", "planContent",
"isApiErrorMessage", "error", "apiError",
"apiErrorStatus", "errorDetails", // CC ≥ 2.1.~120: richer API-error envelope
}

// JSONL record type values.
Expand Down
15 changes: 12 additions & 3 deletions internal/config/schema/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,17 @@ const (

// Schema version and CC version range.
const (
// Version is the current schema version.
Version = "1.0.0"
// Version is the current schema version. Bumped to
// 1.1.0 on 2026-05-23 when five new optional fields
// (interruptedMessageId, attributionPlugin,
// attributionSkill, apiErrorStatus, errorDetails)
// were added to OptionalFields. MINOR bump because
// adding optional fields is backwards-compatible
// per semver \u2014 old records still validate.
Version = "1.1.0"
// CCVersionRange is the CC version range tested.
CCVersionRange = "2.1.2\u20132.1.92"
// 2.1.150 is the version observed in user-submitted
// drift reports; CC versions in between added the new
// fields incrementally.
CCVersionRange = "2.1.2\u20132.1.150"
)
31 changes: 31 additions & 0 deletions internal/journal/schema/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,37 @@ func TestKnownField(t *testing.T) {
}
}

// TestKnownField_PostV1FieldDrift pins the optional fields
// added to OptionalFields after the initial 1.0.0 schema —
// guards against silent regression of the drift-fix that
// landed on 2026-05-23. Each field name here corresponds to
// JSONL data observed in user-submitted journals from
// Claude Code versions beyond the 2.1.92 range covered by
// 1.0.0. If a future refactor drops one of these from
// OptionalFields, this test fires immediately and the
// schema-drift CLI starts complaining about it again.
func TestKnownField_PostV1FieldDrift(t *testing.T) {
s := Default()
for _, field := range []string{
"interruptedMessageId",
"attributionPlugin",
"attributionSkill",
"apiErrorStatus",
"errorDetails",
} {
t.Run(field, func(t *testing.T) {
for _, rt := range []string{"user", "assistant"} {
if !s.KnownField(rt, field) {
t.Errorf(
"%q should be known for %q (post-1.0 drift fix)",
field, rt,
)
}
}
})
}
}

func TestKnownRecordType(t *testing.T) {
s := Default()

Expand Down
119 changes: 119 additions & 0 deletions specs/fix-journal-schema-drift.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Absorb post-1.0 Claude Code Journal Field Drift

`ctx journal schema check` reported five unknown fields
in user JSONL records:

- `apiErrorStatus`
- `attributionPlugin`
- `attributionSkill`
- `errorDetails`
- `interruptedMessageId`

All five are optional fields Claude Code added to message
records in versions between 2.1.92 (end of the schema's
1.0.0 declared range) and 2.1.150 (the version users are
running today). The schema was ~60 minor versions stale
relative to the surface it was validating against.

## Problem

The 1.0.0 schema baseline was empirically derived from CC
versions 2.1.2–2.1.92 (`internal/config/schema/report.go`
constants `Version` and `CCVersionRange`). Field
expectations live in `internal/config/schema/field.go`
as `RequiredFields` and `OptionalFields` string slices.
When CC ships new optional fields, the schema check
flags them as drift until they're added to
`OptionalFields`. The check is a *passive* drift detector
— it does not auto-extend the schema — so each new field
requires a one-line addition plus a version bump.

The lag here was ~6 weeks of CC release cadence. Symptoms:

```
Schema drift detected in 27 file(s):
Unknown fields: apiErrorStatus, attributionPlugin,
attributionSkill, errorDetails, interruptedMessageId
```

End-users see this every time they run `ctx agent`,
`ctx journal source`, or any code path that triggers the
schema check. The noise is harmless (the parser still
ingests the records) but trains operators to ignore
schema-check output, which is exactly the failure mode
the check is meant to prevent.

## Solution

1. **Append the five fields to `OptionalFields`** in
`internal/config/schema/field.go`, grouped by concept
(error context, attribution, message metadata) with
brief version-provenance comments.
2. **Bump `Version` 1.0.0 → 1.1.0** — MINOR per semver,
because adding optional fields is backwards-compatible
(records written against 1.0.0 still validate cleanly).
3. **Bump `CCVersionRange` end 2.1.92 → 2.1.150** to
reflect the version range the new field set covers.
4. **Pin the new fields with a regression test**
(`TestKnownField_PostV1FieldDrift` in
`internal/journal/schema/schema_test.go`) so a future
refactor that drops one of these from `OptionalFields`
fires immediately rather than re-surfacing as noise in
user terminals.
5. **Gitignore generated reports**
(`.context/reports/`). The schema check writes a
detailed report under `.context/reports/schema-drift.md`
that's operator-local generated output, matching the
established ignore pattern for `.context/logs/`,
`.context/state/`, `.context/journal/`, etc. Without
the ignore, a `git add -A` could leak the report into
commits.

## Policy: additive field drift handling

For future CC field-drift fixes that match the same
shape (new optional field appears, schema check flags it):

- **MINOR bump** if the addition is backwards-compatible
(the common case for new optional fields).
- **MAJOR bump** if a field is removed or made required
retroactively (rare; would also need migration logic).
- **PATCH bump** for fixes to existing field handling
that don't change the declared field set.
- **CCVersionRange end** always bumps to the latest CC
version observed in the user-submitted drift report,
even if we can't pin the exact CC version that
introduced each field. (We don't run CC's full release
archaeology; the range marks "we've seen records from
CC versions up to N and they validate cleanly.")
- **Regression test** for each new field added: pin
`KnownField(rt, field) == true` so a future "let's
tidy" refactor can't silently drop the field and
re-introduce the drift.

## Out of Scope

- **Auto-extending schema** on drift detection. The
passive design is intentional — operators see drift,
decide whether to upgrade ctx or pin to a specific CC
version, and the maintainer team owns the per-version
evaluation. Auto-extension would silently mask
potentially-meaningful CC behavior changes.
- **Field-level type validation** (e.g., asserting
`apiErrorStatus` is a string, not a number). The
current check is field-presence only. A typed
validation pass is a separate concern.
- **Schema-version negotiation** in the parser. The
parser is permissive of unknown fields; only the
`check` command flags them. No negotiation needed.

## Verification

- `go test ./internal/journal/schema/...` —
`TestKnownField_PostV1FieldDrift` passes for each of
the five new fields against both `user` and
`assistant` record types.
- After install, `ctx journal schema check` reports zero
drift on user journal directories.
- `make lint` clean (one line-length wrap was needed to
fit a doc comment under 80 chars).
Loading
Loading