fix(intent): M1 + M2 — the parser fails closed; --approve reaches any brief file

M1: headings inside fenced code blocks and HTML comments are text, not
headings, and ## Intent / ## Goal must each appear exactly once. A second
section, an example section in a code block or comment, or a '## ' line in
a code block inside Intent no longer carries the old approval: check gives
INVALID (duplicate) or STALE, stamp refuses a duplicate. A plain brief
hashes exactly as before (pinned), so no stamped brief goes stale.
Re-run of the PM's 29 probes: vi-a, vi-b, vii-a, vii-b now stop; the rest
unchanged. The PM's p7c probe was a no-op (its replace string is not in the
base brief); vii-c is covered by its own test, red on 66e1fa1.

M2: /trekbrief --approve takes <project-dir | brief-file>. New
intent-approval.mjs --resolve decides: a directory → <dir>/brief.md and
/trekplan --project; a file → itself and /trekplan --brief. Phase 4h and the
stamp line use {BRIEF_PATH}; /trekplan's halt table names
/trekbrief --approve {brief_path}, which works on both brief modes, and so
does the gate's hint. HANDOVER-CONTRACTS' "open to every producer's brief"
is now true. Chose fixing the path over rewording the contract because two
real briefs (docs/*-brief.md) had no approval path at all.

Red ff760ed 9/29 → green 29/29. Suite 1219: 1217 pass / 0 fail / 2 skip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-23 09:22:03 +02:00
commit 20f32bb06b
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
7 changed files with 129 additions and 45 deletions

View file

@ -169,7 +169,7 @@ Output: `.claude/projects/{YYYY-MM-DD}-{slug}/brief.md`
| **Default** | `/trekbrief <task>` | Dynamic interview until quality gates pass. No question cap. | | **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. | | **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. | | **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. | | **Approve** | `/trekbrief --approve <project-dir \| brief-file>` | 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. `/trekbrief` is **always interactive**. There is no foreground/background mode — the interview requires user input.
@ -264,7 +264,7 @@ Output:
`--brief` or `--project` is **required**. `/trekplan` with no brief exits with an error and a pointer to `/trekbrief`. `--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. **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 | brief-file>` 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 ### What the plan contains

View file

@ -1,7 +1,7 @@
--- ---
name: trekbrief 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. 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> | --approve <project-dir>" argument-hint: "[--quick] <task description> | --approve <project-dir | brief-file>"
allowed-tools: Agent, Read, Glob, Grep, Write, Edit, Bash, AskUserQuestion allowed-tools: Agent, Read, Glob, Grep, Write, Edit, Bash, AskUserQuestion
--- ---
@ -51,20 +51,30 @@ Parse `$ARGUMENTS`:
by the autonomy-gate state machine via the CLI shim: 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}`. `node ${CLAUDE_PLUGIN_ROOT}/lib/util/autonomy-gate.mjs --state X --event Y --gates {true|false}`.
4. `--approve <project-dir>`: set **mode = approve**. No interview, no 4. `--approve <project-dir | brief-file>`: set **mode = approve**. No
drafting. Set `PROJECT_DIR = <project-dir>` (trim trailing slash). If interview, no drafting. Set `APPROVE_ARG` to the argument and resolve it:
`{PROJECT_DIR}/brief.md` does not exist, print
`Error: no brief at {PROJECT_DIR}/brief.md — run /trekbrief first.` and stop. ```bash
Otherwise jump straight to **Phase 4h**, run it, print its report, and node ${CLAUDE_PLUGIN_ROOT}/lib/validators/intent-approval.mjs --resolve "{APPROVE_ARG}"
stop. This is the remedy `/trekplan` names when it halts on an unapproved ```
or stale intent.
A directory resolves to `<dir>/brief.md` (a `/trekplan --project` brief). A
file resolves to itself, whatever its name (a `/trekplan --brief` brief).
On exit 1, print its `error` and stop. Otherwise set `BRIEF_PATH`,
`PROJECT_DIR` and `PLAN_COMMAND` from `brief_path`, `project_dir` and
`plan_command`, 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, on both of its brief modes.
In every other mode, `BRIEF_PATH = {PROJECT_DIR}/brief.md` and
`PLAN_COMMAND = /trekplan --project {PROJECT_DIR}`.
If no task description is provided (and mode is not approve), output usage and stop: If no task description is provided (and mode is not approve), output usage and stop:
``` ```
Usage: /trekbrief <task description> Usage: /trekbrief <task description>
/trekbrief --quick <task description> /trekbrief --quick <task description>
/trekbrief --approve <project-dir> /trekbrief --approve <project-dir | brief-file>
Modes: Modes:
default Dynamic interview until quality gates pass — brief with research plan default Dynamic interview until quality gates pass — brief with research plan
@ -742,7 +752,7 @@ both paths pass through it — and it is **asked even in `--quick` mode**; there
is no skip path and no default answer. is no skip path and no default answer.
1. Show the operator the `## Intent` and `## Goal` sections of 1. Show the operator the `## Intent` and `## Goal` sections of
`{PROJECT_DIR}/brief.md` **verbatim** — the exact text the approval will `{BRIEF_PATH}` **verbatim** — the exact text the approval will
bind to. Do not summarize them. bind to. Do not summarize them.
2. Ask via `AskUserQuestion` — one question: 2. Ask via `AskUserQuestion` — one question:
**"Is this the intent /trekplan should plan against?"** **"Is this the intent /trekplan should plan against?"**
@ -750,14 +760,14 @@ is no skip path and no default answer.
| Option | Effect | | Option | Effect |
|--------|--------| |--------|--------|
| **Approve** | Stamp the brief (step 3). | | **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. | | **Revise first** | The operator states the change; edit `## Intent` / `## Goal` 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 {PROJECT_DIR}` is run. | | **Leave unapproved** | Write nothing. `/trekplan` will halt on this brief until `/trekbrief --approve {BRIEF_PATH}` is run. |
3. **Only on an explicit "Approve"** — never inferred from silence, from an 3. **Only on an explicit "Approve"** — never inferred from silence, from an
earlier answer, or from a "looks good" in free text — run: earlier answer, or from a "looks good" in free text — run:
```bash ```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/intent-approval.mjs --stamp "{PROJECT_DIR}/brief.md" node ${CLAUDE_PLUGIN_ROOT}/lib/validators/intent-approval.mjs --stamp "{BRIEF_PATH}"
``` ```
This writes `intent_approved_hash` (sha256 over the normalized `## Intent` This writes `intent_approved_hash` (sha256 over the normalized `## Intent`
@ -777,7 +787,7 @@ Intent: {approved (sha256:…12 hex) | left unapproved — /trekplan will halt}
``` ```
In `--approve` mode, stop after the report and print the next step: In `--approve` mode, stop after the report and print the next step:
`/trekplan --project {PROJECT_DIR}` (only if approved). `{PLAN_COMMAND}` (only if approved).
## Phase 5 — Auto-orchestration opt-in (if research_topics > 0) ## Phase 5 — Auto-orchestration opt-in (if research_topics > 0)

View file

@ -191,9 +191,9 @@ agent, write no plan. Print each error's `message` and `hint`, then stop:
| Code | Meaning | What the operator does | | 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_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_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 | | `BRIEF_INTENT_APPROVAL_INVALID` | Marker malformed, or `## Intent` / `## Goal` missing, empty or present more than once | Fix the brief, then the same command |
If the command does not print the JSON report (module not found, node 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 missing, an unresolved `${CLAUDE_PLUGIN_ROOT}`), **halt** as well and show the

View file

@ -74,7 +74,7 @@ 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` | 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. | | `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_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 \| brief-file>`). |
| `intent_approved_at` | string | optional | ISO-8601 | When the stamp was written. Informational; the gate reads the hash. | | `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): **Body invariants:** required sections (validator runs in strict mode at write-time, soft mode at read-time):
@ -106,10 +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. **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. **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 | brief-file>`, 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:** **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_INTENT_NOT_APPROVED` / `BRIEF_INTENT_APPROVAL_STALE` / `BRIEF_INTENT_APPROVAL_INVALID` → `/trekplan` halts (intent-approval gate, above); remedy `/trekbrief --approve <project-dir | brief-file>`
- `BRIEF_NOT_FOUND` → consumer halts with a usage message - `BRIEF_NOT_FOUND` → consumer halts with a usage message
- `FM_MISSING` → file has no frontmatter; halt - `FM_MISSING` → file has no frontmatter; halt
- `BRIEF_WRONG_TYPE` → file is not a brief; halt - `BRIEF_WRONG_TYPE` → file is not a brief; halt

View file

@ -10,7 +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 | | `--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`. | | `--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`. | | `--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`. | | `--approve <project-dir \| brief-file>` | (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). 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

@ -29,9 +29,11 @@
// exit 0 = approved and current; exit 1 = stop (errors); exit 2 = usage // exit 0 = approved and current; exit 1 = stop (errors); exit 2 = usage
// node lib/validators/intent-approval.mjs --stamp <brief.md> // node lib/validators/intent-approval.mjs --stamp <brief.md>
// writes the marker and emits `brief-approved`; exit 0 stamped, 1 refused // writes the marker and emits `brief-approved`; exit 0 stamped, 1 refused
// node lib/validators/intent-approval.mjs --resolve <project-dir | brief-file>
// what `/trekbrief --approve` approves: {brief_path, project_dir, plan_command}; exit 1 = no brief
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; import { existsSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
import { basename, dirname, join } from 'node:path'; import { basename, dirname, join } from 'node:path';
import { parseDocument } from '../util/frontmatter.mjs'; import { parseDocument } from '../util/frontmatter.mjs';
import { issue } from '../util/result.mjs'; import { issue } from '../util/result.mjs';
@ -44,15 +46,45 @@ export const INTENT_SECTIONS = Object.freeze(['Intent', 'Goal']);
const HASH_RE = /^sha256:[0-9a-f]{64}$/; const HASH_RE = /^sha256:[0-9a-f]{64}$/;
const STATS_FILENAME = 'trekexecute-stats.jsonl'; // where event-emit writes lifecycle events 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".'; const APPROVE_HINT = 'Run /trekbrief --approve <project-dir | brief-file> — the operator reads ## Intent + ## Goal and answers "Approve".';
function extractSection(body, heading) { /**
const re = new RegExp(`^##\\s+${heading}\\b.*$`, 'm'); * Line indexes of the `## ` headings that are real headings: a `## ` line
const m = re.exec(body); * inside a fenced code block or an HTML comment is text, not a heading. Seeing
if (!m) return null; * it as one let an example `## Intent` capture the hash, or cut the real
const after = body.slice(m.index + m[0].length); * section short (M1).
const next = after.search(/^##\s/m); */
return next === -1 ? after : after.slice(0, next); function headingLines(lines) {
const out = [];
let fence = null; // { ch, len } of the open fence
let inComment = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (fence) {
const close = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line);
if (close && close[1][0] === fence.ch && close[1].length >= fence.len) fence = null;
continue;
}
if (inComment) {
if (line.includes('-->')) inComment = false;
continue;
}
const open = /^ {0,3}(`{3,}|~{3,})/.exec(line);
if (open) { fence = { ch: open[1][0], len: open[1].length }; continue; }
if (/^##\s/.test(line)) out.push(i);
const c = line.lastIndexOf('<!--');
if (c >= 0 && line.indexOf('-->', c + 4) < 0) inComment = true;
}
return out;
}
/** Every `## <heading>` section's text (one entry per occurrence). */
function extractSections(lines, heads, heading) {
const re = new RegExp(`^##\\s+${heading}\\b`);
return heads.filter((i) => re.test(lines[i])).map((i) => {
const next = heads.find((j) => j > i);
return lines.slice(i + 1, next === undefined ? lines.length : next).join('\n');
});
} }
function normalize(text) { function normalize(text) {
@ -64,20 +96,33 @@ function normalize(text) {
/** /**
* Hash ## Intent + ## Goal of a brief body. * Hash ## Intent + ## Goal of a brief body.
* @returns {{ hash: string|null, missing: string[] }} hash is null when a section is absent or empty. * @returns {{ hash: string|null, missing: string[], duplicate: string[] }} hash is null when a
* section is absent or empty, or appears more than once (which one would the approval cover?).
*/ */
export function computeIntentHash(body) { export function computeIntentHash(body) {
const lines = (body || '').split('\n');
const heads = headingLines(lines);
const parts = []; const parts = [];
const missing = []; const missing = [];
const duplicate = [];
for (const h of INTENT_SECTIONS) { for (const h of INTENT_SECTIONS) {
const s = extractSection(body || '', h); const found = extractSections(lines, heads, h);
const n = s === null ? '' : normalize(s); if (found.length > 1) duplicate.push(h);
if (!n) missing.push(h); const n = found.length === 1 ? normalize(found[0]) : '';
if (!n && found.length <= 1) missing.push(h);
parts.push(n); parts.push(n);
} }
if (missing.length) return { hash: null, missing }; if (missing.length || duplicate.length) return { hash: null, missing, duplicate };
const hex = createHash('sha256').update(JSON.stringify(parts), 'utf8').digest('hex'); const hex = createHash('sha256').update(JSON.stringify(parts), 'utf8').digest('hex');
return { hash: `sha256:${hex}`, missing }; return { hash: `sha256:${hex}`, missing, duplicate };
}
/** Why the approval cannot bind, in words. */
function unbindable({ missing, duplicate }) {
const why = [];
if (duplicate.length) why.push(`## ${duplicate.join(' and ## ')} appears more than once`);
if (missing.length) why.push(`## ${missing.join(' and ## ')} is missing or empty`);
return why.join('; ');
} }
/** Check the approval marker of a brief's text. Returns a validator Result. */ /** Check the approval marker of a brief's text. Returns a validator Result. */
@ -108,12 +153,13 @@ export function checkIntentApprovalContent(text) {
)); ));
return result(); return result();
} }
const { hash, missing } = computeIntentHash(body); const bound = computeIntentHash(body);
const { hash } = bound;
if (!hash) { if (!hash) {
errors.push(issue( errors.push(issue(
'BRIEF_INTENT_APPROVAL_INVALID', 'BRIEF_INTENT_APPROVAL_INVALID',
`The approval cannot bind: ## ${missing.join(' and ## ')} is missing or empty.`, `The approval cannot bind: ${unbindable(bound)}.`,
'Write the missing section(s), then ' + APPROVE_HINT, 'Leave exactly one non-empty ## Intent and one ## Goal, then ' + APPROVE_HINT,
)); ));
return result(); return result();
} }
@ -134,8 +180,9 @@ export function checkIntentApprovalContent(text) {
export function stampIntentApproval(text, now = new Date()) { export function stampIntentApproval(text, now = new Date()) {
const doc = parseDocument(text); const doc = parseDocument(text);
if (!doc.valid) return { stamped: false, text, reason: 'frontmatter does not parse' }; if (!doc.valid) return { stamped: false, text, reason: 'frontmatter does not parse' };
const { hash, missing } = computeIntentHash(doc.parsed.body || ''); const bound = computeIntentHash(doc.parsed.body || '');
if (!hash) return { stamped: false, text, reason: `## ${missing.join(' and ## ')} missing or empty` }; const { hash } = bound;
if (!hash) return { stamped: false, text, reason: unbindable(bound) };
const m = /^(?---\r?\n)([\s\S]*?)(\r?\n---)/.exec(text); const m = /^(?---\r?\n)([\s\S]*?)(\r?\n---)/.exec(text);
if (!m) return { stamped: false, text, reason: 'no frontmatter block' }; if (!m) return { stamped: false, text, reason: 'no frontmatter block' };
const approvedAt = now.toISOString(); const approvedAt = now.toISOString();
@ -157,12 +204,39 @@ export function resolveApprovalDataDir(env = process.env) {
return resolveStatsDir(env); return resolveStatsDir(env);
} }
/**
* What `/trekbrief --approve <arg>` approves. A directory means `<dir>/brief.md`
* (a /trekplan --project brief); a file is itself (a /trekplan --brief brief,
* whatever its name). Before M2 only `<dir>/brief.md` was reachable.
* @returns {{ brief_path: string, project_dir: string, plan_command: string } | { error: string }}
*/
export function resolveApproveTarget(arg) {
const target = String(arg || '').replace(/\/+$/, '');
if (!target) return { error: 'no brief given — /trekbrief --approve <project-dir | brief-file>' };
let isDir = false;
try { isDir = statSync(target).isDirectory(); } catch { /* absent */ }
const briefPath = isDir ? join(target, 'brief.md') : target;
if (!existsSync(briefPath) || statSync(briefPath).isDirectory()) {
return { error: `no brief at ${briefPath} — run /trekbrief first` };
}
const projectDir = dirname(briefPath);
const planCommand = basename(briefPath) === 'brief.md'
? `/trekplan --project ${projectDir}`
: `/trekplan --brief ${briefPath}`;
return { brief_path: briefPath, project_dir: projectDir, plan_command: planCommand };
}
if (import.meta.url === `file://${process.argv[1]}`) { if (import.meta.url === `file://${process.argv[1]}`) {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const file = args.find((a) => !a.startsWith('--')); const file = args.find((a) => !a.startsWith('--'));
if (args.includes('--resolve')) {
const r = resolveApproveTarget(file);
process.stdout.write(JSON.stringify(r) + '\n');
process.exit(r.error ? 1 : 0);
}
const mode = args.includes('--stamp') ? 'stamp' : args.includes('--check') ? 'check' : null; const mode = args.includes('--stamp') ? 'stamp' : args.includes('--check') ? 'check' : null;
if (!file || !mode) { if (!file || !mode) {
process.stderr.write('Usage: intent-approval.mjs --check [--json] <brief.md> | --stamp <brief.md>\n'); process.stderr.write('Usage: intent-approval.mjs --check [--json] <brief.md> | --stamp <brief.md> | --resolve <project-dir | brief-file>\n');
process.exit(2); process.exit(2);
} }
let text = null; let text = null;

View file

@ -305,8 +305,8 @@ test('/trekbrief --approve is a parsed flag and a documented mode', async () =>
assert.equal(r.flags['--approve'], '.claude/projects/2026-09-23-dark-mode-toggle'); assert.equal(r.flags['--approve'], '.claude/projects/2026-09-23-dark-mode-toggle');
assert.deepEqual(r.unknown, []); assert.deepEqual(r.unknown, []);
assert.deepEqual(r.errors, []); assert.deepEqual(r.errors, []);
assert.match(read('commands/trekbrief.md'), /--approve <project-dir>/); assert.match(read('commands/trekbrief.md'), /--approve <project-dir \| brief-file>/);
assert.match(read('docs/command-modes.md'), /`--approve <project-dir>`/); assert.match(read('docs/command-modes.md'), /`--approve <project-dir \\\| brief-file>`/);
}); });
// ---- M1: the parser fails CLOSED on extra, hidden or cut-off sections ---- // ---- M1: the parser fails CLOSED on extra, hidden or cut-off sections ----