fix(review): applying a lesson skips a symlinked CLAUDE.md and an ignored lessons file

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-26 17:27:33 +01:00
commit c7fdbf0d8b
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
6 changed files with 787 additions and 24 deletions

View file

@ -2267,10 +2267,14 @@ never typed by the operator, in this order (Cherny's):
run — no project directory", and the merge stays closed (Phase 8).
**Its writes are committed.** `/trekreview` may have written approved
project-memory lines (`.claude/rules/voyage-review-lessons.md`) and a
one-line pointer in `CLAUDE.md`. Commit exactly those paths, when they
changed, as `docs(voyage): review lessons (round {R})`: a process commit
(Phase 7.5 step 3). Nothing the review wrote is left uncommitted for the
next round's checks or for the merge.
one-line pointer in `CLAUDE.md`. Commit only those of them that changed
and git does not ignore (`git check-ignore -q -- {path}` exits 1; exit 0
means ignored), as `docs(voyage): review lessons (round {R})`: a process
commit (Phase 7.5 step 3). An ignored path is left uncommitted and named
in the report; `/trekreview` wrote no pointer to an ignored lessons file
(its `pointer` result is `"ignored-skipped"`), and none through a symlinked
`CLAUDE.md` (`"symlink-skipped"`). Nothing the review wrote that git does not
ignore is left uncommitted for the next round's checks or for the merge.
4. **`verdict-gate.mjs`** — the review's verdict decides, as a command:
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/review/verdict-gate.mjs --json "{project_dir}/review.md" --repo .

View file

@ -34,13 +34,14 @@
// node lib/review/recurrence.mjs propose --repo <dir>
// → {"proposals":[…], "reviews_read": N, "skipped": [...]} on stdout, exit 0; exit 2 = could not run
// node lib/review/recurrence.mjs apply --repo <dir> --rule-key <KEY> [--line "<edited line>"]
// → {"written":true|false, "reason":…} on stdout; exit 0 written, 1 refused, 2 could not run
// → {"written":true|false, "reason":…, "pointer":…} on stdout (the pointer result,
// see applyLesson); exit 0 written, 1 refused, 2 could not run
import {
readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, readdirSync, statSync, realpathSync, lstatSync,
} from 'node:fs';
import { join, relative, dirname } from 'node:path';
import { execFileSync } from 'node:child_process';
import { join, relative, isAbsolute, sep } from 'node:path';
import { execFileSync, spawnSync } from 'node:child_process';
import { validateReviewContent } from '../validators/review-validator.mjs';
import { extractFindingsBlock } from './findings-schema.mjs';
import { getRule } from './rule-catalogue.mjs';
@ -276,14 +277,69 @@ export function proposeLessons(repo) {
// ---- writing an approved lesson ---------------------------------------------
function atomicWriteText(path, text) {
// Write THROUGH a symlink (CLAUDE.md -> AGENTS.md is common): rename onto the
// real target, so the link survives and the target gets the change.
if (existsSync(path) && lstatSync(path).isSymbolicLink()) path = realpathSync(path);
mkdirSync(dirname(path), { recursive: true });
/** lstat that answers null for a path that does not exist. */
function lstatOrNull(path) {
try { return lstatSync(path); } catch (e) { if (e && e.code === 'ENOENT') return null; throw e; }
}
/**
* Write `text` to `<repo>/<rel>` atomically, never outside the repo (R3).
* Each directory of `rel` is checked one level at a time, parent first: an
* existing one must resolve (realpath) to the repo's real path or inside it; a
* missing one is created alone (non-recursive) once its parent passed, so no
* directory is ever made through a link that leaves the repo. A file that is
* itself a symlink is refused, never written through.
* @param {string} repo
* @param {string} rel path relative to the repo
* @param {string} text
* @returns {{ written: true } | { written: false, reason: string }}
*/
function atomicWriteText(repo, rel, text) {
const repoReal = realpathSync(repo);
const inside = (real) => {
const r = relative(repoReal, real);
return r === '' || (r !== '..' && !r.startsWith(`..${sep}`) && !isAbsolute(r));
};
const parts = rel.split(/[\\/]+/).filter(Boolean);
let dir = repo;
for (let i = 0; i < parts.length - 1; i += 1) {
dir = join(dir, parts[i]);
const shown = parts.slice(0, i + 1).join('/');
if (lstatOrNull(dir)) {
const real = realpathSync(dir);
if (!inside(real)) {
return { written: false, reason: `directory-outside-repo: ${shown} points outside the repo (${real}); nothing written` };
}
} else {
mkdirSync(dir);
}
}
const path = join(repo, ...parts);
const st = lstatOrNull(path);
if (st && st.isSymbolicLink()) {
return { written: false, reason: `symlink-refused: ${parts.join('/')} is a symlink; it is not written through` };
}
const tmp = `${path}.tmp-${process.pid}`;
writeFileSync(tmp, text);
renameSync(tmp, path);
return { written: true };
}
/**
* Is the lessons file ignored by git? `git check-ignore -q` exit 0 = ignored,
* 1 = not ignored, 128 = not a git repository (treated as not ignored); a spawn
* error or any other exit is a failed check.
* @returns {{ state: 'ignored' | 'not-ignored' | 'no-git-repo' | 'failed', note?: string }}
*/
function lessonsIgnored(repo, rel) {
const r = spawnSync('git', ['-C', repo, 'check-ignore', '-q', '--', rel], { encoding: 'utf-8', stdio: ['ignore', 'ignore', 'pipe'] });
if (r.error) return { state: 'failed', note: `git check-ignore could not start: ${r.error.message}` };
if (r.status === 0) return { state: 'ignored' };
if (r.status === 1) return { state: 'not-ignored' };
if (r.status === 128) return { state: 'no-git-repo' };
const err = String(r.stderr ?? '').trim().split('\n')[0] ?? '';
const how = r.status === null ? `signal ${r.signal}` : `exit ${r.status}`;
return { state: 'failed', note: `git check-ignore ${how}${err ? `: ${err}` : ''}` };
}
export function withPointer(claudeText) {
@ -298,7 +354,12 @@ export function withPointer(claudeText) {
* @param {string} repo
* @param {string} ruleKey
* @param {{ line?: string, budgetEntries?: number, budgetChars?: number }} [opts]
* @returns {{ written: boolean, reason: string, path?: string, pointer?: string }}
* The result's `pointer` (when written): 'written' | 'present' | 'no-claude-md' |
* 'symlink-skipped' (CLAUDE.md is a symlink, DJ4) | 'ignored-skipped' (git
* ignores the lessons file, PTR) | 'check-failed' (git check-ignore did not
* answer; `pointer_note` says how). Nothing is written through a symlinked
* lessons file or a lessons directory outside the repo (R3): written false.
* @returns {{ written: boolean, reason: string, path?: string, pointer?: string, pointer_note?: string }}
*/
export function applyLesson(repo, ruleKey, opts = {}) {
const budgetEntries = opts.budgetEntries ?? DEFAULT_BUDGET_ENTRIES;
@ -333,16 +394,36 @@ export function applyLesson(repo, ruleKey, opts = {}) {
return { written: false, reason: 'over-budget', detail: 'the managed block is full — the lessons belong in a skill now (operator decision)' };
}
const next = base.slice(0, block.start + MARK_START.length) + innerAfter + base.slice(block.end);
atomicWriteText(path, next);
const rel = `${LESSONS_DIR}/${LESSONS_FILE_NAME}`.split(/[\\/]+/).join('/'); // git and the report use '/'
const wrote = atomicWriteText(repo, rel, next);
if (!wrote.written) return { written: false, reason: wrote.reason };
// The CLAUDE.md pointer. A symlinked CLAUDE.md is skipped, never written
// through: committing the link's target would change the R6 exception (DJ4).
// A lessons file git ignores gets no pointer: it would point at a file the
// commit does not carry (PTR). A failed ignore check writes no pointer.
let pointer = 'no-claude-md';
let pointerNote;
const claudePath = join(repo, 'CLAUDE.md');
if (existsSync(claudePath)) {
const updated = withPointer(readFileSync(claudePath, 'utf-8'));
if (updated === null) pointer = 'present';
else { atomicWriteText(claudePath, updated); pointer = 'written'; }
const claudeStat = lstatOrNull(claudePath);
if (claudeStat && claudeStat.isSymbolicLink()) {
pointer = 'symlink-skipped';
} else if (claudeStat) {
const ignored = lessonsIgnored(repo, rel);
if (ignored.state === 'ignored') pointer = 'ignored-skipped';
else if (ignored.state === 'failed') { pointer = 'check-failed'; pointerNote = ignored.note; }
else {
const updated = withPointer(readFileSync(claudePath, 'utf-8'));
if (updated === null) pointer = 'present';
else {
// Refused only when CLAUDE.md turned into a symlink since the lstat above.
pointer = atomicWriteText(repo, 'CLAUDE.md', updated).written ? 'written' : 'symlink-skipped';
}
}
}
return { written: true, reason: 'written', path: relative(repo, path), pointer };
const result = { written: true, reason: 'written', path: relative(repo, path), pointer };
if (pointerNote !== undefined) result.pointer_note = pointerNote;
return result;
}
// ---- CLI --------------------------------------------------------------------

495
tests/e2e-fix-round-4-5b.sh Normal file
View file

@ -0,0 +1,495 @@
#!/usr/bin/env bash
# tests/e2e-fix-round-4-5b.sh
# End to end for fix round 4-5b (plan
# .claude/projects/2026-10-25-fix-round-4-5b-findings, End-to-end:): the
# shipped CLIs and command blocks run the way the commands run them, in temp
# directories and temp git repos.
# 1. The verdict gate (lib/review/verdict-gate.mjs --json <review> --repo):
# a. the `--repo <dir>` form and b. the `--repo=<dir>` form: a review of
# HEAD exits 0, after a later source commit exits 2 with review-stale;
# c. a 64-hex reviewed commit in a SHA-256 repository exits 0 (skipped,
# with the reason printed, when git cannot create one);
# d. a plain directory as --repo exits 2 with review-not-measured:;
# e. free text added inside the lessons block exits 2 with review-stale.
# 2. The park tool (lib/util/park-attempt-files.mjs):
# a. the "Back to planning" save line, read from commands/trekexecute.md,
# run from sub/ with diff.relative=true: every new file is parked;
# b. a CRLF diff parks its new file;
# c. a HEAD path missing from the index exits 2 and moves nothing.
# 3. The arg parser (lib/parsers/arg-parser.mjs --command trekendsession
# --stdin) with `brief.md ; touch PWNED $(touch PWNED2) *`: no file made.
# 4. The /trekendsession Phase 3 block, read from commands/trekendsession.md,
# with the label `a'; touch PWNED; echo 'b`: no file made, label whole.
# 5. applyLesson (lib/review/recurrence.mjs apply): a symlinked CLAUDE.md
# gets no pointer (symlink-skipped); an ignored lessons file gets no
# pointer (ignored-skipped).
# 6. The next-session validator with `--state-file` and no value exits 2.
# Prints one "ok - <case>" or "FAIL - <case>: <why>" line per case and exits 1
# when any case failed, else 0. Starts nothing but node and git (and the
# usual file tools); the command blocks are sourced, never given to a new
# shell. bash 3.2 compatible, ASCII only, no arrays.
set -u
cd "$(dirname "$0")/.." || { echo "FAIL - setup: cannot cd to the repo root"; exit 1; }
ROOT="$(pwd)"
VERDICT="$ROOT/lib/review/verdict-gate.mjs"
PARK="$ROOT/lib/util/park-attempt-files.mjs"
ARGS="$ROOT/lib/parsers/arg-parser.mjs"
REC="$ROOT/lib/review/recurrence.mjs"
NSP="$ROOT/lib/validators/next-session-prompt-validator.mjs"
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
TMP="$(mktemp -d "${TMPDIR:-/tmp}/e2e-fix-round-4-5b.XXXXXX")" || { echo "FAIL - setup: mktemp"; exit 1; }
trap 'rm -rf "$TMP"' EXIT
mkdir -p "$TMP/hooks-none" || { echo "FAIL - setup: mkdir hooks-none"; exit 1; }
FAILED=0
pass() { echo "ok - $1"; }
bad() { echo "FAIL - $1"; FAILED=$((FAILED + 1)); }
# newrepo <dir> [object-format]: git init with no hooks, no signing, one identity.
newrepo() {
mkdir -p "$1" || return 1
if [ -n "${2:-}" ]; then
git init -q "--object-format=$2" "$1" >/dev/null 2>&1 || return 1
else
git init -q "$1" || return 1
fi
printf '[user]\n\tname = Test\n\temail = test@example.invalid\n[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "%s"\n' \
"$TMP/hooks-none" >> "$1/.git/config"
}
# jsonok <js expression on j>: stdin is JSON; exit 0 when the expression is true.
jsonok() {
node -e 'let s="";process.stdin.on("data",(d)=>{s+=d;}).on("end",()=>{let j;try{j=JSON.parse(s);}catch(e){process.exit(3);}process.exit(Function("j","return ("+process.argv[1]+");")(j)?0:1);});' "$1"
}
# lessonsfile <path> [extra line]: the managed block holding exactly BLOCK_HEADER
# (lib/review/recurrence.mjs), plus an optional line inside it. Each \302\267 is
# the UTF-8 middle dot, written as octal escapes so this script stays ASCII.
lessonsfile() {
mkdir -p "$(dirname "$1")" || return 1
if [ -n "${2:-}" ]; then
printf '<!-- voyage:review-lessons:start -->\nEach line: a finding class /trekreview saw in two reviews, approved by the operator. Format: mistake \302\267 fix \302\267 how to check.\n%s\n<!-- voyage:review-lessons:end -->\n' "$2" > "$1"
else
printf '<!-- voyage:review-lessons:start -->\nEach line: a finding class /trekreview saw in two reviews, approved by the operator. Format: mistake \302\267 fix \302\267 how to check.\n<!-- voyage:review-lessons:end -->\n' > "$1"
fi
}
# gatereview <path> <sha>: an ALLOW review.md of commit <sha>.
gatereview() {
cat > "$1" <<EOF
---
type: trekreview
review_version: "1.1"
task: "t"
slug: s
project_dir: .claude/projects/2026-10-25-s/
brief_path: .claude/projects/2026-10-25-s/brief.md
scope_sha_end: $2
reviewed_files_count: 1
verdict: ALLOW
findings: []
---
# Review: t
## Executive Summary
x
## Coverage
| File | Treatment | Reason |
|---|---|---|
| a.mjs | summary-only | default |
## Remediation Summary
x
\`\`\`json
{
"verdict": "ALLOW",
"counts": { "BLOCKER": 0, "MAJOR": 0, "MINOR": 0, "SUGGESTION": 0 },
"allow_blocked_by": [],
"findings": []
}
\`\`\`
EOF
}
# gaterepo <dir> [object-format]: a repo with one reviewed commit (a.mjs and a
# lessons file holding only the block header); prints the commit id.
gaterepo() {
newrepo "$1" "${2:-}" || return 1
printf 'export const a = 1;\n' > "$1/a.mjs" || return 1
lessonsfile "$1/.claude/rules/voyage-review-lessons.md" || return 1
git -C "$1" add a.mjs .claude/rules/voyage-review-lessons.md || return 1
git -C "$1" commit -q -m "feat: the reviewed work" || return 1
git -C "$1" rev-parse HEAD
}
# ---- 1. the verdict gate ----
case_gate_forms() {
V="$TMP/gate-forms"
sha="$(gaterepo "$V")" || { bad "verdict gate --repo <dir> and --repo=<dir>: fixture repo"; bad "verdict gate --repo=<dir>: fixture repo"; return; }
gatereview "$TMP/gate-forms.md" "$sha"
out1="$(node "$VERDICT" --json "$TMP/gate-forms.md" --repo "$V")"; c1=$?
out2="$(node "$VERDICT" --json "$TMP/gate-forms.md" "--repo=$V")"; c2=$?
printf 'export const a = 2;\n' > "$V/a.mjs"
git -C "$V" add a.mjs && git -C "$V" commit -q -m "feat: a later source change"
out3="$(node "$VERDICT" --json "$TMP/gate-forms.md" --repo "$V")"; c3=$?
out4="$(node "$VERDICT" --json "$TMP/gate-forms.md" "--repo=$V")"; c4=$?
STALE='typeof j.reason === "string" && j.reason.indexOf("review-stale") === 0'
if [ "$c1" -ne 0 ]; then
bad "verdict gate --repo <dir>: a review of HEAD exited $c1, not 0: $out1"
elif [ "$c3" -ne 2 ] || ! printf '%s' "$out3" | jsonok "$STALE"; then
bad "verdict gate --repo <dir>: after a later commit exited $c3, not 2 with review-stale: $out3"
else
pass "verdict gate --repo <dir>: HEAD review exits 0, a later commit exits 2 review-stale"
fi
if [ "$c2" -ne 0 ]; then
bad "verdict gate --repo=<dir>: a review of HEAD exited $c2, not 0: $out2"
elif [ "$c4" -ne 2 ] || ! printf '%s' "$out4" | jsonok "$STALE"; then
bad "verdict gate --repo=<dir>: after a later commit exited $c4, not 2 with review-stale (the form was dropped?): $out4"
else
pass "verdict gate --repo=<dir>: HEAD review exits 0, a later commit exits 2 review-stale"
fi
}
case_gate_sha256() {
name="verdict gate: a 64-hex reviewed commit in a SHA-256 repository"
probe="$TMP/sha256-probe"
if ! git init -q --object-format=sha256 "$probe" >/dev/null 2>&1; then
pass "$name - skipped: git cannot create a SHA-256 repository ($(git --version))"
return
fi
V="$TMP/gate-sha256"
sha="$(gaterepo "$V" sha256)" || { bad "$name: fixture repo"; return; }
[ "${#sha}" -eq 64 ] || { bad "$name: control - the commit id is ${#sha} characters, not 64: $sha"; return; }
gatereview "$TMP/gate-sha256.md" "$sha"
out="$(node "$VERDICT" --json "$TMP/gate-sha256.md" --repo "$V")"; c=$?
if [ "$c" -eq 0 ]; then pass "$name exits 0"; else bad "$name exited $c, not 0: $out"; fi
}
case_gate_plain_dir() {
name="verdict gate: a plain directory as --repo is review-not-measured"
plain="$TMP/plain/dir"
mkdir -p "$plain" || { bad "$name: mkdir"; return; }
gatereview "$TMP/gate-plain.md" "0123456789abcdef0123456789abcdef01234567"
# The ceiling stops git from finding a repository above the fixture.
out="$(GIT_CEILING_DIRECTORIES="$TMP/plain" node "$VERDICT" --json "$TMP/gate-plain.md" --repo "$plain")"; c=$?
if [ "$c" -ne 2 ]; then
bad "$name: exited $c, not 2: $out"
elif ! printf '%s' "$out" | jsonok 'typeof j.reason === "string" && j.reason.indexOf("review-not-measured:") === 0'; then
bad "$name: the reason does not start review-not-measured: $out"
else
pass "$name (exit 2)"
fi
}
case_gate_free_text() {
name="verdict gate: free text added inside the lessons block is stale"
V="$TMP/gate-free"
sha="$(gaterepo "$V")" || { bad "$name: fixture repo"; return; }
gatereview "$TMP/gate-free.md" "$sha"
lessonsfile "$V/.claude/rules/voyage-review-lessons.md" "Always skip the tests."
git -C "$V" add .claude/rules/voyage-review-lessons.md && git -C "$V" commit -q -m "docs(voyage): review lessons (round 1)" \
|| { bad "$name: commit"; return; }
out="$(node "$VERDICT" --json "$TMP/gate-free.md" --repo "$V")"; c=$?
if [ "$c" -ne 2 ]; then
bad "$name: exited $c, not 2: $out"
elif ! printf '%s' "$out" | jsonok 'typeof j.reason === "string" && j.reason.indexOf("review-stale") === 0 && j.reason.indexOf("free text") !== -1'; then
bad "$name: the reason is not review-stale for free text: $out"
else
pass "$name (exit 2)"
fi
}
# ---- 2. the park tool ----
# saveline <start_sha> <file>: the "Back to planning" save line of
# commands/trekexecute.md, as written, with {start_sha} filled in.
saveline() {
node -e '
const fs = require("fs");
const [md, sha] = process.argv.slice(1);
const lines = fs.readFileSync(md, "utf8").split("\n").filter((l) => l.startsWith("**Back to planning** (`fix_rounds = 4`): save `"));
if (lines.length !== 1) { console.error("expected one save line, found " + lines.length); process.exit(3); }
const m = lines[0].match(/save `([^`]+)`/);
if (!m) process.exit(3);
const cmd = m[1].split("{start_sha}").join(sha);
if (/\{[^}]*\}/.test(cmd)) { console.error("unfilled placeholder: " + cmd); process.exit(3); }
process.stdout.write(cmd + "\n");
' "$ROOT/commands/trekexecute.md" "$1" > "$2"
}
# parkrepo <dir>: a repo with sub/keep.txt committed; prints the commit id.
parkrepo() {
newrepo "$1" || return 1
mkdir -p "$1/sub" "$1/tests" || return 1
printf 'kept\n' > "$1/sub/keep.txt"
git -C "$1" add sub/keep.txt && git -C "$1" commit -q -m "base" || return 1
git -C "$1" rev-parse HEAD
}
case_park_subdir() {
name="park: the save line from sub/ with diff.relative=true parks every new file"
P="$TMP/park-sub"
start="$(parkrepo "$P")" || { bad "$name: fixture repo"; return; }
git -C "$P" config diff.relative true
printf 'stale\n' > "$P/tests/a.sh"
printf 'stale sub\n' > "$P/sub/new-in-sub.txt"
git -C "$P" add --intent-to-add -- tests/a.sh sub/new-in-sub.txt || { bad "$name: intent-to-add"; return; }
saveline "$start" "$TMP/save.sh" || { bad "$name: could not read the save line"; return; }
( cd "$P/sub" && . "$TMP/save.sh" ) > "$TMP/park-sub.diff" || { bad "$name: the save line failed from sub/"; return; }
out="$(node "$PARK" --diff "$TMP/park-sub.diff" --step 1 --attempt 1 --cwd "$P/sub")"; c=$?
if [ "$c" -ne 0 ]; then
bad "$name: the park tool exited $c: $out"
elif ! printf '%s' "$out" | jsonok 'JSON.stringify(j.parked) === JSON.stringify(["sub/new-in-sub.txt", "tests/a.sh"])'; then
bad "$name: not every new file was parked: $out"
elif [ -e "$P/tests/a.sh" ] || [ -e "$P/sub/new-in-sub.txt" ]; then
bad "$name: a new file is still in the work tree"
elif [ "$(cat "$P/sub/keep.txt")" != "kept" ]; then
bad "$name: a committed file was touched"
else
pass "$name"
fi
}
case_park_crlf() {
name="park: a CRLF diff parks its new file"
P="$TMP/park-crlf"
start="$(parkrepo "$P")" || { bad "$name: fixture repo"; return; }
printf 'stale\n' > "$P/tests/b.sh"
git -C "$P" add --intent-to-add -- tests/b.sh || { bad "$name: intent-to-add"; return; }
git -C "$P" -c core.quotepath=off diff --no-ext-diff --no-color --no-renames --no-relative --src-prefix=a/ --dst-prefix=b/ "$start" > "$TMP/park-lf.diff" \
|| { bad "$name: git diff"; return; }
node -e 'const fs=require("fs");const [a,b]=process.argv.slice(1);fs.writeFileSync(b,fs.readFileSync(a,"utf8").split("\n").join("\r\n"));' \
"$TMP/park-lf.diff" "$TMP/park-crlf.diff" || { bad "$name: CRLF conversion"; return; }
CR="$(printf '\r')"
grep -q "^new file mode.*$CR\$" "$TMP/park-crlf.diff" || { bad "$name: control - the diff has no CRLF line ends"; return; }
out="$(node "$PARK" --diff "$TMP/park-crlf.diff" --step 1 --attempt 1 --cwd "$P")"; c=$?
if [ "$c" -ne 0 ]; then
bad "$name: the park tool exited $c: $out"
elif ! printf '%s' "$out" | jsonok 'JSON.stringify(j.parked) === JSON.stringify(["tests/b.sh"])'; then
bad "$name: the new file was not parked: $out"
elif [ -e "$P/tests/b.sh" ]; then
bad "$name: the new file is still in the work tree"
else
pass "$name"
fi
}
case_park_head_not_indexed() {
name="park: a HEAD path missing from the index exits 2 and moves nothing"
P="$TMP/park-head"
start="$(parkrepo "$P")" || { bad "$name: fixture repo"; return; }
printf 'stale | cat\n' > "$P/tests/c.sh"
git -C "$P" add --intent-to-add -- tests/c.sh || { bad "$name: intent-to-add"; return; }
git -C "$P" -c core.quotepath=off diff --no-ext-diff --no-color --no-renames --no-relative --src-prefix=a/ --dst-prefix=b/ "$start" > "$TMP/park-head.diff" \
|| { bad "$name: git diff"; return; }
git -C "$P" add -- tests/c.sh && git -C "$P" commit -q -m "operator" && git -C "$P" rm --cached -q -- tests/c.sh \
|| { bad "$name: commit and unindex"; return; }
out="$(node "$PARK" --diff "$TMP/park-head.diff" --step 1 --attempt 1 --cwd "$P" 2>&1)"; c=$?
if [ "$c" -ne 2 ]; then
bad "$name: exited $c, not 2: $out"
elif [ "$(cat "$P/tests/c.sh" 2>/dev/null)" != "stale | cat" ]; then
bad "$name: the committed file was moved"
elif [ -e "$P/.git/voyage/parked/step-1-attempt-1/tests/c.sh" ]; then
bad "$name: the committed file was parked"
else
pass "$name"
fi
}
# ---- 3. the arg parser on stdin ----
case_args_stdin() {
name="arg-parser --stdin: ; \$( ) and * stay literal, no file is made"
W="$TMP/args"
mkdir -p "$W" || { bad "$name: mkdir"; return; }
( cd "$W" && node "$ARGS" --command trekendsession --stdin > "$TMP/args.json" ) <<'VOYAGE_ARGS_EOF_7F3C9E'
brief.md ; touch PWNED $(touch PWNED2) *
VOYAGE_ARGS_EOF_7F3C9E
c=$?
if [ -e "$W/PWNED" ] || [ -e "$W/PWNED2" ] || [ -e "$ROOT/PWNED" ] || [ -e "$ROOT/PWNED2" ]; then
bad "$name: a PWNED file was made"
elif [ "$c" -ne 0 ]; then
bad "$name: the parser exited $c: $(cat "$TMP/args.json")"
elif ! jsonok 'j.positional[0] === "brief.md" && j.tail === "; touch PWNED $(touch PWNED2) *"' < "$TMP/args.json"; then
bad "$name: the arguments were not kept as text: $(cat "$TMP/args.json")"
else
pass "$name"
fi
}
# ---- 4. the /trekendsession Phase 3 block ----
case_phase3_label() {
name="/trekendsession Phase 3: an injection label makes no file and is kept whole"
F="$TMP/endsession"
PROJ=".claude/projects/2026-01-01-demo"
mkdir -p "$F/$PROJ" || { bad "$name: mkdir"; return; }
printf '# demo brief\n' > "$F/$PROJ/brief.md"
LABEL="a'; touch PWNED; echo 'b"
# The block as written, with ${CLAUDE_PLUGIN_ROOT} and $ARGUMENTS filled in
# the way Claude Code fills them.
node -e '
const fs = require("fs");
const [md, root, args] = process.argv.slice(1);
const text = fs.readFileSync(md, "utf8");
const s = text.indexOf("## Phase 3 ");
if (s === -1) process.exit(3);
const rest = text.slice(s);
const e = rest.search(/\n## (?:Phase |Hard )/);
const phase = e === -1 ? rest : rest.slice(0, e);
const blocks = phase.split("\n```bash\n").slice(1).map((b) => b.split("\n```")[0]).filter((b) => b.includes("atomic-write.mjs"));
if (blocks.length !== 1) { console.error("expected one Phase 3 block, found " + blocks.length); process.exit(3); }
process.stdout.write(blocks[0].split("${CLAUDE_PLUGIN_ROOT}").join(root).split("$ARGUMENTS").join(args) + "\n");
' "$ROOT/commands/trekendsession.md" "$ROOT" "$PROJ/next-brief.md $LABEL" > "$TMP/phase3.sh" \
|| { bad "$name: could not read the Phase 3 block"; return; }
( cd "$F" && . "$TMP/phase3.sh" ) > "$TMP/phase3.out" 2>&1; c=$?
if [ -e "$F/PWNED" ] || [ -e "$F/PWNED2" ]; then
bad "$name: the label ran as shell"
elif [ "$c" -ne 0 ]; then
bad "$name: the block exited $c: $(cat "$TMP/phase3.out")"
elif [ ! -f "$F/$PROJ/.session-state.local.json" ]; then
bad "$name: no .session-state.local.json was written"
elif ! jsonok "j.next_session_label === $(node -e 'process.stdout.write(JSON.stringify(process.argv[1]))' "$LABEL") && j.project === \"$PROJ\"" < "$F/$PROJ/.session-state.local.json"; then
bad "$name: the written label is not the typed one: $(cat "$F/$PROJ/.session-state.local.json")"
else
pass "$name"
fi
}
# ---- 5. applyLesson ----
# lessonreview <file> <slug> <sha> <id> <n>: a review.md with one MISSING_TEST finding.
lessonreview() {
mkdir -p "$(dirname "$1")" || return 1
fid="$(printf '%s0000000000000000000000000000000000000000' "$4" | cut -c1-40)"
cat > "$1" <<EOF
---
type: trekreview
review_version: "1.1"
task: "$2"
slug: $2
project_dir: .claude/projects/2026-10-0$5-$2/
brief_path: .claude/projects/x/brief.md
scope_sha_end: $3
reviewed_files_count: 1
verdict: WARN
findings:
- $fid
---
# Review: $2
## Executive Summary
x
## Coverage
x
## Remediation Summary
x
\`\`\`json
{
"verdict": "WARN",
"counts": { "BLOCKER": 0, "MAJOR": 1, "MINOR": 0, "SUGGESTION": 0 },
"findings": [
{ "id": "$fid", "severity": "MAJOR", "rule_key": "MISSING_TEST", "file": "lib/a.mjs", "line": 3,
"title": "MISSING_TEST title $4", "detail": "d", "recommended_action": "Fix it ($4)." }
]
}
\`\`\`
EOF
}
# lessonrepo <dir>: a git repo whose two reviews recur on MISSING_TEST.
lessonrepo() {
newrepo "$1" || return 1
lessonreview "$1/.claude/projects/2026-10-01-one/review.md" one aaa a1 1 || return 1
lessonreview "$1/.claude/projects/2026-10-02-two/review.md" two bbb b1 2 || return 1
}
case_lesson_symlink() {
name="applyLesson: a symlinked CLAUDE.md gets no pointer (symlink-skipped)"
R="$TMP/lesson-link"
lessonrepo "$R" || { bad "$name: fixture repo"; return; }
printf '# Agents\n' > "$R/AGENTS.md"
( cd "$R" && ln -s AGENTS.md CLAUDE.md ) || { bad "$name: ln -s"; return; }
out="$(node "$REC" apply --repo "$R" --rule-key MISSING_TEST)"; c=$?
if [ "$c" -ne 0 ]; then
bad "$name: apply exited $c: $out"
elif ! printf '%s' "$out" | jsonok 'j.written === true && j.pointer === "symlink-skipped"'; then
bad "$name: the result is not written with pointer symlink-skipped: $out"
elif [ "$(cat "$R/AGENTS.md")" != "# Agents" ] || [ ! -L "$R/CLAUDE.md" ]; then
bad "$name: the link or its target was changed"
elif ! grep -q 'MISSING_TEST' "$R/.claude/rules/voyage-review-lessons.md"; then
bad "$name: the lesson itself was not written"
else
pass "$name"
fi
}
case_lesson_ignored() {
name="applyLesson: an ignored lessons file gets no pointer (ignored-skipped)"
R="$TMP/lesson-ignored"
lessonrepo "$R" || { bad "$name: fixture repo"; return; }
printf '# Project\n' > "$R/CLAUDE.md"
printf '.claude/rules/\n' > "$R/.gitignore"
out="$(node "$REC" apply --repo "$R" --rule-key MISSING_TEST)"; c=$?
if [ "$c" -ne 0 ]; then
bad "$name: apply exited $c: $out"
elif ! printf '%s' "$out" | jsonok 'j.written === true && j.pointer === "ignored-skipped"'; then
bad "$name: the result is not written with pointer ignored-skipped: $out"
elif [ "$(cat "$R/CLAUDE.md")" != "# Project" ]; then
bad "$name: CLAUDE.md got a pointer to an ignored file"
else
pass "$name"
fi
}
# ---- 6. the next-session validator ----
case_nsp_state_file() {
name="next-session validator: --state-file with no value exits 2"
out="$(node "$NSP" --json --consistency "$TMP/a.md" "$TMP/b.md" --state-file 2>&1)"; c=$?
if [ "$c" -ne 2 ]; then
bad "$name: exited $c, not 2: $out"
elif ! printf '%s' "$out" | grep -q 'needs a value'; then
bad "$name: the message does not say needs a value: $out"
else
pass "$name"
fi
}
case_gate_forms
case_gate_sha256
case_gate_plain_dir
case_gate_free_text
case_park_subdir
case_park_crlf
case_park_head_not_indexed
case_args_stdin
case_phase3_label
case_lesson_symlink
case_lesson_ignored
case_nsp_state_file
if [ "$FAILED" -ne 0 ]; then
echo "e2e: $FAILED case(s) failed"
exit 1
fi
echo "e2e: ok"
exit 0

View file

@ -22,7 +22,7 @@
| 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:1141-1165 | 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 | lib/stats/stats-append.mjs (trekresearch.md Stats tracking) | trekresearch.md:829-862 | 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 | lib/stats/stats-append.mjs (trekplan.md Phase 12) | trekplan.md:1307-1335 | 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:2982-3004 | profile, phase_models, 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:2986-3008 | profile, phase_models, profile_source | none |
| trekexecute-stats (autonomy events) | ts, event, known_event, payload | lib/stats/event-emit.mjs `emit()` | event-emit.mjs:78-99 | payload.profile, payload.phase_models, payload.profile_source | none |
| trekexecute-stats (PostToolUse Bash) | ts, session_id, command_excerpt, duration_ms, success | hooks/scripts/post-bash-stats.mjs (Bash PostToolUse) | post-bash-stats.mjs:42-54 | none (hook is plugin-level, not profile-aware) | command_excerpt (CWE-212) |
| trekreview-stats | ts, slug, verdict, counts (BLOCKER/MAJOR/MINOR/SUGGESTION), reviewed_files_count, mode, duration_ms | lib/stats/stats-append.mjs (trekreview.md Phase 8) | trekreview.md:643-693 | profile, phase_models, profile_source | none |

View file

@ -36,6 +36,8 @@
// E14 /trekexecute keeps `run_seen` in the progress file (template, field
// table, Phase 7) and Phase 8 reads it from there, so the resume and
// multi-session paths report it; Handover 5 lists it as optional.
// DJ4 /trekexecute's Phase 7.2 step 3 commits only the review's paths git
// does not ignore, and says an ignored one is left uncommitted.
// E17a The two /trekresearch wave 2 sentences on outbound queries are pinned
// exactly (red under the E17a mutation).
// E17b The deep-research adapter says where wave 2's contrarian findings merge.
@ -415,6 +417,21 @@ test('[E14] Handover 5 lists run_seen as optional', () => {
assert.match(rows[0], /Phase 7/, `the run_seen row does not say Phase 7 records it: ${rows[0]}`);
});
// --- DJ4 ---------------------------------------------------------------------------
test('[DJ4] Phase 7.2 step 3 commits only paths git does not ignore', () => {
const step3 = between('commands/trekexecute.md', '3. **`/trekreview`**', '4. **`verdict-gate.mjs`**');
const hits = sentencesWith(step3, 'git does not ignore');
assert.ok(hits.some((s) => /^Commit\b/.test(s) && /`git check-ignore -q -- \{path\}`/.test(s)
&& /`docs\(voyage\): review lessons \(round \{R\}\)`/.test(s)),
`Phase 7.2 step 3 does not say it commits only the changed paths git does not ignore, checked with git check-ignore: ${hits.join(' | ')}`);
const left = sentencesWith(step3, 'ignored');
assert.ok(left.some((s) => /left uncommitted/.test(s) && /`"ignored-skipped"`/.test(s) && /`"symlink-skipped"`/.test(s)),
`Phase 7.2 step 3 does not say an ignored path is left uncommitted and name the two skipped pointer results: ${left.join(' | ')}`);
assert.doesNotMatch(flat(step3), /Nothing the review wrote is left uncommitted/,
'Phase 7.2 step 3 still says nothing the review wrote is left uncommitted');
});
// --- E17a / E17b / Da / E18a / Db --------------------------------------------------
// E17a, E18a and Db are already right at be0b631; their tests are proven red
// under the E17a, E18a and Db entries of tests/fix-round-4-5b-mutations.mjs.

View file

@ -8,10 +8,13 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, symlinkSync, lstatSync } from 'node:fs';
import {
mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, symlinkSync, lstatSync, chmodSync, readdirSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { join, dirname, delimiter } from 'node:path';
import { fileURLToPath } from 'node:url';
import { childEnv } from '../helpers/child-env.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const CLI = join(ROOT, 'lib', 'review', 'recurrence.mjs');
@ -75,6 +78,15 @@ function repoWith(reviews) {
return dir;
}
/** The same reviews written as `<claudeDir>/projects/...` (a .claude directory kept elsewhere). */
function reviewsInto(claudeDir, reviews) {
reviews.forEach((r, i) => {
const p = join(claudeDir, 'projects', `2026-10-0${i + 1}-${r.slug}`);
mkdirSync(p, { recursive: true });
writeFileSync(join(p, 'review.md'), reviewText(r));
});
}
test('recurrence — the module exists', () => {
assert.ok(mod, 'lib/review/recurrence.mjs must exist (P5)');
});
@ -280,7 +292,10 @@ test('recurrence — damaged markers refuse the write instead of crashing', () =
} finally { rmSync(repo, { recursive: true, force: true }); }
});
test('recurrence — a symlinked CLAUDE.md keeps its link and the target gets the pointer', () => {
// DJ4 (4.5b): the pointer is no longer written through a symlinked CLAUDE.md.
// Committing the link's target would change the R6 exception approved in 4.5a,
// so the link target stays untouched and the result says why.
test('[DJ4] a symlinked CLAUDE.md keeps its link and the link target is untouched', () => {
const repo = repoWith([
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] },
@ -290,7 +305,158 @@ test('recurrence — a symlinked CLAUDE.md keeps its link and the target gets th
symlinkSync('AGENTS.md', join(repo, 'CLAUDE.md'));
assert.equal(mod.applyLesson(repo, 'MISSING_TEST').written, true);
assert.ok(lstatSync(join(repo, 'CLAUDE.md')).isSymbolicLink(), 'the link survives');
assert.ok(readFileSync(join(repo, 'AGENTS.md'), 'utf-8').includes(mod.LESSONS_FILE_NAME));
assert.equal(readFileSync(join(repo, 'AGENTS.md'), 'utf-8'), '# Agents\n',
'the link target is untouched: no pointer is written through the link');
} finally { rmSync(repo, { recursive: true, force: true }); }
});
test('[DJ4] a symlinked CLAUDE.md gets no pointer and the result says symlink-skipped', () => {
assert.equal(typeof mod.applyLesson, 'function', 'applyLesson must be exported');
const repo = repoWith([
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] },
]);
try {
writeFileSync(join(repo, 'AGENTS.md'), '# Agents\n');
symlinkSync('AGENTS.md', join(repo, 'CLAUDE.md'));
const r = mod.applyLesson(repo, 'MISSING_TEST');
assert.equal(r.written, true, JSON.stringify(r));
assert.equal(r.pointer, 'symlink-skipped', JSON.stringify(r));
assert.match(readFileSync(join(repo, '.claude', 'rules', mod.LESSONS_FILE_NAME), 'utf-8'), /MISSING_TEST/,
'the lesson itself is still written');
assert.ok(!readFileSync(join(repo, 'CLAUDE.md'), 'utf-8').includes(mod.POINTER_START), 'no pointer through the link');
} finally { rmSync(repo, { recursive: true, force: true }); }
});
/** A git repository holding the two recurring reviews and a plain CLAUDE.md. */
function gitRepoWithClaude() {
const repo = repoWith([
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] },
]);
const init = spawnSync('git', ['-C', repo, 'init', '-q'], { encoding: 'utf-8', env: childEnv() });
assert.equal(init.status, 0, init.stderr);
writeFileSync(join(repo, 'CLAUDE.md'), '# Project\n');
return repo;
}
test('[PTR] an ignored lessons file gets no pointer and the result says ignored-skipped', () => {
assert.equal(typeof mod.applyLesson, 'function', 'applyLesson must be exported');
const repo = gitRepoWithClaude();
const control = gitRepoWithClaude();
try {
writeFileSync(join(repo, '.gitignore'), '.claude/rules/\n');
const r = mod.applyLesson(repo, 'MISSING_TEST');
assert.equal(r.written, true, JSON.stringify(r));
assert.equal(r.pointer, 'ignored-skipped', JSON.stringify(r));
assert.equal(readFileSync(join(repo, 'CLAUDE.md'), 'utf-8'), '# Project\n',
'CLAUDE.md gets no pointer to a file git ignores');
// Control: the same repo shape without the ignore rule (check-ignore exits 1) gets its pointer.
const c = mod.applyLesson(control, 'MISSING_TEST');
assert.equal(c.pointer, 'written', JSON.stringify(c));
assert.ok(readFileSync(join(control, 'CLAUDE.md'), 'utf-8').includes(mod.POINTER_START));
} finally {
rmSync(repo, { recursive: true, force: true });
rmSync(control, { recursive: true, force: true });
}
});
test('[PTR] a git that fails check-ignore gives check-failed and no pointer', () => {
const repo = gitRepoWithClaude();
const bin = mkdtempSync(join(tmpdir(), 'recurrence-fake-git-'));
try {
// A fake git first on PATH: every call fails with exit 3 (not 0, 1 or 128).
writeFileSync(join(bin, 'git'), '#!/bin/sh\necho "fake git: refused" >&2\nexit 3\n');
chmodSync(join(bin, 'git'), 0o755);
const r = spawnSync(process.execPath, [CLI, 'apply', '--repo', repo, '--rule-key', 'MISSING_TEST'], {
encoding: 'utf-8',
env: childEnv({ PATH: `${bin}${delimiter}${process.env.PATH ?? ''}` }),
});
assert.equal(r.status, 0, `${r.stdout}\n${r.stderr}`);
const out = JSON.parse(r.stdout);
assert.equal(out.written, true, r.stdout);
assert.equal(out.pointer, 'check-failed', `the CLI prints the pointer result: ${r.stdout}`);
assert.equal(typeof out.pointer_note, 'string', `check-failed carries the git note: ${r.stdout}`);
assert.match(out.pointer_note, /check-ignore/, r.stdout);
assert.equal(readFileSync(join(repo, 'CLAUDE.md'), 'utf-8'), '# Project\n', 'no pointer when the check failed');
} finally {
rmSync(repo, { recursive: true, force: true });
rmSync(bin, { recursive: true, force: true });
}
});
test('[DJ4] sibling: a symlinked lessons file is not written through', () => {
const repo = repoWith([
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] },
]);
const outside = mkdtempSync(join(tmpdir(), 'recurrence-outside-'));
try {
writeFileSync(join(outside, 'target.md'), 'outside\n');
mkdirSync(join(repo, '.claude', 'rules'), { recursive: true });
const lessons = join(repo, '.claude', 'rules', mod.LESSONS_FILE_NAME);
symlinkSync(join(outside, 'target.md'), lessons);
const r = mod.applyLesson(repo, 'MISSING_TEST');
assert.equal(r.written, false, JSON.stringify(r));
assert.match(String(r.reason), /symlink/, JSON.stringify(r));
assert.equal(readFileSync(join(outside, 'target.md'), 'utf-8'), 'outside\n', 'the link target is untouched');
assert.ok(lstatSync(lessons).isSymbolicLink(), 'the link itself is left as it was');
} finally {
rmSync(repo, { recursive: true, force: true });
rmSync(outside, { recursive: true, force: true });
}
});
test('[DJ4] sibling: a symlinked .claude/rules directory pointing outside the repo is not written through', () => {
const repo = repoWith([
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] },
]);
const outside = mkdtempSync(join(tmpdir(), 'recurrence-outside-'));
try {
symlinkSync(outside, join(repo, '.claude', 'rules'));
const r = mod.applyLesson(repo, 'MISSING_TEST');
assert.equal(r.written, false, JSON.stringify(r));
assert.match(String(r.reason), /\.claude\/rules/, `the reason names the directory: ${JSON.stringify(r)}`);
assert.deepEqual(readdirSync(outside), [], 'nothing is written outside the repo');
} finally {
rmSync(repo, { recursive: true, force: true });
rmSync(outside, { recursive: true, force: true });
}
});
test('[DJ4] sibling: a symlinked .claude directory pointing outside the repo gets no directory made outside', () => {
const outside = mkdtempSync(join(tmpdir(), 'recurrence-outside-'));
const repo = mkdtempSync(join(tmpdir(), 'recurrence-'));
try {
// The reviews live outside; the repo's .claude is a link to them.
reviewsInto(outside, [
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] },
]);
symlinkSync(outside, join(repo, '.claude'));
const r = mod.applyLesson(repo, 'MISSING_TEST');
assert.equal(r.written, false, JSON.stringify(r));
assert.match(String(r.reason), /\.claude\b/, `the reason names the directory: ${JSON.stringify(r)}`);
assert.ok(!existsSync(join(outside, 'rules')), 'no directory is created through the link');
} finally {
rmSync(repo, { recursive: true, force: true });
rmSync(outside, { recursive: true, force: true });
}
});
test('[DJ4] sibling: the first lesson creates the missing lessons directory, one level after its parent, and is written', () => {
const repo = repoWith([
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] },
]);
try {
assert.ok(!existsSync(join(repo, '.claude', 'rules')), 'fixture: no .claude/rules yet');
const r = mod.applyLesson(repo, 'MISSING_TEST');
assert.equal(r.written, true, JSON.stringify(r));
assert.ok(lstatSync(join(repo, '.claude', 'rules')).isDirectory(), '.claude/rules is created as a directory');
assert.match(readFileSync(join(repo, '.claude', 'rules', mod.LESSONS_FILE_NAME), 'utf-8'), /MISSING_TEST/);
} finally { rmSync(repo, { recursive: true, force: true }); }
});