feat(brief): optional threat model in the brief (decision 164, 218)

A brief may carry an optional `## Threat Model` with one line per bullet,
`- Inside: <text>` or `- Outside: <text>`. Present and malformed (no such
bullet, another label or form, empty text, two sections) is
BRIEF_THREAT_MODEL_MALFORMED; absent is today's rule. No brief_version bump
(the ceremony / artifact_store precedent). Sections are found fence- and
comment-aware through intent-approval's new sectionTexts, so the validator
and the hash count the same sections; brief-validator exports
parseThreatModel for the reader.

The intent hash covers `## Threat Model` when present (two sections -> null
hash, as Intent and Goal), so an edited, added or removed threat model
makes the approval stale; a brief without one hashes exactly as before. The
stale message and the /trekplan halt table name the section.

/trekbrief offers the section in its question round when the task touches
a guard, a gate, untrusted input, secrets or publishing, writes it only
when given, shows it verbatim at the Phase 4h approval, and --approve reads
it out. The template documents it; brief-gate-coverage pairs the new code.

Part 2, step 2.1 of the E plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-08 08:47:51 +01:00
commit 74f75dcb7d
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
7 changed files with 350 additions and 23 deletions

View file

@ -89,7 +89,7 @@ Usage: /trekbrief <task description>
Modes:
default Dynamic interview until quality gates pass — brief with research plan
--quick Compact start; still escalates on weak sections — brief with research plan
--approve Approve an existing brief's ## Intent + ## Goal so /trekplan will plan it
--approve Approve an existing brief's ## Intent + ## Goal (+ ## Threat Model) so /trekplan will plan it
Examples:
/trekbrief Add user authentication with JWT tokens
@ -210,7 +210,8 @@ The round runs in the form of `voyage:interviewing`. What `/trekbrief` adds:
Success Criteria, Research Plan, Non-Goals, Constraints, Preferences, NFRs,
Prior Attempts), plus three fixed questions every round carries: framing
(Phase 2.5, first), the size class (Step 3b) and where the brief is stored
(`artifact_store`, below).
(`artifact_store`, below); and the threat model (below) when the task
touches a guard, a gate, untrusted input, secrets or publishing.
2. **The budget is the class budget** (table below). A `bounded` or
`architectural` round is consecutive `AskUserQuestion` calls with no
drafting in between.
@ -247,6 +248,17 @@ the repo is public or you cannot tell:
The approval log is committed either way; it holds hashes, times and file
names, never the text.
**The threat model** (`## Threat Model`, optional; E, decision 164). Offer it
as one of the round's questions only when the task touches a guard, a gate,
untrusted input, secrets or publishing; otherwise the brief has none and every
security finding counts. Recommend what is inside (what the work must defend
against) and what is outside (what it deliberately does not), one line each.
Voyage's own security findings (plan-critic, scope-guardian, the code
reviewer's part for command texts) block inside it; one that quotes an
`Outside:` line becomes a listed known issue instead of a blocker. Findings
from the security engine block as before. The intent approval (Phase 4h)
covers the section.
**Borrowed, with credit.** The size classes, "heaviest in doubt" and the
one-way ratchet: obra/superpowers@8ca22db (MIT, © 2025 Jesse Vincent),
skills/brainstorming. The class ladder: Kiro's docs (kiro.dev — Plan mode,
@ -272,6 +284,7 @@ state = {
prior_attempts: { content: "", probes: 0 }, # optional
ceremony: "small | bounded | architectural", # Step 3b; only moves up
artifact_store: "git | local", # Step 3c
threat_model: { inside: [], outside: [] }, # Step 3c; optional, only when offered
assumptions: [], # predicted, hollow or force-stopped answers
deferred: [], # over budget, with why
question_history: [] # list of questions asked
@ -503,6 +516,11 @@ Build the brief text from Phase 3 state by filling the template:
(what you said apart from what the operator said); and the Deferred list,
one line of why each.
- **Prior Attempts:** from state, or "None — fresh task."
- **Threat Model:** only when the operator gave one in the round (Step 3c):
`## Threat Model` with one line per bullet, each `- Inside: <text>` or
`- Outside: <text>`, and nothing else as a bullet (the validator blocks any
other bullet with `BRIEF_THREAT_MODEL_MALFORMED`). Otherwise leave the
section out. Optional in the schema, so no `brief_version` bump.
**Step 4b — Write draft to disk**
@ -715,8 +733,9 @@ criterion runs plain `bash`: offer `/bin/bash`).
brief is validated, run `scripts/annotate.mjs` to build the page where the
operator writes their own notes. The loop that follows is the skill
`voyage:annotating`: call it when the operator pastes the notes back (a
Question is answered, not applied; after Phase 4h, a note on `## Intent` or
`## Goal` makes the approval stale, and that is said before the edit).
Question is answered, not applied; after Phase 4h, a note on `## Intent`,
`## Goal` or `## Threat Model` makes the approval stale, and that is said
before the edit).
```bash
ANNOT_HTML=$(node ${CLAUDE_PLUGIN_ROOT}/scripts/annotate.mjs "{PROJECT_DIR}/brief.md" 2>&1)
@ -759,16 +778,18 @@ is given. It runs on EVERY brief, before the Phase 5 manual/auto fork, so
both paths pass through it — and it is **asked even in `--quick` mode**; there
is no skip path and no default answer.
1. Show the operator the `## Intent` and `## Goal` sections of
`{BRIEF_PATH}` **verbatim** — the exact text the approval will
bind to. Do not summarize them.
1. Show the operator the `## Intent`, `## Goal` and, when present,
`## Threat Model` sections of `{BRIEF_PATH}` **verbatim** — the exact text
the approval will bind to. Do not summarize them. In `--approve` mode too:
a brief from another producer may carry a `## Threat Model`, and it is read
out with the rest, since the approval covers it.
2. Ask via `AskUserQuestion` — one question:
**"Is this the intent /trekplan should plan against?"**
| Option | Effect |
|--------|--------|
| **Approve** | Check the answer (step 3), then stamp, log and commit (steps 4–6). |
| **Revise first** | The operator states the change; edit `## Intent` / `## Goal` in `{BRIEF_PATH}`, re-run the Phase 4g validator, then return to step 1. |
| **Revise first** | The operator states the change; edit `## Intent` / `## Goal` / `## Threat Model` in `{BRIEF_PATH}`, re-run the Phase 4g validator, then return to step 1. |
| **Leave unapproved** | Write nothing. `/trekplan` will halt on this brief until `/trekbrief --approve {BRIEF_PATH}` is run. |
3. **Is the answer an approval?** Only an explicit "Approve" is. Check the
@ -794,9 +815,9 @@ node ${CLAUDE_PLUGIN_ROOT}/lib/validators/intent-approval.mjs --stamp "{BRIEF_PA
```
This writes `intent_approved_hash` (sha256 over the normalized `## Intent`
+ `## Goal`) and `intent_approved_at` into the frontmatter. It prints JSON; if `stamped` is `false`
+ `## Goal`, + `## Threat Model` when present) and `intent_approved_at` into the frontmatter. It prints JSON; if `stamped` is `false`
(e.g. `## Goal` is empty), report the `reason` and return to step 1. Editing
`## Intent` or `## Goal` afterwards makes the approval stale — `/trekplan`
`## Intent`, `## Goal` or `## Threat Model` afterwards makes the approval stale — `/trekplan`
halts until the operator approves the new text.
5. **Log the approval** (K2), whatever `artifact_store` says. `{INTENT_HASH}`

View file

@ -197,9 +197,9 @@ agent, write no plan. Print each error's `message` and `hint`, then stop:
| Code | Meaning | What the operator does |
|------|---------|------------------------|
| `BRIEF_INTENT_NOT_APPROVED` | No approval marker (`intent_approved_hash`) in the brief | `/trekbrief --approve {brief_path}` — read `## Intent` + `## Goal`, answer "Approve" |
| `BRIEF_INTENT_APPROVAL_STALE` | `## Intent` or `## Goal` changed after approval | Same command — the approval covers the old text, not this one |
| `BRIEF_INTENT_APPROVAL_INVALID` | Marker malformed, or `## Intent` / `## Goal` missing, empty or present more than once | Fix the brief, then the same command |
| `BRIEF_INTENT_NOT_APPROVED` | No approval marker (`intent_approved_hash`) in the brief | `/trekbrief --approve {brief_path}` — read `## Intent` + `## Goal` (+ `## Threat Model` when present), answer "Approve" |
| `BRIEF_INTENT_APPROVAL_STALE` | `## Intent`, `## Goal` or `## Threat Model` changed (or a `## Threat Model` was added or removed) after approval | Same command — the approval covers the old text, not this one |
| `BRIEF_INTENT_APPROVAL_INVALID` | Marker malformed, or `## Intent` / `## Goal` missing, empty or present more than once, or `## Threat Model` present more than once | Fix the brief, then the same command |
If the command does not print the JSON report (module not found, node
missing, an unresolved `${CLAUDE_PLUGIN_ROOT}`), **halt** as well and show the