feat(voyage): S6 — v5.5 brief framing enforcement (brief_version 2.2)

Implements the CLAUDE.md cross-cutting invariant "brief framing must match
operator intent" as a controlled brief_version 2.1->2.2 bump (operator option A1).
Three defense layers, version-gated at >=2.2 so existing 2.0/2.1 briefs stay
valid (forward + backward compatible), mirroring the phase_signals >=2.1 gate:

- L1 framing: enum field (preserve|refine|replace|new-direction). Enum-checked
  on any version when present (BRIEF_INVALID_FRAMING); missing at >=2.2 ->
  BRIEF_MISSING_FRAMING. /trekbrief Phase 2.5 collects it BEFORE any brief prose
  (non-skippable, even in --quick).
- L2 memory alignment: new brief-reviewer dimension 6 comparing brief Intent/Goal
  + framing against operator memory for explicit contradictions; degrades to
  score 5 (N/A) when no memory context is supplied. Wired into Phase 4e gate
  (memory_alignment.score >= 4).
- L3 obligatory ## TL;DR (<=5 content lines) at >=2.2; soft cap ->
  BRIEF_TLDR_TOO_LONG warning.

trekreview briefs are exempt from the framing/TL;DR gate. Handover 1 PUBLIC
CONTRACT doc, README "What's new", and the CLAUDE.md invariant + agents table
(brief-reviewer 5->6 dimensions) updated to 2.2 (schema axis only; plugin
version badge + CHANGELOG remain S10).

Iron Law followed: validator tests red->green first. Tests 586 -> 606
(+20, 604 pass / 2 skip). claude plugin validate passes (pre-existing
CLAUDE.md root-context warning unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqBYc8Ltrk7LipyJmGxXiB
This commit is contained in:
Kjell Tore Guttormsen 2026-06-18 13:09:50 +02:00
commit 736ae55d66
10 changed files with 409 additions and 31 deletions

View file

@ -8,7 +8,7 @@ Voyage — a contract-driven Claude Code pipeline: brief, research, plan, execut
> **Trinity context (2026-05-13, informational).** Voyage is Tier 1 (per-task) of a three-tier architecture in active design under the author's private marketplace: Tier 2 `app-creator` (per-app — "what does the app need, what's the next brief?") produces briefs Voyage consumes; Tier 3 `app-factory` (per-portfolio — "which app needs me now?") aggregates state across multiple app-creator instances. Both are pre-implementation and will ship to Forgejo when ready. **Asymmetry is a hard invariant:** Voyage stays unaware of Tier 2/3. Handover 1 (brief format) is the only integration point — any compatible producer can feed Voyage, app-creator is not privileged. Brief-schema changes are therefore breaking changes for downstream consumers, formalized as a public contract in v5.4 — see `docs/HANDOVER-CONTRACTS.md` §Handover 1 (PUBLIC CONTRACT). > **Trinity context (2026-05-13, informational).** Voyage is Tier 1 (per-task) of a three-tier architecture in active design under the author's private marketplace: Tier 2 `app-creator` (per-app — "what does the app need, what's the next brief?") produces briefs Voyage consumes; Tier 3 `app-factory` (per-portfolio — "which app needs me now?") aggregates state across multiple app-creator instances. Both are pre-implementation and will ship to Forgejo when ready. **Asymmetry is a hard invariant:** Voyage stays unaware of Tier 2/3. Handover 1 (brief format) is the only integration point — any compatible producer can feed Voyage, app-creator is not privileged. Brief-schema changes are therefore breaking changes for downstream consumers, formalized as a public contract in v5.4 — see `docs/HANDOVER-CONTRACTS.md` §Handover 1 (PUBLIC CONTRACT).
> **Cross-cutting invariant: brief framing must match operator intent (2026-05-15).** Etablert etter residiv. Briefen er pipelinens source of truth; operatørens intent lever i hodet + i memory-filer (`feedback_*`, `project_*`); pipelinen tvinger ikke alignment. Høyere reasoning-kraft polerer feil premiss istedenfor å utfordre det. **Tre lag av forsvar (input-siden), alle BLOCKER ved brudd når v5.5 shipper:** (1) eksplisitt `framing: preserve|refine|replace|new-direction` i brief-frontmatter, `AskUserQuestion`-validert før brief-prosa skrives; (2) memory-alignment check som ny dimensjon i `brief-reviewer` — sammenlikner brief-prosa mot relevante memory-filer og rapporterer eksplisitte motsigelser; (3) obligatorisk `## TL;DR`-seksjon (≤ 5 linjer) øverst i `brief.md`. Implementeres i v5.5 (tracket i `STATE.md` § NESTE STEG når aktivt). Inntil shipping: operatør må manuelt sjekke at briefens framingord ikke motsier intent, særlig etter avvist iterasjon hvor "delta fra forrige" er en farlig default-ankring. > **Cross-cutting invariant: brief framing must match operator intent (2026-05-15).** Etablert etter residiv. Briefen er pipelinens source of truth; operatørens intent lever i hodet + i memory-filer (`feedback_*`, `project_*`); pipelinen tvinger ikke alignment. Høyere reasoning-kraft polerer feil premiss istedenfor å utfordre det. **Tre lag av forsvar (input-siden), implementert i S6 som `brief_version 2.2`-gate (v5.5), alle BLOCKER ved brudd for briefer som deklarerer ≥ 2.2:** (1) eksplisitt `framing: preserve|refine|replace|new-direction` i brief-frontmatter, `AskUserQuestion`-validert i `/trekbrief` Phase 2.5 før brief-prosa skrives (ikke-skippbar, også i `--quick`); enum-feil → `BRIEF_INVALID_FRAMING` (alle versjoner), fravær ved ≥ 2.2 → `BRIEF_MISSING_FRAMING`; (2) memory-alignment som dimensjon 6 i `brief-reviewer` — sammenlikner brief-prosa + `framing` mot relevante memory-filer, rapporterer kun eksplisitte motsigelser (degraderer til score 5 N/A uten memory-kontekst), wired til Phase 4e-gate (`memory_alignment.score ≥ 4`); (3) obligatorisk `## TL;DR`-seksjon (≤ 5 linjer, soft-cap → `BRIEF_TLDR_TOO_LONG`) øverst i `brief.md`. Eksisterende 2.0/2.1-briefer forblir gyldige (forward+backward-compat, speiler `phase_signals ≥ 2.1`-presedensen); `trekreview`-briefer er unntatt. **Schema-akse bumpet (2.1→2.2); plugin-versjon-badge + CHANGELOG bumpes ved den koordinerte releasen (S10).** Kontrakt-evolusjon dokumentert i `docs/HANDOVER-CONTRACTS.md` §Handover 1. Den gamle manuelle stopgap-sjekken er dermed retired for ≥ 2.2-briefer.
## Commands ## Commands
@ -39,7 +39,7 @@ Full flag reference for each command (modes, `--gates`, `--profile`, breaking ch
| git-historian | opus | Recent changes, ownership, hot files | | git-historian | opus | Recent changes, ownership, hot files |
| research-scout | opus | External docs for unfamiliar tech (conditional, planning only) | | research-scout | opus | External docs for unfamiliar tech (conditional, planning only) |
| convention-scanner | opus | Coding conventions: naming, style, error handling, test patterns | | convention-scanner | opus | Coding conventions: naming, style, error handling, test patterns |
| brief-reviewer | opus | Task brief quality (5 dimensions: completeness, consistency, testability, scope clarity, research plan validity) | | brief-reviewer | opus | Task brief quality (6 dimensions: completeness, consistency, testability, scope clarity, research plan validity, memory alignment) |
| brief-conformance-reviewer | opus | Brief conformance review (SC + Non-Goal traceability) | | brief-conformance-reviewer | opus | Brief conformance review (SC + Non-Goal traceability) |
| code-correctness-reviewer | opus | Code correctness review (7 dimensions) | | code-correctness-reviewer | opus | Code correctness review (7 dimensions) |
| review-coordinator | opus | Judge Agent — dedup + reasonableness filter + verdict | | review-coordinator | opus | Judge Agent — dedup + reasonableness filter + verdict |

View file

@ -10,6 +10,12 @@
A [Claude Code](https://docs.anthropic.com/en/docs/claude-code) plugin for deep implementation planning, multi-source research, autonomous execution, independent post-hoc review, and zero-friction multi-session resumption. Six commands, one pipeline: A [Claude Code](https://docs.anthropic.com/en/docs/claude-code) plugin for deep implementation planning, multi-source research, autonomous execution, independent post-hoc review, and zero-friction multi-session resumption. Six commands, one pipeline:
> **What's new — brief framing enforcement (`brief_version 2.2`)**`/trekbrief` now opens with a **framing declaration** (Phase 2.5): every brief records `framing: preserve | refine | replace | new-direction` *before* any prose is written, so the plan can't quietly polish a wrong premise. Three version-gated layers ship at `brief_version ≥ 2.2`:
> - **Framing field** — a required `framing` enum in frontmatter; enum-checked on any version, and a missing value at `≥ 2.2` is a blocker. Collected in Phase 2.5 before prose, non-skippable even in `--quick`.
> - **Memory alignment** — a new `brief-reviewer` dimension compares the brief's Intent/Goal + framing against operator memory and flags *explicit* contradictions (a no-op that scores N/A when no memory is supplied).
> - **Obligatory `## TL;DR`** — a ≤ 5-line, framing-anchored summary at the top of every brief, so a wrong premise is caught at a glance.
> Existing `2.0`/`2.1` briefs stay valid (forward + backward compatible), mirroring the `phase_signals ≥ 2.1` gate. This is a **schema-axis** change — the plugin version badge bumps at the next coordinated release; see [docs/HANDOVER-CONTRACTS.md](docs/HANDOVER-CONTRACTS.md) §Handover 1 for the contract evolution.
>
> **What's new in v5.1.1** — Remediation patch closing 11 of 12 findings from the v5.1.0 review (SC8 dogfood gate scheduled for sesjon 8). Lukker: > **What's new in v5.1.1** — Remediation patch closing 11 of 12 findings from the v5.1.0 review (SC8 dogfood gate scheduled for sesjon 8). Lukker:
> - **Bug fixes (load-bearing):** YAML-number bypass in `brief-validator` (#8) + doc-consistency pin lock-in (#11) so the gate fires for both quoted and unquoted `brief_version`. > - **Bug fixes (load-bearing):** YAML-number bypass in `brief-validator` (#8) + doc-consistency pin lock-in (#11) so the gate fires for both quoted and unquoted `brief_version`.
> - **Wiring:** `phase-signal-resolver` helper wired into all 4 downstream commands (#9) with TDD pair `resolvePhaseModel` + profile-resolver non-interference test (#4 SC5); `brief-validator` gate required uniformly in `/trekresearch` + `/trekexecute` (#12). > - **Wiring:** `phase-signal-resolver` helper wired into all 4 downstream commands (#9) with TDD pair `resolvePhaseModel` + profile-resolver non-interference test (#4 SC5); `brief-validator` gate required uniformly in `/trekresearch` + `/trekexecute` (#12).

View file

@ -35,8 +35,12 @@ missing, vague, or contradictory.
## Input ## Input
You receive the path to a brief file (trekbrief v2.0 format, produced by You receive the path to a brief file (trekbrief format, produced by
`/trekbrief`). Read it and evaluate its quality across five dimensions. `/trekbrief`). Read it and evaluate its quality across six dimensions.
The caller may also supply **operator memory context** (paths or excerpts of
`feedback_*` / `project_*` facts) in your prompt for the memory-alignment
dimension. If none is supplied, that dimension scores 5 (N/A) — see dimension 6.
A brief has these sections (see template for full structure): A brief has these sections (see template for full structure):
- `## Intent` — why the work matters (load-bearing) - `## Intent` — why the work matters (load-bearing)
@ -148,6 +152,31 @@ Flag as **research-plan invalid** if:
- `research_topics` count in frontmatter does not match section count - `research_topics` count in frontmatter does not match section count
- `research_status: complete` but research files are missing on disk - `research_status: complete` but research files are missing on disk
### 6. Memory alignment (NEW in v5.5)
The brief is the pipeline's source of truth, but the operator's *real* intent
also lives in memory facts (`feedback_*`, `project_*`). When the two diverge,
downstream reasoning power polishes a wrong premise instead of challenging it.
This dimension is the second layer of the framing-alignment defense.
The caller MAY supply operator memory excerpts or file paths in your prompt.
- **If memory context IS supplied:** compare the brief — especially `## Intent`,
`## Goal`, and the frontmatter `framing:` value — against those facts. Report
**EXPLICIT contradictions only** (a brief claim that a memory fact directly
negates), never vibes or soft mismatches.
- **If NO memory context is supplied:** score `5`, set `contradictions: []`, and
note "no memory context supplied". Do not speculate or invent contradictions.
Flag as **memory-misaligned** if:
- The frontmatter `framing:` value contradicts memory (e.g. `framing: preserve`
but memory records the prior direction was explicitly abandoned)
- `## Intent` or `## Goal` asserts a premise a memory fact directly negates
- A Constraint or Preference contradicts a recorded operator preference
For each contradiction, capture: the brief claim (quoted), the memory fact
(quoted), and the source file. These feed `/trekbrief` Phase 4e follow-ups.
## Rating ## Rating
Rate each dimension on two parallel scales: Rate each dimension on two parallel scales:
@ -193,6 +222,7 @@ find it by reading the last `json` code fence.
| Testability | {Pass/Weak/Fail} | {brief summary or "None"} | | Testability | {Pass/Weak/Fail} | {brief summary or "None"} |
| Scope clarity | {Pass/Weak/Fail} | {brief summary or "None"} | | Scope clarity | {Pass/Weak/Fail} | {brief summary or "None"} |
| Research Plan | {Pass/Weak/Fail} | {brief summary or "None"} | | Research Plan | {Pass/Weak/Fail} | {brief summary or "None"} |
| Memory alignment | {Pass/Weak/Fail} | {brief summary, "None", or "N/A — no memory"} |
### Findings ### Findings
@ -224,6 +254,12 @@ information that would strengthen the brief. List only if actionable.}
{ "topic": "{topic title}", "issue": "{what is missing or wrong}" } { "topic": "{topic title}", "issue": "{what is missing or wrong}" }
] ]
}, },
"memory_alignment": {
"score": 1-5,
"contradictions": [
{ "brief_claim": "{quoted brief text}", "memory_fact": "{quoted memory fact}", "file": "{source file}" }
]
},
"verdict": "PROCEED | PROCEED_WITH_RISKS | REVISE" "verdict": "PROCEED | PROCEED_WITH_RISKS | REVISE"
} }
``` ```

View file

@ -99,6 +99,41 @@ If the directory already exists and is non-empty, warn and ask:
Use `AskUserQuestion` with three options. If "pick new slug", ask for a Use `AskUserQuestion` with three options. If "pick new slug", ask for a
new slug and restart Phase 2. new slug and restart Phase 2.
## Phase 2.5 — Framing declaration (v5.5)
Before gathering ANY brief content, the operator MUST declare how this brief
relates to prior operator intent. This is the **first layer of the
framing-alignment defense** (CLAUDE.md cross-cutting invariant): the premise is
declared explicitly *before* the interview can drift, and long before `/trekplan`
can polish a wrong premise with reasoning power.
**This runs BEFORE any brief prose is drafted.** The committed value is written to
brief frontmatter as `framing: <value>` in Step 4a and is REQUIRED for
`brief_version: "2.2"` (the validator emits `BRIEF_MISSING_FRAMING` otherwise).
Ask via `AskUserQuestion` — one question, four canonical options:
| Option | Maps to `framing:` | Meaning |
|--------|--------------------|---------|
| **Preserve** | `preserve` | Same intent as before; this brief continues a prior direction unchanged. |
| **Refine** | `refine` | Same core intent, sharpened or narrowed scope. |
| **Replace** | `replace` | Supersedes a prior brief's approach; intent re-stated from scratch. |
| **New direction** | `new-direction` | Net-new intent; no prior brief to anchor against. |
Commit the answer to `state.framing` immediately.
**No safe default.** `framing` cannot be guessed — the danger the invariant guards
against is exactly "delta from last is a dangerous default anchor after a rejected
iteration." Therefore **this question is asked even in `--quick` mode** and is the
one dialog that has no skip path. If the operator force-stops here, re-surface the
four options once more; the brief cannot be written at `brief_version: "2.2"`
without a committed framing value.
Report:
```
Framing: {preserve | refine | replace | new-direction}
```
## Phase 3 — Completeness loop ## Phase 3 — Completeness loop
Phase 3 is a **section-driven completeness loop**. Instead of a numbered Phase 3 is a **section-driven completeness loop**. Instead of a numbered
@ -424,7 +459,15 @@ Build the brief text from Phase 3 state by filling the template:
- **Frontmatter:** populate `task`, `slug`, `project_dir`, `research_topics` - **Frontmatter:** populate `task`, `slug`, `project_dir`, `research_topics`
(count of topics), `research_status: pending`, `auto_research: false` (count of topics), `research_status: pending`, `auto_research: false`
(will update in Phase 5 if user opts in), `interview_turns` (total (will update in Phase 5 if user opts in), `interview_turns` (total
questions asked across Phase 3 + Phase 4), `source: interview`. questions asked across Phase 3 + Phase 4), `source: interview`. Set
`brief_version: "2.2"` and `framing: <state.framing>` (committed in Phase
2.5 — never omit; the validator blocks a 2.2 brief without it).
- **TL;DR (v5.5, required at 2.2):** write a `## TL;DR` section (≤ 5 content
lines) at the very top of the body, before `## Intent`. It is the
framing-anchored one-glance summary — what the brief asks for and how it
relates to prior intent given `framing: <state.framing>`. Drafting it FIRST
forces the wrong-premise check before the rest of the prose is written. Keep
it to ≤ 5 lines (the validator warns with `BRIEF_TLDR_TOO_LONG` above that).
- **Intent:** expand the user's motivation into 35 sentences. Load-bearing. - **Intent:** expand the user's motivation into 35 sentences. Load-bearing.
- **Goal:** concrete end state. - **Goal:** concrete end state.
- **Non-Goals:** from state, or "- None explicitly stated" bullet if empty. - **Non-Goals:** from state, or "- None explicitly stated" bullet if empty.
@ -446,12 +489,22 @@ final file is only written after the gate passes).
**Step 4c — Launch brief-reviewer** **Step 4c — Launch brief-reviewer**
**Gather memory context first (v5.5, layer 2 of the framing defense).** If the
operator's environment exposes memory facts (e.g. an auto-memory `MEMORY.md` plus
`feedback_*` / `project_*` topic files), collect the paths or excerpts of those
relevant to this task. This is best-effort and environment-dependent: if no memory
is available, pass nothing — the reviewer scores the memory-alignment dimension
`5` (N/A) when no context is supplied.
Launch the `brief-reviewer` agent (foreground, blocking) with the prompt: Launch the `brief-reviewer` agent (foreground, blocking) with the prompt:
> "Review this task brief for quality: `{PROJECT_DIR}/brief.md.draft`. > "Review this task brief for quality: `{PROJECT_DIR}/brief.md.draft`.
> Check completeness, consistency, testability, scope clarity, and > Check completeness, consistency, testability, scope clarity,
> research-plan validity. Report findings, verdict, and the required > research-plan validity, and memory alignment. Report findings, verdict, and
> machine-readable JSON block." > the required machine-readable JSON block.
> Operator memory context (compare the brief's Intent/Goal and its declared
> `framing:` value against these for EXPLICIT contradictions only):
> {memory paths or excerpts, or "none supplied"}."
**Step 4d — Parse JSON scores** **Step 4d — Parse JSON scores**
@ -460,12 +513,13 @@ Extract per-dimension scores:
``` ```
review = { review = {
completeness: { score, gaps }, completeness: { score, gaps },
consistency: { score, issues }, consistency: { score, issues },
testability: { score, weak_criteria }, testability: { score, weak_criteria },
scope_clarity: { score, unclear_sections }, scope_clarity: { score, unclear_sections },
research_plan: { score, invalid_topics }, research_plan: { score, invalid_topics },
verdict: "PROCEED | PROCEED_WITH_RISKS | REVISE" memory_alignment:{ score, contradictions }, # v5.5 — layer 2
verdict: "PROCEED | PROCEED_WITH_RISKS | REVISE"
} }
``` ```
@ -484,6 +538,10 @@ The gate **passes** when all of the following are true:
- `testability.score ≥ 4` - `testability.score ≥ 4`
- `scope_clarity.score ≥ 4` - `scope_clarity.score ≥ 4`
- `research_plan.score == 5` - `research_plan.score == 5`
- `memory_alignment.score ≥ 4` (v5.5 — a score ≤ 3 means the reviewer found an
EXPLICIT contradiction between the brief and operator memory; this is a
framing-alignment blocker, not a wording nit. A `5` is also returned when no
memory context was supplied, so this never blocks environments without memory.)
(Research Plan requires a perfect score because its format is checked (Research Plan requires a perfect score because its format is checked
mechanically: ends in `?`, `Required for plan steps` filled, scope is mechanically: ends in `?`, `Required for plan steps` filled, scope is
@ -499,9 +557,11 @@ stumble.)
**If gate fails AND iteration count < 3:** **If gate fails AND iteration count < 3:**
1. Identify the weakest dimension (lowest score; tie broken by priority: 1. Identify the weakest dimension (lowest score; tie broken by priority:
research_plan > testability > completeness > consistency > scope_clarity). research_plan > memory_alignment > testability > completeness >
consistency > scope_clarity).
2. Generate a targeted follow-up question from the dimension's detail 2. Generate a targeted follow-up question from the dimension's detail
field (gaps / issues / weak_criteria / unclear_sections / invalid_topics). field (gaps / issues / weak_criteria / unclear_sections / invalid_topics /
contradictions).
Example generators: Example generators:
- `completeness.gaps: ["Non-Goals empty, unclear if deliberate"]` - `completeness.gaps: ["Non-Goals empty, unclear if deliberate"]`
→ "You did not specify anything out-of-scope. Is that deliberate, or → "You did not specify anything out-of-scope. Is that deliberate, or
@ -514,6 +574,10 @@ stumble.)
→ "For research topic 'JWT': which plan steps depend on the answer? → "For research topic 'JWT': which plan steps depend on the answer?
Give one or two concrete kinds of step (e.g., 'library selection', Give one or two concrete kinds of step (e.g., 'library selection',
'threat model', 'migration strategy')." 'threat model', 'migration strategy')."
- `memory_alignment.contradictions: [{"brief_claim":"continue the REST approach","memory_fact":"team decided to move to GraphQL","file":"project_api.md"}]`
→ "Your brief's framing says 'preserve', but memory records the team
moved off REST to GraphQL. Is this brief intentionally reviving REST,
or should the framing be 'replace' / 'new-direction'?"
3. Ask via `AskUserQuestion`. Record the answer into Phase 3 state. 3. Ask via `AskUserQuestion`. Record the answer into Phase 3 state.
4. Return to Step 4a with incremented iteration count. The reviewer sees 4. Return to Step 4a with incremented iteration count. The reviewer sees
an updated draft, so you MUST re-read the brief and regenerate the an updated draft, so you MUST re-read the brief and regenerate the

View file

@ -10,7 +10,7 @@ Each artifact carries an explicit version field. Schema bumps are coordinated:
| Artifact | Field | Current | | Artifact | Field | Current |
|---|---|---| |---|---|---|
| `brief.md` | `brief_version` (frontmatter) | `2.1` | | `brief.md` | `brief_version` (frontmatter) | `2.2` |
| `research/*.md` | (implicit; tracked via `type: trekresearch-brief`) | unversioned | | `research/*.md` | (implicit; tracked via `type: trekresearch-brief`) | unversioned |
| `plan.md` | `plan_version` (frontmatter) | `1.7` | | `plan.md` | `plan_version` (frontmatter) | `1.7` |
| `progress.json` | `schema_version` (top-level) | `"1"` | | `progress.json` | `schema_version` (top-level) | `"1"` |
@ -43,7 +43,7 @@ Every validator exposes a CLI: `node lib/validators/<name>.mjs --json <path>` re
## Handover 1 — `brief.md` → research/ (PUBLIC CONTRACT) ## Handover 1 — `brief.md` → research/ (PUBLIC CONTRACT)
**Stability tier: PUBLIC CONTRACT.** Handover 1 is the *only* public integration boundary of the pipeline: `brief.md` is what an upstream producer hands to Voyage, and Voyage consumes it without any knowledge of who produced it. This asymmetry is a hard invariant (see `CLAUDE.md` §Trinity context) — the interactive `/trekbrief` interview is just one producer; a `manual` brief, or an external per-app / per-portfolio producer, is equally valid as long as the artifact conforms to the schema below. No producer is privileged. The consequence: changing this schema — renaming or removing a field, narrowing an enum, or promoting an optional field to required — is a **breaking change for every downstream consumer** and MUST follow the [breaking-change protocol](#breaking-change-protocol) (version bump + N-1 compatibility window). Additive *optional* fields are non-breaking by design: the validator tolerates unknown frontmatter keys silently (forward-compat), so a newer brief still validates against an older consumer. As of the v5.4 contract formalization, `brief_version` **2.1** is the frozen public-contract baseline. **Stability tier: PUBLIC CONTRACT.** Handover 1 is the *only* public integration boundary of the pipeline: `brief.md` is what an upstream producer hands to Voyage, and Voyage consumes it without any knowledge of who produced it. This asymmetry is a hard invariant (see `CLAUDE.md` §Trinity context) — the interactive `/trekbrief` interview is just one producer; a `manual` brief, or an external per-app / per-portfolio producer, is equally valid as long as the artifact conforms to the schema below. No producer is privileged. The consequence: changing this schema — renaming or removing a field, narrowing an enum, or promoting an optional field to required — is a **breaking change for every downstream consumer** and MUST follow the [breaking-change protocol](#breaking-change-protocol) (version bump + N-1 compatibility window). Additive *optional* fields are non-breaking by design: the validator tolerates unknown frontmatter keys silently (forward-compat), so a newer brief still validates against an older consumer. The v5.4 contract formalization froze `brief_version` **2.1** as the public-contract baseline; **v5.5 evolves it to `2.2`** under the breaking-change protocol — adding the required `framing` field + `## TL;DR` section gated at ≥ 2.2 (existing 2.0/2.1 briefs stay valid). See the Versioning paragraph below for the 2.2 details.
**Producer:** `/trekbrief` Phase 4g (after `brief-reviewer` stop-gate passes or iteration cap is hit). **Producer:** `/trekbrief` Phase 4g (after `brief-reviewer` stop-gate passes or iteration cap is hit).
@ -58,7 +58,7 @@ Every validator exposes a CLI: `node lib/validators/<name>.mjs --json <path>` re
| Field | Type | Required | Allowed values | Notes | | Field | Type | Required | Allowed values | Notes |
|---|---|---|---|---| |---|---|---|---|---|
| `type` | string | yes | `trekbrief` | Hard-coded discriminator | | `type` | string | yes | `trekbrief` | Hard-coded discriminator |
| `brief_version` | string | yes | `"2.1"` (current) | Bump on schema change | | `brief_version` | string | yes | `"2.2"` (current) | Bump on schema change |
| `created` | date | yes | YYYY-MM-DD | | | `created` | date | yes | YYYY-MM-DD | |
| `task` | string | yes | one-line description | | | `task` | string | yes | one-line description | |
| `slug` | string | yes | URL-safe slug | Used in project_dir | | `slug` | string | yes | URL-safe slug | Used in project_dir |
@ -71,8 +71,10 @@ Every validator exposes a CLI: `node lib/validators/<name>.mjs --json <path>` re
| `brief_quality` | string | optional | `complete \| partial` | Set when iteration cap is hit | | `brief_quality` | string | optional | `complete \| partial` | Set when iteration cap is hit |
| `phase_signals` | list | optional (v5.1+) | list of `{phase, effort?, model?}` entries | Per-phase effort + model commitment from Phase 3.5. Mutually exclusive with `phase_signals_partial`. | | `phase_signals` | list | optional (v5.1+) | list of `{phase, effort?, model?}` entries | Per-phase effort + model commitment from Phase 3.5. Mutually exclusive with `phase_signals_partial`. |
| `phase_signals_partial` | bool | optional (v5.1+) | `true` | Force-stop record from Phase 3.5. Mutually exclusive with `phase_signals`. | | `phase_signals_partial` | bool | optional (v5.1+) | `true` | Force-stop record from Phase 3.5. Mutually exclusive with `phase_signals`. |
| `framing` | string | **required at ≥ 2.2** (v5.5) | `preserve \| refine \| replace \| new-direction` | How this brief relates to prior operator intent. Enum-checked on any version when present; missing → `BRIEF_MISSING_FRAMING` at `brief_version ≥ 2.2`. Layer 1 of the framing-alignment defense. |
**Body invariants:** required sections (validator runs in strict mode at write-time, soft mode at read-time): **Body invariants:** required sections (validator runs in strict mode at write-time, soft mode at read-time):
- `## TL;DR`**required at `brief_version ≥ 2.2`** (v5.5); ≤ 5 content lines (soft cap → `BRIEF_TLDR_TOO_LONG` warning). Layer 3 of the framing-alignment defense.
- `## Intent` - `## Intent`
- `## Goal` - `## Goal`
- `## Success Criteria` - `## Success Criteria`
@ -89,11 +91,14 @@ Optional but standard sections: `## Non-Goals`, `## Constraints`, `## Preference
| Status enum | every read | `research_status ∈ allowed values` | | Status enum | every read | `research_status ∈ allowed values` |
| **State machine** | every read | `research_topics > 0 && research_status === "skipped"` requires `brief_quality === "partial"` | | **State machine** | every read | `research_topics > 0 && research_status === "skipped"` requires `brief_quality === "partial"` |
| **v5.1 sequencing gate** | every read | `brief_version ≥ 2.1` requires `phase_signals` (list) OR `phase_signals_partial: true` — error `BRIEF_V51_MISSING_SIGNALS` on miss. Validator-only enforcement; commands surface, don't re-enforce. | | **v5.1 sequencing gate** | every read | `brief_version ≥ 2.1` requires `phase_signals` (list) OR `phase_signals_partial: true` — error `BRIEF_V51_MISSING_SIGNALS` on miss. Validator-only enforcement; commands surface, don't re-enforce. |
| Body sections | strict only | All `BRIEF_BODY_SECTIONS` present | | **v5.5 framing gate** | every read | `framing` enum-checked on any version when present (`BRIEF_INVALID_FRAMING`); at `brief_version ≥ 2.2` a missing `framing``BRIEF_MISSING_FRAMING` and a missing `## TL;DR``BRIEF_MISSING_SECTION` (strict) / warning (soft). `trekreview` briefs are exempt. |
| Body sections | strict only | All `BRIEF_BODY_SECTIONS` present (`## TL;DR` added at ≥ 2.2) |
**State machine** detail: a brief that says it has research topics but skipped them must explicitly admit it (via `brief_quality: partial`). This is the most common failure mode the validator catches. **State machine** detail: a brief that says it has research topics but skipped them must explicitly admit it (via `brief_quality: partial`). This is the most common failure mode the validator catches.
**Versioning:** current is `2.1` (v5.1 — adds optional `phase_signals` + `phase_signals_partial`). The forward-compat policy in `brief-validator.mjs` header still applies: unknown frontmatter keys flow through silently, so a `2.1` brief still validates against pre-v5.1 consumers. The version bump exists because v2.1 activates the **version-conditional sequencing gate** (above) — the only check in the validator that triggers on `brief_version` rather than field-presence. There are no live `1.x` briefs; remove legacy paths in next major. The v5.4 contract formalization **freezes this schema as the public-contract baseline** (see the PUBLIC CONTRACT callout under the Handover 1 heading): per the S3 effort-axis decision, `phase_signals` stays **optional**, and the v2.1 sequencing gate (`phase_signals` **or** `phase_signals_partial`) is the stability mechanism. Promoting `phase_signals` to required would be a future breaking change (→ `3.0`) under the protocol above — explicitly *not* part of v5.4. **Versioning:** current is `2.1` (v5.1 — adds optional `phase_signals` + `phase_signals_partial`). The forward-compat policy in `brief-validator.mjs` header still applies: unknown frontmatter keys flow through silently, so a `2.1` brief still validates against pre-v5.1 consumers. The version bump exists because v2.1 activates the **version-conditional sequencing gate** (above) — the only check in the validator that triggers on `brief_version` rather than field-presence. There are no live `1.x` briefs; remove legacy paths in next major. The v5.4 contract formalization **froze `2.1` as the public-contract baseline** (see the PUBLIC CONTRACT callout under the Handover 1 heading): per the S3 effort-axis decision, `phase_signals` stays **optional**, and the v2.1 sequencing gate (`phase_signals` **or** `phase_signals_partial`) is the stability mechanism. Promoting `phase_signals` to required would be a future breaking change under the protocol above — explicitly *not* part of v5.4.
**v5.5 → `2.2` (framing enforcement).** `2.2` adds two **required-at-2.2** elements — the `framing` enum field and the `## TL;DR` body section — gated identically to the v2.1 mechanism: the new requirements fire only on `brief_version ≥ 2.2`, so every existing `2.0` / `2.1` brief still validates (forward- and backward-compatible). Because adding required elements is a breaking change for any producer that declares `2.2`, this is a controlled version bump under the breaking-change protocol: downstream producers (e.g. a Tier-2 per-app producer) that emit `brief_version: "2.2"` MUST also emit `framing` + `## TL;DR`. The framing enum is additionally enforced on *any* version when the field is present (`BRIEF_INVALID_FRAMING`). The three framing-alignment layers (framing field, `brief-reviewer` memory-alignment dimension, obligatory TL;DR) implement the `CLAUDE.md` cross-cutting invariant. Note: the *plugin* version bump + CHANGELOG entry for this schema change land at the coordinated release (matrix §S10), separate from this schema axis.
**Failure modes:** **Failure modes:**
- `BRIEF_NOT_FOUND` → consumer halts with a usage message - `BRIEF_NOT_FOUND` → consumer halts with a usage message

View file

@ -18,6 +18,23 @@ export const BRIEF_RESEARCH_STATUS_VALUES = ['pending', 'in_progress', 'complete
export const BRIEF_BODY_SECTIONS = ['Intent', 'Goal', 'Success Criteria']; export const BRIEF_BODY_SECTIONS = ['Intent', 'Goal', 'Success Criteria'];
export const PHASE_SIGNAL_PHASES = Object.freeze(['research', 'plan', 'execute', 'review']); export const PHASE_SIGNAL_PHASES = Object.freeze(['research', 'plan', 'execute', 'review']);
export const EFFORT_LEVELS = Object.freeze(['low', 'standard', 'high']); export const EFFORT_LEVELS = Object.freeze(['low', 'standard', 'high']);
// v5.5 — framing: how this brief relates to prior operator intent (the first layer
// of the framing-alignment defense). Required at brief_version ≥ 2.2.
export const BRIEF_FRAMING_VALUES = Object.freeze(['preserve', 'refine', 'replace', 'new-direction']);
// v5.5 — obligatory TL;DR section (≤ 5 content lines) at the top of brief.md,
// gated at brief_version ≥ 2.2. Soft cap is a warning, not a blocker.
export const BRIEF_TLDR_MAX_LINES = 5;
// Extract the raw text of a `## {heading}` section body (between its heading line
// and the next `## ` heading, or end of document). Returns null if absent.
function extractSection(body, heading) {
const re = new RegExp(`^##\\s+${heading}\\b.*$`, 'm');
const m = re.exec(body);
if (!m) return null;
const after = body.slice(m.index + m[0].length);
const next = after.search(/^##\s/m);
return next === -1 ? after : after.slice(0, next);
}
function getRequiredFields(type) { function getRequiredFields(type) {
return type === 'trekreview' ? REVIEW_AS_BRIEF_REQUIRED_FRONTMATTER : BRIEF_REQUIRED_FRONTMATTER; return type === 'trekreview' ? REVIEW_AS_BRIEF_REQUIRED_FRONTMATTER : BRIEF_REQUIRED_FRONTMATTER;
@ -88,12 +105,23 @@ export function validateBriefContent(text, opts = {}) {
// a string ("2.1") or a number (2.1). v5.1.0 shipped with an unquoted-2.1 template // a string ("2.1") or a number (2.1). v5.1.0 shipped with an unquoted-2.1 template
// that silently bypassed this gate — fix locked in by quoting the template AND // that silently bypassed this gate — fix locked in by quoting the template AND
// accepting both shapes here as defense-in-depth (v5.1.1, finding 3c834097/df1435a2). // accepting both shapes here as defense-in-depth (v5.1.1, finding 3c834097/df1435a2).
// v5.5 — framing enum check fires on ANY version when the field is present but
// malformed. The missing-framing BLOCKER below is version-gated (≥ 2.2).
if ('framing' in fm && !BRIEF_FRAMING_VALUES.includes(fm.framing)) {
errors.push(issue(
'BRIEF_INVALID_FRAMING',
`framing "${fm.framing}" not in [${BRIEF_FRAMING_VALUES.join(', ')}]`,
'framing declares how this brief relates to prior operator intent.',
));
}
if (typeof fm.brief_version === 'string' || typeof fm.brief_version === 'number') { if (typeof fm.brief_version === 'string' || typeof fm.brief_version === 'number') {
const vm = String(fm.brief_version).match(/^(\d+)\.(\d+)$/); const vm = String(fm.brief_version).match(/^(\d+)\.(\d+)$/);
if (vm) { if (vm) {
const major = Number(vm[1]); const major = Number(vm[1]);
const minor = Number(vm[2]); const minor = Number(vm[2]);
const atLeast21 = major > 2 || (major === 2 && minor >= 1); const atLeast21 = major > 2 || (major === 2 && minor >= 1);
const atLeast22 = major > 2 || (major === 2 && minor >= 2);
if (atLeast21 && !hasSignals && !hasPartial && fm.type !== 'trekreview') { if (atLeast21 && !hasSignals && !hasPartial && fm.type !== 'trekreview') {
errors.push(issue( errors.push(issue(
'BRIEF_V51_MISSING_SIGNALS', 'BRIEF_V51_MISSING_SIGNALS',
@ -101,6 +129,29 @@ export function validateBriefContent(text, opts = {}) {
'Re-run /trekbrief — Phase 3.5 collects per-phase effort + model signals.', 'Re-run /trekbrief — Phase 3.5 collects per-phase effort + model signals.',
)); ));
} }
// v5.5 framing enforcement — gated at ≥ 2.2 (trekreview briefs are exempt).
if (atLeast22 && fm.type !== 'trekreview') {
if (!('framing' in fm)) {
errors.push(issue(
'BRIEF_MISSING_FRAMING',
'brief_version ≥ 2.2 requires a framing: field',
`Set framing to one of [${BRIEF_FRAMING_VALUES.join(', ')}] — /trekbrief Phase 2.5 collects it before any brief prose is written.`,
));
}
const tldr = extractSection(body, 'TL;DR');
if (tldr === null) {
const tldrIssue = issue('BRIEF_MISSING_SECTION', 'Required body section missing: ## TL;DR');
if (strict) errors.push(tldrIssue); else warnings.push(tldrIssue);
} else {
const lines = tldr.split('\n').map(l => l.trim()).filter(Boolean);
if (lines.length > BRIEF_TLDR_MAX_LINES) {
warnings.push(issue(
'BRIEF_TLDR_TOO_LONG',
`## TL;DR has ${lines.length} content lines (max ${BRIEF_TLDR_MAX_LINES}) — keep it to a one-glance summary`,
));
}
}
}
} }
} }

View file

@ -1,6 +1,6 @@
--- ---
type: trekbrief type: trekbrief
brief_version: "2.1" brief_version: "2.2"
created: {YYYY-MM-DD} created: {YYYY-MM-DD}
task: "{one-line task description}" task: "{one-line task description}"
slug: {slug} slug: {slug}
@ -10,6 +10,12 @@ research_status: pending # pending | in_progress | complete | skipped
auto_research: false # true if user opted into Claude-managed research auto_research: false # true if user opted into Claude-managed research
interview_turns: {N} interview_turns: {N}
source: {interview | manual} source: {interview | manual}
# v5.5 — framing: how this brief relates to prior operator intent. REQUIRED at
# brief_version ≥ 2.2. One of: preserve | refine | replace | new-direction.
# AskUserQuestion-validated in /trekbrief Phase 2.5 BEFORE any brief prose is
# written — the first layer of the framing-alignment defense (guards against the
# plan polishing a wrong premise after a rejected iteration).
framing: {preserve | refine | replace | new-direction}
# v5.1 — per-phase effort + model signal (Phase 3.5). # v5.1 — per-phase effort + model signal (Phase 3.5).
# `effort` ∈ {low, standard, high}. Omit `model:` for `standard` so composition # `effort` ∈ {low, standard, high}. Omit `model:` for `standard` so composition
# falls through to profile resolver. Force-stop alternative is the commented # falls through to profile resolver. Force-stop alternative is the commented
@ -33,6 +39,15 @@ phase_signals:
> reads it to produce the implementation plan. Every decision in the plan must > reads it to produce the implementation plan. Every decision in the plan must
> trace back to content in this brief. > trace back to content in this brief.
## TL;DR
*≤ 5 lines. The framing-anchored one-glance summary: what this brief asks for and
how it relates to prior operator intent (framing: {preserve | refine | replace |
new-direction}). Written FIRST so a reader catches a wrong premise before reading
the full brief. Required at brief_version ≥ 2.2.*
{≤5-line summary.}
## Intent ## Intent
*Why are we doing this? What is the motivation, user need, or strategic context? *Why are we doing this? What is the motivation, user need, or strategic context?

View file

@ -21,6 +21,8 @@ import { parseDocument } from '../../lib/util/frontmatter.mjs';
const HERE = dirname(fileURLToPath(import.meta.url)); const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..'); const ROOT = join(HERE, '..', '..');
const COMMAND_FILE = join(ROOT, 'commands', 'trekbrief.md'); const COMMAND_FILE = join(ROOT, 'commands', 'trekbrief.md');
const REVIEWER_FILE = join(ROOT, 'agents', 'brief-reviewer.md');
const TEMPLATE_FILE = join(ROOT, 'templates', 'trekbrief-template.md');
const FIXTURE = (name) => join(ROOT, 'tests', 'fixtures', name); const FIXTURE = (name) => join(ROOT, 'tests', 'fixtures', name);
function read() { function read() {
@ -97,6 +99,62 @@ test('trekbrief — SC1: missing phase_signals + brief_version 2.1 triggers BRIE
); );
}); });
// --- v5.5 — framing enforcement + TL;DR + memory-alignment prose-pins ---
test('trekbrief — v5.5 Phase 2.5 framing declaration heading present', () => {
const text = read();
assert.match(text, /^## Phase 2\.5 — Framing declaration/m,
'Phase 2.5 framing-declaration heading missing from commands/trekbrief.md');
});
test('trekbrief — v5.5 Phase 2.5 references all four framing values', () => {
const text = read();
const start = text.indexOf('## Phase 2.5');
const section = text.slice(start, text.indexOf('## Phase 3', start));
for (const v of ['preserve', 'refine', 'replace', 'new-direction']) {
assert.ok(section.includes(v), `Phase 2.5 missing framing value "${v}"`);
}
});
test('trekbrief — v5.5 Phase 2.5 runs before any brief prose (precedes Phase 3)', () => {
const text = read();
assert.ok(text.indexOf('## Phase 2.5') < text.indexOf('## Phase 3'),
'Phase 2.5 must come before the completeness loop (before prose)');
assert.ok(text.includes('even in `--quick` mode'),
'framing must be non-skippable even in --quick mode');
});
test('trekbrief — v5.5 Step 4a writes framing + brief_version 2.2 + generates TL;DR', () => {
const text = read();
assert.ok(/brief_version: "2\.2"/.test(text), 'Step 4a must set brief_version 2.2');
assert.ok(/framing: <state\.framing>/.test(text), 'Step 4a must write the committed framing value');
assert.ok(/## TL;DR/.test(text), 'Step 4a must generate the TL;DR section');
});
test('trekbrief — v5.5 Phase 4e gate includes memory_alignment', () => {
const text = read();
assert.ok(/memory_alignment\.score ≥ 4/.test(text),
'Phase 4e gate must require memory_alignment.score ≥ 4');
});
test('trekbrief — v5.5 brief-reviewer declares the memory-alignment dimension', () => {
const reviewer = readFileSync(REVIEWER_FILE, 'utf8');
assert.match(reviewer, /### 6\. Memory alignment/,
'brief-reviewer.md missing dimension 6 (memory alignment)');
assert.ok(reviewer.includes('"memory_alignment"'),
'brief-reviewer.md JSON schema missing memory_alignment key');
assert.ok(/no memory context (is )?supplied/i.test(reviewer),
'brief-reviewer must define the no-memory-context N/A fallback');
});
test('trekbrief — v5.5 template carries framing field, 2.2, and TL;DR section', () => {
const tpl = readFileSync(TEMPLATE_FILE, 'utf8');
assert.ok(/brief_version: "2\.2"/.test(tpl), 'template must declare brief_version 2.2');
assert.match(tpl, /^framing: \{preserve \| refine \| replace \| new-direction\}/m,
'template frontmatter must include the framing field');
assert.match(tpl, /^## TL;DR$/m, 'template must include the ## TL;DR section');
});
test('trekbrief — SC1: phase_signals_partial: true does NOT trigger the gate', () => { test('trekbrief — SC1: phase_signals_partial: true does NOT trigger the gate', () => {
const partial = `--- const partial = `---
type: trekbrief type: trekbrief

View file

@ -559,10 +559,10 @@ test('operational files no longer reference trekrevise (v5.0.0 removal)', () =>
// --- v5.1 — phase_signals + brief_version 2.1 --- // --- v5.1 — phase_signals + brief_version 2.1 ---
test('v5.1 — templates/trekbrief-template.md declares brief_version: "2.1" (quoted)', () => { test('v5.5 — templates/trekbrief-template.md declares brief_version: "2.2" (quoted)', () => {
const t = read('templates/trekbrief-template.md'); const t = read('templates/trekbrief-template.md');
assert.match(t, /^brief_version: "2\.1"$/m, assert.match(t, /^brief_version: "2\.2"$/m,
'trekbrief-template.md must declare brief_version: "2.1" (quoted) — unquoted parses as Number and bypasses sequencing gate'); 'trekbrief-template.md must declare brief_version: "2.2" (quoted) — unquoted parses as Number and bypasses sequencing gate; v5.5 bumped 2.1→2.2 for framing enforcement');
}); });
test('v5.1 — templates/trekbrief-template.md contains phase_signals: block', () => { test('v5.1 — templates/trekbrief-template.md contains phase_signals: block', () => {
@ -641,18 +641,53 @@ test('v5.4 — Handover 1 declares the PUBLIC CONTRACT callout + breaking-for-do
'the callout must state that a brief-schema change is a breaking change for every downstream consumer'); 'the callout must state that a brief-schema change is a breaking change for every downstream consumer');
}); });
test('v5.4 — brief_version 2.1 is the frozen public-contract baseline (no stale 2.0)', () => { test('v5.5 — brief_version 2.2 is current in the public-contract schema table (no stale 2.0/2.1)', () => {
const t = read('docs/HANDOVER-CONTRACTS.md'); const t = read('docs/HANDOVER-CONTRACTS.md');
assert.ok(!t.includes('`"2.0"` (current)'), assert.ok(!t.includes('`"2.0"` (current)'),
'Handover 1 schema table must not still mark brief_version "2.0" as current (stale)'); 'Handover 1 schema table must not still mark brief_version "2.0" as current (stale)');
assert.ok(t.includes('`"2.1"` (current)'), assert.ok(!t.includes('`"2.1"` (current)'),
'Handover 1 schema table must mark brief_version "2.1" as current'); 'Handover 1 schema table must not still mark brief_version "2.1" as current (stale — v5.5 bumped to 2.2)');
assert.ok(t.includes('`"2.2"` (current)'),
'Handover 1 schema table must mark brief_version "2.2" as current');
}); });
test('v5.4 — phase_signals stays optional: v5.4 freezes, does not promote to required', () => { test('v5.4 — phase_signals stays optional: not promoted to required', () => {
const t = read('docs/HANDOVER-CONTRACTS.md'); const t = read('docs/HANDOVER-CONTRACTS.md');
assert.ok(!t.includes('v5.4 may promote'), assert.ok(!t.includes('v5.4 may promote'),
'the speculative "v5.4 may promote phase_signals to required" line must be resolved (S3 froze the shape)'); 'the speculative "v5.4 may promote phase_signals to required" line must be resolved (S3 froze the shape)');
assert.ok(t.includes('freezes this schema as the public-contract baseline'), assert.ok(t.includes('froze `2.1` as the public-contract baseline'),
'the Versioning note must record that v5.4 freezes the schema as the public-contract baseline'); 'the Versioning note must record that v5.4 froze 2.1 as the public-contract baseline');
});
// --- v5.5 — framing enforcement: brief_version 2.2 (Handover 1 contract evolution) ---
// S6: the framing-alignment invariant (CLAUDE.md) ships as a controlled brief_version
// bump 2.1→2.2 adding two required-at-2.2 elements (framing field + ## TL;DR section),
// version-gated so 2.0/2.1 briefs stay valid. These pin the contract-doc evolution.
test('v5.5 — Handover 1 schema table adds the framing field row', () => {
const t = read('docs/HANDOVER-CONTRACTS.md');
assert.ok(t.includes('| `framing` |'),
'HANDOVER-CONTRACTS must add a framing row to the Handover 1 schema table');
assert.ok(t.includes('preserve \\| refine \\| replace \\| new-direction'),
'the framing row must list the four canonical enum values');
});
test('v5.5 — Handover 1 documents the framing gate + required-at-2.2 TL;DR section', () => {
const t = read('docs/HANDOVER-CONTRACTS.md');
assert.ok(t.includes('v5.5 framing gate'),
'validation-strategy table must add a v5.5 framing gate row');
assert.ok(/##\s*TL;DR.*required at `brief_version ≥ 2\.2`/.test(t) || t.includes('`## TL;DR` — **required at `brief_version ≥ 2.2`**'),
'body-invariants must mark ## TL;DR as required at brief_version ≥ 2.2');
});
test('v5.5 — Versioning note records the 2.2 framing-enforcement evolution', () => {
const t = read('docs/HANDOVER-CONTRACTS.md');
assert.ok(t.includes('v5.5 → `2.2` (framing enforcement)'),
'the Versioning note must document the v5.5 → 2.2 framing-enforcement evolution');
});
test('v5.5 — voyage README.md mentions framing enforcement / brief_version 2.2', () => {
const t = read('README.md');
assert.ok(t.includes('framing') && t.includes('brief_version 2.2'),
'voyage README.md must carry a "What\'s new" note for framing enforcement (brief_version 2.2)');
}); });

View file

@ -250,3 +250,111 @@ test('validateBrief — v5.1.1: UNQUOTED brief_version 2.1 WITH phase_signals is
assert.equal(r.valid, true, JSON.stringify(r.errors)); assert.equal(r.valid, true, JSON.stringify(r.errors));
assert.ok(!r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS')); assert.ok(!r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS'));
}); });
// --- v5.5 — framing enforcement + obligatory TL;DR (gated at brief_version ≥ 2.2) ---
// Operator decision (S6, option A1): framing + TL;DR are hard BLOCKERs for briefs
// declaring brief_version ≥ 2.2; existing 2.0/2.1 briefs stay valid (forward-compat,
// mirroring the phase_signals ≥ 2.1 precedent). The framing ENUM check fires on any
// version when the field is present but malformed.
const GOOD_BRIEF_22 = `---
type: trekbrief
brief_version: "2.2"
created: 2026-06-18
task: "Add JWT auth to API"
slug: jwt-auth
project_dir: .claude/projects/2026-06-18-jwt-auth/
research_topics: 0
research_status: complete
auto_research: false
interview_turns: 5
source: interview
framing: new-direction
phase_signals_partial: true
---
# Task: JWT auth
## TL;DR
Net-new JWT auth; no prior brief to anchor against.
## Intent
Why this matters.
## Goal
What success looks like.
## Success Criteria
- All tests pass.
`;
test('validateBrief — v5.5 well-formed 2.2 brief (framing + TL;DR) accepted', () => {
const r = validateBriefContent(GOOD_BRIEF_22, { strict: true });
assert.equal(r.valid, true, JSON.stringify(r.errors));
});
test('validateBrief — v5.5 framing enum: invalid value rejected on any version', () => {
const t = GOOD_BRIEF.replace('source: interview\n', 'source: interview\nframing: sideways\n');
const r = validateBriefContent(t, { strict: true });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'BRIEF_INVALID_FRAMING'));
});
test('validateBrief — v5.5 framing enum: all four canonical values accepted', () => {
for (const v of ['preserve', 'refine', 'replace', 'new-direction']) {
const t = GOOD_BRIEF_22.replace('framing: new-direction', `framing: ${v}`);
const r = validateBriefContent(t, { strict: true });
assert.equal(r.valid, true, `framing=${v}: ${JSON.stringify(r.errors)}`);
}
});
test('validateBrief — v5.5 brief_version 2.2 missing framing rejected (BRIEF_MISSING_FRAMING)', () => {
const t = GOOD_BRIEF_22.replace('framing: new-direction\n', '');
const r = validateBriefContent(t, { strict: true });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'BRIEF_MISSING_FRAMING'));
});
test('validateBrief — v5.5 brief_version 2.2 missing ## TL;DR rejected (strict)', () => {
const t = GOOD_BRIEF_22.replace(/## TL;DR\n\n[^\n]*\n\n/, '');
const r = validateBriefContent(t, { strict: true });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'BRIEF_MISSING_SECTION' && /TL;DR/.test(e.message)));
});
test('validateBrief — v5.5 brief_version 2.2 missing ## TL;DR demoted to warning (soft)', () => {
const t = GOOD_BRIEF_22.replace(/## TL;DR\n\n[^\n]*\n\n/, '');
const r = validateBriefContent(t, { strict: false });
assert.ok(r.warnings.find(w => w.code === 'BRIEF_MISSING_SECTION' && /TL;DR/.test(w.message)));
});
test('validateBrief — v5.5 TL;DR exceeding 5 lines emits BRIEF_TLDR_TOO_LONG warning', () => {
const longTldr = ['l1', 'l2', 'l3', 'l4', 'l5', 'l6'].join('\n');
const t = GOOD_BRIEF_22.replace('Net-new JWT auth; no prior brief to anchor against.', longTldr);
const r = validateBriefContent(t, { strict: true });
assert.ok(
r.warnings.find(w => w.code === 'BRIEF_TLDR_TOO_LONG'),
`expected TL;DR-too-long warning; warnings=${JSON.stringify(r.warnings)}`,
);
});
test('validateBrief — v5.5 backward-compat: 2.1 brief without framing/TL;DR stays valid', () => {
const t = GOOD_BRIEF
.replace('brief_version: "2.0"', 'brief_version: "2.1"')
.replace('source: interview\n', 'source: interview\nphase_signals_partial: true\n');
const r = validateBriefContent(t, { strict: true });
assert.equal(r.valid, true, JSON.stringify(r.errors));
assert.ok(!r.errors.find(e => e.code === 'BRIEF_MISSING_FRAMING'));
assert.ok(!r.errors.find(e => e.code === 'BRIEF_MISSING_SECTION' && /TL;DR/.test(e.message)));
});
test('validateBrief — v5.5 trekreview brief not subject to framing/TL;DR gate', () => {
const r = validateBriefContent(REVIEW_AS_BRIEF, { strict: true });
assert.equal(r.valid, true, JSON.stringify(r.errors));
assert.ok(!r.errors.find(e => e.code === 'BRIEF_MISSING_FRAMING'));
assert.ok(!r.errors.find(e => e.code === 'BRIEF_MISSING_SECTION' && /TL;DR/.test(e.message)));
});