enhance(#4095): checkpoint:decision auto-selection is opt-in via auto_select (#4912)

* enhance(#4095): checkpoint:decision auto-selection is opt-in via auto_select

Auto-mode used to auto-select a checkpoint:decision's first <option>
unconditionally, making a decision checkpoint's safety depend on option
presentation order rather than an authored choice. Add an optional
auto_select="<option-id>" attribute on the <task> tag: absent, auto-mode
now escalates to a human exactly like gate="blocking-human" does; present,
it names the option auto-mode selects; naming an id with no matching
<option id> is a hard structural-validation error at plan-parse time
rather than a silent fallback to the first option. gate="blocking-human"
continues to win over everything, unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4095): anchor auto_select/id attribute regexes past hyphenated decoys

An isolated adversarial review of the auto_select work found that both new
attribute regexes used \b as their left anchor, which is a word boundary,
not a "start of attribute name" boundary. A decoy attribute ending in the
same word (e.g. data-id="...") sitting before the real id="..." on the
same <option> tag matched first, silently corrupting the extracted option
id. Anchor on (?:^|\s) instead so only the real attribute name can match.
Adds a regression test reproducing the exact decoy-attribute shape, plus a
Unicode option-id test from the same review pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(#4095): register auto-select-attribute.test.cjs in the docs-guard lane

lint-docs-guard-registration failed: the new test reads docs/reference/
plan-md.md but was not registered, so a future edit to that doc could
silently desync from the test without the guard catching it on the PR
that changed the doc. Registered alongside its direct precedents
(precondition-element.test.cjs, reversibility-tagging.test.cjs), which
read the same file for the same reason.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4095): fit the decision bullet under execute-phase.md's frozen byte ceiling

The remote gsd-test run caught what local checks missed: execute-phase.md
carries a frozen ADR-857 Phase-6 byte ceiling (93600) with only 36 bytes
of headroom before this change, and the original checkpoint:decision
wording pushed it to 93772 (over the ceiling). Cascaded into failures in
phase6-capstone-conformance, execute-phase-completion-reconciliation,
claude-orchestration, and the compact-content drift-report test.

Also caught: tests/package-legitimacy-gate.test.cjs anchors a
"decision is conditional, not unconditional" safety check on the literal
phrase "first option" in the decision bullet — which #4095 deliberately
removes, since there is no more unconditional first-option pick. The test
was asserting an assumption this change intentionally makes obsolete;
re-anchored on tokens that still identify the bullet ('decision',
'auto-spawn') without weakening what the test actually verifies (the
bullet must still carry a blocking-human carve-out).

Also fixed a word-order mismatch between my own new test's regex and the
actual doc text it was asserting against (tests/auto-select-attribute.test.cjs).

Regenerated the compact-content benchmark baseline
(tests/fixtures/compact-content-benchmark-baseline.json) to match the new
byte counts.

Emitted-Drift-Ack-Growth: gsd-executor.md — +7 bytes (49138 -> 49145), from the auto_select carve-out added to the checkpoint:decision auto-mode bullet; already trimmed once to fit the 49152 hard cap.
Emitted-Drift-Ack-Growth: execute-phase.md — +12 bytes (93564 -> 93576), from the same carve-out in the orchestrator's decision bullet; kept 24 bytes under the frozen 93600 ADR-857 ceiling after two rounds of trimming for clarity vs. margin.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore(#4095): backfill changeset PR number

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-09-20 23:47:01 -04:00
committed by GitHub
parent 029acd9158
commit eea9247c93
11 changed files with 477 additions and 11 deletions

View File

@@ -1016,6 +1016,10 @@ interface PlanTaskInfo {
// checkpoint:decision fields
hasDecision: boolean;
hasOptions: boolean;
/** `auto_select` attribute value from the opening `<task>` tag, or null if absent (#4095). */
autoSelect: string | null;
/** `id` attribute of every `<option id="…">` found inside `<options>…</options>`, in document order (#4095). */
optionIds: string[];
// checkpoint:human-action fields
hasInstructions: boolean;
hasVerification: boolean;
@@ -1060,6 +1064,15 @@ function extractPlanTaskInfos(content: string): PlanTaskInfo[] {
const hasName = nameArr.length > 0;
const name = hasName ? nameArr[0].trim() : '';
// `(?:^|\s)` (not `\b`) so a hyphenated attribute ending in `auto_select`
// can never be mistaken for the real attribute — the same defensive
// anchor as extractOptionIds' `id` match below (#4095).
const autoSelectMatch = attrs.match(/(?:^|\s)auto_select\s*=\s*["']([^"']*)["']/);
const autoSelect = autoSelectMatch ? autoSelectMatch[1] : null;
const optionsArr = extractTaggedBlocks(body, 'options');
const optionIds = optionsArr.length > 0 ? extractOptionIds(optionsArr[0]) : [];
infos.push({
name,
type,
@@ -1077,6 +1090,8 @@ function extractPlanTaskInfos(content: string): PlanTaskInfo[] {
hasHowToVerify: /<how-to-verify[\s>]/.test(body),
hasDecision: /<decision[\s>]/.test(body),
hasOptions: /<options[\s>]/.test(body),
autoSelect,
optionIds,
hasInstructions: /<instructions[\s>]/.test(body),
hasVerification: /<verification[\s>]/.test(body),
hasResumeSignal: /<resume-signal[\s>]/.test(body),
@@ -1090,6 +1105,36 @@ function extractPlanTaskInfos(content: string): PlanTaskInfo[] {
return infos;
}
/**
* Extract the `id` attribute of every `<option id="…">` opening tag found in
* `optionsBody` (the inner text of one `<options>…</options>` block), in
* document order. Bounded attribute scan (`[^>]{0,500}`), mirroring the same
* ReDoS-safe idiom `extractPlanTaskInfos` uses for the `<task type="…">`
* attribute string — this file's established pattern for reading an
* attribute value without a general XML parser (#4095).
*/
function extractOptionIds(optionsBody: string): string[] {
const ids: string[] = [];
if (typeof optionsBody !== 'string' || optionsBody.length === 0) return ids;
const OPTION_OPEN_RE = /<option(\s[^>]{0,500})?>/g;
let match: RegExpExecArray | null;
while ((match = OPTION_OPEN_RE.exec(optionsBody)) !== null) {
const attrs = match[1] ?? '';
// `(?:^|\s)` (not `\b`) so a decoy attribute like `data-id="…"` inside
// the same opening tag cannot be mistaken for the real `id` — `\b`
// matches at the `-`→`i` boundary too, which `.match()`'s
// first-hit-wins semantics would then silently prefer (#4095).
const idMatch = attrs.match(/(?:^|\s)id\s*=\s*["']([^"']{1,200})["']/);
if (idMatch) ids.push(idMatch[1]);
if (match.index === OPTION_OPEN_RE.lastIndex) {
OPTION_OPEN_RE.lastIndex++;
}
}
return ids;
}
function isCheckpointType(type: string): boolean {
return type.startsWith('checkpoint:');
}
@@ -1131,6 +1176,16 @@ function validatePlanTaskStructure(task: PlanTaskInfo): { errors: string[]; warn
case 'checkpoint:decision':
if (!task.hasDecision) errors.push(`Task '${taskName}' missing <decision>`);
if (!task.hasOptions) errors.push(`Task '${taskName}' missing <options>`);
if (task.autoSelect !== null) {
if (task.autoSelect.length === 0) {
errors.push(`Task '${taskName}' auto_select is empty — name an <option id="…">`);
} else if (task.hasOptions && !task.optionIds.includes(task.autoSelect)) {
errors.push(
`Task '${taskName}' auto_select="${task.autoSelect}" does not match any `
+ `<option id="…"> (available: ${task.optionIds.join(', ') || 'none'})`,
);
}
}
break;
case 'checkpoint:human-action':
if (!task.hasAction) errors.push(`Task '${taskName}' missing <action>`);