* 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:
@@ -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>`);
|
||||
|
||||
Reference in New Issue
Block a user