Files
imbad0202__academic-researc…/scripts/ars_apply_revision_patch.py
T

913 lines
36 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Apply a revision patch to an anchored draft — two-phase, fail-closed.
#89 Item 7 Slice A. Normative source:
`docs/design/2026-06-10-390-diff-patch-revision-mode-spec.md` §3.3
(deterministic apply), §3.2 (patch document constraints).
**Phase 1 — validate everything, touch nothing.** Schema-validate the
patch; verify `base_draft_hash` against the base file's raw bytes; parse
the base with the shared parser; check every op (target exists,
`old_hash` matches the current normalized block text, each block ID in
at most one op in any role — the `DOC-BODY-START` sentinel included,
no `<!--block:` in `new_text`, `new_text` segments cleanly). ANY failure
rejects the whole patch with a structured report and no output artifact:
a stale-base or hallucinated-target patch cannot half-land.
**Phase 2 — apply all, by byte-span splicing.** The original byte stream
is spliced at the validated spans; every byte outside them — untouched
blocks' marker lines and inter-block separator bytes included — is
copied verbatim from the base. Hash normalization is read-side only and
never appears in the output. Fresh IDs are assigned by this script alone
(`max(existing) + 1`, base-document encounter order). Output is written
to a temp file and atomically renamed; a post-write self-check re-parses
the result and asserts marker uniqueness + grammar.
**Structural-shape triggers** (§3.3, deterministic): heading-block ops,
feat: diff/patch revision mode Slice B — revision-mode adoption (#89 Item 7) (#426) Closes #424 Slice B of #89 Item 7 (spec #390), building on the Slice A deterministic toolchain (#423). `academic-paper` revision mode now runs **anchorize → patch → deterministic apply → finalizer** instead of asking `draft_writer_agent` to re-emit the complete paper — confining the DELEGATE-52 silent-distortion surface to the blocks an operation explicitly touches. ## What landed (the 6 deliverables) 1. **Writer patch-output contract** — `draft_writer_agent.md § Patch-Document Revision Emission`: patch as a `phase6_*/revision_patch_round<N>.json` sidecar (#424 emission decision), hashes copied from the block manifest never computed, `[PATCH-ESCALATION-REQUIRED:]` pre-drafting tag, retry-once, provisional Schema 8 items with mechanical fields left to the orchestrator. 2. **Orchestration sequencing** — `pipeline_orchestrator_agent.md § Revision-Round Patch Sequencing`: five normative steps with a no-rewrite window between manifest generation and apply (a finalizer pass in between would produce spurious hash mismatches). 3. **Escalation gate** — two trigger layers (pre-drafting classification / apply-time `refused_structural`), MANDATORY checkpoint wording, never auto-fallback to full re-emission, escalated rounds re-anchorize under a new ID generation and stamp `mode: full_reemission_escalated`. 4. **Schema 8 delta** — `ResponseItem.change_block_ids` (optional), orchestrator-populated from the apply report (§3.5 role split — inserted block IDs are post-apply facts). 5. **Protocol doc + Mode B commands** — `academic-paper/references/revision_patch_protocol.md`: exact anchorize/apply command sequence, exit codes, apply report as a required re-review input, marker lifecycle. 6. **Lint** — `scripts/check_390_revision_patch_discipline.py` (8 invariants) + 30 mutation tests, wired into `spec-consistency.yml` + the pytest manifest. ## Recorded ship decisions (spec §0 amendment, cross-model concurrence) - **`touched_ratio` threshold = 0.6** — now the apply-script CLI default, strict `>` comparator, `1.0` disables. - **`insert_after` heading-anchor exemption** — anchoring on a heading no longer fires the heading trigger when the inserted text carries no headings (routine "insert body text after a section heading"); heading replace/delete and heading-bearing inserted text still flag. ## §10 open items closed (verified, not assumed) First-party check found that `formatter_agent.md` had no marker-strip rule for ANY marker kind and `word_count_conventions.md` had no comment-exclusion rule — the spec's "expectation" pointed at nothing. Both added: Phase 7 strips all ARS markers (`ref`/`anchor`/`block`) from converted final outputs after the marker-dependent gates run (working drafts + `phase6_*/` keep theirs); word counts strip `<!--...-->` before splitting. Max single-op `new_text` size folded into the existing triggers (no separate cap). `preserved_ratio` surfaces next to the #389 round-trip count. ## Quality - Full CI pytest manifest green (52 entries; +1 new entry). - `/simplify` pass extracted the shared `h2_section_body` / `check_section_literals` helpers into `scripts/_skill_lint.py` (check_390 + check_394 now import them) and de-duplicated the protocol-doc marker lifecycle into authoritative pointers. - Dual-track ship gate: personal-boundary lint PASSED (0 violations); codex `exec` read-only review (gpt-5.5, xhigh); diff carries no `HEEACT`/`Springer`/`hei-platform` strings. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-12 09:02:53 +08:00
net section-count change, and `blocks_touched / blocks_total` strictly
above `--touched-ratio-threshold` (default 0.6 — the #424 Slice B ship
decision; pass 1.0 to disable, since the ratio never exceeds 1.0 and the
comparator is strict per the spec's "above a threshold"). The ratio is
computed and recorded in every report regardless. Any raised flag
refuses the apply unless `--acknowledge-structural` is set (the §3.6
escalation checkpoint owns that decision; this script only enforces it).
`blocks_touched` counts replace/delete targets; an `insert_after`
feat: diff/patch revision mode Slice B — revision-mode adoption (#89 Item 7) (#426) Closes #424 Slice B of #89 Item 7 (spec #390), building on the Slice A deterministic toolchain (#423). `academic-paper` revision mode now runs **anchorize → patch → deterministic apply → finalizer** instead of asking `draft_writer_agent` to re-emit the complete paper — confining the DELEGATE-52 silent-distortion surface to the blocks an operation explicitly touches. ## What landed (the 6 deliverables) 1. **Writer patch-output contract** — `draft_writer_agent.md § Patch-Document Revision Emission`: patch as a `phase6_*/revision_patch_round<N>.json` sidecar (#424 emission decision), hashes copied from the block manifest never computed, `[PATCH-ESCALATION-REQUIRED:]` pre-drafting tag, retry-once, provisional Schema 8 items with mechanical fields left to the orchestrator. 2. **Orchestration sequencing** — `pipeline_orchestrator_agent.md § Revision-Round Patch Sequencing`: five normative steps with a no-rewrite window between manifest generation and apply (a finalizer pass in between would produce spurious hash mismatches). 3. **Escalation gate** — two trigger layers (pre-drafting classification / apply-time `refused_structural`), MANDATORY checkpoint wording, never auto-fallback to full re-emission, escalated rounds re-anchorize under a new ID generation and stamp `mode: full_reemission_escalated`. 4. **Schema 8 delta** — `ResponseItem.change_block_ids` (optional), orchestrator-populated from the apply report (§3.5 role split — inserted block IDs are post-apply facts). 5. **Protocol doc + Mode B commands** — `academic-paper/references/revision_patch_protocol.md`: exact anchorize/apply command sequence, exit codes, apply report as a required re-review input, marker lifecycle. 6. **Lint** — `scripts/check_390_revision_patch_discipline.py` (8 invariants) + 30 mutation tests, wired into `spec-consistency.yml` + the pytest manifest. ## Recorded ship decisions (spec §0 amendment, cross-model concurrence) - **`touched_ratio` threshold = 0.6** — now the apply-script CLI default, strict `>` comparator, `1.0` disables. - **`insert_after` heading-anchor exemption** — anchoring on a heading no longer fires the heading trigger when the inserted text carries no headings (routine "insert body text after a section heading"); heading replace/delete and heading-bearing inserted text still flag. ## §10 open items closed (verified, not assumed) First-party check found that `formatter_agent.md` had no marker-strip rule for ANY marker kind and `word_count_conventions.md` had no comment-exclusion rule — the spec's "expectation" pointed at nothing. Both added: Phase 7 strips all ARS markers (`ref`/`anchor`/`block`) from converted final outputs after the marker-dependent gates run (working drafts + `phase6_*/` keep theirs); word counts strip `<!--...-->` before splitting. Max single-op `new_text` size folded into the existing triggers (no separate cap). `preserved_ratio` surfaces next to the #389 round-trip count. ## Quality - Full CI pytest manifest green (52 entries; +1 new entry). - `/simplify` pass extracted the shared `h2_section_body` / `check_section_literals` helpers into `scripts/_skill_lint.py` (check_390 + check_394 now import them) and de-duplicated the protocol-doc marker lifecycle into authoritative pointers. - Dual-track ship gate: personal-boundary lint PASSED (0 violations); codex `exec` read-only review (gpt-5.5, xhigh); diff carries no `HEEACT`/`Springer`/`hei-platform` strings. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-12 09:02:53 +08:00
anchor's content is not touched, so it does not count. Heading-anchor
exemption (#424): an `insert_after` whose anchor is a heading raises NO
heading flag when its segmented `new_text` contains no heading blocks —
inserting body text after a section heading leaves every heading byte
untouched; heading-bearing `new_text` still flags via its segments.
**Pure-move check** (§3.3): an inserted segment whose normalized-text
hash equals a same-patch deleted block's `old_hash` is recorded as a
machine-verified `pure_move` pair. No similarity heuristics for
moved-and-reworded text — that lands as ordinary touched-block exposure.
Exit codes: 0 = applied; 2 = Phase 1 rejection (structured report on
stdout); 3 = structural-shape refusal (unacknowledged); 4 = post-write
self-check failure (bug, not user error).
Current-write usage (patch 1.1 only):
python scripts/ars_apply_revision_patch.py base.md patch.json \
--block-manifest base.md.block-manifest.json \
--output base.rev2.md [--report-out R.json] \
[--roadmap roadmap.json --author-adjudication author.json \
--claim-surface-manifest claims.json --artifact-root BUNDLE_ROOT] \
[--integrity-issue-list issue-list.json] \
feat: diff/patch revision mode Slice B — revision-mode adoption (#89 Item 7) (#426) Closes #424 Slice B of #89 Item 7 (spec #390), building on the Slice A deterministic toolchain (#423). `academic-paper` revision mode now runs **anchorize → patch → deterministic apply → finalizer** instead of asking `draft_writer_agent` to re-emit the complete paper — confining the DELEGATE-52 silent-distortion surface to the blocks an operation explicitly touches. ## What landed (the 6 deliverables) 1. **Writer patch-output contract** — `draft_writer_agent.md § Patch-Document Revision Emission`: patch as a `phase6_*/revision_patch_round<N>.json` sidecar (#424 emission decision), hashes copied from the block manifest never computed, `[PATCH-ESCALATION-REQUIRED:]` pre-drafting tag, retry-once, provisional Schema 8 items with mechanical fields left to the orchestrator. 2. **Orchestration sequencing** — `pipeline_orchestrator_agent.md § Revision-Round Patch Sequencing`: five normative steps with a no-rewrite window between manifest generation and apply (a finalizer pass in between would produce spurious hash mismatches). 3. **Escalation gate** — two trigger layers (pre-drafting classification / apply-time `refused_structural`), MANDATORY checkpoint wording, never auto-fallback to full re-emission, escalated rounds re-anchorize under a new ID generation and stamp `mode: full_reemission_escalated`. 4. **Schema 8 delta** — `ResponseItem.change_block_ids` (optional), orchestrator-populated from the apply report (§3.5 role split — inserted block IDs are post-apply facts). 5. **Protocol doc + Mode B commands** — `academic-paper/references/revision_patch_protocol.md`: exact anchorize/apply command sequence, exit codes, apply report as a required re-review input, marker lifecycle. 6. **Lint** — `scripts/check_390_revision_patch_discipline.py` (8 invariants) + 30 mutation tests, wired into `spec-consistency.yml` + the pytest manifest. ## Recorded ship decisions (spec §0 amendment, cross-model concurrence) - **`touched_ratio` threshold = 0.6** — now the apply-script CLI default, strict `>` comparator, `1.0` disables. - **`insert_after` heading-anchor exemption** — anchoring on a heading no longer fires the heading trigger when the inserted text carries no headings (routine "insert body text after a section heading"); heading replace/delete and heading-bearing inserted text still flag. ## §10 open items closed (verified, not assumed) First-party check found that `formatter_agent.md` had no marker-strip rule for ANY marker kind and `word_count_conventions.md` had no comment-exclusion rule — the spec's "expectation" pointed at nothing. Both added: Phase 7 strips all ARS markers (`ref`/`anchor`/`block`) from converted final outputs after the marker-dependent gates run (working drafts + `phase6_*/` keep theirs); word counts strip `<!--...-->` before splitting. Max single-op `new_text` size folded into the existing triggers (no separate cap). `preserved_ratio` surfaces next to the #389 round-trip count. ## Quality - Full CI pytest manifest green (52 entries; +1 new entry). - `/simplify` pass extracted the shared `h2_section_body` / `check_section_literals` helpers into `scripts/_skill_lint.py` (check_390 + check_394 now import them) and de-duplicated the protocol-doc marker lifecycle into authoritative pointers. - Dual-track ship gate: personal-boundary lint PASSED (0 violations); codex `exec` read-only review (gpt-5.5, xhigh); diff carries no `HEEACT`/`Springer`/`hei-platform` strings. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-12 09:02:53 +08:00
[--acknowledge-structural] [--touched-ratio-threshold 0.6]
"""
from __future__ import annotations
import argparse
feat(re-review): #576 Spec B PR-B1 — contract schemas + synthesis checker + patch_digest (format 1.2) (#605) * feat(re-review): #576 Spec B PR-B1 — contract schemas + synthesis checker + patch_digest First leg of the B1→B2→B3 implementation chain for the three-gate evidence-before-persuasion re-review contract (design: PR #604): - shared/contracts/re_review/{precommitment,verdict_record,traceability, input_manifest}.schema.json — §5/§11 field-for-field, all record types and closed sets; NewStandardRecord gains new_standard_id (spec §5.1 amended in-place: new_standard_ref needs a stable target) - scripts/check_re_review_synthesis.py — §13 recomputation checker, stdlib-only (#510 architecture class); graded exit codes 0/1/2 - scripts/test_check_re_review_synthesis.py — 148-test mutation suite: 3 hand-pinned goldens, one violating fixture per invariant, §10 card fixtures pinned from both example files (DA synthetic), §6 unit table, jsonschema parity - scripts/ars_apply_revision_patch.py — apply report gains patch_digest, REPORT_FORMAT_VERSION 1.1→1.2 (1.0→1.1 precedent; + protocol doc row) - CI: unified pytest manifest entry (spec-consistency runs it) No behavior change: nothing emits these artifacts until PR-B2 turns the contract on as the Stage 3' default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NWFfSwbbixMNDHXQhQ8nRo * fix(re-review): close round-1 three-track findings (2+6 P1, 2+4 P2) General track (Opus 5 xhigh) + codex (gpt-5.6-sol xhigh) round-1 closures: - §3.4 Direction column enforced in schema + checker: valid_rebuttal upgrades to FULLY_ADDRESSED only (the sole letter-anchor basis can no longer move a verdict sideways into the p2_addressed_rate numerator); author_pointer_located_evidence is a strict upgrade to PARTIALLY/FULLY - superseded reapplications keep their dispatch-time pre_reapplication _verdict — only CURRENT records take the chain-tail fallback (a legal successful retry no longer aborts); retry golden + double-current fixture - G2(d) acceptance backs exactly ONE user_accepted_fail_closed adjustment (an orphan acceptance cannot clear the deferral) - CrossModelResolution must reference a reapplication whose answer_refs contains its intent (cross-wiring closed) - challenged-proposal drafted bodies are NEVER booked (content-equality exclusivity) + per-item drafted-body uniqueness - §11 degradation (iii): escalation exceptions unsubstantiatable without the original manuscript - source_reviewer bound VERBATIM to the Schema 7 reviewer field - half-transported P1 items (transported markers present, severity absent) refuse driving_severity null (B1 suppression closed) - apply-report grammar strict: JSON object, numeric dotted version (the pre-1.2 absence policy needs a VALID version below 1.2), 12-hex hashes - path: refs are RELATIVE only (no absolute/drive/traversal), schema+checker - §13 aborted-emission exemption scope: abort precedence over deferral; criteria_drift stays bidirectionally recomputed - letter-present-but-blockless letters get a visible empty-layer NOTE Security track round 1: CONVERGED 0/0. Suite 148 -> 167 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NWFfSwbbixMNDHXQhQ8nRo * fix(re-review): close round-2 findings (codex 2 P1; general 1 P1 + 2 P2, two shared) - superseded reapplications are now verified, not skipped: supersession is defined for FAILED (CANNOT_VERIFY) attempts only, and a failed attempt appends nothing, so its dispatch-time pre_reapplication_verdict must equal its direct retry's — closes the G2(b) covering-predicate rewrite channel through stale user-form resolutions (codex #1 / general P1-1) - an aborted emission claiming manifest_incomplete / manifest_hash_mismatch / synthesis_mismatch as root cause fails: the checker reached recomputation, so the §11 manifest layer validated against the same hash-bound inputs (codex #2 / general P2-2) - §5.3 letter-tag condition implemented: letter-tagged anchors on a reapplication (and its mechanically-copied cross_model_adjudication adjustment) are valid exactly when the re-examined chain carries a booked valid_rebuttal record — the §3.4 "assertion in the letter with no locatable manuscript evidence changes nothing" machine witness (general P2-1) Security round 2: CONVERGED 0/0 (second consecutive). Suite 167 -> 171. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NWFfSwbbixMNDHXQhQ8nRo * fix(re-review): close round-3 findings (codex 1 P1, general 1 P2) - the supersession guard is now UNCONDITIONAL: a verdict-changing successful reapplication carrying its derived adjustment can no longer be superseded past the failed-only check (§6 — a retry names the FAILED attempt it supersedes); the stale-resolution rewrite channel is closed on the derived-adjustment path too (codex round-3 P1) - §7 judge adjudication scope enforced: a cross_model DissentAdjudication is valid only for dissents on P1 (must_fix) items — the judge's scope EQUALS the §9 pass's P1 coverage, and P2 dissents always take the G2(a) user path even on an active setup (general round-3 P2) Security round 3: CONVERGED 0/0 (third consecutive). Suite 171 -> 173. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NWFfSwbbixMNDHXQhQ8nRo * fix(re-review): close round-4 findings (codex 2 P1, general 1 P1; one codex half adjudicated against) - §6 trigger binding: a divergence-only re-application (answer_refs with intent refs only) and a system ResolutionIntent both require an EVALUATED P1 row (cross_model_verdict present, implying an active configuration) — the forged intent→reapplication→resolution chain can no longer rewrite a committed P1/P2 verdict on a not_configured run (general round-4 P1; closes the §18 "Phase 2B cannot silently relax" acceptance surface on the reapplication side) - a G2dAcceptance referencing a SUPERSEDED reapplication fails — a stale acceptance can no longer override the successful current retry (codex round-4 #2) - adjudications below an untripped §7 bound are rejected ("dissents below the bound stand unadjudicated by design") (codex round-4 #1b) - ADJUDICATED AGAINST codex round-4 #1a (reject user adjudicator on active-setup P1 dissents): the §6 deferral loop records a user-adjudicated DissentAdjudication DIRECTLY with no activity qualifier, and the §9 pass can be per-row unavailable — a one-way rule would make judge-transport failure unrecoverable. General round-4 independently reached the same conclusion; pinned by a stays-legal test. Security round 4: CONVERGED 0/0 (fourth consecutive). Suite 173 -> 178. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NWFfSwbbixMNDHXQhQ8nRo * fix(re-review): close round-5 findings (codex 1 P1, general 1 P1) - forged-divergence closure (codex): a divergence-only re-application now requires a REAL dispatch-time divergence (cross_model_verdict != pre_reapplication_verdict — §6 identifies diverges BEFORE the system intent is emitted) and its chain must carry the ORIGINAL mandating system intent; an originally-agree row can no longer manufacture its own diverges status after the fact - ghost-dissent closure (general): every DissentRecord must be APPLIED — the item's verdict record carries applied_criterion dissented:<id> (§7 reverse witness); an unapplied dissent can no longer trip the §7 bound and authorize an original_upheld re-application second chance Both are siblings of the round-4 trigger-binding rule: no committed verdict moves without its genuine triggering divergence/dissent. Security round 5: CONVERGED 0/0 (fifth consecutive). Suite 178 -> 181. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NWFfSwbbixMNDHXQhQ8nRo --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:17:00 +08:00
import hashlib
import json
import sys
from pathlib import Path
import jsonschema
if __package__ in (None, ""): # pragma: no cover - direct CLI invocation
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from scripts._block_parser import (
BLOCK_ID_FORMAT,
MARKER_PREFIX,
Block,
BlockParseError,
ParsedDocument,
atomic_write_bytes,
base_draft_hash,
parse_document,
segment_fragment,
)
from scripts.revision_roadmap import (
ArtifactStore,
ContractError,
_read_path_once,
load_json_path,
validate_block_manifest,
validate_claim_surface_manifest,
validate_integrity_patch_authorization,
validate_review_patch_authorization,
validate_roadmap,
)
REPO_ROOT = Path(__file__).resolve().parent.parent
PATCH_SCHEMA_PATH = REPO_ROOT / "shared" / "contracts" / "patch" / "revision_patch.schema.json"
DOC_BODY_START = "DOC-BODY-START"
# 1.3 is the first current-write report under #670. It adds the replayed
# authorization_witness and explicit per-op claim/collateral declarations.
# Historical patch/report replay is isolated in scripts/legacy; this CLI does
# not write an authorization PASS for patch 1.0.
REPORT_FORMAT_VERSION = "1.3"
feat: diff/patch revision mode Slice B — revision-mode adoption (#89 Item 7) (#426) Closes #424 Slice B of #89 Item 7 (spec #390), building on the Slice A deterministic toolchain (#423). `academic-paper` revision mode now runs **anchorize → patch → deterministic apply → finalizer** instead of asking `draft_writer_agent` to re-emit the complete paper — confining the DELEGATE-52 silent-distortion surface to the blocks an operation explicitly touches. ## What landed (the 6 deliverables) 1. **Writer patch-output contract** — `draft_writer_agent.md § Patch-Document Revision Emission`: patch as a `phase6_*/revision_patch_round<N>.json` sidecar (#424 emission decision), hashes copied from the block manifest never computed, `[PATCH-ESCALATION-REQUIRED:]` pre-drafting tag, retry-once, provisional Schema 8 items with mechanical fields left to the orchestrator. 2. **Orchestration sequencing** — `pipeline_orchestrator_agent.md § Revision-Round Patch Sequencing`: five normative steps with a no-rewrite window between manifest generation and apply (a finalizer pass in between would produce spurious hash mismatches). 3. **Escalation gate** — two trigger layers (pre-drafting classification / apply-time `refused_structural`), MANDATORY checkpoint wording, never auto-fallback to full re-emission, escalated rounds re-anchorize under a new ID generation and stamp `mode: full_reemission_escalated`. 4. **Schema 8 delta** — `ResponseItem.change_block_ids` (optional), orchestrator-populated from the apply report (§3.5 role split — inserted block IDs are post-apply facts). 5. **Protocol doc + Mode B commands** — `academic-paper/references/revision_patch_protocol.md`: exact anchorize/apply command sequence, exit codes, apply report as a required re-review input, marker lifecycle. 6. **Lint** — `scripts/check_390_revision_patch_discipline.py` (8 invariants) + 30 mutation tests, wired into `spec-consistency.yml` + the pytest manifest. ## Recorded ship decisions (spec §0 amendment, cross-model concurrence) - **`touched_ratio` threshold = 0.6** — now the apply-script CLI default, strict `>` comparator, `1.0` disables. - **`insert_after` heading-anchor exemption** — anchoring on a heading no longer fires the heading trigger when the inserted text carries no headings (routine "insert body text after a section heading"); heading replace/delete and heading-bearing inserted text still flag. ## §10 open items closed (verified, not assumed) First-party check found that `formatter_agent.md` had no marker-strip rule for ANY marker kind and `word_count_conventions.md` had no comment-exclusion rule — the spec's "expectation" pointed at nothing. Both added: Phase 7 strips all ARS markers (`ref`/`anchor`/`block`) from converted final outputs after the marker-dependent gates run (working drafts + `phase6_*/` keep theirs); word counts strip `<!--...-->` before splitting. Max single-op `new_text` size folded into the existing triggers (no separate cap). `preserved_ratio` surfaces next to the #389 round-trip count. ## Quality - Full CI pytest manifest green (52 entries; +1 new entry). - `/simplify` pass extracted the shared `h2_section_body` / `check_section_literals` helpers into `scripts/_skill_lint.py` (check_390 + check_394 now import them) and de-duplicated the protocol-doc marker lifecycle into authoritative pointers. - Dual-track ship gate: personal-boundary lint PASSED (0 violations); codex `exec` read-only review (gpt-5.5, xhigh); diff carries no `HEEACT`/`Springer`/`hei-platform` strings. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-12 09:02:53 +08:00
# #424 Slice B ship decision (spec §3.3 required it; recorded in the spec's
# amendment log). Strict `>` comparator per the spec's "above a threshold":
# 1.0 disables the trigger because touched/total never exceeds 1.0.
DEFAULT_TOUCHED_RATIO_THRESHOLD = 0.6
def _ratio_threshold(raw: str) -> float:
"""argparse type for --touched-ratio-threshold: a finite value in
[0.0, 1.0]. Rejects NaN (which makes `touched_ratio > NaN` silently
False, disabling the trigger off the documented 1.0 path), inf, and
out-of-range values."""
try:
value = float(raw)
except ValueError:
raise argparse.ArgumentTypeError(f"{raw!r} is not a number")
import math
if not math.isfinite(value) or not (0.0 <= value <= 1.0):
raise argparse.ArgumentTypeError(
f"{raw!r} must be a finite ratio in [0.0, 1.0] "
"(1.0 disables the trigger; NaN/inf/negative are rejected)"
)
return value
class ApplyRejection(Exception):
"""Phase 1 rejection carrying the structured failure list."""
def __init__(self, failures: list[dict]):
self.failures = failures
super().__init__(f"{len(failures)} validation failure(s)")
class StructuralRefusal(Exception):
"""Unacknowledged structural-shape flags (§3.3 / §3.6)."""
def __init__(self, flags: dict):
self.flags = flags
super().__init__("structural-shape flags raised without acknowledgement")
def _fail(failures: list[dict], op_index: int | None, kind: str, message: str, **extra) -> None:
entry = {"op_index": op_index, "kind": kind, "message": message}
entry.update(extra)
failures.append(entry)
def _load_patch_schema() -> dict:
return json.loads(PATCH_SCHEMA_PATH.read_text(encoding="utf-8"))
def validate_patch(
patch: dict,
base_raw: bytes,
base: ParsedDocument,
*,
touched_ratio_threshold: float | None,
) -> dict:
"""Phase 1. Returns the analysis dict Phase 2 consumes, or raises."""
failures: list[dict] = []
validator = jsonschema.Draft202012Validator(_load_patch_schema())
schema_errors = sorted(validator.iter_errors(patch), key=lambda e: list(e.absolute_path))
for err in schema_errors:
path = "/".join(str(p) for p in err.absolute_path) or "(root)"
_fail(failures, None, "schema_invalid", f"{path}: {err.message}")
if schema_errors:
raise ApplyRejection(failures)
actual_base_hash = base_draft_hash(base_raw)
if patch["base_draft_hash"] != actual_base_hash:
_fail(
failures,
None,
"base_hash_mismatch",
"patch was generated against a different base (stale-base rejection)",
expected=patch["base_draft_hash"],
actual=actual_base_hash,
)
by_id = base.block_by_id()
seen_targets: dict[str, int] = {}
analyses: list[dict] = []
for idx, op in enumerate(patch["ops"]):
block_id = op["block_id"]
analysis: dict = {"op": op, "op_index": idx, "segments": None, "target": None}
if block_id in seen_targets:
_fail(
failures,
idx,
"duplicate_target",
f"block {block_id} already named by op {seen_targets[block_id]} "
"(each block ID appears in at most one op, in any role)",
)
else:
seen_targets[block_id] = idx
if block_id == DOC_BODY_START:
pass # position sentinel, no anchor block to precondition on
elif block_id not in by_id:
_fail(failures, idx, "unknown_block_id", f"block {block_id} does not exist in the base")
else:
target = by_id[block_id]
analysis["target"] = target
if op["old_hash"] != target.norm_hash:
_fail(
failures,
idx,
"hash_mismatch",
f"block {block_id} content is not what the patch preconditions on",
expected=op["old_hash"],
actual=target.norm_hash,
)
new_text = op.get("new_text")
if new_text is not None:
if MARKER_PREFIX in new_text:
_fail(
failures,
idx,
"marker_in_new_text",
"new_text must not contain <!--block: markers "
"(ID assignment is the apply script's exclusive authority)",
)
else:
try:
analysis["segments"] = segment_fragment(new_text)
except BlockParseError as exc:
_fail(failures, idx, f"new_text_invalid:{exc.kind}", str(exc))
analyses.append(analysis)
if failures:
raise ApplyRejection(failures)
# Structural-shape flags (§3.3) — deterministic, computed on the
# validated patch only.
heading_op_indexes: list[int] = []
headings_delta = 0
touched = 0
for analysis in analyses:
op = analysis["op"]
target: Block | None = analysis["target"]
segments: list[Block] | None = analysis["segments"]
seg_headings = sum(1 for s in (segments or []) if s.kind == "heading")
target_is_heading = target is not None and target.kind == "heading"
feat: diff/patch revision mode Slice B — revision-mode adoption (#89 Item 7) (#426) Closes #424 Slice B of #89 Item 7 (spec #390), building on the Slice A deterministic toolchain (#423). `academic-paper` revision mode now runs **anchorize → patch → deterministic apply → finalizer** instead of asking `draft_writer_agent` to re-emit the complete paper — confining the DELEGATE-52 silent-distortion surface to the blocks an operation explicitly touches. ## What landed (the 6 deliverables) 1. **Writer patch-output contract** — `draft_writer_agent.md § Patch-Document Revision Emission`: patch as a `phase6_*/revision_patch_round<N>.json` sidecar (#424 emission decision), hashes copied from the block manifest never computed, `[PATCH-ESCALATION-REQUIRED:]` pre-drafting tag, retry-once, provisional Schema 8 items with mechanical fields left to the orchestrator. 2. **Orchestration sequencing** — `pipeline_orchestrator_agent.md § Revision-Round Patch Sequencing`: five normative steps with a no-rewrite window between manifest generation and apply (a finalizer pass in between would produce spurious hash mismatches). 3. **Escalation gate** — two trigger layers (pre-drafting classification / apply-time `refused_structural`), MANDATORY checkpoint wording, never auto-fallback to full re-emission, escalated rounds re-anchorize under a new ID generation and stamp `mode: full_reemission_escalated`. 4. **Schema 8 delta** — `ResponseItem.change_block_ids` (optional), orchestrator-populated from the apply report (§3.5 role split — inserted block IDs are post-apply facts). 5. **Protocol doc + Mode B commands** — `academic-paper/references/revision_patch_protocol.md`: exact anchorize/apply command sequence, exit codes, apply report as a required re-review input, marker lifecycle. 6. **Lint** — `scripts/check_390_revision_patch_discipline.py` (8 invariants) + 30 mutation tests, wired into `spec-consistency.yml` + the pytest manifest. ## Recorded ship decisions (spec §0 amendment, cross-model concurrence) - **`touched_ratio` threshold = 0.6** — now the apply-script CLI default, strict `>` comparator, `1.0` disables. - **`insert_after` heading-anchor exemption** — anchoring on a heading no longer fires the heading trigger when the inserted text carries no headings (routine "insert body text after a section heading"); heading replace/delete and heading-bearing inserted text still flag. ## §10 open items closed (verified, not assumed) First-party check found that `formatter_agent.md` had no marker-strip rule for ANY marker kind and `word_count_conventions.md` had no comment-exclusion rule — the spec's "expectation" pointed at nothing. Both added: Phase 7 strips all ARS markers (`ref`/`anchor`/`block`) from converted final outputs after the marker-dependent gates run (working drafts + `phase6_*/` keep theirs); word counts strip `<!--...-->` before splitting. Max single-op `new_text` size folded into the existing triggers (no separate cap). `preserved_ratio` surfaces next to the #389 round-trip count. ## Quality - Full CI pytest manifest green (52 entries; +1 new entry). - `/simplify` pass extracted the shared `h2_section_body` / `check_section_literals` helpers into `scripts/_skill_lint.py` (check_390 + check_394 now import them) and de-duplicated the protocol-doc marker lifecycle into authoritative pointers. - Dual-track ship gate: personal-boundary lint PASSED (0 violations); codex `exec` read-only review (gpt-5.5, xhigh); diff carries no `HEEACT`/`Springer`/`hei-platform` strings. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-12 09:02:53 +08:00
# §3.3 heading rule with the #424 heading-anchor exemption: an op
# that REWRITES or DELETES a heading block, or whose segmented
# new_text CONTAINS a heading, flags. An insert_after merely
# ANCHORED on a heading does not — the heading's bytes are
# untouched, and "insert body text after a section heading" is the
# most common legitimate insertion; flagging it daily would erode
# the checkpoint (alarm fatigue). Heading-bearing new_text still
# flags via seg_headings whatever the anchor is.
if op["op"] == "replace_block":
touched += 1
headings_delta += seg_headings - (1 if target_is_heading else 0)
feat: diff/patch revision mode Slice B — revision-mode adoption (#89 Item 7) (#426) Closes #424 Slice B of #89 Item 7 (spec #390), building on the Slice A deterministic toolchain (#423). `academic-paper` revision mode now runs **anchorize → patch → deterministic apply → finalizer** instead of asking `draft_writer_agent` to re-emit the complete paper — confining the DELEGATE-52 silent-distortion surface to the blocks an operation explicitly touches. ## What landed (the 6 deliverables) 1. **Writer patch-output contract** — `draft_writer_agent.md § Patch-Document Revision Emission`: patch as a `phase6_*/revision_patch_round<N>.json` sidecar (#424 emission decision), hashes copied from the block manifest never computed, `[PATCH-ESCALATION-REQUIRED:]` pre-drafting tag, retry-once, provisional Schema 8 items with mechanical fields left to the orchestrator. 2. **Orchestration sequencing** — `pipeline_orchestrator_agent.md § Revision-Round Patch Sequencing`: five normative steps with a no-rewrite window between manifest generation and apply (a finalizer pass in between would produce spurious hash mismatches). 3. **Escalation gate** — two trigger layers (pre-drafting classification / apply-time `refused_structural`), MANDATORY checkpoint wording, never auto-fallback to full re-emission, escalated rounds re-anchorize under a new ID generation and stamp `mode: full_reemission_escalated`. 4. **Schema 8 delta** — `ResponseItem.change_block_ids` (optional), orchestrator-populated from the apply report (§3.5 role split — inserted block IDs are post-apply facts). 5. **Protocol doc + Mode B commands** — `academic-paper/references/revision_patch_protocol.md`: exact anchorize/apply command sequence, exit codes, apply report as a required re-review input, marker lifecycle. 6. **Lint** — `scripts/check_390_revision_patch_discipline.py` (8 invariants) + 30 mutation tests, wired into `spec-consistency.yml` + the pytest manifest. ## Recorded ship decisions (spec §0 amendment, cross-model concurrence) - **`touched_ratio` threshold = 0.6** — now the apply-script CLI default, strict `>` comparator, `1.0` disables. - **`insert_after` heading-anchor exemption** — anchoring on a heading no longer fires the heading trigger when the inserted text carries no headings (routine "insert body text after a section heading"); heading replace/delete and heading-bearing inserted text still flag. ## §10 open items closed (verified, not assumed) First-party check found that `formatter_agent.md` had no marker-strip rule for ANY marker kind and `word_count_conventions.md` had no comment-exclusion rule — the spec's "expectation" pointed at nothing. Both added: Phase 7 strips all ARS markers (`ref`/`anchor`/`block`) from converted final outputs after the marker-dependent gates run (working drafts + `phase6_*/` keep theirs); word counts strip `<!--...-->` before splitting. Max single-op `new_text` size folded into the existing triggers (no separate cap). `preserved_ratio` surfaces next to the #389 round-trip count. ## Quality - Full CI pytest manifest green (52 entries; +1 new entry). - `/simplify` pass extracted the shared `h2_section_body` / `check_section_literals` helpers into `scripts/_skill_lint.py` (check_390 + check_394 now import them) and de-duplicated the protocol-doc marker lifecycle into authoritative pointers. - Dual-track ship gate: personal-boundary lint PASSED (0 violations); codex `exec` read-only review (gpt-5.5, xhigh); diff carries no `HEEACT`/`Springer`/`hei-platform` strings. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-12 09:02:53 +08:00
flags_heading = target_is_heading or bool(seg_headings)
elif op["op"] == "delete_block":
touched += 1
headings_delta -= 1 if target_is_heading else 0
feat: diff/patch revision mode Slice B — revision-mode adoption (#89 Item 7) (#426) Closes #424 Slice B of #89 Item 7 (spec #390), building on the Slice A deterministic toolchain (#423). `academic-paper` revision mode now runs **anchorize → patch → deterministic apply → finalizer** instead of asking `draft_writer_agent` to re-emit the complete paper — confining the DELEGATE-52 silent-distortion surface to the blocks an operation explicitly touches. ## What landed (the 6 deliverables) 1. **Writer patch-output contract** — `draft_writer_agent.md § Patch-Document Revision Emission`: patch as a `phase6_*/revision_patch_round<N>.json` sidecar (#424 emission decision), hashes copied from the block manifest never computed, `[PATCH-ESCALATION-REQUIRED:]` pre-drafting tag, retry-once, provisional Schema 8 items with mechanical fields left to the orchestrator. 2. **Orchestration sequencing** — `pipeline_orchestrator_agent.md § Revision-Round Patch Sequencing`: five normative steps with a no-rewrite window between manifest generation and apply (a finalizer pass in between would produce spurious hash mismatches). 3. **Escalation gate** — two trigger layers (pre-drafting classification / apply-time `refused_structural`), MANDATORY checkpoint wording, never auto-fallback to full re-emission, escalated rounds re-anchorize under a new ID generation and stamp `mode: full_reemission_escalated`. 4. **Schema 8 delta** — `ResponseItem.change_block_ids` (optional), orchestrator-populated from the apply report (§3.5 role split — inserted block IDs are post-apply facts). 5. **Protocol doc + Mode B commands** — `academic-paper/references/revision_patch_protocol.md`: exact anchorize/apply command sequence, exit codes, apply report as a required re-review input, marker lifecycle. 6. **Lint** — `scripts/check_390_revision_patch_discipline.py` (8 invariants) + 30 mutation tests, wired into `spec-consistency.yml` + the pytest manifest. ## Recorded ship decisions (spec §0 amendment, cross-model concurrence) - **`touched_ratio` threshold = 0.6** — now the apply-script CLI default, strict `>` comparator, `1.0` disables. - **`insert_after` heading-anchor exemption** — anchoring on a heading no longer fires the heading trigger when the inserted text carries no headings (routine "insert body text after a section heading"); heading replace/delete and heading-bearing inserted text still flag. ## §10 open items closed (verified, not assumed) First-party check found that `formatter_agent.md` had no marker-strip rule for ANY marker kind and `word_count_conventions.md` had no comment-exclusion rule — the spec's "expectation" pointed at nothing. Both added: Phase 7 strips all ARS markers (`ref`/`anchor`/`block`) from converted final outputs after the marker-dependent gates run (working drafts + `phase6_*/` keep theirs); word counts strip `<!--...-->` before splitting. Max single-op `new_text` size folded into the existing triggers (no separate cap). `preserved_ratio` surfaces next to the #389 round-trip count. ## Quality - Full CI pytest manifest green (52 entries; +1 new entry). - `/simplify` pass extracted the shared `h2_section_body` / `check_section_literals` helpers into `scripts/_skill_lint.py` (check_390 + check_394 now import them) and de-duplicated the protocol-doc marker lifecycle into authoritative pointers. - Dual-track ship gate: personal-boundary lint PASSED (0 violations); codex `exec` read-only review (gpt-5.5, xhigh); diff carries no `HEEACT`/`Springer`/`hei-platform` strings. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-12 09:02:53 +08:00
flags_heading = target_is_heading
else: # insert_after
headings_delta += seg_headings
feat: diff/patch revision mode Slice B — revision-mode adoption (#89 Item 7) (#426) Closes #424 Slice B of #89 Item 7 (spec #390), building on the Slice A deterministic toolchain (#423). `academic-paper` revision mode now runs **anchorize → patch → deterministic apply → finalizer** instead of asking `draft_writer_agent` to re-emit the complete paper — confining the DELEGATE-52 silent-distortion surface to the blocks an operation explicitly touches. ## What landed (the 6 deliverables) 1. **Writer patch-output contract** — `draft_writer_agent.md § Patch-Document Revision Emission`: patch as a `phase6_*/revision_patch_round<N>.json` sidecar (#424 emission decision), hashes copied from the block manifest never computed, `[PATCH-ESCALATION-REQUIRED:]` pre-drafting tag, retry-once, provisional Schema 8 items with mechanical fields left to the orchestrator. 2. **Orchestration sequencing** — `pipeline_orchestrator_agent.md § Revision-Round Patch Sequencing`: five normative steps with a no-rewrite window between manifest generation and apply (a finalizer pass in between would produce spurious hash mismatches). 3. **Escalation gate** — two trigger layers (pre-drafting classification / apply-time `refused_structural`), MANDATORY checkpoint wording, never auto-fallback to full re-emission, escalated rounds re-anchorize under a new ID generation and stamp `mode: full_reemission_escalated`. 4. **Schema 8 delta** — `ResponseItem.change_block_ids` (optional), orchestrator-populated from the apply report (§3.5 role split — inserted block IDs are post-apply facts). 5. **Protocol doc + Mode B commands** — `academic-paper/references/revision_patch_protocol.md`: exact anchorize/apply command sequence, exit codes, apply report as a required re-review input, marker lifecycle. 6. **Lint** — `scripts/check_390_revision_patch_discipline.py` (8 invariants) + 30 mutation tests, wired into `spec-consistency.yml` + the pytest manifest. ## Recorded ship decisions (spec §0 amendment, cross-model concurrence) - **`touched_ratio` threshold = 0.6** — now the apply-script CLI default, strict `>` comparator, `1.0` disables. - **`insert_after` heading-anchor exemption** — anchoring on a heading no longer fires the heading trigger when the inserted text carries no headings (routine "insert body text after a section heading"); heading replace/delete and heading-bearing inserted text still flag. ## §10 open items closed (verified, not assumed) First-party check found that `formatter_agent.md` had no marker-strip rule for ANY marker kind and `word_count_conventions.md` had no comment-exclusion rule — the spec's "expectation" pointed at nothing. Both added: Phase 7 strips all ARS markers (`ref`/`anchor`/`block`) from converted final outputs after the marker-dependent gates run (working drafts + `phase6_*/` keep theirs); word counts strip `<!--...-->` before splitting. Max single-op `new_text` size folded into the existing triggers (no separate cap). `preserved_ratio` surfaces next to the #389 round-trip count. ## Quality - Full CI pytest manifest green (52 entries; +1 new entry). - `/simplify` pass extracted the shared `h2_section_body` / `check_section_literals` helpers into `scripts/_skill_lint.py` (check_390 + check_394 now import them) and de-duplicated the protocol-doc marker lifecycle into authoritative pointers. - Dual-track ship gate: personal-boundary lint PASSED (0 violations); codex `exec` read-only review (gpt-5.5, xhigh); diff carries no `HEEACT`/`Springer`/`hei-platform` strings. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-12 09:02:53 +08:00
flags_heading = bool(seg_headings)
if flags_heading:
heading_op_indexes.append(analysis["op_index"])
blocks_total = len(base.blocks)
touched_ratio = (touched / blocks_total) if blocks_total else 0.0
ratio_exceeded = (
touched_ratio_threshold is not None and touched_ratio > touched_ratio_threshold
)
structural_flags = {
"heading_op_indexes": heading_op_indexes,
"section_count_delta": headings_delta,
"touched_ratio": round(touched_ratio, 4),
"touched_ratio_threshold": touched_ratio_threshold,
"touched_ratio_exceeded": ratio_exceeded,
"any": bool(heading_op_indexes) or headings_delta != 0 or ratio_exceeded,
}
# Pure-move pairs (§3.3): inserted segment hash == deleted block hash.
deleted_hashes = {
a["op"]["old_hash"]: a["op"]["block_id"]
for a in analyses
if a["op"]["op"] == "delete_block"
}
pure_move_seeds = []
for analysis in analyses:
if analysis["op"]["op"] not in ("replace_block", "insert_after"):
continue
for seg_idx, seg in enumerate(analysis["segments"] or []):
if seg.norm_hash in deleted_hashes:
pure_move_seeds.append(
{
"from_block_id": deleted_hashes[seg.norm_hash],
"op_index": analysis["op_index"],
"segment_index": seg_idx,
}
)
return {
"analyses": analyses,
"structural_flags": structural_flags,
"pure_move_seeds": pure_move_seeds,
"counters_base": {"blocks_total": blocks_total, "blocks_touched": touched},
}
def _render_segments(
new_text: str,
segments: list[Block],
*,
first_keeps_marker: bool,
fresh_ids: list[str],
) -> str:
"""Render fragment segments with marker lines, joined by blank lines.
``fresh_ids`` supplies the IDs for every segment that needs one
(all of them, except the first when ``first_keeps_marker``). Each
rendered segment ends with exactly one newline.
"""
rendered: list[str] = []
fresh_iter = iter(fresh_ids)
for seg_idx, seg in enumerate(segments):
seg_text = new_text[seg.span[0] : seg.span[1]]
if not seg_text.endswith("\n"):
seg_text += "\n"
if seg_idx == 0 and first_keeps_marker:
rendered.append(seg_text)
else:
rendered.append(f"<!--block:{next(fresh_iter)}-->\n{seg_text}")
return "\n".join(rendered)
def apply_patch(base_text: str, base: ParsedDocument, analysis: dict) -> tuple[str, dict]:
"""Phase 2: splice. Returns (output_text, phase2_report_fields)."""
blocks = base.blocks
n = len(blocks)
text = base_text
ops_by_target: dict[str, dict] = {}
doc_body_start_op: dict | None = None
for a in analysis["analyses"]:
if a["op"]["block_id"] == DOC_BODY_START:
doc_body_start_op = a
else:
ops_by_target[a["op"]["block_id"]] = a
next_num = base.next_fresh_id_num()
fresh_assigned: list[str] = []
seg_id_map: dict[tuple[int, int], str] = {} # (op_index, seg_index) -> fresh id
ops_applied: list[dict] = []
def _take_fresh(op_index: int, seg_indexes: list[int]) -> list[str]:
nonlocal next_num
ids = []
for seg_idx in seg_indexes:
fid = BLOCK_ID_FORMAT.format(next_num)
next_num += 1
ids.append(fid)
fresh_assigned.append(fid)
seg_id_map[(op_index, seg_idx)] = fid
return ids
# Splicing model: a block's unit is [full_start, next.full_start); the
# "gap" is the separator bytes between its content end and the next
# block's full_start, copied verbatim (§3.3 byte-span splicing). The
# gap must be RECOMPUTED instead of copied only where its boundary
# disappears (everything after it deleted) or never existed (an
# insertion lands where the base had zero inter-block bytes).
deleted = {
a["op"]["block_id"]
for a in analysis["analyses"]
if a["op"]["op"] == "delete_block"
}
# Highest index that survives the patch; `i >= last_kept` ⇔ every
# block after i is deleted (O(1) per block instead of a tail scan).
last_kept = max(
(j for j in range(n) if blocks[j].block_id not in deleted),
default=-1,
)
out: list[str] = []
head_end = blocks[0].full_start if n else len(text)
out.append(text[0:head_end])
if doc_body_start_op is not None:
a = doc_body_start_op
segments = a["segments"]
ids = _take_fresh(a["op_index"], list(range(len(segments))))
rendered = _render_segments(
a["op"]["new_text"], segments, first_keeps_marker=False, fresh_ids=ids
)
out.append(rendered)
if n:
out.append("\n")
ops_applied.append(
{
"op_index": a["op_index"],
"op": "insert_after",
"block_id": DOC_BODY_START,
"roadmap_item_ids": a["op"]["roadmap_item_ids"],
"claim_strength_changes": a["op"]["claim_strength_changes"],
"collateral_authorization_ids": a["op"]["collateral_authorization_ids"],
"new_block_ids": ids,
}
)
for i, block in enumerate(blocks):
unit_end = blocks[i + 1].full_start if i + 1 < n else len(text)
content_end = block.span[1]
gap = text[content_end:unit_end]
a = ops_by_target.get(block.block_id) if block.block_id else None
if a is not None and a["op"]["op"] == "delete_block":
ops_applied.append(
{
"op_index": a["op_index"],
"op": "delete_block",
"block_id": block.block_id,
"roadmap_item_ids": a["op"]["roadmap_item_ids"],
"claim_strength_changes": a["op"]["claim_strength_changes"],
"collateral_authorization_ids": a["op"]["collateral_authorization_ids"],
"new_block_ids": [],
}
)
continue # marker, content, and following separator all dropped
# Suppress the separator when everything after this block is
# deleted (a last-block delete must not leave trailing blanks).
rest_all_deleted = i + 1 < n and i >= last_kept
if a is None:
out.append(text[block.full_start : content_end])
out.append("" if rest_all_deleted else gap)
continue
op = a["op"]
if op["op"] == "replace_block":
segments = a["segments"]
ids = _take_fresh(a["op_index"], list(range(1, len(segments))))
if block.marker_span is not None:
out.append(text[block.marker_span[0] : block.marker_span[1]])
rendered = _render_segments(
op["new_text"], segments, first_keeps_marker=True, fresh_ids=ids
)
content = text[block.span[0] : block.span[1]]
if not content.endswith("\n") and rendered.endswith("\n"):
rendered = rendered[:-1] # final block without EOL stays EOL-less
out.append(rendered)
out.append("" if rest_all_deleted else gap)
ops_applied.append(
{
"op_index": a["op_index"],
"op": "replace_block",
"block_id": block.block_id,
"roadmap_item_ids": op["roadmap_item_ids"],
"claim_strength_changes": op["claim_strength_changes"],
"collateral_authorization_ids": op["collateral_authorization_ids"],
"new_block_ids": ids,
}
)
else: # insert_after
segments = a["segments"]
ids = _take_fresh(a["op_index"], list(range(len(segments))))
out.append(text[block.full_start : content_end])
content = text[block.span[0] : block.span[1]]
prefix = "\n" if content.endswith("\n") else "\n\n"
rendered = _render_segments(
op["new_text"], segments, first_keeps_marker=False, fresh_ids=ids
)
out.append(prefix + rendered)
if gap == "" and i + 1 < n and not rest_all_deleted:
out.append("\n") # keep a blank line before an adjacent next block
out.append("" if rest_all_deleted else gap)
ops_applied.append(
{
"op_index": a["op_index"],
"op": "insert_after",
"block_id": block.block_id,
"roadmap_item_ids": op["roadmap_item_ids"],
"claim_strength_changes": op["claim_strength_changes"],
"collateral_authorization_ids": op["collateral_authorization_ids"],
"new_block_ids": ids,
}
)
output_text = "".join(out)
target_id_by_op_index = {
a["op_index"]: a["op"]["block_id"]
for a in analysis["analyses"]
if a["op"]["op"] == "replace_block"
}
pure_move_pairs = []
for seed in analysis["pure_move_seeds"]:
to_id = seg_id_map.get((seed["op_index"], seed["segment_index"]))
if to_id is None and seed["segment_index"] == 0:
# replace_block keeps the target's ID on the head segment.
to_id = target_id_by_op_index.get(seed["op_index"])
pure_move_pairs.append(
{
"from_block_id": seed["from_block_id"],
"to_block_id": to_id,
"op_index": seed["op_index"],
}
)
ops_applied.sort(key=lambda entry: entry["op_index"])
return output_text, {
"ops_applied": ops_applied,
"fresh_block_ids": fresh_assigned,
"pure_move_pairs": pure_move_pairs,
}
def run(
base_path: Path,
patch_path: Path,
output_path: Path,
report_path: Path,
*,
acknowledge_structural: bool,
touched_ratio_threshold: float | None,
block_manifest_path: Path | None = None,
roadmap_path: Path | None = None,
author_adjudication_path: Path | None = None,
claim_surface_manifest_path: Path | None = None,
artifact_root: Path | None = None,
integrity_issue_list_path: Path | None = None,
integrity_authorization_path: Path | None = None,
) -> dict:
"""Full two-phase apply. Raises ApplyRejection / StructuralRefusal /
feat: diff/patch revision mode Slice B — revision-mode adoption (#89 Item 7) (#426) Closes #424 Slice B of #89 Item 7 (spec #390), building on the Slice A deterministic toolchain (#423). `academic-paper` revision mode now runs **anchorize → patch → deterministic apply → finalizer** instead of asking `draft_writer_agent` to re-emit the complete paper — confining the DELEGATE-52 silent-distortion surface to the blocks an operation explicitly touches. ## What landed (the 6 deliverables) 1. **Writer patch-output contract** — `draft_writer_agent.md § Patch-Document Revision Emission`: patch as a `phase6_*/revision_patch_round<N>.json` sidecar (#424 emission decision), hashes copied from the block manifest never computed, `[PATCH-ESCALATION-REQUIRED:]` pre-drafting tag, retry-once, provisional Schema 8 items with mechanical fields left to the orchestrator. 2. **Orchestration sequencing** — `pipeline_orchestrator_agent.md § Revision-Round Patch Sequencing`: five normative steps with a no-rewrite window between manifest generation and apply (a finalizer pass in between would produce spurious hash mismatches). 3. **Escalation gate** — two trigger layers (pre-drafting classification / apply-time `refused_structural`), MANDATORY checkpoint wording, never auto-fallback to full re-emission, escalated rounds re-anchorize under a new ID generation and stamp `mode: full_reemission_escalated`. 4. **Schema 8 delta** — `ResponseItem.change_block_ids` (optional), orchestrator-populated from the apply report (§3.5 role split — inserted block IDs are post-apply facts). 5. **Protocol doc + Mode B commands** — `academic-paper/references/revision_patch_protocol.md`: exact anchorize/apply command sequence, exit codes, apply report as a required re-review input, marker lifecycle. 6. **Lint** — `scripts/check_390_revision_patch_discipline.py` (8 invariants) + 30 mutation tests, wired into `spec-consistency.yml` + the pytest manifest. ## Recorded ship decisions (spec §0 amendment, cross-model concurrence) - **`touched_ratio` threshold = 0.6** — now the apply-script CLI default, strict `>` comparator, `1.0` disables. - **`insert_after` heading-anchor exemption** — anchoring on a heading no longer fires the heading trigger when the inserted text carries no headings (routine "insert body text after a section heading"); heading replace/delete and heading-bearing inserted text still flag. ## §10 open items closed (verified, not assumed) First-party check found that `formatter_agent.md` had no marker-strip rule for ANY marker kind and `word_count_conventions.md` had no comment-exclusion rule — the spec's "expectation" pointed at nothing. Both added: Phase 7 strips all ARS markers (`ref`/`anchor`/`block`) from converted final outputs after the marker-dependent gates run (working drafts + `phase6_*/` keep theirs); word counts strip `<!--...-->` before splitting. Max single-op `new_text` size folded into the existing triggers (no separate cap). `preserved_ratio` surfaces next to the #389 round-trip count. ## Quality - Full CI pytest manifest green (52 entries; +1 new entry). - `/simplify` pass extracted the shared `h2_section_body` / `check_section_literals` helpers into `scripts/_skill_lint.py` (check_390 + check_394 now import them) and de-duplicated the protocol-doc marker lifecycle into authoritative pointers. - Dual-track ship gate: personal-boundary lint PASSED (0 violations); codex `exec` read-only review (gpt-5.5, xhigh); diff carries no `HEEACT`/`Springer`/`hei-platform` strings. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-12 09:02:53 +08:00
BlockParseError; returns the success report dict.
`touched_ratio_threshold`: the CLI defaults this to
DEFAULT_TOUCHED_RATIO_THRESHOLD (0.6, the #424 ship decision); a
programmatic caller may pass `None` for record-only mode — the ratio is
still computed and recorded in the report, but never triggers a
structural refusal (for callers that own their own escalation policy).
"""
resolved = {
"base": base_path.resolve(),
"output": output_path.resolve(),
"report": report_path.resolve(),
}
collisions = []
if resolved["output"] == resolved["base"]:
collisions.append("--output must not name the base draft (the base is never modified)")
if resolved["report"] in (resolved["base"], resolved["output"]):
collisions.append("--report-out must not name the base draft or the output draft")
if collisions:
raise ApplyRejection(
[
{"op_index": None, "kind": "artifact_path_collision", "message": msg}
for msg in collisions
]
)
# The output is a NEW versioned artifact (§3.3 supersession): refusing
# to overwrite an existing file is what makes the report-failure
# cleanup below safe — any file at output_path is one this run created.
exists = [
{"op_index": None, "kind": "artifact_already_exists", "message": msg}
for p, msg in (
(output_path, "--output already exists; the revised draft must be a new versioned artifact"),
(report_path, "--report-out already exists; each apply emits its own report"),
)
if p.exists()
]
if exists:
raise ApplyRejection(exists)
try:
base_raw = _read_path_once(base_path, "base draft")
base_text = base_raw.decode("utf-8")
except (ContractError, UnicodeDecodeError) as exc:
message = "; ".join(exc.failures) if isinstance(exc, ContractError) else str(exc)
raise ApplyRejection(
[{"op_index": None, "kind": "base_read_invalid", "message": message}]
) from exc
try:
patch, patch_raw = load_json_path(patch_path, "revision patch")
except ContractError as exc:
raise ApplyRejection(
[
{"op_index": None, "kind": "patch_read_invalid", "message": failure}
for failure in exc.failures
]
) from exc
try:
base = parse_document(base_text)
except BlockParseError as exc:
raise ApplyRejection(
[{"op_index": None, "kind": f"base_parse_rejected:{exc.kind}", "message": str(exc)}]
) from exc
analysis = validate_patch(
patch, base_raw, base, touched_ratio_threshold=touched_ratio_threshold
)
# Authority replay is a Phase-1 gate. All named inputs are read once,
# exact-byte/hash checked, and validated before any output is written.
try:
if block_manifest_path is None:
raise ContractError(["current patch apply requires --block-manifest"])
block_manifest, block_manifest_raw = load_json_path(
block_manifest_path, "block manifest"
)
validate_block_manifest(block_manifest, block_manifest_raw, base_raw)
context = patch["authorization_context"]
if context == "review_roadmap":
missing = [
name
for name, value in (
("--roadmap", roadmap_path),
("--author-adjudication", author_adjudication_path),
("--claim-surface-manifest", claim_surface_manifest_path),
("--artifact-root", artifact_root),
)
if value is None
]
forbidden_integrity = [
name
for name, value in (
("--integrity-issue-list", integrity_issue_list_path),
("--integrity-authorization", integrity_authorization_path),
)
if value is not None
]
if forbidden_integrity:
raise ContractError(
[
"review-roadmap patch must not receive "
+ ", ".join(forbidden_integrity)
]
)
if missing:
raise ContractError(
["review-roadmap patch is missing " + ", ".join(missing)]
)
assert roadmap_path is not None
assert author_adjudication_path is not None
assert claim_surface_manifest_path is not None
assert artifact_root is not None
roadmap, roadmap_raw = load_json_path(roadmap_path, "revision roadmap")
validate_roadmap(
roadmap,
roadmap_raw=roadmap_raw,
base_raw=base_raw,
block_manifest=block_manifest,
block_manifest_raw=block_manifest_raw,
)
claim_surface, claim_surface_raw = load_json_path(
claim_surface_manifest_path, "claim surface manifest"
)
surfaces = validate_claim_surface_manifest(
claim_surface,
claim_surface_raw=claim_surface_raw,
roadmap=roadmap,
roadmap_raw=roadmap_raw,
base_raw=base_raw,
artifact_store=ArtifactStore(artifact_root),
)
adjudication, adjudication_raw = load_json_path(
author_adjudication_path, "author adjudication"
)
authorization_witness = validate_review_patch_authorization(
patch,
base_raw=base_raw,
roadmap=roadmap,
roadmap_raw=roadmap_raw,
adjudication=adjudication,
adjudication_raw=adjudication_raw,
claim_surface=claim_surface,
claim_surface_raw=claim_surface_raw,
surfaces_by_id=surfaces,
)
elif context == "integrity_correction":
forbidden = [
name
for name, value in (
("--roadmap", roadmap_path),
("--author-adjudication", author_adjudication_path),
("--claim-surface-manifest", claim_surface_manifest_path),
("--artifact-root", artifact_root),
)
if value is not None
]
if forbidden:
raise ContractError(
["integrity-correction patch must not receive " + ", ".join(forbidden)]
)
missing = [
name
for name, value in (
("--integrity-issue-list", integrity_issue_list_path),
("--integrity-authorization", integrity_authorization_path),
)
if value is None
]
if missing:
raise ContractError(
["integrity-correction patch requires " + ", ".join(missing)]
)
assert integrity_issue_list_path is not None
assert integrity_authorization_path is not None
issue_list, issue_list_raw = load_json_path(
integrity_issue_list_path, "integrity correction list"
)
integrity_authorization, integrity_authorization_raw = load_json_path(
integrity_authorization_path,
"integrity correction authorization",
)
authorization_witness = validate_integrity_patch_authorization(
patch,
patch_raw=patch_raw,
base_raw=base_raw,
issue_list=issue_list,
issue_list_raw=issue_list_raw,
integrity_authorization=integrity_authorization,
integrity_authorization_raw=integrity_authorization_raw,
)
else: # schema validation makes this unreachable; keep fail-closed.
raise ContractError([f"unsupported authorization_context {context!r}"])
except ContractError as exc:
raise ApplyRejection(
[
{"op_index": None, "kind": "authorization_invalid", "message": failure}
for failure in exc.failures
]
) from exc
flags = analysis["structural_flags"]
flags["acknowledged"] = acknowledge_structural
if flags["any"] and not acknowledge_structural:
raise StructuralRefusal(flags)
output_text, phase2 = apply_patch(base_text, base, analysis)
# Post-write self-check (marker uniqueness + grammar): a failure here
# is a splicer bug — no artifact may land.
reparsed = parse_document(output_text)
ids = [b.block_id for b in reparsed.blocks if b.block_id is not None]
if len(ids) != len(set(ids)): # pragma: no cover - parser already rejects
raise AssertionError("self-check: duplicate markers in apply output")
fix(reviewer): re-review yardstick continuity + Stage 4→3' handoff completeness + apply-report output hash (#574/#576 pre-work) (#577) * fix(reviewer): re-review yardstick continuity + Stage 4->3' handoff completeness + apply-report output hash (#574/#576 pre-work) Three bug-class gaps fixed ahead of the #576 contract design: 1. Yardstick continuity: re-review reuses the Round-1 Reviewer Configuration Cards instead of re-running field_analyst over the revised manuscript (new § Yardstick Continuity; visible [YARDSTICK-REGENERATED] fallback marker on a new Judge Record line). 2. Stage 4 -> 3' handoff: the orchestrator transfer row gains the Revision Roadmap + #390 apply report(s) + Round-1 configuration cards; the re-review protocol input list gains the same entries. #528 orchestrator content lock re-pinned per documented procedure. 3. Apply report format 1.1: new output_draft_hash binds the report to the exact revised-draft bytes it describes; consumers instructed to check it before relying on untouched-block evidence. 3 new tests (TestReportOutputHash, red-first); full suite 3518 passed / 3 skipped / 1 xfailed; check_390 / check_268 / boundary-semantics / spec-consistency lints green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bHgbm9bstvmorumtPa65J * refactor: /simplify pass — terse SKILL.md mode cell, STALE-REPORT vocabulary alignment, trimmed version comment - SKILL.md re-review agents cell shrunk to sibling shape + section pointer (marker literal lives in the protocol authority only) - revision_patch_protocol.md names the submission verifier's STALE-REPORT guard as the sibling freshness pattern (single-source vocabulary) - REPORT_FORMAT_VERSION comment trimmed to the load-bearing facts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bHgbm9bstvmorumtPa65J * fix: close codex round-1 findings — Stage 4->3' lockstep across all six authority surfaces + tiering roster + Schema 6 reviewer_configuration - P1: the new re-review inputs (Roadmap, apply report, Round-1 cards) now appear on every Stage 4->3' authority: academic-pipeline SKILL.md, pipeline_state_machine.md (transition row + artifact lineage rows), team_collaboration_protocol.md, state_tracker_agent.md, academic-paper-reviewer SKILL.md re-review input line, and two_stage_review_protocol.md (surface codex round 1 did not name). Three #528 content locks re-pinned per documented procedure. - P1: shared/model_tiering.md prompt-caching roster no longer re-dispatches field_analyst at Stage 3' (Round-1 cards passed as data instead). - P2: Schema 6 judge_record gains optional reviewer_configuration member carrying round1_cards_reused / [YARDSTICK-REGENERATED ...] verbatim. Full suite green; boundary-semantics / model-tiering / check_390 / check_268 / spec-consistency lints green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bHgbm9bstvmorumtPa65J * fix: close codex round-2 findings — examples lockstep, any-path regeneration fallback, canonical reviewer_configuration token, #390 spec §0.1 amendment - P1: three registered examples updated to the new Stage 3' contract (full_pipeline + revision_recovery show reused Round-1 cards and EIC-only verification; mid_entry gains an explicit note that its field_analyst run is legitimate FULL-mode behavior, not re-review) - P1: the regeneration fallback now covers ANY path where Round-1 cards are unavailable (standalone, mid-entry, lost artifacts) — silent regeneration is a protocol violation on every path; resolves the standalone-only wording that left mid-entry pipeline runs undefined - P2: model_tiering roster note qualified (normal-path freeze, marked fallback preserved as the sole exception) - P2: Judge Record template emits the Schema 6 canonical token round1_cards_reused - P2: #390 spec gains §0.1 amendment recording apply-report format 1.1 + output_draft_hash Full suite green; all five lints green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bHgbm9bstvmorumtPa65J * fix: close codex round-3 findings — example mode labels + EIC-only synthesis narrative + corrected mid-entry rationale - P1: full_pipeline_example Stage 3' relabeled re-review mode (was declaring full mode around an EIC-only dispatch) - P1: revision_recovery synthesizer narrative no longer claims a five-reviewer concurrence the EIC-only contract never ran - P2: mid_entry rationale records the true history (ARS quick-mode Round 1 + user-requested fresh full review), not an external-human round that never happened Full suite green; spec-consistency + boundary-semantics green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bHgbm9bstvmorumtPa65J * fix: close codex round-4 findings — mode-gated Roadmap prerequisite + apply-report sidecar path - P1: state_tracker Stage 3' Roadmap requirement gated on re-review mode (a user-requested fresh full review at 3' — mid-entry quick->full path — legitimately has no Roadmap) - P2: orchestrator handoff names the apply report at its real location (<output>.apply-report.json beside the revised draft; only the patch document lives under phase6_*) Two #528 content locks re-pinned. Full suite green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bHgbm9bstvmorumtPa65J * fix: close codex round-5 finding — mode-gate the orchestrator Stage 4->3' transfer row The authoritative handoff row now states it is the re-review-mode transfer (the default Stage 3') and defines the fresh-full-review alternative (mid-entry quick->full path): Revised Draft + available context only, full-mode dispatch, not marked a verification round — consistent with the state_tracker exception added in round 4. #528 orchestrator content lock re-pinned. Full suite green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bHgbm9bstvmorumtPa65J * fix: close codex round-6 finding — propagate the Stage 3' mode gate to SKILL.md, state machine, and collaboration mirrors The fresh-full-review branch (mid-entry quick->full path) is now named on all remaining Stage 4->3' authorities, matching the orchestrator and state_tracker rows from rounds 4-5. Two #528 content locks re-pinned. Full suite green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bHgbm9bstvmorumtPa65J * fix: proactively close the remaining mode-gate class — two_stage protocol, ARCHITECTURE row, lineage cards row Same class as codex rounds 4-6 (unconditional Stage 3' surfaces vs the fresh-full-review branch); swept the remaining mirrors in one pass instead of one per review round. State-machine lock re-pinned. Full suite green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bHgbm9bstvmorumtPa65J * fix: close codex round-7 findings — mode-gate the adjacent Mode/Output/dependency/checklist declarations - P1 x4: two_stage Mode+Output bullets, ARCHITECTURE mode+artifact cells, state-machine Roadmap lineage row, team-collaboration handoff checklist all now carry the re-review-default / fresh-full-review split - P2: state_tracker cards marked re-review-only (no spurious warning on the quick->full path); full_pipeline_example Stage 4 deliverables list the apply-report sidecar it later transfers Two #528 content locks re-pinned. Full suite green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bHgbm9bstvmorumtPa65J * docs(changelog): record the 8-round dual-track review trajectory and full lockstep scope Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bHgbm9bstvmorumtPa65J --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 12:03:35 +08:00
output_bytes = output_text.encode("utf-8")
atomic_write_bytes(output_path, output_bytes)
counters_base = analysis["counters_base"]
blocks_total = counters_base["blocks_total"]
touched = counters_base["blocks_touched"]
preserved = blocks_total - touched
report = {
"report_format_version": REPORT_FORMAT_VERSION,
"mode": "patch",
"base_path": str(base_path),
"output_path": str(output_path),
"base_draft_hash": patch["base_draft_hash"],
fix(reviewer): re-review yardstick continuity + Stage 4→3' handoff completeness + apply-report output hash (#574/#576 pre-work) (#577) * fix(reviewer): re-review yardstick continuity + Stage 4->3' handoff completeness + apply-report output hash (#574/#576 pre-work) Three bug-class gaps fixed ahead of the #576 contract design: 1. Yardstick continuity: re-review reuses the Round-1 Reviewer Configuration Cards instead of re-running field_analyst over the revised manuscript (new § Yardstick Continuity; visible [YARDSTICK-REGENERATED] fallback marker on a new Judge Record line). 2. Stage 4 -> 3' handoff: the orchestrator transfer row gains the Revision Roadmap + #390 apply report(s) + Round-1 configuration cards; the re-review protocol input list gains the same entries. #528 orchestrator content lock re-pinned per documented procedure. 3. Apply report format 1.1: new output_draft_hash binds the report to the exact revised-draft bytes it describes; consumers instructed to check it before relying on untouched-block evidence. 3 new tests (TestReportOutputHash, red-first); full suite 3518 passed / 3 skipped / 1 xfailed; check_390 / check_268 / boundary-semantics / spec-consistency lints green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bHgbm9bstvmorumtPa65J * refactor: /simplify pass — terse SKILL.md mode cell, STALE-REPORT vocabulary alignment, trimmed version comment - SKILL.md re-review agents cell shrunk to sibling shape + section pointer (marker literal lives in the protocol authority only) - revision_patch_protocol.md names the submission verifier's STALE-REPORT guard as the sibling freshness pattern (single-source vocabulary) - REPORT_FORMAT_VERSION comment trimmed to the load-bearing facts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bHgbm9bstvmorumtPa65J * fix: close codex round-1 findings — Stage 4->3' lockstep across all six authority surfaces + tiering roster + Schema 6 reviewer_configuration - P1: the new re-review inputs (Roadmap, apply report, Round-1 cards) now appear on every Stage 4->3' authority: academic-pipeline SKILL.md, pipeline_state_machine.md (transition row + artifact lineage rows), team_collaboration_protocol.md, state_tracker_agent.md, academic-paper-reviewer SKILL.md re-review input line, and two_stage_review_protocol.md (surface codex round 1 did not name). Three #528 content locks re-pinned per documented procedure. - P1: shared/model_tiering.md prompt-caching roster no longer re-dispatches field_analyst at Stage 3' (Round-1 cards passed as data instead). - P2: Schema 6 judge_record gains optional reviewer_configuration member carrying round1_cards_reused / [YARDSTICK-REGENERATED ...] verbatim. Full suite green; boundary-semantics / model-tiering / check_390 / check_268 / spec-consistency lints green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bHgbm9bstvmorumtPa65J * fix: close codex round-2 findings — examples lockstep, any-path regeneration fallback, canonical reviewer_configuration token, #390 spec §0.1 amendment - P1: three registered examples updated to the new Stage 3' contract (full_pipeline + revision_recovery show reused Round-1 cards and EIC-only verification; mid_entry gains an explicit note that its field_analyst run is legitimate FULL-mode behavior, not re-review) - P1: the regeneration fallback now covers ANY path where Round-1 cards are unavailable (standalone, mid-entry, lost artifacts) — silent regeneration is a protocol violation on every path; resolves the standalone-only wording that left mid-entry pipeline runs undefined - P2: model_tiering roster note qualified (normal-path freeze, marked fallback preserved as the sole exception) - P2: Judge Record template emits the Schema 6 canonical token round1_cards_reused - P2: #390 spec gains §0.1 amendment recording apply-report format 1.1 + output_draft_hash Full suite green; all five lints green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bHgbm9bstvmorumtPa65J * fix: close codex round-3 findings — example mode labels + EIC-only synthesis narrative + corrected mid-entry rationale - P1: full_pipeline_example Stage 3' relabeled re-review mode (was declaring full mode around an EIC-only dispatch) - P1: revision_recovery synthesizer narrative no longer claims a five-reviewer concurrence the EIC-only contract never ran - P2: mid_entry rationale records the true history (ARS quick-mode Round 1 + user-requested fresh full review), not an external-human round that never happened Full suite green; spec-consistency + boundary-semantics green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bHgbm9bstvmorumtPa65J * fix: close codex round-4 findings — mode-gated Roadmap prerequisite + apply-report sidecar path - P1: state_tracker Stage 3' Roadmap requirement gated on re-review mode (a user-requested fresh full review at 3' — mid-entry quick->full path — legitimately has no Roadmap) - P2: orchestrator handoff names the apply report at its real location (<output>.apply-report.json beside the revised draft; only the patch document lives under phase6_*) Two #528 content locks re-pinned. Full suite green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bHgbm9bstvmorumtPa65J * fix: close codex round-5 finding — mode-gate the orchestrator Stage 4->3' transfer row The authoritative handoff row now states it is the re-review-mode transfer (the default Stage 3') and defines the fresh-full-review alternative (mid-entry quick->full path): Revised Draft + available context only, full-mode dispatch, not marked a verification round — consistent with the state_tracker exception added in round 4. #528 orchestrator content lock re-pinned. Full suite green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bHgbm9bstvmorumtPa65J * fix: close codex round-6 finding — propagate the Stage 3' mode gate to SKILL.md, state machine, and collaboration mirrors The fresh-full-review branch (mid-entry quick->full path) is now named on all remaining Stage 4->3' authorities, matching the orchestrator and state_tracker rows from rounds 4-5. Two #528 content locks re-pinned. Full suite green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bHgbm9bstvmorumtPa65J * fix: proactively close the remaining mode-gate class — two_stage protocol, ARCHITECTURE row, lineage cards row Same class as codex rounds 4-6 (unconditional Stage 3' surfaces vs the fresh-full-review branch); swept the remaining mirrors in one pass instead of one per review round. State-machine lock re-pinned. Full suite green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bHgbm9bstvmorumtPa65J * fix: close codex round-7 findings — mode-gate the adjacent Mode/Output/dependency/checklist declarations - P1 x4: two_stage Mode+Output bullets, ARCHITECTURE mode+artifact cells, state-machine Roadmap lineage row, team-collaboration handoff checklist all now carry the re-review-default / fresh-full-review split - P2: state_tracker cards marked re-review-only (no spurious warning on the quick->full path); full_pipeline_example Stage 4 deliverables list the apply-report sidecar it later transfers Two #528 content locks re-pinned. Full suite green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bHgbm9bstvmorumtPa65J * docs(changelog): record the 8-round dual-track review trajectory and full lockstep scope Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bHgbm9bstvmorumtPa65J --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 12:03:35 +08:00
"output_draft_hash": base_draft_hash(output_bytes),
feat(re-review): #576 Spec B PR-B1 — contract schemas + synthesis checker + patch_digest (format 1.2) (#605) * feat(re-review): #576 Spec B PR-B1 — contract schemas + synthesis checker + patch_digest First leg of the B1→B2→B3 implementation chain for the three-gate evidence-before-persuasion re-review contract (design: PR #604): - shared/contracts/re_review/{precommitment,verdict_record,traceability, input_manifest}.schema.json — §5/§11 field-for-field, all record types and closed sets; NewStandardRecord gains new_standard_id (spec §5.1 amended in-place: new_standard_ref needs a stable target) - scripts/check_re_review_synthesis.py — §13 recomputation checker, stdlib-only (#510 architecture class); graded exit codes 0/1/2 - scripts/test_check_re_review_synthesis.py — 148-test mutation suite: 3 hand-pinned goldens, one violating fixture per invariant, §10 card fixtures pinned from both example files (DA synthetic), §6 unit table, jsonschema parity - scripts/ars_apply_revision_patch.py — apply report gains patch_digest, REPORT_FORMAT_VERSION 1.1→1.2 (1.0→1.1 precedent; + protocol doc row) - CI: unified pytest manifest entry (spec-consistency runs it) No behavior change: nothing emits these artifacts until PR-B2 turns the contract on as the Stage 3' default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NWFfSwbbixMNDHXQhQ8nRo * fix(re-review): close round-1 three-track findings (2+6 P1, 2+4 P2) General track (Opus 5 xhigh) + codex (gpt-5.6-sol xhigh) round-1 closures: - §3.4 Direction column enforced in schema + checker: valid_rebuttal upgrades to FULLY_ADDRESSED only (the sole letter-anchor basis can no longer move a verdict sideways into the p2_addressed_rate numerator); author_pointer_located_evidence is a strict upgrade to PARTIALLY/FULLY - superseded reapplications keep their dispatch-time pre_reapplication _verdict — only CURRENT records take the chain-tail fallback (a legal successful retry no longer aborts); retry golden + double-current fixture - G2(d) acceptance backs exactly ONE user_accepted_fail_closed adjustment (an orphan acceptance cannot clear the deferral) - CrossModelResolution must reference a reapplication whose answer_refs contains its intent (cross-wiring closed) - challenged-proposal drafted bodies are NEVER booked (content-equality exclusivity) + per-item drafted-body uniqueness - §11 degradation (iii): escalation exceptions unsubstantiatable without the original manuscript - source_reviewer bound VERBATIM to the Schema 7 reviewer field - half-transported P1 items (transported markers present, severity absent) refuse driving_severity null (B1 suppression closed) - apply-report grammar strict: JSON object, numeric dotted version (the pre-1.2 absence policy needs a VALID version below 1.2), 12-hex hashes - path: refs are RELATIVE only (no absolute/drive/traversal), schema+checker - §13 aborted-emission exemption scope: abort precedence over deferral; criteria_drift stays bidirectionally recomputed - letter-present-but-blockless letters get a visible empty-layer NOTE Security track round 1: CONVERGED 0/0. Suite 148 -> 167 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NWFfSwbbixMNDHXQhQ8nRo * fix(re-review): close round-2 findings (codex 2 P1; general 1 P1 + 2 P2, two shared) - superseded reapplications are now verified, not skipped: supersession is defined for FAILED (CANNOT_VERIFY) attempts only, and a failed attempt appends nothing, so its dispatch-time pre_reapplication_verdict must equal its direct retry's — closes the G2(b) covering-predicate rewrite channel through stale user-form resolutions (codex #1 / general P1-1) - an aborted emission claiming manifest_incomplete / manifest_hash_mismatch / synthesis_mismatch as root cause fails: the checker reached recomputation, so the §11 manifest layer validated against the same hash-bound inputs (codex #2 / general P2-2) - §5.3 letter-tag condition implemented: letter-tagged anchors on a reapplication (and its mechanically-copied cross_model_adjudication adjustment) are valid exactly when the re-examined chain carries a booked valid_rebuttal record — the §3.4 "assertion in the letter with no locatable manuscript evidence changes nothing" machine witness (general P2-1) Security round 2: CONVERGED 0/0 (second consecutive). Suite 167 -> 171. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NWFfSwbbixMNDHXQhQ8nRo * fix(re-review): close round-3 findings (codex 1 P1, general 1 P2) - the supersession guard is now UNCONDITIONAL: a verdict-changing successful reapplication carrying its derived adjustment can no longer be superseded past the failed-only check (§6 — a retry names the FAILED attempt it supersedes); the stale-resolution rewrite channel is closed on the derived-adjustment path too (codex round-3 P1) - §7 judge adjudication scope enforced: a cross_model DissentAdjudication is valid only for dissents on P1 (must_fix) items — the judge's scope EQUALS the §9 pass's P1 coverage, and P2 dissents always take the G2(a) user path even on an active setup (general round-3 P2) Security round 3: CONVERGED 0/0 (third consecutive). Suite 171 -> 173. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NWFfSwbbixMNDHXQhQ8nRo * fix(re-review): close round-4 findings (codex 2 P1, general 1 P1; one codex half adjudicated against) - §6 trigger binding: a divergence-only re-application (answer_refs with intent refs only) and a system ResolutionIntent both require an EVALUATED P1 row (cross_model_verdict present, implying an active configuration) — the forged intent→reapplication→resolution chain can no longer rewrite a committed P1/P2 verdict on a not_configured run (general round-4 P1; closes the §18 "Phase 2B cannot silently relax" acceptance surface on the reapplication side) - a G2dAcceptance referencing a SUPERSEDED reapplication fails — a stale acceptance can no longer override the successful current retry (codex round-4 #2) - adjudications below an untripped §7 bound are rejected ("dissents below the bound stand unadjudicated by design") (codex round-4 #1b) - ADJUDICATED AGAINST codex round-4 #1a (reject user adjudicator on active-setup P1 dissents): the §6 deferral loop records a user-adjudicated DissentAdjudication DIRECTLY with no activity qualifier, and the §9 pass can be per-row unavailable — a one-way rule would make judge-transport failure unrecoverable. General round-4 independently reached the same conclusion; pinned by a stays-legal test. Security round 4: CONVERGED 0/0 (fourth consecutive). Suite 173 -> 178. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NWFfSwbbixMNDHXQhQ8nRo * fix(re-review): close round-5 findings (codex 1 P1, general 1 P1) - forged-divergence closure (codex): a divergence-only re-application now requires a REAL dispatch-time divergence (cross_model_verdict != pre_reapplication_verdict — §6 identifies diverges BEFORE the system intent is emitted) and its chain must carry the ORIGINAL mandating system intent; an originally-agree row can no longer manufacture its own diverges status after the fact - ghost-dissent closure (general): every DissentRecord must be APPLIED — the item's verdict record carries applied_criterion dissented:<id> (§7 reverse witness); an unapplied dissent can no longer trip the §7 bound and authorize an original_upheld re-application second chance Both are siblings of the round-4 trigger-binding rule: no committed verdict moves without its genuine triggering divergence/dissent. Security round 5: CONVERGED 0/0 (fifth consecutive). Suite 178 -> 181. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NWFfSwbbixMNDHXQhQ8nRo --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:17:00 +08:00
# Full 64-hex (not the 12-hex draft-hash form): it pairs with the
# §11 manifest's `revision_patches[i].sha256`, which is full-width.
"patch_digest": hashlib.sha256(patch_raw).hexdigest(),
"revision_round": patch["revision_round"],
"authorization_context": patch["authorization_context"],
"authorization_witness": authorization_witness,
"ops_applied": phase2["ops_applied"],
"fresh_block_ids": phase2["fresh_block_ids"],
"pure_move_pairs": phase2["pure_move_pairs"],
"structural_flags": flags,
"counters": {
"blocks_total": blocks_total,
"blocks_touched": touched,
"blocks_preserved_byte_identical": preserved,
"preserved_ratio": round(preserved / blocks_total, 4) if blocks_total else 0.0,
},
}
try:
feat: diff/patch revision mode Slice B — revision-mode adoption (#89 Item 7) (#426) Closes #424 Slice B of #89 Item 7 (spec #390), building on the Slice A deterministic toolchain (#423). `academic-paper` revision mode now runs **anchorize → patch → deterministic apply → finalizer** instead of asking `draft_writer_agent` to re-emit the complete paper — confining the DELEGATE-52 silent-distortion surface to the blocks an operation explicitly touches. ## What landed (the 6 deliverables) 1. **Writer patch-output contract** — `draft_writer_agent.md § Patch-Document Revision Emission`: patch as a `phase6_*/revision_patch_round<N>.json` sidecar (#424 emission decision), hashes copied from the block manifest never computed, `[PATCH-ESCALATION-REQUIRED:]` pre-drafting tag, retry-once, provisional Schema 8 items with mechanical fields left to the orchestrator. 2. **Orchestration sequencing** — `pipeline_orchestrator_agent.md § Revision-Round Patch Sequencing`: five normative steps with a no-rewrite window between manifest generation and apply (a finalizer pass in between would produce spurious hash mismatches). 3. **Escalation gate** — two trigger layers (pre-drafting classification / apply-time `refused_structural`), MANDATORY checkpoint wording, never auto-fallback to full re-emission, escalated rounds re-anchorize under a new ID generation and stamp `mode: full_reemission_escalated`. 4. **Schema 8 delta** — `ResponseItem.change_block_ids` (optional), orchestrator-populated from the apply report (§3.5 role split — inserted block IDs are post-apply facts). 5. **Protocol doc + Mode B commands** — `academic-paper/references/revision_patch_protocol.md`: exact anchorize/apply command sequence, exit codes, apply report as a required re-review input, marker lifecycle. 6. **Lint** — `scripts/check_390_revision_patch_discipline.py` (8 invariants) + 30 mutation tests, wired into `spec-consistency.yml` + the pytest manifest. ## Recorded ship decisions (spec §0 amendment, cross-model concurrence) - **`touched_ratio` threshold = 0.6** — now the apply-script CLI default, strict `>` comparator, `1.0` disables. - **`insert_after` heading-anchor exemption** — anchoring on a heading no longer fires the heading trigger when the inserted text carries no headings (routine "insert body text after a section heading"); heading replace/delete and heading-bearing inserted text still flag. ## §10 open items closed (verified, not assumed) First-party check found that `formatter_agent.md` had no marker-strip rule for ANY marker kind and `word_count_conventions.md` had no comment-exclusion rule — the spec's "expectation" pointed at nothing. Both added: Phase 7 strips all ARS markers (`ref`/`anchor`/`block`) from converted final outputs after the marker-dependent gates run (working drafts + `phase6_*/` keep theirs); word counts strip `<!--...-->` before splitting. Max single-op `new_text` size folded into the existing triggers (no separate cap). `preserved_ratio` surfaces next to the #389 round-trip count. ## Quality - Full CI pytest manifest green (52 entries; +1 new entry). - `/simplify` pass extracted the shared `h2_section_body` / `check_section_literals` helpers into `scripts/_skill_lint.py` (check_390 + check_394 now import them) and de-duplicated the protocol-doc marker lifecycle into authoritative pointers. - Dual-track ship gate: personal-boundary lint PASSED (0 violations); codex `exec` read-only review (gpt-5.5, xhigh); diff carries no `HEEACT`/`Springer`/`hei-platform` strings. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-12 09:02:53 +08:00
# allow_nan=False: a non-finite counter would serialize as bare
# `NaN`/`Infinity` (invalid JSON for strict readers). The threshold
# is range-validated upstream, so this is belt-and-suspenders.
atomic_write_bytes(
report_path,
feat: diff/patch revision mode Slice B — revision-mode adoption (#89 Item 7) (#426) Closes #424 Slice B of #89 Item 7 (spec #390), building on the Slice A deterministic toolchain (#423). `academic-paper` revision mode now runs **anchorize → patch → deterministic apply → finalizer** instead of asking `draft_writer_agent` to re-emit the complete paper — confining the DELEGATE-52 silent-distortion surface to the blocks an operation explicitly touches. ## What landed (the 6 deliverables) 1. **Writer patch-output contract** — `draft_writer_agent.md § Patch-Document Revision Emission`: patch as a `phase6_*/revision_patch_round<N>.json` sidecar (#424 emission decision), hashes copied from the block manifest never computed, `[PATCH-ESCALATION-REQUIRED:]` pre-drafting tag, retry-once, provisional Schema 8 items with mechanical fields left to the orchestrator. 2. **Orchestration sequencing** — `pipeline_orchestrator_agent.md § Revision-Round Patch Sequencing`: five normative steps with a no-rewrite window between manifest generation and apply (a finalizer pass in between would produce spurious hash mismatches). 3. **Escalation gate** — two trigger layers (pre-drafting classification / apply-time `refused_structural`), MANDATORY checkpoint wording, never auto-fallback to full re-emission, escalated rounds re-anchorize under a new ID generation and stamp `mode: full_reemission_escalated`. 4. **Schema 8 delta** — `ResponseItem.change_block_ids` (optional), orchestrator-populated from the apply report (§3.5 role split — inserted block IDs are post-apply facts). 5. **Protocol doc + Mode B commands** — `academic-paper/references/revision_patch_protocol.md`: exact anchorize/apply command sequence, exit codes, apply report as a required re-review input, marker lifecycle. 6. **Lint** — `scripts/check_390_revision_patch_discipline.py` (8 invariants) + 30 mutation tests, wired into `spec-consistency.yml` + the pytest manifest. ## Recorded ship decisions (spec §0 amendment, cross-model concurrence) - **`touched_ratio` threshold = 0.6** — now the apply-script CLI default, strict `>` comparator, `1.0` disables. - **`insert_after` heading-anchor exemption** — anchoring on a heading no longer fires the heading trigger when the inserted text carries no headings (routine "insert body text after a section heading"); heading replace/delete and heading-bearing inserted text still flag. ## §10 open items closed (verified, not assumed) First-party check found that `formatter_agent.md` had no marker-strip rule for ANY marker kind and `word_count_conventions.md` had no comment-exclusion rule — the spec's "expectation" pointed at nothing. Both added: Phase 7 strips all ARS markers (`ref`/`anchor`/`block`) from converted final outputs after the marker-dependent gates run (working drafts + `phase6_*/` keep theirs); word counts strip `<!--...-->` before splitting. Max single-op `new_text` size folded into the existing triggers (no separate cap). `preserved_ratio` surfaces next to the #389 round-trip count. ## Quality - Full CI pytest manifest green (52 entries; +1 new entry). - `/simplify` pass extracted the shared `h2_section_body` / `check_section_literals` helpers into `scripts/_skill_lint.py` (check_390 + check_394 now import them) and de-duplicated the protocol-doc marker lifecycle into authoritative pointers. - Dual-track ship gate: personal-boundary lint PASSED (0 violations); codex `exec` read-only review (gpt-5.5, xhigh); diff carries no `HEEACT`/`Springer`/`hei-platform` strings. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-12 09:02:53 +08:00
(json.dumps(report, ensure_ascii=False, indent=2, allow_nan=False)
+ "\n").encode("utf-8"),
)
except BaseException:
# The output and its apply report land as a pair: a report-write
# failure must not leave a revised draft with no provenance record.
output_path.unlink(missing_ok=True)
raise
return report
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("base", type=Path, help="anchored base draft (never modified)")
parser.add_argument("patch", type=Path, help="revision patch JSON")
parser.add_argument(
"--block-manifest",
type=Path,
required=True,
help="exact script-generated block manifest for the base draft",
)
parser.add_argument("--roadmap", type=Path, help="immutable reviewer roadmap (review context)")
parser.add_argument(
"--author-adjudication",
type=Path,
help="hash-bound explicit author-choice sidecar (review context)",
)
parser.add_argument(
"--claim-surface-manifest",
type=Path,
help="registered exact claim surfaces (review context)",
)
parser.add_argument(
"--artifact-root",
type=Path,
help="root for claim-intent artifacts named by the claim-surface manifest",
)
parser.add_argument(
"--integrity-issue-list",
type=Path,
help="hash-bound correction proposal list (integrity context only)",
)
parser.add_argument(
"--integrity-authorization",
type=Path,
help="explicit-author exact-patch authorization (integrity context only)",
)
parser.add_argument("--output", type=Path, required=True, help="revised draft output path")
parser.add_argument(
"--report-out",
type=Path,
default=None,
help="apply report path (default: <output>.apply-report.json)",
)
parser.add_argument(
"--acknowledge-structural",
action="store_true",
help="proceed despite structural-shape flags (set only after the §3.6 escalation checkpoint)",
)
parser.add_argument(
"--touched-ratio-threshold",
feat: diff/patch revision mode Slice B — revision-mode adoption (#89 Item 7) (#426) Closes #424 Slice B of #89 Item 7 (spec #390), building on the Slice A deterministic toolchain (#423). `academic-paper` revision mode now runs **anchorize → patch → deterministic apply → finalizer** instead of asking `draft_writer_agent` to re-emit the complete paper — confining the DELEGATE-52 silent-distortion surface to the blocks an operation explicitly touches. ## What landed (the 6 deliverables) 1. **Writer patch-output contract** — `draft_writer_agent.md § Patch-Document Revision Emission`: patch as a `phase6_*/revision_patch_round<N>.json` sidecar (#424 emission decision), hashes copied from the block manifest never computed, `[PATCH-ESCALATION-REQUIRED:]` pre-drafting tag, retry-once, provisional Schema 8 items with mechanical fields left to the orchestrator. 2. **Orchestration sequencing** — `pipeline_orchestrator_agent.md § Revision-Round Patch Sequencing`: five normative steps with a no-rewrite window between manifest generation and apply (a finalizer pass in between would produce spurious hash mismatches). 3. **Escalation gate** — two trigger layers (pre-drafting classification / apply-time `refused_structural`), MANDATORY checkpoint wording, never auto-fallback to full re-emission, escalated rounds re-anchorize under a new ID generation and stamp `mode: full_reemission_escalated`. 4. **Schema 8 delta** — `ResponseItem.change_block_ids` (optional), orchestrator-populated from the apply report (§3.5 role split — inserted block IDs are post-apply facts). 5. **Protocol doc + Mode B commands** — `academic-paper/references/revision_patch_protocol.md`: exact anchorize/apply command sequence, exit codes, apply report as a required re-review input, marker lifecycle. 6. **Lint** — `scripts/check_390_revision_patch_discipline.py` (8 invariants) + 30 mutation tests, wired into `spec-consistency.yml` + the pytest manifest. ## Recorded ship decisions (spec §0 amendment, cross-model concurrence) - **`touched_ratio` threshold = 0.6** — now the apply-script CLI default, strict `>` comparator, `1.0` disables. - **`insert_after` heading-anchor exemption** — anchoring on a heading no longer fires the heading trigger when the inserted text carries no headings (routine "insert body text after a section heading"); heading replace/delete and heading-bearing inserted text still flag. ## §10 open items closed (verified, not assumed) First-party check found that `formatter_agent.md` had no marker-strip rule for ANY marker kind and `word_count_conventions.md` had no comment-exclusion rule — the spec's "expectation" pointed at nothing. Both added: Phase 7 strips all ARS markers (`ref`/`anchor`/`block`) from converted final outputs after the marker-dependent gates run (working drafts + `phase6_*/` keep theirs); word counts strip `<!--...-->` before splitting. Max single-op `new_text` size folded into the existing triggers (no separate cap). `preserved_ratio` surfaces next to the #389 round-trip count. ## Quality - Full CI pytest manifest green (52 entries; +1 new entry). - `/simplify` pass extracted the shared `h2_section_body` / `check_section_literals` helpers into `scripts/_skill_lint.py` (check_390 + check_394 now import them) and de-duplicated the protocol-doc marker lifecycle into authoritative pointers. - Dual-track ship gate: personal-boundary lint PASSED (0 violations); codex `exec` read-only review (gpt-5.5, xhigh); diff carries no `HEEACT`/`Springer`/`hei-platform` strings. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-12 09:02:53 +08:00
type=_ratio_threshold,
default=DEFAULT_TOUCHED_RATIO_THRESHOLD,
help="touched-ratio trigger threshold, a finite ratio in [0.0, 1.0] "
"(default %(default)s, the #424 ship decision; fires when "
"blocks_touched/blocks_total is strictly above it; pass 1.0 to disable)",
)
args = parser.parse_args(argv)
report_path = args.report_out or Path(str(args.output) + ".apply-report.json")
try:
report = run(
args.base,
args.patch,
args.output,
report_path,
acknowledge_structural=args.acknowledge_structural,
touched_ratio_threshold=args.touched_ratio_threshold,
block_manifest_path=args.block_manifest,
roadmap_path=args.roadmap,
author_adjudication_path=args.author_adjudication,
claim_surface_manifest_path=args.claim_surface_manifest,
artifact_root=args.artifact_root,
integrity_issue_list_path=args.integrity_issue_list,
integrity_authorization_path=args.integrity_authorization,
)
except ApplyRejection as exc:
print(json.dumps({"result": "rejected", "phase": 1, "failures": exc.failures}, indent=2))
return 2
except StructuralRefusal as exc:
print(
json.dumps(
{
"result": "refused_structural",
"structural_flags": exc.flags,
"hint": "re-run with --acknowledge-structural only after the "
"escalation checkpoint (spec §3.6)",
},
indent=2,
)
)
return 3
except AssertionError as exc: # pragma: no cover - self-check path
print(f"SELF-CHECK FAILED (bug, no artifact written): {exc}", file=sys.stderr)
return 4
counters = report["counters"]
print(
"apply ok: {applied} op(s); {preserved}/{total} blocks preserved byte-identical "
"(ratio {ratio}); report {rpath}".format(
applied=len(report["ops_applied"]),
preserved=counters["blocks_preserved_byte_identical"],
total=counters["blocks_total"],
ratio=counters["preserved_ratio"],
rpath=report_path,
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())