feat(intent): /trekplan halts without an approved intent; the approval is stamped on both paths

Veikart steg 1, økt 1 av 2. The three holes measured in the grounds:
(1) ## Intent / ## Goal were only checked for existence, (2) no approval
marker existed, (3) brief-approved was emitted only on the auto path.

- lib/validators/intent-approval.mjs: --check / --stamp. The marker is
  intent_approved_hash (sha256 over normalized ## Intent + ## Goal) +
  intent_approved_at. Editing either section after approval → STALE.
- /trekplan Phase 1 (Read the brief, both --brief and --project): runs
  --check and HALTS on BRIEF_INTENT_NOT_APPROVED / _STALE / _INVALID with
  the remedy spelled out; an unrunnable check halts too.
- /trekbrief Phase 4h (before the Phase 5 fork, asked even in --quick):
  shows Intent + Goal verbatim, AskUserQuestion Approve / Revise / Leave;
  only "Approve" runs --stamp. --stamp emits brief-approved, so the manual
  (default) path records it; the auto path's own emission is removed.
  New mode /trekbrief --approve <project-dir> = Phase 4h alone.
- README, CLAUDE.md, command-modes, HANDOVER-CONTRACTS §Handover 1,
  jsonl-schemas (trekbrief-stats gains intent_approved).

Valgt ingen brief_version-bump fordi skjemaendringen er rent additiv (to
valgfrie felt) og kravet sitter i /trekplan — enhver produsents brief kan
godkjennes via /trekbrief --approve uten produsentendring. Valgt eget
--check-kall i stedet for et flagg på brief-validator fordi --brief-stien
i dag ikke kjører validatoren i det hele tatt; et nytt validatorkall der
ville også stoppe på andre feil. Valgt fallback-datamappe = målestokkens
(plugins/data/voyage-…) fordi CLAUDE_PLUGIN_DATA er tom i Bash-miljøet og
event-emits egen fallback er stille skip — slik ble brief-approved 0 records.

What the marker does NOT prove (module header, command prose, contract):
same user, same machine — any session can stamp. A trace, not a signature.

Suite 1183 → 1201 (1199/0/2). Mutants M1–M5 (stale check off, check
always valid, gate line removed, stamp emits nothing, no normalization)
each fell ≥ 1 test. yardstick unchanged: RED, 1 of 3 countable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-23 07:50:51 +02:00
commit f5dc08f660
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
9 changed files with 315 additions and 14 deletions

View file

@ -14,9 +14,9 @@ Voyage — a contract-driven Claude Code pipeline: brief, research, plan, execut
| Command | Description | Orchestrator model |
|---------|-------------|--------------------|
| `/trekbrief` | Brief — interactive interview produces a task brief with explicit research plan; optionally orchestrates the pipeline | session |
| `/trekbrief` | Brief — interactive interview produces a task brief with explicit research plan; ends with the operator approving `## Intent` + `## Goal` (`--approve <dir>` does only that); optionally orchestrates the pipeline | session |
| `/trekresearch` | Research — deep local + external research, produces structured research brief. Opt-in `--engine {swarm\|deep-research}` delegates the external phase to Claude Code's built-in `/deep-research` workflow (swarm default) | session |
| `/trekplan` | Plan — brief-reviewer, explore, plan, review. Requires `--brief` or `--project`. Auto-discovers `architecture/overview.md` if present | session |
| `/trekplan` | Plan — brief-reviewer, explore, plan, review. Requires `--brief` or `--project`; halts unless the brief's intent is approved (hash-bound, stale on edit). Auto-discovers `architecture/overview.md` if present | session |
| `/trekexecute` | Execute — disciplined plan/session-spec executor with failure recovery | session |
| `/trekreview` | Review — independent post-hoc review of delivered code against the brief. Produces `review.md` with severity-tagged findings (Handover 6) | session |
| `/trekcontinue` | Continue — resumes the next session of a multi-session voyage project. Reads `.session-state.local.json` (Handover 7) and immediately begins executing | session |

View file

@ -169,6 +169,7 @@ Output: `.claude/projects/{YYYY-MM-DD}-{slug}/brief.md`
| **Default** | `/trekbrief <task>` | Dynamic interview until quality gates pass. No question cap. |
| **Quick** | `/trekbrief --quick <task>` | Starts compact (optional sections get at most one probe), still escalates on weak required sections or failed review gate. |
| **Profile** | `/trekbrief --profile <name> <task>` | (v4.1.0) Pin model profile for the brief phase: `economy` / `balanced` / `premium` / `fable` / `<custom>`. See [Profile system](#profile-system-v410) below. |
| **Approve** | `/trekbrief --approve <project-dir>` | No interview: show an existing brief's `## Intent` + `## Goal` verbatim and ask the operator to approve them. The same step (Phase 4h) ends every normal `/trekbrief` run. |
`/trekbrief` is **always interactive**. There is no foreground/background mode — the interview requires user input.
@ -263,6 +264,8 @@ Output:
`--brief` or `--project` is **required**. `/trekplan` with no brief exits with an error and a pointer to `/trekbrief`.
**Intent approval gate.** `/trekplan` halts before planning anything unless the brief's `## Intent` + `## Goal` carry the operator's approval. `/trekbrief` asks for it at the end of every interview (Phase 4h, also in `--quick`), or run `/trekbrief --approve <project-dir>` later. The approval is a hash over those two sections: edit either one afterwards and `/trekplan` halts again until the new text is approved. Review briefs (`type: trekreview`) are exempt. The marker is a trace that the approval step ran, **not a signature**. Any session on the same machine could write it.
### What the plan contains
Every plan includes:

View file

@ -1,7 +1,7 @@
---
name: trekbrief
description: Interactive interview that produces a task brief with explicit research plan. Feeds /trekresearch and /trekplan. Optionally orchestrates the full pipeline end-to-end.
argument-hint: "[--quick] <task description>"
argument-hint: "[--quick] <task description> | --approve <project-dir>"
allowed-tools: Agent, Read, Glob, Grep, Write, Edit, Bash, AskUserQuestion
---
@ -51,15 +51,25 @@ Parse `$ARGUMENTS`:
by the autonomy-gate state machine via the CLI shim:
`node ${CLAUDE_PLUGIN_ROOT}/lib/util/autonomy-gate.mjs --state X --event Y --gates {true|false}`.
If no task description is provided, output usage and stop:
4. `--approve <project-dir>`: set **mode = approve**. No interview, no
drafting. Set `PROJECT_DIR = <project-dir>` (trim trailing slash). If
`{PROJECT_DIR}/brief.md` does not exist, print
`Error: no brief at {PROJECT_DIR}/brief.md — run /trekbrief first.` and stop.
Otherwise jump straight to **Phase 4h**, run it, print its report, and
stop. This is the remedy `/trekplan` names when it halts on an unapproved
or stale intent.
If no task description is provided (and mode is not approve), output usage and stop:
```
Usage: /trekbrief <task description>
/trekbrief --quick <task description>
/trekbrief --approve <project-dir>
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
Examples:
/trekbrief Add user authentication with JWT tokens
@ -723,6 +733,52 @@ the tab and reopen the same file.
────────────────────────────────────────────────────────────────────
```
## Phase 4h — Intent approval (veikart steg 1)
`/trekplan` does not plan against an intent the operator has not approved
(it halts on `BRIEF_INTENT_NOT_APPROVED`). This phase is where that approval
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
`{PROJECT_DIR}/brief.md` **verbatim** — the exact text the approval will
bind to. Do not summarize them.
2. Ask via `AskUserQuestion` — one question:
**"Is this the intent /trekplan should plan against?"**
| Option | Effect |
|--------|--------|
| **Approve** | Stamp the brief (step 3). |
| **Revise first** | The operator states the change; edit `## Intent` / `## Goal` in `brief.md`, re-run the Phase 4g validator, then return to step 1. |
| **Leave unapproved** | Write nothing. `/trekplan` will halt on this brief until `/trekbrief --approve {PROJECT_DIR}` is run. |
3. **Only on an explicit "Approve"** — never inferred from silence, from an
earlier answer, or from a "looks good" in free text — run:
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/intent-approval.mjs --stamp "{PROJECT_DIR}/brief.md"
```
This writes `intent_approved_hash` (sha256 over the normalized `## Intent`
+ `## Goal`) and `intent_approved_at` into the frontmatter, and emits the
`brief-approved` lifecycle event. 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`
halts until the operator approves the new text.
What the stamp does NOT prove: any session on this machine could run the
same command. It is a trace that this step ran against this text, not the
operator's signature (see the header of `lib/validators/intent-approval.mjs`).
Report:
```
Intent: {approved (sha256:…12 hex) | left unapproved — /trekplan will halt}
```
In `--approve` mode, stop after the report and print the next step:
`/trekplan --project {PROJECT_DIR}` (only if approved).
## Phase 5 — Auto-orchestration opt-in (if research_topics > 0)
**Skip this phase if research_topics = 0.** Proceed directly to Phase 6.
@ -763,16 +819,14 @@ Stop. Do not continue to Phase 6.
### Auto path
**Auto requires an approved intent.** If Phase 4h ended "Leave unapproved",
the auto chain would halt at `/trekplan` anyway — print the manual-path
output instead and stop.
Set `auto_research: true` in the brief's frontmatter (edit the file).
Emit the brief-approved lifecycle event so downstream observability sees
the pipeline kick off (consumed by `lib/stats/event-emit.mjs`):
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/stats/event-emit.mjs \
--event brief-approved \
--payload "{\"project\":\"${PROJECT_DIR}\"}"
```
The `brief-approved` lifecycle event was already emitted by the Phase 4h
stamp — on both paths — so it is not emitted again here.
If `gates_mode == true`: pause here via `AskUserQuestion` —
"Auto-mode confirmed. Proceed to research now? (yes/no)". If the user
@ -902,10 +956,14 @@ Append one record to `${CLAUDE_PLUGIN_DATA}/trekbrief-stats.jsonl`:
"research_topics": {N},
"auto_research": {true | false},
"auto_result": "{completed | cancelled | failed | manual}",
"intent_approved": {true | false},
"project_dir": "{path}"
}
```
`intent_approved` is `true` only when Phase 4h ran the stamp (the operator
answered "Approve").
If `${CLAUDE_PLUGIN_DATA}` is not set or not writable, skip silently.
Never let stats failures block the workflow.

View file

@ -177,6 +177,31 @@ Do not continue past this step if no brief was provided.
### Read the brief
**Intent approval gate (veikart steg 1).** Before anything is read for
planning, check that the operator approved THIS brief's intent. Runs for
`--brief` and `--project` alike; `--quick` does not skip it and there is no
override flag:
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/intent-approval.mjs --check --json "{brief_path}"
```
Exit 0 → continue. Any other exit → **halt** — do not read further, spawn no
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 {project_dir}` — 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 or empty | 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
raw error: an approval check that could not run is never a pass.
`trekreview` briefs are exempt (the check returns exit 0 for them). What the
marker does NOT prove — it is a trace that the approval step ran against this
text, not a signature: see the header of `lib/validators/intent-approval.mjs`.
Read the brief file and parse its frontmatter. Extract:
- `task` — one-line task description
- `slug` — slug for plan filenames

View file

@ -74,6 +74,8 @@ Every validator exposes a CLI: `node lib/validators/<name>.mjs --json <path>` re
| `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`. |
| `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. |
| `intent_approved_hash` | string | optional in the schema; **required by `/trekplan`** (veikart steg 1) | `"sha256:<64 hex>"` | Hash over the normalized `## Intent` + `## Goal`. Written only by `lib/validators/intent-approval.mjs --stamp`, which `/trekbrief` runs on the operator's explicit "Approve" (Phase 4h, or `--approve <project-dir>`). |
| `intent_approved_at` | string | optional | ISO-8601 | When the stamp was written. Informational; the gate reads the hash. |
**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.
@ -104,7 +106,10 @@ Optional but standard sections: `## Non-Goals`, `## Constraints`, `## Preference
**Pre-2.2 briefs receive zero framing enforcement (producer-elective defense).** The framing-alignment defense is gated at `brief_version ≥ 2.2`, so a brief declaring `2.0` or `2.1` gets **none** of it: `framing` is optional and a missing value is not an error, the `## TL;DR` section is not required, and the `brief-reviewer` memory-alignment dimension scores N/A. The backward-compatibility this buys is deliberate, but it also means the framing defense is **producer-elective**: any producer can sidestep the entire defense — deliberately or by shipping a legacy template — simply by declaring `brief_version: "2.1"`, and the pipeline raises no signal by default. This is a documented property of the contract, not a bug. Two remedies close it for a given consumer: (1) require upstream producers to emit `brief_version: "2.2"` (which forces `framing` + `## TL;DR`); or (2) pass the opt-in **`--min-brief-version <ver>`** flag to `/trekplan` or `/trekresearch` (forwarded to the validator as `--min-version`), which raises a `BRIEF_VERSION_BELOW_MINIMUM` **warning** — never a block — when a brief declares a version below the floor. The flag defaults to off, so the N-1 compatibility window for `2.0`/`2.1` briefs is preserved unless a consumer explicitly opts into the stricter floor.
**Intent approval gate (veikart steg 1) — additive schema, consumer-side requirement.** The two `intent_approved_*` fields are additive and optional in the schema: `brief-validator.mjs` does not require them, `/trekresearch` does not read them, and no `brief_version` bump is involved. The requirement lives in `/trekplan` Phase 1 (`### Read the brief`), which runs `intent-approval.mjs --check` and **halts** on `BRIEF_INTENT_NOT_APPROVED` (no marker), `BRIEF_INTENT_APPROVAL_STALE` (`## Intent` or `## Goal` changed after approval) or `BRIEF_INTENT_APPROVAL_INVALID` (malformed marker, or a section missing). It is version-independent, so a brief from ANY producer, of any `brief_version`, stops at `/trekplan` until approved. No producer has to emit anything new: the operator approves through `/trekbrief --approve <project-dir>`, a Voyage-side step open to every producer's brief, so no producer is privileged. `trekreview` briefs are exempt. **What the marker does not prove:** same user, same machine. Any session can run `--stamp`. The marker is a trace that the approval step ran against this exact text, not a signature (see the header of `lib/validators/intent-approval.mjs`). `--stamp` also emits `brief-approved` to `trekexecute-stats.jsonl` under `CLAUDE_PLUGIN_DATA`, falling back to the plugin data dir the yardstick reads, on both the manual and the auto path.
**Failure modes:**
- `BRIEF_INTENT_NOT_APPROVED` / `BRIEF_INTENT_APPROVAL_STALE` / `BRIEF_INTENT_APPROVAL_INVALID` → `/trekplan` halts (intent-approval gate, above); remedy `/trekbrief --approve <project-dir>`
- `BRIEF_NOT_FOUND` → consumer halts with a usage message
- `FM_MISSING` → file has no frontmatter; halt
- `BRIEF_WRONG_TYPE` → file is not a brief; halt

View file

@ -10,6 +10,7 @@ Per-command flag tables, imported from `CLAUDE.md` via pointer.
| `--quick` | Compact start; still escalates if required sections are weak or the brief-review gate fails → brief.md with research plan |
| `--gates {true\|false}` | (v3.4.0) Boolean autonomy-gate flag; present → gating on. Policy (`gates_mode`) detailed under `## Autonomy mode` in `docs/operations.md`. |
| `--profile <name>` | (v4.1.0) Model profile: `economy` / `balanced` / `premium` / `fable` / `<custom>`. Sets `phase_models` for the brief phase. See `## Profile system` in `docs/operations.md`. |
| `--approve <project-dir>` | (veikart steg 1) No interview: show the existing brief's `## Intent` + `## Goal` verbatim, ask the operator to approve, and on "Approve" stamp `intent_approved_hash` + `intent_approved_at` and emit `brief-approved`. The remedy `/trekplan` names when it halts on `BRIEF_INTENT_NOT_APPROVED` / `BRIEF_INTENT_APPROVAL_STALE`. |
Always interactive. Phase 3 is a section-driven completeness loop (no hard cap on question count); Phase 4 runs a `brief-reviewer` stop-gate with max 3 review iterations. After writing the brief, asks the user to choose manual (print commands) or auto (Claude runs research + plan in foreground).

View file

@ -7,7 +7,7 @@
const FLAG_SCHEMA = {
trekbrief: {
boolean: ['--quick', '--fg'],
valued: ['--profile'],
valued: ['--profile', '--approve'],
aliases: {},
},
trekresearch: {

View file

@ -0,0 +1,209 @@
// lib/validators/intent-approval.mjs
// Intent approval marker — the gate /trekplan passes before it plans anything.
//
// A brief carries two optional frontmatter fields once the operator has
// approved its intent in /trekbrief (Phase 4h, or `/trekbrief --approve`):
//
// intent_approved_hash: "sha256:<64 hex>" hash over ## Intent + ## Goal
// intent_approved_at: "<ISO-8601>" when the stamp was written
//
// The hash binds the approval to the EXACT intent text: each section is
// normalized (lines trimmed, whitespace runs collapsed, blank lines dropped)
// and the pair is hashed. Reflowing a paragraph keeps the approval; changing
// a word in ## Intent or ## Goal after approval makes it stale, and /trekplan
// stops until the operator approves the new text.
//
// WHAT THE MARKER DOES NOT PROVE. Same user, same machine: any session with
// write access to the brief can run `--stamp` itself, or write the two fields
// by hand. The marker is a TRACE that the approval step ran against this text,
// NOT a signature binding a person — the same limit as the order queue's
// `--from`. It catches drift (intent edited after approval) and omission (no
// approval step at all); it does not catch a session that approves on the
// operator's behalf.
//
// trekreview briefs are exempt: they are produced from a review of an already
// planned brief and carry no ## Intent of their own.
//
// CLI:
// node lib/validators/intent-approval.mjs --check [--json] <brief.md>
// exit 0 = approved and current; exit 1 = stop (errors); exit 2 = usage
// node lib/validators/intent-approval.mjs --stamp <brief.md>
// writes the marker and emits `brief-approved`; exit 0 stamped, 1 refused
import { createHash } from 'node:crypto';
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { basename, dirname, join } from 'node:path';
import { parseDocument } from '../util/frontmatter.mjs';
import { issue } from '../util/result.mjs';
import { emit } from '../stats/event-emit.mjs';
export const INTENT_HASH_FIELD = 'intent_approved_hash';
export const INTENT_APPROVED_AT_FIELD = 'intent_approved_at';
export const INTENT_SECTIONS = Object.freeze(['Intent', 'Goal']);
const HASH_RE = /^sha256:[0-9a-f]{64}$/;
const STATS_FILENAME = 'trekexecute-stats.jsonl'; // where event-emit writes lifecycle events
const APPROVE_HINT = 'Run /trekbrief --approve <project-dir> — the operator reads ## Intent + ## Goal and answers "Approve".';
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 normalize(text) {
return text.split(/\r?\n/)
.map((l) => l.trim().replace(/\s+/g, ' '))
.filter(Boolean)
.join('\n');
}
/**
* Hash ## Intent + ## Goal of a brief body.
* @returns {{ hash: string|null, missing: string[] }} hash is null when a section is absent or empty.
*/
export function computeIntentHash(body) {
const parts = [];
const missing = [];
for (const h of INTENT_SECTIONS) {
const s = extractSection(body || '', h);
const n = s === null ? '' : normalize(s);
if (!n) missing.push(h);
parts.push(n);
}
if (missing.length) return { hash: null, missing };
const hex = createHash('sha256').update(JSON.stringify(parts), 'utf8').digest('hex');
return { hash: `sha256:${hex}`, missing };
}
/** Check the approval marker of a brief's text. Returns a validator Result. */
export function checkIntentApprovalContent(text) {
const doc = parseDocument(text);
if (!doc.valid) return doc;
const fm = doc.parsed.frontmatter || {};
const body = doc.parsed.body || '';
const errors = [];
const result = () => ({ valid: errors.length === 0, errors, warnings: [], parsed: { frontmatter: fm } });
if (fm.type === 'trekreview') return result();
if (!(INTENT_HASH_FIELD in fm)) {
errors.push(issue(
'BRIEF_INTENT_NOT_APPROVED',
'The brief\'s intent has not been approved — /trekplan does not plan against an unapproved intent.',
APPROVE_HINT,
));
return result();
}
const recorded = String(fm[INTENT_HASH_FIELD]);
if (!HASH_RE.test(recorded)) {
errors.push(issue(
'BRIEF_INTENT_APPROVAL_INVALID',
`${INTENT_HASH_FIELD} "${recorded}" is not "sha256:<64 hex>" — the marker is malformed.`,
APPROVE_HINT,
));
return result();
}
const { hash, missing } = computeIntentHash(body);
if (!hash) {
errors.push(issue(
'BRIEF_INTENT_APPROVAL_INVALID',
`The approval cannot bind: ## ${missing.join(' and ## ')} is missing or empty.`,
'Write the missing section(s), then ' + APPROVE_HINT,
));
return result();
}
if (hash !== recorded) {
errors.push(issue(
'BRIEF_INTENT_APPROVAL_STALE',
'## Intent or ## Goal changed after the intent was approved — the approval covers the old text, not this one.',
APPROVE_HINT,
));
}
return result();
}
/**
* Write the approval marker into a brief's frontmatter (replacing any prior one).
* @returns {{ stamped: boolean, text: string, hash?: string, approvedAt?: string, reason?: string }}
*/
export function stampIntentApproval(text, now = new Date()) {
const doc = parseDocument(text);
if (!doc.valid) return { stamped: false, text, reason: 'frontmatter does not parse' };
const { hash, missing } = computeIntentHash(doc.parsed.body || '');
if (!hash) return { stamped: false, text, reason: `## ${missing.join(' and ## ')} missing or empty` };
const m = /^(?---\r?\n)([\s\S]*?)(\r?\n---)/.exec(text);
if (!m) return { stamped: false, text, reason: 'no frontmatter block' };
const approvedAt = now.toISOString();
const kept = m[2].split(/\r?\n/).filter((l) =>
!l.startsWith(`${INTENT_HASH_FIELD}:`) && !l.startsWith(`${INTENT_APPROVED_AT_FIELD}:`));
kept.push(`${INTENT_HASH_FIELD}: "${hash}"`, `${INTENT_APPROVED_AT_FIELD}: "${approvedAt}"`);
const out = m[1] + kept.join('\n') + m[3] + text.slice(m.index + m[0].length);
return { stamped: true, text: out, hash, approvedAt };
}
/**
* Where the brief-approved record goes. CLAUDE_PLUGIN_DATA when the harness
* provides it; otherwise the plugin data dir the hooks write to and
* scripts/yardstick.mjs reads — the Bash tool env carries no CLAUDE_PLUGIN_DATA,
* and event-emit's own fallback is a silent skip, which is how brief-approved
* reached 0 records.
*/
export function resolveApprovalDataDir(env = process.env) {
if (env.CLAUDE_PLUGIN_DATA) return env.CLAUDE_PLUGIN_DATA;
const home = env.HOME && env.HOME.length > 0 ? env.HOME : homedir();
return join(home, '.claude', 'plugins', 'data', 'voyage-ktg-plugin-marketplace');
}
if (import.meta.url === `file://${process.argv[1]}`) {
const args = process.argv.slice(2);
const file = args.find((a) => !a.startsWith('--'));
const mode = args.includes('--stamp') ? 'stamp' : args.includes('--check') ? 'check' : null;
if (!file || !mode) {
process.stderr.write('Usage: intent-approval.mjs --check [--json] <brief.md> | --stamp <brief.md>\n');
process.exit(2);
}
let text = null;
if (existsSync(file)) {
try { text = readFileSync(file, 'utf8'); } catch { text = null; }
}
if (mode === 'check') {
const r = text === null
? { valid: false, errors: [issue('BRIEF_NOT_FOUND', `Cannot read brief: ${file}`)], warnings: [] }
: checkIntentApprovalContent(text);
if (args.includes('--json')) {
process.stdout.write(JSON.stringify({ valid: r.valid, errors: r.errors, warnings: r.warnings }, null, 2) + '\n');
} else {
process.stdout.write(`intent-approval: ${r.valid ? 'APPROVED' : 'STOP'} ${file}\n`);
for (const e of r.errors) process.stderr.write(` ERROR [${e.code}] ${e.message}\n ${e.hint || ''}\n`);
}
process.exit(r.valid ? 0 : 1);
}
// --stamp
if (text === null) {
process.stdout.write(JSON.stringify({ stamped: false, reason: `cannot read ${file}` }) + '\n');
process.exit(1);
}
const s = stampIntentApproval(text);
if (!s.stamped) {
process.stdout.write(JSON.stringify({ stamped: false, reason: s.reason }) + '\n');
process.exit(1);
}
const tmp = join(dirname(file), `.${basename(file)}.approve.tmp`);
writeFileSync(tmp, s.text);
renameSync(tmp, file);
const fm = parseDocument(s.text).parsed.frontmatter || {};
const record = emit('brief-approved',
{ project: dirname(file), slug: fm.slug ?? null, intent_hash: s.hash },
{ path: join(resolveApprovalDataDir(), STATS_FILENAME) });
process.stdout.write(JSON.stringify({
stamped: true, intent_hash: s.hash, approved_at: s.approvedAt, record,
}) + '\n');
process.exit(0);
}

View file

@ -19,7 +19,7 @@
| schema_id | fields | writer_path | line_ref | v4.1 additive | PII |
|-----------|--------|-------------|----------|---------------|-----|
| trekbrief-stats | ts, task, slug, mode, interview_turns, review_iterations, brief_quality, research_topics, auto_research, auto_result, project_dir | commands/trekbrief.md (orchestrator-emit Phase 7) | trekbrief.md:657-672 | profile, phase_models, profile_source | none |
| trekbrief-stats | ts, task, slug, mode, interview_turns, review_iterations, brief_quality, research_topics, auto_research, auto_result, intent_approved, project_dir | commands/trekbrief.md (orchestrator-emit Phase 7) | trekbrief.md:657-672 | profile, phase_models, profile_source | none |
| trekresearch-stats | ts, question, mode, scope, engine, slug, project_dir, brief_path, dimensions, dimensions_baseline, dimensions_baseline_preserved, effort, conv_turns, empty_turns, unique_sources, agents_local, agents_external, gemini_used, confidence, contradictions, open_questions | commands/trekresearch.md (orchestrator-emit Stats tracking) | trekresearch.md:634-676 | profile, phase_models, parallel_agents, external_research_enabled, profile_source | none |
| trekplan-stats | ts, task, mode, slug, brief_path, project_dir, codebase_size, codebase_files, agents_deployed, deep_dives, research_briefs_used, research_scout_used, critic_verdict, guardian_verdict, outcome | commands/trekplan.md (orchestrator-emit Phase 12) | trekplan.md:805-826 | profile, phase_models, parallel_agents, profile_source | none |
| trekexecute-stats (Phase 9 record) | ts, plan, plan_type, mode, result, steps_total, steps_passed, steps_failed, steps_skipped, failed_at_step | commands/trekexecute.md (orchestrator-emit Phase 9) | trekexecute.md:1479-1494 | profile, phase_models, profile_source | none |