Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
8378fc0
fix(gate): tick AC/DoD rows only after verdict refusals; refuse silen…
mfittko Oct 27, 2026
baf7600
fix(gate): skip spec-id-named rows in the tick and restore the re-fet…
mfittko Oct 27, 2026
85660bb
docs(changes): shorten 2898 changelog entry to 200 characters
mfittko Oct 27, 2026
837a0e2
fix(gate): resolve open-act criteria fail-closed, defer tick writes o…
mfittko Oct 27, 2026
a43006e
docs(changes): shorten 2898 tick-integrity entry to 200 chars
mfittko Oct 27, 2026
2870f7d
fix(gate): match a spec id after any word boundary in an open act cri…
mfittko Oct 27, 2026
7ede9a7
fix(gate): keep a label a reload-only linked issue carries unticked i…
mfittko Oct 28, 2026
f139c23
fix(gate): tick after the last refusal, resolve spec ids to one row, …
mfittko Oct 28, 2026
6f28141
fix(gate): resolve a spec id to a PR row only when the PR body is the…
mfittko Oct 28, 2026
b0ebc65
fix(gate): bounded normalized label skip, late-refusal before tick wr…
mfittko Oct 28, 2026
7d83896
docs(gate): align tick contract with act-item skip and post-refusal w…
mfittko Oct 28, 2026
e20c449
docs(gate): shorten 2898 changelog, scope AC step 5 id skip to PR-bod…
mfittko Oct 28, 2026
dd004cf
Merge remote-tracking branch 'origin/main' into issue-2898
mfittko Nov 1, 2026
dd03b14
fix(gate): guard inline finding ids before deferred AC ticks
mfittko Nov 1, 2026
d07ad39
fix(test): address round-1 review nits on tick integrity
mfittko Nov 1, 2026
d03184e
fix(gate): fence- and section-aware untick guard, category-scoped tic…
mfittko Nov 1, 2026
b361113
fix(github): untick guard tolerates heading renames and section moves
mfittko Nov 1, 2026
95cc789
fix(github): untick guard grants move credit only when the row's key …
mfittko Nov 1, 2026
cee773c
fix(github): one fence-aware AC/DoD scope model for tick, preview and…
mfittko Nov 1, 2026
092c268
test(github): cover nested-DoD tick pick and scope-only preview chang…
mfittko Nov 1, 2026
d5ddd31
test(github): drop the same-label Notes case that passes on the pre-f…
mfittko Nov 1, 2026
5caa83b
Merge remote-tracking branch 'origin/main' into issue-2898
mfittko Nov 1, 2026
57ffb66
fix(gate): evidence_only act items hold their row; register CHECKLIST…
mfittko Nov 1, 2026
058b4bb
fix(docs): cite CHECKLIST-SCOPE-ONE-MODEL in the edit-pr untick refusal
mfittko Nov 1, 2026
c4d852d
refactor(gate): tickReviewerVerifiedItems always queues writes
mfittko Nov 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .claude/skills/docs/acceptance-criteria-verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ Canonical owner for the acceptance-criteria verification procedure run during th

4. **Verify each AC item** against the proposed changes on the current PR head.

5. **Automatic tick before composing:** the reviewers of the `acceptance-criteria` and `pr-checklist` angles list the exact label of each checklist item they verified at this head in their findings artifact's `verifiedItems` field (defined in the review agent's structured findings artifact). Fan-in carries the deduplicated union into the durable findings ledger. For a `pre_approval_gate` post with `--findings-ledger`, `upsert-checkpoint-verdict.mjs` ticks exactly those labels before it composes the verdict. In `fanout_fanin` mode the ledger must first pass the fan-out angle-coverage checks. In every mode its provenance must be consistent and record a fresh (not carried) `acceptance-criteria` or `pr-checklist` review. Otherwise nothing is ticked. The poster flips each matching `- [ ]` to `- [x]` in the PR body's unchecked Acceptance criteria and Definition of done items. When the spec-of-record still has unticked AC items (`uncheckedAcItems`), it also ticks each linked issue body that the coordination load read and that carries unticked AC items. A label outside those unchecked lists is not ticked. A linked issue whose body the coordination load could not fetch is skipped and stays spec-of-record unavailable. The match is exact-label, and the tick never blanket-checks and never unchecks. Each changed body gets one edit. The poster then reloads the refinement artifact, so the verdict composition and the `clean` guards read the ticked bodies. A failed fetch or edit of a body being ticked fails closed, and no verdict is posted. The tick does not run for `draft_gate`. On a matrix-representation issue the issue-side tick is a no-op, and the PR body's derived checklist is the ticked surface.
5. **Automatic tick before composing:** the reviewers of the `acceptance-criteria` and `pr-checklist` angles list the exact label of each checklist item they verified at this head in their findings artifact's `verifiedItems` field (defined in the review agent's structured findings artifact). Fan-in carries the deduplicated union into the durable findings ledger. For a `pre_approval_gate` post with `--findings-ledger`, `upsert-checkpoint-verdict.mjs` computes the ticked bodies as a dry run for exactly those labels before it composes the verdict. In `fanout_fanin` mode the ledger must first pass the fan-out angle-coverage checks. In every mode its provenance must be consistent and record a fresh (not carried) `acceptance-criteria` or `pr-checklist` review. Otherwise nothing is ticked. The poster flips each matching `- [ ]` to `- [x]` in the PR body's unchecked Acceptance criteria and Definition of done items. When the spec-of-record still has unticked AC items (`uncheckedAcItems`), it also ticks each linked issue body that the coordination load read and that carries unticked AC items. A label outside those unchecked lists is not ticked. A verified label that an open judge act item (including one with an evidence-only remedy) names (by label text or by an `ac:<n>` / `dod:<n>` id) stays unticked. An id that does not resolve in the pinned spec, or whose text matches no unchecked PR row, skips every PR-body row of its category; the skip applies to PR-body rows only, not to linked issue bodies. Do not hand-tick a row left unticked for an open act item with `tick-verified-checkboxes`. A linked issue whose body the coordination load could not fetch is skipped and stays spec-of-record unavailable. The match is exact-label, and the tick never blanket-checks and never unchecks. Each changed body gets one edit, written only after every refusal check and directly before the verdict is posted. The verdict composition and the `clean` guards read the dry-run ticked bodies. A failed fetch or edit of a body being ticked fails closed, and no verdict is posted. A refusal changes no body. A failed post (HTTP error) after the write keeps the ticks, and a rerun is idempotent. The tick does not run for `draft_gate`. On a matrix-representation issue the issue-side tick is a no-op, and the PR body's derived checklist is the ticked surface.

<!-- rule: CHECKLIST-SCOPE-ONE-MODEL -->
**CHECKLIST-SCOPE-ONE-MODEL:** every PR-body tick-integrity check reads checklist scope from `checklistLineScopes` in `packages/core/src/loop/issue-refinement-artifact.mjs`: the fence-aware scope of a line is `ac`, `dod`, or none, set by its nearest Acceptance criteria or Definition of done heading, so a Definition of done section nested under Acceptance criteria is `dod`. The category-scoped tick, the live checklist preview and the `edit-pr` untick guard key a checkbox row by that scope plus its label. A heading rename inside one scope keeps a checked row. A heading edit that moves a checked row into or out of a scope counts as losing it and needs `--allow-untick`. A second fence scanner or section classifier MUST NOT decide that scope for the PR body. The linked-issue tick keeps its own `acOnly` section filter.

**Manual tick paths:** the automatic tick needs `verifiedItems`, and only the fan-out path writes them (per-angle artifacts through `consolidate-fanin`). It also reads the unchecked PR-body lists, which the coordination state fills only for a tracker-backed PR with a linked issue (step 7). The tick reads `verifiedItems` only from a trusted ledger: consistent provenance that records a fresh `acceptance-criteria` or `pr-checklist` review, plus fan-out angle coverage in `fanout_fanin` mode. Two paths therefore tick nothing automatically. The first is a post that carries no trusted `verifiedItems`: a post without `--findings-ledger`, a light-mode `inline_single_agent` round (with or without a ledger), or a ledger that lacks a fresh `acceptance-criteria` or `pr-checklist` review or fails the `fanout_fanin` angle coverage. The second is a lightweight or issue-less PR. On those paths the operator ticks each verified item before posting with `node scripts/github/tick-verified-checkboxes.mjs --repo <owner/name> --issue <issue-number> --pr <pr-number> --verified <exact label>...`. Without a tracker-backed issue, the operator passes `--pr` alone. The CLI applies the same exact-label tick. On a fan-out post for a tracker-backed PR with a linked issue, the CLI is a manual correction tool only.

Expand Down
3 changes: 3 additions & 0 deletions changes/2898-tick-integrity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Fixed

- `upsert-checkpoint-verdict` ticks verified rows after all refusal checks, leaves act-named rows unticked; `edit-pr` refuses a silent untick without `--allow-untick` (#2898)
Comment thread
mfittko marked this conversation as resolved.
Resolved
36 changes: 36 additions & 0 deletions packages/core/src/loop/issue-refinement-artifact.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1436,6 +1436,42 @@ export function checklistSectionLineIndices(body, { acOnly = false } = {}) {
return indices;
}

/**
* Per-line checklist scope of a body, indexed like `body.split(/\r?\n/)`:
* "ac" or "dod" for a line under an Acceptance criteria / Definition of done
* section (nearest matching ancestor wins, so a `### Definition of done`
* nested under `## Acceptance criteria` is "dod"), null for any other line,
* undefined for a line inside fenced code. One scope model for every
* tick-integrity consumer (CHECKLIST-SCOPE-ONE-MODEL).
*
* @param {string} body
* @returns {Array<"ac"|"dod"|null|undefined>}
*/
export function checklistLineScopes(body) {
const lines = typeof body === "string" ? body.split(/\r?\n/u) : [];
const scopes = new Array(lines.length).fill(undefined);
const sections = parseMarkdownSections(body);
const mark = (from, texts, scope) => {
let fence = null;
texts.forEach((text, k) => {
const step = stepFence(fence, text);
fence = step.fence;
if (!step.insideFence) scopes[from + k] = scope;
});
};
mark(0, lines.slice(0, sections[0]?.line ?? lines.length), null);
const stack = [];
for (const section of sections) {
while (stack.length > 0 && stack.at(-1).level >= section.level) stack.pop();
const own = ACCEPTANCE_SECTION_PATTERNS.some((p) => p.test(section.name))
? "ac"
: DOD_SECTION_PATTERNS.some((p) => p.test(section.name)) ? "dod" : (stack.at(-1)?.scope ?? null);
stack.push({ level: section.level, scope: own });
mark(section.line + 1, section.bodyLines, own);
}
return scopes;
}

/**
* Decide what an enqueue caller should do with a refinement-artifact result,
* so an un-refined item never lands in the Next Up pickup column in the first
Expand Down
42 changes: 41 additions & 1 deletion scripts/github/edit-pr.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
import { parsePrNumber, requireTokenValue, resolveBodyOrFile, runChild } from "../_cli-primitives.mjs";
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
import { detectGrillEmbedHeading } from "@dev-loops/core/loop/issue-refinement-artifact";
import { checklistLineScopes, detectGrillEmbedHeading } from "@dev-loops/core/loop/issue-refinement-artifact";
import {
detectClosingKeyword,
resolveExpectedIssueFromPrContext,
Expand Down Expand Up @@ -37,6 +37,10 @@ At least one edit:
--enforce-grill Opt-in GRILL-SUBLOOP-NO-EMBED-SYNTHESIS (#1628):
refuse a body that embeds grill
transcript/synthesis/Q&A headings.
--allow-untick Allow a body that unticks or removes a checked
\`[x]\` checkbox row (a flip to \`[ ]\`, a delete
or a reworded label; refused by default, naming
the rows).
--allow-cross-issue Waive the branch-derived closing-reference
mismatch guard for a deliberate cross-issue
reference. By default a new body whose closing
Expand Down Expand Up @@ -78,6 +82,7 @@ export function parseEditPrCliArgs(argv) {
base: { type: "string" },
"enforce-grill": { type: "boolean" },
"allow-cross-issue": { type: "boolean" },
"allow-untick": { type: "boolean" },
...JQ_OUTPUT_PARSE_OPTIONS,
},
allowPositionals: true,
Expand All @@ -97,6 +102,7 @@ export function parseEditPrCliArgs(argv) {
base: undefined,
enforceGrill: false,
allowCrossIssue: false,
allowUntick: false,
jq: undefined,
silent: false,
};
Expand Down Expand Up @@ -185,6 +191,10 @@ export function parseEditPrCliArgs(argv) {
options.enforceGrill = true;
continue;
}
if (token.name === "allow-untick") {
options.allowUntick = token.value === undefined || token.value === true || /^(?:true|1)$/iu.test(String(token.value));
continue;
}
if (token.name === "allow-cross-issue") {
// Boolean flag: bare enables; parseArgs also accepts `=value` for a
// boolean, so honor an explicit `=false`/`=0` (disable) rather than
Expand Down Expand Up @@ -297,6 +307,32 @@ async function buildEditArgs(options) {
return { args, edited };
}

// Labels of `[x]` rows in currentBody that nextBody no longer carries as `[x]`:
// unticked, deleted or reworded rows, across every checkbox in the body. Counts
// per label, so one lost occurrence of a duplicated label is reported.
// The row grammar matches CHECKBOX_RE in tick-verified-checkboxes.mjs and the gate's checklist parser:
// an optional blockquote prefix, then a [-*+] or N. or N) marker.
const CHECKBOX_ROW = /^\s*(?:>\s*)*(?:[-*+]|\d+[.)])\s+\[([ xX])\]\s+(.+?)\s*$/u;
function untickedRows(currentBody, nextBody) {
// Fence-aware and scope-keyed (CHECKLIST-SCOPE-ONE-MODEL): the key is the row's AC/DoD/other scope plus its label, from the
// same checklistLineScopes the tick path uses. A rename inside one scope keeps the row; a heading edit that moves a
// checked row into or out of a scope loses it. Rows inside fenced code have no scope and do not count.
const checked = (body) => {
const scopes = checklistLineScopes(body);
return body.split(/\r?\n/u).flatMap((line, i) => {
const m = scopes[i] === undefined ? null : CHECKBOX_ROW.exec(line);
return m && m[1] !== " " ? [{ key: `${scopes[i]}\u0000${m[2]}`, label: m[2] }] : [];
});
};
const remaining = new Map();
for (const r of checked(nextBody)) remaining.set(r.key, (remaining.get(r.key) ?? 0) + 1);
return checked(currentBody).filter((r) => {
const left = remaining.get(r.key) ?? 0;
if (left > 0) remaining.set(r.key, left - 1);
return left === 0;
}).map((r) => r.label);
}

export async function editPr(
options,
{ env = process.env, ghCommand = "gh", run = runChild, fetchPrContext = defaultFetchPrContext, waiverWriter = false, currentBody } = {},
Expand Down Expand Up @@ -330,6 +366,10 @@ export async function editPr(
}
const waiverRefusal = waiverLineChangeRefusal({ currentBody: baseline, nextBody: guardBody, action: "write" });
if (waiverRefusal) throw new Error(waiverRefusal);
const unticked = untickedRows(baseline, guardBody);
if (unticked.length > 0 && !options.allowUntick) {
throw new Error(`CHECKLIST-SCOPE-ONE-MODEL: refusing to untick or remove ${unticked.length} checked row(s) on PR #${options.pr} (unticked, deleted or reworded): ${unticked.map((l) => JSON.stringify(l)).join("; ")}. Edit from the current body, or pass --allow-untick for a deliberate change.`);
}
}
if (!options.allowCrossIssue && detectClosingKeyword(guardBody)) {
const ctx = await readPrContext();
Expand Down
4 changes: 2 additions & 2 deletions scripts/github/tick-verified-checkboxes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ const CHECKBOX_RE = /^(\s*)((?:>\s*)*)([-*+]|\d+[.)])\s+\[([ xX])\]\s+(.*?)(\r?)
// an already-`[x]` verified label is neither flipped nor reported as unmatched.
// Returns { body, flipped, unmatched } where flipped = labels changed this call
// and unmatched = verified labels absent from the body's checklist. An optional
// `lineInScope(index)` limits the flip to the lines it accepts; the CLI omits it.
// `lineInScope(index, label)` limits the flip to the lines it accepts; the CLI omits it.
export function tickVerifiedCheckboxes(body, verifiedLabels, lineInScope = () => true) {
const verified = new Set(
(verifiedLabels ?? []).map((l) => String(l).trim()).filter((l) => l.length > 0),
Expand All @@ -71,11 +71,11 @@ export function tickVerifiedCheckboxes(body, verifiedLabels, lineInScope = () =>
const found = new Set();
const lines = String(body).split("\n");
const nextLines = lines.map((line, index) => {
if (!lineInScope(index)) return line;
const m = line.match(CHECKBOX_RE);
if (!m) return line;
const [, indent, quote, marker, mark, rest, cr] = m;
const label = rest.trim();
if (!lineInScope(index, label)) return line;
if (!verified.has(label)) return line;
found.add(label);
if (mark !== " ") return line; // already checked — leave it, idempotent
Expand Down
Loading
Loading