merge: wave 1 order 2 (intent) into main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
21c28df3bc
16 changed files with 1527 additions and 85 deletions
|
|
@ -8,7 +8,9 @@ Voyage — a contract-driven Claude Code pipeline: brief, research, plan, execut
|
|||
|
||||
> **Trinity context (informational).** Voyage is Tier 1 (per-task) of a three-tier architecture. **Asymmetry is a hard invariant:** Voyage stays unaware of Tier 2/3; Handover 1 (brief format) is the only integration point, no producer is privileged, and brief-schema changes are breaking for downstream consumers (formalized as a public contract in v5.5.0). Tier 2/3 producer detail + the public contract → `docs/HANDOVER-CONTRACTS.md` §Handover 1 (PUBLIC CONTRACT).
|
||||
|
||||
> **Cross-cutting invariant: brief framing must match operator intent.** The brief is the pipeline's source of truth; operator intent lives in memory files — the pipeline must not polish a wrong premise. Enforced as the `brief_version 2.2` gate (v5.5), all BLOCKER for briefs declaring ≥ 2.2: (1) explicit `framing: preserve|refine|replace|new-direction` frontmatter, `AskUserQuestion`-validated in `/trekbrief` Phase 2.5; (2) memory-alignment as `brief-reviewer` dimension 6; (3) mandatory `## TL;DR`. Existing 2.0/2.1 briefs stay valid; `trekreview` briefs are exempt. Full implementation + contract evolution → `docs/HANDOVER-CONTRACTS.md` §Handover 1 (PUBLIC CONTRACT).
|
||||
> **Cross-cutting invariant: brief framing must match operator intent.** The brief is the pipeline's source of truth; operator intent lives in memory files — the pipeline must not polish a wrong premise. Enforced as the `brief_version 2.2` gate (v5.5), all BLOCKER for briefs declaring ≥ 2.2: (1) explicit `framing: preserve|refine|replace|new-direction` frontmatter, `AskUserQuestion`-validated in `/trekbrief` Phase 2.5; (2) memory-alignment as `brief-reviewer` dimension 6; (3) mandatory `## TL;DR`. Existing 2.0/2.1 briefs stay valid; `trekreview` briefs are exempt. Two optional fields ride on 2.2 without a bump: `ceremony: small|bounded|architectural` (K1; absent = `architectural`) and `artifact_store: git|local` (absent = `local`). Full implementation + contract evolution → `docs/HANDOVER-CONTRACTS.md` §Handover 1 (PUBLIC CONTRACT).
|
||||
|
||||
> **Approvals are logged in the target repo.** `lib/util/approval-log.mjs` (K2) keeps an append-only, hash-chained log bound to each approved content's sha256 (`.claude/voyage/approvals.jsonl`), committed by `lib/util/artifact-commit.mjs`; `/trekplan` Phase 1 halts unless it holds the brief's current intent. A trace, not a signature: its trust limits are in the module header.
|
||||
|
||||
> **Binding rule (operator decision, 2026-09-27): Voyage never starts Claude Code.**
|
||||
> Nothing Voyage ships may start a Claude Code session or model run: no `claude`
|
||||
|
|
@ -26,7 +28,7 @@ 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; ends with the operator approving `## Intent` + `## Goal` (`--approve <dir>` does only that, then writes the trekbrief record); optionally orchestrates the pipeline | session |
|
||||
| `/trekbrief` | Brief — one question round sized by class (`ceremony: small\|bounded\|architectural`, heaviest in doubt, said aloud, only moved up) produces a task brief with explicit research plan; ends with the operator explicitly approving `## Intent` + `## Goal` («høres bra ut» is not approval), logged in the target repo's hash-chained approval log and committed (the brief too when `artifact_store: git`) (`--approve <dir>` does only the approval, then writes the trekbrief record); 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`; halts unless the brief's intent is approved (hash-bound, stale on edit). Auto-discovers `architecture/overview.md` if present. Plan 1.8: `Test first:` per step + one `End-to-end:` step; a design part for `ceremony: architectural` (absent = architectural); critic loop of max 3 rounds, recorded in the plan, and `plan-gate.mjs` (0 go / 1 stop / 2 could not run) decides | session |
|
||||
| `/trekexecute` | Execute — disciplined plan/session-spec executor with failure recovery | session |
|
||||
|
|
|
|||
18
README.md
18
README.md
|
|
@ -156,18 +156,26 @@ What this pipeline does **not** solve — read this before adopting it:
|
|||
|
||||
## `/trekbrief` — Brief
|
||||
|
||||
Interactive requirements-gathering command. Runs a **dynamic, quality-gated interview** and produces a **task brief** with an explicit research plan. Optionally orchestrates the rest of the pipeline.
|
||||
Interactive requirements-gathering command. Runs a **question round sized to the task** and produces a **task brief** with an explicit research plan. Optionally orchestrates the rest of the pipeline.
|
||||
|
||||
A section-driven interview loop fills required brief sections (Intent / Goal / Success Criteria / Research Plan) until each shows initial signal, then `brief-reviewer` scores the draft on six dimensions (completeness, consistency, testability, scope clarity, research-plan validity, memory alignment) and gates publication. Max 3 review iterations; force-stop yields a `brief_quality: partial` brief with the failing dimensions documented.
|
||||
**Ceremony by size.** The task is put in one of three classes, `small`, `bounded` or `architectural` (the heaviest when in doubt), said aloud, overridable by you, and only ever moved up when the task proves bigger. The class sets the question budget (a `small` task: at most 4 questions, in one round) and the review cap, and is written to the brief as `ceremony:`.
|
||||
|
||||
**The question round.** Facts are looked up in the repo, never asked. Every question that can be answered now is asked at once, the recommended answer first, ranked by impact × uncertainty; what the budget leaves out is on a visible Deferred list, and what can be predicted is written down as an assumption you see before approving. «Høres bra ut», «gjør som du vil» and an empty answer decide nothing.
|
||||
|
||||
Then `brief-reviewer` scores the draft on six dimensions (completeness, consistency, testability, scope clarity, research-plan validity, memory alignment) and gates publication, up to 1 / 2 / 3 review iterations by class; force-stop yields a `brief_quality: partial` brief with the failing dimensions documented.
|
||||
|
||||
**Approval, logged and committed.** Every run ends with you approving `## Intent` + `## Goal` (only an explicit "Approve" counts). The approval is appended to the target repo's hash-chained approval log, `.claude/voyage/approvals.jsonl`, bound to the intent's sha256, and committed. The log is always committed; the brief only when you chose "in git" for this task (`artifact_store: git`). "Only on this machine" keeps the project folder out of git via `.git/info/exclude`. The log is a trace, not a signature: any session on your machine could write it, and the commit is what makes it visible.
|
||||
|
||||
Output: `.claude/projects/{YYYY-MM-DD}-{slug}/brief.md`
|
||||
|
||||
Borrowed, with credit: size classes and the ratchet from obra/superpowers (MIT), the class ladder from Kiro's docs (idea only), the round from mattpocock/skills (MIT), the budget and Deferred list from GitHub Spec Kit (MIT), the stop rule and hollow answers from addyosmani/agent-skills (MIT), the hash-chained log from bashebr/ai-native-sdlc (MIT). Pinned commits in `commands/trekbrief.md` Phase 3 and `lib/util/approval-log.mjs`.
|
||||
|
||||
### Modes
|
||||
|
||||
| Mode | Usage | Behavior |
|
||||
|------|-------|----------|
|
||||
| **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. |
|
||||
| **Default** | `/trekbrief <task>` | Question round sized by class, then review and approval. |
|
||||
| **Quick** | `/trekbrief --quick <task>` | Proposes `small` as the class; still said aloud, overridable, and raised if the task proves bigger. |
|
||||
| **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 \| brief-file>` | No interview: show an existing brief's `## Intent` + `## Goal` verbatim and ask the operator to approve them; on "Approve" it also writes the trekbrief stats record. The same step (Phase 4h) ends every normal `/trekbrief` run. |
|
||||
|
||||
|
|
@ -187,7 +195,7 @@ If you say "stop" or "enough" during Phase 4, the current review findings are su
|
|||
- **Constraints / Preferences / NFRs** — technical, time, resource limits
|
||||
- **Success Criteria** — 2-4 falsifiable commands/observations
|
||||
- **Research Plan** — N topics, each with research question, scope (local/external/both), confidence needed, cost estimate, and a ready-to-run `/trekresearch` command
|
||||
- **Open Questions / Assumptions** — from "I don't know" answers and implicit gaps
|
||||
- **Open Questions / Assumptions** — from "I don't know" answers and implicit gaps, what was assumed instead of asked, and the Deferred list
|
||||
- **Prior Attempts** — what worked/failed before
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -33,10 +33,9 @@ foreground if the user opts in.
|
|||
Parse `$ARGUMENTS`:
|
||||
|
||||
1. If arguments start with `--quick`: set **mode = quick**. The interview
|
||||
starts more compactly (fewer opening probes per section) but still
|
||||
escalates automatically if quality gates fail. There is no hard cap on
|
||||
question count — quality drives the loop, not a counter. Strip the flag;
|
||||
remainder is the task description.
|
||||
proposes `small` as the starting size class (Phase 3 still says the class
|
||||
aloud, the operator can override it, and it moves up if the task proves
|
||||
bigger). Strip the flag; remainder is the task description.
|
||||
|
||||
2. Otherwise: **mode = default**. Interview probes each section until the
|
||||
completeness gate (Phase 3) and brief-review gate (Phase 4) both pass.
|
||||
|
|
@ -131,7 +130,9 @@ can polish a wrong premise with reasoning power.
|
|||
brief frontmatter as `framing: <value>` in Step 4a and is REQUIRED for
|
||||
`brief_version: "2.2"` (the validator emits `BRIEF_MISSING_FRAMING` otherwise).
|
||||
|
||||
Ask via `AskUserQuestion` — one question, four canonical options:
|
||||
It is the **first question of the Phase 3 round** — asked in the same
|
||||
`AskUserQuestion` call as the round's other questions, not as a call of its
|
||||
own — with four canonical options:
|
||||
|
||||
| Option | Maps to `framing:` | Meaning |
|
||||
|--------|--------------------|---------|
|
||||
|
|
@ -147,7 +148,8 @@ against is exactly "delta from last is a dangerous default anchor after a reject
|
|||
iteration." Therefore **this question is asked even in `--quick` mode** and is the
|
||||
one dialog that has no skip path. If the operator force-stops here, re-surface the
|
||||
four options once more; the brief cannot be written at `brief_version: "2.2"`
|
||||
without a committed framing value.
|
||||
without a committed framing value. A hollow answer («høres bra ut», «gjør som
|
||||
du vil», an empty reply) is not a framing value either: ask it again.
|
||||
|
||||
Report:
|
||||
```
|
||||
|
|
@ -156,13 +158,105 @@ Framing: {preserve | refine | replace | new-direction}
|
|||
|
||||
## Phase 3 — Completeness loop
|
||||
|
||||
Phase 3 is a **section-driven completeness loop**. Instead of a numbered
|
||||
question list, maintain an internal state of brief sections and keep asking
|
||||
until every required section has substantive content. Quality drives the
|
||||
loop — there is no hard cap on question count.
|
||||
Phase 3 gathers the brief's content in **one question round**, sized by the
|
||||
task's **size class** (ceremony by size). Facts are looked up, never asked;
|
||||
then every question that can be answered now goes out at once, each with the
|
||||
recommended answer first. Use `AskUserQuestion` for every question.
|
||||
|
||||
Use `AskUserQuestion` for every question. **Ask one question at a time.**
|
||||
Never dump multiple questions.
|
||||
### Step 3a — Look up facts first
|
||||
|
||||
Never ask the operator a fact a helper can look up: which framework the repo
|
||||
uses, where a module lives, what the test command is, whether a file exists.
|
||||
Read the repo (Glob / Grep / Read, or a subagent for a wide search — never with
|
||||
the Agent tool's `name` parameter) and carry what you found into the round. The
|
||||
operator answers decisions and intent, not lookups.
|
||||
|
||||
### Step 3b — Ceremony by size (K1)
|
||||
|
||||
Put the task in one of three size classes. In doubt, take the heaviest that fits.
|
||||
|
||||
| Class | Typical task | What the pipeline does |
|
||||
|---|---|---|
|
||||
| `small` | one change in one place, no new dependency, no interface change, easy to undo | one round, no follow-up; brief-reviewer runs once and its gaps become written assumptions, not questions; Phase 3.5 is skipped |
|
||||
| `bounded` | several files or one module behind a clear boundary, no architecture decision | the round + at most one follow-up; at most one brief-review follow-up; Phase 3.5 asks one question for all four phases |
|
||||
| `architectural` | a new module, interface, dependency, data model or migration; anything hard to undo | follow-up rounds while answers open new branches; the full brief-review loop; Phase 3.5 asks per phase |
|
||||
|
||||
- **Said aloud.** State the class and why in one line *before* the round
|
||||
("This reads as `small`: one setting on one screen, no new dependency."),
|
||||
and put it to the operator as one of the round's questions: your class as
|
||||
the recommendation, the other two as the override.
|
||||
- **Only ever moves up.** When an answer, the brief-reviewer or the repo shows
|
||||
more than the class assumed (a migration, a new dependency, an interface
|
||||
change), raise the class, say so in one line, and use the new budget from
|
||||
then on. You never lower it yourself; only the operator's explicit override
|
||||
lowers it.
|
||||
- **Written to the brief** as `ceremony: <class>` in Step 4a. An absent field
|
||||
reads as `architectural`, so a brief from another producer gets the
|
||||
heaviest treatment downstream.
|
||||
|
||||
### Step 3c — The question round
|
||||
|
||||
1. **Candidates.** The coverage list is the question bank below (Intent, Goal,
|
||||
Success Criteria, Research Plan, Non-Goals, Constraints, Preferences, NFRs,
|
||||
Prior Attempts), plus three fixed questions every round carries: framing
|
||||
(Phase 2.5, first), the size class (Step 3b) and where the brief is stored
|
||||
(`artifact_store`, below).
|
||||
2. **Drop what you can predict.** If the task description, the repo and the
|
||||
answers so far let you predict the answer, do not ask: write the prediction
|
||||
into `## Open Questions / Assumptions`, marked "assumed, not asked", where
|
||||
the operator sees it before approving.
|
||||
3. **Rank the rest by Impact × Uncertainty**: how much the plan changes with
|
||||
the answer, times how unsure you are of it. Ask the highest within the
|
||||
class budget. Everything over the budget goes on a **Deferred** list, one
|
||||
line of why each, printed with the round and written into
|
||||
`## Open Questions / Assumptions` — the budget never hides a gap.
|
||||
4. **One round.** All questions that can be answered now, in one
|
||||
`AskUserQuestion` call. The tool takes at most 4 questions per call, so a
|
||||
`bounded` or `architectural` round is consecutive calls with no drafting in
|
||||
between. Each question is a full sentence in plain words, says in one
|
||||
clause why it matters, and lists the **recommended answer first**, marked
|
||||
"(Recommended)", with the consequence of each option in its description.
|
||||
5. **Stop rule.** Stop asking as soon as you can predict the operator's
|
||||
next three answers; write those predictions down as assumptions instead. A
|
||||
follow-up round is only for an answer that opens a branch you cannot
|
||||
predict (`small`: never; `bounded`: at most one).
|
||||
6. **Answers that decide nothing.** «høres bra ut», «gjør som du vil», "sounds
|
||||
good", "whatever you think" and an empty reply are not a decision. Record
|
||||
the recommended answer as an assumption ("not confirmed — the answer was
|
||||
«…»"), never as the operator's choice.
|
||||
|
||||
**Budget per class** (interview questions, the three fixed ones included; the
|
||||
Phase 4h approval is not counted):
|
||||
|
||||
| Class | Questions | Rounds |
|
||||
|---|---|---|
|
||||
| `small` | 4 | one `AskUserQuestion` call, no follow-up |
|
||||
| `bounded` | 8 | the round + at most one follow-up |
|
||||
| `architectural` | 12 per round | the round + follow-ups while answers open new branches |
|
||||
|
||||
**Where the brief is stored** (`artifact_store`; operator decision
|
||||
2026-10-06: the approval log is always committed, the brief only by choice).
|
||||
One question every round. Recommend `git` for a private repo and `local` when
|
||||
the repo is public or you cannot tell:
|
||||
|
||||
| Option | Maps to | Consequence |
|
||||
|---|---|---|
|
||||
| **In git** | `artifact_store: git` | `brief.md` (and later `plan.md`) is committed together with its approval. |
|
||||
| **Only on this machine** | `artifact_store: local` | The project folder is kept out of git through the repo's `.git/info/exclude`, a file that is never committed. |
|
||||
|
||||
The approval log is committed either way; it holds hashes, times and file
|
||||
names, never the text.
|
||||
|
||||
**Borrowed, with credit.** The size classes, "heaviest in doubt" and the
|
||||
one-way ratchet: obra/superpowers@8ca22db (MIT, © 2025 Jesse Vincent),
|
||||
skills/brainstorming. The class ladder: Kiro's docs (kiro.dev — Plan mode,
|
||||
Quick Spec, full spec; read 2026-10-06; an idea only, Kiro is a closed
|
||||
product). The round, the recommended answer first and "never ask a fact":
|
||||
mattpocock/skills@c55ee46 (MIT), productivity/grilling. The budget,
|
||||
Impact × Uncertainty and the Deferred list: GitHub Spec Kit (MIT),
|
||||
templates/commands/clarify.md at 2e941fe. The stop rule and the answers that
|
||||
decide nothing: addyosmani/agent-skills@9d0c60d (MIT, © 2025 Addy Osmani),
|
||||
skills/interview-me.
|
||||
|
||||
### Internal state
|
||||
|
||||
|
|
@ -179,6 +273,10 @@ state = {
|
|||
preferences: { content: [], probes: 0 }, # optional
|
||||
nfrs: { content: [], probes: 0 }, # optional
|
||||
prior_attempts: { content: "", probes: 0 }, # optional
|
||||
ceremony: "small | bounded | architectural", # Step 3b; only moves up
|
||||
artifact_store: "git | local", # Step 3c
|
||||
assumptions: [], # predicted, hollow or force-stopped answers
|
||||
deferred: [], # over budget, with why
|
||||
question_history: [] # list of questions asked
|
||||
}
|
||||
```
|
||||
|
|
@ -195,7 +293,9 @@ Four sections MUST have substantive content before exiting Phase 3:
|
|||
2. **Goal** — full sentence or paragraph
|
||||
3. **Success Criteria** — at least one concrete, testable item
|
||||
4. **Research Plan** — either ≥ 1 topic probed, OR the user has explicitly
|
||||
confirmed "no external research needed"
|
||||
confirmed "no external research needed", OR (when you can predict it) it
|
||||
is written down as the assumption "no external research needed", which the
|
||||
operator sees in the brief before approving
|
||||
|
||||
"Substantive" means: non-empty, not a trivial one-word reply, not
|
||||
"I don't know" without a recorded assumption. The strict falsifiability
|
||||
|
|
@ -209,9 +309,9 @@ assumed" in Phase 4's draft.
|
|||
|
||||
### Question bank (per section)
|
||||
|
||||
Pick the next question from the section's bank based on `content` and
|
||||
`probes`. Wording must stay conversational — only the *selection* is
|
||||
section-driven, not the tone.
|
||||
The coverage list Step 3c draws its candidates from. Each variant is a
|
||||
starting point for a round question, not a script; write it as a full
|
||||
sentence with your recommended answer first.
|
||||
|
||||
**Intent** (required):
|
||||
- _Anchor_ (probes=0, content empty): "Why are we doing this? What is the
|
||||
|
|
@ -269,25 +369,20 @@ section-driven, not the tone.
|
|||
|
||||
### Selection rule
|
||||
|
||||
On each loop iteration:
|
||||
Within the round (Step 3c), and in a follow-up round:
|
||||
|
||||
1. Compute the next section to probe:
|
||||
- If any required section is below the initial-signal gate → pick the
|
||||
weakest required section in this priority order:
|
||||
Intent → Goal → Success Criteria → Research Plan.
|
||||
- Else if an optional section is clearly missing and likely material
|
||||
to scope (heuristic: the task description hints at constraints or
|
||||
NFRs) → probe it at most once.
|
||||
- Else: exit Phase 3.
|
||||
2. Within the chosen section, pick the question variant:
|
||||
- If `probes == 0` and content is empty → _Anchor_.
|
||||
- If content exists but is shallow → _Follow-up_ or _Sharpen_.
|
||||
- If the section is Research Plan and topics exist → iterate per-topic
|
||||
sharpen across incomplete topics.
|
||||
3. Ensure the exact question is NOT already in `question_history`. If it
|
||||
is, pick the next variant or skip to the next weakest section.
|
||||
4. Ask via `AskUserQuestion`. Append question to history. Increment probes.
|
||||
5. Record the answer into `content`. Never overwrite — merge.
|
||||
1. A required section below the initial-signal gate outranks every optional
|
||||
one, in this order: Intent → Goal → Success Criteria → Research Plan — unless
|
||||
you can predict it from the task and the repo, in which case it becomes a
|
||||
written assumption the operator sees before approving.
|
||||
2. An optional section is a candidate only when the task hints that it
|
||||
matters to scope (constraints, NFRs); it is asked at most once.
|
||||
3. Per section, pick the variant: _Anchor_ when nothing is known, _Follow-up_
|
||||
or _Sharpen_ when an answer is shallow, per-topic sharpen for Research Plan
|
||||
topics.
|
||||
4. Never repeat a question in `question_history`. Append each asked question,
|
||||
increment its section's `probes`, and merge the answer into `content` —
|
||||
never overwrite.
|
||||
|
||||
### Research topic identification
|
||||
|
||||
|
|
@ -317,10 +412,10 @@ list. Trust the user. If no topics emerge after probing, the user confirms
|
|||
### Quick mode adjustments
|
||||
|
||||
If **mode = quick**:
|
||||
- For optional sections, cap probes at 1 each. Do not revisit optional
|
||||
sections during Phase 3.
|
||||
- Required sections still have no probe cap — quality gate still applies.
|
||||
- Prefer _Anchor_ variants over _Sharpen_ on the first pass.
|
||||
- Propose `small` as the class in Step 3b. It is still said aloud, the
|
||||
operator can still override it, and it still moves up when the task proves
|
||||
bigger; `--quick` then follows the new class's budget.
|
||||
- Prefer _Anchor_ variants over _Sharpen_.
|
||||
|
||||
### Force-stop path
|
||||
|
||||
|
|
@ -334,12 +429,17 @@ similar, break the loop immediately:
|
|||
### Exit condition
|
||||
|
||||
Exit Phase 3 when:
|
||||
- All four required sections meet the initial-signal gate, OR
|
||||
- All four required sections meet the initial-signal gate (asked, or written
|
||||
down as an assumption the operator will see), OR
|
||||
- The class budget is used up — what is left goes on the Deferred list, OR
|
||||
- The user has force-stopped.
|
||||
|
||||
Report:
|
||||
```
|
||||
Phase 3 complete: {N} questions asked across {M} sections.
|
||||
Class: {small | bounded | architectural}{ (raised from X: <why>) }
|
||||
Phase 3 complete: {N} of {budget} questions asked in {R} round(s).
|
||||
Assumed, not asked: {K} Deferred: {D}
|
||||
Stored: {in git | only on this machine}
|
||||
Proceeding to draft and review.
|
||||
```
|
||||
|
||||
|
|
@ -364,11 +464,17 @@ Before entering Phase 3.5 the following must be populated:
|
|||
If any are absent: skip Phase 3.5 entirely and write `phase_signals_partial:
|
||||
true` to the draft frontmatter. Do not block.
|
||||
|
||||
### --quick mode
|
||||
### By size class (and --quick)
|
||||
|
||||
If the operator launched with `--quick`: skip Phase 3.5 entirely and
|
||||
auto-write `phase_signals_partial: true` to draft frontmatter. The brief
|
||||
will satisfy the v5.1 sequencing gate without going through the dialog.
|
||||
- `small` (and `--quick` while the class stays `small`): skip Phase 3.5
|
||||
entirely and auto-write `phase_signals_partial: true` to draft frontmatter.
|
||||
The brief satisfies the v5.1 sequencing gate without the dialog, and the
|
||||
downstream commands fall back to the profile.
|
||||
- `bounded`: ask ONE question in the Step 3c round — the tier for all four
|
||||
phases, with 4 options from the mapping below — and write the same tier
|
||||
for `research`, `plan`, `execute` and `review`.
|
||||
- `architectural`: the per-phase dialog below, as one `AskUserQuestion` call
|
||||
carrying four questions (one per phase).
|
||||
|
||||
### Default-derivation heuristic (LLM judgment, not algorithmic)
|
||||
|
||||
|
|
@ -384,10 +490,10 @@ these signals — they are weak heuristics, not rules:
|
|||
Mix these into one proposed default per phase. Document the proposed tier
|
||||
in the question body so the operator sees why it was picked.
|
||||
|
||||
### The loop — 4 tier-coupled AskUserQuestion calls
|
||||
### The dialog — one call, one tier-coupled question per phase
|
||||
|
||||
Loop over `[research, plan, execute, review]` in order. For each phase,
|
||||
issue one `AskUserQuestion` with 4 options:
|
||||
For `[research, plan, execute, review]`, ask one question each, all four in
|
||||
one `AskUserQuestion` call, each question with 4 options:
|
||||
|
||||
| Option | Maps to phase_signals entry |
|
||||
|--------|----------------------------|
|
||||
|
|
@ -397,9 +503,10 @@ issue one `AskUserQuestion` with 4 options:
|
|||
| **Fable (max quality)** | `{phase: <name>, effort: high, model: fable}` |
|
||||
|
||||
The proposed tier per phase (from the default-derivation heuristic) MUST be
|
||||
labelled `(default)` in the option list so the operator can one-click
|
||||
accept. Commit the chosen tier immediately to an in-memory `effort_state`
|
||||
dict — no bulk summary-before-commit. The loop is interruptible.
|
||||
labelled `(default)` and listed first so the operator can one-click
|
||||
accept. Commit the chosen tiers to an in-memory `effort_state` dict as the
|
||||
answers come back — no bulk summary-before-commit. A hollow or empty answer
|
||||
for a phase leaves that phase uncommitted (the force-stop path below).
|
||||
|
||||
The mapping table is canonical:
|
||||
- `low → {effort: low, model: sonnet}` (force sonnet for the low-cost path)
|
||||
|
|
@ -417,7 +524,7 @@ saved for one model does not carry to another.
|
|||
|
||||
### Force-stop handling
|
||||
|
||||
If during any of the four `AskUserQuestion` calls the operator says "stop",
|
||||
If in answer to the dialog the operator says "stop",
|
||||
"skip", "enough", "just write it", or similar, do NOT exit silently — apply
|
||||
the Phase 4f force-stop pattern verbatim:
|
||||
|
||||
|
|
@ -477,8 +584,12 @@ Read the brief template first:
|
|||
|
||||
### Loop bound
|
||||
|
||||
**Maximum 3 review iterations.** This bounds cost in the worst case while
|
||||
leaving room for two rounds of targeted follow-ups.
|
||||
**The cap follows the size class:** `small` 1 review iteration (no
|
||||
follow-up question: what fails is written into `## Open Questions /
|
||||
Assumptions` and `## Brief Quality`), `bounded` 2 (one follow-up),
|
||||
`architectural` 3 (two rounds of targeted follow-ups). A follow-up question
|
||||
counts against the class budget; when the reviewer shows the task is bigger
|
||||
than its class, raise the class first (Step 3b) and use the new cap.
|
||||
|
||||
### Iteration step-by-step
|
||||
|
||||
|
|
@ -491,7 +602,10 @@ Build the brief text from Phase 3 state by filling the template:
|
|||
(will update in Phase 5 if user opts in), `interview_turns` (total
|
||||
questions asked across Phase 3 + Phase 4), `source: interview`. Set
|
||||
`brief_version: "2.2"` and `framing: <state.framing>` (committed in Phase
|
||||
2.5 — never omit; the validator blocks a 2.2 brief without it).
|
||||
2.5 — never omit; the validator blocks a 2.2 brief without it). Set
|
||||
`ceremony: <state.ceremony>` and `artifact_store: <state.artifact_store>`
|
||||
(both optional in the schema, so no `brief_version` bump; always written
|
||||
by this command).
|
||||
- **TL;DR (v5.5, required at 2.2):** write a `## TL;DR` section (≤ 5 content
|
||||
lines) at the very top of the body, before `## Intent`. It is the
|
||||
framing-anchored one-glance summary — what the brief asks for and how it
|
||||
|
|
@ -509,7 +623,10 @@ Build the brief text from Phase 3 state by filling the template:
|
|||
research needed — user confirmed solid knowledge of all plan
|
||||
dependencies" note.
|
||||
- **Open Questions / Assumptions:** from any `"I don't know"` answers
|
||||
recorded during Phase 3, plus implicit gaps.
|
||||
recorded during Phase 3, plus implicit gaps; every `state.assumptions`
|
||||
entry marked "assumed, not asked" or "not confirmed — the answer was «…»"
|
||||
(what you said apart from what the operator said); and the Deferred list,
|
||||
one line of why each.
|
||||
- **Prior Attempts:** from state, or "None — fresh task."
|
||||
|
||||
**Step 4b — Write draft to disk**
|
||||
|
|
@ -597,7 +714,7 @@ stumble.)
|
|||
`brief.md` fresh.
|
||||
3. Break the loop and proceed to Step 4g.
|
||||
|
||||
**If gate fails AND iteration count < 3:**
|
||||
**If gate fails AND iteration count < the class cap:**
|
||||
1. Identify the weakest dimension (lowest score; tie broken by priority:
|
||||
research_plan > memory_alignment > testability > completeness >
|
||||
consistency > scope_clarity).
|
||||
|
|
@ -630,7 +747,7 @@ stumble.)
|
|||
> question_history}. Focus on issues that remain after those answers —
|
||||
> do not re-raise gaps that have already been addressed."
|
||||
|
||||
**If gate fails AND iteration count == 3 (loop exhausted):**
|
||||
**If gate fails AND iteration count == the class cap (loop exhausted):**
|
||||
1. Move `brief.md.draft` → `brief.md`.
|
||||
2. Add `brief_quality: partial` to the frontmatter (edit the file
|
||||
post-rename — insert the key above the closing `---`).
|
||||
|
|
@ -760,12 +877,27 @@ is no skip path and no default answer.
|
|||
|
||||
| Option | Effect |
|
||||
|--------|--------|
|
||||
| **Approve** | Stamp the brief (step 3). |
|
||||
| **Approve** | Check the answer (step 3), then stamp, log and commit (steps 4–6). |
|
||||
| **Revise first** | The operator states the change; edit `## Intent` / `## Goal` in `{BRIEF_PATH}`, re-run the Phase 4g validator, then return to step 1. |
|
||||
| **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
|
||||
earlier answer, or from a "looks good" in free text — run:
|
||||
3. **Is the answer an approval?** Only an explicit "Approve" is. Check the
|
||||
operator's answer as given, verbatim, on stdin (never in the command line):
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/util/approval-log.mjs answer <<'ANSWER'
|
||||
{OPERATOR_ANSWER}
|
||||
ANSWER
|
||||
```
|
||||
|
||||
Exit 0 → step 4. Exit 1 → it is not an approval: «høres bra ut»,
|
||||
«gjør som du vil», "sounds good", "looks good", "whatever you think" and an
|
||||
empty answer all land here, as does anything you would have to infer approval
|
||||
from (silence, an earlier answer). Say in one line that the answer was not
|
||||
taken as approval and why, then ask step 2 once more; a second
|
||||
non-approval is "Leave unapproved". Never stamp after exit 1.
|
||||
|
||||
4. **Stamp the brief:**
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/intent-approval.mjs --stamp "{BRIEF_PATH}"
|
||||
|
|
@ -778,13 +910,44 @@ node ${CLAUDE_PLUGIN_ROOT}/lib/validators/intent-approval.mjs --stamp "{BRIEF_PA
|
|||
`## 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`).
|
||||
5. **Log the approval** (K2), whatever `artifact_store` says. `{INTENT_HASH}`
|
||||
is the `intent_hash` the stamp just printed:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/util/approval-log.mjs append --kind intent --subject "{BRIEF_PATH}" --sha256 "{INTENT_HASH}"
|
||||
```
|
||||
|
||||
It appends one hash-chained record to the target repo's
|
||||
`.claude/voyage/approvals.jsonl`. On exit 1 or 2, print its `reason` in one
|
||||
line: the brief is stamped but not logged, so `/trekplan` halts
|
||||
(`not-logged`) until `/trekbrief --approve {BRIEF_PATH}` succeeds.
|
||||
|
||||
6. **Commit in the target repo.** `{ARTIFACT_STORE}` is the brief's
|
||||
`artifact_store` (`local` when absent, e.g. in `--approve` mode on another
|
||||
producer's brief); `{slug}` is the brief's `slug`:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/util/artifact-commit.mjs --store {ARTIFACT_STORE} --artifact "{BRIEF_PATH}" --message "docs(voyage): approve intent {slug}"
|
||||
```
|
||||
|
||||
`git`: the brief and the log in one commit. `local`: only the log; the
|
||||
project folder is kept out of git through `.git/info/exclude`. The commit
|
||||
names its paths, so anything else the operator has staged stays staged and
|
||||
uncommitted. On exit 1 (e.g. the repo ignores `.claude/`) or 2 (not a git
|
||||
repository), print the `reason` in one line and continue: the approval is
|
||||
logged on disk, not committed, and `/trekplan` reports it as uncommitted.
|
||||
|
||||
What the stamp and the log do NOT prove: any session on this machine could
|
||||
run the same commands. They are a trace that this step ran against this
|
||||
text, not the operator's signature; the commit is what makes an approval
|
||||
visible in `git log` (see the headers of `lib/validators/intent-approval.mjs`
|
||||
and `lib/util/approval-log.mjs`).
|
||||
|
||||
Report:
|
||||
```
|
||||
Intent: {approved (sha256:…12 hex) | left unapproved — /trekplan will halt}
|
||||
Intent: {approved (sha256:…12 hex) | left unapproved — /trekplan will halt}
|
||||
Logged: {record {seq} in .claude/voyage/approvals.jsonl | no — {reason}}
|
||||
Committed: {{short sha}: brief + log | {short sha}: log only, brief kept on this machine | no — {reason}}
|
||||
```
|
||||
|
||||
In `--approve` mode, run **Phase 7** (`"mode": "approve"`) when the stamp
|
||||
|
|
@ -1035,10 +1198,13 @@ can audit which profile drove which session.
|
|||
7. **Auto mode blocks foreground.** If the user opts into auto, this
|
||||
session waits for research + planning to complete. Document this in
|
||||
the opt-in question.
|
||||
8. **Quality gates, not question counts.** Phase 3 and Phase 4 are
|
||||
quality-gated loops; do not enforce a hard cap on interview questions.
|
||||
The brief-review gate (Phase 4) caps at 3 review iterations to bound
|
||||
cost, but Phase 3 has no cap — required-section content drives exit.
|
||||
8. **Ceremony by size.** The size class sets the question budget and the
|
||||
review cap (Phase 3, Phase 4). It is said aloud, the operator can override
|
||||
it, and on your own you only ever move it up. The budget never hides a gap:
|
||||
what it leaves out is on the Deferred list or written down as an assumption.
|
||||
11. **Only an explicit approval approves.** «høres bra ut», «gjør som du vil»
|
||||
and an empty answer decide nothing (Phase 4h step 3). Every approval is
|
||||
logged in the target repo's approval log and committed there.
|
||||
9. **Never write `brief.md` while the review gate is still pending.**
|
||||
Draft lives in `brief.md.draft` until the loop terminates. A caller
|
||||
that sees `brief.md` must be able to trust that Phase 4 finished.
|
||||
|
|
|
|||
|
|
@ -205,12 +205,39 @@ the same trust class as self-stamping. 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`.
|
||||
|
||||
**Approval log gate (K2).** The marker passed; now check that the approval is
|
||||
in the target repo's approval log, the chain is intact, and the logged
|
||||
approval covers this intent. `{intent_approved_hash}` is the brief's
|
||||
frontmatter value (the check above just confirmed it is current). Same scope
|
||||
as the check above: both brief modes, `--quick` included, no override flag;
|
||||
`trekreview` briefs skip it (they carry no intent hash).
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/util/approval-log.mjs verify --json --kind intent --subject "{brief_path}" --sha256 "{intent_approved_hash}"
|
||||
```
|
||||
|
||||
Exit 0 (`approved`) → continue; if `uncommitted` is above 0, say in one line
|
||||
that N approvals are in the log but not committed yet. Exit 1 → **halt** — no
|
||||
agent, no plan — print `status` and `detail`:
|
||||
|
||||
| `status` | Meaning | What the operator does |
|
||||
|----------|---------|------------------------|
|
||||
| `broken` | A record in `.claude/voyage/approvals.jsonl` was edited, removed or reordered | Restore the log from git (`git checkout -- .claude/voyage/approvals.jsonl`), then re-run |
|
||||
| `diverged` | The committed log is no longer the start of the file: committed approvals were rewritten | Same: restore from git; the rewrite shows in `git diff` |
|
||||
| `not-logged` | No intent approval for this brief in the log (approved before the log existed, or the log step failed) | `/trekbrief --approve {brief_path}` |
|
||||
| `mismatch` | The latest logged approval covers other intent text | `/trekbrief --approve {brief_path}` |
|
||||
|
||||
Exit 2, or no JSON at all → **halt** and show the raw error: a log check that
|
||||
could not run is never a pass.
|
||||
|
||||
Read the brief file and parse its frontmatter. Extract:
|
||||
- `task` — one-line task description
|
||||
- `slug` — slug for plan filenames
|
||||
- `project_dir` — if present, overrides derived project path (optional)
|
||||
- `research_topics` — N (used as a sanity check)
|
||||
- `research_status` — `pending | in_progress | complete | skipped`
|
||||
- `ceremony` — size class (K1) `small | bounded | architectural`; absent = `architectural` (heaviest in doubt)
|
||||
- `artifact_store` — `git | local`, where Phase 11 keeps the plan; absent = `local`
|
||||
|
||||
If `research_status == pending` and `research_topics > 0`:
|
||||
- Warn the user: "Brief declares {N} research topics but research is still
|
||||
|
|
@ -225,6 +252,7 @@ Mode: {foreground | quick | decompose}
|
|||
Brief: {brief_path}
|
||||
Project: {project_dir or "-"}
|
||||
Research: {N local briefs, M extra via --research}
|
||||
Class: {small | bounded | architectural} {(absent → architectural)}
|
||||
```
|
||||
|
||||
### When the input is type:trekreview (Handover 6)
|
||||
|
|
@ -1013,6 +1041,40 @@ the tab and reopen the same file.
|
|||
|
||||
## Phase 11 — Handoff
|
||||
|
||||
### Plan approval (before every handoff below)
|
||||
|
||||
Whatever the operator answered in Phase 10 ("save", "execute", …), first ask
|
||||
whether this plan, as written now, is approved. The approval binds to the
|
||||
plan file's sha256, so any later edit makes it stale.
|
||||
|
||||
1. Ask via `AskUserQuestion` — one question: **"Approve this plan as written?"**
|
||||
Options: **Approve** (recommended only when the critic left no open
|
||||
BLOCKER) / **Not yet**.
|
||||
2. Check the answer as given, verbatim, on stdin:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/util/approval-log.mjs answer <<'ANSWER'
|
||||
{OPERATOR_ANSWER}
|
||||
ANSWER
|
||||
```
|
||||
|
||||
Exit 1 is not an approval («høres bra ut», «gjør som du vil», an empty
|
||||
answer): say so in one line and treat it as **Not yet**.
|
||||
3. On exit 0, log the approval and commit it in the target repo
|
||||
(`{artifact_store}` from Phase 1; `local` keeps `plan.md` out of git and
|
||||
commits only the log):
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/util/approval-log.mjs append --kind plan --subject "{plan_path}" --sha256-of "{plan_path}"
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/util/artifact-commit.mjs --store {artifact_store} --artifact "{plan_path}" --message "docs(voyage): approve plan {slug}"
|
||||
```
|
||||
|
||||
Print one line each: `Plan approval: record {seq}` and `Committed: {short
|
||||
sha} | no — {reason}`. A failed append or commit is reported, never hidden.
|
||||
|
||||
**Not yet** → "save" keeps the plan unapproved; "execute" does not start —
|
||||
return to Phase 10 to refine.
|
||||
|
||||
### "save" / "later" / "done"
|
||||
|
||||
Confirm the plan and brief file locations and exit.
|
||||
|
|
|
|||
|
|
@ -76,6 +76,8 @@ Every validator exposes a CLI: `node lib/validators/<name>.mjs --json <path>` re
|
|||
| `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 \| brief-file>`). |
|
||||
| `intent_approved_at` | string | optional | ISO-8601 | When the stamp was written. Informational; the gate reads the hash. |
|
||||
| `ceremony` | string | optional (K1) | `small \| bounded \| architectural` | The brief's size class: sets `/trekbrief`'s question budget and review cap, and what downstream phases scale to. **Absent (or unreadable) = `architectural`**, the heaviest, so another producer's brief is never treated lighter by accident. Enum-checked when present (`BRIEF_INVALID_CEREMONY`). Read it through `briefCeremony(fm)` in `brief-validator.mjs`. |
|
||||
| `artifact_store` | string | optional | `git \| local` | Whether the brief and plan are committed to the target repo (`git`) or kept out of git through `.git/info/exclude` (`local`). The approval log is committed either way. **Absent = `local`**: nothing is published without the operator's yes. Enum-checked when present (`BRIEF_INVALID_ARTIFACT_STORE`). |
|
||||
|
||||
**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.
|
||||
|
|
@ -108,8 +110,12 @@ Optional but standard sections: `## Non-Goals`, `## Constraints`, `## Preference
|
|||
|
||||
**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, empty or present more than once; a `## ` line inside a code block or HTML comment is not a heading). 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, whether it is planned with `--project` (`<dir>/brief.md`) or `--brief <file>`, so no producer is privileged. `trekreview` briefs are exempt. The exemption is read from the brief's own frontmatter, so relabelling a brief `type: trekreview` walks past the gate, the same trust class as self-stamping. **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.
|
||||
|
||||
**Approval log (K2) — consumer-side requirement, no schema change.** After the stamp, `/trekbrief` appends the approval to the target repo's log with `lib/util/approval-log.mjs append --kind intent --subject <brief> --sha256 <intent hash>` and commits it with `lib/util/artifact-commit.mjs` (the log always; the brief too when `artifact_store: git`). The log (`.claude/voyage/approvals.jsonl`, read and written only by that CLI) is append-only and hash-chained: each record carries `seq`, `ts`, `kind` (`intent | plan | review | merge | release`), `subject`, the content's `sha256`, an optional `expires`, `prev` and its own `hash`. `/trekplan` Phase 1 runs `approval-log.mjs verify --kind intent --subject <brief> --sha256 <intent_approved_hash>` after the marker check and halts on `broken` (a record edited, removed or reordered), `diverged` (the committed log is no longer a prefix of the file), `not-logged` or `mismatch`; exit 2 (could not run) halts too. A brief approved before the log existed is `not-logged`: the remedy is the same `/trekbrief --approve`, open to every producer's brief. Only an explicit "Approve" counts: `approval-log.mjs answer` refuses «høres bra ut», «gjør som du vil» and an empty answer. Trust limits are those of the marker (same user, same machine; a trace, not a signature); what the log adds is that each approval is a commit in `git log`, and that a committed approval cannot be rewritten without `diverged`. Adapted from bashebr/ai-native-sdlc@11fb8d7 (MIT), corrected so the approval is bound to content and cannot be written unseen.
|
||||
|
||||
**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-file>`
|
||||
- approval log `broken` / `diverged` → `/trekplan` halts; restore `.claude/voyage/approvals.jsonl` from git. `not-logged` / `mismatch` → halts; remedy `/trekbrief --approve <project-dir | brief-file>`
|
||||
- `BRIEF_INVALID_CEREMONY` / `BRIEF_INVALID_ARTIFACT_STORE` → strict halt; the optional field is present with a value outside its enum
|
||||
- `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
|
||||
|
|
|
|||
|
|
@ -6,13 +6,13 @@ Per-command flag tables, imported from `CLAUDE.md` via pointer.
|
|||
|
||||
| Flag | Behavior |
|
||||
|------|----------|
|
||||
| _(default)_ | Dynamic interview until quality gates pass → 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 |
|
||||
| _(default)_ | Question round sized by class (`small` / `bounded` / `architectural`), brief-review gate, approval → brief.md with research plan |
|
||||
| `--quick` | Proposes `small` as the class (still said aloud, overridable, raised when the task proves bigger) → 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 \| 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`, emit `brief-approved`, and write the trekbrief stats record (`"mode": "approve"`). 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 puts the task in a size class (`ceremony:`, K1; heaviest in doubt, said aloud, overridable, only moved up) and asks one round of questions within the class budget (`small` 4, `bounded` 8, `architectural` 12 per round), recommendation first, facts looked up instead of asked, a visible Deferred list; it also asks where the brief is stored (`artifact_store: git | local`). Phase 4 runs a `brief-reviewer` stop-gate with 1 / 2 / 3 review iterations by class. Phase 4h takes only an explicit "Approve" («høres bra ut» and an empty answer are refused by `lib/util/approval-log.mjs answer`), stamps the brief, appends the approval to the target repo's log (`approval-log.mjs append`, K2) and commits it (`lib/util/artifact-commit.mjs`: the log always, the brief when `git`). After that, asks the user to choose manual (print commands) or auto (Claude runs research + plan in foreground).
|
||||
|
||||
## /trekresearch modes
|
||||
|
||||
|
|
@ -64,6 +64,8 @@ protocol, thresholds, and the exact commands in
|
|||
|
||||
**Breaking change (v2.0):** one of `--brief` or `--project` is required. There is no interview inside `/trekplan`. The `--spec` flag has been removed — use `/trekbrief` to produce a brief instead.
|
||||
|
||||
**Approval gates (no flag turns them off).** Phase 1 halts unless the brief's intent marker is current (`intent-approval.mjs --check`) AND the target repo's approval log is intact and holds that intent (`approval-log.mjs verify --kind intent`; `broken`, `diverged`, `not-logged` or `mismatch` halt; remedy `/trekbrief --approve`). Phase 1 also reads `ceremony` (absent = `architectural`) and `artifact_store` (absent = `local`). Phase 11 asks for an explicit plan approval before any handoff, logs it bound to `plan.md`'s sha256 and commits it (the plan itself only when `artifact_store: git`); "execute" does not start on "Not yet".
|
||||
|
||||
If `{project_dir}/architecture/overview.md` exists (typically produced by an opt-in upstream architect plugin, not bundled), the plan command auto-discovers it and treats `cc_features_proposed` as priors. Missing file is fine — discovery is additive, not required.
|
||||
|
||||
## /trekexecute modes
|
||||
|
|
|
|||
332
lib/util/approval-log.mjs
Normal file
332
lib/util/approval-log.mjs
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
// lib/util/approval-log.mjs
|
||||
// The approval log (K2): an append-only, hash-chained record of what the
|
||||
// operator approved, bound to the sha256 of the approved content. One log per
|
||||
// target repo, at .claude/voyage/approvals.jsonl; it is committed to the target
|
||||
// repo (operator decision 2026-10-06: the log always goes to git, the brief and
|
||||
// the plan only when the operator chooses so). Kinds: intent, plan, review,
|
||||
// merge, release. Nothing but this CLI reads or writes the file.
|
||||
//
|
||||
// Adapted from bashebr/ai-native-sdlc@11fb8d7 (MIT, © 2026 AI-Native SDLC
|
||||
// contributors): its gate ledger (prev_hash + hash per record, verify walks the
|
||||
// chain). Two of its flaws are corrected here: an approval there is not bound to
|
||||
// the approved content (here every record carries the content's sha256 and
|
||||
// `verify --sha256` checks the current content against it), and the agent can
|
||||
// write its own approval unseen (here the log is committed, `verify` reports
|
||||
// records that are not committed yet, and a rewrite of committed records is
|
||||
// `diverged`).
|
||||
//
|
||||
// WHAT THE LOG DOES NOT PROVE. Same user, same machine: any session that can run
|
||||
// this CLI can append an approval, exactly as it can run
|
||||
// `intent-approval.mjs --stamp`. A record is a TRACE that the approval step ran
|
||||
// against this content, NOT a signature binding a person. What it does catch:
|
||||
// - an edited, deleted or reordered record that was not re-hashed (`broken`);
|
||||
// - content changed after approval (`mismatch` for that subject);
|
||||
// - an approval that never happened (`not-logged`);
|
||||
// - a rewrite that re-hashes the whole chain, once the old chain was committed
|
||||
// (`diverged`: the committed log is no longer a prefix of the file). Before the
|
||||
// first commit, a full re-hash is invisible to the chain; git history is the
|
||||
// only witness, which is why every approval is committed right away.
|
||||
// An approval a session writes and commits on the operator's behalf is visible as
|
||||
// a commit in `git log`, not prevented. That is the trust class, stated openly.
|
||||
//
|
||||
// CLI (exit 0 = ok, 1 = broken chain / refused, 2 = could not run; 2 is never green):
|
||||
// node lib/util/approval-log.mjs append --kind <kind> --subject <path>
|
||||
// (--sha256 <hex | sha256:hex> | --sha256-of <file>) [--expires <ISO>] [--repo <dir>]
|
||||
// node lib/util/approval-log.mjs verify [--json] [--repo <dir>]
|
||||
// [--kind <kind> --subject <path> (--sha256 <hex> | --sha256-of <file>)]
|
||||
// node lib/util/approval-log.mjs answer [--text <answer>] (stdin when --text is absent)
|
||||
// node lib/util/approval-log.mjs digest <file>
|
||||
// The repo is --repo, else the git top level of the working directory, else the
|
||||
// working directory. A subject inside the repo is stored relative to it.
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmdirSync, statSync,
|
||||
} from 'node:fs';
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const APPROVAL_KINDS = Object.freeze(['intent', 'plan', 'review', 'merge', 'release']);
|
||||
export const LOG_RELPATH = '.claude/voyage/approvals.jsonl';
|
||||
export const GENESIS = '0'.repeat(64);
|
||||
const FIELDS = Object.freeze(['v', 'seq', 'ts', 'kind', 'subject', 'sha256', 'expires', 'prev']);
|
||||
const HEX_RE = /^[0-9a-f]{64}$/;
|
||||
const EXIT = Object.freeze({
|
||||
intact: 0, empty: 0, approved: 0,
|
||||
broken: 1, diverged: 1, 'not-logged': 1, mismatch: 1, expired: 1,
|
||||
error: 2,
|
||||
});
|
||||
|
||||
const sha256 = (s) => createHash('sha256').update(s).digest('hex');
|
||||
|
||||
function realOr(p) {
|
||||
try { return realpathSync(p); } catch { /* absent */ }
|
||||
try { return join(realpathSync(dirname(p)), basename(p)); } catch { return resolve(p); }
|
||||
}
|
||||
|
||||
/** The repo the log belongs to: explicit, else git top level of cwd, else cwd. */
|
||||
export function resolveRepo(explicit, cwd = process.cwd()) {
|
||||
if (explicit) return realOr(resolve(cwd, explicit));
|
||||
const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' });
|
||||
if (r.status === 0 && r.stdout.trim()) return realOr(r.stdout.trim());
|
||||
return realOr(cwd);
|
||||
}
|
||||
|
||||
export function logPath(repo) { return join(repo, ...LOG_RELPATH.split('/')); }
|
||||
|
||||
/** A subject inside the repo is stored repo-relative with forward slashes; outside, absolute. */
|
||||
export function normalizeSubject(repo, subject, cwd = process.cwd()) {
|
||||
const real = realOr(resolve(cwd, subject));
|
||||
const rel = relative(repo, real);
|
||||
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) return rel.split(sep).join('/');
|
||||
return real;
|
||||
}
|
||||
|
||||
/** "sha256:<hex>" or "<hex>" → lowercase hex, or null. */
|
||||
export function normalizeHex(value) {
|
||||
const v = String(value ?? '').trim().replace(/^sha256:/, '').toLowerCase();
|
||||
return HEX_RE.test(v) ? v : null;
|
||||
}
|
||||
|
||||
export function digestFile(file) {
|
||||
return sha256(readFileSync(file));
|
||||
}
|
||||
|
||||
export function recordHash(rec) {
|
||||
const canon = {};
|
||||
for (const k of FIELDS) canon[k] = rec[k] ?? null;
|
||||
return sha256(JSON.stringify(canon));
|
||||
}
|
||||
|
||||
/** Read and walk the chain. @returns {{ exists, lines, records, broken: null | {line, why}, error? }} */
|
||||
export function readLog(repo) {
|
||||
const p = logPath(repo);
|
||||
if (!existsSync(p)) return { exists: false, lines: [], records: [], broken: null };
|
||||
let text;
|
||||
try {
|
||||
if (statSync(p).isDirectory()) return { exists: true, lines: [], records: [], broken: null, error: `${p} is a directory` };
|
||||
text = readFileSync(p, 'utf8');
|
||||
} catch (e) {
|
||||
return { exists: true, lines: [], records: [], broken: null, error: `cannot read ${p}: ${e.message}` };
|
||||
}
|
||||
const lines = text.split('\n');
|
||||
if (lines[lines.length - 1] === '') lines.pop();
|
||||
const records = [];
|
||||
let prev = GENESIS;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const at = i + 1;
|
||||
let rec;
|
||||
try { rec = JSON.parse(lines[i]); } catch { return { exists: true, lines, records, broken: { line: at, why: 'not JSON' } }; }
|
||||
const keys = rec && typeof rec === 'object' ? Object.keys(rec).sort().join(',') : '';
|
||||
if (keys !== [...FIELDS, 'hash'].sort().join(',')) return { exists: true, lines, records, broken: { line: at, why: 'fields differ from a record' } };
|
||||
if (rec.seq !== at) return { exists: true, lines, records, broken: { line: at, why: `seq ${rec.seq}, expected ${at}` } };
|
||||
if (rec.prev !== prev) return { exists: true, lines, records, broken: { line: at, why: 'prev does not link to the record before' } };
|
||||
if (recordHash(rec) !== rec.hash) return { exists: true, lines, records, broken: { line: at, why: 'hash does not match the record' } };
|
||||
records.push(rec);
|
||||
prev = rec.hash;
|
||||
}
|
||||
return { exists: true, lines, records, broken: null };
|
||||
}
|
||||
|
||||
/** The committed log's lines, or null when there is no git, no HEAD or the log is not tracked. */
|
||||
function committedLines(repo) {
|
||||
const r = spawnSync('git', ['-C', repo, 'show', `HEAD:${LOG_RELPATH}`], { encoding: 'utf8' });
|
||||
if (r.status !== 0) return null;
|
||||
const lines = r.stdout.split('\n');
|
||||
if (lines[lines.length - 1] === '') lines.pop();
|
||||
return lines;
|
||||
}
|
||||
|
||||
function inGit(repo) {
|
||||
return spawnSync('git', ['-C', repo, 'rev-parse', '--is-inside-work-tree'], { encoding: 'utf8' }).status === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the chain, and optionally that `subject`'s latest `kind` record approves `sha`.
|
||||
* @returns {{ status: string, exit: number, records: number, uncommitted: number|null, detail?: string, record?: object }}
|
||||
*/
|
||||
export function verifyLog({ repo, kind, subject, sha, now = new Date() }) {
|
||||
const log = readLog(repo);
|
||||
const base = { log: logPath(repo), records: log.records.length, uncommitted: null };
|
||||
if (log.error) return { ...base, status: 'error', exit: 2, detail: log.error };
|
||||
if (log.broken) return { ...base, status: 'broken', exit: 1, detail: `line ${log.broken.line}: ${log.broken.why}` };
|
||||
if (log.exists && inGit(repo)) {
|
||||
const head = committedLines(repo) || [];
|
||||
const prefix = head.length <= log.lines.length && head.every((l, i) => l === log.lines[i]);
|
||||
if (!prefix) {
|
||||
return { ...base, status: 'diverged', exit: 1, detail: 'the committed log is not a prefix of the file: committed approvals were rewritten or removed' };
|
||||
}
|
||||
base.uncommitted = log.lines.length - head.length;
|
||||
}
|
||||
if (subject === undefined) {
|
||||
const status = log.records.length ? 'intact' : 'empty';
|
||||
return { ...base, status, exit: EXIT[status] };
|
||||
}
|
||||
const latest = [...log.records].reverse().find((r) => r.kind === kind && r.subject === subject);
|
||||
if (!latest) return { ...base, status: 'not-logged', exit: 1, detail: `no ${kind} approval for ${subject} in the log` };
|
||||
if (latest.sha256 !== sha) {
|
||||
return { ...base, status: 'mismatch', exit: 1, record: latest, detail: `the latest ${kind} approval for ${subject} covers other content` };
|
||||
}
|
||||
if (latest.expires && Date.parse(latest.expires) <= now.getTime()) {
|
||||
return { ...base, status: 'expired', exit: 1, record: latest, detail: `the approval expired ${latest.expires}` };
|
||||
}
|
||||
return { ...base, status: 'approved', exit: 0, record: latest };
|
||||
}
|
||||
|
||||
function sleep(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
|
||||
|
||||
function withLock(p, fn) {
|
||||
const lock = `${p}.lock`;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
try { mkdirSync(lock); break; } catch (e) {
|
||||
if (e.code !== 'EEXIST') throw e;
|
||||
try { if (Date.now() - statSync(lock).mtimeMs > 10_000) rmdirSync(lock); } catch { /* raced */ }
|
||||
if (i === 199) throw new Error(`log is locked: ${lock}`);
|
||||
sleep(25);
|
||||
}
|
||||
}
|
||||
try { return fn(); } finally { try { rmdirSync(lock); } catch { /* gone */ } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one approval. Refuses (exit 1) a bad kind/subject/hash/expiry or a log
|
||||
* that does not verify; never extends a broken or diverged chain.
|
||||
*/
|
||||
export function appendApproval({ repo, kind, subject, sha, expires, now = new Date() }) {
|
||||
if (!APPROVAL_KINDS.includes(kind)) return { appended: false, exit: 1, reason: `kind "${kind}" not in [${APPROVAL_KINDS.join(', ')}]` };
|
||||
if (!subject) return { appended: false, exit: 1, reason: '--subject is required' };
|
||||
if (!sha) return { appended: false, exit: 1, reason: '--sha256 must be 64 hex characters (optionally prefixed "sha256:")' };
|
||||
let exp = null;
|
||||
if (expires !== undefined) {
|
||||
const t = Date.parse(expires);
|
||||
if (Number.isNaN(t)) return { appended: false, exit: 1, reason: `--expires "${expires}" is not an ISO-8601 time` };
|
||||
exp = new Date(t).toISOString();
|
||||
}
|
||||
const p = logPath(repo);
|
||||
try {
|
||||
mkdirSync(dirname(p), { recursive: true });
|
||||
return withLock(p, () => {
|
||||
const v = verifyLog({ repo, now });
|
||||
if (v.exit === 2) return { appended: false, exit: 2, reason: v.detail };
|
||||
if (v.exit === 1) return { appended: false, exit: 1, reason: `the log does not verify (${v.status}: ${v.detail}); nothing appended` };
|
||||
const log = readLog(repo);
|
||||
const last = log.records[log.records.length - 1];
|
||||
const rec = {
|
||||
v: 1, seq: log.records.length + 1, ts: now.toISOString(), kind, subject, sha256: sha,
|
||||
expires: exp, prev: last ? last.hash : GENESIS,
|
||||
};
|
||||
rec.hash = recordHash(rec);
|
||||
let raw = '';
|
||||
try { raw = readFileSync(p, 'utf8'); } catch { /* new log */ }
|
||||
appendFileSync(p, (raw && !raw.endsWith('\n') ? '\n' : '') + JSON.stringify(rec) + '\n');
|
||||
return { appended: true, exit: 0, record: rec, log: p };
|
||||
});
|
||||
} catch (e) {
|
||||
return { appended: false, exit: 2, reason: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Answers that are not approval. Adapted from addyosmani/agent-skills@9d0c60d
|
||||
// (MIT, © 2025 Addy Osmani), skills/interview-me: «Sounds good» and «Whatever
|
||||
// you think» are not a yes. Only an explicit approval counts; everything else,
|
||||
// the empty answer included, is not one.
|
||||
const EXPLICIT = new Set([
|
||||
'approve', 'approved', 'i approve', 'yes', 'yes, approve', 'yes approve',
|
||||
'godkjenn', 'godkjent', 'godkjenner', 'jeg godkjenner', 'ja', 'ja, godkjent', 'ja godkjent',
|
||||
]);
|
||||
const HOLLOW = [
|
||||
'høres bra ut', 'hores bra ut', 'gjør som du vil', 'gjor som du vil', 'ser bra ut', 'greit nok',
|
||||
'sounds good', 'whatever you think', 'looks good', 'lgtm', 'up to you', 'your call', 'fine by me',
|
||||
'ok', 'okay', 'sure', 'fine', 'kjør på', 'kjor pa',
|
||||
];
|
||||
|
||||
/** @returns {{ approved: boolean, reason: 'explicit'|'empty'|'hollow'|'not-explicit' }} */
|
||||
export function isExplicitApproval(answer) {
|
||||
const t = String(answer ?? '').trim().toLowerCase().replace(/[\s.!]+$/u, '').replace(/\s+/g, ' ');
|
||||
if (!t) return { approved: false, reason: 'empty' };
|
||||
if (EXPLICIT.has(t)) return { approved: true, reason: 'explicit' };
|
||||
if (HOLLOW.some((h) => t === h || (h.includes(' ') && t.includes(h)))) return { approved: false, reason: 'hollow' };
|
||||
return { approved: false, reason: 'not-explicit' };
|
||||
}
|
||||
|
||||
// ---- CLI ----
|
||||
|
||||
function opt(args, name) {
|
||||
const i = args.indexOf(name);
|
||||
return i >= 0 ? args[i + 1] : undefined;
|
||||
}
|
||||
|
||||
function out(obj, code) {
|
||||
process.stdout.write(JSON.stringify(obj) + '\n');
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
function shaFromArgs(args) {
|
||||
const of = opt(args, '--sha256-of');
|
||||
if (of !== undefined) {
|
||||
try { return { sha: digestFile(of) }; } catch (e) { return { error: `cannot read ${of}: ${e.message}` }; }
|
||||
}
|
||||
const raw = opt(args, '--sha256');
|
||||
return { sha: raw === undefined ? undefined : normalizeHex(raw), raw };
|
||||
}
|
||||
|
||||
function isMain() {
|
||||
try {
|
||||
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMain()) {
|
||||
const [cmd, ...args] = process.argv.slice(2);
|
||||
if (cmd === 'answer') {
|
||||
let text = opt(args, '--text');
|
||||
if (text === undefined) { try { text = readFileSync(0, 'utf8'); } catch { text = ''; } }
|
||||
const r = isExplicitApproval(text);
|
||||
out(r, r.approved ? 0 : 1);
|
||||
}
|
||||
if (cmd === 'digest') {
|
||||
const file = args.find((a) => !a.startsWith('--'));
|
||||
try { process.stdout.write(digestFile(file) + '\n'); process.exit(0); } catch (e) {
|
||||
process.stderr.write(`approval-log digest: cannot read ${file}: ${e.message}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
if (cmd !== 'append' && cmd !== 'verify') {
|
||||
process.stderr.write('Usage: approval-log.mjs append|verify|answer|digest — see the header of lib/util/approval-log.mjs\n');
|
||||
process.exit(2);
|
||||
}
|
||||
const repo = resolveRepo(opt(args, '--repo'));
|
||||
const kind = opt(args, '--kind');
|
||||
const subjectArg = opt(args, '--subject');
|
||||
const { sha, raw, error } = shaFromArgs(args);
|
||||
|
||||
if (cmd === 'append') {
|
||||
if (error) out({ appended: false, reason: error }, 2);
|
||||
const r = appendApproval({
|
||||
repo, kind, subject: subjectArg ? normalizeSubject(repo, subjectArg) : '',
|
||||
sha: raw !== undefined || sha ? sha : undefined, expires: opt(args, '--expires'),
|
||||
});
|
||||
const { exit, ...rest } = r;
|
||||
out(rest, exit);
|
||||
}
|
||||
|
||||
// verify
|
||||
const filtered = [kind, subjectArg, sha ?? raw ?? error].filter((x) => x !== undefined).length;
|
||||
let r;
|
||||
if (error) r = { status: 'error', exit: 2, detail: error };
|
||||
else if (filtered !== 0 && filtered !== 3) r = { status: 'error', exit: 2, detail: 'a subject check needs --kind, --subject and --sha256 (or --sha256-of) together' };
|
||||
else if (filtered === 3 && !sha) r = { status: 'error', exit: 2, detail: `--sha256 "${raw}" is not 64 hex characters` };
|
||||
else if (filtered === 3 && !APPROVAL_KINDS.includes(kind)) r = { status: 'error', exit: 2, detail: `kind "${kind}" not in [${APPROVAL_KINDS.join(', ')}]` };
|
||||
else r = verifyLog({ repo, kind, subject: filtered === 3 ? normalizeSubject(repo, subjectArg) : undefined, sha });
|
||||
const { exit, ...rest } = r;
|
||||
if (args.includes('--json')) {
|
||||
process.stdout.write(JSON.stringify(rest) + '\n');
|
||||
} else {
|
||||
process.stdout.write(`approval-log: ${rest.status}${rest.detail ? ` — ${rest.detail}` : ''}\n`);
|
||||
if (rest.uncommitted) process.stdout.write(` ${rest.uncommitted} approval(s) not committed yet\n`);
|
||||
}
|
||||
process.exit(exit);
|
||||
}
|
||||
114
lib/util/artifact-commit.mjs
Normal file
114
lib/util/artifact-commit.mjs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
// lib/util/artifact-commit.mjs
|
||||
// Commit an approved artifact in the TARGET repo. The approval log
|
||||
// (lib/util/approval-log.mjs) is always committed; the artifact itself (brief.md,
|
||||
// plan.md) only when the operator chose `artifact_store: git`. With `local` the
|
||||
// artifact's folder is kept out of git through the repo's own .git/info/exclude,
|
||||
// a file that is never committed, so no shared file changes (operator decision
|
||||
// 2026-10-06: «Loggen alltid, briefen etter valg»).
|
||||
//
|
||||
// The commit names its paths (`git commit --only -- <paths>`): whatever else the
|
||||
// operator has staged stays staged and uncommitted. A path the repo ignores is
|
||||
// never forced in; the refusal says so. The repo's own git hooks run as usual.
|
||||
//
|
||||
// CLI:
|
||||
// node lib/util/artifact-commit.mjs --store <git|local> [--artifact <path>]...
|
||||
// [--message <msg>] [--repo <dir>]
|
||||
// prints {committed, commit?, paths, excluded, warnings, reason?}
|
||||
// exit 0 = committed, or nothing new to commit; 1 = refused (ignored path, path
|
||||
// outside the repo, git add/commit failed); 2 = could not run (not a git repo,
|
||||
// bad arguments, git timed out). 2 is never green.
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { appendFileSync, existsSync, readFileSync, realpathSync } from 'node:fs';
|
||||
import { isAbsolute, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { LOG_RELPATH, logPath, normalizeSubject } from './approval-log.mjs';
|
||||
|
||||
export const ARTIFACT_STORES = Object.freeze(['git', 'local']);
|
||||
const DEFAULT_MESSAGE = 'docs(voyage): record approval';
|
||||
|
||||
function git(repo, args, timeout = 60_000) {
|
||||
return spawnSync('git', ['-C', repo, ...args], { encoding: 'utf8', timeout });
|
||||
}
|
||||
|
||||
/** Add `/<dir>/` to the repo's .git/info/exclude once. @returns the pattern */
|
||||
function excludeLocally(repo, rel) {
|
||||
const slash = rel.lastIndexOf('/');
|
||||
const pattern = slash > 0 ? `/${rel.slice(0, slash)}/` : `/${rel}`;
|
||||
const where = git(repo, ['rev-parse', '--git-path', 'info/exclude']).stdout.trim();
|
||||
const file = isAbsolute(where) ? where : resolve(repo, where);
|
||||
const text = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
||||
if (!text.split('\n').includes(pattern)) {
|
||||
appendFileSync(file, (text && !text.endsWith('\n') ? '\n' : '') + pattern + '\n');
|
||||
}
|
||||
return pattern;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {{ exit: number, committed: boolean, commit?: string, paths: string[],
|
||||
* excluded: string[], warnings: string[], reason?: string }}
|
||||
*/
|
||||
export function commitArtifacts({ repo, store, artifacts = [], message = DEFAULT_MESSAGE }) {
|
||||
const res = { committed: false, paths: [], excluded: [], warnings: [] };
|
||||
if (!ARTIFACT_STORES.includes(store)) {
|
||||
return { ...res, exit: 2, reason: `--store must be one of [${ARTIFACT_STORES.join(', ')}]` };
|
||||
}
|
||||
if (existsSync(logPath(repo))) res.paths.push(LOG_RELPATH);
|
||||
for (const a of artifacts) {
|
||||
const rel = normalizeSubject(repo, a);
|
||||
if (isAbsolute(rel)) return { ...res, exit: 1, reason: `${a} is outside the repo ${repo}` };
|
||||
if (store === 'git') { res.paths.push(rel); continue; }
|
||||
if (git(repo, ['ls-files', '--error-unmatch', '--', rel]).status === 0) {
|
||||
res.warnings.push(`${rel} is already tracked; it stays in git (local applies to new files only)`);
|
||||
continue;
|
||||
}
|
||||
res.excluded.push(excludeLocally(repo, rel));
|
||||
}
|
||||
if (!res.paths.length) return { ...res, exit: 0, reason: 'nothing to commit' };
|
||||
|
||||
const add = git(repo, ['add', '--', ...res.paths]);
|
||||
if (add.status !== 0) {
|
||||
const ignored = /ignored/i.test(add.stderr);
|
||||
return {
|
||||
...res, exit: 1,
|
||||
reason: ignored
|
||||
? `${res.paths.join(', ')}: ignored by the repo (.gitignore); not forced in — nothing committed`
|
||||
: `git add failed: ${add.stderr.trim()}`,
|
||||
};
|
||||
}
|
||||
if (git(repo, ['diff', '--cached', '--quiet', '--', ...res.paths]).status === 0) {
|
||||
return { ...res, exit: 0, reason: 'nothing to commit' };
|
||||
}
|
||||
const c = git(repo, ['commit', '-q', '--only', '-m', message, '--', ...res.paths]);
|
||||
if (c.error) return { ...res, exit: 2, reason: `git commit did not finish: ${c.error.message}` };
|
||||
if (c.status !== 0) return { ...res, exit: 1, reason: `git commit failed: ${(c.stderr || c.stdout).trim()}` };
|
||||
return { ...res, exit: 0, committed: true, commit: git(repo, ['rev-parse', 'HEAD']).stdout.trim() };
|
||||
}
|
||||
|
||||
function isMain() {
|
||||
try {
|
||||
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMain()) {
|
||||
const args = process.argv.slice(2);
|
||||
const opt = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; };
|
||||
const artifacts = args.flatMap((a, i) => (a === '--artifact' && args[i + 1] !== undefined ? [args[i + 1]] : []));
|
||||
const top = spawnSync('git', ['rev-parse', '--show-toplevel'], {
|
||||
cwd: opt('--repo') ? resolve(opt('--repo')) : process.cwd(), encoding: 'utf8',
|
||||
});
|
||||
let r;
|
||||
if (top.status !== 0 || !top.stdout.trim()) {
|
||||
r = { exit: 2, committed: false, paths: [], excluded: [], warnings: [], reason: 'not a git repository — nothing committed' };
|
||||
} else {
|
||||
r = commitArtifacts({
|
||||
repo: realpathSync(top.stdout.trim()), store: opt('--store'), artifacts, message: opt('--message') || DEFAULT_MESSAGE,
|
||||
});
|
||||
}
|
||||
const { exit, ...rest } = r;
|
||||
process.stdout.write(JSON.stringify(rest) + '\n');
|
||||
process.exit(exit);
|
||||
}
|
||||
|
|
@ -6,7 +6,8 @@
|
|||
// optional fields (jf. the source_findings precedent on trekreview) can be
|
||||
// added without a brief_version bump.
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { readFileSync, existsSync, realpathSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseDocument } from '../util/frontmatter.mjs';
|
||||
import { issue, ok, fail } from '../util/result.mjs';
|
||||
import { BASE_ALLOWED_MODELS } from './profile-validator.mjs';
|
||||
|
|
@ -24,6 +25,25 @@ export const BRIEF_FRAMING_VALUES = Object.freeze(['preserve', 'refine', 'replac
|
|||
// v5.5 — obligatory TL;DR section (≤ 5 content lines) at the top of brief.md,
|
||||
// gated at brief_version ≥ 2.2. Soft cap is a warning, not a blocker.
|
||||
export const BRIEF_TLDR_MAX_LINES = 5;
|
||||
// K1 — the brief's size class (ceremony by size). Optional, additive, no
|
||||
// brief_version bump; absent (or invalid) reads as the heaviest class.
|
||||
export const BRIEF_CEREMONY_VALUES = Object.freeze(['small', 'bounded', 'architectural']);
|
||||
export const BRIEF_CEREMONY_DEFAULT = 'architectural';
|
||||
// Where the brief and the plan live: committed (`git`) or kept out of git
|
||||
// (`local`). The approval log is committed either way. Optional, additive;
|
||||
// absent reads as `local`, so nothing is published without the operator's yes.
|
||||
export const BRIEF_ARTIFACT_STORE_VALUES = Object.freeze(['git', 'local']);
|
||||
export const BRIEF_ARTIFACT_STORE_DEFAULT = 'local';
|
||||
|
||||
/** The brief's size class; absent or invalid → architectural (never lighter in doubt). */
|
||||
export function briefCeremony(fm) {
|
||||
return BRIEF_CEREMONY_VALUES.includes(fm?.ceremony) ? fm.ceremony : BRIEF_CEREMONY_DEFAULT;
|
||||
}
|
||||
|
||||
/** Where the brief and plan are stored; absent or invalid → local. */
|
||||
export function briefArtifactStore(fm) {
|
||||
return BRIEF_ARTIFACT_STORE_VALUES.includes(fm?.artifact_store) ? fm.artifact_store : BRIEF_ARTIFACT_STORE_DEFAULT;
|
||||
}
|
||||
|
||||
// Extract the raw text of a `## {heading}` section body (between its heading line
|
||||
// and the next `## ` heading, or end of document). Returns null if absent.
|
||||
|
|
@ -115,6 +135,21 @@ export function validateBriefContent(text, opts = {}) {
|
|||
));
|
||||
}
|
||||
|
||||
if ('ceremony' in fm && !BRIEF_CEREMONY_VALUES.includes(fm.ceremony)) {
|
||||
errors.push(issue(
|
||||
'BRIEF_INVALID_CEREMONY',
|
||||
`ceremony "${fm.ceremony}" not in [${BRIEF_CEREMONY_VALUES.join(', ')}]`,
|
||||
'ceremony is the brief\'s size class; leave it out to mean architectural (the heaviest).',
|
||||
));
|
||||
}
|
||||
if ('artifact_store' in fm && !BRIEF_ARTIFACT_STORE_VALUES.includes(fm.artifact_store)) {
|
||||
errors.push(issue(
|
||||
'BRIEF_INVALID_ARTIFACT_STORE',
|
||||
`artifact_store "${fm.artifact_store}" not in [${BRIEF_ARTIFACT_STORE_VALUES.join(', ')}]`,
|
||||
'git = the brief and plan are committed; local = kept out of git. Leave it out to mean local.',
|
||||
));
|
||||
}
|
||||
|
||||
if (typeof fm.brief_version === 'string' || typeof fm.brief_version === 'number') {
|
||||
const vm = String(fm.brief_version).match(/^(\d+)\.(\d+)$/);
|
||||
if (vm) {
|
||||
|
|
@ -246,7 +281,15 @@ export function validateBrief(filePath, opts = {}) {
|
|||
return { ...r, parsed: { ...r.parsed, filePath } };
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
function isMain() {
|
||||
try {
|
||||
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMain()) {
|
||||
const args = process.argv.slice(2);
|
||||
const strict = !args.includes('--soft');
|
||||
const minIdx = args.indexOf('--min-version');
|
||||
|
|
|
|||
|
|
@ -39,8 +39,9 @@
|
|||
// what `/trekbrief --approve` approves: {brief_path, project_dir, plan_command}; exit 1 = no brief
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { existsSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseDocument } from '../util/frontmatter.mjs';
|
||||
import { issue } from '../util/result.mjs';
|
||||
import { emit } from '../stats/event-emit.mjs';
|
||||
|
|
@ -233,7 +234,18 @@ export function resolveApproveTarget(arg) {
|
|||
return { brief_path: briefPath, project_dir: projectDir, plan_command: planCommand };
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
// Main-module guard on real paths: comparing against `file://` + argv[1] fails
|
||||
// when the plugin sits behind a symlink or in a path with a space; the CLI then
|
||||
// exits 0 having checked nothing.
|
||||
function isMain() {
|
||||
try {
|
||||
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMain()) {
|
||||
const args = process.argv.slice(2);
|
||||
const file = args.find((a) => !a.startsWith('--'));
|
||||
if (args.includes('--resolve')) {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,14 @@ source: {interview | manual}
|
|||
# written — the first layer of the framing-alignment defense (guards against the
|
||||
# plan polishing a wrong premise after a rejected iteration).
|
||||
framing: {preserve | refine | replace | new-direction}
|
||||
# Size class (ceremony by size), said aloud in the /trekbrief question round and
|
||||
# overridable by the operator; it only ever moves up mid-task. Optional: absent
|
||||
# reads as architectural (the heaviest). One of: small | bounded | architectural.
|
||||
ceremony: {small | bounded | architectural}
|
||||
# Where the brief and plan live: git (committed) or local (kept out of git via
|
||||
# .git/info/exclude). The approval log is committed either way. Optional: absent
|
||||
# reads as local. Asked once per task in the /trekbrief question round.
|
||||
artifact_store: {git | local}
|
||||
# v5.1 — per-phase effort + model signal (Phase 3.5).
|
||||
# `effort` ∈ {low, standard, high}; `model` ∈ {sonnet, opus, fable} (v5.9).
|
||||
# Omit `model:` for `standard` so composition falls through to profile
|
||||
|
|
|
|||
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, intent_approved, project_dir, order_id, order_claimed_at, order_reason | commands/trekbrief.md (orchestrator-emit Phase 7; order_* added by lib/stats/stats-append.mjs) | trekbrief.md:961-976 | 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, order_id, order_claimed_at, order_reason | commands/trekbrief.md (orchestrator-emit Phase 7; order_* added by lib/stats/stats-append.mjs) | trekbrief.md:1124-1139 | 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 | lib/stats/stats-append.mjs (trekexecute.md Phase 9) | trekexecute.md:1890-1912 | profile, phase_models, profile_source | none |
|
||||
|
|
|
|||
282
tests/lib/approval-log.test.mjs
Normal file
282
tests/lib/approval-log.test.mjs
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
// tests/lib/approval-log.test.mjs
|
||||
// K2 — the approval log: append-only, hash-chained, bound to the content's sha256.
|
||||
// Contract (docs/bolge1 § 4, K2): `append --kind <intent|plan|review|merge|release>
|
||||
// --subject <path> --sha256 <hex> [--expires <ISO>]` and `verify`; exit 0 = ok,
|
||||
// 1 = broken chain / refused, 2 = could not run (never green).
|
||||
//
|
||||
// The module is imported dynamically so a missing module fails each test on its own.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
const CLI = join(ROOT, 'lib', 'util', 'approval-log.mjs');
|
||||
const load = () => import(CLI);
|
||||
|
||||
const H1 = 'a'.repeat(64);
|
||||
const H2 = 'b'.repeat(64);
|
||||
|
||||
function tmp() { return mkdtempSync(join(tmpdir(), 'approval-log-')); }
|
||||
|
||||
function cli(args, cwd) {
|
||||
return spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: 'utf8' });
|
||||
}
|
||||
|
||||
function git(cwd, ...args) {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
||||
assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`);
|
||||
return r.stdout;
|
||||
}
|
||||
|
||||
function gitRepo() {
|
||||
const d = tmp();
|
||||
git(d, 'init', '-q');
|
||||
git(d, 'config', 'user.name', 'Test');
|
||||
git(d, 'config', 'user.email', 'test@example.invalid');
|
||||
git(d, 'config', 'commit.gpgsign', 'false');
|
||||
mkdirSync(join(d, '.hooks-none'));
|
||||
git(d, 'config', 'core.hooksPath', '.hooks-none');
|
||||
return d;
|
||||
}
|
||||
|
||||
function logFile(repo) { return join(repo, '.claude', 'voyage', 'approvals.jsonl'); }
|
||||
|
||||
// ---- the test that fails first (order 2) ----
|
||||
|
||||
test('two appends, then one byte changed in the FIRST record → verify exits 1', () => {
|
||||
const d = tmp();
|
||||
try {
|
||||
assert.equal(cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d).status, 0);
|
||||
assert.equal(cli(['append', '--kind', 'plan', '--subject', 'plan.md', '--sha256', H2], d).status, 0);
|
||||
assert.equal(cli(['verify'], d).status, 0, 'an untouched chain verifies');
|
||||
const p = logFile(d);
|
||||
const text = readFileSync(p, 'utf8');
|
||||
const i = text.indexOf('brief.md');
|
||||
writeFileSync(p, text.slice(0, i) + 'B' + text.slice(i + 1)); // one byte, first record
|
||||
const r = cli(['verify', '--json'], d);
|
||||
assert.equal(r.status, 1, r.stdout + r.stderr);
|
||||
assert.equal(JSON.parse(r.stdout).status, 'broken');
|
||||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('a changed byte inside the stored hash field also breaks the chain', () => {
|
||||
const d = tmp();
|
||||
try {
|
||||
cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d);
|
||||
cli(['append', '--kind', 'plan', '--subject', 'plan.md', '--sha256', H2], d);
|
||||
const p = logFile(d);
|
||||
const lines = readFileSync(p, 'utf8').split('\n');
|
||||
const rec = JSON.parse(lines[0]);
|
||||
rec.sha256 = H2; // re-bind the first approval to other content
|
||||
lines[0] = JSON.stringify(rec);
|
||||
writeFileSync(p, lines.join('\n'));
|
||||
assert.equal(cli(['verify'], d).status, 1);
|
||||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('deleting the first record breaks the chain (prev no longer links)', () => {
|
||||
const d = tmp();
|
||||
try {
|
||||
cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d);
|
||||
cli(['append', '--kind', 'plan', '--subject', 'plan.md', '--sha256', H2], d);
|
||||
const p = logFile(d);
|
||||
const lines = readFileSync(p, 'utf8').split('\n');
|
||||
writeFileSync(p, lines.slice(1).join('\n'));
|
||||
assert.equal(cli(['verify'], d).status, 1);
|
||||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('a record carries kind, subject, sha256, ts, prev and hash; prev links the chain', async () => {
|
||||
const { readLog } = await load();
|
||||
const d = tmp();
|
||||
try {
|
||||
cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d);
|
||||
cli(['append', '--kind', 'plan', '--subject', 'plan.md', '--sha256', H2, '--expires', '2099-01-01T00:00:00Z'], d);
|
||||
const recs = readLog(d).records;
|
||||
assert.equal(recs.length, 2);
|
||||
assert.equal(recs[0].kind, 'intent');
|
||||
assert.equal(recs[0].subject, 'brief.md');
|
||||
assert.equal(recs[0].sha256, H1);
|
||||
assert.equal(recs[0].prev, '0'.repeat(64));
|
||||
assert.equal(recs[1].prev, recs[0].hash);
|
||||
assert.equal(recs[1].expires, '2099-01-01T00:00:00.000Z');
|
||||
assert.match(recs[1].ts, /^\d{4}-\d\d-\d\dT/);
|
||||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('append refuses a bad kind, a bad hash and a bad expiry (exit 1), and writes nothing', () => {
|
||||
const d = tmp();
|
||||
try {
|
||||
const bad = cli(['append', '--kind', 'vibes', '--subject', 'x', '--sha256', H1], d);
|
||||
assert.equal(bad.status, 1);
|
||||
assert.equal(JSON.parse(bad.stdout).appended, false, 'a refusal is reported, not a crash');
|
||||
assert.equal(cli(['append', '--kind', 'intent', '--subject', 'x', '--sha256', 'nothex'], d).status, 1);
|
||||
assert.equal(cli(['append', '--kind', 'intent', '--subject', 'x', '--sha256', H1, '--expires', 'tomorrow'], d).status, 1);
|
||||
assert.equal(cli(['append', '--kind', 'intent', '--sha256', H1], d).status, 1, 'subject is required');
|
||||
assert.equal(existsSync(logFile(d)), false);
|
||||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('append accepts the "sha256:<hex>" form the intent stamp prints', () => {
|
||||
const d = tmp();
|
||||
try {
|
||||
assert.equal(cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', `sha256:${H1}`], d).status, 0);
|
||||
assert.equal(cli(['verify', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d).status, 0);
|
||||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('append refuses to extend a broken chain (exit 1)', () => {
|
||||
const d = tmp();
|
||||
try {
|
||||
cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d);
|
||||
const p = logFile(d);
|
||||
writeFileSync(p, readFileSync(p, 'utf8').replace('brief.md', 'brjef.md'));
|
||||
const before = readFileSync(p, 'utf8');
|
||||
assert.equal(cli(['append', '--kind', 'plan', '--subject', 'plan.md', '--sha256', H2], d).status, 1);
|
||||
assert.equal(readFileSync(p, 'utf8'), before, 'nothing appended onto a broken chain');
|
||||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
// ---- verify for one subject: the gate /trekplan runs ----
|
||||
|
||||
test('verify --subject: the current approval passes; other content, no record, or expiry is exit 1', () => {
|
||||
const d = tmp();
|
||||
try {
|
||||
cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d);
|
||||
const ok = cli(['verify', '--json', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d);
|
||||
assert.equal(ok.status, 0, ok.stdout);
|
||||
assert.equal(JSON.parse(ok.stdout).status, 'approved');
|
||||
const other = cli(['verify', '--json', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H2], d);
|
||||
assert.equal(other.status, 1);
|
||||
assert.equal(JSON.parse(other.stdout).status, 'mismatch');
|
||||
const none = cli(['verify', '--json', '--kind', 'intent', '--subject', 'other.md', '--sha256', H1], d);
|
||||
assert.equal(none.status, 1);
|
||||
assert.equal(JSON.parse(none.stdout).status, 'not-logged');
|
||||
cli(['append', '--kind', 'merge', '--subject', 'main', '--sha256', H2, '--expires', '2000-01-01T00:00:00Z'], d);
|
||||
const old = cli(['verify', '--json', '--kind', 'merge', '--subject', 'main', '--sha256', H2], d);
|
||||
assert.equal(old.status, 1);
|
||||
assert.equal(JSON.parse(old.stdout).status, 'expired');
|
||||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('verify --subject: the LATEST record for the subject decides (re-approval supersedes)', () => {
|
||||
const d = tmp();
|
||||
try {
|
||||
cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d);
|
||||
cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H2], d);
|
||||
assert.equal(cli(['verify', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H2], d).status, 0);
|
||||
assert.equal(cli(['verify', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d).status, 1);
|
||||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('the subject is stored relative to the repo, so an absolute and a relative path match', () => {
|
||||
const d = tmp();
|
||||
try {
|
||||
mkdirSync(join(d, 'docs'));
|
||||
writeFileSync(join(d, 'docs', 'b.md'), 'x');
|
||||
cli(['append', '--kind', 'intent', '--subject', join(d, 'docs', 'b.md'), '--sha256', H1], d);
|
||||
assert.equal(cli(['verify', '--kind', 'intent', '--subject', 'docs/b.md', '--sha256', H1], d).status, 0);
|
||||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('verify with no log: the empty chain is intact (0); a subject check is not-logged (1)', () => {
|
||||
const d = tmp();
|
||||
try {
|
||||
assert.equal(cli(['verify'], d).status, 0);
|
||||
assert.equal(cli(['verify', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d).status, 1);
|
||||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('verify that cannot run is exit 2, never green (bad args, log is a directory)', () => {
|
||||
const d = tmp();
|
||||
try {
|
||||
assert.equal(cli(['verify', '--kind', 'intent'], d).status, 2, 'a partial subject filter is a usage error');
|
||||
assert.equal(cli(['frobnicate'], d).status, 2);
|
||||
mkdirSync(logFile(d), { recursive: true });
|
||||
assert.equal(cli(['verify'], d).status, 2);
|
||||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('--sha256-of <file> binds to the file bytes; digest prints the same hex', () => {
|
||||
const d = tmp();
|
||||
try {
|
||||
writeFileSync(join(d, 'plan.md'), '# Plan\n');
|
||||
const hex = cli(['digest', join(d, 'plan.md')], d).stdout.trim();
|
||||
assert.match(hex, /^[0-9a-f]{64}$/);
|
||||
assert.equal(cli(['append', '--kind', 'plan', '--subject', 'plan.md', '--sha256-of', 'plan.md'], d).status, 0);
|
||||
assert.equal(cli(['verify', '--kind', 'plan', '--subject', 'plan.md', '--sha256', hex], d).status, 0);
|
||||
writeFileSync(join(d, 'plan.md'), '# Plan, edited after approval\n');
|
||||
assert.equal(cli(['verify', '--kind', 'plan', '--subject', 'plan.md', '--sha256-of', 'plan.md'], d).status, 1);
|
||||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
// ---- committed log: a rewrite of committed records shows; uncommitted records are listed ----
|
||||
|
||||
test('in git: records not yet committed are reported; a committed record removed from the file is exit 1', () => {
|
||||
const d = gitRepo();
|
||||
try {
|
||||
cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d);
|
||||
git(d, 'add', '.claude/voyage/approvals.jsonl');
|
||||
git(d, 'commit', '-q', '-m', 'log');
|
||||
cli(['append', '--kind', 'plan', '--subject', 'plan.md', '--sha256', H2], d);
|
||||
const r = cli(['verify', '--json'], d);
|
||||
assert.equal(r.status, 0, r.stdout);
|
||||
assert.equal(JSON.parse(r.stdout).uncommitted, 1, 'the second approval is visible as not committed');
|
||||
// A session that rewrites the log from scratch makes a valid chain that does not
|
||||
// start with what was committed: the chain alone cannot see that, the commit can.
|
||||
rmSync(logFile(d));
|
||||
cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H2], d);
|
||||
const w = cli(['verify', '--json'], d);
|
||||
assert.equal(w.status, 1, w.stdout);
|
||||
assert.equal(JSON.parse(w.stdout).status, 'diverged');
|
||||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
// ---- answers that are not approval (agent-skills interview-me) ----
|
||||
|
||||
test('«høres bra ut», «gjør som du vil» and an empty answer are not approval; "Approve" is', async () => {
|
||||
const { isExplicitApproval } = await load();
|
||||
for (const a of ['høres bra ut', 'Høres bra ut!', 'gjør som du vil', 'sounds good', 'Whatever you think', 'looks good', 'ok', '', ' ']) {
|
||||
assert.equal(isExplicitApproval(a).approved, false, `«${a}» must not count as approval`);
|
||||
}
|
||||
for (const a of ['Approve', 'approve', 'Godkjenn', 'godkjent']) {
|
||||
assert.equal(isExplicitApproval(a).approved, true, `«${a}» is an explicit approval`);
|
||||
}
|
||||
assert.equal(isExplicitApproval('').reason, 'empty');
|
||||
assert.equal(isExplicitApproval('høres bra ut').reason, 'hollow');
|
||||
});
|
||||
|
||||
test('the answer CLI: exit 0 only for an explicit approval', () => {
|
||||
const d = tmp();
|
||||
try {
|
||||
assert.equal(cli(['answer', '--text', 'Approve'], d).status, 0);
|
||||
assert.equal(cli(['answer', '--text', 'høres bra ut'], d).status, 1);
|
||||
assert.equal(cli(['answer', '--text', 'gjør som du vil'], d).status, 1);
|
||||
assert.equal(cli(['answer', '--text', ''], d).status, 1);
|
||||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
// ---- honesty and the main-module guard ----
|
||||
|
||||
test('the module states its trust limits (a trace, not a signature) and credits ai-native-sdlc', () => {
|
||||
const src = readFileSync(CLI, 'utf8');
|
||||
assert.match(src, /NOT a signature/);
|
||||
assert.match(src, /same user, same machine/i);
|
||||
assert.match(src, /bashebr\/ai-native-sdlc@11fb8d7 \(MIT/);
|
||||
});
|
||||
|
||||
test('the CLI runs through a symlinked path with a space in it (realpath main-module guard)', () => {
|
||||
const d = tmp();
|
||||
try {
|
||||
const link = join(d, 'with space');
|
||||
spawnSync('ln', ['-s', join(ROOT, 'lib'), link]);
|
||||
const r = spawnSync(process.execPath, [join(link, 'util', 'approval-log.mjs'), 'verify'], { cwd: d, encoding: 'utf8' });
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||||
});
|
||||
115
tests/lib/artifact-commit.test.mjs
Normal file
115
tests/lib/artifact-commit.test.mjs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
// tests/lib/artifact-commit.test.mjs
|
||||
// The commit in the target repo: the approval log always, the brief or plan only
|
||||
// when the operator chose `git` (operator decision 2026-10-06: «Loggen alltid,
|
||||
// briefen etter valg»). Never sweeps up anything else the operator has staged.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
const CLI = join(ROOT, 'lib', 'util', 'artifact-commit.mjs');
|
||||
const LOG = join(ROOT, 'lib', 'util', 'approval-log.mjs');
|
||||
const H1 = 'c'.repeat(64);
|
||||
|
||||
function git(cwd, ...args) {
|
||||
return spawnSync('git', args, { cwd, encoding: 'utf8' });
|
||||
}
|
||||
|
||||
function repo() {
|
||||
const d = mkdtempSync(join(tmpdir(), 'artifact-commit-'));
|
||||
git(d, 'init', '-q');
|
||||
git(d, 'config', 'user.name', 'Test');
|
||||
git(d, 'config', 'user.email', 'test@example.invalid');
|
||||
git(d, 'config', 'commit.gpgsign', 'false');
|
||||
mkdirSync(join(d, '.hooks-none'));
|
||||
git(d, 'config', 'core.hooksPath', '.hooks-none');
|
||||
writeFileSync(join(d, 'README.md'), 'x\n');
|
||||
git(d, 'add', 'README.md');
|
||||
git(d, 'commit', '-q', '-m', 'init');
|
||||
const proj = join(d, '.claude', 'projects', '2026-10-06-toggle');
|
||||
mkdirSync(proj, { recursive: true });
|
||||
writeFileSync(join(proj, 'brief.md'), '# brief\n');
|
||||
spawnSync(process.execPath, [LOG, 'append', '--kind', 'intent', '--subject', join(proj, 'brief.md'), '--sha256', H1], { cwd: d });
|
||||
return { d, brief: join(proj, 'brief.md') };
|
||||
}
|
||||
|
||||
function run(args, cwd) {
|
||||
return spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: 'utf8' });
|
||||
}
|
||||
|
||||
const committedFiles = (d) => git(d, 'show', '--name-only', '--format=', 'HEAD').stdout.trim().split('\n').filter(Boolean);
|
||||
|
||||
test('store git: the brief and the log land in one commit, and nothing else the operator staged', () => {
|
||||
const { d, brief } = repo();
|
||||
try {
|
||||
writeFileSync(join(d, 'unrelated.txt'), 'staged by the operator\n');
|
||||
git(d, 'add', 'unrelated.txt');
|
||||
const r = run(['--store', 'git', '--artifact', brief, '--message', 'docs(voyage): approve intent toggle'], d);
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
assert.equal(JSON.parse(r.stdout).committed, true);
|
||||
assert.deepEqual(committedFiles(d).sort(),
|
||||
['.claude/projects/2026-10-06-toggle/brief.md', '.claude/voyage/approvals.jsonl']);
|
||||
assert.match(git(d, 'diff', '--cached', '--name-only').stdout, /unrelated\.txt/, 'the operator\'s staged file stays staged, uncommitted');
|
||||
assert.match(git(d, 'log', '-1', '--format=%s').stdout, /approve intent toggle/);
|
||||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('store local: only the log is committed, and the project folder is kept out of git locally', () => {
|
||||
const { d, brief } = repo();
|
||||
try {
|
||||
const r = run(['--store', 'local', '--artifact', brief, '--message', 'docs(voyage): approve intent toggle'], d);
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
assert.deepEqual(committedFiles(d), ['.claude/voyage/approvals.jsonl']);
|
||||
const exclude = readFileSync(join(d, '.git', 'info', 'exclude'), 'utf8');
|
||||
assert.match(exclude, /^\/\.claude\/projects\/2026-10-06-toggle\/$/m);
|
||||
assert.equal(git(d, 'status', '--porcelain', '--', '.claude/projects').stdout.trim(), '',
|
||||
'the local-only brief does not show as untracked');
|
||||
// idempotent: a second run adds no second exclude line
|
||||
run(['--store', 'local', '--artifact', brief, '--message', 'again'], d);
|
||||
const again = readFileSync(join(d, '.git', 'info', 'exclude'), 'utf8');
|
||||
assert.equal(again.split('\n').filter((l) => l.includes('2026-10-06-toggle')).length, 1);
|
||||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('nothing new to commit is exit 0 with committed:false (a re-run is not an error)', () => {
|
||||
const { d, brief } = repo();
|
||||
try {
|
||||
assert.equal(run(['--store', 'git', '--artifact', brief, '--message', 'one'], d).status, 0);
|
||||
const r = run(['--store', 'git', '--artifact', brief, '--message', 'two'], d);
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
assert.equal(JSON.parse(r.stdout).committed, false);
|
||||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('a path the repo ignores is not forced in: exit 1 with the reason', () => {
|
||||
const { d, brief } = repo();
|
||||
try {
|
||||
writeFileSync(join(d, '.gitignore'), '.claude/\n');
|
||||
const r = run(['--store', 'git', '--artifact', brief, '--message', 'x'], d);
|
||||
assert.equal(r.status, 1, r.stdout + r.stderr);
|
||||
assert.match(JSON.parse(r.stdout).reason, /ignored/i);
|
||||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('not a git repository: exit 2 (could not run), never a silent pass', () => {
|
||||
const d = mkdtempSync(join(tmpdir(), 'artifact-commit-nogit-'));
|
||||
try {
|
||||
writeFileSync(join(d, 'brief.md'), 'x');
|
||||
const r = run(['--store', 'git', '--artifact', join(d, 'brief.md'), '--message', 'x'], d);
|
||||
assert.equal(r.status, 2, r.stdout + r.stderr);
|
||||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('a missing or unknown --store is a usage error (exit 2)', () => {
|
||||
const { d, brief } = repo();
|
||||
try {
|
||||
assert.equal(run(['--artifact', brief, '--message', 'x'], d).status, 2);
|
||||
assert.equal(run(['--store', 'cloud', '--artifact', brief, '--message', 'x'], d).status, 2);
|
||||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||||
});
|
||||
182
tests/lib/intent-prose-pins.test.mjs
Normal file
182
tests/lib/intent-prose-pins.test.mjs
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
// tests/lib/intent-prose-pins.test.mjs
|
||||
// Order 2 (P1 — intent as a stored artifact): the prose pins for /trekbrief's
|
||||
// ceremony by size, the question round, answers that are not approval, the
|
||||
// approval log and the commit; and /trekplan's Phase 1 log gate and Phase 11
|
||||
// plan approval. The command lines are EXTRACTED from the prose and executed,
|
||||
// so removing a line, or pointing it at the wrong module, fails here.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
const read = (rel) => readFileSync(join(ROOT, rel), 'utf8');
|
||||
|
||||
/** Text from `## <heading>` up to the next `## ` heading. */
|
||||
function section(text, heading) {
|
||||
const start = text.indexOf(`\n## ${heading}`);
|
||||
assert.ok(start >= 0, `missing ## ${heading}`);
|
||||
const next = text.indexOf('\n## ', start + 4);
|
||||
return text.slice(start, next < 0 ? undefined : next);
|
||||
}
|
||||
|
||||
/** The single line in `text` that runs `lib/<mod>` with `verb`. */
|
||||
function line(text, mod, verb) {
|
||||
const esc = mod.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
||||
const re = new RegExp(`^\\s*node \\$\\{CLAUDE_PLUGIN_ROOT\\}/lib/${esc}\\b`);
|
||||
const hits = text.split('\n').filter((l) => re.test(l) && l.includes(verb));
|
||||
assert.equal(hits.length, 1, `expected exactly one \`${mod} ${verb}\` line; found ${hits.length}`);
|
||||
return hits[0].trim();
|
||||
}
|
||||
|
||||
function bash(cmd, cwd) {
|
||||
return spawnSync('bash', ['-c', cmd.split('${CLAUDE_PLUGIN_ROOT}').join(ROOT)], { cwd, encoding: 'utf8' });
|
||||
}
|
||||
|
||||
const brief = read('commands/trekbrief.md');
|
||||
const plan = read('commands/trekplan.md');
|
||||
|
||||
// ---- /trekbrief: ceremony by size ----
|
||||
|
||||
test('trekbrief: three classes, heaviest in doubt, said aloud, overridable, only upward mid-task', () => {
|
||||
const p3 = section(brief, 'Phase 3');
|
||||
for (const c of ['small', 'bounded', 'architectural']) assert.ok(p3.includes(`\`${c}\``), `class ${c}`);
|
||||
assert.match(p3, /heaviest/i);
|
||||
assert.match(p3, /aloud/i);
|
||||
assert.match(p3, /override/i);
|
||||
assert.match(p3, /only (ever )?(moves? )?up/i);
|
||||
assert.match(p3, /`ceremony:/, 'the class is written to the brief (K1)');
|
||||
});
|
||||
|
||||
test('trekbrief: the round — all answerable questions at once, recommendation first, no fact questions', () => {
|
||||
const p3 = section(brief, 'Phase 3');
|
||||
assert.match(p3, /Use `AskUserQuestion` for every question/);
|
||||
assert.doesNotMatch(brief, /Ask one question at a time/, 'the one-at-a-time rule is replaced by the round');
|
||||
assert.match(p3, /one round/i);
|
||||
assert.match(p3, /recommend(ed|ation)[^.\n]*first/i);
|
||||
assert.match(p3, /never ask[^.\n]*fact/i);
|
||||
assert.match(p3, /Impact × Uncertainty/);
|
||||
assert.match(p3, /[Dd]eferred/);
|
||||
assert.match(p3, /next three answers/);
|
||||
});
|
||||
|
||||
test('trekbrief: the budget for a small task is at most 5 questions and fits one AskUserQuestion call', () => {
|
||||
const p3 = section(brief, 'Phase 3');
|
||||
const m = p3.match(/`small`[^\n|]*\|\s*(\d+)/);
|
||||
assert.ok(m, 'the budget table must give a number for `small`');
|
||||
assert.ok(Number(m[1]) <= 5 && Number(m[1]) <= 4, `small budget ${m[1]} must be ≤ 5 and fit one call (max 4 questions)`);
|
||||
assert.match(p3, /framing/i, 'framing is one of the round\'s questions');
|
||||
assert.match(p3, /artifact_store/, 'the storage choice is one of the round\'s questions');
|
||||
});
|
||||
|
||||
test('trekbrief: credits every borrowing of the round and the classes', () => {
|
||||
const p3 = section(brief, 'Phase 3');
|
||||
for (const src of ['obra/superpowers@8ca22db', 'mattpocock/skills@c55ee46', 'GitHub Spec Kit', 'addyosmani/agent-skills@9d0c60d', 'kiro.dev']) {
|
||||
assert.ok(p3.includes(src), `credit for ${src}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('trekbrief: hard rule 8 no longer says Phase 3 has no cap', () => {
|
||||
assert.doesNotMatch(brief, /Phase 3 has no cap/);
|
||||
});
|
||||
|
||||
// ---- /trekbrief Phase 4h: answers that are not approval, the log, the commit ----
|
||||
|
||||
test('trekbrief 4h: the answer check runs before the stamp, then append, then commit', () => {
|
||||
const p4h = section(brief, 'Phase 4h');
|
||||
const ans = p4h.indexOf(line(p4h, 'util/approval-log.mjs', ' answer'));
|
||||
const stamp = p4h.indexOf('intent-approval.mjs --stamp');
|
||||
const app = p4h.indexOf(line(p4h, 'util/approval-log.mjs', ' append'));
|
||||
const com = p4h.indexOf(line(p4h, 'util/artifact-commit.mjs', '--store'));
|
||||
assert.ok(ans >= 0 && ans < stamp && stamp < app && app < com, 'order: answer → stamp → append → commit');
|
||||
assert.match(p4h, /høres bra ut/);
|
||||
assert.match(p4h, /gjør som du vil/);
|
||||
assert.match(p4h, /empty/i);
|
||||
});
|
||||
|
||||
test('trekbrief 4h: the answer line, run as written, refuses «høres bra ut» and «gjør som du vil»', () => {
|
||||
const p4h = section(brief, 'Phase 4h');
|
||||
const start = p4h.indexOf(line(p4h, 'util/approval-log.mjs', ' answer'));
|
||||
const block = p4h.slice(start, p4h.indexOf('```', start)).trim(); // the heredoc, up to the fence
|
||||
const d = mkdtempSync(join(tmpdir(), 'intent-pins-'));
|
||||
try {
|
||||
for (const [answer, code] of [['høres bra ut', 1], ['gjør som du vil', 1], ['', 1], ['Approve', 0]]) {
|
||||
const r = bash(block.split('{OPERATOR_ANSWER}').join(answer), d);
|
||||
assert.equal(r.status, code, `«${answer}» → ${r.status} (${r.stdout}${r.stderr})`);
|
||||
}
|
||||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('trekbrief 4h: append binds the intent hash; commit uses the operator\'s storage choice', () => {
|
||||
const p4h = section(brief, 'Phase 4h');
|
||||
const app = line(p4h, 'util/approval-log.mjs', ' append');
|
||||
assert.match(app, /--kind intent/);
|
||||
assert.match(app, /--subject "\{BRIEF_PATH\}"/);
|
||||
assert.match(app, /--sha256 "\{INTENT_HASH\}"/);
|
||||
const com = line(p4h, 'util/artifact-commit.mjs', '--store');
|
||||
assert.match(com, /--store \{ARTIFACT_STORE\}/);
|
||||
assert.match(com, /--artifact "\{BRIEF_PATH\}"/);
|
||||
});
|
||||
|
||||
// ---- /trekplan Phase 1: the log gate ----
|
||||
|
||||
function gateLine() {
|
||||
const read1 = plan.slice(plan.indexOf('### Read the brief'), plan.indexOf('## Phase 1.5'));
|
||||
return line(read1, 'util/approval-log.mjs', ' verify');
|
||||
}
|
||||
|
||||
test('trekplan Phase 1: the log gate sits after the marker check, before planning, and says halt', () => {
|
||||
const readIdx = plan.indexOf('### Read the brief');
|
||||
const check = plan.indexOf('intent-approval.mjs --check');
|
||||
const gate = plan.indexOf(gateLine());
|
||||
const p15 = plan.indexOf('## Phase 1.5');
|
||||
assert.ok(readIdx < check && check < gate && gate < p15);
|
||||
const after = plan.slice(gate, gate + 2000);
|
||||
assert.match(after, /\*\*halt\*\*/i);
|
||||
for (const s of ['broken', 'diverged', 'not-logged', 'mismatch']) assert.ok(after.includes(`\`${s}\``), s);
|
||||
assert.match(gateLine(), /--kind intent --subject "\{brief_path\}" --sha256 "\{intent_approved_hash\}"/);
|
||||
});
|
||||
|
||||
test('trekplan Phase 1: the gate line, run as written, stops (exit 1) when the brief\'s log is broken', () => {
|
||||
const d = mkdtempSync(join(tmpdir(), 'intent-pins-gate-'));
|
||||
try {
|
||||
const H = 'd'.repeat(64);
|
||||
const logCli = join(ROOT, 'lib', 'util', 'approval-log.mjs');
|
||||
writeFileSync(join(d, 'brief.md'), 'x');
|
||||
spawnSync(process.execPath, [logCli, 'append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H], { cwd: d });
|
||||
spawnSync(process.execPath, [logCli, 'append', '--kind', 'plan', '--subject', 'plan.md', '--sha256', H], { cwd: d });
|
||||
const cmd = gateLine().split('{brief_path}').join('brief.md').split('{intent_approved_hash}').join(`sha256:${H}`);
|
||||
assert.equal(bash(cmd, d).status, 0, 'intact log with the brief\'s approval passes');
|
||||
const p = join(d, '.claude', 'voyage', 'approvals.jsonl');
|
||||
const text = readFileSync(p, 'utf8');
|
||||
writeFileSync(p, text.replace('"intent"', '"intenT"'));
|
||||
const r = bash(cmd, d);
|
||||
assert.equal(r.status, 1, r.stdout + r.stderr);
|
||||
assert.equal(JSON.parse(r.stdout).status, 'broken');
|
||||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('trekplan Phase 1: reads ceremony (K1, absent = architectural) and artifact_store (absent = local)', () => {
|
||||
const read1 = plan.slice(plan.indexOf('### Read the brief'), plan.indexOf('### When the input is type:trekreview'));
|
||||
assert.match(read1, /`ceremony`[^\n]*absent[^\n]*`architectural`/);
|
||||
assert.match(read1, /`artifact_store`[^\n]*absent[^\n]*`local`/);
|
||||
});
|
||||
|
||||
// ---- /trekplan Phase 11: plan approval before any handoff ----
|
||||
|
||||
test('trekplan Phase 11: plan approval (answer check → append plan → commit) comes before every handoff', () => {
|
||||
const p11 = section(plan, 'Phase 11');
|
||||
const ans = p11.indexOf(line(p11, 'util/approval-log.mjs', ' answer'));
|
||||
const app = p11.indexOf(line(p11, 'util/approval-log.mjs', ' append'));
|
||||
const com = p11.indexOf(line(p11, 'util/artifact-commit.mjs', '--store'));
|
||||
const save = p11.indexOf('### "save"');
|
||||
assert.ok(ans >= 0 && ans < app && app < com && com < save);
|
||||
assert.match(line(p11, 'util/approval-log.mjs', ' append'), /--kind plan --subject "\{plan_path\}" --sha256-of "\{plan_path\}"/);
|
||||
assert.match(line(p11, 'util/artifact-commit.mjs', '--store'), /--store \{artifact_store\} --artifact "\{plan_path\}"/);
|
||||
assert.match(p11, /AskUserQuestion/);
|
||||
});
|
||||
108
tests/validators/brief-ceremony.test.mjs
Normal file
108
tests/validators/brief-ceremony.test.mjs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// tests/validators/brief-ceremony.test.mjs
|
||||
// K1 — the brief's size class: optional `ceremony: small | bounded | architectural`;
|
||||
// absent = architectural (heaviest when in doubt). Plus the optional
|
||||
// `artifact_store: git | local` (operator decision 2026-10-06); absent = local,
|
||||
// so nothing is published without a yes. Both are additive: no brief_version bump.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
const MODULE = join(ROOT, 'lib', 'validators', 'brief-validator.mjs');
|
||||
const load = () => import(MODULE);
|
||||
|
||||
const brief = (extra = '') => `---
|
||||
type: trekbrief
|
||||
brief_version: "2.2"
|
||||
created: 2026-10-06
|
||||
task: "Add a dark-mode toggle"
|
||||
slug: dark-mode-toggle
|
||||
project_dir: .claude/projects/2026-10-06-dark-mode-toggle/
|
||||
research_topics: 0
|
||||
research_status: complete
|
||||
framing: new-direction
|
||||
phase_signals_partial: true
|
||||
${extra}---
|
||||
|
||||
# Task
|
||||
|
||||
## TL;DR
|
||||
|
||||
A toggle.
|
||||
|
||||
## Intent
|
||||
|
||||
Readers cook at night.
|
||||
|
||||
## Goal
|
||||
|
||||
A toggle.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- It persists.
|
||||
`;
|
||||
|
||||
const codes = (r) => r.errors.map((e) => e.code);
|
||||
|
||||
test('ceremony: huge is rejected (BRIEF_INVALID_CEREMONY)', async () => {
|
||||
const { validateBriefContent } = await load();
|
||||
const r = validateBriefContent(brief('ceremony: huge\n'));
|
||||
assert.equal(r.valid, false);
|
||||
assert.deepEqual(codes(r), ['BRIEF_INVALID_CEREMONY']);
|
||||
});
|
||||
|
||||
test('ceremony: small, bounded and architectural are accepted', async () => {
|
||||
const { validateBriefContent } = await load();
|
||||
for (const c of ['small', 'bounded', 'architectural']) {
|
||||
const r = validateBriefContent(brief(`ceremony: ${c}\n`));
|
||||
assert.deepEqual(codes(r), [], c);
|
||||
}
|
||||
});
|
||||
|
||||
test('ceremony absent is valid and reads as architectural (heaviest in doubt)', async () => {
|
||||
const { validateBriefContent, briefCeremony, BRIEF_CEREMONY_DEFAULT } = await load();
|
||||
const r = validateBriefContent(brief());
|
||||
assert.deepEqual(codes(r), []);
|
||||
assert.equal(BRIEF_CEREMONY_DEFAULT, 'architectural');
|
||||
assert.equal(briefCeremony(r.parsed.frontmatter), 'architectural');
|
||||
assert.equal(briefCeremony({ ceremony: 'small' }), 'small');
|
||||
assert.equal(briefCeremony({ ceremony: 'huge' }), 'architectural', 'an invalid value never reads lighter');
|
||||
});
|
||||
|
||||
test('artifact_store: git and local accepted, anything else rejected, absent reads as local', async () => {
|
||||
const { validateBriefContent, briefArtifactStore } = await load();
|
||||
assert.deepEqual(codes(validateBriefContent(brief('artifact_store: git\n'))), []);
|
||||
assert.deepEqual(codes(validateBriefContent(brief('artifact_store: local\n'))), []);
|
||||
assert.deepEqual(codes(validateBriefContent(brief('artifact_store: cloud\n'))), ['BRIEF_INVALID_ARTIFACT_STORE']);
|
||||
assert.equal(briefArtifactStore({}), 'local');
|
||||
assert.equal(briefArtifactStore({ artifact_store: 'git' }), 'git');
|
||||
});
|
||||
|
||||
test('no brief_version bump: the template still declares 2.2 and documents both fields', () => {
|
||||
const tpl = spawnSync('cat', [join(ROOT, 'templates', 'trekbrief-template.md')], { encoding: 'utf8' }).stdout;
|
||||
assert.match(tpl, /^brief_version: "2\.2"$/m);
|
||||
assert.match(tpl, /^ceremony: /m);
|
||||
assert.match(tpl, /^artifact_store: /m);
|
||||
});
|
||||
|
||||
test('the validator CLI runs through a symlinked path with a space (realpath main-module guard)', () => {
|
||||
const d = mkdtempSync(join(tmpdir(), 'brief-ceremony-'));
|
||||
try {
|
||||
const link = join(d, 'with space');
|
||||
spawnSync('ln', ['-s', join(ROOT, 'lib'), link]);
|
||||
writeFileSync(join(d, 'b.md'), brief('ceremony: huge\n'));
|
||||
const r = spawnSync(process.execPath, [join(link, 'validators', 'brief-validator.mjs'), '--json', join(d, 'b.md')], { encoding: 'utf8' });
|
||||
assert.equal(r.status, 1, r.stdout + r.stderr);
|
||||
assert.ok(JSON.parse(r.stdout).errors.some((e) => e.code === 'BRIEF_INVALID_CEREMONY'));
|
||||
const ia = spawnSync(process.execPath, [join(link, 'validators', 'intent-approval.mjs'), '--check', '--json', join(d, 'b.md')], { encoding: 'utf8' });
|
||||
assert.equal(ia.status, 1, 'intent-approval runs (and stops on the unapproved brief) through the same path');
|
||||
assert.ok(JSON.parse(ia.stdout).errors.some((e) => e.code === 'BRIEF_INTENT_NOT_APPROVED'));
|
||||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue