Files
gstack/test/autoplan-publication-guard.test.ts
f67c478f05 v1.91.34.0 fix: /autoplan on current Claude Code and long sessions, quieter pre-push and sync guards, gstack-post, gstack-doctor (#3069)
* docs: Oct 7 follow-up wave plan (pre-review)

* docs: Oct 7 follow-up wave plan, reviewed through /autoplan

* docs: Oct 7 wave plan, record final gate decisions (UC1 accepted, UC2 rejected)

* docs: credit #1822 to @Mike-E-Log and #1743 to @habassa5 (v1.64.0.0)

The v1.64.0.0 CHANGELOG entry credited the extension token fix (#1822)
and the polyfill exited promise (#1743) to the wrong handle, and the
extension-sender-auth test header repeated the first slip. Commit history
is not rewritten.

Fixes #3054.

Co-authored-by: Mike Ilog <48659643+Mike-E-Log@users.noreply.github.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(context-restore): offer newer checkpoints from nested repos and task worktrees

/context-restore could present an older checkpoint as the latest with no
error in two layouts:

1. A session started in a non-git parent saved from a nested repo, so the
   checkpoint went to the nested repo's bucket. Restore in the parent now
   lists checkpoints of other buckets whose project_root lies below the cwd
   (BELOW_CWD; the 200 newest are read, and from $HOME only each project's
   newest). /context-save also leaves a JSON pointer (checkpoint path, bucket,
   project_root) in the starting directory's bucket when $CLAUDE_PROJECT_DIR
   is set and files elsewhere; restore lists those as POINTER, in or out of git.
2. A plan checkpoint on the main tree's branch shadowed a newer checkpoint
   saved in a ticket worktree. When another branch holds a newer checkpoint
   for the same task (a worktree below the main tree, read from the new
   worktree: stamp; equal normalized titles; or a shared #123 / ABC-123
   token in titles or branch names), restore prints NEWER_TASK and the skill
   shows both and proposes the newer one. Generic titles (wip, checkpoint,
   save, notes, todo) never match on title alone, and the worktree rule
   applies only when restoring from the main tree, so sibling worktrees
   still never shadow each other.

The branch-first ordering itself is unchanged.

Refs #3065.

Co-authored-by: Thomas Gernhardt <t.gernhardt@softwareprojekt.net>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(redact-prepush): list each MEDIUM finding with rule, file line and fix

The pre-push hook printed only a MEDIUM count, which nobody could review
without re-running the engine by hand. It now prints one line per finding:
rule id, file and file line, and a fix, never the matched value.

Line numbers come from the hunk headers (combined --cc headers included), so
a finding in a later hunk or past a scan-chunk boundary points at the real
file line. Findings from the per-commit catch-up path name the commit whose
file line it is, so content removed before the pushed tip is still locatable.

Plan items: CEO-7 (summary half), DX-9 (fix lines), ENG-8 (line mapping).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(redact-prepush): stop flagging your own email and already-public authors (#3060)

The hook never passed the engine's selfEmail / repoPublicEmails, so every
push that added the pusher's address, or a commit author's, raised a MEDIUM
pii.email warning for an address the destination already shows.

The hook now reads `git config user.email` and one bounded `git log` of
author and committer addresses, raw and mailmapped (%ae %aE %ce %cE): the
history of every pushed tip (those commits become public with the push),
each remote tip being replaced, and, for a new branch or an unknown remote
tip pushed to a configured remote, that remote's own tracking refs. A URL or
unknown destination never borrows tracking refs, and another remote's
history never counts.

The read stops at 50,000 commits (the newest authors are used) and 5 seconds.
An error or the timeout passes no history and, when an email finding is
listed, prints one line saying suppression was limited for this push. The two
inputs fail independently. HIGH findings and every other rule are untouched.

Controls: a stranger's address, an address only another remote knows, a URL
push next to a matching remote, and a first push of a repo with no history
all still report.

Plan items: A3, CEO-7, CEO-23 (A3 half), DX-9 (partial history), ENG-8
(history scope, mailmap).

Co-authored-by: akmandhania <143462686+akmandhania@users.noreply.github.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(redact-prepush): per-address allowlist for pii.email (gstack.redact.allowEmail)

`git config --add gstack.redact.allowEmail <address>` allows that address for
MEDIUM pii.email findings only, so a team alias or support address that has
to be in the repo stops warning without reaching for --no-verify. It never
affects HIGH findings or any other rule, and the pii.email fix line names it.

Controls: a different address still reports, and a HIGH secret next to an
allowed address still blocks.

Plan item: DX-17.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(troubleshooting): pre-push pii.email findings, exemptions and allowlist

Plan item: DX-12 (pre-push touchpoint).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(redact-prepush): assemble fixture addresses at runtime

The email fixtures were literals, so this branch's own push listed 47
pii.email findings in its test file. Build them from a helper instead, the
way the neighbouring tests build credential and IP fixtures.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(browse): offer the user's signed-in tab, name silent aside repl failures

Rules (rendered into every browsing skill, kept short):
- Rule 1: before the first openTab, offer the user's tabs on the target
  origin (title and origin only; the rest of listBrowserTabs() stays
  private) and attach only after the user confirms one.
- Rule 4: a second sign-in wall after a confirmed sign-in means the session
  is bound to the tab or URL; offer that tab instead of another sign-in.
- Rule 6 no longer suggests an early return; rule 7 says a fast [ok without
  GSTACK_STEP_OK is an abort.

Cookbook (browse, devex-review): a "Silent failures" list (evaluate returns
only serializable values, no top-level return, locator(sel).press(key), an
empty DOM read is about the selector until the screenshot and the request's
response say otherwise, read toggle state before clicking) and a signed-in
tab recipe that prints only target-origin tabs and never closes the user's tab.

Parity caps for design-consultation, land-and-deploy, qa and the browse
skeleton are raised to the measured sizes, with notes.

Refs #3063, #3064.

Co-authored-by: Thomas Gernhardt <t.gernhardt@softwareprojekt.net>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(codex): run gstack-codex-probe as a command, one subcommand per probe

Generated skills now execute bin/gstack-codex-probe (select-model, check-auth,
show-sandbox, check-sandbox, probe-model, check-version, show-first-use-notice,
run-with-timeout, log-event, log-hang, help) and capture its KEY: value lines,
so no helper state crosses into the calling shell and the preflight works from
bash, zsh or sh. select-model keeps --model > GSTACK_CODEX_MODEL > config.toml
precedence; run-with-timeout supervises with the existing gtimeout/timeout/
watchdog chain and returns 124 on a deadline; an unknown subcommand exits 64.

Sourcing keeps working for skills rendered before the upgrade and prints one
deprecation line naming /gstack-upgrade; it and the zsh self-locate block are
removed in a release on or after 2026-10-21 (DX-18). Parity caps raised with
measured notes for the longer capture lines.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(gstack-post): one argv-only, redaction-scanned write path for PR and issue text

bin/gstack-post posts a PR/MR or issue comment, a review-comment reply, a
PR title or body, a new PR/MR or a new issue from agent-written files. Every
value reaches gh/glab as its own argv element in --flag=<value> form or on
stdin, never through a shell string, and a target is a number or a URL of
this repository on the same host.

The helper owns the redaction scan: it reads each file once, scans those
exact bytes (visibility from config, then gh/glab; the pusher's own email
suppressed) and sends the same bytes. HIGH is refused with exit 1 and no
confirmation can post it. MEDIUM exits 2 with RULE:/LINE:/PART: lines and a
TOKEN: digest of the bytes, destination, operation and findings; only
--confirm <token> for those exact bytes posts, so any edit needs a new
confirmation. gh/glab failures exit 3, usage errors 64. It also carries the
projectCards REST fallback and the title read-back that /ship did by hand.

The logic lives in lib/gstack-post.ts behind an injectable runner, so its
tests run in the Windows free lane; the stub-gh executable test is POSIX.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(autoplan): guard verifies dispatches on Claude Code 2.1.29x (schema strip, unflushed call, named causes)

Cause A: Claude Code journals the model's raw Agent input but hands
PreToolUse the schema-parsed input; with the fork-subagent gate on (the
interactive default in 2.1.292) run_in_background is dropped, so the journal
record never deep-equaled the payload. nativeToolInput now drops only that
documented strip for Agent. Agent input is bound on both paths: keys limited
to prompt, description, subagent_type (general-purpose) and
run_in_background; a model override or any other key is denied.

Cause B: when a ready journal lacks the current call, the guard evaluates
the payload placed after the last journaled record instead of polling for a
record that may arrive only with the tool result. A phase transition counts
a report only when a later journaled record follows it (and, on the journal
path, it is not in the current call's message). One batch rule covers both
paths; a background reviewer keeps the invocation armed until its
completion notice.

Every outcome now has a stable reason code, disposition and anchor
(autoplan/bin/guard-reasons.ts). UC1: Claude-Code-caused states
(unrecognized shape, journal lag, rewritten journal, oversized record,
unchecked-version schema strip) allow with a visible warning; integrity
failures stay denials. No denial advises a bare retry. Every guarded
decision appends one content-free line to analytics/autoplan-guard.jsonl,
trimmed to 1,000 lines past 256 KiB.

Co-authored-by: Jared <182125291+yolo-jared@users.noreply.github.com>
Co-authored-by: crblabs <293123359+crblabs@users.noreply.github.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(autoplan): publish each phase report with a no-op call; foreground requests are conditional

Phase-close step 6 sends the report in a message whose only tool call is
the Bash no-op ': autoplan-published <phase>', and step 7 makes the next
guarded call in a later message, so the guard sees the report flushed
before the next phase entry regardless of Claude Code's journal timing.

Every site that asked for run_in_background: false now asks for it only
when the Agent schema offers it and says how to recover when a dispatch
runs in the background anyway (wait for its completion notice). Parity
caps rise by the measured bytes.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(ship,spec,review): PR and issue text goes out only through gstack-post

/ship: Step 18 writes the title into an agent-written file. An existing PR's
title is piped from gh/glab through gstack-pr-title-rewrite.sh --stdin (new),
so neither the agent nor a shell string carries it; a new title is written
with the file tool and its version prefix checked in the file. Step 19
composes the body as before and publishes with gstack-post pr-create, or
pr-body then pr-title, which scan the exact bytes they send. The separate
title and body scans, the hand-rolled REST fallback and the title self-check
are gone (the helper does all three). Title preparation moved into the
pr-body section, so the skeleton shrinks.

/spec files its issue with gstack-post issue-create and drops the pre-issue
re-scan; Greptile replies use gstack-post reply and pr-comment.
scripts/resolvers/redact-doc.ts loses its PR and issue sinks, and a test
keeps any template from rendering one again.

Tests that pinned the old scan-then-gh wiring now pin the gstack-post wiring;
the publication tests run the real compose and publish blocks against a stub
gh with the real helper (HIGH in title or body refused, MEDIUM token flow).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* ci: pin Claude Code 2.1.292 in the CI image

2.1.292 is the npm latest users run. Its interactive default turns on the
fork-subagent gate, which strips run_in_background from the Agent schema
and backgrounds reviewers; CI on 2.1.284 never saw the /autoplan guard
break. The image tag is a content hash, so every eval workflow picks up
the new image without a workflow edit.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(brain-sync): one drain lock for every writer to the artifacts repo

gstack-artifacts-init, gstack-brain-restore, gstack-gbrain-source-wireup
and the skill-start merge ran git in $GSTACK_HOME without the drain's lock,
so the drain could never tell an abandoned .git/index.lock from a live one.
They now take the same mkdir lock (bin/gstack-brain-lock.sh): the drain and
the skill-start merge skip when it is held, user-run writers wait for it
(GSTACK_BRAIN_LOCK_WAIT, default 120 s), and a child of the holder (the
drain's daily worktree advance, restore's wireup) inherits it by token.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(brain-sync): hold back only the flagged file, keep coupled artifacts whole

One file with secret-shaped text used to block every push, silently. The
drain now attributes scanner hits to files in one pass over the staged diff
(split on its diff --git headers), holds back each flagged file with the
rest of its coupled group, unstages it, and requires the remaining staged
diff to re-scan clean; anything short of that falls back to a full unstage
and status blocked. Held files stay queued and are re-scanned every drain.

- Status file gains held_count, drainable, last_drain_at, last_push_at and
  held[] (path, scanner rule, dependents, both fixes); new status held.
- Coupled groups come from an explicit map (decisions.jsonl and
  decisions.active.json): a queued member stages the whole group, a group
  stages only when its snapshot matches its log in the index, a skipped
  member stops the whole group.
- --unskip-file undoes --skip-file and queues the path again.
- An index lock older than 10 minutes is removed under the drain lock; a
  younger one, a failed git add, a failed commit (hooks included) keep the
  queue and report status error. A verified empty staged diff is the only
  "nothing to commit". git add runs once, batched.
- The scanner reads bytes, so undecodable content no longer crashes it into
  a false clean; matched text never reaches the status file.
- An empty-queue run with unpushed sync commits stays push_failed.
- Privacy-held duplicate records collapse to one per path; --status counts
  a spool too deep for a shell glob.
- --attention prints skill start's fixed attention lines from the sync
  status table in the usage header.

Co-authored-by: v639dragoon <matthewsbrickley@gmail.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(skill-start): ARTIFACTS_SYNC attention lines for a stuck sync

Skill start now prints one fixed attention line per sync-status-table row
that applies (held, blocked, push_failed, error, unknown, stale-push,
stale-drain), each naming gstack-brain-sync by absolute path. Values from
the status file are clamped (status code to the table, counts to digits,
timestamps to their shape), so the status message, file paths and scanner
output never reach the line. The preamble tells the model to surface the
line in one sentence and continue. Skill-start tests are generated from the
table in bin/gstack-brain-sync, and docs/gbrain-sync-errors.md carries the
same table.

Co-authored-by: v639dragoon <matthewsbrickley@gmail.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(outside-review): severity words in label position count as findings

The Oct 7 /autoplan run's Codex CEO and DX reviews labeled seven and eight
findings High/Medium without [Pn] tags, and the classifier printed
VERDICT: clean / FINDINGS: none for both. lib/outside-review-result.ts now
reads severity words in label position (Severity: High, a leading High: /
High — / [High], **High**, a table cell): critical/high block like P0/P1,
medium/low are advisory. Words in prose (high-level, low-risk, no critical
findings) never count. A review or structured response with neither tags,
labels nor an explicit no-findings statement is unverified, never clean
(DX-11, ENG-10). Design-direction proposals move to a new proposal gate so
they keep needing only their Recommendation line, and review prompts ask
for a severity per finding. Fixtures reproduce the CEO/DX label shapes, the
eng [P1] output, a table and prose negative controls; the stored Codex panel
keeps its verdicts. docs/troubleshooting.md explains how the verdict is read.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat: gstack-doctor readiness report and setup --status Codex row (B5)

bin/gstack-doctor prints one row per check without starting a skill:
install root (the registry rows behind ./setup --status), state root, Bun
against the floor, hook parse check, Codex mode and self-locate via
gstack-codex-probe subcommands, the cached model probe with its age
(--live runs the paid probe-model), artifacts-sync health from the status
file with the status code clamped, the browse bundle, Claude Code version,
the largest session journal and the last five schema-1 guard reason codes.
Rows are ok, warn, not configured or fail; only fail exits non-zero; fix
lines name helpers by absolute install-root path.

./setup --status gains a read-only Codex row (bin/gstack-codex-status.sh,
shared with the doctor) and ends with the doctor's absolute path.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: bug-report template asks for doctor output; README troubleshooting entry; doctor test in the Windows lane

The template gives the Claude Code doctor path and a lookup for other
hosts and names no bare gstack- command (asserted in
test/gstack-doctor.test.ts). The doctor test runs every script through
explicit bash argv, so it joins the curated Windows free subset.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(troubleshooting): readiness check section points at gstack-doctor

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(question-tuning,plan-tune,gbrain): free text reaches helpers only through agent-written files

Question tuning no longer pipes the question summary through printf: a
registered id needs no summary, and for an unregistered id the agent writes
the summary to .gstack/tmp/qt.txt and passes --summary-file (new). The
helper resolves a relative path against the git top level, removes scratch
files under .gstack/tmp, and asks normally when the file is missing.

/plan-tune tune events carry the user's own words again: they go into a
file the agent writes and reach gstack-question-preference --write through
--free-text-file (new), still behind the injection filter and the
user-origin gate.

docs/gbrain-write-surfaces.md drops its heredoc templates for the
agent-written page file on stdin, matching the per-skill save block, and the
generic save block stops showing a quoted --content argument.

The tuning prose was tightened so every preamble stays within its size cap.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(evals): daily autoplan-schema-canary on the latest Claude Code

A periodic rule case installs the latest published Claude Code with
install scripts off, verifies registry signatures, and runs one headless
session with the fork-subagent gate on whose PreToolUse hook records an
Agent and a Read payload. Each payload must match its journal record under
the guard's own comparison, keep the journal's key set apart from the
documented run_in_background strip, and stay inside the Agent allowlist;
a background Agent's completion notice must keep the shape the guard reads.
evals-periodic.yml runs it daily on its own schedule and concurrency group
(read-only token); the weekly census also includes it. A free test proves
the comparison fails on an injected strip.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(doctor): read the artifacts sync held count from held_count, the field the drain writes

* fix(artifacts sync): the init and restore pre-commit hooks catch Bearer/Basic/Token-prefixed JSON auth headers like the drain does

* feat(greptile-early): detect Greptile, ask once on public repos, open the early draft, wait for the review

bin/gstack-greptile-early backs /ship's early Greptile PR:
- detect: a root .greptile/ folder (which takes precedence), greptile.json,
  or a Greptile comment on the repo in the last 90 days; triggerOnDrafts from
  the effective root config; repo visibility; public-repo consent. It prints
  the signal, that /ship will push and open a draft now, the 10-minute wait
  cap and the absolute gstack-config command that turns it off.
- consent yes|no: the public-repo answer, remembered per repo in the
  project's state.
- open: opens the draft through gstack-post with a body saying /ship opened
  it early, and posts one @greptileai comment through gstack-post when the
  config does not set triggerOnDrafts: true. An open PR is left alone.
- wait: polls every 30 s for a completed Greptile check run, a Greptile
  review of the head commit or a summary naming it; each call returns within
  about 90 s and reports timeout 10 minutes after the PR opened.

gstack-config gains ship_greptile_early (default true, invalid values
rejected): on automatically whenever the repo uses Greptile, off per user.
The logic is in lib/greptile-early.ts behind an injectable runner, so its
fixture-repo tests run in the Windows lane; an end-to-end test drives the
real helper and gstack-post against a stub gh.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(ship): early Greptile PR at Step 6.5, bounded wait at Step 10 (#3020)

When the repo uses Greptile, /ship pushes and opens a draft PR right after
its free tests pass (new lazy section greptile-early, Step 6.5), so Greptile
reviews while Steps 7-9 run. A public repo asks once, remembered per repo;
declining keeps today's order. Step 10 waits for that review with
gstack-greptile-early wait and takes the existing unavailable route on a
timeout, never a claim of zero comments. Step 18 replaces the provisional
title, Step 19 marks the draft ready unless a draft was requested, and a
/ship that stops after Step 6.5 leaves the draft open with one comment
saying why.

ship's union cap rises to 1.5275 with the measured note (B4 +836 bytes,
B2 +4,732 bytes); the step-numbering guard allows 6.5; ship goldens are
refreshed for B4 and B2.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(autoplan): the publication no-op is 'true autoplan-published <phase>'

Measured on Claude Code 2.1.292 in a default-settings interactive session:
a bare ':' Bash command asks for approval ('This command requires
approval'), and after a background reviewer's completion notice starts a
new turn the skill's allowed tools no longer cover it, so the phase-close
no-op prompted the user. 'true' with arguments runs without approval and
prints nothing. The guard only needs a later journaled record, so the
command changes everywhere it is named.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(evals): live autoplan-guard-pty case on the pinned Claude Code

A gate-tier rule case (one trial, haiku) drives an interactive PTY
session with default settings through a project skill that carries
/autoplan's own hook block: init, Phase 1 entry, a CEO reviewer dispatch
that sends run_in_background, a prepared close packet, the report
published with the no-op, and the Phase 2 entry in a later message. It
reads outcomes from the guard decision log (three verified allows) and
fails if the no-op raises a permission card; other cards are approved and
printed.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(autoplan): bounded, append-tolerant owned-journal read (A2)

The publication guard no longer reads the whole parent journal or refuses
it at 32 MiB. lib/claude-owned-journal.ts streams the bytes present at open
to the last complete line (pass one), keeps a content-free index per record
and decides ownership, ancestry and causal order on it with the same checks
and reason codes as before. Pass two reloads in full only what the
evaluation reads (autoplan/bin/guard-journal.ts): every literal init and its
result, the current call, and inside the invocation window (from the latest
human turn at or before the bound chain's first init) guarded and
close-packet Read results, structured Bash results, error results, assistant
text after the first close Read and active-plan mutations, plus the earlier
Read a dedup reply cites even before the window (ENG-4).

- lib/claude-journal-records.ts: per-record event extraction, record
  metadata and the causal-order checks, shared by readPlanCountTranscript
  and the bounded reader. The batch reader keeps its whole-file read and cap.
- Appends after open never fail a read; same device and inode and no
  shrink are required (identity). A second read in one hook invocation must
  hash bytes [0, first size) the same, or it is `rewritten` (UC1 unverified);
  the unrecognized-shape double read uses that prefix hash. `changing` is
  removed (REMOVED_CODES; its anchor stays).
- OWNED_RECORD_MAX_BYTES (32 MiB): only one record over it is too_large.
  OWNED_RETAINED_MAX_BYTES (32 MiB): over it is the new oversized_invocation
  fallback denial. GSTACK_TRANSCRIPT_TEST_MAX_BYTES and
  GSTACK_TRANSCRIPT_TEST_RETAINED_BYTES can only lower them.
- ENG-5: evaluation groups uses and results by tool id once (no per-tool
  rescans).

CEO-25 / ENG-5 spike (120 MiB synthetic, 31,242 records, 4 vCPU Linux,
Bun 1.4.2): a full streaming rescan (parse every line, small index, sha256)
took 254-326 ms at 147-161 MiB peak RSS; a pass-one index cache under the
state root (load cached index, verify a 64 KiB tail hash, parse the tail)
took 26-29 ms at 47 MiB. The rescan meets the 1 s budget, so the cache is
not adopted: it would add trusted persistent state and invalidation for no
budget need. A backward tail read cannot prove competing_root or cycle, so
it was not a candidate.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(autoplan): journal-read benchmark and its dispatch-only workflow (CEO-2, CEO-6)

scripts/measure-journal-read.ts builds two synthetic 120 MiB parent journals
around a real /autoplan invocation (mixed: ~28k records, most bytes inside
the invocation; large-invocation: ~89k records with a current invocation of
many small records), keeps appending while the guard reads, and measures one
hook call in a fresh process (wall ms, peak RSS). The workflow runs it on
ubuntu-latest and macos-latest on workflow_dispatch only.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(evals): autoplan-long-session, a resumed >100 MiB session enters Phase 1

CEO-15 with ENG-6's observer: a real session is compacted, its journal
padded past 100 MiB with owned records before the compact boundary, and
the resumed interactive session runs the scripted phase boundary; the
guard decision log must show a verified Phase 1 allow. A free test proves
that observer on a padded journal through the lower-only bound seams
(the PTY counting reader refuses the same journal), with a sidechain
control that must deny.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.34.0: CHANGELOG, TODOS, version; harness decision for test-pr-profile; ship title-step test reads its new section

* docs(changelog): use an example.com address in the allowEmail example

* test: assemble GitHub-token fixtures at runtime in the new brain-sync and skill-start tests (CI credential scan)

* fix: integration repairs across the wave (Step 6.5 fence, printed helper paths on Windows, probe usage without a heredoc) and tests pinned to the moved title and gstack-post contracts

* test(doctor): the healthy fixture builds browse.exe on Windows, the name setup uses there

* test(shared-libs): recognize Claude Code 2.1.292's internal git probe prefix (format.pretty and per-hook disables), with its CI receipt

* fix(ship-measure): report lists v1 measurements as an older format instead of crashing

---------

Co-authored-by: Mike Ilog <48659643+Mike-E-Log@users.noreply.github.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
Co-authored-by: Thomas Gernhardt <t.gernhardt@softwareprojekt.net>
Co-authored-by: akmandhania <143462686+akmandhania@users.noreply.github.com>
Co-authored-by: Jared <182125291+yolo-jared@users.noreply.github.com>
Co-authored-by: crblabs <293123359+crblabs@users.noreply.github.com>
Co-authored-by: v639dragoon <matthewsbrickley@gmail.com>
2026-10-07 23:29:05 +00:00

1513 lines
107 KiB
TypeScript

import { afterAll, afterEach, beforeAll, describe, expect, jest, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { tmpdir } from 'node:os';
import { randomUUID } from 'node:crypto';
import { initializePlan, prepareMethodology, createSnapshot, preparePhaseClose, prepareAmendedInput } from '../bin/gstack-autoplan-snapshot';
import { evaluateAutoplanPublication, runPublicationHook, autoplanReadRange, type PublicationHookInput } from '../autoplan/bin/phase-publication-hook.ts';
import { readOwnedClaudePublicTranscript, readPlanCountTranscript, transcriptReadLimit, OWNED_TRANSCRIPT_MAX_BYTES, nativePathSpelling, ownedNativePath, sameNativePath, type ClaudeParentPublicEvent } from '../lib/claude-public-transcript';
import { prematureAutoplanPhaseEntry } from './helpers/autoplan-method-read-audit';
import captured from './fixtures/autoplan-publication-boundary-361c.json';
import consumption from './fixtures/autoplan-phase-consumption-491.json';
const ROOT = fs.realpathSync(path.join(import.meta.dir, '..'));
// Every guarded decision appends to the guard log; keep it out of the real state root.
let previousGstackHome: string | undefined;
let guardLogHome = '';
beforeAll(() => {
previousGstackHome = process.env.GSTACK_HOME;
guardLogHome = fs.mkdtempSync(path.join(tmpdir(), 'autoplan-guard-log-'));
process.env.GSTACK_HOME = guardLogHome;
});
afterAll(() => {
if (previousGstackHome === undefined) delete process.env.GSTACK_HOME;
else process.env.GSTACK_HOME = previousGstackHome;
fs.rmSync(guardLogHome, { recursive: true, force: true });
});
const dirs: string[] = [];
afterEach(() => { for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); });
const phaseNumber = { ceo: 1, design: 2, dx: 2.5, eng: 3 };
type Phase = keyof typeof phaseNumber;
const clock = Date.parse('2026-09-17T04:00:00Z');
async function withNativeProjectDirectory<T>(cwd: string | undefined, work: () => Promise<T>): Promise<T> {
const previous = process.env.CLAUDE_PROJECT_DIR;
if (cwd === undefined) delete process.env.CLAUDE_PROJECT_DIR;
else process.env.CLAUDE_PROJECT_DIR = cwd;
try { return await work(); }
finally {
if (previous === undefined) delete process.env.CLAUDE_PROJECT_DIR;
else process.env.CLAUDE_PROJECT_DIR = previous;
}
}
async function withPublicationClock<T>(work: () => Promise<T>): Promise<T> {
expect(jest.isFakeTimers()).toBe(false);
const immediate = globalThis.setImmediate;
jest.useFakeTimers();
try {
let settled = false;
const pending = Promise.resolve().then(work);
void pending.then(() => { settled = true; }, () => { settled = true; });
for (let elapsed = 0; elapsed <= 2_000; elapsed += 50) {
await new Promise<void>(resolve => immediate(resolve));
if (settled) return await pending;
if (elapsed < 2_000) jest.advanceTimersByTime(50);
}
throw new Error('Publication hook did not settle within its 2000ms polling budget.');
} finally {
jest.clearAllTimers();
jest.useRealTimers();
}
}
function fixture(phase: Phase = 'ceo', next = 'design') {
const cwd = fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), 'autoplan-publication-'))); dirs.push(cwd);
const source = path.join(cwd, 'source.md'), active = path.join(cwd, 'active.md'), restore = path.join(cwd, 'restore.md');
fs.writeFileSync(source, '# Current plan\nKeep documented behavior.\n');
const init = initializePlan(source, active, restore);
const skill = path.join(cwd, 'SKILL.md');
fs.writeFileSync(skill, `---\nname: plan-${phase === 'dx' ? 'devex' : phase}-review\n---\n## Review Sections\nApply every current review criterion.\n`);
const method = prepareMethodology(phase, skill, restore).methodologyPath;
const checkpoint = createSnapshot(phase, active, restore, method).snapshotPath;
fs.appendFileSync(active, `<!-- autoplan-accepted:${phase} -->\nNone: retain the current behavior.\n<!-- /autoplan-accepted:${phase} -->\n`);
const packet = preparePhaseClose(phase, active, checkpoint, restore, method);
const sessionId = randomUUID(), events: ClaudeParentPublicEvent[] = [];
const add = (event: any) => {
const full = { sessionId, timestamp: new Date(clock + events.length).toISOString(), order: events.length, ...event };
events.push(full); return full;
};
const use = (id: string, name: string, input: object) => add({ kind: 'use', toolUseId: id, name, input });
const result = (id: string, extra: object) => add({ kind: 'result', toolUseId: id, isError: false, ...extra });
const read = (id: string, file: string, offset = 1, limit?: number) => {
const text = fs.readFileSync(fs.realpathSync(file), 'utf8'), lines = text.split('\n');
const count = Math.min(limit ?? lines.length, lines.length - offset + 1);
use(id, 'Read', { file_path: file, offset, ...(limit === undefined ? {} : { limit }) });
result(id, { file: { filePath: file, content: lines.slice(offset - 1, offset - 1 + count).join('\n'),
startLine: offset, numLines: count, totalLines: lines.length } });
};
use('init', 'Bash', { command: `cd '${cwd}'\nbun "${ROOT}/bin/gstack-autoplan-snapshot.ts" init \\\n "${source}" "${active}" "${restore}"` });
result('init', { content: JSON.stringify(init) });
read('entry', path.join(ROOT, 'autoplan', 'sections', `${phase}-phase.md`));
read('close', packet.closePacketPath);
const message = (text = `Phase ${phaseNumber[phase]} complete.`) => add({ kind: 'message', text });
const target = path.join(ROOT, 'autoplan', 'sections', next === 'tasks' ? 'tasks-aggregator.md' : `${next}-phase.md`);
const input: PublicationHookInput = { hook_event_name: 'PreToolUse', session_id: sessionId, cwd,
transcript_path: path.join(cwd, 'config', 'projects', 'fixture', `${sessionId}.jsonl`),
tool_name: 'Read', tool_use_id: 'next', tool_input: { file_path: target } };
const current = () => use('next', 'Read', input.tool_input);
const evaluate = () => evaluateAutoplanPublication(input, ROOT, events);
const reorder = () => events.forEach((e, i) => { e.order = i; });
const journal = () => {
fs.mkdirSync(path.dirname(input.transcript_path), { recursive: true });
let parent: string | null = null;
const record = (role: string, content: unknown, extra: object = {}) => {
const uuid = randomUUID(); const r = { uuid, parentUuid: parent, cwd, sessionId, isSidechain: false,
timestamp: new Date(clock).toISOString(), type: role, message: { role, content }, ...extra };
parent = uuid; return r;
};
const rows = [record('user', '<command-message>autoplan</command-message>\n<command-name>/autoplan</command-name>',
{ origin: { kind: 'human' }, promptId: randomUUID() })];
for (const e of events) {
if (e.kind === 'use') rows.push(record('assistant', [{ type: 'tool_use', id: e.toolUseId, name: e.name, input: e.input }], { timestamp: e.timestamp }));
else if (e.kind === 'result') rows.push(record('user', [{ type: 'tool_result', tool_use_id: e.toolUseId,
content: e.content ?? 'Read complete.', is_error: e.isError }], { timestamp: e.timestamp, toolUseResult: { file: e.file } }));
else if (e.kind === 'message') rows.push(record('assistant', [{ type: 'text', text: e.text }], { timestamp: e.timestamp }));
}
fs.writeFileSync(input.transcript_path, rows.map(x => JSON.stringify(x)).join('\n') + '\n');
return { rows, record };
};
return { cwd, source, active, restore, init, method, checkpoint, packet, sessionId, events, input,
add, use, result, read, message, current, evaluate, reorder, journal };
}
describe('Autoplan parent publication guard', () => {
function checkpointWriter(f: ReturnType<typeof fixture>) {
const create = (phase: Phase, id: string) => {
const skill = path.join(f.cwd, id, 'SKILL.md');
fs.mkdirSync(path.dirname(skill));
fs.writeFileSync(skill, `---\nname: plan-${phase === 'dx' ? 'devex' : phase}-review\n---\n## Review Sections\nApply every current review criterion.\n`);
const method = prepareMethodology(phase, skill, f.restore).methodologyPath;
f.use(id, 'Bash', { command: `bun "${ROOT}/bin/gstack-autoplan-snapshot.ts" create ${phase} "${f.active}" "${f.restore}" "${method}"` });
const snapshot = createSnapshot(phase, f.active, f.restore, method);
f.result(id, { content: JSON.stringify(snapshot) });
return { method, snapshot };
};
return create;
}
function completedCycle() {
const f = fixture(); f.message();
const create = checkpointWriter(f), first = new Map<Phase, ReturnType<typeof create>>();
let unread: ReturnType<typeof create>;
for (const phase of ['design', 'dx', 'eng'] as const) {
f.read(`${phase}-entry`, path.join(ROOT, 'autoplan/sections', `${phase}-phase.md`));
const generation = create(phase, `${phase}-create`);
first.set(phase, generation);
f.read(`${phase}-snapshot`, path.join(path.dirname(generation.snapshot.snapshotPath), 'snapshot.json'));
// This old snapshot is acknowledged before closing, but never Read.
if (phase === 'eng') unread = create('eng', 'unread-historical-create');
fs.appendFileSync(f.active, `<!-- autoplan-accepted:${phase} -->\nNone: retain the current behavior.\n<!-- /autoplan-accepted:${phase} -->\n`);
const close = preparePhaseClose(phase, f.active, generation.snapshot.snapshotPath, f.restore, generation.method);
f.read(`${phase}-close`, close.closePacketPath); f.message(`Phase ${phaseNumber[phase]} complete.`);
}
const tasks = path.join(ROOT, 'autoplan/sections/tasks-aggregator.md');
f.read('first-tasks', tasks);
return { f, create, first, unread: unread!, tasks };
}
test('a fresh Eng checkpoint after tasks requires a new close and parent publication', async () => {
const { f, create, tasks } = completedCycle();
const rerun = create('eng', 'rerun-create');
f.read('rerun-snapshot', path.join(path.dirname(rerun.snapshot.snapshotPath), 'snapshot.json'));
f.input.tool_input = { file_path: tasks }; f.current(); f.journal();
const output: any = await withNativeProjectDirectory(f.cwd, () => runPublicationHook(f.input, ROOT));
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
f.events.pop();
const close = preparePhaseClose('eng', f.active, rerun.snapshot.snapshotPath, f.restore, rerun.method);
f.read('rerun-close', close.closePacketPath); f.current(); f.journal();
expect(await withNativeProjectDirectory(f.cwd, () => runPublicationHook(f.input, ROOT)))
.toMatchObject({ hookSpecificOutput: { permissionDecisionReason: expect.stringContaining('Publish the filled Phase 3') } });
f.events.pop(); f.message('Phase 3 complete.'); f.current(); f.journal();
expect(await withNativeProjectDirectory(f.cwd, () => runPublicationHook(f.input, ROOT))).toEqual({});
});
for (const phase of ['ceo', 'design', 'dx', 'eng'] as const)
test(`a fresh ${phase} checkpoint reopens its own publication boundary after tasks`, () => {
const { f, create } = completedCycle(), rerun = create(phase, 'rerun-create');
// Creation supplies no forward-phase credit, but the owned new checkpoint
// invalidates the earlier cycle even before its first snapshot Read.
const next = { ceo: 'design-phase', design: 'eng-phase', dx: 'eng-phase', eng: 'tasks-aggregator' }[phase];
f.input.tool_input = { file_path: path.join(ROOT, 'autoplan/sections', `${next}.md`) }; f.current();
expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining(`Phase ${phaseNumber[phase]} close procedure`) });
f.events.pop();
f.use('rerun-review', 'Agent', { prompt: rerun.snapshot.nativeDispatchPrompt });
f.result('rerun-review', { content: 'Native reviewer launched.' });
const close = preparePhaseClose(phase, f.active, rerun.snapshot.snapshotPath, f.restore, rerun.method);
f.read('rerun-close', close.closePacketPath); f.current();
expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining(`Publish the filled Phase ${phaseNumber[phase]}`) });
f.events.pop(); f.message(`Phase ${phaseNumber[phase]} complete.`); f.current();
expect(f.evaluate()).toEqual({ allow: true });
});
test('historical driver, methodology and previously unread snapshot recovery does not reopen a phase', () => {
const { f, first, unread, tasks } = completedCycle();
for (const [id, file] of [['driver', path.join(ROOT, 'autoplan/sections/ceo-phase.md')],
['methodology', first.get('design')!.method], ['snapshot', unread.snapshot.snapshotPath]] as const) f.read(`history-${id}`, file);
f.input.tool_input = { file_path: tasks }; f.current();
expect(f.evaluate()).toEqual({ allow: true });
});
test('an old close and a new report cannot close a different rerun checkpoint', () => {
const { f, tasks } = completedCycle(), create = checkpointWriter(f);
const oldClose = f.events.find(e => e.kind === 'use' && e.toolUseId === 'eng-close')!;
create('eng', 'new-generation');
f.read('old-close-replayed', oldClose.input!.file_path as string); f.message('Phase 3 complete.');
f.input.tool_input = { file_path: tasks }; f.current();
expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('earlier checkpoint') });
});
for (const status of ['partial', 'failed', 'pending'] as const)
test(`a ${status} rerun close cannot borrow the first cycle's completed report`, () => {
const { f, create, tasks } = completedCycle(), rerun = create('eng', 'rerun');
const close = preparePhaseClose('eng', f.active, rerun.snapshot.snapshotPath, f.restore, rerun.method);
f.read('new-close', close.closePacketPath, 1, status === 'partial' ? 1 : undefined);
if (status === 'failed') f.events.at(-1)!.isError = true;
if (status === 'pending') f.events.pop();
f.input.tool_input = { file_path: tasks }; f.current();
expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Read every line') });
});
for (const kind of ['failed', 'unpaired', 'orphan-result', 'result-before-use', 'foreign-active',
'foreign-restore', 'reflected-old', 'altered-identity', 'altered-prompt', 'altered-baseline'] as const)
test(`a ${kind} checkpoint result cannot reopen historical phase state`, () => {
const { f, create, first, tasks } = completedCycle();
const old = first.get('eng')!.snapshot, fresh = create('eng', 'untrusted-create');
const use = f.events.at(-2)!, result = f.events.at(-1)!;
if (kind === 'failed') result.isError = true;
if (kind === 'unpaired') f.events.pop();
if (kind === 'orphan-result') f.events.splice(-2, 1);
if (kind === 'result-before-use') f.events.splice(-2, 2, result, use);
let output = fresh.snapshot;
if (kind === 'reflected-old') output = old;
if (kind === 'foreign-active' || kind === 'foreign-restore') {
const foreign = fixture('eng', 'tasks');
output = createSnapshot('eng', foreign.active, foreign.restore, foreign.method);
if (kind === 'foreign-restore') {
const method = prepareMethodology('eng', path.join(foreign.cwd, 'SKILL.md'), foreign.restore).methodologyPath;
output = createSnapshot('eng', f.active, foreign.restore, method);
}
}
if (kind === 'altered-identity') output = { ...output, sourceBytes: output.sourceBytes + 1 };
if (kind === 'altered-prompt') output = { ...output, nativePrompt: output.nativePrompt + 'Forged.' };
if (kind === 'altered-baseline') output = { ...output, baselineEdits: { ...output.baselineEdits, record: 'Forged checkpoint.' } };
result.content = JSON.stringify(output); f.reorder();
f.input.tool_input = { file_path: tasks }; f.current();
expect(f.evaluate()).toEqual({ allow: true });
});
for (const operation of ['prepare-close', 'amend-input'] as const)
test(`an internal ${operation} export does not start a new checkpoint generation`, () => {
const { f, first, tasks } = completedCycle(), prior = first.get('eng')!;
f.use('internal-export', 'Bash', { command: operation });
const output = operation === 'prepare-close'
? preparePhaseClose('eng', f.active, prior.snapshot.snapshotPath, f.restore, prior.method)
: prepareAmendedInput('eng', f.active, prior.snapshot.snapshotPath, f.restore, prior.method);
f.result('internal-export', { content: JSON.stringify(output) });
f.input.tool_input = { file_path: tasks }; f.current();
expect(f.evaluate()).toEqual({ allow: true });
});
test('CEO second voice creation preserves its fixed Step-0 checkpoint', () => {
const f = fixture(); f.events.splice(4); const create = checkpointWriter(f);
const step0 = create('ceo', 'step0'), voice = create('ceo', 'voice');
f.read('voice-input', voice.snapshot.snapshotPath);
const close = preparePhaseClose('ceo', f.active, step0.snapshot.snapshotPath, f.restore, step0.method);
f.read('step0-close', close.closePacketPath); f.message(); f.current();
expect(f.evaluate()).toEqual({ allow: true });
});
test('preparing a later checkpoint cannot advance past an unpublished rerun', () => {
const { f, create, tasks } = completedCycle();
const design = create('design', 'design-rerun'); create('eng', 'prepared-eng');
f.input.tool_input = { file_path: tasks }; f.current();
expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Phase 2 close procedure') });
f.events.pop(); f.read('current-recovery', design.method);
f.input.tool_input = { file_path: design.method }; f.current();
expect(f.evaluate()).toEqual({ allow: true });
});
function changedDirectory(publish = true, next = 'design-phase.md') {
const f = fixture();
const registry = path.join(f.cwd, 'registry');
fs.mkdirSync(registry); fs.symlinkSync(path.join(ROOT, 'autoplan'), path.join(registry, 'autoplan'));
const currentCwd = fs.realpathSync(path.join(registry, 'autoplan/sections'));
f.use('cd', 'Bash', { command: `cd '${registry}/autoplan/sections' && pwd` });
f.result('cd', { content: currentCwd });
if (publish) f.message();
f.input.cwd = currentCwd;
f.input.tool_input = { file_path: next };
f.current();
const { rows } = f.journal();
const change = rows.findIndex(r => Array.isArray(r.message.content) &&
r.message.content.some((b: any) => b.type === 'tool_result' && b.tool_use_id === 'cd'));
for (const row of rows.slice(change)) row.cwd = currentCwd;
const save = () => fs.writeFileSync(f.input.transcript_path, rows.map(r => JSON.stringify(r)).join('\n') + '\n');
save(); return { f, rows, save };
}
test('native project ownership survives a Bash cd through the installed skill link', async () => {
const { f } = changedDirectory();
expect(readOwnedClaudePublicTranscript(f.input.transcript_path, f.input.cwd, f.sessionId).transcript.status).toBe('missing');
expect(await withNativeProjectDirectory(f.cwd, () => runPublicationHook(f.input, ROOT))).toEqual({});
});
test('native project ownership still requires publication and permits same-phase repair after cd', async () => {
const { f } = changedDirectory(false);
const output: any = await withNativeProjectDirectory(f.cwd, () => runPublicationHook(f.input, ROOT));
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('Publish the filled Phase 1');
const same = changedDirectory(false, 'ceo-phase.md');
expect(await withNativeProjectDirectory(same.f.cwd, () => runPublicationHook(same.f.input, ROOT))).toEqual({});
});
test('the literal init parser retains Windows drive and UNC identities', () => {
const source = fs.readFileSync(path.join(ROOT, 'autoplan/bin/phase-publication-hook.ts'), 'utf8');
const fn = source.slice(source.indexOf('function initArguments('), source.indexOf('\nfunction invocation('));
const parse = new Function('path', 'fs', 'process', 'ownPath', 'nativePathSpelling', 'samePath',
new Bun.Transpiler({ loader: 'ts' }).transformSync(fn) + '\nreturn initArguments;')(
path.win32, { realpathSync: (file: string) => file }, { platform: 'win32' },
(value: unknown) => ownedNativePath(value, path.win32), nativePathSpelling,
(a: unknown, b: unknown) => sameNativePath(a, b, path.win32));
for (const root of [String.raw`C:\repo`, String.raw`\\server\share\repo`]) {
const args = ['source.md', 'active.md', 'restore.md'].map(file => path.win32.join(root, file));
const script = path.win32.join(root, 'bin/gstack-autoplan-snapshot.ts');
const singleQuoted = [script, ...args].map(value => `'${value}'`);
const command = `bun ${singleQuoted[0]} init ${singleQuoted.slice(1).join(' ')}`;
expect(parse(command, root)).toEqual(args);
const forward = [script, ...args].map(value => '"' + value.replaceAll('\\', '/') + '"');
expect(parse(`bun ${forward[0]} init ${forward.slice(1).join(' ')}`, root)).toEqual(args);
if (!root.startsWith('\\\\')) {
const native = [script, ...args].map(value => '"' + value + '"');
expect(parse(`bun ${native[0]} init ${native.slice(1).join(' ')}`, root)).toEqual(args);
// gstack-paths prints /c/...; Git Bash hands bun C:/... for the same file.
const msys = [script, ...args].map(value => '"/c/' + value.slice(3).replaceAll('\\', '/') + '"');
expect(parse(`bun ${msys[0]} init ${msys.slice(1).join(' ')}`, root)).toEqual(args);
const lower = [script, ...args].map(value => `'c${value.slice(1)}'`);
expect(parse(`bun ${lower[0]} init ${lower.slice(1).join(' ')}`, root)).toEqual(args);
for (const bad of ['/cc/repo/source.md', '/c', '/c/repo/../source.md'])
expect(parse(`bun "${msys[0]!.slice(1, -1)}" init "${bad}" ${msys.slice(2).join(' ')}`, root)?.[0]).not.toBe(args[0]);
}
for (const bad of [command + ' && true', command.replace('source.md', String.raw`..\source.md`),
command.replace('source.md', 'nested/../source.md'), command.replace(script, path.win32.join(root, 'foreign.ts'))]) {
expect(parse(bad, root)).toBeUndefined();
}
}
});
const withReadLimit = async <T>(bytes: number, run: () => T | Promise<T>): Promise<T> => {
const prior = process.env.GSTACK_TRANSCRIPT_TEST_MAX_BYTES;
process.env.GSTACK_TRANSCRIPT_TEST_MAX_BYTES = String(bytes);
try { return await run(); } finally {
if (prior === undefined) delete process.env.GSTACK_TRANSCRIPT_TEST_MAX_BYTES; else process.env.GSTACK_TRANSCRIPT_TEST_MAX_BYTES = prior;
}
};
test('CEO-2 + UC1: a journal larger than the record bound reads; one record one byte over it is an unverified allow naming its size, code and anchor', async () => {
const f = fixture();
f.message(); f.current(); f.journal();
const size = fs.statSync(f.input.transcript_path).size;
const record = Math.max(...fs.readFileSync(f.input.transcript_path, 'utf8').split('\n').map(line => Buffer.byteLength(line)));
expect(size).toBeGreaterThan(record);
await withReadLimit(record, async () => {
expect(readOwnedClaudePublicTranscript(f.input.transcript_path, f.input.cwd, f.sessionId).transcript.status).toBe('ready');
expect(await withNativeProjectDirectory(f.cwd, () => runPublicationHook(f.input, ROOT))).toEqual({});
});
const output: any = await withReadLimit(record - 1, async () => {
expect(readOwnedClaudePublicTranscript(f.input.transcript_path, f.input.cwd, f.sessionId).transcript.reason).toBe('too_large');
return withNativeProjectDirectory(f.cwd, () => runPublicationHook(f.input, ROOT));
});
// Claude Code wrote a journal the guard cannot verify: allow, warn visibly, never deny.
expect(output.hookSpecificOutput.permissionDecision).toBeUndefined();
const warning: string = output.systemMessage;
expect(warning).toContain('phase publication was NOT verified');
expect(warning).toMatch(/[\d.]+ MiB, over the [\d.]+ MiB record limit/);
expect(warning).not.toContain(' 0 MiB');
expect(warning).toContain('code too_large');
expect(warning).toContain('docs/autoplan-guard-troubleshooting.md#too-large');
expect(warning).toContain('Phase-report enforcement was skipped');
expect(output.hookSpecificOutput.additionalContext).toContain('true autoplan-published <phase>');
});
test('#3050: the batch read and the narrowed parent loop report too_large, not a plain error', async () => {
const f = fixture();
f.message(); f.current(); f.journal();
const size = fs.statSync(f.input.transcript_path).size;
const config = path.dirname(path.dirname(path.dirname(f.input.transcript_path)));
await withReadLimit(size - 1, () => {
const batch = readPlanCountTranscript(config, f.input.cwd, () => {});
expect([batch.status, batch.reason]).toEqual(['error', 'too_large']);
const narrowed = readPlanCountTranscript(config, f.input.cwd, () => {}, f.input.transcript_path);
expect([narrowed.status, narrowed.reason]).toEqual(['error', 'too_large']);
});
});
test('#3050: the test seam can only lower the cap', async () => {
await withReadLimit(OWNED_TRANSCRIPT_MAX_BYTES * 4, () => expect(transcriptReadLimit()).toBe(OWNED_TRANSCRIPT_MAX_BYTES));
});
test('#3050: a real identity failure still reports identity', () => {
const f = fixture();
f.message(); f.current(); f.journal();
const other = path.join(path.dirname(f.input.transcript_path), 'not-this-session.jsonl');
fs.copyFileSync(f.input.transcript_path, other);
expect(readOwnedClaudePublicTranscript(other, f.input.cwd, f.sessionId).transcript.reason).toBe('identity');
});
test('native hook accepts platform paths without evaluating shell escapes', async () => {
// The same actual hook receives native separators in Windows CI.
const f = fixture();
f.message(); f.current(); f.journal();
expect(await withNativeProjectDirectory(f.cwd, () => runPublicationHook(f.input, ROOT))).toEqual({});
const command = f.events[0]!.input!.command as string;
for (const suffix of ['; true', ' && true', ' | cat']) {
f.events[0]!.input!.command = command + suffix; f.journal();
const output: any = await withNativeProjectDirectory(f.cwd, () => runPublicationHook(f.input, ROOT));
expect(output.hookSpecificOutput.permissionDecision).toBe('deny');
}
for (const escaped of ['\\$HOME', '\\`whoami\\`', '\\"quoted']) {
f.events[0]!.input!.command = command.replace(f.source, f.source + escaped); f.journal();
const output: any = await withNativeProjectDirectory(f.cwd, () => runPublicationHook(f.input, ROOT));
expect(output.hookSpecificOutput.permissionDecision).toBe('deny');
}
});
test('native project ownership preserves the absent-env same-directory adapter', async () => {
const f = fixture(); f.message(); f.current(); f.journal();
expect(await withNativeProjectDirectory(undefined, () => runPublicationHook(f.input, ROOT))).toEqual({});
});
for (const project of ['absent', 'empty', 'relative', 'foreign', 'unnormalized'] as const) {
test.serial(`native project ownership rejects ${project} original-directory evidence after cd`, async () => {
const { f } = changedDirectory();
const value = project === 'absent' ? undefined : project === 'empty' ? '' : project === 'relative' ? 'relative' :
project === 'foreign' ? path.join(f.cwd, 'foreign') : f.cwd + '/.';
const output: any = await withNativeProjectDirectory(value, () => withPublicationClock(() => runPublicationHook(f.input, ROOT)));
expect(output.hookSpecificOutput.permissionDecision).toBe('deny');
});
}
for (const mutation of ['foreign-root', 'sidechain', 'dangling', 'competing-root', 'duplicate-root',
'foreign-session', 'wrong-current-input', 'incomplete-current'] as const) {
test.serial(`native project ownership retains ${mutation} rejection after cd`, async () => {
const { f, rows, save } = changedDirectory();
const current = rows.at(-1)!;
if (mutation === 'foreign-root') rows[0]!.cwd = path.join(f.cwd, 'foreign');
if (mutation === 'sidechain') current.isSidechain = true;
if (mutation === 'dangling') current.parentUuid = randomUUID();
if (mutation === 'competing-root') rows.push({ ...rows[0]!, uuid: randomUUID() });
if (mutation === 'duplicate-root') rows.push({ ...rows[0]! });
if (mutation === 'foreign-session') current.sessionId = randomUUID();
if (mutation === 'wrong-current-input') f.input.tool_input = { file_path: 'eng-phase.md' };
save();
if (mutation === 'incomplete-current') {
const bytes = fs.readFileSync(f.input.transcript_path, 'utf8');
fs.writeFileSync(f.input.transcript_path, bytes.slice(0, -1));
}
const output: any = await withNativeProjectDirectory(f.cwd, () => withPublicationClock(() => runPublicationHook(f.input, ROOT)));
expect(output.hookSpecificOutput.permissionDecision).toBe('deny');
});
}
for (const [phase, next] of [['ceo', 'design'], ['design', 'dx'], ['design', 'eng'], ['dx', 'eng'], ['eng', 'tasks']] as const) {
test(`${phase} closes before ${next}; only the parent publication unlocks entry`, () => {
const f = fixture(phase, next); f.current();
expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Publish the filled') });
f.events.pop(); f.message(); f.current(); expect(f.evaluate()).toEqual({ allow: true });
});
}
for (const capture of captured.cases) test(`original ${capture.phase} omission remains a failure through the complete guard adapter`, () => {
const next = capture.native.next[0] as any, ack = capture.native.next[1] as any;
const close = capture.native.close[1] as any;
expect(close.file.startLine).toBe(1); expect(close.file.numLines).toBe(close.file.totalLines);
const actualTranscript = { status: 'ready' as const, calls: [], assistantMessages: capture.messages };
expect(prematureAutoplanPhaseEntry([...capture.native.close, ...capture.native.next] as any, actualTranscript,
[{ phase: capture.nextPhase as any, requiredPhase: phaseNumber[capture.phase as Phase] as any,
paths: [next.input.file_path], content: ack.file.content }], 0)).toMatchObject({ readToolUseId: next.toolUseId });
// Explicit adapter: current filesystem identities come from actual snapshot
// APIs. Every captured parent message remains complete and unchanged; no
// original artifact or paid outcome is rewritten to manufacture a success.
const f = fixture(capture.phase as Phase, capture.nextPhase);
const nativeInit: any = capture.native.init[0], initResult = JSON.parse((capture.native.init[1] as any).content);
(f.events[0] as any).input.command = nativeInit.input.command
.replace('/home/vercel-sandbox/gstack/bin/gstack-autoplan-snapshot.ts', `${ROOT}/bin/gstack-autoplan-snapshot.ts`)
.replace(initResult.sourcePlan, f.source).replace(initResult.activePlan, f.active).replace(initResult.restorePath, f.restore);
[...capture.native.init, ...capture.native.entry, ...capture.native.close].forEach((e, i) => { f.events[i]!.timestamp = e.timestamp; });
for (const m of capture.messages) f.add({ kind: 'message', text: m.text, timestamp: m.timestamp });
f.events.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); f.reorder();
const closeOrder = f.events.find(e => e.kind === 'result' && e.toolUseId === 'close')!.order;
expect(capture.messages.filter(m => m.timestamp > close.timestamp && m.timestamp < next.timestamp)).toEqual([]);
f.current(); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Publish the filled') });
f.events.pop(); f.message(`Phase ${phaseNumber[capture.phase as Phase]} complete.`); f.current();
expect(f.events.filter(e => e.kind === 'message').at(-1)!.order).toBeGreaterThan(closeOrder);
expect(f.evaluate()).toEqual({ allow: true }); // Counterfactual recovery only.
expect(capture.behaviorCredit).toBe(0);
});
for (const text of ['Phase 2 complete.', 'Phase 1 will be complete.', 'Phase 1 complete if the reviewer succeeds.',
'> Phase 1 complete.', '```\nPhase 1 complete.\n```', ' Phase 1 complete.', 'Example:\nPhase 1 complete.',
'Phase 1 is not complete.', 'Phase 1 complete with all review work finished.\nCorrection: this phase is withdrawn.']) {
test(`does not use noncurrent publication: ${JSON.stringify(text)}`, () => {
const f = fixture(); f.message(text); f.current(); expect(f.evaluate().allow).toBe(false);
});
}
for (const mutation of ['no-close', 'partial-close', 'error-close', 'missing-ack', 'foreign-session', 'duplicate-use',
'pre-close-publication', 'after-current-publication', 'changed-plan', 'modified-immutable-packet', 'aliased-packet', 'wrong-init',
'failed-init', 'missing-init', 'forged-init-command', 'wrong-restore', 'pending-phase', 'ambiguous-order'] as const) {
test(`rejects ${mutation}`, () => {
const f = fixture(); f.message(); f.current();
if (mutation === 'no-close') f.events.splice(4, 2);
if (mutation === 'partial-close') (f.events[5] as any).file.numLines--;
if (mutation === 'error-close') (f.events[5] as any).isError = true;
if (mutation === 'missing-ack') f.events.splice(5, 1);
if (mutation === 'foreign-session') f.events[6]!.sessionId = randomUUID();
if (mutation === 'duplicate-use') f.events.splice(5, 0, structuredClone(f.events[4]!));
if (mutation === 'pre-close-publication') f.events.splice(4, 0, ...f.events.splice(6, 1));
if (mutation === 'after-current-publication') f.events.push(...f.events.splice(6, 1));
if (mutation === 'changed-plan') fs.writeFileSync(f.active, fs.readFileSync(f.active, 'utf8').replace('Keep documented behavior.', 'Change behavior.'));
if (mutation === 'modified-immutable-packet') {
fs.chmodSync(f.packet.closePacketPath, 0o644);
// Windows has no POSIX write-bit contract; preserve the rejection
// through the actual artifact bytes instead of a no-op chmod.
if (process.platform === 'win32') fs.appendFileSync(f.packet.closePacketPath, '\nChanged after the native Read.\n');
}
if (mutation === 'aliased-packet') { const alias = path.join(f.cwd, 'packet'); fs.renameSync(f.packet.closePacketPath, alias); fs.symlinkSync(alias, f.packet.closePacketPath); }
if (mutation === 'wrong-init') (f.events[1] as any).content = JSON.stringify({ ...f.init, activePlan: f.source });
if (mutation === 'failed-init') (f.events[1] as any).isError = true;
if (mutation === 'missing-init') f.events.splice(0, 2);
if (mutation === 'forged-init-command') (f.events[0] as any).input.command = `printf '${JSON.stringify(f.init)}'`;
if (mutation === 'wrong-restore') { fs.chmodSync(f.restore, 0o600); fs.writeFileSync(f.restore, 'foreign'); fs.chmodSync(f.restore, 0o400); }
if (mutation === 'pending-phase') f.events.splice(6, 0, { ...(f.events[7] as any), toolUseId: 'parallel' });
f.reorder(); if (mutation === 'ambiguous-order') f.events[5]!.order = f.events[4]!.order;
expect(f.evaluate().allow).toBe(false);
});
}
test('#3045: no init at all keeps the generic missing-evidence denial', () => {
const f = fixture(); f.message(); f.current(); f.events.splice(0, 2); f.reorder();
const reason = (f.evaluate() as any).reason;
expect(reason).toContain('Complete the existing snapshot init step');
expect(reason).not.toContain('cannot bind');
});
for (const [name, command] of [
['a snapshot scope call', (f: any) => `bun "${ROOT}/bin/gstack-autoplan-snapshot.ts" scope "${f.active}"`],
['an unrelated init', (f: any) => `bun other.ts init "${f.source}"`],
] as const)
test(`#3045 control: ${name} keeps the generic missing-evidence denial`, () => {
const f = fixture(); f.message(); f.current();
(f.events[0] as any).input.command = command(f);
const reason = (f.evaluate() as any).reason;
expect(reason).toContain('Complete the existing snapshot init step');
expect(reason).not.toContain('cannot bind');
});
for (const [name, command] of [
['a substituted tool path', (f: any) => `bun "$(realpath ${ROOT}/bin/gstack-autoplan-snapshot.ts)" init "${f.source}" "${f.active}" "${f.restore}"`],
['a redirected literal init', (f: any) => `bun "${ROOT}/bin/gstack-autoplan-snapshot.ts" init "${f.source}" "${f.active}" "${f.restore}" > /dev/null`],
] as const)
test(`#3045: ${name} is named as the cause`, () => {
const f = fixture(); f.message(); f.current();
(f.events[0] as any).input.command = command(f);
expect((f.evaluate() as any).reason).toContain('which this guard cannot bind');
});
for (const [name, command] of [
['a resolved-variable init piped to tail', (f: any) => `ST=$(bun -e 'console.log(require("fs").realpathSync(process.argv[1]))' "${ROOT}/bin/gstack-autoplan-snapshot.ts"); bun "$ST" init "${f.source}" "${f.active}" "${f.restore}" 2>&1 | tail -5`],
['an init through a variable from an earlier call', (f: any) => `bun "$SNAPSHOT_TOOL" init "${f.source}" "${f.active}" "${f.restore}"`],
['a chained literal init', (f: any) => `bun "${ROOT}/bin/gstack-autoplan-snapshot.ts" init "${f.source}" "${f.active}" "${f.restore}" && echo done`],
] as const)
test(`#3045: ${name} is named as the cause with the literal re-run; only the literal re-run binds`, () => {
const f = fixture(); f.message(); f.current();
(f.events[0] as any).input.command = command(f);
const reason = (f.evaluate() as any).reason;
expect(reason).toContain('which this guard cannot bind');
expect(reason).toContain('`bun "<SNAPSHOT_TOOL>" init "<SOURCE_PLAN>" "<ACTIVE_PLAN>" "<RESTORE_PATH>"`');
f.events.splice(2, 0, { ...(structuredClone(f.events[0]!) as any), toolUseId: 're-init',
input: { command: `bun "${ROOT}/bin/gstack-autoplan-snapshot.ts" init "${f.source}" "${f.active}" "${f.restore}"` } },
{ ...(structuredClone(f.events[1]!) as any), toolUseId: 're-init', content: JSON.stringify({ ...f.init, reused: true }) });
f.reorder(); expect(f.evaluate()).toEqual({ allow: true });
});
test('split successful close ranges through EOF are sufficient', () => {
const f = fixture(); f.events.splice(4, 2); f.read('part1', f.packet.closePacketPath, 1, 10);
f.read('part2', f.packet.closePacketPath, 11); f.message(); f.current(); expect(f.evaluate()).toEqual({ allow: true });
});
test('a later incomplete close supersedes the earlier complete close and publication', () => {
const f = fixture(); f.message();
const newer = preparePhaseClose('ceo', f.active, f.checkpoint, f.restore, f.method);
f.read('new-close', newer.closePacketPath, 1, 1); f.current(); expect(f.evaluate().allow).toBe(false);
});
test('an ordinary foreign close-packet filename cannot supersede the owned phase close', () => {
const f = fixture(), foreign = path.join(f.cwd, 'project', 'close-packet.md');
fs.mkdirSync(path.dirname(foreign)); fs.writeFileSync(foreign, 'Ordinary project data.\n');
f.read('unrelated', foreign); f.message(); f.current(); expect(f.evaluate()).toEqual({ allow: true });
f.events.splice(4, 2); f.reorder();
expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Finish the existing Phase 1 close procedure') });
});
test('a malformed later packet inside the owned phase still invalidates old publication', () => {
const f = fixture(); f.message();
const dir = fs.mkdtempSync(path.join(f.cwd, 'autoplan-ceo-')), packet = path.join(dir, 'close-packet.md');
fs.writeFileSync(packet, 'Malformed current packet.\n', { mode: 0o444 });
f.read('malformed-owned', packet); f.current(); expect(f.evaluate().allow).toBe(false);
});
for (const outcome of ['success', 'pending', 'failed'] as const) test(`active plan mutation after close: ${outcome}`, () => {
const f = fixture(); f.message(); f.use('late-edit', 'Edit', { file_path: f.active, old_string: 'current', new_string: 'changed' });
if (outcome !== 'pending') f.result('late-edit', { isError: outcome === 'failed', content: outcome });
f.current(); expect(f.evaluate().allow).toBe(outcome === 'failed');
});
test('a new initialization excludes earlier phase publications', () => {
const f = fixture(); f.message();
// Same initialized plan can be a new source, but a new external restore
// identity is required by the actual init API. No old completion carries.
const active = path.join(f.cwd, 'new-active.md'), restore = path.join(f.cwd, 'new-restore.md');
const init = initializePlan(f.source, active, restore);
f.use('new-init', 'Bash', { command: `bun "${ROOT}/bin/gstack-autoplan-snapshot.ts" init "${f.source}" "${active}" "${restore}"` });
f.result('new-init', { content: JSON.stringify(init) });
f.read('new-entry', path.join(ROOT, 'autoplan/sections/ceo-phase.md')); f.current();
expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Finish the existing Phase 1 close procedure') });
});
test('cached native Read reenters the current invocation without carrying its prior publication', () => {
const f = fixture(); f.message();
const active = path.join(f.cwd, 'fresh-active.md'), restore = path.join(f.cwd, 'fresh-restore.md');
const fresh = initializePlan(f.source, active, restore);
f.add({ kind: 'end_turn' }); f.add({ kind: 'user_turn', autoplan: true });
f.use('fresh-init', 'Bash', { command: `bun "${ROOT}/bin/gstack-autoplan-snapshot.ts" init "${f.source}" "${active}" "${restore}"` });
f.result('fresh-init', { content: JSON.stringify(fresh) });
const file = path.join(ROOT, 'autoplan/sections/ceo-phase.md');
f.use('cached-entry', 'Read', { file_path: file });
f.result('cached-entry', { content: 'Wasted call — file unchanged since your last Read. Refer to that earlier tool_result instead.', file: { filePath: file } });
f.current();
expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Finish the existing Phase 1 close procedure') });
});
for (const [phase, next] of [['ceo', 'design'], ['design', 'dx'], ['dx', 'eng'], ['eng', 'tasks']] as const) {
test(`cached ${phase} entry retains that phase's publication barrier`, () => {
const f = fixture(phase, next), priorUse: any = structuredClone(f.events[2]), priorResult: any = structuredClone(f.events[3]);
priorUse.toolUseId = priorResult.toolUseId = 'prior-entry';
const result: any = f.events[3];
result.content = 'Wasted call — file unchanged since your last Read. Refer to that earlier tool_result instead.';
result.file = { filePath: priorResult.file.filePath };
f.events.unshift(priorUse, priorResult); f.reorder(); f.current();
expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining(`Publish the filled Phase ${phaseNumber[phase]}`) });
f.events.pop(); f.message(); f.current(); expect(f.evaluate()).toEqual({ allow: true });
});
test(`cached ${phase} close still needs publication after its new ACK`, () => {
const f = fixture(phase, next); f.message();
f.use('cache-close', 'Read', { ...(f.events[4] as any).input });
f.result('cache-close', { content: 'Wasted call — file unchanged since your last Read. Refer to that earlier tool_result instead.', file: { filePath: f.packet.closePacketPath } });
f.current(); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining(`Publish the filled Phase ${phaseNumber[phase]}`) });
f.events.pop(); f.message(); f.current(); expect(f.evaluate()).toEqual({ allow: true });
});
}
test('cached incomplete close ranges never turn into a complete close', () => {
const f = fixture(), prior: any = f.events[5], request: any = f.events[4];
request.input.limit = 1; prior.file.content = prior.file.content.split('\n')[0]; prior.file.numLines = 1;
f.use('cache-close', 'Read', { ...request.input });
f.result('cache-close', { content: 'Wasted call — file unchanged since your last Read. Refer to that earlier tool_result instead.', file: { filePath: f.packet.closePacketPath } });
f.message(); f.current();
expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Read every line') });
});
for (const mutation of ['none', 'cache-chain', 'same-range', 'duplicate-prior-use', 'duplicate-prior-result', 'conflicting-prior-use', 'missing-prior', 'stale-body', 'missing-body', 'wrong-total', 'foreign-path', 'foreign-session', 'pending-prior', 'failed-prior', 'ambiguous-prior', 'late-prior-ACK', 'changed-offset', 'changed-limit', 'failed-cache', 'unknown-cache', 'quoted-cache', 'seeded-cache', 'extra-cache-metadata'] as const) {
test(`native cached Read range authentication: ${mutation}`, () => {
const f = fixture(), use: any = structuredClone(f.events[2]), result: any = structuredClone(f.events[3]);
const content = fs.readFileSync(use.input.file_path, 'utf8');
let prior: any[] = [use, result];
const current: any = { ...structuredClone(use), toolUseId: 'cache', order: 10 };
const ack: any = { kind: 'result', sessionId: f.sessionId, toolUseId: 'cache', isError: false, order: 11,
content: 'Wasted call — file unchanged since your last Read. Refer to that earlier tool_result instead.', file: { filePath: use.input.file_path } };
if (mutation === 'cache-chain') prior.push({ ...structuredClone(current), toolUseId: 'prior-cache', order: 5 }, { ...structuredClone(ack), toolUseId: 'prior-cache', order: 6 });
if (mutation === 'same-range') { use.input.offset = current.input.offset = 2; use.input.limit = current.input.limit = 1; result.file.startLine = 2; result.file.numLines = 1; result.file.content = content.split('\n')[1]; }
if (mutation === 'duplicate-prior-use') prior.push({ ...structuredClone(use), order: 4 });
if (mutation === 'duplicate-prior-result') prior.push({ ...structuredClone(result), order: 4 });
if (mutation === 'conflicting-prior-use') prior.push({ ...structuredClone(use), input: { file_path: use.input.file_path + '.foreign' }, order: 4 });
if (mutation === 'missing-prior') prior = [];
if (mutation === 'stale-body') result.file.content += 'changed';
if (mutation === 'missing-body') delete result.file.content;
if (mutation === 'wrong-total') result.file.totalLines++;
if (mutation === 'foreign-path') use.input.file_path += '.foreign';
if (mutation === 'foreign-session') use.sessionId = result.sessionId = 'foreign';
if (mutation === 'pending-prior') prior.pop();
if (mutation === 'failed-prior') result.isError = true;
if (mutation === 'ambiguous-prior') prior.push({ ...result, isError: true, order: 4 });
if (mutation === 'late-prior-ACK') result.order = 12;
if (mutation === 'changed-offset') current.input.offset = 2;
if (mutation === 'changed-limit') current.input.limit = 1;
if (mutation === 'failed-cache') ack.isError = true;
if (mutation === 'unknown-cache') delete ack.isError;
if (mutation === 'quoted-cache') ack.content = `"${ack.content}"`;
if (mutation === 'seeded-cache') ack.content = '<system-reminder>This file is already in your context (see "Contents" above) and has not changed on disk. Use that content instead of re-reading.</system-reminder>';
if (mutation === 'extra-cache-metadata') ack.file.content = 'unverified';
const range = autoplanReadRange(current, ack, content, prior);
if (['none', 'cache-chain', 'same-range', 'duplicate-prior-use', 'duplicate-prior-result'].includes(mutation)) expect(range).toEqual(mutation === 'same-range' ? { start: 2, end: 2 } : { start: 1, end: content.split('\n').length });
else expect(range).toBeUndefined();
});
}
for (const mutation of ['missing-prior', 'stale-body', 'foreign-body', 'failed-cache', 'seeded-cache'] as const) test(`unverified cached initial entry cannot bypass the next phase: ${mutation}`, () => {
const f = fixture(), oldUse: any = structuredClone(f.events[2]), oldResult: any = structuredClone(f.events[3]);
oldUse.toolUseId = oldResult.toolUseId = 'old-read';
f.events.splice(4); (f.events[3] as any).file = { filePath: oldUse.input.file_path };
(f.events[3] as any).content = 'Wasted call — file unchanged since your last Read. Refer to that earlier tool_result instead.';
if (mutation === 'stale-body') oldResult.file.content += 'different';
if (mutation === 'foreign-body') oldUse.input.file_path += '.foreign';
if (mutation === 'failed-cache') (f.events[3] as any).isError = true;
if (mutation === 'seeded-cache') (f.events[3] as any).content = '<system-reminder>This file is already in your context</system-reminder>';
if (mutation !== 'missing-prior') f.events.unshift(oldUse, oldResult);
f.reorder(); f.current(); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Phase 1') });
f.input.tool_input.file_path = path.join(ROOT, 'autoplan/sections/ceo-phase.md');
(f.events.at(-1) as any).input = f.input.tool_input;
expect(f.evaluate()).toEqual({ allow: true });
});
test('an invocation with no observed phase cannot skip its initial CEO entry', () => {
const f = fixture(); f.events.splice(2); f.current();
expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Phase 1') });
});
for (const field of ['methodology', 'checkpoint', 'packet-phase'] as const) test(`a ${field} identity from another invocation cannot grant entry`, () => {
const f = fixture(); f.message(); f.current();
if (field === 'methodology') {
const file = path.join(path.dirname(f.method), 'methodology.json'), manifest = JSON.parse(fs.readFileSync(file, 'utf8'));
manifest.restorePath = f.source; fs.chmodSync(file, 0o644); fs.writeFileSync(file, JSON.stringify(manifest) + '\n'); fs.chmodSync(file, 0o444);
} else if (field === 'checkpoint') {
fs.chmodSync(f.checkpoint, 0o644); fs.writeFileSync(f.checkpoint, 'different baseline'); fs.chmodSync(f.checkpoint, 0o444);
} else {
const file = f.packet.closePacketPath; fs.chmodSync(file, 0o644);
fs.writeFileSync(file, fs.readFileSync(file, 'utf8').replace('"phase":"ceo"', '"phase":"design"')); fs.chmodSync(file, 0o444);
}
expect(f.evaluate().allow).toBe(false);
});
test('same-phase and ordinary methodology reads do not require a close', () => {
const f = fixture(); f.events.splice(4, 2); f.input.tool_input.file_path = path.join(ROOT, 'autoplan/sections/ceo-phase.md');
f.current(); expect(f.evaluate()).toEqual({ allow: true });
f.input.tool_input.file_path = f.method; expect(f.evaluate()).toEqual({ allow: true });
});
test('installed symlink identity is accepted, a different installation is denied', () => {
const f = fixture(); f.message();
const registry = path.join(f.cwd, 'registry'); fs.mkdirSync(registry); fs.symlinkSync(path.join(ROOT, 'autoplan'), path.join(registry, 'autoplan'));
f.input.tool_input.file_path = path.join(registry, 'autoplan/sections/design-phase.md'); f.current(); expect(f.evaluate().allow).toBe(true);
fs.unlinkSync(path.join(registry, 'autoplan')); fs.mkdirSync(path.join(registry, 'autoplan/sections'), { recursive: true });
fs.copyFileSync(path.join(ROOT, 'autoplan/sections/design-phase.md'), f.input.tool_input.file_path as string);
expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('different or unavailable installation') });
});
test('an explicit child is outside parent publication authority', () => {
const f = fixture(); f.current(); f.input.agent_id = 'reviewer-child'; expect(f.evaluate()).toEqual({ allow: true });
});
test('same native timestamp still uses append order for report then Read', () => {
const f = fixture(); f.message(); f.current(); f.events.forEach(e => { e.timestamp = new Date(clock).toISOString(); });
expect(f.evaluate()).toEqual({ allow: true });
});
test('a terminal turn followed by an authenticated new human request releases the old invocation', () => {
const f = fixture(); f.add({ kind: 'end_turn' }); f.add({ kind: 'user_turn', autoplan: false }); f.current();
expect(f.evaluate()).toEqual({ allow: true });
(f.events[7] as any).autoplan = true; expect(f.evaluate().allow).toBe(false);
});
test('end_turn alone and metadata-free tool results cannot release the invocation', () => {
const f = fixture(); f.add({ kind: 'end_turn' }); f.result('other', { content: 'continue' }); f.current();
expect(f.evaluate().allow).toBe(false);
});
test('a real same-restore init re-arms without erasing an outstanding publication', () => {
const f = fixture(); f.add({ kind: 'end_turn' }); f.add({ kind: 'user_turn', autoplan: false });
f.use('re-init', 'Bash', (f.events[0] as any).input); f.result('re-init', { content: JSON.stringify({ ...f.init, reused: true }) });
f.current(); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Publish the filled') });
});
test.serial('unavailable journal polls every 50ms for the full 2000ms budget', async () => {
const f = fixture();
await withNativeProjectDirectory(f.cwd, () => withPublicationClock(async () => {
const started = performance.now(), timer = jest.spyOn(globalThis, 'setTimeout');
try {
const output: any = await runPublicationHook(f.input, ROOT);
expect(performance.now() - started).toBe(2_000);
expect(timer.mock.calls.map(([, delay]) => delay)).toEqual(Array(40).fill(50));
expect(output.hookSpecificOutput.permissionDecision).toBe('deny');
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('no missing-publication conclusion');
} finally { timer.mockRestore(); }
}));
});
test.serial('the publication clock restores native timers after success and failure', async () => {
const timeout = globalThis.setTimeout, immediate = globalThis.setImmediate, now = performance.now;
const failure = new Error('fixture failure');
const project = process.env.CLAUDE_PROJECT_DIR;
for (const outcome of ['success', 'throw', 'over-budget']) {
const pending = withNativeProjectDirectory(undefined, () => withPublicationClock(async () => {
await new Promise(resolve => setTimeout(resolve, outcome === 'over-budget' ? 2_050 : 50));
if (outcome === 'throw') throw failure;
return 'settled';
}));
const result = await pending.then(value => value, error => error);
if (outcome === 'throw') expect(result).toBe(failure);
else if (outcome === 'over-budget') {
expect(result).toBeInstanceOf(Error);
expect(result.message).toContain('did not settle within its 2000ms polling budget');
} else expect(result).toBe('settled');
expect(jest.isFakeTimers()).toBe(false);
expect(globalThis.setTimeout).toBe(timeout);
expect(globalThis.setImmediate).toBe(immediate);
expect(performance.now).toBe(now);
expect(process.env.CLAUDE_PROJECT_DIR).toBe(project);
}
});
test.serial('the owned journal can arrive asynchronously on the last poll before the deadline', async () => {
const f = fixture(); f.message(); f.current();
await withNativeProjectDirectory(f.cwd, () => withPublicationClock(async () => {
const started = performance.now();
setTimeout(() => f.journal(), 1_950);
expect(await runPublicationHook(f.input, ROOT)).toEqual({});
expect(performance.now() - started).toBe(1_950);
const decoded = readOwnedClaudePublicTranscript(f.input.transcript_path, f.cwd, f.sessionId);
expect(decoded.transcript.status).toBe('ready');
expect(decoded.events.some(e => e.kind === 'use' && e.toolUseId === 'next')).toBe(true);
}));
});
test('the actual owned native reader and asynchronous hook admit a flushed same-response report', async () => {
const f = fixture(); f.message(); f.current();
setTimeout(() => f.journal(), 100);
expect(await runPublicationHook(f.input, ROOT)).toEqual({});
const decoded = readOwnedClaudePublicTranscript(f.input.transcript_path, f.cwd, f.sessionId);
expect(decoded.transcript.status).toBe('ready');
const report = decoded.events.find(e => e.kind === 'message')!, current = decoded.events.find(e => e.kind === 'use' && e.toolUseId === 'next')!;
expect(report.order).toBeLessThan(current.order);
});
test('a native in-flight range Read uses the established phase without inventing a journal record', async () => {
const f = fixture();
const skill = path.join(f.cwd, 'streamed-skill', 'SKILL.md');
fs.mkdirSync(path.dirname(skill));
fs.writeFileSync(skill, '---\nname: plan-ceo-review\n---\n## Review Sections\n' +
Array.from({ length: 1_200 }, (_, i) => `Review criterion ${i + 1}`).join('\n') + '\n');
const method = prepareMethodology('ceo', skill, f.restore).methodologyPath;
f.input.tool_input = { file_path: method, offset: 601, limit: 600 };
f.journal();
const before = readOwnedClaudePublicTranscript(f.input.transcript_path, f.cwd, f.sessionId);
expect(before.transcript.status).toBe('ready');
expect(before.events.some(e => e.kind === 'use' && e.toolUseId === f.input.tool_use_id)).toBe(false);
// Pinned Claude dispatches the native hook after the complete tool block,
// before message_stop makes that current use available in the journal.
expect(await withNativeProjectDirectory(f.cwd, () => runPublicationHook(f.input, ROOT))).toEqual({});
expect(readOwnedClaudePublicTranscript(f.input.transcript_path, f.cwd, f.sessionId)).toEqual(before);
});
test('an in-flight Read can revisit an earlier established phase', async () => {
const f = fixture('design', 'ceo'); f.journal();
expect(await withNativeProjectDirectory(f.cwd, () => runPublicationHook(f.input, ROOT))).toEqual({});
});
// CEO-1 replaced the pending-Read rule: the payload stands in for an unflushed
// current call, so a first Phase 1 entry is allowed from it (it needs no prior
// report), and a tool pending in an earlier message is journal lag (UC1: unverified).
for (const [kind, expected] of [['initial-entry', 'allow'], ['forged-prior-range', 'allow'], ['pending-prior-entry', 'journal_lag']] as const)
test.serial(`an in-flight native Read under CEO-1: ${kind} is ${expected}`, async () => {
const f = fixture(); f.input.tool_input = { file_path: f.method, offset: 1, limit: 1 };
if (kind === 'initial-entry') f.events.splice(2);
if (kind === 'pending-prior-entry') f.use('prior-pending', 'Read', { file_path: f.method });
if (kind === 'forged-prior-range') for (const event of f.events)
if (event.kind === 'result' && event.file) (event.file as { content: string }).content += '\nForged native range.';
f.journal();
const output: any = await withNativeProjectDirectory(f.cwd, () => withPublicationClock(() => runPublicationHook(f.input, ROOT)));
if (expected === 'allow') expect(output).toEqual({});
else {
expect(output.hookSpecificOutput.permissionDecision).toBeUndefined();
expect(output.systemMessage).toContain('code journal_lag');
}
});
for (const kind of ['new-phase', 'agent', 'foreign-methodology', 'duplicate',
'foreign-session', 'orphan-current-result', 'unpublished-predecessor', 'rearmed-human',
'malformed-journal', 'symlinked-journal'] as const)
test.serial(`an in-flight native Read does not bypass ${kind}`, async () => {
const f = fixture(); f.input.tool_input = { file_path: f.method, offset: 1, limit: 1 };
if (kind === 'new-phase') { f.message(); f.input.tool_input = { file_path: path.join(ROOT, 'autoplan/sections/design-phase.md') }; }
if (kind === 'agent') { f.input.tool_name = 'Agent'; f.input.tool_input = { prompt: 'You are the independent CEO reviewer for this phase.\n' }; }
if (kind === 'foreign-methodology') f.input.tool_input = { file_path: fixture().method };
if (kind === 'duplicate') f.events.push({ ...f.events[2]!, order: f.events.length });
if (kind === 'orphan-current-result') f.result(f.input.tool_use_id, { content: 'Forged current acknowledgment.' });
if (kind === 'unpublished-predecessor') {
const later = path.join(ROOT, 'autoplan/sections/design-phase.md');
f.read('unguarded-later-entry', later); f.input.tool_input = { file_path: later };
}
const { rows, record } = f.journal();
if (kind === 'rearmed-human') {
rows.push(record('assistant', [], { message: { role: 'assistant', content: [], stop_reason: 'end_turn' } }),
record('user', '<command-message>autoplan</command-message>\n<command-name>/autoplan</command-name>',
{ origin: { kind: 'human' }, promptId: randomUUID() }));
fs.writeFileSync(f.input.transcript_path, rows.map(r => JSON.stringify(r)).join('\n') + '\n');
}
if (kind === 'malformed-journal') fs.appendFileSync(f.input.transcript_path, 'not a native JSON record\n');
if (kind === 'symlinked-journal') {
const target = path.join(path.dirname(f.input.transcript_path), 'aliased.jsonl');
fs.renameSync(f.input.transcript_path, target); fs.symlinkSync(target, f.input.transcript_path);
}
if (kind === 'foreign-session') f.input.session_id = randomUUID();
const output: any = await withNativeProjectDirectory(f.cwd, () => withPublicationClock(() => runPublicationHook(f.input, ROOT)));
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
expect(readOwnedClaudePublicTranscript(f.input.transcript_path, f.cwd, f.sessionId).events
.filter(e => e.kind === 'use' && e.toolUseId === f.input.tool_use_id)).toHaveLength(0);
});
test('owned reader refuses a symlink and incomplete current record', async () => {
const f = fixture(); f.message(); f.current(); f.journal();
const bytes = fs.readFileSync(f.input.transcript_path, 'utf8'); fs.writeFileSync(f.input.transcript_path, bytes.slice(0, -1));
expect(readOwnedClaudePublicTranscript(f.input.transcript_path, f.cwd, f.sessionId).events.some(e => e.kind === 'use' && e.toolUseId === 'next')).toBe(false);
const other = path.join(f.cwd, 'other.jsonl'); fs.renameSync(f.input.transcript_path, other); fs.symlinkSync(other, f.input.transcript_path);
expect(readOwnedClaudePublicTranscript(f.input.transcript_path, f.cwd, f.sessionId).transcript.status).toBe('error');
});
for (const kind of ['human', 'missing-origin', 'meta', 'tool-result', 'no-end-turn', 'compact-summary'] as const)
test(`native lifecycle projection: ${kind}`, async () => {
const f = fixture(), { rows, record } = f.journal();
if (kind !== 'no-end-turn') rows.push(record('assistant', [{ type: 'text', text: 'Work ended.' }], {
message: { role: 'assistant', id: 'msg_terminal', stop_reason: 'end_turn', content: [{ type: 'text', text: 'Work ended.' }] },
}));
rows.push(record('system', undefined, { type: 'system', subtype: 'turn_duration', message: undefined }));
const user = record('user', 'Read the driver for a new unrelated task.', {
origin: { kind: 'human' }, promptSource: 'typed', promptId: randomUUID(),
}) as any;
if (kind === 'missing-origin') delete user.origin;
if (kind === 'meta') user.isMeta = true;
if (kind === 'tool-result') { delete user.origin; user.message.content = [{ type: 'tool_result', tool_use_id: 'answer', content: 'Continue' }]; }
if (kind === 'compact-summary') { delete user.origin; user.isMeta = true; user.message.content = 'Phase 1 complete.'; }
rows.push(user, record('assistant', [{ type: 'tool_use', id: 'next', name: 'Read', input: f.input.tool_input }]));
fs.writeFileSync(f.input.transcript_path, rows.map(x => JSON.stringify(x)).join('\n') + '\n');
const decoded = readOwnedClaudePublicTranscript(f.input.transcript_path, f.cwd, f.sessionId);
expect(decoded.events.filter(e => e.kind === 'user_turn')).toHaveLength(kind === 'human' || kind === 'no-end-turn' ? 2 : 1);
const result: any = await runPublicationHook(f.input, ROOT);
if (kind === 'human') expect(result).toEqual({});
else expect(result.hookSpecificOutput?.permissionDecision).toBe('deny');
});
test('compaction keeps real parent ancestry; its summary supplies no publication', async () => {
const f = fixture(), { rows, record } = f.journal();
const prior = rows.at(-1)!;
const boundary: any = record('system', undefined, { type: 'system', subtype: 'compact_boundary', parentUuid: null,
logicalParentUuid: prior.uuid, message: undefined });
rows.push(boundary, record('user', 'Phase 1 complete.', { isMeta: true }));
const current: any = record('assistant', [{ type: 'tool_use', id: 'next', name: 'Read', input: f.input.tool_input }]);
rows.push(current); fs.writeFileSync(f.input.transcript_path, rows.map(x => JSON.stringify(x)).join('\n') + '\n');
const before: any = await runPublicationHook(f.input, ROOT); expect(before.hookSpecificOutput?.permissionDecision).toBe('deny');
// A real publication follows the retained close ACK, even with compaction.
const message: any = { ...current, uuid: randomUUID(), message: { role: 'assistant', content: [{ type: 'text', text: 'Phase 1 complete.' }] } };
current.parentUuid = message.uuid; rows.splice(-1, 0, message);
fs.writeFileSync(f.input.transcript_path, rows.map(x => JSON.stringify(x)).join('\n') + '\n');
expect(await runPublicationHook(f.input, ROOT)).toEqual({});
boundary.logicalParentUuid = randomUUID();
fs.writeFileSync(f.input.transcript_path, rows.map(x => JSON.stringify(x)).join('\n') + '\n');
expect(readOwnedClaudePublicTranscript(f.input.transcript_path, f.cwd, f.sessionId).events.some(e => e.kind === 'use' && e.toolUseId === 'next')).toBe(false);
});
});
describe('Autoplan authenticated phase consumption', () => {
function nextSnapshot(f: ReturnType<typeof fixture>) {
const dir = path.join(f.cwd, 'next-skill'); fs.mkdirSync(dir);
const skill = path.join(dir, 'SKILL.md');
fs.writeFileSync(skill, '---\nname: plan-eng-review\n---\n## Review Sections\nApply every engineering criterion.\n');
const method = prepareMethodology('eng', skill, f.restore).methodologyPath;
return { method, snapshot: createSnapshot('eng', f.active, f.restore, method) };
}
test('captured complete Bash driver delivery preserves the original earliest DX omission', () => {
const request = consumption.events.find(e => e.kind === 'use' && e.name === 'Bash')!;
const pair = consumption.events.filter(e => e.toolUseId === request.toolUseId);
const transcript = { status: 'ready' as const, calls: [], assistantMessages: consumption.messages };
expect(prematureAutoplanPhaseEntry(pair as any, transcript, [{ phase: 'eng', requiredPhase: 2.5,
paths: [path.join(ROOT, 'autoplan/sections/eng-phase.md')], content: consumption.driverContent }], 0))
.toMatchObject({ readToolUseId: request.toolUseId, requiredPhase: 2.5 });
});
test('an owned next methodology Read cannot bypass the missing current parent publication', () => {
const f = fixture('dx', 'eng'), next = nextSnapshot(f);
f.input.tool_input = { file_path: next.method }; f.current();
expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Publish the filled Phase 2.5') });
});
test('an exact next native Agent dispatch cannot bypass the missing current parent publication', () => {
const f = fixture('dx', 'eng'), next = nextSnapshot(f);
f.input.tool_name = 'Agent'; f.input.tool_input = { prompt: next.snapshot.nativeDispatchPrompt };
f.use('next', 'Agent', f.input.tool_input);
expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Publish the filled Phase 2.5') });
});
const nextInput = (f: ReturnType<typeof fixture>, file: string) => { f.input.tool_input = { file_path: file }; f.current(); };
function target(f: ReturnType<typeof fixture>, kind: string) {
const n = nextSnapshot(f);
return kind === 'methodology' ? n.method : kind === 'methodology.json' ? path.join(path.dirname(n.method), kind) :
path.join(path.dirname(n.snapshot.snapshotPath), kind === 'implementation' ? 'eng-implementation.md' : kind);
}
for (const kind of ['methodology', 'methodology.json', 'native-prompt.md', 'snapshot.json', 'source-implementation.md', 'implementation']) {
test(`owned ${kind} consumption needs close and publication, then permits recovery`, () => {
const f = fixture('dx', 'eng'), file = target(f, kind);
const close = f.events.splice(4, 2); f.reorder(); nextInput(f, file);
expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Finish the existing Phase 2.5 close') });
f.events.pop(); f.events.push(...close); f.reorder(); f.current();
expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Publish the filled Phase 2.5') });
f.events.pop(); f.message(); f.current(); expect(f.evaluate()).toEqual({ allow: true });
});
}
test('actual captured parent wording does not publish DX or become tool-output credit', () => {
const f = fixture('dx', 'eng'), next = nextSnapshot(f);
for (const message of consumption.messages) f.message(message.text);
f.use('print-report', 'Bash', { command: 'print-report' }); f.result('print-report', { content: 'Phase 2.5 complete.' });
nextInput(f, next.method);
expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Publish the filled Phase 2.5') });
});
for (const by of ['methodology', 'snapshot', 'close', 'Agent'] as const) test(`an owned ${by} delivery infers the actual phase without a driver Read`, () => {
const f = fixture('dx', 'eng'); f.events.splice(2, 2); f.reorder();
if (by !== 'close') {
const prior = f.events.splice(2);
if (by === 'Agent') {
const manifest = JSON.parse(fs.readFileSync(path.join(path.dirname(f.checkpoint), 'snapshot.json'), 'utf8'));
f.use('native-dx', 'Agent', { prompt: manifest.nativeDispatchPrompt }); f.result('native-dx', { content: 'Native reviewer launched.' });
} else f.read('owned-entry', by === 'methodology' ? f.method : path.join(path.dirname(f.checkpoint), 'snapshot.json'));
f.events.push(...prior); f.reorder();
}
f.current(); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Publish the filled Phase 2.5') });
f.events.pop(); f.message(); f.current(); expect(f.evaluate()).toEqual({ allow: true });
});
test('an already delivered unguarded future methodology does not erase the unpublished predecessor', () => {
const f = fixture('dx', 'eng'), next = nextSnapshot(f); f.read('unguarded-future', next.method);
f.input.tool_name = 'Agent'; f.input.tool_input = { prompt: next.snapshot.nativeDispatchPrompt }; f.use('next', 'Agent', f.input.tool_input);
expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Publish the filled Phase 2.5') });
f.events.pop(); f.message(); f.use('next', 'Agent', f.input.tool_input); expect(f.evaluate()).toEqual({ allow: true });
});
for (const kind of ['pending', 'error', 'foreign-session', 'incomplete'] as const) test(`${kind} future delivery cannot advance phase state`, () => {
const f = fixture('dx', 'eng'), next = nextSnapshot(f); f.read('future', next.method);
const ack = f.events.at(-1)! as any;
if (kind === 'pending') f.events.pop();
if (kind === 'error') ack.isError = true;
if (kind === 'foreign-session') ack.sessionId = 'foreign';
if (kind === 'incomplete') ack.file.content += 'not-delivered';
f.input.tool_name = 'Agent'; f.input.tool_input = { prompt: next.snapshot.nativeDispatchPrompt }; f.use('next', 'Agent', f.input.tool_input);
expect(f.evaluate().allow).toBe(false);
});
for (const invalid of ['denied-Agent', 'failed-Read', 'malformed-close'] as const) test(`${invalid} cannot poison same-phase repair`, () => {
const f = fixture('dx', 'eng');
if (invalid === 'denied-Agent') { f.use('bad', 'Agent', { prompt: 'You are the independent ENG reviewer for this phase.\nRead file: "/foreign/native-prompt.md"' }); f.result('bad', { isError: true }); }
if (invalid === 'failed-Read') { f.use('bad', 'Read', { file_path: path.join(f.cwd, 'autoplan-eng-foreign', 'methodology.md') }); f.result('bad', { isError: true }); }
if (invalid === 'malformed-close') {
const d = fs.mkdtempSync(path.join(f.cwd, 'autoplan-dx-')), file = path.join(d, 'close-packet.md');
fs.writeFileSync(file, 'Malformed owned close.\n', { mode: 0o444 }); f.read('bad', file);
}
nextInput(f, f.method); expect(f.evaluate()).toEqual({ allow: true });
f.events.pop(); f.input.tool_input = { file_path: path.join(ROOT, 'autoplan/sections/eng-phase.md') }; f.current();
expect(f.evaluate().allow).toBe(false);
f.events.pop(); const repaired = preparePhaseClose('dx', f.active, f.checkpoint, f.restore, f.method);
f.read('repaired-close', repaired.closePacketPath); f.message(); f.current(); expect(f.evaluate()).toEqual({ allow: true });
});
for (const invalid of ['foreign-root', 'foreign-restore', 'foreign-active', 'mutable', 'aliased', 'wrong-native', 'partial-prompt'] as const) test(`${invalid} current phase consumption remains denied`, () => {
const f = fixture('dx', 'eng'), next = nextSnapshot(f); f.message();
if (invalid === 'foreign-root') { nextInput(f, path.join(f.cwd, 'foreign', 'autoplan-eng-methodology-data', 'methodology.md')); }
else if (invalid === 'partial-prompt' || invalid === 'wrong-native') {
f.input.tool_name = 'Agent'; f.input.tool_input = { prompt: next.snapshot.nativeDispatchPrompt + (invalid === 'partial-prompt' ? '\nChanged' : '') };
if (invalid === 'wrong-native') { fs.chmodSync(next.snapshot.nativePromptPath, 0o644); fs.appendFileSync(next.snapshot.nativePromptPath, 'Changed'); fs.chmodSync(next.snapshot.nativePromptPath, 0o444); }
f.use('next', 'Agent', f.input.tool_input);
} else {
const manifest = path.join(path.dirname(next.method), 'methodology.json');
if (invalid === 'foreign-restore') { const x = JSON.parse(fs.readFileSync(manifest, 'utf8')); x.restorePath = f.source; fs.chmodSync(manifest, 0o644); fs.writeFileSync(manifest, JSON.stringify(x)); fs.chmodSync(manifest, 0o444); }
if (invalid === 'foreign-active') { const file = path.join(path.dirname(next.snapshot.snapshotPath), 'snapshot.json'); const x = JSON.parse(fs.readFileSync(file, 'utf8')); x.activePlan = f.source; fs.chmodSync(file, 0o644); fs.writeFileSync(file, JSON.stringify(x)); fs.chmodSync(file, 0o444); }
if (invalid === 'mutable') {
fs.chmodSync(next.method, 0o644);
// The manifest hash remains mandatory on Windows, where mode bits do not.
if (process.platform === 'win32') fs.appendFileSync(next.method, '\nChanged after methodology preparation.\n');
}
if (invalid === 'aliased') { const file = next.method + '.saved'; fs.renameSync(next.method, file); fs.symlinkSync(file, next.method); }
nextInput(f, invalid === 'foreign-active' ? next.snapshot.nativePromptPath : next.method);
}
expect(f.evaluate().allow).toBe(false);
});
test('captured report-only Edit preserves implementation and accepted records, but still needs publication', () => {
const f = fixture('dx', 'eng'), edit = consumption.events.find(e => e.kind === 'use' && e.name === 'Edit')!.input as any;
fs.appendFileSync(f.active, edit.old_string + '\n');
f.use('report-edit', 'Edit', { ...edit, file_path: f.active });
fs.writeFileSync(f.active, fs.readFileSync(f.active, 'utf8').replace(edit.old_string, edit.new_string));
f.result('report-edit', { content: 'File updated.' }); f.current();
expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Publish the filled Phase 2.5') });
f.events.pop(); f.message(); f.current(); expect(f.evaluate()).toEqual({ allow: true });
});
for (const change of ['accepted-None', 'accepted-unapplied', 'implementation', 'ambiguous-new', 'replace-all', 'missing-input', 'Write', 'pending-Edit', 'unknown-result'] as const) test(`post-close ${change} requires a new verified close`, () => {
const f = fixture(); f.message();
let old = 'None: retain the current behavior.', next = 'None: silently changed decision.';
if (change === 'implementation') { old = 'Keep documented behavior.'; next = 'Implement new behavior.'; }
if (change === 'accepted-unapplied') next = '- Add a new capability.\n';
if (change === 'ambiguous-new') next = 'Review record';
const input: any = { file_path: f.active, old_string: old, new_string: next };
if (change === 'replace-all') input.replace_all = true;
if (change === 'missing-input') delete input.old_string;
if (change === 'Write') { delete input.old_string; delete input.new_string; input.content = fs.readFileSync(f.active, 'utf8'); }
f.use('mutation', change === 'Write' ? 'Write' : 'Edit', input);
if (change !== 'Write' && change !== 'pending-Edit') fs.writeFileSync(f.active, fs.readFileSync(f.active, 'utf8').replace(old, next));
if (change !== 'pending-Edit') f.result('mutation', { ...(change === 'unknown-result' ? { isError: undefined } : {}), content: 'Updated.' });
f.current(); expect(f.evaluate().allow).toBe(false);
});
test('multiple exact report-only Edits replay in reverse native order', () => {
const f = fixture(); fs.appendFileSync(f.active, 'Original report note.\n');
for (const [i, old, next] of [[1, 'Original report note.', 'Revised report note.'], [2, 'Revised report note.', 'Final report note.']] as const) {
f.use(`edit-${i}`, 'Edit', { file_path: f.active, old_string: old, new_string: next });
fs.writeFileSync(f.active, fs.readFileSync(f.active, 'utf8').replace(old, next)); f.result(`edit-${i}`, { content: 'Updated.' });
}
f.message(); f.current(); expect(f.evaluate()).toEqual({ allow: true });
});
test('ordinary repair tools, same-phase artifacts and unrelated child Agents remain available', () => {
const f = fixture('dx', 'eng'); fs.writeFileSync(f.active, fs.readFileSync(f.active, 'utf8').replace('Keep documented behavior.', 'Repair current behavior.'));
nextInput(f, f.method); expect(f.evaluate()).toEqual({ allow: true });
f.input.tool_name = 'Bash'; f.input.tool_input = { command: 'current-phase repair' }; expect(f.evaluate()).toEqual({ allow: true });
f.input.tool_name = 'Agent'; f.input.tool_input = { prompt: 'Investigate this current-phase prerequisite.' }; expect(f.evaluate()).toEqual({ allow: true });
});
test('rearm excludes disarmed future Reads and still requires the original outstanding publication', () => {
const f = fixture('dx', 'eng'), next = nextSnapshot(f);
f.add({ kind: 'end_turn' }); f.add({ kind: 'user_turn', autoplan: false });
f.read('unrelated-future', next.method);
f.use('re-init', 'Bash', (f.events[0] as any).input); f.result('re-init', { content: JSON.stringify({ ...f.init, reused: true }) });
nextInput(f, next.method); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Publish the filled Phase 2.5') });
});
test('bound future native dispatch reaches the actual asynchronous hook reader', async () => {
const f = fixture('dx', 'eng'), next = nextSnapshot(f);
f.input.tool_name = 'Agent'; f.input.tool_input = { prompt: next.snapshot.nativeDispatchPrompt }; f.use('next', 'Agent', f.input.tool_input); f.journal();
expect(await runPublicationHook(f.input, ROOT)).toMatchObject({ hookSpecificOutput: { permissionDecision: 'deny' } });
f.events.pop(); f.message(); f.use('next', 'Agent', f.input.tool_input); f.journal();
expect(await runPublicationHook(f.input, ROOT)).toEqual({});
});
function audit(f: ReturnType<typeof fixture>) {
return prematureAutoplanPhaseEntry(f.events.filter(e => e.kind === 'use' || e.kind === 'result') as any,
{ status: 'ready', calls: [], assistantMessages: f.events.filter(e => e.kind === 'message') as any },
[{ phase: 'eng', requiredPhase: 2.5, paths: [path.join(ROOT, 'autoplan/sections/eng-phase.md')],
content: fs.readFileSync(path.join(ROOT, 'autoplan/sections/eng-phase.md'), 'utf8') }], 0);
}
test('Bash terminal whitespace transport preserves complete driver delivery', () => {
const f = fixture('dx', 'eng');
const file = path.join(ROOT, 'autoplan/sections/eng-phase.md');
f.use('native-cat', 'Bash', { command: `cat "${file}"` });
f.result('native-cat', { content: fs.readFileSync(file, 'utf8').trimEnd() });
expect(audit(f)).toMatchObject({ phase: 'eng', readToolUseId: 'native-cat' });
});
for (const ending of ['', '\n', '\r\n', ' \t\r\n', '\n\n\n']) test(`complete Bash driver accepts only terminal whitespace normalization: ${JSON.stringify(ending)}`, () => {
const f = fixture('dx', 'eng'), content = fs.readFileSync(path.join(ROOT, 'autoplan/sections/eng-phase.md'), 'utf8');
f.use('transport', 'Bash', { command: 'opaque-native-driver-loader' });
f.result('transport', { content: content.trimEnd() + ending });
expect(audit(f)).toMatchObject({ readToolUseId: 'transport' });
f.message(); expect(audit(f)).toMatchObject({ readToolUseId: 'transport' });
});
for (const change of ['missing-first', 'missing-last', 'internal-space', 'leading-space', 'same-line-prefix', 'same-line-suffix'] as const) test(`meaningful Bash driver content remains strict: ${change}`, () => {
const f = fixture('dx', 'eng'), original = fs.readFileSync(path.join(ROOT, 'autoplan/sections/eng-phase.md'), 'utf8').trimEnd();
const text = change === 'missing-first' ? original.slice(1) : change === 'missing-last' ? original.slice(0, -1) :
change === 'internal-space' ? original.replace('Read ', 'Read ') : change === 'leading-space' ? ' ' + original :
change === 'same-line-prefix' ? 'PREFIX' + original : original + 'SUFFIX';
expect(text).not.toBe(original);
f.use('invalid-transport', 'Bash', { command: 'opaque-loader' }); f.result('invalid-transport', { content: text });
expect(audit(f)).toBeNull();
});
test('cached future driver delivery is visible to the detector at the current native request', () => {
const f = fixture('dx', 'eng'), file = path.join(ROOT, 'autoplan/sections/eng-phase.md');
f.read('earlier-body', file);
const prior = f.events.splice(-2); f.events.unshift(...prior); f.reorder();
// Earlier body predates this audit window. The new native cache ACK is the
// current entry; an eventual report does not erase that earlier violation.
prior.forEach(e => { e.timestamp = new Date(clock - 100).toISOString(); });
f.use('cached-future', 'Read', { file_path: file, offset: 1 });
f.result('cached-future', { content: 'Wasted call — file unchanged since your last Read. Refer to that earlier tool_result instead.', file: { filePath: file } });
const detect = () => prematureAutoplanPhaseEntry(f.events.filter(e => e.kind === 'use' || e.kind === 'result') as any,
{ status: 'ready', calls: [], assistantMessages: f.events.filter(e => e.kind === 'message') as any },
[{ phase: 'eng', requiredPhase: 2.5, paths: [file], content: fs.readFileSync(file, 'utf8') }], clock);
expect(detect()).toMatchObject({ readToolUseId: 'cached-future' });
f.message(); expect(detect()).toMatchObject({ readToolUseId: 'cached-future' });
});
for (const kind of ['Read', 'Agent'] as const) test(`the actual detector authenticates ${kind} phase consumption through the current snapshot APIs`, () => {
const f = fixture('dx', 'eng'), next = nextSnapshot(f);
if (kind === 'Read') f.read('eng-consumer', next.method, 2, 1);
else { f.use('eng-consumer', 'Agent', { prompt: next.snapshot.nativeDispatchPrompt }); f.result('eng-consumer', { content: 'Native child launched.' }); }
expect(audit(f)).toMatchObject({ phase: 'eng', requiredPhase: 2.5, readToolUseId: 'eng-consumer' });
f.message();
expect(audit(f)).toMatchObject({ readToolUseId: 'eng-consumer', reportAt: f.events.at(-1)!.timestamp });
const message = f.events.pop()!; message.timestamp = new Date(clock - 1).toISOString(); f.events.unshift(message);
expect(audit(f)).toBeNull();
});
for (const mutation of ['missing-result', 'error', 'foreign-session', 'bad-content', 'wrong-restore', 'wrong-active', 'wrong-prompt', 'conflicting-result'] as const) test(`detector ${mutation} supplies no authenticated consumption`, () => {
const f = fixture('dx', 'eng'), next = nextSnapshot(f);
const agent = ['wrong-active', 'wrong-prompt'].includes(mutation);
if (agent) { f.use('consumer', 'Agent', { prompt: next.snapshot.nativeDispatchPrompt + (mutation === 'wrong-prompt' ? '\nForeign change.' : '') }); f.result('consumer', { content: 'Launched.' }); }
else f.read('consumer', next.method);
const result = f.events.at(-1)! as any;
if (mutation === 'missing-result') f.events.pop();
if (mutation === 'error') result.isError = true;
if (mutation === 'foreign-session') result.sessionId = 'foreign';
if (mutation === 'bad-content') result.file.content += 'Changed.';
if (mutation === 'conflicting-result') f.add({ ...result, isError: true });
if (mutation === 'wrong-restore' || mutation === 'wrong-active') {
const file = mutation === 'wrong-restore' ? path.join(path.dirname(next.method), 'methodology.json') : path.join(path.dirname(next.snapshot.snapshotPath), 'snapshot.json');
const m = JSON.parse(fs.readFileSync(file, 'utf8')); m[mutation === 'wrong-restore' ? 'restorePath' : 'activePlan'] = f.source;
fs.chmodSync(file, 0o644); fs.writeFileSync(file, JSON.stringify(m)); fs.chmodSync(file, 0o444);
}
expect(audit(f)).toBeNull();
});
for (const mode of ['literal-command', 'opaque-command', 'text-block', 'late-report', 'partial', 'changed', 'error', 'foreign-session', 'backward-result', 'conflicting-result', 'quoted-report', 'earlier-report'] as const) test(`captured Bash driver delivery: ${mode}`, () => {
const request = structuredClone(consumption.events.find(e => e.kind === 'use' && e.name === 'Bash')!) as any;
const result = structuredClone(consumption.events.find(e => e.kind === 'result' && e.toolUseId === request.toolUseId)!) as any;
const transcript: any = { status: 'ready', calls: [], assistantMessages: structuredClone(consumption.messages) };
if (mode === 'opaque-command') request.input.command = 'run-current-script';
if (mode === 'text-block') result.content = [{ type: 'text', text: result.content }];
if (mode === 'partial') result.content = result.content.replace(consumption.driverContent, consumption.driverContent.slice(1));
if (mode === 'changed') result.content = result.content.replace(consumption.driverContent, consumption.driverContent.replace('Phase 3', 'Phase 9'));
if (mode === 'error') result.isError = true;
if (mode === 'foreign-session') result.sessionId = 'foreign';
if (mode === 'backward-result') result.timestamp = new Date(Date.parse(request.timestamp) - 1).toISOString();
if (['late-report', 'quoted-report', 'earlier-report'].includes(mode)) transcript.assistantMessages.push({ sessionId: request.sessionId,
timestamp: new Date(Date.parse(request.timestamp) + (mode === 'late-report' ? 1 : -1)).toISOString(),
text: mode === 'quoted-report' ? '```\nPhase 2.5 complete.\n```' : 'Phase 2.5 complete.' });
const events = [request, result]; if (mode === 'conflicting-result') events.push({ ...result, isError: true });
const detected = prematureAutoplanPhaseEntry(events, transcript, [{ phase: 'eng', requiredPhase: 2.5,
paths: [path.join(ROOT, 'autoplan/sections/eng-phase.md')], content: consumption.driverContent }], 0);
const expected = ['literal-command', 'opaque-command', 'text-block', 'late-report', 'quoted-report'].includes(mode);
expect(detected !== null).toBe(expected);
if (expected) expect(detected!.readToolUseId).toBe(request.toolUseId);
});
});
describe('Autoplan native journal roots and Windows spellings', () => {
const windows = process.platform === 'win32';
function published(publish = true) {
const f = fixture(); if (publish) f.message(); f.current();
const { rows } = f.journal();
const save = () => fs.writeFileSync(f.input.transcript_path, rows.map(r => JSON.stringify(r)).join('\n') + '\n');
return { f, rows, save };
}
const attachment = (f: ReturnType<typeof fixture>, parentUuid: string | null, extra: object = {}): any => ({
uuid: randomUUID(), parentUuid, cwd: f.cwd, sessionId: f.sessionId, isSidechain: false,
timestamp: new Date(clock).toISOString(), type: 'attachment',
attachment: { type: 'hook_success', hookEvent: 'SessionStart' }, ...extra });
const hook = (f: ReturnType<typeof fixture>, project = f.cwd) =>
withNativeProjectDirectory(project, () => runPublicationHook(f.input, ROOT));
const status = (f: ReturnType<typeof fixture>) =>
readOwnedClaudePublicTranscript(f.input.transcript_path, f.cwd, f.sessionId).transcript.status;
for (const shape of ['one', 'chained', 'stray-before'] as const)
test(`a SessionStart attachment root owns the journal beneath it (${shape})`, async () => {
const { f, rows, save } = published();
const root = attachment(f, null), chain = shape === 'chained' ? [root, attachment(f, root.uuid)] : [root];
rows[0].parentUuid = chain.at(-1)!.uuid;
rows.unshift(...(shape === 'stray-before' ? [attachment(f, null)] : []), ...chain); save();
expect(status(f)).toBe('ready');
expect(await hook(f)).toEqual({});
});
test('an attachment root still requires the parent publication', async () => {
const { f, rows, save } = published(false);
const root = attachment(f, null); rows[0].parentUuid = root.uuid; rows.unshift(root); save();
expect(await hook(f)).toMatchObject({ hookSpecificOutput: { permissionDecisionReason: expect.stringContaining('Publish the filled Phase 1') } });
});
test('a message-bearing attachment root is an unrecognized shape: advisory, never ownership', async () => {
const { f, rows, save } = published();
const root = attachment(f, null, { message: { role: 'system', content: [] } });
rows[0].parentUuid = root.uuid; rows.unshift(root); save();
const read = readOwnedClaudePublicTranscript(f.input.transcript_path, f.cwd, f.sessionId);
expect(read.transcript.status).not.toBe('ready');
expect(read.transcript.reason).toBe('unrecognized_shape:first_turn:attachment:hook_success');
expect(read.events).toEqual([]);
const home = fs.mkdtempSync(path.join(tmpdir(), 'autoplan-guard-home-')); dirs.push(home);
const previous = process.env.GSTACK_HOME; process.env.GSTACK_HOME = home;
try {
const output: any = await hook(f);
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
expect(output.systemMessage).toContain('phase publication was NOT verified for this session');
} finally { if (previous === undefined) delete process.env.GSTACK_HOME; else process.env.GSTACK_HOME = previous; }
});
for (const kind of ['foreign-cwd', 'sidechain', 'competing-conversation', 'competing-user-root'] as const)
test(`an attachment root cannot supply ownership: ${kind}`, async () => {
const { f, rows, save } = published();
const root = attachment(f, null, kind === 'foreign-cwd' ? { cwd: path.join(f.cwd, 'foreign') }
: kind === 'sidechain' ? { isSidechain: true } : {});
rows[0].parentUuid = root.uuid; rows.unshift(root);
if (kind === 'competing-conversation' || kind === 'competing-user-root') {
const other = attachment(f, null);
const prompt = { ...structuredClone(rows[1]), uuid: randomUUID(), parentUuid: kind === 'competing-user-root' ? null : other.uuid };
rows.push(...(kind === 'competing-conversation' ? [other] : []), prompt);
}
save();
expect(status(f)).not.toBe('ready');
expect((await hook(f) as any).hookSpecificOutput?.permissionDecision).toBe('deny');
});
test.if(windows)('CLAUDE_PROJECT_DIR spelled C:/ or c:\\ names the native journal cwd', async () => {
const { f } = published();
expect(await hook(f, f.cwd.replaceAll('\\', '/'))).toEqual({});
expect(await hook(f, f.cwd[0]!.toLowerCase() + f.cwd.slice(1))).toEqual({});
expect((await hook(f, f.cwd + '\\.') as any).hookSpecificOutput?.permissionDecision).toBe('deny');
});
test.if(windows)('an init command spelled with Git Bash /c/ paths binds the same invocation', () => {
const f = fixture(); f.message(); f.current();
const msys = (file: string) => '/' + file[0]!.toLowerCase() + file.slice(2).replaceAll('\\', '/');
(f.events[0] as any).input.command = `bun "${ROOT}/bin/gstack-autoplan-snapshot.ts" init "${msys(f.source)}" "${msys(f.active)}" "${msys(f.restore)}"`;
expect(f.evaluate()).toEqual({ allow: true });
});
test.if(windows)('a close packet Read spelled C:/ is still the close Read', () => {
const f = fixture(), close = f.events.find(e => e.kind === 'use' && e.toolUseId === 'close') as any;
const forward = (close.input!.file_path as string).replaceAll('\\', '/');
close.input!.file_path = forward;
(f.events.find(e => e.kind === 'result' && e.toolUseId === 'close') as any).file.filePath = forward;
f.message(); f.current();
expect(f.evaluate()).toEqual({ allow: true });
});
test.if(windows)('an active-plan Edit spelled C:/ after the close Read is still a mutation', () => {
const f = fixture(); f.message();
f.use('edit', 'Edit', { file_path: f.active.replaceAll('\\', '/'), old_string: 'Keep', new_string: 'Drop' });
f.current();
expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('mutation is pending') });
});
});
describe('Autoplan phase entry through the gbrain :user render (#2569)', () => {
const generator = fs.readFileSync(path.join(ROOT, 'scripts/gen-skill-docs.ts'), 'utf8');
const rewriteSectionBase: (content: string, linkRoot: string | null) => string = new Function(
new Bun.Transpiler({ loader: 'ts' }).transformSync(generator.slice(generator.indexOf('function rewriteSectionBase('),
generator.indexOf('\n}\n', generator.indexOf('function rewriteSectionBase(')) + 2)) + '\nreturn rewriteSectionBase;')();
const sections = ['ceo-phase.md', 'design-phase.md', 'dx-phase.md', 'eng-phase.md', 'tasks-aggregator.md'];
/** Writes what setup's swap-in render writes: the generator's rewrite with --link-root = the final render root. */
function render(root: string, linkRoot = root, edit = (text: string) => text) {
fs.mkdirSync(path.join(root, 'autoplan', 'sections'), { recursive: true });
for (const name of sections) fs.writeFileSync(path.join(root, 'autoplan', 'sections', name),
edit(rewriteSectionBase(fs.readFileSync(path.join(ROOT, 'autoplan/sections', name), 'utf8'), linkRoot)));
return root;
}
function withEnv<T>(env: Record<string, string | undefined>, work: () => T): T {
const previous = Object.fromEntries(Object.keys(env).map(k => [k, process.env[k]]));
for (const [k, v] of Object.entries(env)) if (v === undefined) delete process.env[k]; else process.env[k] = v;
try { return work(); }
finally { for (const [k, v] of Object.entries(previous)) if (v === undefined) delete process.env[k]; else process.env[k] = v; }
}
/** CEO entered through `entry` and published; the next phase entry is the request under judgment. */
function entered(entry: string, next?: string) {
const f = fixture(), text = fs.readFileSync(entry, 'utf8'), lines = text.split('\n');
const use = f.events.find(e => e.kind === 'use' && e.toolUseId === 'entry') as any;
const result = f.events.find(e => e.kind === 'result' && e.toolUseId === 'entry') as any;
use.input = { file_path: entry, offset: 1 };
(result as any).file = { filePath: entry, content: text, startLine: 1, numLines: lines.length, totalLines: lines.length };
f.message();
if (next) f.input.tool_input = { file_path: next };
f.current();
return f;
}
const scratch = () => { const dir = fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), 'autoplan-render-'))); dirs.push(dir); return dir; };
const denied = { allow: false, reason: expect.stringContaining('different or unavailable installation') };
test('the hook rewrite is the generator rewrite', () => {
const linkRoot = path.join(scratch(), 'render', 'claude');
for (const name of [...sections, 'phase-close.md']) {
const text = fs.readFileSync(path.join(ROOT, 'autoplan/sections', name), 'utf8');
const hook = fs.readFileSync(path.join(ROOT, 'autoplan/bin/phase-publication-hook.ts'), 'utf8');
const fn = hook.slice(hook.indexOf('function renderSectionBase('), hook.indexOf('\n}\n', hook.indexOf('function renderSectionBase(')) + 2);
const ours = new Function(new Bun.Transpiler({ loader: 'ts' }).transformSync(fn) + '\nreturn renderSectionBase;')();
expect(ours(text, linkRoot)).toBe(rewriteSectionBase(text, linkRoot));
}
});
for (const via of ['GSTACK_USER_RENDER_DIR', 'GSTACK_HOME', 'HOME', 'empty GSTACK_USER_RENDER_DIR'] as const)
test(`the render root setup resolves through ${via} is this installation`, () => {
const home = scratch(), root = render(via === 'GSTACK_USER_RENDER_DIR' ? path.join(home, 'custom') :
via === 'HOME' ? path.join(home, '.gstack', 'render', 'claude') : path.join(home, 'render', 'claude'));
const env = via === 'GSTACK_USER_RENDER_DIR' ? { GSTACK_USER_RENDER_DIR: root, GSTACK_HOME: undefined }
: via === 'HOME' ? { GSTACK_USER_RENDER_DIR: undefined, GSTACK_HOME: undefined, HOME: home }
: { GSTACK_USER_RENDER_DIR: via === 'GSTACK_HOME' ? undefined : '', GSTACK_HOME: home };
const f = entered(path.join(root, 'autoplan/sections/ceo-phase.md'), path.join(root, 'autoplan/sections/design-phase.md'));
expect(withEnv(env, () => f.evaluate())).toEqual({ allow: true });
});
for (const kind of ['sibling-root', 'other-home', 'linked-out', 'changed-bytes', 'stale-link-root', 'no-render-root'] as const)
test(`a render lookalike is a different installation: ${kind}`, () => {
const home = scratch(), root = path.join(home, 'render', 'claude');
let next = path.join(root, 'autoplan/sections/design-phase.md'), env: Record<string, string | undefined> = { GSTACK_USER_RENDER_DIR: undefined, GSTACK_HOME: home };
if (kind === 'sibling-root') { render(root); next = path.join(render(path.join(home, 'render', 'claude2')), 'autoplan/sections/design-phase.md'); }
if (kind === 'other-home') { const other = scratch(); render(root); next = path.join(render(path.join(other, 'render', 'claude')), 'autoplan/sections/design-phase.md'); }
if (kind === 'linked-out') {
const outside = render(path.join(scratch(), 'outside'), root);
fs.mkdirSync(path.join(root, 'autoplan'), { recursive: true });
fs.symlinkSync(path.join(outside, 'autoplan', 'sections'), path.join(root, 'autoplan', 'sections'), 'junction');
}
if (kind === 'changed-bytes') render(root, root, text => text + 'Forged instruction.\n');
if (kind === 'stale-link-root') render(root, path.join(home, 'previous-render'));
if (kind === 'no-render-root') { render(root); env = { GSTACK_USER_RENDER_DIR: path.join(home, 'missing'), GSTACK_HOME: home }; }
const f = entered(path.join(ROOT, 'autoplan/sections/ceo-phase.md'), next);
expect(withEnv(env, () => f.evaluate())).toMatchObject(denied);
});
});
describe('Autoplan current-use identity: journal spelling vs PreToolUse spelling', () => {
// Claude journals the model's file_path verbatim and hands PreToolUse its resolved form.
const spellings = (file: string) => [
path.join(path.dirname(file), '.') + path.sep + '.' + path.sep + path.basename(file),
...(process.platform === 'win32' ? [path.dirname(file) + '/' + path.basename(file)] : []),
];
for (const kind of ['dot-segment', 'forward-slash'] as const)
test.if(kind === 'dot-segment' || process.platform === 'win32')(`a ${kind} journal spelling is the same current Read`, () => {
const f = fixture(); f.message();
const target = f.input.tool_input.file_path as string, journal = spellings(target)[kind === 'dot-segment' ? 0 : 1]!;
f.use('next', 'Read', { file_path: journal });
expect(f.evaluate()).toEqual({ allow: true });
});
for (const kind of ['other-file', 'extra-field'] as const)
test(`a current Read that differs beyond spelling is not the same identity: ${kind}`, () => {
const f = fixture(); f.message();
const target = f.input.tool_input.file_path as string;
f.use('next', 'Read', kind === 'other-file' ? { file_path: path.join(path.dirname(target), 'eng-phase.md') }
: { file_path: spellings(target)[0], limit: 5 });
expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('identity is unavailable') });
});
});
describe('Autoplan Read coverage of a CRLF artifact', () => {
// A CRLF plan carries CRLF into the close packet; Claude's Read reports those lines without CR.
const disk = 'Binding: {}\r\n## Implementation plan\r\nKeep it.\nlast line\r\n';
const use: any = { kind: 'use', sessionId: 's', toolUseId: 't', name: 'Read', order: 1, input: { file_path: 'C:\\x\\close-packet.md' } };
const result = (content: string): any => ({ kind: 'result', sessionId: 's', toolUseId: 't', isError: false, order: 2,
file: { filePath: 'C:\\x\\close-packet.md', content, startLine: 1, numLines: 5, totalLines: 5 } });
for (const [kind, reported, credited] of [
['crlf-stripped', disk.replace(/\r\n/g, '\n'), true],
['verbatim', disk, true],
['changed-text', disk.replace(/\r\n/g, '\n').replace('Keep it.', 'Drop it.'), false],
['mid-line-cr-dropped', 'Binding: {}\n## Implementation plan\nKeep it.\nlast line\n', true],
] as const)
test(`a ${kind} report ${credited ? 'covers' : 'does not cover'} every line`, () => {
expect(autoplanReadRange(use, result(reported), disk)).toEqual(credited ? { start: 1, end: 5 } : undefined);
});
test('a CR inside a line is content, not a line ending', () => {
const inner = 'a\rb\nc';
const r: any = { ...result('ab\nc'), file: { filePath: 'C:\\x\\close-packet.md', content: 'ab\nc', startLine: 1, numLines: 2, totalLines: 2 } };
expect(autoplanReadRange(use, r, inner)).toBeUndefined();
});
});
describe('Autoplan restore-point header line ending', () => {
// init writes an LF header above a CRLF plan; Claude's Edit then rewrites every ending as CRLF.
for (const [kind, ending, allowed] of [['crlf', '\r\n', true], ['lf', '\n', true],
['trailing-text', ' forged\n', false], ['no-newline', '', false]] as const)
test(`a restore-point header ending ${kind} ${allowed ? 'binds' : 'does not bind'} the invocation`, () => {
const f = fixture(); f.message(); f.current();
const text = fs.readFileSync(f.active, 'utf8'), end = text.indexOf('-->') + 3;
fs.writeFileSync(f.active, text.slice(0, end) + ending + text.slice(text.indexOf('\n', end) + 1));
expect(f.evaluate()).toEqual(allowed ? { allow: true }
: { allow: false, reason: expect.stringContaining('initialization artifacts do not match') });
});
});
describe('Autoplan ownership in a linked git worktree session', () => {
const posix = (file: string) => file.replaceAll('\\', '/');
/** git worktree add's two-way link: <repo>/.git/worktrees/<name>/gitdir <-> <worktree>/.git. */
function admin(repo: string, worktree: string) {
const entry = path.join(repo, '.git', 'worktrees', 'session');
fs.mkdirSync(entry, { recursive: true });
fs.writeFileSync(path.join(entry, 'gitdir'), posix(path.join(worktree, '.git')) + '\n');
return entry;
}
const pointer = (worktree: string, entry: string) => fs.writeFileSync(path.join(worktree, '.git'), `gitdir: ${posix(entry)}\n`);
const repo = () => { const dir = fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), 'autoplan-repo-'))); dirs.push(dir); return dir; };
const run = (f: ReturnType<typeof fixture>, root: string) => withNativeProjectDirectory(root, () => runPublicationHook(f.input, ROOT));
test('the repository root owns its linked worktree journal', async () => {
const f = fixture(); f.message(); f.current(); f.journal();
const root = repo(); pointer(f.cwd, admin(root, f.cwd));
expect(await run(f, root)).toEqual({});
});
test('a linked worktree journal still requires the parent publication', async () => {
const f = fixture(); f.current(); f.journal();
const root = repo(); pointer(f.cwd, admin(root, f.cwd));
expect(await run(f, root)).toMatchObject({ hookSpecificOutput: { permissionDecisionReason: expect.stringContaining('Publish the filled Phase 1') } });
});
test.if(process.platform === 'win32')('a forward-slash, lowercase-drive gitdir names the same worktree', async () => {
const f = fixture(); f.message(); f.current(); f.journal();
const root = repo(), lower = (file: string) => posix(file[0]!.toLowerCase() + file.slice(1));
const entry = path.join(root, '.git', 'worktrees', 'session'); fs.mkdirSync(entry, { recursive: true });
fs.writeFileSync(path.join(entry, 'gitdir'), lower(path.join(f.cwd, '.git')) + '\n');
fs.writeFileSync(path.join(f.cwd, '.git'), `gitdir: ${lower(entry)}\n`);
expect(await run(f, lower(root))).toEqual({});
});
test('worktree.useRelativePaths links resolve against each file\'s own directory', async () => {
const f = fixture(); f.message(); f.current(); f.journal();
const root = repo(), entry = path.join(root, '.git', 'worktrees', 'session'); fs.mkdirSync(entry, { recursive: true });
// Exactly what git 2.55 `-c worktree.useRelativePaths=true worktree add` writes.
fs.writeFileSync(path.join(entry, 'gitdir'), posix(path.relative(entry, path.join(f.cwd, '.git'))) + '\n');
fs.writeFileSync(path.join(f.cwd, '.git'), `gitdir: ${posix(path.relative(f.cwd, entry))}\n`);
expect(await run(f, root)).toEqual({});
});
test('an unrelated linked worktree leaves the exact project-directory owner unchanged', async () => {
const f = fixture(); f.message(); f.current(); f.journal();
const elsewhere = repo(); fs.mkdirSync(path.join(f.cwd, '.git', 'worktrees', 'session'), { recursive: true });
fs.writeFileSync(path.join(f.cwd, '.git', 'worktrees', 'session', 'gitdir'), posix(path.join(elsewhere, '.git')) + '\n');
expect(await run(f, f.cwd)).toEqual({});
});
for (const kind of ['no-link', 'worktree-file-only', 'admin-entry-only', 'other-repository',
'submodule-gitdir', 'project-is-itself-a-worktree', 'bare-repository'] as const)
test(`an unlinked directory cannot own the journal: ${kind}`, async () => {
const f = fixture(); f.message(); f.current(); f.journal();
const root = repo();
if (kind === 'worktree-file-only') pointer(f.cwd, path.join(root, '.git', 'worktrees', 'session'));
if (kind === 'admin-entry-only') admin(root, f.cwd);
// The root claims the worktree, but the worktree names another repository.
if (kind === 'other-repository') { admin(root, f.cwd); pointer(f.cwd, admin(repo(), f.cwd)); }
if (kind === 'submodule-gitdir') {
admin(root, f.cwd); const modules = path.join(root, '.git', 'modules', 'session');
fs.mkdirSync(modules, { recursive: true }); pointer(f.cwd, modules);
}
// A project directory whose own .git is a file has no worktree registry of its own.
if (kind === 'project-is-itself-a-worktree') { fs.writeFileSync(path.join(root, '.git'), 'gitdir: elsewhere\n'); pointer(f.cwd, path.join(root, '.git', 'worktrees', 'session')); }
if (kind === 'bare-repository') {
const entry = path.join(root, 'worktrees', 'session'); fs.mkdirSync(entry, { recursive: true });
fs.writeFileSync(path.join(entry, 'gitdir'), posix(path.join(f.cwd, '.git')) + '\n'); pointer(f.cwd, entry);
}
const output: any = await run(f, root);
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('code foreign_cwd');
});
});