Files
msd-core/tests/revision-remediation-binding.test.cjs
Dennis Alexis Valin Dittrich 1017898cb9 fix(#3771): make remediation examples non-binding and surface revision conflicts (#3916)
* fix(#3771): separate the binding property from the advisory remediation

Checker findings fused "what property failed" with "how to fix it" into a
single `fix_hint` and never marked which half binds. The checker rendered
every hint under a "must fix" heading, the orchestrators injected the issues
verbatim and ordered targeted updates, and the shared revision references
mapped each hint to a prescriptive strategy — so a contract-following planner
applied a hint literally even when a smaller mechanism satisfied the same
property, or when the hint contradicted a locked decision. There was no
channel to report that conflict, and every attempt burned a revision
iteration.

Checker side: every issue now carries a binding `required_property` (the
invariant that failed) plus its evidence and severity, and `fix_hint` is
labelled non-binding wherever it appears — including the human-facing blocker
rendering, so "must fix" unambiguously names the property and never the
example.

Planner side: revision re-checks locked decisions, capability guidance and
existing plan constraints before editing; satisfying a blocker through a
smaller valid alternative counts as addressing it; and a hint that conflicts
with any of those returns `REVISION_CONFLICT` carrying the conflict and the
alternatives considered. Orchestrators route that to user choice or the
configured plan-review convergence loop without consuming retry budget.

Also applied to the UI-spec revision loop and the gap-plan hint, and the
generic pattern's stray `suggested_fix` field name is reconciled to the
plan-checker's `fix_hint`.

Nothing legitimately binding is weakened: blockers still block, severity
still gates, iteration caps and stall escalation still fire, and required
task fields and decision coverage still hold.

Refs #3771

* test(#3771): pin the binding/advisory split across the revision chain

Locks the separation at every link that carries it: the checker's issue
schema and blocker rendering, the planner's constraint re-check and
REVISION_CONFLICT return, the generic pattern's reconciled field names, and
each orchestrator's conflict routing without retry-budget consumption. Also
pins what must not have been weakened — blockers, severity gating, iteration
caps and stall escalation.

Red against the pre-fix prose: 32 of 34 assertions fail (the 2 that pass are
the preservation checks, correctly).

Refs #3771

* chore(#3771): add changeset fragment for the remediation-binding fix

* chore(#3771): acknowledge the remediation-binding growth

Five runtime-loaded files grow: the two checkers carry the binding/advisory
split where the model reads it (a `required_property` on every dimension
example, since a schema the examples contradict teaches the examples), and
the three orchestrators carry the REVISION_CONFLICT route, which has to live
with the `iteration_count`/`revision_count` state it declines to spend.

Deletes tests/emitted-drift-acks/3172-stated-failing-direction.json: it is
fully spent on next and still owned plan-phase.md, so it walls off a key it
can no longer clear (#3078). Its removal is the documented remedy for the
duplicate-key collision, not drive-by cleanup.

* fix(#3771): close the review gaps in the conflict contract

Adversarial review (Codex, Antigravity) found four real defects in the first
pass, each confirmed against the source before acting:

- The UI checker's structured return still ordered `Fix: {exact fix required}`
  and "list each BLOCK dimension with exact fix required". The dimension
  examples had been marked non-binding but the rendering the researcher
  actually reads had not — the same omission this issue is about.
- `ui-phase` and the canonical `revision-loop` flow incremented their counter
  BEFORE dispatching the reviser, so "do NOT increment on REVISION_CONFLICT"
  was unreachable prose: the iteration was already spent. The increment now
  sits on the return path in both.
- The conflict gate offered "accept as-is", which is an early exit from a
  still-failing blocker — a weakening the brief explicitly forbids. The three
  options are now adopt an alternative / override the constraint / amend the
  constraint; every one resolves the conflict. Accepting an unaddressed blocker
  remains available only at the unchanged iteration-cap escalation.
- The convergence route was declarative: nothing in
  plan-review-convergence.md could receive a conflict. plan-phase now records
  it in REVIEWS.md — the channel that loop already consumes — convergence
  refuses to declare convergence over an open entry, and routing back into a
  run convergence itself started is explicitly excluded as a cycle. `quick` has
  no REVIEWS.md and no phase, so its convergence branch was dead prose and is
  deleted in favour of asking the user.

Also reconciles the last two drifted field names (`finding`, `affected_field`)
to the plan-checker schema, and repairs a silent no-op: the few-shot
`required_property` insertion never applied because those lines are
blockquoted, and the test's own block filter was anchored on indentation only,
so a vacuous loop passed over zero blocks. Both are fixed and the filter now
asserts it found blocks.

Refs #3771

* chore(#3771): extend the growth acknowledgment for the review round

plan-review-convergence.md joins the list: the conflict route needed a
receiving end, and it lands on the seam that loop already reads (REVIEWS.md)
rather than a new mechanism. The plan-phase, ui-phase and gsd-ui-checker
entries gain the second-pass reasoning — an executable convergence branch, the
increment moved onto the return path, and the structured return that still
ordered an exact fix.

* fix(#3771): make the conflict route bounded, ordered, and owned

Round-2 adversarial review found five more defects, each confirmed in the
source before acting:

- The convergence gate sat AFTER `gsd_run state planned-phase` and the success
  banner, so a run could write and announce convergence over an unresolved
  conflict. OPEN_CONFLICTS is now read from REVIEWS.md and is part of the
  converged CONDITION, evaluated before any write.
- plan-phase's cycle-exclusion ("unless this run was invoked by convergence")
  was not a question the orchestrator can answer at runtime. plan-phase now
  never invokes convergence at all — it records the conflict when a phase
  REVIEWS.md exists and resolves it with the user in-place, which removes the
  cycle instead of describing it.
- Closure had no owner. plan-phase writes the row, so plan-phase strikes it
  resolved; convergence only reads. An open row is a live blocker, never a
  stale artifact.
- Declining to increment the counter removed the only bound on the conflict
  path: an agent returning the same conflict forever would loop unattended. A
  conflict naming the same `required_property` twice in a row is now a stall
  and escalates through the existing gate.
- verify-work's gap-plan revision loop hands `<revision_context>` to
  gsd-planner and so inherits the whole contract, but stated none of it and
  could not handle the conflict return. It is now covered like the others, and
  is in the test's orchestrator table.

Refs #3771

* chore(#3771): acknowledge the round-2 growth

verify-work.md joins the list — the flow the second review found missed — and
the plan-phase, ui-phase and plan-review-convergence entries gain the
round-2 reasoning: the gate moved ahead of the state write, the convergence
hand-off replaced with a runtime-checkable record-and-resolve, and the
recurrence bound that replaces the counter the conflict path stopped spending.

* fix(#3771): make the convergence gate countable and stop the conflict fall-through

Third adversarial round (Antigravity) found three defects:

- The OPEN_CONFLICTS pipeline had no `grep -v '~~'` despite its own comment
  claiming one, and `grep -c '^| '` also counts a markdown table's header and
  separator rows — every resolved conflict would have read as open and
  convergence would have deadlocked instead of converging. plan-phase now
  records each conflict as a `- [ ]` checklist line and flips it to `- [x]`, so
  the gate is an exact fixed-string match with no table parsing.
- "then continue below" fell through to the checker spawn, so a SECOND
  REVISION_CONFLICT would have been handed to the checker as though it were a
  revised plan. plan-phase, quick and verify-work now re-evaluate the return
  from the top of the conflict handler; ui-phase already looped back.
- revision-loop.md still described plan-phase routing a conflict to the
  convergence loop instead of asking — the behaviour round 2 removed. Recording
  is now stated as being in addition to asking, never instead of it.

Refs #3771

* chore(#3771): bring the changeset in line with what shipped

Two review rounds widened the change after the fragment was written:
verify-work's gap-plan revision and the convergence loop are covered, two
more drifted field names are reconciled, and the conflict path carries an
explicit recurrence bound.

* fix(#3771): declare and emit the REVISION_CONFLICT marker

check:contract-drift on CI caught what local lint never reached: four
workflows dispatch on `## REVISION_CONFLICT`, but no agent declared or emitted
it — an orphan consumer, matching a marker nothing produces. The shared
reference (planner-revision.md Step 7b) described the return; the agent
definitions did not carry it.

gsd-planner and gsd-ui-researcher now emit the marker in-fence alongside their
other return markers, and both registry rows in agent-contracts.md declare it.
gsd-planner's Consumed by gains the two workflows that dispatch on it and were
missing from the row.

The gate is right: a return contract belongs where the agent is defined, not
only in a reference the agent happens to load.

Refs #3771

* chore(#3771): acknowledge the return-marker growth

gsd-planner.md and gsd-ui-researcher.md each gain the REVISION_CONFLICT
marker that check:contract-drift requires them to emit.

* fix(#3771): hoist the shared conflict protocol out of the workflows

Two CI failures, both correct gates:

- tests/few-shot-calibration.test.cjs pins the plan-checker calibration file
  at exactly 4 examples (2 positive, 2 negative). The example added in the
  first pass broke that balance — and described PLANNER behaviour in the
  CHECKER's calibration set, which is the wrong surface for it. Removed; the
  smaller-alternative rule is already normative in gsd-plan-checker.md and
  planner-revision.md, and pinned by the regression suite.
- tests/phase6-capstone-conformance.test.cjs (ADR-857 phase 6, #1168) requires
  plan-phase.md to stay BELOW its pre-phase-6 baseline of 94519 bytes. The
  inline conflict block pushed it to 94988.

The fix for the second is the one that should have been made first: the
record/resolve/close protocol and the recurrence bound were identical in four
workflows, and revision-loop.md — which plan-phase already @-imports — is what
a shared contract is for. The protocol now lives there once; plan-phase states
only its bindings (which counter, which artifact, which next step) and points
at it. plan-phase.md: 94988 -> 92739, under the ratchet with headroom, and the
four-way duplication is gone.

quick, ui-phase and verify-work do not import the reference, so they keep their
inline statements. The suite asserts each rule against what the runtime
actually loads for that orchestrator, not against the file in isolation.

Refs #3771

* docs(#3771): state the shared-protocol relationship accurately

Three of the four revision-bearing workflows do not @-import revision-loop.md,
so 'follows it verbatim' overstated the coupling. Only plan-phase defers; the
others restate the rules inline and this section is the authority they must
agree with.

* refactor(#3771): name the authority instead of restating it four times

Self-review finding: the same ~700-byte reviser paragraph was inlined in four
prompts while gsd-planner already loads planner-revision.md whenever
<revision_context> is present (agents/gsd-planner.md:555, :588) — a fifth copy
of the same contract. Four places to edit in lockstep is precisely the drift
class this PR exists to fix; the generic pattern calling the field
suggested_fix while the checker emitted fix_hint is what that looks like after
a year.

Each prompt now carries only the load-bearing clauses and names the authority
it summarises. Deliberately NOT reduced to a bare pointer: these are LLM
prompts, and a contract stated only in a file the reader is supposed to fetch
is the failure mode of this very bug. The saving is modest (~50 bytes each) —
the point is the named source of truth, not the bytes.

Suite: dropped seven assertions that pinned heading text and bold-lead
phrasing a reword would break without changing what the runtime is told; their
neighbours already pin the same contract by content. 519 -> 499 lines, 61
tests. Red gate against origin/next: 56 of 61 fail.

Refs #3771

* fix(#3771): sanitize agent-authored conflict text and bound total conflicts

Cross-AI review (agy/Gemini) raised two security findings. I confirmed the
first by running the gate's own awk against a crafted file and reproduced it
exactly; the second is a real hole in my bound.

1. FAIL-OPEN in the convergence gate. The conflict text is agent-authored and
   is appended to REVIEWS.md, which the gate scans with an awk that stops at
   the next '## ' heading. One line of agent text beginning '## ' ends that
   scan early, so conflicts below it are never counted and convergence declares
   success over a live blocker. Measured: 3 open conflicts, awk returned 2.

   Fixed at the write boundary, which is the trust boundary: every field has
   newlines and tabs collapsed to spaces and a leading '#', '-', '|' or fence
   stripped, so one conflict is exactly one line. Both producing agents now
   declare their fields single-line plain text, and the reader states the
   invariant it depends on so a later edit cannot silently break it. Verified:
   3 open + 1 resolved now counts 3; missing file and absent section count 0.

2. The recurrence bound was 'same required_property twice in a row', which an
   agent alternating property names never trips, leaving the un-incremented
   conflict path unbounded. Now bounded twice: the repeat rule catches the
   common case, and the THIRD conflict return of a loop escalates whatever
   property it names. A conflict still never consumes a revision iteration;
   this cap is separate from and additional to the revision cap.

Rejected from the same review: deleting 'a planner that reaches
required_property by a smaller or different mechanism has addressed the issue
in full' from the CHECKER prompt as misplaced. It is load-bearing exactly
there. A checker that does not know a different mechanism counts will re-flag
the issue on re-check, which is the revision loop that never terminates. The
argument offered for deleting it, that the checker evaluates the new state
independently, describes the failure mode.

Refs #3771

* fix(#3771): fail closed on an unverifiable convergence gate

Second cross-AI pass (agy, this time with the full files rather than the diff)
found two more, both real:

1. The gate read REVIEWS_FILE with `2>/dev/null || echo 0`, so an unreadable or
   empty path counted as ZERO open conflicts and converged. That path is
   resolved a few lines earlier by a pre-existing unquoted
   `ls ${phase_dir}/${padded_phase}-REVIEWS.md` (line 346, not touched by this
   PR), which yields an empty string rather than an error when the path
   contains a space. Unverifiable is not clean: the gate now tests -z and -r
   first and BLOCKS. Verified both branches.

   The unquoted ls itself is left alone deliberately — it predates this change
   and belongs to the reviews lookup, not the conflict gate. Fixing it at my
   own boundary removes its effect on this gate without widening scope.

2. REVIEWS.md is writable by the review agent, which could flip a `- [ ]` to
   `- [x]` or delete the section and forge the state of a blocking gate. The
   section now declares a single writer: /gsd:plan-phase appends and closes,
   every other agent leaves it byte-for-byte alone, readers read.

Also trimmed a clause that explained the increment ordering by reference to
what the file said before this PR. Commit history is not instruction, and
these files are prompts.

Rejected: the claim that quick's conflict gate deadlocks autonomous pipelines
by asking the user. Its existing max-iteration escalation in the same file
already asks the user the same way; this adds no new interaction class.
Noted but out of scope: the per-dimension YAML example blocks and the shim
boilerplate duplicated across agent prompts both predate this change.

Refs #3771

* fix(#3771): count conflicts by line shape, not by section

CodeRabbit review on the rehearsal PR. Five findings, all valid, all applied.

The best one is a deletion. The convergence gate scanned between
'## Plan-Revision Conflicts' and the next '## ' heading, and that scan stops at
the FIRST heading it meets — so one stray '## ' line hid every conflict beneath
it and returned 0, converging over a live blocker. Reproduced: section-scan 0,
shape-scan 1. Sanitizing at the write boundary does not cover a hand-edited,
legacy, or foreign-written REVIEWS.md, so the reader needed its own guarantee.

It now matches the conflict line SHAPE anywhere in the file:

  grep -c '^- \[ \] .*required_property:'

No section bookkeeping, nothing a heading can truncate, and it composes with the
writer's sanitization (which strips a leading '-' from agent text, so agent prose
cannot forge the shape). Verified: injected heading -> 1, all resolved -> 0.

The other four:

- Both checkers told the author never to emit a contradictory fix_hint, then
  offered an escape hatch that put the forbidden route in the hint anyway. They
  now name NO route in that case and state only that the property conflicts with
  the constraint. A hint carrying a forbidden route is applied by anyone who
  trusts hints.
- The REVISION_CONFLICT marker description in gsd-planner.md was narrower than
  planner-revision.md: it covered a contradictory hint but not an unreachable
  required_property. A planner reading only the agent file would have burned
  retry budget on the case the reference routes to a conflict.
- The few-shot calibration examples used uppercase BLOCKER/INFO while the schema
  defines blocker/warning/info. Pre-existing, but it is the same schema-vs-example
  disagreement this PR exists to end, and the file was already being edited.
- verify-work's re-entry instruction existed but sat after the Bounded clause, so
  the paragraph read "re-spawn ... stop re-spawning ... after re-spawning". The
  re-entry now immediately follows the re-spawn, and states that only a
  non-conflict return may reach the checker or increment iteration_count.

Refs #3771

* fix(#3771): resolve the contradictory scope_sanity severity examples

Sixth CodeRabbit finding, posted outside the diff range and missed on my first
read — I had claimed all findings were addressed after reading only the five
inline comments. This one was in the review body.

agents/gsd-plan-checker.md carried TWO scope_sanity examples with identical
metrics (5 tasks, 12 files) and OPPOSITE severities: warning in Dimension 5,
blocker in <examples>. Line 872 states "2-3 tasks/plan good, 4 warning, 5+
blocker" and the severity table lists warning as "Scope 4 tasks (borderline)",
so the warning example contradicted both.

ADR-2629 Decision 5's "over budget is a WARNING, never a blocker" does not
excuse it: that rule governs the smart-zone TOKEN estimate (the estimate-check
verb, lines 299-306), which is a different axis from task count. Verified in
source before touching it.

The contradiction is pre-existing but this PR made it binding and visible:
severity is now declared part of the binding payload, and both examples were
given the same required_property, so they now disagree on the severity of an
identical finding about an identical property.

Deviating from the proposed correction, which was warning -> blocker: that
would duplicate the <examples> entry outright (same tasks, files, severity).
The Dimension 5 example is instead made a genuine 4-task borderline warning, so
the file keeps one worked example per severity and the thresholds, the severity
table and both examples finally agree.

Refs #3771

* fix(#3771): stop laundering a grep error into zero open conflicts

Seventh CodeRabbit finding — from a SECOND review round my own CR-4 push
triggered, which I had not looked for. This one is a regression I introduced
while fixing the previous fail-open.

CR-4 replaced the truncatable section scan with:

  OPEN_CONFLICTS=$(grep -c '^- \[ \] .*required_property:' "$REVIEWS_FILE" || true)

`|| true` masks every grep failure. grep exits 1 for "no matches" (a legitimate
zero) but 2 for a read error, and `|| true` turns both into an empty capture
that `${OPEN_CONFLICTS:-0}` renders as 0. If REVIEWS.md is removed or becomes
unreadable between the -r check and the scan, the gate reports no conflicts and
convergence proceeds. Proven: unreadable file -> captured empty -> 0.

The status is now inspected, and only exit 1 counts as zero; anything else
blocks.

My first attempt at this fix was itself wrong and my own harness caught it: I
wrote `if ! grep ...; then grep_status=$?`, but `!` inverts the status, so `$?`
in that branch is 0 and every failure reads as success — the clean-file case
printed "BLOCKED (grep exit 0)". The status must be read in the ELSE branch of a
non-negated `if`, which is what CodeRabbit proposed. Both traps are now pinned
by tests.

Verified end to end: all resolved -> 0, no conflicts at all -> 0, injected
heading -> 1, unreadable file -> BLOCKED with grep exit 2.

Refs #3771

* test(#3771): execute the conflict gate instead of reading it

CodeRabbit round three: 0 actionable, 1 nitpick — "these assertions inspect
Markdown source only; they do not prove that grep status 1 produces zero
conflicts or that a scan error exits before convergence." Rated Trivial. It is
the most valuable finding of the three rounds.

This gate has been wrong three times: a section scan a heading could truncate, a
`|| true` that laundered grep's error status into zero, and an `if !` whose `$?`
reported the negation rather than the command. Every one of those passed the
text assertions that existed at the time. I proved each fix by hand in a shell,
and none of that proof lived in the suite.

The gate is one self-contained fenced block, so the test now extracts it from
the workflow — located by content, not line number — writes it to a script and
RUNS it against fixtures: two open plus one resolved counts 2; no matches counts
0 and does not fail; a conflict below an injected `## ` heading still counts; an
unreadable path and an empty path both BLOCK with a non-zero status and no zero
count on stdout.

Non-vacuity proven by mutation rather than asserted. Reverting the gate to each
of its three historical broken forms reds the suite:

  section-scan awk  -> 7 failures (5 in the gate cases)
  || true           -> 4 failures (3 in the gate cases)
  if ! (negated $?) -> 4 failures (3 in the gate cases)
  restored          -> 69 pass, 0 fail

The prose assertions stay: they are the right instrument for a prompt. This
covers the one part of the change that is real shell an orchestrator executes.

Refs #3771

* test(#3771): route the gate harness through the shared test helpers

ESLint's project rules caught three violations in the new harness: an unbounded
execFileSync (DEFECT.UNBOUNDED-SUBPROCESS — an unbounded spawn is an indefinite
hang, and on macOS CI that is how a stuck run stops reporting instead of failing)
and two raw fs.rmSync calls, which skip the Windows-EBUSY retry budget that
helpers.cleanup carries.

Now uses createTempDir/cleanup from tests/helpers.cjs and passes an explicit
30s timeout. Suppressing the rules was available and would have been the wrong
call: both exist because of real CI failure modes on platforms I am not testing
on.

* chore(#3771): backfill the changeset PR number

The pr: field is drift-checked against the PR event payload, so it cannot be
written before the PR exists. Set to 3916.

* fix(#3771): close revision conflict persistence gaps

Use the authoritative review artifact, keep conflict and normal retry paths disjoint, and enforce one writer-reader grammar so malformed state fails closed.

Emitted-Drift-Ack-Growth: diagnose-issues.md — #3771 marks the gap-plan remediation hint non-binding while keeping root_cause authoritative
Emitted-Drift-Ack-Growth: gsd-plan-checker.md — #3771 separates binding required_property evidence from advisory fix_hint examples across the checker contract
Emitted-Drift-Ack-Growth: gsd-planner.md — #3771 declares the REVISION_CONFLICT return used when remediation contradicts governing constraints
Emitted-Drift-Ack-Growth: gsd-ui-checker.md — #3771 applies the same binding-property and advisory-hint split to UI review findings
Emitted-Drift-Ack-Growth: gsd-ui-researcher.md — #3771 defines the UI revision producer's structured REVISION_CONFLICT return
Emitted-Drift-Ack-Growth: plan-phase.md — #3771 routes and persists bounded revision conflicts before spending the normal retry budget
Emitted-Drift-Ack-Growth: plan-review-convergence.md — #3771 adds the fail-closed owned-block parser and prevents convergence over open conflicts
Emitted-Drift-Ack-Growth: review.md — #3771 emits and preserves the canonical writer-owned conflict block across review regeneration
Emitted-Drift-Ack-Growth: ui-phase.md — #3771 routes UI revision conflicts to resolution before consuming revision_count
Emitted-Drift-Ack-Growth: verify-work.md — #3771 gives gap-plan revision the same bounded conflict route before iteration_count

* test(#3916): guard rebases against schema drift

Load the current-base progressive-disclosure examples so every integrated issue remains bound by required_property after branch reconciliation.

* test(#3916): skip the extracted-gate suite's bash spawns on win32

Third review round's sole survivor: runConflictGate()/withReviews() spawn
bash against a Node-native temp path built by createTempDir(), which is
backslash-separated on the Windows CI lane and not a path Git Bash is
guaranteed to accept (DEFECT.WINDOWS-TEST-PORTABILITY, matching the
observed CI failure at revision-remediation-binding.test.cjs:844,
ENOENT on a path Windows read as a directory separator). No eslint rule
catches it since the call has neither a chmod nor a `bash -c` form.

Guards the four call sites with the repo's existing skipOnWin32
convention (describe/test `{ skip: IS_WINDOWS }`) rather than
normalizing the harness path to forward slashes, which would defeat the
one test whose purpose is proving the production gate does NOT rewrite
a literal backslash in a POSIX filename.

* fix(#3916): backfill changeset pr field to the fork validation PR number

* fix(#3771): forbid silently accepting an open plan-revision conflict at max-cycles escalation

The max-cycles escalation prompt only surfaced HIGH_COUNT and ACTIONABLE_COUNT; an open
plan-revision conflict (OPEN_CONFLICTS > 0) was never disclosed there, and "Proceed anyway"
could exit successfully over it — exactly the failure mode this PR exists to close (a success
banner over an unresolved conflict nobody resolved). Blockers still block: withhold "Proceed
anyway" and route to Manual review whenever a conflict is open.

* fix(#3771): do not hard-block REVISION_CONFLICT persistence when no REVIEWS.md exists yet

A phase's first-ever revision cycle can return REVISION_CONFLICT before any REVIEWS.md has been
written — REVIEWS_PATH is then legitimately empty, not a corrupt or deleted file. The persistence
gate's own accompanying prose already says the record channel applies 'when REVIEWS_FILE is
non-empty', but the bash condition never checked that, so it hard-blocked every conflict on a
brand-new phase regardless of whether persistence was even expected to run. Require a non-empty
REVIEWS_FILE before treating a missing file as an error.

* chore(#3771): raise the plan-phase.md ADR-857 host-loop ceiling to 96700

The frozen pre-phase-6 ceiling (94519) collided on rebase: this PR's own
REVISION_CONFLICT persistence/routing gate is core planner control flow, not an
un-extracted optional feature, and landed alongside an unrelated, already-merged
same-file growth (the #4.6 context-drift pre-check) already on next. Same
rationale #1298 already established for execute-phase.md's ceiling.

* chore(#3916): backfill changeset pr field to the upstream PR number

* fix(#3771): make the writer-side REVISION_CONFLICT sanitize step real shell

The Conflict Return record channel sanitized agent-authored fields via a
prose instruction ("Sanitize each agent-authored field before appending")
for the orchestrator LLM to apply by hand, while the reader-side gate in
plan-review-convergence.md parses the same slot with real, executed awk.
Flagged Minor across two review rounds (round 4, round 6) since no code
performed the sanitize anywhere.

plan-phase.md's Conflict Return step now runs a real bash gate: sanitize
each field (collapse newline/tab to space, strip a leading #/-/|/fence),
build the one-line record, skip the append if an identical line already
exists (idempotent), insert before the writer-owned end delimiter, and
fail closed if that delimiter is missing rather than silently dropping
the conflict.

tests/revision-remediation-binding.test.cjs extracts and RUNS the new
fence (matching how the reader gate is already tested), composing it
with the existing reader gate: hostile-field fast-check fuzzing, a
repeated-conflict idempotency check, and a missing-delimiter fail-closed
check that the file is left byte-for-byte unchanged on failure.

Emitted-Drift-Ack-Growth: plan-phase.md — #3916 turns the writer-side
REVISION_CONFLICT sanitize+insert step into real, executed shell instead
of a prose instruction, matching the reader gate's existing rigor

* fix(#3916): backfill changeset pr field to the fork validation PR number

Fork CI's changeset-lint reads the real PR number from its own event
payload; the fragment still carried the upstream number from the last
sync, so the DEFECT.CHANGESET-PR-FIELD-DRIFT check failed on this fork
PR. Re-backfill to the upstream number before the final push.

* fix(#3771): close the awk -v forgery and same-session close gaps agy found

Adversarial review (gemini-3.8-flash-high via the internal agy review
lane) on the full PR found two BLOCKERs against the just-added
writer-side conflict gate:

1. `awk -v line="${LINE}"` decodes escape sequences in its argument, so
   a literal two-character `\n` in agent-authored text became a real
   newline inside awk, splitting the appended record across two
   physical lines. `tr` only strips actual control bytes, so it never
   saw this — it defeated the exact forgery the gate exists to
   prevent, both the reader's zero-count and the writer's own
   idempotency check. Fixed by passing LINE/END through awk's
   ENVIRON, which is not escape-decoded.

2. A conflict resolved and re-spawned within the same plan-phase
   session was never flipped from `- [ ]` to `- [x]` — the record
   channel bullet said "plan-phase closes it," but no step did. Only
   a *separate* `--reviews` re-entry (line ~622, still prose-only)
   closes conflicts; the in-session resolve path left them open
   forever, permanently blocking convergence. Fixed by carrying the
   just-written line in `PENDING_CONFLICT` and closing it in the
   `Otherwise` branch before the checker re-spawns.

Also fixed a MAJOR: docs/COMMANDS.md described the `--max-cycles`
escalation gate as uniformly offering "proceed or review manually,"
but the code (this PR's own change) withholds "Proceed anyway"
specifically when a plan-revision conflict is open — only manual
review is offered in that case. Docs now say so.

Not applied: the reviewer's `\r` truncated to plain tr from a MINOR
that also asked for temp-file permission preservation across `mktemp`.
Applying `chmod --reference` is not portable to macOS/BSD `chmod`, so
this is left as a documented low-severity tradeoff — the temp file now
sits alongside REVIEWS.md (same filesystem, atomic `mv`), which was
the same finding's more substantive half. Also not applied: a
suggested `gsd_run review record-conflict` CLI subcommand to
deduplicate the two `awk` blocks — a new command plus wiring is out of
scope for a review-remediation fix.

tests/revision-remediation-binding.test.cjs adds regression coverage
for both BLOCKERs: a literal-backslash-n hostile field composed with
the reader gate, and a close-gate extraction that verifies the flip to
`[x]`, the reader's count dropping to 0, and a fail-closed path when
the pending line is missing.

Emitted-Drift-Ack-Growth: plan-phase.md — #3916 fixes an awk -v escape-
decoding forgery and adds the missing same-session conflict-close step
an adversarial review found in the writer-side gate

* chore(#3916): backfill changeset pr field to the upstream PR number

Fork-validation CI needed pr: 1 to pass its own changeset-lint; restore
pr: 3916 before this push reaches open-gsd/gsd-core.

* fix(#3771): trim plan-phase.md prose back under the XL byte cap

Merging origin/next's unrelated growth pushed plan-phase.md 473 bytes
past the workflow-size-budget XL cap and the ADR-857 phase-6 baseline,
both tripped by CI after review approval. Removed an unpinned inert
bash comment and tightened connective prose in three REVISION_CONFLICT
bullets; no executable shell or test-pinned substring changed.

* chore(rehearsal): pin changeset pr field to fork rehearsal PR #25

Scratch-only commit for the rehearsal branch's own CI. Will not be
carried onto the branch backing upstream #3916 — that keeps pr: 3916.

* fix(#3771): address CodeRabbit findings on the REVISION_CONFLICT protocol

Fork rehearsal PR #25's first CodeRabbit pass surfaced 7 findings against
the already-approved #3916 diff; each verified against current code
before fixing (none hallucinated):

- plan-phase.md: writer-side awk gates now strip a trailing \r before
  comparing lines, matching the reader gate (plan-review-convergence.md)
  -- a CRLF REVIEWS.md previously made both writer gates fail closed.
- plan-phase.md: the close-fence's REVIEWS_FILE/PENDING_CONFLICT/
  CONFLICT_RESOLUTION were read without ever being (re)defined in that
  fence -- shell state does not survive across separate fenced blocks
  (same convention already documented in review.md). Added the explicit
  recompute/set instruction.
- revision-loop.md: previous_conflict_property was never reset after a
  normal (non-conflict) revision, so a later, unrelated conflict on the
  same property could be misread as a repeat and escalate prematurely.
- gsd-plan-checker.md / few-shot-examples/plan-checker.md: two example
  required_property strings were unconditionally binding in a way their
  own dimension's rules aren't (no-analog RESEARCH.md fallback; tasks
  that create no functions), now scoped to match.
- quick/steps/plan-checker-loop.md: added the same disjoint
  "Otherwise (not REVISION_CONFLICT)" branch plan-phase.md already had,
  closing an ambiguity between the conflict and non-conflict return paths.
- revision-remediation-binding.test.cjs: the REVIEWS_PATH init-order
  assertion used indexOf() without checking for -1, so it would pass
  vacuously if either anchor were renamed away.

Also restores an "Export the row's CONFLICT_*" instruction I had cut in
the prior byte-budget trim -- checked non-pinned by tests, but it was the
only text telling the agent to set those vars before the awk block reads
them via ENVIRON.

Net growth from these fixes required reclaiming bytes elsewhere in
plan-phase.md (verified against every pinned substring in
revision-remediation-binding.test.cjs) to stay under the XL tier's
hard 98304-byte cap; final size 98245 bytes.

* fix(#3771): resync the #4079 shrink-only mirror to the current PRE_PHASE6 line

tests/plan-phase-background-wait-wakeup.test.cjs (landed on next via an
unrelated #4079 PR, merged in by this branch's next-sync) mirrored
plan-phase.md's phase6 shrink-only ceiling as a hardcoded local constant
(94519) rather than reading tests/phase6-capstone-conformance.test.cjs's
PRE_PHASE6 value. That value has since been legitimately raised twice
during this PR's own review (94519 -> 96700 -> 98300) to accommodate the
REVISION_CONFLICT persistence/routing gate. The two branches' independent
histories left the mirror stale post-merge -- not a textual git conflict,
but the same class of thing. Resynced to 98300.

* fix(#3771): address round-2 CodeRabbit findings on the conflict gates

CodeRabbit's re-review of the previous remediation commit found two real
issues in what it had already flagged:

- Both writer-side awk CRLF fixes used \`sub(/\r$/, "")\` directly on \`\$0\`,
  which mutates it in place -- \`{ print }\` then emitted the CR-stripped
  copy for every passed-through line, silently rewriting an unrelated
  CRLF REVIEWS.md to LF on any insert or close. Now compares against a
  separate \`cur\` copy and prints the original, untouched \`\$0\`.
- The close-fence's "recompute REVIEWS_FILE/PENDING_CONFLICT" prose
  implied in-fence derivation, but the fence has no such code and the
  test harness (\`runCloseGate\`) deliberately supplies all three as
  pre-set env vars -- matching how the open fence's "Export the row's
  CONFLICT_*" instruction already works. Reworded to "export ... in the
  same invocation", matching that established, test-verified pattern
  instead of promising logic that isn't there.

Added a regression test proving the CRLF fix no longer touches
passthrough lines (red against the mutate-in-place version, green now).

* fix(#3771): use a CRLF-safe check in the new passthrough regression test

local/no-crlf-fragile-split forbids splitting readFileSync content on a
literal \n (Windows git-autocrlf checkouts yield \r\n). My CRLF
passthrough-preservation test from the previous commit did exactly that
to inspect the first line. Replaced with a direct startsWith() check
against the known CRLF-terminated header, which needs no split.

* test(#3771): assert the record itself is inserted in the CRLF passthrough test

CodeRabbit nitpick (round 3): the passthrough-preservation test checked
gate status and the pre-existing line's CRLF ending, but never asserted
the new REVISION_CONFLICT record was actually written.

* fix(#3771): address agy/gemini-3.8-flash-high adversarial review findings

Full-PR adversarial review (internal /gsd-review antigravity lane,
gemini-3.8-flash-high) surfaced 9 findings; each verified against current
code before fixing (none hallucinated):

HIGH:
- quick-batch/steps/plan-checker-loop.md never received the
  required_property/fix_hint binding language or REVISION_CONFLICT
  handling this PR added everywhere else -- a genuinely unmigrated
  producing context. Migrated to match quick/steps/plan-checker-loop.md,
  and added it to the ORCHESTRATORS consistency battery in
  revision-remediation-binding.test.cjs so future drift is caught
  automatically.
- The close-fence's PENDING_CONFLICT was an agent-supplied env var that
  had to exactly reconstruct a five-field sanitized line across a
  multi-minute subagent dispatch -- fragile, and a scalar var also meant
  a second simultaneous conflict silently dropped the first on overwrite.
  Redesigned to match the open conflict by CONFLICT_DIMENSION/
  CONFLICT_PLAN identity instead: the agent re-supplies two short,
  already-tracked identifiers rather than reconstructing the full
  sanitized text, and each conflict resolves independently regardless of
  how many are open. Updated the test harness's runCloseGate contract to
  match, and added a two-open-conflicts regression test.

MEDIUM:
- plan-phase.md's `--reviews` replanning path told the reader to "flip
  the matching line to [x]" in prose only, with no executable path to
  it -- pointed it at the same close gate used in step 12.
- plan-review-convergence.md's reader-gate awk tolerated a blank line
  before the opening delimiter but not before the heading that follows
  it; a formatter or LLM writer inserting one would hard-abort
  convergence on an otherwise well-formed REVIEWS.md. Added the same
  tolerance already granted above it, with a regression test.

LOW:
- Clarified that the escalation destination for a stalled conflict is
  the same iteration/revision-count cap gate already defined in each of
  quick, quick-batch, ui-phase, and verify-work, rather than an
  undefined "stall" concept.
- Clarified "twice in a row" means no successful revision intervened,
  matching revision-loop.md's now-explicit previous_conflict_property
  reset.
- Fixed gsd-ui-researcher.md's stale rationale text, copied verbatim
  from planner-revision.md: ui-phase presents the conflict table
  directly to the user, it does not persist to a shared file scanned by
  heading.

Net growth again required reclaiming bytes in plan-phase.md (verified
against every pinned substring in revision-remediation-binding.test.cjs)
to stay under the XL tier's hard 98304-byte cap; removed a now-dead
PENDING_CONFLICT assignment in the process. Final size 98258 bytes.

* fix(#3771): scope row 48's quick/steps guard away from plan-checker-loop.md

tests/gsd-quick-batch-quick-regression.test.cjs's row 48 (#3676) flagged
this branch's quick-batch/steps/plan-checker-loop.md migration (the agy
HIGH finding) as a violation, because it also edits
quick/steps/plan-checker-loop.md for the same underlying #3771 protocol
fix.

Verified against git history before scoping: 2f64e6230 (#3676's own
landing commit) CREATED quick-batch/steps/plan-checker-loop.md as a new,
independent 119-line file, never a call-site into quick/'s copy. Row
48's "shared primitives, never edits the ordinary quick command" premise
was never about this specific file -- it was always meant to carry its
own per-flow copy of whatever revision-loop contract applies, same as
ui-phase.md/verify-work.md throughout this PR. This is the same
false-positive class the row's own comments already document scoping
away twice (#3730, #2529 round 40); excluded plan-checker-loop.md from
its touched-quick-steps check with the same evidence trail.

* chore(#3771): point changeset pr field at upstream PR 3916

---------

Co-authored-by: davdittrich <davdittrich@gmail.com>
Co-authored-by: CI Rebase Check <ci@gsd-redux>
Co-authored-by: Test <test@test.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-05 15:16:38 -04:00

1142 lines
61 KiB
JavaScript

/**
* Remediation binding-ness across the revision contract (#3771).
*
* The remediation channel used to fuse "what property failed" with "how to fix it"
* into a single `fix_hint` and never said which half binds. The checker rendered
* every hint under a "must fix" heading, the orchestrators injected the issues
* verbatim and ordered targeted updates, and the shared revision references mapped
* each hint to a prescriptive strategy. A contract-following planner therefore
* applied a hint literally even when a smaller mechanism satisfied the same
* property, or when the hint contradicted a locked decision — with no channel to
* report the conflict, and each attempt burning a revision iteration.
*
* ## What this suite locks
*
* The separation, at every link in the chain that carries it:
* - checker side — `required_property` + evidence + severity bind; `fix_hint` is
* marked non-binding, including in the human-facing blocker rendering
* - planner side — constraints are re-checked before editing, a smaller
* mechanism counts as addressing, and conflicts return `REVISION_CONFLICT`
* - orchestrators — the conflict is routed to the user or to the configured
* convergence loop WITHOUT consuming retry budget
* - field naming — the generic pattern and the plan-checker schema agree on
* `fix_hint`; `suggested_fix` is retired
*
* It also pins what must NOT have been weakened: blockers still block, severity
* still gates, the iteration caps and stall escalation still fire.
*
* ## What it cannot prove
*
* That a model acts on the text. The subject is a set of LLM prompts; no test in
* this repo can prove behavior for the checker's other dimensions either. Stated so
* the coverage claim is honest rather than implied.
*
* Multi-line phrases are asserted against a whitespace-normalized copy (`flat`), which
* is CRLF-tolerant and survives a re-wrap of the same words — the runtime loads these
* files whole, including on a checkout that produced CRLF.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fc = require('fast-check');
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
// Project rules: temp dirs and their removal go through the shared helpers (cleanup carries the
// Windows-EBUSY retry budget), and every synchronous spawn is bounded.
const { createTempDir, cleanup } = require('./helpers.cjs');
// runConflictGate()/withReviews() spawn bash against a Node-native temp path built by
// createTempDir(); on win32 that path is backslash-separated and handed to Git Bash, which
// is not guaranteed present on every runner (DEFECT.WINDOWS-TEST-PORTABILITY). POSIX-sh
// execution semantics of the extracted gate are proven on the POSIX lanes.
const IS_WINDOWS = process.platform === 'win32';
/** Bound for the extracted-gate subprocess: it runs one grep over a small fixture. */
const GATE_TIMEOUT_MS = 30_000;
const ROOT = path.join(__dirname, '..');
const read = (...parts) => fs.readFileSync(path.join(ROOT, ...parts), 'utf-8');
/**
* Collapse every whitespace run to one space. Multi-line phrases are asserted against
* this form so a re-wrap of the prose — which changes nothing the runtime reads — cannot
* red the suite, while the words themselves stay pinned. CRLF folds out here too.
*/
const flat = (content) => content.replace(/\s+/g, ' ');
const PLAN_CHECKER = read('agents', 'gsd-plan-checker.md');
const UI_CHECKER = read('agents', 'gsd-ui-checker.md');
const PLANNER_REVISION = read('gsd-core', 'references', 'planner-revision.md');
const REVISION_LOOP = read('gsd-core', 'references', 'revision-loop.md');
const FEW_SHOT = read('gsd-core', 'references', 'few-shot-examples', 'plan-checker.md');
const PLAN_PHASE = read('gsd-core', 'workflows', 'plan-phase.md');
const QUICK_LOOP = read('gsd-core', 'workflows', 'quick', 'steps', 'plan-checker-loop.md');
const QUICK_BATCH_LOOP = read('gsd-core', 'workflows', 'quick-batch', 'steps', 'plan-checker-loop.md');
const UI_PHASE = read('gsd-core', 'workflows', 'ui-phase.md');
const DIAGNOSE = read('gsd-core', 'workflows', 'diagnose-issues.md');
const CONVERGENCE = read('gsd-core', 'workflows', 'plan-review-convergence.md');
const VERIFY_WORK = read('gsd-core', 'workflows', 'verify-work.md');
const PLANNER = read('agents', 'gsd-planner.md');
const UI_RESEARCHER = read('agents', 'gsd-ui-researcher.md');
const CONTRACTS = read('gsd-core', 'references', 'agent-contracts.md');
const REVIEW = read('gsd-core', 'workflows', 'review.md');
const COMMANDS = read('docs', 'COMMANDS.md');
// ── Helpers ────────────────────────────────────────────────────────
/**
* The convergence conflict gate, extracted from the workflow and RUN.
*
* Everything else in this suite asserts on prose. That is the right instrument for a prompt,
* but this one block is real shell that an orchestrator executes, and it has been wrong four
* times — a heading-truncated scan, a laundered read error, an inverted status, and a global
* line-shape scan that let raw reviewer text forge blocking state.
* Every one of those passed the text assertions that existed at the time. So this block gets
* executed against fixtures instead of read.
*
* Located by content (the fence containing the OPEN_CONFLICTS assignment), not by line number,
* so re-ordering the document cannot silently point this at the wrong block.
*/
function extractConflictGate() {
const fences = CONVERGENCE.split(/```/);
const block = fences.find((f) => /^bash\r?\n/.test(f) && /OPEN_CONFLICTS=\$\(awk/.test(f));
assert.ok(block, 'could not find the bash fence containing the OPEN_CONFLICTS gate');
return block.replace(/^bash\r?\n/, '');
}
/** Run the extracted gate with REVIEWS_FILE set. Returns { status, stdout, stderr }. */
function runConflictGate(reviewsFile) {
const dir = createTempDir('gsd-3771-gate-');
try {
const script = path.join(dir, 'gate.sh');
fs.writeFileSync(script, `${extractConflictGate()}\nprintf '%s' "\${OPEN_CONFLICTS}"\n`);
try {
const stdout = execFileSync('bash', [script], {
env: { ...process.env, REVIEWS_FILE: reviewsFile },
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: GATE_TIMEOUT_MS,
});
return { status: 0, stdout, stderr: '' };
} catch (err) {
return { status: err.status, stdout: err.stdout || '', stderr: err.stderr || '' };
}
} finally {
cleanup(dir);
}
}
/**
* The writer-side sanitize+insert gate (#3916), extracted from plan-phase.md and RUN —
* same rationale as `extractConflictGate` above: this is real shell an orchestrator
* executes, not prose an LLM applies by hand, so it is proven against fixtures.
* Located by content (the fence assigning `LINE="- [ ] REVISION_CONFLICT`).
*/
function extractWriterGate() {
const fences = PLAN_PHASE.split(/```/);
const block = fences.find((f) => /^bash\r?\n/.test(f) && /LINE="- \[ \] REVISION_CONFLICT/.test(f));
assert.ok(block, 'could not find the bash fence containing the writer-side sanitize+insert gate');
return block.replace(/^bash\r?\n/, '');
}
/** Run the writer gate against `reviewsFile` with the five conflict fields as env. Mutates the file. */
function runWriterGate(reviewsFile, fields) {
const dir = createTempDir('gsd-3916-writer-');
try {
const script = path.join(dir, 'writer.sh');
fs.writeFileSync(script, extractWriterGate());
try {
execFileSync('bash', [script], {
env: {
...process.env,
CONVERGENCE_ENABLED: 'true',
REVIEWS_FILE: reviewsFile,
CONFLICT_DIMENSION: fields.dimension ?? '',
CONFLICT_PLAN: fields.plan ?? '',
CONFLICT_PROPERTY: fields.property ?? '',
CONFLICT_CONSTRAINT: fields.constraint ?? '',
CONFLICT_ALTERNATIVES: fields.alternatives ?? '',
},
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: GATE_TIMEOUT_MS,
});
return { status: 0 };
} catch (err) {
return { status: err.status, stderr: err.stderr || '' };
}
} finally {
cleanup(dir);
}
}
/**
* The close-on-resolve gate (#3916 adversarial-review fix, redesigned in round 4 to match by
* CONFLICT_DIMENSION/CONFLICT_PLAN identity instead of the full sanitized line -- an agent
* re-deriving five sanitized fields byte-for-byte across a multi-minute subagent dispatch is far
* more failure-prone than re-supplying two short identifiers it already tracks), extracted from
* plan-phase.md's `**Otherwise (revised plans...` branch and RUN. Located by content (the fence
* assigning `PREFIX="- [ ] REVISION_CONFLICT`).
*/
function extractCloseGate() {
const fences = PLAN_PHASE.split(/```/);
const block = fences.find((f) => /^bash\r?\n/.test(f) && /PREFIX="- \[ \] REVISION_CONFLICT/.test(f));
assert.ok(block, 'could not find the bash fence containing the close-on-resolve gate');
return block.replace(/^bash\r?\n/, '');
}
/** Run the close gate against `reviewsFile`, closing the open `dimension`/`plan` conflict with `resolution`. */
function runCloseGate(reviewsFile, dimension, plan, resolution) {
const dir = createTempDir('gsd-3916-close-');
try {
const script = path.join(dir, 'close.sh');
fs.writeFileSync(script, extractCloseGate());
try {
execFileSync('bash', [script], {
env: {
...process.env,
CONVERGENCE_ENABLED: 'true',
REVIEWS_FILE: reviewsFile,
CONFLICT_DIMENSION: dimension,
CONFLICT_PLAN: plan,
CONFLICT_RESOLUTION: resolution ?? '',
},
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: GATE_TIMEOUT_MS,
});
return { status: 0 };
} catch (err) {
return { status: err.status, stderr: err.stderr || '' };
}
} finally {
cleanup(dir);
}
}
/** Write a REVIEWS.md fixture and hand its path to `fn`. */
function withReviews(body, fn, filename = '07-REVIEWS.md') {
const dir = createTempDir('gsd-3771-reviews-');
try {
const file = path.join(dir, filename);
fs.writeFileSync(file, body);
return fn(file);
} finally {
cleanup(dir);
}
}
const OPEN = (id) => `- [ ] REVISION_CONFLICT ${id} — required_property: p | conflicts with: D-1 | alternatives: a`;
const RESOLVED = (id) => `- [x] REVISION_CONFLICT ${id} — required_property: p | resolved: adopted alternative`;
const CONFLICTS_BEGIN = '<!-- gsd:plan-revision-conflicts:begin -->';
const CONFLICTS_END = '<!-- gsd:plan-revision-conflicts:end -->';
const reviewsArtifact = (conflicts = '', reviewerText = '') =>
`# Cross-AI Plan Review — Phase 7\n\n${CONFLICTS_BEGIN}\n## Plan-Revision Conflicts\n${conflicts}${CONFLICTS_END}\n\n${reviewerText}`;
/** Extract the canonical writer template without normalizing indentation or line wrapping. */
function extractConflictTemplate() {
const fences = REVISION_LOOP.split(/```/);
const block = fences.find((f) => /^markdown\r?\n/.test(f) && /required_property: \{property\}/.test(f));
assert.ok(block, 'could not find the canonical Plan-Revision Conflicts writer template');
return block.replace(/^markdown\r?\n/, '').replace(/\r?\n$/, '');
}
/** Agent-authored reference rendering used only to test the documented writer/reader contract. */
function renderConflictTemplate({ dimension, plan, property, constraint, alternatives }) {
const clean = (value) => String(value)
.replace(/[\r\n\t]+/g, ' ')
.replace(/^\s*[#|`-]+\s*/, '');
return extractConflictTemplate()
.replace('{dimension}', clean(dimension))
.replace('{plan}', clean(plan))
.replace('{property}', clean(property))
.replace(/\{locked decision\s+D-nn \/ CLAUDE\.md rule \/ plan constraint\}/, clean(constraint))
.replace("{the agent's alternatives}", clean(alternatives));
}
/**
* Every fenced YAML issue example that names a `fix_hint`. Each block is returned
* whole so an assertion can check the two fields co-occur rather than merely both
* existing somewhere in the file.
*/
function yamlIssueBlocks(content) {
return content
.split(/```/)
// `[>\s]*` not `\s*`: the few-shot file blockquotes its YAML (`> fix_hint:`), so an
// indent-only anchor matched nothing there and every loop over it ran zero times.
.filter((block) => /(^|\r?\n)[>\s]*fix_hint:/.test(block));
}
// ── Checker side: binding payload vs advisory remediation ──────────
describe('#3771 checker states the property and marks the example non-binding', () => {
test('the issue schema carries required_property and evidence, with binding-ness declared', () => {
const schema = PLAN_CHECKER.slice(PLAN_CHECKER.indexOf('## Issue Format'));
assert.match(schema, /required_property:.*#\s*BINDING/,
'Issue Format must declare required_property as the binding invariant');
assert.match(schema, /description:.*#\s*BINDING.*evidence/i,
'Issue Format must declare description as the binding evidence field');
assert.match(schema, /fix_hint:.*#\s*NON-BINDING/,
'Issue Format must declare fix_hint as non-binding');
});
test('a smaller mechanism counts as addressing, and a conflicting hint is never authored', () => {
assert.match(PLAN_CHECKER, /smaller or different mechanism has addressed the issue in full/,
'the checker must concede that a smaller valid mechanism fully addresses the issue');
assert.match(flat(PLAN_CHECKER), /Never author a `fix_hint` you can see contradicts/,
'the checker must not emit remediation that contradicts a constraint it can see');
});
test('every YAML issue example carries required_property alongside its fix_hint', () => {
const blocks = yamlIssueBlocks(PLAN_CHECKER);
assert.ok(blocks.length >= 15, `expected the dimension examples to be present, found ${blocks.length}`);
for (const block of blocks) {
assert.match(
block,
/(^|\r?\n)[>\s]*required_property:/,
`issue example names fix_hint but no required_property:\n${block.trim().slice(0, 240)}`
);
}
});
test('progressive-disclosure issue examples carry the same binding schema', () => {
const examplesPath = path.join(
ROOT,
'gsd-core',
'references',
'plan-checker-examples.md'
);
assert.ok(
fs.existsSync(examplesPath),
'the current-base plan-checker examples reference must be present after integration'
);
const blocks = yamlIssueBlocks(fs.readFileSync(examplesPath, 'utf-8'));
assert.ok(blocks.length > 0, 'the progressive-disclosure reference must contain an issue example');
for (const block of blocks) {
assert.match(
block,
/(^|\r?\n)[>\s]*required_property:/,
`progressive-disclosure issue example lacks required_property:\n${block.trim().slice(0, 200)}`
);
}
});
test('the blocker rendering names the property, not the example, as what must be fixed', () => {
assert.match(
PLAN_CHECKER,
/### Blockers — these properties must hold \("must fix" is the property, never the example\)/,
'"must fix" must unambiguously refer to the required property'
);
assert.match(PLAN_CHECKER, /- Evidence: \{description\}/,
'the blocker rendering must surface the evidence');
assert.match(
PLAN_CHECKER,
/- Example fix \(non-binding — any mechanism reaching the property counts\): \{fix_hint\}/,
'the blocker rendering must label the hint non-binding at the point of display'
);
assert.doesNotMatch(PLAN_CHECKER, /(^|\r?\n)- Fix: \{fix_hint\}/,
'the bare "Fix: {fix_hint}" rendering reads as a prescription and must be gone');
});
test('the adversarial stance requires the property and evidence, not just severity', () => {
assert.match(
flat(PLAN_CHECKER),
/Neither are issues without a `required_property`/,
'a missing required_property must invalidate the finding the same way a missing severity does'
);
});
test('the success checklist gates on the binding/advisory split', () => {
assert.match(
flat(PLAN_CHECKER),
/binding `required_property` \+ evidence \+ severity, with `fix_hint` rendered as a non-binding example/,
'success_criteria must require the split, or the checker is never told to produce it'
);
});
test('the calibration examples model the split and a smaller-alternative acceptance', () => {
assert.doesNotMatch(FEW_SHOT, /suggested_fix|(^|\n)>?\s*finding:|affected_field/,
'few-shot examples must use the plan-checker schema field names, not the drifted ones');
const fewShotBlocks = yamlIssueBlocks(FEW_SHOT);
assert.ok(fewShotBlocks.length >= 3,
`expected the few-shot issue examples to be found, got ${fewShotBlocks.length} — ` +
'a zero here means the block filter stopped matching, not that the file is clean');
for (const block of fewShotBlocks) {
assert.match(block, /(^|\r?\n)[>\s]*required_property:/,
`few-shot issue example lacks required_property:\n${block.trim().slice(0, 200)}`);
}
// The smaller-alternative rule is NOT demonstrated here on purpose: this file is the
// CHECKER's calibration set, fixed by tests/few-shot-calibration.test.cjs at 2 positive +
// 2 negative, and the rule is about what the PLANNER may do with a hint. It is normative in
// the checker and in planner-revision.md, and pinned by the assertions in this suite.
assert.match(flat(FEW_SHOT), /because the binding payload is the property rather than the example, the planner may satisfy it a different way/,
'the calibration commentary must still teach that the hint does not bind');
});
});
// ── Planner side: re-check, smaller alternative, conflict channel ───
describe('#3771 revision re-checks constraints and has a conflict path', () => {
test('constraints are re-read before any edit', () => {
const stepAt = PLANNER_REVISION.indexOf('### Step 2.5');
assert.ok(stepAt > 0, 'a constraint re-check step must exist before Step 3');
const step = PLANNER_REVISION.slice(stepAt);
assert.match(step, /Locked decisions in CONTEXT\.md/, 'locked decisions must be re-checked');
assert.match(step, /capability \/ project guidance/i, 'capability guidance must be re-checked');
assert.match(step, /Constraints the existing plans already encode/, 'plan constraints must be re-checked');
});
test('binding-ness of each field is stated to the planner', () => {
assert.match(flat(PLANNER_REVISION), /`fix_hint` is \*\*one example\*\*/,
'the planner must be told the hint is an example');
assert.match(
flat(PLANNER_REVISION),
/Never treat the absence of the field as licence to apply `fix_hint` literally/,
'an older checker return without required_property must not fall back to literal application'
);
});
test('a smaller sufficient mechanism is preferred and reported as addressed', () => {
assert.match(flat(PLANNER_REVISION), /must be reported as addressed, naming the property satisfied and the mechanism used/);
});
// A marker four workflows dispatch on must be declared and emitted where the agent is
// defined, not only in the shared reference — otherwise nothing produces what they match.
test('the producing agents declare and emit REVISION_CONFLICT', () => {
for (const [name, agent] of [['gsd-planner', PLANNER], ['gsd-ui-researcher', UI_RESEARCHER]]) {
assert.match(agent, /```markdown\r?\n## REVISION_CONFLICT/,
`${name} must emit the marker in-fence, or check:contract-drift reports an orphan consumer`);
}
const plannerRow = CONTRACTS.split(/\r?\n/).find((l) => l.startsWith('| gsd-planner |'));
const uiRow = CONTRACTS.split(/\r?\n/).find((l) => l.startsWith('| gsd-ui-researcher |'));
assert.ok(plannerRow && uiRow, 'both registry rows must exist');
for (const [name, row] of [['gsd-planner', plannerRow], ['gsd-ui-researcher', uiRow]]) {
assert.match(row, /`## REVISION_CONFLICT`/, `${name}'s registry row must declare the marker`);
}
for (const consumer of ['quick/steps/plan-checker-loop.md', 'verify-work.md']) {
assert.ok(plannerRow.includes(consumer),
`gsd-planner's Consumed by must list ${consumer} — it dispatches on the marker`);
}
});
test('conflicts return REVISION_CONFLICT carrying conflicts and alternatives', () => {
assert.match(PLANNER_REVISION, /## REVISION_CONFLICT/);
const block = PLANNER_REVISION.slice(PLANNER_REVISION.indexOf('### Step 7b'));
assert.match(block, /### Alternatives Considered/, 'the conflict must carry alternatives');
assert.match(block, /Conflicts with/, 'the conflict must name what it conflicts with');
assert.match(block, /it does not count as a failed revision iteration/,
'a conflict must not consume retry budget');
});
test('the completion checklist accepts a smaller mechanism and rejects conflicting application', () => {
const checklist = PLANNER_REVISION.slice(PLANNER_REVISION.indexOf('### Step 5: Validate Changes'));
assert.match(checklist, /smaller\/different mechanism \(both count as addressed\)/);
assert.match(checklist, /No `fix_hint` applied that contradicts a locked decision/);
assert.doesNotMatch(checklist, /- \[ \] All flagged issues addressed\r?\n/,
'the old "all flagged issues addressed" line implies literal application and must be replaced');
});
});
// ── Generic pattern: naming reconciled, literal-application removed ─
describe('#3771 generic revision pattern carries the same separation', () => {
test('the field list matches the plan-checker schema', () => {
assert.match(flat(REVISION_LOOP), /`plan`, `dimension`, `severity`, `required_property`, `description`, `task`, `fix_hint`/,
'the generic pattern must advertise exactly the plan-checker schema');
});
test('BLOCKERs are satisfied by property, not by literal application of the hint', () => {
assert.doesNotMatch(REVISION_LOOP, /For each BLOCKER: make the required change/,
'"make the required change" orders the example applied and must be gone');
assert.match(REVISION_LOOP, /For each BLOCKER: make required_property true/);
assert.match(REVISION_LOOP, /a smaller or different mechanism that makes the same property true/);
});
test('the conflict return is handled before the iteration counter and stall check', () => {
const section = REVISION_LOOP.slice(REVISION_LOOP.indexOf('### Conflict Return'));
assert.ok(section.length > 0, 'the pattern must define a conflict return');
assert.match(section, /has not failed and has not stalled/);
assert.match(flat(section), /Do NOT increment the iteration counter and do NOT update `prev_issue_count`/);
assert.match(flat(REVISION_LOOP), /The increment is step g, AFTER the producing agent returns/,
'the canonical flow must place the increment on the return path, or the rule above is unreachable');
assert.doesNotMatch(flat(REVISION_LOOP), /a\. iteration \+= 1/,
'the pre-dispatch increment is the ordering defect and must be gone');
assert.match(flat(section), /Accepting the output with the blocker still open is NOT offered here/,
'the conflict gate must not become an early exit from a blocker');
});
test('the shared contract does not describe a hand-off that no workflow performs', () => {
assert.match(flat(REVISION_LOOP), /recording is in addition to asking, never instead of it/,
'after #3771 round 2 no workflow hands a conflict to a loop and returns');
assert.doesNotMatch(flat(REVISION_LOOP), /it may route there instead of asking directly/,
'the superseded routing description must not survive as drift');
});
// The conflict text is agent-authored and lands inside a writer-owned slot. Newlines are
// still a trust boundary: an embedded record-shaped line could forge an extra blocker.
test('agent-authored conflict text is sanitized at the write boundary', () => {
assert.match(flat(REVISION_LOOP), /Sanitize before writing — the conflict text is agent-authored/,
'the shared protocol must sanitize where the untrusted text enters the file');
assert.match(flat(REVISION_LOOP), /collapse every newline and tab to a single space, and strip any leading `#`/,
'the rule must name the exact transform, or it is advice rather than a control');
assert.match(flat(REVISION_LOOP), /embedded newline can forge an extra conflict-shaped record inside the owned slot/,
'the contract must state the concrete forgery sanitization prevents');
assert.match(flat(PLAN_PHASE), /Sanitize-then-insert is real shell/,
'the workflow that does the appending must run the rule, not restate it as prose (#3916)');
for (const [name, agent] of [['planner-revision', PLANNER_REVISION], ['gsd-ui-researcher', UI_RESEARCHER]]) {
assert.match(flat(agent), /\*\*Every field is one line of plain text\.\*\*/,
`${name} must forbid the shapes the writer would otherwise have to strip`);
}
assert.match(flat(CONVERGENCE), /reader counts only the first fixed slot at that position/,
'the reader must state the ownership boundary that excludes raw reviewer text');
});
// A missing or non-file artifact must never read as "no conflicts".
// Unverifiable is not the same as clean.
test('the convergence gate fails CLOSED when it cannot read or parse REVIEWS.md', () => {
assert.match(CONVERGENCE, /if \[ ! -f "\$\{REVIEWS_FILE\}" \]; then/,
'the gate must require a regular file before trusting a count of zero');
assert.match(flat(CONVERGENCE), /Refusing to declare convergence on an unverifiable gate/,
'an unreadable or malformed gate input must block, not pass');
assert.match(CONVERGENCE, /OPEN_CONFLICTS=\$\(awk/,
'the executable reader must parse the owned slot');
assert.match(CONVERGENCE, /awk_status=\$\?/,
'a parser failure must remain distinguishable from a legitimate zero');
assert.doesNotMatch(extractConflictGate(), /\|\| true/,
'the owned-block parser must not launder a failure into zero');
});
// ── The gate, EXECUTED ───────────────────────────────────────────
// Source assertions above prove the text says the right thing. These prove the shell does it.
describe('#3771 the extracted conflict gate behaves', { skip: IS_WINDOWS }, () => {
test('counts open conflicts and ignores resolved ones', () => {
withReviews(reviewsArtifact(`${OPEN('a/1')}\n${RESOLVED('b/2')}\n${OPEN('c/3')}\n`), (f) => {
const r = runConflictGate(f);
assert.equal(r.status, 0, `gate should succeed; stderr: ${r.stderr}`);
assert.equal(r.stdout, '2', 'two open, one resolved');
});
});
test('accepts a CRLF artifact without accepting a malformed CRLF boundary', () => {
const crlf = (content) => content.replace(/\n/g, '\r\n');
withReviews(crlf(reviewsArtifact(`${OPEN('a/1')}\n`)), (f) => {
const r = runConflictGate(f);
assert.equal(r.status, 0, `valid CRLF artifact should succeed; stderr: ${r.stderr}`);
assert.equal(r.stdout, '1');
});
withReviews(crlf(reviewsArtifact('').replace(CONFLICTS_END, `${CONFLICTS_END} forged`)), (f) => {
const r = runConflictGate(f);
assert.notEqual(r.status, 0, 'a non-exact CRLF end boundary must still block');
assert.match(r.stderr, /BLOCKED/);
});
});
test('a nested opening delimiter fails CLOSED', () => {
const nested = reviewsArtifact('').replace(
'## Plan-Revision Conflicts\n',
`## Plan-Revision Conflicts\n${CONFLICTS_BEGIN}\n`
);
withReviews(nested, (f) => {
const r = runConflictGate(f);
assert.notEqual(r.status, 0, 'a nested opening delimiter must not hide later state');
assert.match(r.stderr, /BLOCKED/);
});
});
// Adversarial-review regression (agy/gemini-3.8-flash-high, #3916 round 4): a blank line
// before the delimiter is already tolerated; the heading was not, so a formatter (Prettier,
// markdownlint) or an LLM writer inserting one would hard-abort convergence on a well-formed
// file.
test('a blank line between the opening delimiter and the heading is tolerated', () => {
const spaced = reviewsArtifact(`${OPEN('a/1')}\n`).replace(
`${CONFLICTS_BEGIN}\n## Plan-Revision Conflicts\n`,
`${CONFLICTS_BEGIN}\n\n## Plan-Revision Conflicts\n`
);
withReviews(spaced, (f) => {
const r = runConflictGate(f);
assert.equal(r.status, 0, `a blank line before the heading must not block: ${r.stderr}`);
assert.equal(r.stdout, '1');
});
});
test('a missing or altered canonical heading fails CLOSED', () => {
for (const replacement of ['', '## Altered Conflict Heading\n']) {
const malformed = reviewsArtifact(`${OPEN('a/1')}\n`).replace(
'## Plan-Revision Conflicts\n',
replacement
);
withReviews(malformed, (f) => {
const r = runConflictGate(f);
assert.notEqual(r.status, 0, 'a non-canonical owned block must not be accepted or regenerated');
assert.match(r.stderr, /BLOCKED/);
});
}
});
test('an empty owned block is a legitimate zero, not an error', () => {
withReviews(reviewsArtifact('', '## Reviews\n\nNothing here.\n'), (f) => {
const r = runConflictGate(f);
assert.equal(r.status, 0, `no matches must not fail the gate; stderr: ${r.stderr}`);
assert.equal(r.stdout, '0');
});
});
// The defect that started this: a section-scoped scan stops at the first `## ` it meets.
test('an injected heading cannot hide a conflict beneath it', () => {
withReviews(reviewsArtifact(`${RESOLVED('a/1')}\n## Injected By Agent Text\n${OPEN('b/2')}\n`), (f) => {
const r = runConflictGate(f);
assert.equal(r.status, 0, `gate should succeed; stderr: ${r.stderr}`);
assert.equal(r.stdout, '1', 'the conflict below the injected heading must still count');
});
});
// An unreadable artifact must fail before the parser can emit a count.
test('a scan failure BLOCKS instead of reporting zero conflicts', () => {
const r = runConflictGate('/nonexistent/definitely-not-here/07-REVIEWS.md');
assert.notEqual(r.status, 0, 'an unreadable REVIEWS.md must not converge');
assert.match(r.stderr, /BLOCKED/, 'the gate must say why it refused');
assert.notEqual(r.stdout.trim(), '0', 'it must not emit a zero count on failure');
});
test('an empty REVIEWS_FILE path BLOCKS', () => {
const r = runConflictGate('');
assert.notEqual(r.status, 0, 'an unresolved path must not converge');
assert.match(r.stderr, /BLOCKED/);
});
});
// The slot is a blocking gate's state. One content owner, or it can be forged.
test('only plan-phase may mutate the conflicts section', () => {
assert.match(flat(CONVERGENCE), /\*\*Only `\/gsd:plan-phase` mutates the contents of this slot\.\*\*/,
'the section needs exactly one declared content owner');
assert.match(flat(CONVERGENCE), /review agent preserves the existing `## Plan-Revision Conflicts` block byte-for-byte/,
'the artifact writer may delimit and preserve the slot, never synthesize its state');
});
test('an issue satisfied by a smaller mechanism counts as resolved for the loop checks', () => {
assert.match(
REVISION_LOOP,
/A remediation hint is an example, not an order/,
'the Important Notes must state the binding rule the loop depends on'
);
});
});
// ── Orchestrators: routing without burning retry budget ────────────
const ORCHESTRATORS = [
['plan-phase', PLAN_PHASE, 'iteration_count'],
['quick plan-checker-loop', QUICK_LOOP, 'iteration_count'],
// quick-batch's per-item loop was missed in the initial pass (agy/gemini-3.8-flash-high
// adversarial review, #3916 round 4) -- it hands <revision_context> to gsd-planner exactly
// like quick's single-task loop, but had none of this contract until that review caught it.
['quick-batch plan-checker-loop', QUICK_BATCH_LOOP, 'iteration_count'],
['ui-phase', UI_PHASE, 'revision_count'],
// verify-work's gap-plan revision hands <revision_context> to gsd-planner, so it inherits the
// contract whether or not it states it. It was missed in the first pass (#3771 round-2 review).
['verify-work gap-plan revision', VERIFY_WORK, 'iteration_count'],
];
describe('#3771 every revision orchestrator routes conflicts instead of retrying', () => {
// plan-phase @-imports revision-loop.md, so the shared Conflict Return protocol really is in
// its loaded context and it states only its own bindings. The other three do not import it and
// must carry the rules inline. `loadedFor` is what the runtime actually puts in front of each
// orchestrator — the honest surface to assert a shared rule against.
const importsShared = (content) => /@~\/\.claude\/gsd-core\/references\/revision-loop\.md/.test(content);
const loadedFor = (content) => (importsShared(content) ? flat(content + '\n' + REVISION_LOOP) : flat(content));
test('plan-phase delegates the shared protocol rather than duplicating it', () => {
assert.ok(importsShared(PLAN_PHASE), 'plan-phase must @-import the reference it defers to');
assert.match(flat(PLAN_PHASE), /follow the shared Conflict Return protocol in `gsd-core\/references\/revision-loop\.md`/,
'the delegation must be explicit, or the bindings have no protocol to bind to');
});
for (const [name, content, counter] of ORCHESTRATORS) {
const loaded = loadedFor(content);
test(`${name} tells the reviser the hint is non-binding`, () => {
assert.match(loaded, /`fix_hint` is ONE non-binding example route/,
`${name} must mark the remediation example non-binding in its revision prompt`);
assert.match(loaded, /smaller or different mechanism reaching the same property/,
`${name} must accept a smaller alternative`);
});
test(`${name} orders a constraint re-check before editing`, () => {
assert.match(loaded, /BEFORE editing/,
`${name} must order the constraint re-check before any edit`);
assert.match(loaded, /return `## REVISION_CONFLICT` with the conflict and\s+the alternatives rather than applying or working around it/,
`${name} must forbid applying a conflicting hint`);
});
// Four prompts state this contract; planner-revision.md is the authority they must agree
// with. Each must name where that authority is, or the next editor updates one of five.
test(`${name} names the authority its inline statement summarises`, () => {
assert.match(loaded, /Full contract:\s+`gsd-core\/references\/planner-revision\.md`|see your `## Revision Conflict`\s+section/,
`${name} must point at the contract its prompt paraphrases`);
});
test(`${name} routes REVISION_CONFLICT without consuming ${counter}`, () => {
assert.match(loaded, /## REVISION_CONFLICT/,
`${name} must handle the conflict return`);
assert.match(
loaded,
new RegExp(`[Dd]o NOT increment (the iteration counter|\`?${counter}\`?)`),
`${name} must not spend a revision iteration on an unresolvable conflict`
);
});
// A counter incremented BEFORE dispatch is already spent when the conflict comes back, so
// "do NOT increment" would be unreachable prose. The increment must sit on the return path.
test(`${name} increments ${counter} on the return, not before dispatch`, () => {
assert.doesNotMatch(
flat(content),
new RegExp(`- Increment \`${counter}\` - Re-spawn`),
`${name} must not increment ${counter} before the reviser is dispatched`
);
assert.match(loaded, new RegExp(`(returns|return) [^.]*increment \`?${counter}\`?|increment \`?${counter}\`?, then re-spawn|Counter not spent: \`${counter}\``, 'i'),
`${name} must increment ${counter} only once the reviser has returned`);
});
// Not incrementing the counter removes the bound the counter provided. Something must
// replace it, or an agent returning the same conflict forever loops unattended.
// Two bounds, because one is evadable: an agent alternating property names never trips the
// repeat rule, so the repeat rule alone leaves the un-incremented path unbounded.
test(`${name} bounds conflict recurrence so the un-incremented path cannot spin`, () => {
assert.match(
loaded,
/same `required_property` (a second time in a row|twice in a row)/i,
`${name} must detect a repeated conflict rather than re-spawning forever`
);
assert.match(
loaded,
/THIRD conflict return of this loop whatever property it names/,
`${name} must cap TOTAL conflict returns — round-robin across property names evades the repeat rule`
);
});
// The conflict gate resolves the conflict; it must not become an early exit from a blocker.
test(`${name} re-evaluates a second conflict instead of falling through to the checker`, () => {
assert.match(
loaded,
/re-evaluate (its|the [a-z]+'s|the) return (from the top of this handler|here)|return to this step/,
`${name} must loop back on the re-spawn, not fall through to the checker spawn`
);
});
test(`${name} does not offer accepting the output with the blocker still open`, () => {
assert.match(
loaded,
/is NOT offered here/,
`${name} must state that accepting an unaddressed blocker is not one of the conflict options`
);
assert.match(loaded, /amend the constraint/,
`${name} must offer amending the constraint as the third resolving option`);
});
}
test('plan-phase checker retry is explicitly the non-conflict return path', () => {
const handler = PLAN_PHASE.slice(
PLAN_PHASE.indexOf('**If the planner returns `## REVISION_CONFLICT`:**'),
PLAN_PHASE.indexOf('## 12.5. Plan Bounce')
);
assert.match(
handler,
/\*\*Otherwise \(revised plans, not `## REVISION_CONFLICT`\):\*\*[\s\S]*?Spawn checker again \(step 10\), then increment `iteration_count`\./,
'the normal checker path must be disjoint from the conflict re-entry path'
);
assert.doesNotMatch(handler, /\nAfter planner returns ->/,
'an unconditional post-return instruction textually falls through from REVISION_CONFLICT');
});
test('plan-phase records the conflict on a channel it can actually test for', () => {
assert.match(PLAN_PHASE, /workflow\.plan_review_convergence/,
'plan-phase must consult the convergence config');
assert.match(PLAN_PHASE, /REVIEWS_FILE="\$\{REVIEWS_PATH\}"/,
'conflict persistence must use the path initialized by the workflow');
assert.doesNotMatch(PLAN_PHASE, /REVIEWS_FILE=\$\(ls "\$\{PHASE_DIR\}"\/\*-REVIEWS\.md/,
'a second glob lookup can select a different review artifact');
assert.match(flat(PLAN_PHASE), /CONVERGENCE_ENABLED.*true.*\[ ! -f "\$\{REVIEWS_FILE\}" \].*BLOCKED: cannot persist plan-revision conflict/i,
'enabled persistence must fail closed unless REVIEWS_PATH is a regular file');
// #3916: a phase's FIRST revision cycle can hit REVISION_CONFLICT before any REVIEWS.md
// exists, so REVIEWS_PATH is legitimately empty there — that must not hard-block the return.
assert.match(flat(PLAN_PHASE), /CONVERGENCE_ENABLED.*true.*\[ -n "\$\{REVIEWS_FILE\}" \].*\[ ! -f "\$\{REVIEWS_FILE\}" \].*BLOCKED: cannot persist plan-revision conflict/i,
'the hard-block must require a NON-EMPTY REVIEWS_FILE, or a brand-new phase with no reviews yet can never return a conflict at all');
assert.match(flat(PLAN_PHASE), /plan-phase wrote the line, so plan-phase closes it/,
'closure must have exactly one named owner, or a line can be orphaned open');
assert.match(flat(REVISION_LOOP), /never invokes `\/gsd:plan-review-convergence`/,
'plan-phase runs inside that loop; invoking it would be a cycle');
// A markdown table cannot be counted by any simple filter — its header and separator rows
// look like data. The recorded shape must be one the reader can match exactly.
assert.match(flat(REVISION_LOOP), /A checkbox, not a table row/,
'the recorded conflict must be countable without parsing a table');
assert.match(REVISION_LOOP, /- \[ \] REVISION_CONFLICT \{dimension\}\/\{plan\} — required_property:/,
'the shared protocol must define the open form the convergence gate matches');
assert.match(flat(REVISION_LOOP), /owns flipping it to `- \[x\]`/,
'the close step must produce the resolved form the gate excludes');
});
test('convergence gates on the conflicts BEFORE it writes state or prints success', () => {
assert.match(CONVERGENCE, /## Plan-Revision Conflicts/,
'the convergence loop must know about the section plan-phase writes');
assert.match(CONVERGENCE, /OPEN_CONFLICTS=/,
'the count must be read from REVIEWS.md — CYCLE_SUMMARY does not carry it');
// The counter and writer must agree on both marker and ownership boundary.
assert.match(CONVERGENCE, /in_owned && \/\^- \\\[ \\\] REVISION_CONFLICT \.\*required_property:\//,
'the gate must count the conflict line shape only while inside the owned slot');
assert.match(CONVERGENCE, /gsd:plan-revision-conflicts:begin/);
assert.match(CONVERGENCE, /gsd:plan-revision-conflicts:end/);
assert.doesNotMatch(CONVERGENCE, /grep -c/,
'the superseded global scan would count raw reviewer text and must not return');
assert.match(flat(CONVERGENCE), /escalates rather than deadlocking/,
'the gate must state that an unresolvable conflict still terminates at MAX_CYCLES');
assert.match(
CONVERGENCE,
/\*\*If HIGH_COUNT == 0 and ACTIONABLE_COUNT == 0 and OPEN_CONFLICTS == 0 \(converged\):\*\*/,
'an open conflict must be part of the converged CONDITION, not a note after the banner'
);
// Ordering is the whole finding: the gate placed after `state planned-phase` would write
// and announce convergence over a conflict nobody resolved.
const gateAt = CONVERGENCE.indexOf('OPEN_CONFLICTS=$(awk');
const writeAt = CONVERGENCE.indexOf('gsd_run state planned-phase');
const bannerAt = CONVERGENCE.indexOf('GSD ► CONVERGENCE COMPLETE');
assert.ok(gateAt > 0 && writeAt > 0 && bannerAt > 0, 'all three anchors must exist');
assert.ok(gateAt < writeAt, 'the conflict gate must precede the planned-phase state write');
assert.ok(gateAt < bannerAt, 'the conflict gate must precede the convergence banner');
assert.match(flat(CONVERGENCE), /Re-running the planner against an unchanged conflict cannot resolve it/,
'the replan step must be told that re-running alone cannot clear a conflict');
});
test('quick does not advertise a convergence route it has no artifact for', () => {
assert.match(flat(QUICK_LOOP), /A quick task has no REVIEWS\.md and no phase/,
'quick must say why the convergence route does not apply, rather than dangling a dead branch');
});
test('the conflict is surfaced to the user with its alternatives', () => {
for (const [name, content] of ORCHESTRATORS) {
assert.match(loadedFor(content), /alternatives to the user|conflict and its alternatives to the user/,
`${name} must present the alternatives rather than deciding silently`);
}
});
});
// ── UI-spec loop and the gap-plan hint ─────────────────────────────
describe('#3771 the UI-spec and gap-plan hints are marked non-binding too', () => {
test('the UI checker states the property and marks its hint an example', () => {
assert.match(UI_CHECKER, /\*\*`fix_hint` is an example, never an order\.\*\*/);
assert.match(flat(UI_CHECKER), /reaches the same property by a smaller or different mechanism has resolved the issue in full/);
const uiBlocks = yamlIssueBlocks(UI_CHECKER);
assert.ok(uiBlocks.length >= 6, `expected the UI dimension examples, got ${uiBlocks.length}`);
assert.doesNotMatch(UI_CHECKER, /exact fix required/,
'the UI verdict must not order an exact fix — that is the prescription this fix removes');
assert.match(flat(UI_CHECKER), /- \*\*Dimension \{N\} — \{name\}:\*\* \{required_property\} Evidence: \{description\} Example fix \(non-binding/,
'the UI ISSUES FOUND rendering must name the property, its evidence, and a non-binding example');
for (const block of uiBlocks) {
assert.match(block, /(^|\r?\n)[>\s]*required_property:/,
`UI checker issue example lacks required_property:\n${block.trim().slice(0, 200)}`);
}
});
test('the UI-spec revision resolves listed issues rather than applying listed fixes', () => {
assert.doesNotMatch(UI_PHASE, /fix ONLY the listed issues/,
'"fix ONLY the listed issues" pairs with a prescriptive hint; it must read as resolve');
assert.match(UI_PHASE, /resolve ONLY the listed issues/);
});
test('the gap-plan hint is bound to the root cause, not to the suggested direction', () => {
assert.doesNotMatch(DIAGNOSE, /- suggested_fix: Hint for gap closure plan/,
'the gap-closure hint must not read as the binding payload');
assert.match(DIAGNOSE, /fix_hint: NON-BINDING example route for the gap closure plan/);
assert.match(flat(DIAGNOSE), /the binding payload is `root_cause`/);
});
});
// ── Preservation: nothing legitimately binding was weakened ────────
describe('#3771 preserves everything that legitimately binds', () => {
test('blockers still block and severity still gates', () => {
assert.match(PLAN_CHECKER, /Issues without a severity classification are not valid output/);
assert.match(PLAN_CHECKER, /\*\*blocker\*\* - The `required_property` must hold before execution/);
assert.match(PLAN_CHECKER, /\*\*BLOCKER\*\* — the phase goal will not be achieved if this is not fixed before execution/);
});
test('iteration caps and stall escalation still fire', () => {
assert.match(REVISION_LOOP, /## Pattern: Check-Revise-Escalate \(max 3 iterations\)/);
assert.match(REVISION_LOOP, /If the count does not decrease between consecutive iterations/);
assert.match(PLAN_PHASE, /## 12\. Revision Loop \(Max 3 Iterations\)/);
assert.match(PLAN_PHASE, /\*\*Stall detection:\*\* If `issue_count >= prev_issue_count`/);
assert.match(QUICK_LOOP, /\*\*Revision loop \(max 2 iterations\):\*\*/);
assert.match(UI_PHASE, /## 9\. Revision Loop \(Max 2 Iterations\)/);
});
test('required task fields and decision coverage still hold', () => {
assert.match(PLAN_CHECKER, /\*\*FAIL the verification\*\* if any requirement ID from the roadmap is absent/);
assert.match(PLANNER_REVISION, /\*\*DO NOT:\*\* Rewrite entire plans for minor issues/);
assert.match(REVISION_LOOP, /Do NOT introduce new issues while fixing existing ones/);
assert.match(REVISION_LOOP, /Preserve all content not flagged by the checker/);
});
});
// ── PR #3916 live review remediation ──────────────────────────────
describe('#3916 writer, persistence, reader and migration contracts agree', () => {
test('the canonical writer renders one uniquely-discriminated line that the real gate counts',
{ skip: IS_WINDOWS }, () => {
const template = extractConflictTemplate();
assert.doesNotMatch(template, /\r?\n/, 'one conflict must be exactly one physical line');
assert.match(template, /^- \[ \] REVISION_CONFLICT /,
'the writer must start at column zero with a reader-specific discriminator');
const field = fc.oneof(
fc.constantFrom('', 'x', '# heading\nnext', '- item', '| cell', '```fence'),
fc.string({ maxLength: 32 })
);
fc.assert(fc.property(
fc.record({ dimension: field, plan: field, property: field, constraint: field, alternatives: field }),
(fields) => withReviews(reviewsArtifact(`${renderConflictTemplate(fields)}\n`), (file) => {
const result = runConflictGate(file);
assert.equal(result.status, 0, `gate should read a rendered canonical record: ${result.stderr}`);
assert.equal(result.stdout, '1', 'one rendered open conflict must count as one');
})
));
});
test('reviewer-authored conflict markers outside the owned block are not live state',
{ skip: IS_WINDOWS }, () => {
const forged = `${OPEN('forged/reviewer')}\n`;
withReviews(reviewsArtifact('', `## Reviewer Notes\n${forged}`), (file) => {
const result = runConflictGate(file);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, '0');
});
});
test('review regeneration preserves one deterministically bounded conflict block byte-for-byte', () => {
assert.match(flat(REVIEW), /capture only the existing conflict entry bytes after the exact/i);
assert.match(REVIEW, /\{preserved_plan_revision_conflict_entries\}/,
'the REVIEWS.md writer template needs an explicit preservation slot');
assert.match(REVIEW, /<!-- gsd:plan-revision-conflicts:begin -->\n## Plan-Revision Conflicts\n\{preserved_plan_revision_conflict_entries\}\n<!-- gsd:plan-revision-conflicts:end -->/,
'the first-write template must emit the canonical heading before preserved entries');
assert.match(flat(REVIEW), /restore the captured bytes at the explicit slot below/i);
});
test('the canonical flow declares and enforces both conflict counters', () => {
const flow = REVISION_LOOP.slice(REVISION_LOOP.indexOf('### Flow'), REVISION_LOOP.indexOf('### Issue Count Tracking'));
assert.match(flow, /previous_conflict_property = null/);
assert.match(flow, /conflict_return_count = 0/);
assert.match(flow, /conflict_return_count \+= 1/);
assert.match(flow, /If conflict_return_count >= 3/,
'alternating properties must still hit the total-conflict cap');
assert.doesNotMatch(flow, /same required_property[\s\S]*bounds this path/,
'the repeat-only rule must not claim it bounds alternating conflicts');
assert.match(flow, /Else: previous_conflict_property = current required_property[\s\S]*resolve it/,
'a non-repeat resolution must advance the property compared by the next return');
});
test('persisted conflicts are idempotent records and reviews-mode replanning closes them', () => {
assert.match(flat(REVISION_LOOP), /reuse the existing open line instead of appending a duplicate/i,
'identical open state needs idempotency, not a second event identity');
assert.match(flat(PLAN_PHASE), /before replanning from `--reviews`, scan `REVIEWS_PATH` for open plan-revision conflicts/i);
const initAt = PLAN_PHASE.indexOf('REVIEWS_PATH=$(_gsd_field "$INIT" reviews_path)');
const scanAt = PLAN_PHASE.indexOf('**If plans exist AND the `--reviews` flag is set:**');
assert.ok(initAt > 0 && scanAt > 0, 'both REVIEWS_PATH initialization and reviews-mode scan must exist');
assert.ok(initAt < scanAt, 'REVIEWS_PATH must be initialized before reviews-mode scans it');
assert.match(flat(PLAN_PHASE), /flip the matching line to `- \[x\]` once the chosen resolution is applied/i);
});
test('REVIEWS_FILE is a quoted direct path and must be a regular file', () => {
assert.match(CONVERGENCE, /REVIEWS_FILE="\$\{phase_dir\}\/\$\{padded_phase\}-REVIEWS\.md"/);
assert.doesNotMatch(CONVERGENCE, /REVIEWS_FILE=\$\(ls \$\{phase_dir\}/,
'word-splitting and glob expansion must not select the gate input');
assert.match(CONVERGENCE, /\[ ! -f "\$\{REVIEWS_FILE\}" \]/,
'directories and other readable non-files are not valid review artifacts');
});
test('a config query failure blocks persistence instead of reading as disabled', () => {
assert.doesNotMatch(PLAN_PHASE, /config-get workflow\.plan_review_convergence 2>\/dev\/null \|\| echo "false"/);
assert.match(flat(PLAN_PHASE), /BLOCKED: cannot read workflow\.plan_review_convergence/i);
});
test('the gate reads a literal-backslash POSIX filename without rewriting it',
{ skip: IS_WINDOWS }, () => {
withReviews(reviewsArtifact(`${OPEN('a/1')}\n`), (file) => {
const result = runConflictGate(file);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, '1');
}, '07\\-REVIEWS.md');
assert.doesNotMatch(CONVERGENCE, /tr '\\\\' '\/'/,
'a quoted POSIX path is already exact; rewriting backslashes corrupts a valid filename');
});
test('the scope calibration stays inside a declared threshold band', () => {
assert.match(PLAN_CHECKER, /tasks: 4\r?\n\s+files: 8/,
'the warning is triggered by 4 tasks; its file count should remain in the 5-8 target band');
});
test('quick mode names locked decisions only when CONTEXT.md exists', () => {
assert.match(QUICK_LOOP, /\$\{DISCUSS_MODE \? 'locked decisions in ' \+ quick_id \+ '-CONTEXT\.md, ' : ''\}capability guidance/);
});
test('the command docs include open conflicts in the exit condition', () => {
const section = COMMANDS.slice(COMMANDS.indexOf('### `/gsd-plan-review-convergence`'));
assert.match(flat(section), /open `## Plan-Revision Conflicts` entries.*must also be zero/i);
});
// The writer-side sanitize+insert step used to be a prose instruction for the
// orchestrator LLM to apply by hand (flagged as Minor across two review rounds).
// #3916 makes it real shell; these tests RUN it, composing with the existing
// reader gate, so a regression here reds the suite instead of only the prose.
test('the writer gate sanitizes hostile fields and the reader counts exactly one',
{ skip: IS_WINDOWS }, () => {
const field = fc.oneof(
fc.constantFrom('', 'x', '# heading\nnext', '- item', '| cell', '```fence', 'a\tb\nc'),
fc.string({ maxLength: 32 })
);
fc.assert(fc.property(
fc.record({ dimension: field, plan: field, property: field, constraint: field, alternatives: field }),
(fields) => withReviews(reviewsArtifact(), (file) => {
const before = fs.readFileSync(file, 'utf-8');
const result = runWriterGate(file, fields);
assert.equal(result.status, 0, `writer gate should succeed: ${result.stderr}`);
const after = fs.readFileSync(file, 'utf-8');
const added = after.slice(before.lastIndexOf(CONFLICTS_END));
assert.doesNotMatch(added.replace(CONFLICTS_END, ''), /\r?\n.*\S/,
'exactly one physical line must be inserted before the end delimiter');
const reader = runConflictGate(file);
assert.equal(reader.status, 0, reader.stderr);
assert.equal(reader.stdout, '1', 'the reader must count the sanitized insert as one open conflict');
})
));
});
test('the writer gate is idempotent on a repeated identical conflict',
{ skip: IS_WINDOWS }, () => {
withReviews(reviewsArtifact(), (file) => {
const fields = { dimension: 'd', plan: 'p1', property: 'prop', constraint: 'D-1', alternatives: 'alt' };
assert.equal(runWriterGate(file, fields).status, 0);
assert.equal(runWriterGate(file, fields).status, 0);
const reader = runConflictGate(file);
assert.equal(reader.status, 0, reader.stderr);
assert.equal(reader.stdout, '1', 'the same conflict recorded twice must not duplicate the line');
});
});
test('the writer gate fails closed and leaves the file untouched when the owned slot is missing',
{ skip: IS_WINDOWS }, () => {
withReviews('# Cross-AI Plan Review — Phase 7\n\nno owned slot here\n', (file) => {
const before = fs.readFileSync(file, 'utf-8');
const fields = { dimension: 'd', plan: 'p1', property: 'prop', constraint: 'D-1', alternatives: 'alt' };
const result = runWriterGate(file, fields);
assert.notEqual(result.status, 0, 'a missing end delimiter must not silently succeed');
assert.equal(fs.readFileSync(file, 'utf-8'), before,
'a failed write must never partially mutate REVIEWS.md');
});
});
// Adversarial-review regression (agy/gemini-3.8-flash-high, #3916): `awk -v line="$LINE"`
// decodes a literal two-character `\n` in agent text into a real newline — a forgery `tr`
// (which only touches actual control bytes) cannot catch. ENVIRON does not decode escapes.
test('a literal backslash-n in agent text stays on one line (awk -v escape-decoding forgery)',
{ skip: IS_WINDOWS }, () => {
withReviews(reviewsArtifact(), (file) => {
const fields = {
dimension: 'dim with literal \\n mid-text', plan: 'p1', property: 'prop',
constraint: 'D-1', alternatives: 'alt',
};
const result = runWriterGate(file, fields);
assert.equal(result.status, 0, result.stderr);
const reader = runConflictGate(file);
assert.equal(reader.status, 0, reader.stderr);
assert.equal(reader.stdout, '1', 'a literal backslash-n must not split the record into two lines');
});
});
// Adversarial-review regression (#3916): a resolved conflict must actually get flipped to
// `- [x]` in the SAME session that resolved it — nothing else in plan-phase revisits it, so
// an unclosed record blocks convergence forever.
test('the close gate flips a resolved conflict to [x] and the reader no longer counts it',
{ skip: IS_WINDOWS }, () => {
withReviews(reviewsArtifact(), (file) => {
const fields = { dimension: 'd', plan: 'p1', property: 'prop', constraint: 'D-1', alternatives: 'alt' };
assert.equal(runWriterGate(file, fields).status, 0);
assert.equal(runConflictGate(file).stdout, '1');
const result = runCloseGate(file, fields.dimension, fields.plan, 'adopted alternative');
assert.equal(result.status, 0, result.stderr);
const after = fs.readFileSync(file, 'utf-8');
assert.match(after, /^- \[x\] REVISION_CONFLICT .*\| resolved: adopted alternative$/m);
assert.doesNotMatch(after, /^- \[ \] REVISION_CONFLICT/m, 'no open line may survive a close');
const reader = runConflictGate(file);
assert.equal(reader.status, 0, reader.stderr);
assert.equal(reader.stdout, '0', 'a closed conflict must no longer count as open');
});
});
// Adversarial-review regression (agy/gemini-3.8-flash-high, #3916 round 4): with more than one
// conflict open at once, closing by identity must touch only the matching record -- a design
// that closed by a single remembered full-line string would drop whichever conflict it
// overwrote last.
test('the close gate with two open conflicts closes only the matching one', { skip: IS_WINDOWS }, () => {
withReviews(reviewsArtifact(`${OPEN('a/1')}\n${OPEN('b/2')}\n`), (file) => {
const result = runCloseGate(file, 'a', '1', 'adopted alternative');
assert.equal(result.status, 0, result.stderr);
const after = fs.readFileSync(file, 'utf-8');
assert.match(after, /^- \[x\] REVISION_CONFLICT a\/1 .*\| resolved: adopted alternative$/m);
assert.match(after, /^- \[ \] REVISION_CONFLICT b\/2 /m, 'the unrelated open conflict must survive untouched');
assert.equal(runConflictGate(file).stdout, '1', 'exactly one conflict must remain open');
});
});
test('the close gate fails closed when the pending conflict line is not found',
{ skip: IS_WINDOWS }, () => {
withReviews(reviewsArtifact(`${OPEN('a/1')}\n`), (file) => {
const before = fs.readFileSync(file, 'utf-8');
const result = runCloseGate(file, 'never', 'written', 'x');
assert.notEqual(result.status, 0, 'closing a conflict that was never recorded must not silently succeed');
assert.equal(fs.readFileSync(file, 'utf-8'), before,
'a failed close must never partially mutate REVIEWS.md');
});
});
// Adversarial-review regression (#3916): the reader gate strips a trailing \r before
// comparing lines; both writer-side awk gates did not, so a CRLF REVIEWS.md (a Windows
// checkout) made every `$0 == ENVIRON[...]` comparison miss and fail closed forever.
test('the writer gate matches the owned end delimiter on a CRLF REVIEWS.md', { skip: IS_WINDOWS }, () => {
const crlf = (content) => content.replace(/\n/g, '\r\n');
withReviews(crlf(reviewsArtifact()), (file) => {
const fields = { dimension: 'd', plan: 'p1', property: 'prop', constraint: 'D-1', alternatives: 'alt' };
const result = runWriterGate(file, fields);
assert.equal(result.status, 0, `writer gate must match a CRLF end delimiter: ${result.stderr}`);
assert.equal(runConflictGate(file).stdout, '1');
});
});
test('the close gate matches the pending conflict line on a CRLF REVIEWS.md', { skip: IS_WINDOWS }, () => {
const crlf = (content) => content.replace(/\n/g, '\r\n');
withReviews(crlf(reviewsArtifact(`${OPEN('a/1')}\n`)), (file) => {
const result = runCloseGate(file, 'a', '1', 'adopted alternative');
assert.equal(result.status, 0, `close gate must match a CRLF-terminated open line: ${result.stderr}`);
assert.equal(runConflictGate(file).stdout, '0');
});
});
// Adversarial-review regression (#3916): the CRLF fix above must compare a CR-stripped COPY,
// not mutate `$0` in place -- `sub(/\r$/, "")` on `$0` itself silently rewrites every
// passed-through line's ending to LF on any insert or close, corrupting an unrelated file.
test('the writer gate on a CRLF REVIEWS.md leaves unrelated lines CRLF-terminated', { skip: IS_WINDOWS }, () => {
const crlf = (content) => content.replace(/\n/g, '\r\n');
withReviews(crlf(reviewsArtifact()), (file) => {
const fields = { dimension: 'd', plan: 'p1', property: 'prop', constraint: 'D-1', alternatives: 'alt' };
assert.equal(runWriterGate(file, fields).status, 0);
const after = fs.readFileSync(file, 'utf-8');
assert.ok(after.startsWith('# Cross-AI Plan Review — Phase 7\r\n'),
'a pre-existing line must keep its original CRLF ending');
assert.match(after, /- \[ \] REVISION_CONFLICT d\/p1/, 'the record must actually be inserted');
});
});
});