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:
parent
f39e7ccf39
commit
f5dc08f660
9 changed files with 315 additions and 14 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
const FLAG_SCHEMA = {
|
||||
trekbrief: {
|
||||
boolean: ['--quick', '--fg'],
|
||||
valued: ['--profile'],
|
||||
valued: ['--profile', '--approve'],
|
||||
aliases: {},
|
||||
},
|
||||
trekresearch: {
|
||||
|
|
|
|||
209
lib/validators/intent-approval.mjs
Normal file
209
lib/validators/intent-approval.mjs
Normal 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);
|
||||
}
|
||||
2
tests/fixtures/jsonl-schemas.md
vendored
2
tests/fixtures/jsonl-schemas.md
vendored
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue