2026-06-11 16:44:42 +08:00
|
|
|
#!/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
|
2026-06-11 16:44:42 +08:00
|
|
|
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.
|
2026-06-11 16:44:42 +08:00
|
|
|
|
|
|
|
|
**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).
|
|
|
|
|
|
2026-08-10 03:42:09 +08:00
|
|
|
Current-write usage (patch 1.1 only):
|
2026-06-11 16:44:42 +08:00
|
|
|
python scripts/ars_apply_revision_patch.py base.md patch.json \
|
2026-08-10 03:42:09 +08:00
|
|
|
--block-manifest base.md.block-manifest.json \
|
2026-06-11 16:44:42 +08:00
|
|
|
--output base.rev2.md [--report-out R.json] \
|
2026-08-10 03:42:09 +08:00
|
|
|
[--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]
|
2026-06-11 16:44:42 +08:00
|
|
|
"""
|
|
|
|
|
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
|
2026-06-11 16:44:42 +08:00
|
|
|
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,
|
|
|
|
|
)
|
2026-08-10 03:42:09 +08:00
|
|
|
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,
|
|
|
|
|
)
|
2026-06-11 16:44:42 +08:00
|
|
|
|
|
|
|
|
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"
|
2026-08-10 03:42:09 +08:00
|
|
|
# 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
|
2026-06-11 16:44:42 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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.
|
2026-06-11 16:44:42 +08:00
|
|
|
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)
|
2026-06-11 16:44:42 +08:00
|
|
|
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
|
2026-06-11 16:44:42 +08:00
|
|
|
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:
|
2026-06-11 16:44:42 +08:00
|
|
|
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"],
|
2026-08-10 03:42:09 +08:00
|
|
|
"claim_strength_changes": a["op"]["claim_strength_changes"],
|
|
|
|
|
"collateral_authorization_ids": a["op"]["collateral_authorization_ids"],
|
2026-06-11 16:44:42 +08:00
|
|
|
"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"],
|
2026-08-10 03:42:09 +08:00
|
|
|
"claim_strength_changes": a["op"]["claim_strength_changes"],
|
|
|
|
|
"collateral_authorization_ids": a["op"]["collateral_authorization_ids"],
|
2026-06-11 16:44:42 +08:00
|
|
|
"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"],
|
2026-08-10 03:42:09 +08:00
|
|
|
"claim_strength_changes": op["claim_strength_changes"],
|
|
|
|
|
"collateral_authorization_ids": op["collateral_authorization_ids"],
|
2026-06-11 16:44:42 +08:00
|
|
|
"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"],
|
2026-08-10 03:42:09 +08:00
|
|
|
"claim_strength_changes": op["claim_strength_changes"],
|
|
|
|
|
"collateral_authorization_ids": op["collateral_authorization_ids"],
|
2026-06-11 16:44:42 +08:00
|
|
|
"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,
|
2026-08-10 03:42:09 +08:00
|
|
|
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,
|
2026-06-11 16:44:42 +08:00
|
|
|
) -> 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).
|
|
|
|
|
"""
|
2026-06-11 16:44:42 +08:00
|
|
|
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)
|
|
|
|
|
|
2026-08-10 03:42:09 +08:00
|
|
|
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
|
2026-06-11 16:44:42 +08:00
|
|
|
|
|
|
|
|
try:
|
2026-08-10 03:42:09 +08:00
|
|
|
patch, patch_raw = load_json_path(patch_path, "revision patch")
|
|
|
|
|
except ContractError as exc:
|
2026-06-11 16:44:42 +08:00
|
|
|
raise ApplyRejection(
|
2026-08-10 03:42:09 +08:00
|
|
|
[
|
|
|
|
|
{"op_index": None, "kind": "patch_read_invalid", "message": failure}
|
|
|
|
|
for failure in exc.failures
|
|
|
|
|
]
|
2026-06-11 16:44:42 +08:00
|
|
|
) 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
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-10 03:42:09 +08:00
|
|
|
# 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
|
|
|
|
|
|
2026-06-11 16:44:42 +08:00
|
|
|
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)
|
2026-06-11 16:44:42 +08:00
|
|
|
|
|
|
|
|
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(),
|
2026-06-11 16:44:42 +08:00
|
|
|
"revision_round": patch["revision_round"],
|
2026-08-10 03:42:09 +08:00
|
|
|
"authorization_context": patch["authorization_context"],
|
|
|
|
|
"authorization_witness": authorization_witness,
|
2026-06-11 16:44:42 +08:00
|
|
|
"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.
|
2026-06-11 16:44:42 +08:00
|
|
|
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"),
|
2026-06-11 16:44:42 +08:00
|
|
|
)
|
|
|
|
|
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")
|
2026-08-10 03:42:09 +08:00
|
|
|
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)",
|
|
|
|
|
)
|
2026-06-11 16:44:42 +08:00
|
|
|
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)",
|
2026-06-11 16:44:42 +08:00
|
|
|
)
|
|
|
|
|
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,
|
2026-08-10 03:42:09 +08:00
|
|
|
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,
|
2026-06-11 16:44:42 +08:00
|
|
|
)
|
|
|
|
|
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())
|