fix(approval): approval commits carry the caller's Co-Authored-By trailer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-12 20:17:22 +02:00
commit acd0e3dd47
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
8 changed files with 337 additions and 23 deletions

View file

@ -945,10 +945,10 @@ node ${CLAUDE_PLUGIN_ROOT}/lib/util/approval-log.mjs append --kind intent --subj
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`:
producer's brief); `{slug}` is the brief's `slug`; `{model}` is this session's model name (e.g. `Opus 5.5`):
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/util/artifact-commit.mjs --store {ARTIFACT_STORE} --artifact "{BRIEF_PATH}" --message "docs(voyage): approve intent {slug}"
node ${CLAUDE_PLUGIN_ROOT}/lib/util/artifact-commit.mjs --store {ARTIFACT_STORE} --artifact "{BRIEF_PATH}" --message "docs(voyage): approve intent {slug}" --trailer "Co-Authored-By: Claude {model} <noreply@anthropic.com>"
```
`git`: the brief and the log in one commit. `local`: only the log; the

View file

@ -2266,9 +2266,9 @@ the run is `stopped`, and the merge stays closed.
**Approve** / **Not yet**. Check the answer with
`approval-log.mjs answer` (stdin) exactly as `/trekplan` Phase 11 does;
exit 0 → `approval-log.mjs append --kind plan --subject "{plan_path}" --sha256-of "{plan_path}"`
and `artifact-commit.mjs --store {artifact_store} --artifact "{plan_path}" --message "docs(voyage): approve plan {slug}"`.
This is an approval of new plan content, not an execution decision
(Hard rule 1), and not a merge: no guard asks it.
and `artifact-commit.mjs --store {artifact_store} --artifact "{plan_path}" --message "docs(voyage): approve plan {slug}" --trailer "Co-Authored-By: Claude {model} <noreply@anthropic.com>"`,
`{model}` being this session's model name (e.g. `Opus 5.5`). On exit 1 or 2, print the `reason` in one line and continue: the approval is logged but not committed.
This is an approval of new plan content, not an execution decision (Hard rule 1), and not a merge: no guard asks it.
5. **Approve** → add the new steps to progress (`steps.{M}` pending,
`total_steps` raised), run Phase 6 for them (the same loop: helper, checks,
controller, fix rounds), then Phase 7, then this phase again with

View file

@ -1192,11 +1192,11 @@ ANSWER
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):
commits only the log; `{model}` is this session's model name, e.g. `Opus 5.5`):
```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}"
node ${CLAUDE_PLUGIN_ROOT}/lib/util/artifact-commit.mjs --store {artifact_store} --artifact "{plan_path}" --message "docs(voyage): approve plan {slug}" --trailer "Co-Authored-By: Claude {model} <noreply@anthropic.com>"
```
Print one line each: `Plan approval: record {seq}` and `Committed: {short

View file

@ -112,7 +112,7 @@ 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.
**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 commit carries the caller's trailer: every call passes `--trailer "Co-Authored-By: Claude {model} <noreply@anthropic.com>"` with `{model}` filled in, and the CLI refuses (exit 1, nothing committed) a missing trailer, a multi-line one, one that does not read `Co-Authored-By: <name> <<email>>` (an unfilled `{model}` included) and one holding `Claude-Session:`; a bad `--store` stays exit 2 and is checked first. 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>`

View file

@ -10,13 +10,23 @@
// 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.
//
// The commit carries the caller's trailer: `--trailer "<line>"`, one line
// matching `Co-Authored-By: <name> <<email>>` (TRAILER_RE), written as the
// message's last paragraph (`git commit -m <message> -m <trailer>`). A missing
// trailer, a multi-line one, one that does not match (an unfilled `{model}`
// placeholder, no `<email>`, another key) or one holding `Claude-Session:` is
// refused with exit 1. Order: the `--store` check first (exit 2), then the
// trailer (exit 1), both before any git call, so "nothing to commit" cannot
// mask a bad trailer.
//
// CLI:
// node lib/util/artifact-commit.mjs --store <git|local> [--artifact <path>]...
// [--message <msg>] [--repo <dir>]
// --trailer "Co-Authored-By: <name> <<email>>" [--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.
// exit 0 = committed, or nothing new to commit; 1 = refused (missing or bad
// trailer, 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';
@ -26,6 +36,21 @@ import { isMain } from './is-main.mjs';
export const ARTIFACT_STORES = Object.freeze(['git', 'local']);
const DEFAULT_MESSAGE = 'docs(voyage): record approval';
// One `Co-Authored-By:` line; `{`/`}` are refused so an unfilled `{model}` fails.
export const TRAILER_RE = /^Co-Authored-By: [^<>{}\n]+ <[^\s<>@]+@[^\s<>]+>$/;
/** @returns {string|null} why `trailer` is refused, or null when it is valid */
export function trailerProblem(trailer) {
if (typeof trailer !== 'string' || trailer === '') {
return '--trailer "Co-Authored-By: <name> <<email>>" is required — nothing committed';
}
if (/[\r\n]/.test(trailer)) return '--trailer must be one line — nothing committed';
if (trailer.includes('Claude-Session:')) return '--trailer must not hold a Claude-Session: line — nothing committed';
if (!TRAILER_RE.test(trailer)) {
return `--trailer must read "Co-Authored-By: <name> <<email>>" with every placeholder filled; got "${trailer}" — nothing committed`;
}
return null;
}
function git(repo, args, timeout = 60_000) {
return spawnSync('git', ['-C', repo, ...args], { encoding: 'utf8', timeout });
@ -48,11 +73,13 @@ function excludeLocally(repo, rel) {
* @returns {{ exit: number, committed: boolean, commit?: string, paths: string[],
* excluded: string[], warnings: string[], reason?: string }}
*/
export function commitArtifacts({ repo, store, artifacts = [], message = DEFAULT_MESSAGE }) {
export function commitArtifacts({ repo, store, artifacts = [], message = DEFAULT_MESSAGE, trailer }) {
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(', ')}]` };
}
const bad = trailerProblem(trailer);
if (bad) return { ...res, exit: 1, reason: bad };
if (existsSync(logPath(repo))) res.paths.push(LOG_RELPATH);
for (const a of artifacts) {
const rel = normalizeSubject(repo, a);
@ -79,7 +106,7 @@ export function commitArtifacts({ repo, store, artifacts = [], message = DEFAULT
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]);
const c = git(repo, ['commit', '-q', '--only', '-m', message, '-m', trailer, '--', ...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() };
@ -98,6 +125,7 @@ if (isMain(import.meta.url)) {
} else {
r = commitArtifacts({
repo: realpathSync(top.stdout.trim()), store: opt('--store'), artifacts, message: opt('--message') || DEFAULT_MESSAGE,
trailer: opt('--trailer'),
});
}
const { exit, ...rest } = r;

108
tests/e2e-files-trial4.sh Normal file
View file

@ -0,0 +1,108 @@
#!/usr/bin/env bash
# tests/e2e-files-trial4.sh
# End to end for the trial-4 plan (step 13): the three CLIs run the way a
# command runs them.
# 1. lib/validators/plan-validator.mjs --strict --json on three plans built
# with tests/helpers/plan-v18-builder.mjs: a valid 1.9 plan (exit 0), a
# 1.9 plan with an unticked Files entry (exit 1, PLAN_STEP_PATH_NOT_FIRST),
# a 1.8 plan with an unticked path (exit 0, PLAN_STEP_PATH_UNTICKED).
# 2. lib/util/artifact-commit.mjs in a temp git repo: with a trailer (exit 0,
# the trailer is the message's last line), without (exit 1, HEAD unchanged).
# 3. lib/review/verdict-gate.mjs on the legal (exit 0) and the illegal
# (exit 2) brief-conflict review fixtures.
# Prints "e2e: ok" and exits 0, or names the failed check and exits 1.
# bash 3.2 compatible, ASCII only.
set -u
cd "$(dirname "$0")/.." || { echo "e2e: FAIL - cannot cd to the repo root" >&2; exit 1; }
ROOT="$(pwd)"
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
TMP="$(mktemp -d "${TMPDIR:-/tmp}/e2e-files-trial4.XXXXXX")" || { echo "e2e: FAIL - mktemp" >&2; exit 1; }
trap 'rm -rf "$TMP"' EXIT
fail() {
echo "e2e: FAIL - $1" >&2
exit 1
}
TRAILER='Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>'
# ---- 1. the plan validator on three plans ----
node --input-type=module -e '
import { writeFileSync } from "node:fs";
import { buildPlan } from "./tests/helpers/plan-v18-builder.mjs";
const dir = process.argv[1];
const e2e = { endToEnd: "bash tests/e2e.sh" };
writeFileSync(dir + "/plan-1-9-valid.md", buildPlan({ version: "1.9" }));
writeFileSync(dir + "/plan-1-9-unticked.md",
buildPlan({ version: "1.9", steps: [{ filesRaw: "src/mod1.mjs - the parser" }, e2e] }));
writeFileSync(dir + "/plan-1-8-unticked.md",
buildPlan({ steps: [{ filesRaw: "`tests/mod1.test.mjs`, src/mod1.mjs" }, e2e] }));
' "$TMP" || fail "could not write the three plans with tests/helpers/plan-v18-builder.mjs"
out="$(node lib/validators/plan-validator.mjs --strict --json "$TMP/plan-1-9-valid.md" 2>&1)"
rc=$?
[ "$rc" -eq 0 ] || fail "valid 1.9 plan: plan-validator exit $rc, expected 0: $out"
out="$(node lib/validators/plan-validator.mjs --strict --json "$TMP/plan-1-9-unticked.md" 2>&1)"
rc=$?
[ "$rc" -eq 1 ] || fail "1.9 plan with an unticked Files entry: plan-validator exit $rc, expected 1: $out"
echo "$out" | grep -q 'PLAN_STEP_PATH_NOT_FIRST' \
|| fail "1.9 plan with an unticked Files entry: no PLAN_STEP_PATH_NOT_FIRST in: $out"
out="$(node lib/validators/plan-validator.mjs --strict --json "$TMP/plan-1-8-unticked.md" 2>&1)"
rc=$?
[ "$rc" -eq 0 ] || fail "1.8 plan with an unticked path: plan-validator exit $rc, expected 0: $out"
echo "$out" | grep -q 'PLAN_STEP_PATH_UNTICKED' \
|| fail "1.8 plan with an unticked path: no PLAN_STEP_PATH_UNTICKED in: $out"
# ---- 2. the approval commit in a temp git repo ----
REPO="$TMP/repo"
mkdir -p "$REPO" "$TMP/hooks-none" || fail "mkdir $REPO"
git -C "$REPO" init -q || fail "git init"
git -C "$REPO" config user.name 'E2E Test'
git -C "$REPO" config user.email 'test@example.invalid'
git -C "$REPO" config commit.gpgsign false
git -C "$REPO" config core.hooksPath "$TMP/hooks-none"
echo 'x' > "$REPO/README.md"
git -C "$REPO" add README.md && git -C "$REPO" commit -q -m 'init' || fail "initial commit in the temp repo"
echo '# brief' > "$REPO/brief.md"
H1='cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc'
H2='dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd'
(cd "$REPO" && node "$ROOT/lib/util/approval-log.mjs" append --kind intent --subject brief.md --sha256 "$H1" >/dev/null) \
|| fail "approval-log append (first record)"
[ -f "$REPO/.claude/voyage/approvals.jsonl" ] || fail "no .claude/voyage/approvals.jsonl in the temp repo"
out="$(node lib/util/artifact-commit.mjs --repo "$REPO" --store local --message 'docs(voyage): approve intent e2e' --trailer "$TRAILER" 2>&1)"
rc=$?
[ "$rc" -eq 0 ] || fail "artifact-commit with a trailer: exit $rc, expected 0: $out"
last="$(git -C "$REPO" log -1 --format=%B | sed '/^$/d' | tail -n 1)"
[ "$last" = "$TRAILER" ] || fail "artifact-commit with a trailer: the last message line is '$last', expected '$TRAILER'"
(cd "$REPO" && node "$ROOT/lib/util/approval-log.mjs" append --kind intent --subject brief.md --sha256 "$H2" >/dev/null) \
|| fail "approval-log append (second record)"
before="$(git -C "$REPO" rev-parse HEAD)"
out="$(node lib/util/artifact-commit.mjs --repo "$REPO" --store local --message 'docs(voyage): approve intent e2e' 2>&1)"
rc=$?
[ "$rc" -eq 1 ] || fail "artifact-commit without a trailer: exit $rc, expected 1: $out"
after="$(git -C "$REPO" rev-parse HEAD)"
[ "$before" = "$after" ] || fail "artifact-commit without a trailer: HEAD moved from $before to $after"
# ---- 3. the verdict gate on the two brief-conflict fixtures ----
out="$(node lib/review/verdict-gate.mjs tests/fixtures/review-brief-conflict-legal.md 2>&1)"
rc=$?
[ "$rc" -eq 0 ] || fail "verdict-gate on review-brief-conflict-legal.md: exit $rc, expected 0: $out"
out="$(node lib/review/verdict-gate.mjs tests/fixtures/review-brief-conflict-illegal.md 2>&1)"
rc=$?
[ "$rc" -eq 2 ] || fail "verdict-gate on review-brief-conflict-illegal.md: exit $rc, expected 2: $out"
echo 'e2e: ok'
exit 0

View file

@ -0,0 +1,176 @@
// tests/lib/artifact-commit-trailer.test.mjs
// Approval commits carry the caller's `Co-Authored-By:` trailer (plan step 13).
// The CLI takes `--trailer "<line>"`; a missing, multi-line or malformed one
// (an unfilled `{model}`, no `<email>`, another key) or one holding
// `Claude-Session:` is refused with exit 1 before any git call, so "nothing to
// commit" cannot mask it. A usage error (`--store`) stays exit 2 and comes
// first. Every call in commands/*.md passes the trailer, and none carries a
// `Claude-Session:` line.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { childEnv } from '../helpers/child-env.mjs';
import { commitArtifacts } from '../../lib/util/artifact-commit.mjs';
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);
const TRAILER = 'Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>';
function git(cwd, ...args) {
return spawnSync('git', args, { cwd, encoding: 'utf8', env: childEnv() });
}
// Same shape as the repo() factory in tests/lib/artifact-commit.test.mjs.
function repo({ withLog = true } = {}) {
const d = mkdtempSync(join(tmpdir(), 'artifact-trailer-'));
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-12-trailer');
mkdirSync(proj, { recursive: true });
writeFileSync(join(proj, 'brief.md'), '# brief\n');
if (withLog) {
spawnSync(process.execPath, [LOG, 'append', '--kind', 'intent', '--subject', join(proj, 'brief.md'), '--sha256', H1],
{ cwd: d, env: childEnv() });
}
return { d, brief: join(proj, 'brief.md') };
}
function run(args, cwd) {
return spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: 'utf8', env: childEnv() });
}
const head = (d) => git(d, 'rev-parse', 'HEAD').stdout.trim();
test('a passed trailer ends the commit message and git parses it as a trailer', () => {
const { d, brief } = repo();
try {
const r = run(['--store', 'local', '--artifact', brief, '--message', 'docs(voyage): approve intent trailer',
'--trailer', TRAILER], d);
assert.equal(r.status, 0, r.stdout + r.stderr);
assert.equal(JSON.parse(r.stdout).committed, true);
const body = git(d, 'log', '-1', '--format=%B').stdout.trimEnd().split('\n');
assert.equal(body[0], 'docs(voyage): approve intent trailer');
assert.equal(body[body.length - 1], TRAILER, 'the trailer is the last line of the message');
const parsed = git(d, 'log', '-1', '--format=%(trailers:key=Co-Authored-By,valueonly)').stdout.trim();
assert.equal(parsed, 'Claude Opus 5.5 <noreply@anthropic.com>');
} finally { rmSync(d, { recursive: true, force: true }); }
});
const REFUSED = [
['a two-line value', `${TRAILER}\nCo-Authored-By: Other <other@example.invalid>`],
['a value holding Claude-Session:', 'Co-Authored-By: Claude-Session: https://example.invalid/x <noreply@anthropic.com>'],
['a value without <email>', 'Co-Authored-By: Claude Opus 5.5'],
['a non-Co-Authored-By key', 'Signed-off-by: Claude Opus 5.5 <noreply@anthropic.com>'],
['the unfilled {model} placeholder', 'Co-Authored-By: Claude {model} <noreply@anthropic.com>'],
];
for (const [name, value] of REFUSED) {
test(`refused with exit 1, nothing committed: ${name}`, () => {
const { d, brief } = repo();
try {
const before = head(d);
const r = run(['--store', 'local', '--artifact', brief, '--message', 'x', '--trailer', value], d);
assert.equal(r.status, 1, r.stdout + r.stderr);
const out = JSON.parse(r.stdout);
assert.equal(out.committed, false);
assert.match(out.reason, /trailer/i);
assert.equal(head(d), before, 'HEAD unchanged');
} finally { rmSync(d, { recursive: true, force: true }); }
});
}
test('a call without a trailer exits 1 with HEAD unchanged', () => {
const { d, brief } = repo();
try {
const before = head(d);
const r = run(['--store', 'local', '--artifact', brief, '--message', 'x'], d);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(JSON.parse(r.stdout).reason, /--trailer/);
assert.equal(head(d), before, 'HEAD unchanged');
assert.equal(git(d, 'diff', '--cached', '--name-only').stdout.trim(), '', 'nothing staged either');
} finally { rmSync(d, { recursive: true, force: true }); }
});
test('the trailer check comes before any git call: "nothing to commit" does not mask a missing trailer', () => {
const { d, brief } = repo({ withLog: false });
try {
const r = run(['--store', 'local', '--artifact', brief, '--message', 'x'], d);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(JSON.parse(r.stdout).reason, /--trailer/);
} finally { rmSync(d, { recursive: true, force: true }); }
});
test('commitArtifacts takes the trailer too, and refuses without it', () => {
const { d, brief } = repo();
try {
const repoDir = realpathSync(d);
const before = head(d);
const no = commitArtifacts({ repo: repoDir, store: 'local', artifacts: [brief], message: 'x' });
assert.equal(no.exit, 1, JSON.stringify(no));
assert.equal(head(d), before);
const yes = commitArtifacts({ repo: repoDir, store: 'local', artifacts: [brief], message: 'x', trailer: TRAILER });
assert.equal(yes.exit, 0, JSON.stringify(yes));
assert.equal(yes.committed, true);
assert.equal(git(d, 'log', '-1', '--format=%(trailers:key=Co-Authored-By,valueonly)').stdout.trim(),
'Claude Opus 5.5 <noreply@anthropic.com>');
} finally { rmSync(d, { recursive: true, force: true }); }
});
test('usage before trailer: a missing --store is still exit 2, with or without a trailer', () => {
const { d, brief } = repo();
try {
assert.equal(run(['--artifact', brief, '--message', 'x'], d).status, 2);
assert.equal(run(['--artifact', brief, '--message', 'x', '--trailer', TRAILER], d).status, 2);
assert.equal(run(['--store', 'cloud', '--artifact', brief, '--message', 'x', '--trailer', 'bad'], d).status, 2);
} finally { rmSync(d, { recursive: true, force: true }); }
});
// ---- the calls in commands/*.md ----
const CMD_DIR = join(ROOT, 'commands');
// A call is `artifact-commit.mjs` followed by its flags, up to the end of the
// line or the closing backtick of an inline call. A prose mention without
// flags is not a call.
const CALL = /artifact-commit\.mjs[ \t]+--[^`\n]*/g;
function calls() {
const out = [];
for (const f of readdirSync(CMD_DIR).filter((n) => n.endsWith('.md')).sort()) {
const text = readFileSync(join(CMD_DIR, f), 'utf8');
for (const m of text.matchAll(CALL)) out.push({ file: f, call: m[0], index: m.index, text });
}
return out;
}
test('every artifact-commit.mjs call in commands/*.md (exactly 3) passes --trailer, and none holds Claude-Session', () => {
const found = calls();
assert.deepEqual(found.map((c) => c.file), ['trekbrief.md', 'trekexecute.md', 'trekplan.md'],
found.map((c) => `${c.file}: ${c.call}`).join('\n'));
for (const c of found) {
assert.match(c.call, /--trailer "Co-Authored-By: Claude \{model\} <noreply@anthropic\.com>"/, `${c.file}: ${c.call}`);
assert.doesNotMatch(c.call, /Claude-Session/, `${c.file}: ${c.call}`);
}
});
test('commands/trekexecute.md says what to do when the approval commit exits 1 or 2', () => {
const c = calls().find((x) => x.file === 'trekexecute.md');
assert.ok(c, 'the inline call in /trekexecute is found');
const after = c.text.slice(c.index, c.index + 800).replace(/\s+/g, ' ');
assert.ok(after.includes('On exit 1 or 2, print the `reason` in one line and continue: the approval is logged but not committed.'),
after);
});

View file

@ -16,6 +16,8 @@ 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);
// Every call passes a valid trailer (the CLI refuses a call without one).
const TRAILER = ['--trailer', 'Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>'];
function git(cwd, ...args) {
return spawnSync('git', args, { cwd, encoding: 'utf8' });
@ -50,7 +52,7 @@ test('store git: the brief and the log land in one commit, and nothing else the
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);
const r = run(['--store', 'git', '--artifact', brief, '--message', 'docs(voyage): approve intent toggle', ...TRAILER], d);
assert.equal(r.status, 0, r.stdout + r.stderr);
assert.equal(JSON.parse(r.stdout).committed, true);
assert.deepEqual(committedFiles(d).sort(),
@ -63,7 +65,7 @@ test('store git: the brief and the log land in one commit, and nothing else the
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);
const r = run(['--store', 'local', '--artifact', brief, '--message', 'docs(voyage): approve intent toggle', ...TRAILER], 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');
@ -71,7 +73,7 @@ test('store local: only the log is committed, and the project folder is kept out
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);
run(['--store', 'local', '--artifact', brief, '--message', 'again', ...TRAILER], 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 }); }
@ -80,8 +82,8 @@ test('store local: only the log is committed, and the project folder is kept out
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(run(['--store', 'git', '--artifact', brief, '--message', 'one', ...TRAILER], d).status, 0);
const r = run(['--store', 'git', '--artifact', brief, '--message', 'two', ...TRAILER], 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 }); }
@ -91,7 +93,7 @@ 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);
const r = run(['--store', 'git', '--artifact', brief, '--message', 'x', ...TRAILER], 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 }); }
@ -101,7 +103,7 @@ 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);
const r = run(['--store', 'git', '--artifact', join(d, 'brief.md'), '--message', 'x', ...TRAILER], d);
assert.equal(r.status, 2, r.stdout + r.stderr);
} finally { rmSync(d, { recursive: true, force: true }); }
});
@ -109,7 +111,7 @@ test('not a git repository: exit 2 (could not run), never a silent pass', () =>
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);
assert.equal(run(['--artifact', brief, '--message', 'x', ...TRAILER], d).status, 2);
assert.equal(run(['--store', 'cloud', '--artifact', brief, '--message', 'x', ...TRAILER], d).status, 2);
} finally { rmSync(d, { recursive: true, force: true }); }
});