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 on66e1fa1. 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. Redff760ed9/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:
parent
ff760edba2
commit
20f32bb06b
7 changed files with 129 additions and 45 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 ----
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue