diff --git a/plan-design-review/SKILL.md b/plan-design-review/SKILL.md index c3acbf20f..999484048 100644 --- a/plan-design-review/SKILL.md +++ b/plan-design-review/SKILL.md @@ -64,7 +64,9 @@ C) A specific page, file, or path. Recommendation: A when a branch diff exists, otherwise B. Reply with A, B, or C. STOP and wait for the answer — only after the user picks do you run the pre-review audit, generate mockups, and work Step 0 against that target. -## Preamble (run first) +## Preamble (after scope gate) + +**Before the command below:** complete the Scope gate above. When plan mode auto-selects an existing plan, first send ordinary prose naming the actual selected plan title or path: "Scope gate: plan mode — auto-selected B (reviewing )." Send it after this skill loads; a pre-invocation introduction does not count. If the gate asks a question, wait for its answer before running this command. ```bash _SS="$HOME/.claude/skills/gstack/bin/gstack-skill-start" @@ -98,7 +100,7 @@ In plan mode, allowed because they inform the plan: `$B`, `$D`, `codex exec`/`co ## Skill Invocation During Plan Mode -If the user invokes a skill in plan mode, the skill takes precedence over generic plan mode behavior. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" execute. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode. +The invoked skill overrides generic plan mode. **Execute the skill file** starting from the Scope gate (post-load announcement or answered question), then preamble and Step 0. Skill questions are valid; skip only questions the skill resolves. Any AskUserQuestion variant satisfies end-of-turn; use AskUserQuestion Format's tool resolution. If unavailable/failed: `headless` → BLOCKED; `interactive` → prose fallback (satisfies end-of-turn). At STOP, stop: no continuation or ExitPlanMode. Execute "PLAN MODE EXCEPTION — ALWAYS RUN" commands. ExitPlanMode only after skill completion or if the user cancels the skill or tells you to leave plan mode. If `PROACTIVE` is `"false"`, do not auto-invoke or proactively suggest skills. If a skill seems useful, ask: "I think /skillname might help here — want me to run it?" diff --git a/plan-design-review/sections/review-sections.md b/plan-design-review/sections/review-sections.md index d539044df..a08446641 100644 --- a/plan-design-review/sections/review-sections.md +++ b/plan-design-review/sections/review-sections.md @@ -4,16 +4,32 @@ **Anti-skip rule:** Never condense, abbreviate, or skip any review pass (1-7) regardless of plan type (strategy, spec, code, infra). Every pass in this skill exists for a reason. "This is a strategy doc so design passes don't apply" is always wrong — design gaps are where implementation breaks down. If a pass genuinely has zero findings, say "No issues found" and move on — but you must evaluate it. +**Context:** This section continues `plan-design-review/SKILL.md`. If its setup +is no longer in context, Read `~/.claude/skills/gstack/plan-design-review/SKILL.md` +for the System Audit, Design Philosophy, Step 0, Step 0.5 mockup setup (`$D`), +and Section self-check. Use their existing results; do not restart the review. + **Anti-shortcut clause:** Complete one decision cycle per unresolved finding: explain the gap, recommend options, obtain its individual decision, then apply the selected fix. Scope, focus, setup, and next-step choices approve no remedies. -Writing a report, mapping a token, creating a mockup, or listing a task does not -approve a remedy. Inherited exact decisions and preamble-authorized per-issue -auto-decisions remain valid; do not re-ask them. -If findings exist but only navigation was answered, the review is still waiting -for its first issue decision. Never use the final next-step AskUserQuestion to satisfy the issue-approval loop. +Never use the final next-step AskUserQuestion to satisfy the issue-approval loop. With no unresolved findings, no issue question is required. +**Carry decisions across passes.** An issue is one unresolved design requirement +or tradeoff, even when it appears in several plan locations. Before each pass, +compare the plan, DESIGN.md, and the decisions already made: + +| Situation | Required action | +|-----------|-----------------| +| The exact fix already has an individual user decision or a preamble-authorized per-issue auto-decision. | Reuse that decision. Apply it to all affected references and matching tokens; do not ask again. | +| An accepted requirement needs to be copied unchanged into a required artifact, such as the journey storyboard. | Create the artifact without a separate format question. This records the requirement; it approves no new remedy. | +| The plan violates DESIGN.md or has a gap, and no individual decision has approved its fix. | Ask about that issue and wait before fixing it, even if the input names the gap or DESIGN.md prescribes the exact token. Keep the proposed remedy pending meanwhile. | +| New evidence introduces a missing requirement, a conflict, or a new tradeoff. | Name the new issue, offer alternatives, and obtain its individual decision before changing the plan. | + +Writing a report, mapping a token, creating a mockup, or listing a task does not +approve a remedy. If findings exist but only navigation was answered, the review +is still waiting for its first issue decision. + ## Prior Learnings Search for relevant learnings from previous sessions: @@ -52,23 +68,6 @@ matches a past learning, display: This makes the compounding visible. The user should see that gstack is getting smarter on their codebase over time. -**Carry decisions across passes.** Before each pass, reconcile the plan, its -inherited DESIGN.md requirements, and the user's decisions so far. An issue is an -unresolved design requirement or tradeoff, not each paragraph where it appears. Apply an -approved fix to all affected plan references and matching established tokens; -reference that decision in later passes instead of asking for it again. - -Create required review artifacts, such as the journey storyboard, from accepted -design requirements without asking about their format. Copying an inherited -requirement unchanged needs no new design decision. Changing a documented -violation to conform to that requirement is a fix: offer the remedy and obtain -its first individual approval, even when DESIGN.md prescribes the exact token. -Until then, keep the gap and proposed remedy pending in the plan. If -that work exposes a missing design requirement, conflicting requirements, or a new -tradeoff, -raise that unresolved issue individually and wait for approval. A gap explicitly -listed in the input still needs its first individual approval before it is fixed. - **Pass protocol (1-6):** Record the initial 0-10 score. Every `FIX TO 10` below is a proposal: ask about each issue, wait for approval, then edit the plan and re-rate that pass with the reason for the score change. A declined fix remains documented and lowers the final score. Never edit first and ask afterward. Pass 7 is the unscored decision register, not a seventh rating. ### Pass 1: Information Architecture @@ -102,7 +101,10 @@ Apply time-horizon design: 5-sec visceral, 5-min behavioral, 5-year reflective. ### Pass 4: AI Slop Risk -### Design Hard Rules +**Pass 4 evaluation:** Rate 0-10: Does the plan describe specific, intentional UI, or generic patterns? Record each hard-rejection hit and litmus YES/NO with evidence. An unresolved hard rejection caps this pass below 8 (not design-complete); it does not automatically set the score to 0. Litmus answers support findings, not a separate numeric score. +Use plan text and any available mockups as evidence for the rules below. + +#### Design Hard Rules **Classifier: name the mode before you judge a pixel.** The mode is what the visitor's win looks like on THIS surface, not what the product is. A dev tool's landing page is Persuade. A fashion house's docs are Read. - **PERSUADE** (MARKETING/LANDING PAGE: hero-driven, brand-forward, pricing, campaigns) → they decide and act. Design IS the product. Apply Landing Page Rules. @@ -201,8 +203,6 @@ Judgment tells with no detector rule: gradient cta button, stock-photo hero, car Source: [OpenAI "Designing Delightful Frontends with GPT-5.4"](https://developers.openai.com/blog/designing-delightful-frontends-with-gpt-5-4) (Mar 2026) + gstack design methodology. -**Pass 4 evaluation:** Rate 0-10: Does the plan describe specific, intentional UI, or generic patterns? Record each hard-rejection hit and litmus YES/NO with evidence. An unresolved hard rejection caps this pass below 8 (not design-complete); it does not automatically set the score to 0. Litmus answers support findings, not a separate numeric score. - FIX TO 10: Rewrite vague UI descriptions with specific alternatives: - "Cards with icons" → what differentiates these from every SaaS template? - "Hero section" → what makes this hero feel like THIS product? @@ -213,6 +213,7 @@ If visual mockups were generated in Step 0.5, evaluate them against the AI slop ### Pass 5: Design System Alignment Rate 0-10: Does the plan align with DESIGN.md? +If DESIGN.md is absent, rate the plan's explicit token and component specifications. Missing specifications remain findings; do not skip the score or assume alignment. FIX TO 10: If DESIGN.md exists, annotate with specific tokens/components; when it has YAML front matter (the open DESIGN.md format), cite tokens by path (`{colors.primary}`, `{rounded.md}`) so the plan and the file share one vocabulary. If no DESIGN.md, flag the gap and recommend `/design-consultation`. Flag any new component — does it fit the existing vocabulary? Before offering a token-alignment fix, check whether an earlier pass already approved that outcome. If so, apply the established tokens and update every stale gap/reference under that decision; changing the plan location or spelling out the same fix is not a new issue. Ask again only if new evidence exposes an unresolved requirement or tradeoff, and name it. An unapproved violation still needs its first individual decision. @@ -240,6 +241,8 @@ Each decision = one AskUserQuestion with recommendation + WHY + alternatives. Ed ### Post-Pass: Update Mockups (if generated) +After Pass 7: offer the mockup update below when applicable, resolve deferred TODO proposals, reconcile approvals, then synthesize tasks and the Completion Summary. + If mockups were generated in Step 0.5 and review passes changed significant design decisions (information architecture restructure, new states, layout changes), offer to regenerate (one-shot, not a loop): AskUserQuestion: "The review passes changed [list major design changes]. Want me to regenerate mockups to reflect the updated plan? This ensures the visual reference matches what we're actually building." @@ -282,7 +285,7 @@ For design debt: missing a11y, unresolved responsive behavior, deferred empty st Then present options: **A)** Add to TODOS.md **B)** Skip — not valuable enough **C)** Build it now in this PR instead of deferring. Before synthesizing tasks or the completion summary, perform the approval -reconciliation from the Section self-check. Export only agreed implementation work; retain unapproved remedies as pending findings. +reconciliation from the Section self-check in `~/.claude/skills/gstack/plan-design-review/SKILL.md` (Read it if no longer in context). Export only agreed implementation work; retain unapproved remedies as pending findings. Count only individually approved new decisions in "Decisions made" and the review log; a proposed remedy or next-step answer contributes zero. @@ -359,6 +362,12 @@ this run (an empty file means "ran, no findings" — distinct from "didn't run") ### Completion Summary + +**Overall design score:** use the lowest of the six rated pass scores (1-6), +separately before and after approved fixes. Pass 7 is unscored. Keep Step 0's +initial impression in its own row. An overall 8+ therefore means every rated +pass is 8+; unresolved findings still prevent a clean review log. + ``` +====================================================================+ | DESIGN PLAN REVIEW — COMPLETION SUMMARY | diff --git a/plan-design-review/sections/review-sections.md.tmpl b/plan-design-review/sections/review-sections.md.tmpl index 01034469f..44333035a 100644 --- a/plan-design-review/sections/review-sections.md.tmpl +++ b/plan-design-review/sections/review-sections.md.tmpl @@ -2,35 +2,34 @@ **Anti-skip rule:** Never condense, abbreviate, or skip any review pass (1-7) regardless of plan type (strategy, spec, code, infra). Every pass in this skill exists for a reason. "This is a strategy doc so design passes don't apply" is always wrong — design gaps are where implementation breaks down. If a pass genuinely has zero findings, say "No issues found" and move on — but you must evaluate it. +**Context:** This section continues `plan-design-review/SKILL.md`. If its setup +is no longer in context, Read `~/.claude/skills/gstack/plan-design-review/SKILL.md` +for the System Audit, Design Philosophy, Step 0, Step 0.5 mockup setup (`$D`), +and Section self-check. Use their existing results; do not restart the review. + **Anti-shortcut clause:** Complete one decision cycle per unresolved finding: explain the gap, recommend options, obtain its individual decision, then apply the selected fix. Scope, focus, setup, and next-step choices approve no remedies. -Writing a report, mapping a token, creating a mockup, or listing a task does not -approve a remedy. Inherited exact decisions and preamble-authorized per-issue -auto-decisions remain valid; do not re-ask them. -If findings exist but only navigation was answered, the review is still waiting -for its first issue decision. Never use the final next-step AskUserQuestion to satisfy the issue-approval loop. +Never use the final next-step AskUserQuestion to satisfy the issue-approval loop. With no unresolved findings, no issue question is required. +**Carry decisions across passes.** An issue is one unresolved design requirement +or tradeoff, even when it appears in several plan locations. Before each pass, +compare the plan, DESIGN.md, and the decisions already made: + +| Situation | Required action | +|-----------|-----------------| +| The exact fix already has an individual user decision or a preamble-authorized per-issue auto-decision. | Reuse that decision. Apply it to all affected references and matching tokens; do not ask again. | +| An accepted requirement needs to be copied unchanged into a required artifact, such as the journey storyboard. | Create the artifact without a separate format question. This records the requirement; it approves no new remedy. | +| The plan violates DESIGN.md or has a gap, and no individual decision has approved its fix. | Ask about that issue and wait before fixing it, even if the input names the gap or DESIGN.md prescribes the exact token. Keep the proposed remedy pending meanwhile. | +| New evidence introduces a missing requirement, a conflict, or a new tradeoff. | Name the new issue, offer alternatives, and obtain its individual decision before changing the plan. | + +Writing a report, mapping a token, creating a mockup, or listing a task does not +approve a remedy. If findings exist but only navigation was answered, the review +is still waiting for its first issue decision. + {{LEARNINGS_SEARCH}} -**Carry decisions across passes.** Before each pass, reconcile the plan, its -inherited DESIGN.md requirements, and the user's decisions so far. An issue is an -unresolved design requirement or tradeoff, not each paragraph where it appears. Apply an -approved fix to all affected plan references and matching established tokens; -reference that decision in later passes instead of asking for it again. - -Create required review artifacts, such as the journey storyboard, from accepted -design requirements without asking about their format. Copying an inherited -requirement unchanged needs no new design decision. Changing a documented -violation to conform to that requirement is a fix: offer the remedy and obtain -its first individual approval, even when DESIGN.md prescribes the exact token. -Until then, keep the gap and proposed remedy pending in the plan. If -that work exposes a missing design requirement, conflicting requirements, or a new -tradeoff, -raise that unresolved issue individually and wait for approval. A gap explicitly -listed in the input still needs its first individual approval before it is fixed. - **Pass protocol (1-6):** Record the initial 0-10 score. Every `FIX TO 10` below is a proposal: ask about each issue, wait for approval, then edit the plan and re-rate that pass with the reason for the score change. A declined fix remains documented and lowers the final score. Never edit first and ask afterward. Pass 7 is the unscored decision register, not a seventh rating. ### Pass 1: Information Architecture @@ -64,9 +63,10 @@ Apply time-horizon design: 5-sec visceral, 5-min behavioral, 5-year reflective. ### Pass 4: AI Slop Risk -{{DESIGN_HARD_RULES}} - **Pass 4 evaluation:** Rate 0-10: Does the plan describe specific, intentional UI, or generic patterns? Record each hard-rejection hit and litmus YES/NO with evidence. An unresolved hard rejection caps this pass below 8 (not design-complete); it does not automatically set the score to 0. Litmus answers support findings, not a separate numeric score. +Use plan text and any available mockups as evidence for the rules below. + +{{DESIGN_HARD_RULES}} FIX TO 10: Rewrite vague UI descriptions with specific alternatives: - "Cards with icons" → what differentiates these from every SaaS template? @@ -78,6 +78,7 @@ If visual mockups were generated in Step 0.5, evaluate them against the AI slop ### Pass 5: Design System Alignment Rate 0-10: Does the plan align with DESIGN.md? +If DESIGN.md is absent, rate the plan's explicit token and component specifications. Missing specifications remain findings; do not skip the score or assume alignment. FIX TO 10: If DESIGN.md exists, annotate with specific tokens/components; when it has YAML front matter (the open DESIGN.md format), cite tokens by path (`{colors.primary}`, `{rounded.md}`) so the plan and the file share one vocabulary. If no DESIGN.md, flag the gap and recommend `/design-consultation`. Flag any new component — does it fit the existing vocabulary? Before offering a token-alignment fix, check whether an earlier pass already approved that outcome. If so, apply the established tokens and update every stale gap/reference under that decision; changing the plan location or spelling out the same fix is not a new issue. Ask again only if new evidence exposes an unresolved requirement or tradeoff, and name it. An unapproved violation still needs its first individual decision. @@ -105,6 +106,8 @@ Each decision = one AskUserQuestion with recommendation + WHY + alternatives. Ed ### Post-Pass: Update Mockups (if generated) +After Pass 7: offer the mockup update below when applicable, resolve deferred TODO proposals, reconcile approvals, then synthesize tasks and the Completion Summary. + If mockups were generated in Step 0.5 and review passes changed significant design decisions (information architecture restructure, new states, layout changes), offer to regenerate (one-shot, not a loop): AskUserQuestion: "The review passes changed [list major design changes]. Want me to regenerate mockups to reflect the updated plan? This ensures the visual reference matches what we're actually building." @@ -147,13 +150,19 @@ For design debt: missing a11y, unresolved responsive behavior, deferred empty st Then present options: **A)** Add to TODOS.md **B)** Skip — not valuable enough **C)** Build it now in this PR instead of deferring. Before synthesizing tasks or the completion summary, perform the approval -reconciliation from the Section self-check. Export only agreed implementation work; retain unapproved remedies as pending findings. +reconciliation from the Section self-check in `~/.claude/skills/gstack/plan-design-review/SKILL.md` (Read it if no longer in context). Export only agreed implementation work; retain unapproved remedies as pending findings. Count only individually approved new decisions in "Decisions made" and the review log; a proposed remedy or next-step answer contributes zero. {{TASKS_SECTION_EMIT:design-review}} ### Completion Summary + +**Overall design score:** use the lowest of the six rated pass scores (1-6), +separately before and after approved fixes. Pass 7 is unscored. Keep Step 0's +initial impression in its own row. An overall 8+ therefore means every rated +pass is 8+; unresolved findings still prevent a clean review log. + ``` +====================================================================+ | DESIGN PLAN REVIEW — COMPLETION SUMMARY | diff --git a/plan-eng-review/SKILL.md b/plan-eng-review/SKILL.md index c6722b285..c6da0a08e 100644 --- a/plan-eng-review/SKILL.md +++ b/plan-eng-review/SKILL.md @@ -58,7 +58,9 @@ C) A specific file, directory, or path. Recommendation: A when a branch diff exists, otherwise B. Reply with A, B, or C. STOP and wait for the answer — only after the user picks do you run the Design Doc Check and Step 0 against that target. -## Preamble (run first) +## Preamble (after scope gate) + +**Before the command below:** complete the Scope gate above. When plan mode auto-selects an existing plan, first send ordinary prose naming the actual selected plan title or path: "Scope gate: plan mode — auto-selected B (reviewing )." Send it after this skill loads; a pre-invocation introduction does not count. If the gate asks a question, wait for its answer before running this command. ```bash _SS="$HOME/.claude/skills/gstack/bin/gstack-skill-start" @@ -92,7 +94,7 @@ In plan mode, allowed because they inform the plan: `$B`, `$D`, `codex exec`/`co ## Skill Invocation During Plan Mode -If the user invokes a skill in plan mode, the skill takes precedence over generic plan mode behavior. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" execute. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode. +The invoked skill overrides generic plan mode. **Execute the skill file** starting from the Scope gate (post-load announcement or answered question), then preamble and Step 0. Skill questions are valid; skip only questions the skill resolves. Any AskUserQuestion variant satisfies end-of-turn; use AskUserQuestion Format's tool resolution. If unavailable/failed: `headless` → BLOCKED; `interactive` → prose fallback (satisfies end-of-turn). At STOP, stop: no continuation or ExitPlanMode. Execute "PLAN MODE EXCEPTION — ALWAYS RUN" commands. ExitPlanMode only after skill completion or if the user cancels the skill or tells you to leave plan mode. If `PROACTIVE` is `"false"`, do not auto-invoke or proactively suggest skills. If a skill seems useful, ask: "I think /skillname might help here — want me to run it?" diff --git a/scripts/resolvers/design.ts b/scripts/resolvers/design.ts index f719e6bf2..bdb378491 100644 --- a/scripts/resolvers/design.ts +++ b/scripts/resolvers/design.ts @@ -1015,7 +1015,8 @@ Judgment tells with no detector rule: ${judgmentTells.map(e => e.name.toLowerCas // design-review's Methodology categories 5 and 7 already carry the first two. const reflexBlock = (ctx.skillName === 'design-review' ? reflexes.slice(2) : reflexes).join('\n'); - return `### Design Hard Rules + const heading = ctx.skillName === 'plan-design-review' ? '####' : '###'; + return `${heading} Design Hard Rules **Classifier: name the mode before you judge a pixel.** The mode is what the visitor's win looks like on THIS surface, not what the product is. A dev tool's landing page is Persuade. A fashion house's docs are Read. - **PERSUADE** (MARKETING/LANDING PAGE: hero-driven, brand-forward, pricing, campaigns) → they decide and act. Design IS the product. Apply Landing Page Rules. diff --git a/scripts/resolvers/preamble/generate-completion-status.ts b/scripts/resolvers/preamble/generate-completion-status.ts index 03091bdca..65d6f2b7a 100644 --- a/scripts/resolvers/preamble/generate-completion-status.ts +++ b/scripts/resolvers/preamble/generate-completion-status.ts @@ -19,14 +19,17 @@ import type { TemplateContext } from '../types'; * gates. See ceo-plan 2026-04-24 "remove vestigial plan-mode handshake" for * the full rationale. */ -export function generatePlanModeInfo(_ctx: TemplateContext): string { +export function generatePlanModeInfo(ctx: TemplateContext): string { + const invocation = ['plan-design-review', 'plan-eng-review'].includes(ctx.skillName) + ? `The invoked skill overrides generic plan mode. **Execute the skill file** starting from the Scope gate (post-load announcement or answered question), then preamble and Step 0. Skill questions are valid; skip only questions the skill resolves. Any AskUserQuestion variant satisfies end-of-turn; use AskUserQuestion Format's tool resolution. If unavailable/failed: \`headless\` → BLOCKED; \`interactive\` → prose fallback (satisfies end-of-turn). At STOP, stop: no continuation or ExitPlanMode. Execute "PLAN MODE EXCEPTION — ALWAYS RUN" commands. ExitPlanMode only after skill completion or if the user cancels the skill or tells you to leave plan mode.` + : `If the user invokes a skill in plan mode, the skill takes precedence over generic plan mode behavior. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — \`mcp__*__AskUserQuestion\` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: \`headless\` → BLOCKED; \`interactive\` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" execute. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.`; return `## Plan Mode Safe Operations In plan mode, allowed because they inform the plan: \`$B\`, \`$D\`, \`codex exec\`/\`codex review\`, writes to \`~/.gstack/\`, writes to the plan file, and \`open\` for generated artifacts. ## Skill Invocation During Plan Mode -If the user invokes a skill in plan mode, the skill takes precedence over generic plan mode behavior. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — \`mcp__*__AskUserQuestion\` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: \`headless\` → BLOCKED; \`interactive\` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" execute. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.`; +${invocation}`; } export function generateCompletionStatus(ctx: TemplateContext): string { diff --git a/scripts/resolvers/preamble/generate-preamble-bash.ts b/scripts/resolvers/preamble/generate-preamble-bash.ts index 140989d3f..fb82169bd 100644 --- a/scripts/resolvers/preamble/generate-preamble-bash.ts +++ b/scripts/resolvers/preamble/generate-preamble-bash.ts @@ -35,7 +35,13 @@ GSTACK_DESIGN="$GSTACK_ROOT/design/dist" // through $HOME instead (env-var hosts already use $GSTACK_BIN). const shellPath = (p: string) => p.replace(/^~\//, '$HOME/'); - return `## Preamble (run first) + const entry = ['plan-design-review', 'plan-eng-review'].includes(ctx.skillName) + ? `## Preamble (after scope gate) + +**Before the command below:** complete the Scope gate above. When plan mode auto-selects an existing plan, first send ordinary prose naming the actual selected plan title or path: "Scope gate: plan mode — auto-selected B (reviewing )." Send it after this skill loads; a pre-invocation introduction does not count. If the gate asks a question, wait for its answer before running this command.` + : '## Preamble (run first)'; + + return `${entry} \`\`\`bash ${runtimeRoot}_SS="${shellPath(ctx.paths.binDir)}/gstack-skill-start" diff --git a/test/autoplan-command-prefix-au.test.ts b/test/autoplan-command-prefix-au.test.ts new file mode 100644 index 000000000..de9470113 --- /dev/null +++ b/test/autoplan-command-prefix-au.test.ts @@ -0,0 +1,151 @@ +import { expect, test } from 'bun:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import fixture from './fixtures/autoplan-command-prefix-au.json'; +import * as permission from './helpers/autoplan-artifact-permission'; +import { readPendingAutoplanArtifact } from './helpers/autoplan-artifact-recorder'; +import { readPlanCountTranscript, type NativePublicToolEvent } from './helpers/plan-count-transcript'; +import { E2E_TOUCHFILES, selectTests } from './helpers/touchfiles'; + +function replay() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ap-command-')); + const old = path.dirname(path.dirname(fixture.stateRoot)); + const runtime = path.join(root, path.basename(old)), cwd = path.join(root, path.basename(fixture.cwd)); + const replace = (text: string) => text.replaceAll(old, runtime).replaceAll(fixture.cwd, cwd); + const hook = JSON.parse(replace(JSON.stringify(fixture.hook))); + const stateRoot = replace(fixture.stateRoot), config = replace(fixture.config), file = hook.pending.file; + const events = JSON.parse(replace(JSON.stringify(fixture.publicTools))) as NativePublicToolEvent[]; + fs.mkdirSync(cwd, { recursive: true }); fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, fixture.before, { mode: fixture.targetStat.mode }); + const mtime = Number(BigInt(fixture.targetStat.mtimeNs)) / 1e9; + fs.utimesSync(file, mtime, mtime); + fs.mkdirSync(path.dirname(hook.pending.transcriptPath), { recursive: true }); + const records = events.map(e => ({ sessionId: e.sessionId, cwd, isSidechain: false, timestamp: e.timestamp, + requestId: e.requestId, message: { id: e.messageId, role: e.kind === 'use' ? 'assistant' : 'user', + content: e.kind === 'use' ? [{ type: 'tool_use', id: e.toolUseId, name: e.name, input: e.input }] + : [{ type: 'tool_result', tool_use_id: e.toolUseId, content: e.content, is_error: e.isError }] } })); + fs.writeFileSync(hook.pending.transcriptPath, records.map(r => JSON.stringify(r)).join('\n') + '\n'); + const hookFile = path.join(root, 'hook.json'); fs.writeFileSync(hookFile, JSON.stringify(hook)); + const publicTools: NativePublicToolEvent[] = []; + const transcript = readPlanCountTranscript(config, cwd, e => publicTools.push(e)); + const now = Date.parse(fixture.viewportCapturedAt), commandStartedAt = Date.parse(fixture.commandTimestamp); + const pending = readPendingAutoplanArtifact(hookFile, cwd, config, stateRoot, commandStartedAt, publicTools, now, true); + const context = { cwd, ownedStateRoot: stateRoot, ownedNativePlansRoot: path.join(config, 'plans'), + commandStartedAt, now, viewportCapturedAt: now, transcriptStatus: transcript.status, publicTools, pending }; + return { root, file, context, viewport: replace(fixture.viewport), dispose: () => fs.rmSync(root, { recursive: true, force: true }) }; +} +type Replay = ReturnType; +const pick = (r: Replay, seen = new Set()) => permission.pendingAutoplanArtifactPermissionInput(r.viewport, r.context, seen); +const panel = (viewport: string) => viewport.slice(viewport.search(/^[─╌]{8,}\n {0,3}Edit file/m)); + +test('the exact public command display preserves only the current unpublished Edit approval', () => { + const r = replay(); try { + expect(r.context.transcriptStatus).toBe('ready'); expect(r.context.publicTools).toHaveLength(2); + expect(r.context.pending?.toolUseId).toBe(fixture.hook.pending.toolUseId); + expect(r.context.publicTools.some(e => e.toolUseId === r.context.pending?.toolUseId)).toBe(false); + expect(createHash('sha256').update(fs.readFileSync(r.file)).digest('hex')).toBe(fixture.provenance.beforeSHA256); + expect(Math.floor(fs.statSync(r.file).mtimeMs)).toBe(Number(BigInt(fixture.targetStat.mtimeNs) / 1_000_000n)); + expect(permission.autoplanArtifactPermissionInput(r.viewport, r.context, new Set())).toBeNull(); + expect(permission.publishedAutoplanArtifactPermissionInput(r.viewport, r.context, new Set())).toBeNull(); + const expected = { input: '1\r', signature: `${fixture.hook.sessionId}:${fixture.hook.pending.toolUseId}`, file: r.file }; + expect(pick(r)).toEqual(expected); + expect(pick(r, new Set([expected.signature]))).toBeNull(); + expect(pick(r, new Set([permission.autoplanArtifactMenuKey(r.viewport)]))).toBeNull(); + r.viewport = panel(r.viewport); expect(pick(r)).toEqual(expected); + expect(fixture.provenance.paidOutcomesReclassified).toBe(false); + } finally { r.dispose(); } +}); + +test('a plain native command description and wrapped display supply no command authority', () => { + for (const prefix of ['● Recording review metrics\n ⎿ $ echo recorded\n\n', + '⏺ Running local diagnostics\n ⎿ $ bun test\n echo finished\n\n']) { + const r = replay(); try { r.viewport = prefix + panel(r.viewport); expect(pick(r)?.input).toBe('1\r'); } + finally { r.dispose(); } + } +}); + +const screens: Array<[string, (s: string) => string]> = [ + ['source introduction', s => 'Source:\n' + s], ['example introduction', s => 'Example:\n' + s], + ['whole quotation', s => s.split('\n').map(line => '> ' + line).join('\n')], + ['whole code block', s => '```text\n' + s + '\n```'], + ['quoted title', s => s.replace('● Appending spec-review metrics', '● "Appending spec-review metrics"')], + ['source title', s => s.replace('● Appending spec-review metrics', '● Source: an example command')], + ['second native action', s => s.replace(' echo logged', '● Another tool\n ⎿ $ echo other')], + ['indented second action', s => s.replace(' echo logged', ' ● Another tool')], + ['Bash confirmation', s => s.replace(' echo logged', ' Do you want to proceed?')], + ['Bash permission', s => s.replace(' echo logged', ' Bash command requires permission')], + ['second question', s => s.replace(' echo logged', ' ❯ 1. Approve this command')], + ['missing command marker', s => s.replace('⎿ $', '⎿ ')], + ['unbound command prose', s => s.replace(' echo logged', 'Unrelated current prose')], + ['second edit panel', s => s + '\n' + panel(s)], + ['foreign panel path', s => s.replace('projects/gstack-autoplan-chain-zmFsqo/', 'projects/another-project/')], + ['basename-only panel', s => s.replace(/^ …[^\n]+$/m, ' 2026-09-10-user-dashboard.md')], + ['foreign menu target', s => s.replace('edit to 2026-09-10-user-dashboard.md?', 'edit to another.md?')], + ['session approval cursor', s => s.replace('❯ 1. Yes', '❯ 2. Yes')], + ['extra current prompt', s => s + '\nChoose another action'], + ['changed added rows', s => s.replace('zero before advancing', 'ten before advancing')], + ['removed-line gap', s => s.replace(' 98 -', ' 100 -')], + ['added-line gap', s => s.replace(' 98 +', ' 100 +')], + ['different reset start', s => s.replace(' 97 +', ' 96 +')], + ['duplicate removed row', s => s.replace(/(^ 98 -[^\n]*\n)/m, '$1$1')], + ['duplicate added row', s => s.replace(/(^ 98 \+[^\n]*\n)/m, '$1$1')], + ['multiple resets', s => s.replace(' 108 5.', s.slice(s.indexOf(' 97 -'), s.indexOf(' 108 5.')) + ' 108 5.')], + ['truncated removed block', s => s.replace(/^ 99 -[^\n]*\n/m, '')], + ['truncated added block', s => s.replace(/^ 107 \+[^\n]*\n/m, '')], + ['missing panel separator', s => s.replace(/^[─]{8,}\n/m, '')], +]; +for (const [name, change] of screens) test(`current panel remains unambiguous: ${name}`, () => { + const r = replay(); try { r.viewport = change(r.viewport); expect(pick(r)).toBeNull(); } finally { r.dispose(); } +}); + +const bindings: Array<[string, (r: Replay) => void]> = [ + ['missing hook', r => { r.context.pending = undefined; }], + ['wrong hook tool', r => { r.context.pending!.tool = 'Write' as 'Edit'; }], + ['foreign hook session', r => { r.context.pending!.sessionId = 'foreign'; }], + ['foreign hook path', r => { r.context.pending!.file += '.other'; }], + ['missing digest', r => { delete r.context.pending!.editDigest; }], + ['invalid digest', r => { r.context.pending!.editDigest!.beforeSHA256 = 'invalid'; }], + ['wrong before digest', r => { r.context.pending!.editDigest!.beforeSHA256 = '0'.repeat(64); }], + ['wrong addition commitments', r => { r.context.pending!.editDigest!.newLineHashes.fill('0'.repeat(64)); }], + ['current file changed', r => { fs.appendFileSync(r.file, '\nchanged'); fs.utimesSync(r.file, new Date(0), new Date(0)); }], + ['file newer than pending', r => { fs.utimesSync(r.file, new Date(r.context.now), new Date(r.context.now)); }], + ['history is Read', r => { r.context.publicTools[0]!.name = 'Read'; }], + ['foreign history file', r => { r.context.publicTools[0]!.input!.file_path = r.file + '.other'; }], + ['failed history', r => { r.context.publicTools[1]!.isError = true; }], + ['unresolved mutation', r => { r.context.publicTools.pop(); }], + ['published pending request', r => { r.context.publicTools.push({ kind: 'use', name: 'Edit', sessionId: r.context.pending!.sessionId, + toolUseId: r.context.pending!.toolUseId, timestamp: r.context.pending!.timestamp, input: { file_path: r.file } }); }], + ['missing transcript', r => { r.context.transcriptStatus = 'missing'; }], + ['future hook', r => { r.context.pending!.timestamp = new Date(r.context.now + 1).toISOString(); }], + ['viewport predates hook', r => { r.context.viewportCapturedAt = Date.parse(r.context.pending!.timestamp) - 1; }], + ['command after hook', r => { r.context.commandStartedAt = Date.parse(r.context.pending!.timestamp) + 1; }], +]; +for (const [name, change] of bindings) test(`pending authorization is retained: ${name}`, () => { + const r = replay(); try { change(r); expect(pick(r)).toBeNull(); } finally { r.dispose(); } +}); +test('only the Autoplan workflow selects this fixture and behavioral regression', () => { + for (const file of ['test/autoplan-command-prefix-au.test.ts', 'test/fixtures/autoplan-command-prefix-au.json']) + expect(selectTests([file], E2E_TOUCHFILES, []).selected).toEqual(['autoplan-chain-pty']); +}); + +test('removed row order is bound to both the current file and pending digest', () => { + const r = replay(); try { + const rows = r.viewport.split('\n'), a = rows.findIndex(row => /^ 97 -/.test(row)), b = rows.findIndex(row => /^ 98 -/.test(row)); + expect(a).toBeGreaterThan(0); expect(b).toBe(a + 1); + const first = rows[a]!.slice(6), second = rows[b]!.slice(6); + rows[a] = rows[a]!.slice(0, 6) + second; rows[b] = rows[b]!.slice(0, 6) + first; + r.viewport = rows.join('\n'); expect(pick(r)).toBeNull(); + } finally { r.dispose(); } +}); + +test('added row order is bound to the complete pending replacement digest', () => { + const r = replay(); try { + const rows = r.viewport.split('\n'), a = rows.findIndex(row => /^ 97 \+/.test(row)), b = rows.findIndex(row => /^ 98 \+/.test(row)); + expect(a).toBeGreaterThan(0); expect(b).toBe(a + 1); + const first = rows[a]!.slice(6), second = rows[b]!.slice(6); + rows[a] = rows[a]!.slice(0, 6) + second; rows[b] = rows[b]!.slice(0, 6) + first; + r.viewport = rows.join('\n'); expect(pick(r)).toBeNull(); + } finally { r.dispose(); } +}); diff --git a/test/autoplan-with-result-au.test.ts b/test/autoplan-with-result-au.test.ts new file mode 100644 index 000000000..015b4412c --- /dev/null +++ b/test/autoplan-with-result-au.test.ts @@ -0,0 +1,126 @@ +import {expect, test} from 'bun:test'; +import actual from './fixtures/autoplan-with-result-au.json'; +import {autoplanPhaseCompletions} from './helpers/autoplan-phase-observer'; +import {E2E_TOUCHFILES, selectTests} from './helpers/touchfiles'; +import type {PlanCountTranscript} from './helpers/plan-count-transcript'; +const at=Date.parse(actual.timestamp); +const transcript=(text=actual.text):PlanCountTranscript=>({status:'ready',calls:[],assistantMessages:[{...actual,text}]}); +const observe=(text:string)=>autoplanPhaseCompletions(transcript(text),at-1); + +test('the exact first AU DX completion retains its native timestamp without crediting the Eng transition',()=>{ + expect(autoplanPhaseCompletions(transcript(),at-1)).toEqual([{phase:2.5,ts:at}]); + expect(actual.sessionId).toBe('78ce9c42-e5f7-4595-81ea-7d9bb8b4345c'); + expect(actual.timestamp).toBe('2026-09-10T21:35:46.209Z'); +}); + +test('affirmative result clauses share phase identity and the existing completion vocabulary',()=>{ + for(const [phase,name] of [[1,'CEO'],[2,'Design review'],[2.5,'DX'],[3,'Engineering review']] as const) + for(const state of ['complete','completed','done','finished','wrapped up']) + for(const result of ['22 findings recorded in the plan.','the score at 8/10.','all adopted changes written; moving to the next phase.']) { + expect(observe(`Phase ${phase} (${name}) is ${state} with ${result}`)).toEqual([{phase,ts:at}]); + } + expect(observe('**Phase 2.5 wrapped up** with 22 findings retained.')).toEqual([{phase:2.5,ts:at}]); +}); + +const rejected=[ + 'Phase 2.5 wrapped up with ', + 'Phase 2.5 wrapped up without the review.', + 'Phase 2.5 will be complete with 22 findings.', + 'Phase 2.5 is not complete with 22 findings.', + 'Phase 2.5 complete with no completed review.', + 'Phase 2.5 complete with findings still pending.', + 'Phase 2.5 complete with 22 findings if the review finishes.', + 'Phase 2.5 complete with 22 findings once approved.', + 'Phase 2.5 complete with 22 findings when the review ends.', + 'Phase 2.5 complete with 22 findings unless the review fails.', + 'Phase 2.5 complete with 22 findings provided the reviewer agrees.', + 'Phase 2.5 complete with 22 findings?','Phase 2.5 complete with results that will arrive tomorrow.', + 'Phase 2.5 complete with maybe 22 findings.','Phase 2.5 complete with an unfinished review.', + 'Phase 2.5 complete with 22 findings. This phase is withdrawn.', + 'Phase 2.5 complete with 22 findings. This phase is "withdrawn".', + 'Phase 2.5 complete with 22 findings. This phase is not complete.', + 'Phase 2.5 complete with 22 findings. This phase is retracted.', + 'Phase 2.5 complete with 22 findings. The declaration is superseded.', + 'Phase 2.5 complete with a historical example.', + 'Phase 2.5 complete with source instructions.', + 'Phase 2.5 complete with "22 findings recorded".', + 'Phase 2.5 complete with \'22 findings recorded\'.', + 'Phase 2.5 (Design) complete with 22 findings.', + 'Phase 2.5 (DX review if approved) complete with 22 findings.', + 'Phase 4 complete with 22 findings.','Phase 2.1 complete with 22 findings.', + '# Phase 2.5 complete with 22 findings.', + '> Phase 2.5 complete with 22 findings.', + '"Phase 2.5 complete with 22 findings."', + '- Phase 2.5 complete with 22 findings.', + '| Phase 2.5 complete with 22 findings. |', + ' Phase 2.5 complete with 22 findings.', + '\tPhase 2.5 complete with 22 findings.', + '```text\nPhase 2.5 complete with 22 findings.\n```', + '~~~text\nPhase 2.5 complete with 22 findings.\n~~~', + 'Source:\nPhase 2.5 complete with 22 findings.', + 'Historical example:\nPhase 2.5 complete with 22 findings.', + 'Historical review:\nPhase 2.5 complete with 22 findings.', + '**Historical review:**\nPhase 2.5 complete with 22 findings.', + '**Source:**\nPhase 2.5 complete with 22 findings.', + 'Hypothetical scenario:\nPhase 2.5 complete with 22 findings.', + 'Phase 2.5 complete with 22 findings.\n```text\nexample text\n````\nThis phase is withdrawn.', + 'Earlier review:\nPhase 2.5 complete with 22 findings.', + 'Phase 2.5 complete with a hypothetical 8/10 score.', + 'Phase 2.5 complete with 22 findings.\nThis phase is withdrawn.', + 'Phase 2.5 complete with 22 findings.\nThis phase is \"withdrawn\".', + 'Phase 2.5 complete with 22 findings.\n**Phase 2.5** is ‘withdrawn’.', + 'Phase 2.5 complete with 22 findings.\nCurrent status: this phase is no longer current.', + 'The template says:\n\nPhase 2.5 complete with 22 findings.', + 'Example:\nPhase 1 complete with findings.\nPhase 2.5 complete with findings.', +]; +test.each(rejected)('%s cannot supply completion',text=>expect(observe(text)).toEqual([])); + +test('quoted summaries retain their existing concrete-consensus requirement',()=>{ + const summary='> Phase 2.5 complete with 22 findings retained.\n> Consensus: 22/22 accepted.\n> Moving to Phase 3.'; + expect(observe(summary)).toEqual([{phase:2.5,ts:at}]); + for(const text of [summary.replace('22/22','[N]/22'),'Example:\n'+summary,summary.replace('22/22','X/Y')]) + expect(observe(text)).toEqual([]); +}); + +test('native readiness, timestamp, duplicate and observed-order rules remain intact',()=>{ + for(const status of ['missing','error'] as const) + expect(autoplanPhaseCompletions({...transcript(),status},at-1)).toEqual([]); + expect(autoplanPhaseCompletions(transcript(),at+1)).toEqual([]); + expect(autoplanPhaseCompletions({...transcript(),assistantMessages:[{...actual,timestamp:'invalid'}]},at-1)).toEqual([]); + const data=transcript();data.assistantMessages.push({...actual,timestamp:new Date(at+1).toISOString()}); + expect(autoplanPhaseCompletions(data,at-1)).toEqual([{phase:2.5,ts:at}]); + data.assistantMessages.unshift({...actual,text:'Phase 3 complete with 7 findings retained.',timestamp:new Date(at-10).toISOString()}); + expect(autoplanPhaseCompletions(data,at-11)).toEqual([{phase:3,ts:at-10},{phase:2.5,ts:at}]); +}); + +test('the regression and exact public message select only the existing Autoplan workflow',()=>{ + for(const file of ['test/autoplan-with-result-au.test.ts','test/fixtures/autoplan-with-result-au.json']) + expect(selectTests([file],E2E_TOUCHFILES,[]).selected).toEqual(['autoplan-chain-pty']); +}); + + +test('quoted history and a foreign phase withdrawal do not cancel the current completed result',()=>{ + for(const suffix of [ + '> This phase is withdrawn.', + 'Historical note: "This phase is withdrawn."', + 'Example:\nThis phase is withdrawn.', + '```text\nThis phase is withdrawn.\n```', + 'Phase 2 is withdrawn.', + 'Phase 3 complete.\nThis phase is withdrawn.', + ]) expect(observe('Phase 2.5 complete with 22 findings retained.\n'+suffix).some(hit=>hit.phase===2.5)).toBe(true); + expect(observe('Phase 2.5 complete with 22 findings.\nHistorical note:\nThis phase is withdrawn.\nCurrent status: Phase 2.5 is withdrawn.')).toEqual([]); +}); + + +test('a current Markdown status heading resets historical context for an owned withdrawal',()=>{ + const prefix='Phase 2.5 complete with 22 findings retained.\nHistorical note:\nThis phase is withdrawn.\n'; + expect(observe(prefix+'## Current status\nPhase 2.5 is withdrawn.')).toEqual([]); + expect(observe(prefix+'`## Current status`\nThis phase is withdrawn.')).toEqual([{phase:2.5,ts:at}]); +}); + +test('inline code around an owned status is scalar formatting while a whole quoted statement stays literal',()=>{ + const prefix='Phase 2.5 complete with 22 findings retained.\n'; + expect(observe(prefix+'This phase is `withdrawn`.')).toEqual([]); + for(const literal of ['`This phase is withdrawn.`','"This phase is withdrawn."','```text\nThis phase is withdrawn.\n```']) + expect(observe(prefix+literal)).toEqual([{phase:2.5,ts:at}]); +}); diff --git a/test/design-scope-entry-aq.test.ts b/test/design-scope-entry-aq.test.ts index 4d09c6582..7b857306f 100644 --- a/test/design-scope-entry-aq.test.ts +++ b/test/design-scope-entry-aq.test.ts @@ -33,7 +33,7 @@ test('every host expands its real bootstrap after the mandatory entry gate', () const preamble = generatePreamble(ctx); const brain = host.suppressedResolvers?.includes('BASE_BRANCH_DETECT') ? '' : generateBaseBranchDetect(ctx); const expanded = template.replace('{{PREAMBLE}}', preamble).replace('{{BASE_BRANCH_DETECT}}', brain); - expect(expanded.indexOf(announcement)).toBeLessThan(expanded.indexOf('## Preamble (run first)')); + expect(expanded.indexOf(announcement)).toBeLessThan(expanded.indexOf('## Preamble (after scope gate)')); expect(expanded.indexOf('Reply with A, B, or C. STOP and wait')).toBeLessThan(expanded.indexOf('```bash')); expect(expanded.indexOf('```bash')).toBeLessThan(expanded.indexOf('gstack-skill-start', expanded.indexOf('```bash'))); if (brain) expect(expanded.indexOf(announcement)).toBeLessThan(expanded.indexOf(brain)); diff --git a/test/eng-annotated-cache-au.test.ts b/test/eng-annotated-cache-au.test.ts new file mode 100644 index 000000000..e5910fe69 --- /dev/null +++ b/test/eng-annotated-cache-au.test.ts @@ -0,0 +1,57 @@ +import {expect,test} from 'bun:test'; +import captured from './fixtures/eng-annotated-cache-au.json'; +import {engFirstReviewAUQ,engSetupAUQ,engStep0Boundary,nativePlanCallFingerprint,planCountQuestionPhase} from './helpers/claude-pty-runner'; +import type {NativePlanQuestionCall} from './helpers/plan-count-transcript'; +import {E2E_TOUCHFILES} from './helpers/touchfiles-data'; +const fresh=()=>structuredClone(captured.call) as NativePlanQuestionCall; +const fp=(c=fresh())=>nativePlanCallFingerprint(c,Date.parse(c.answeredAt!),true); +const first=(c=fresh())=>engFirstReviewAUQ(fp(c)); +function change(edit:(q:NativePlanQuestionCall['questions'][number])=>void){const c=fresh(),q=c.questions[0]!,picked=q.options.findIndex(o=>o.label===c.answers[q.question]);edit(q);c.answers={[q.question]:q.options[picked]!.label};return c;} +test('exact acknowledged annotated cache finding opens review without changing native ownership',()=>{ + const c=fresh(),before=JSON.stringify(c);expect(first(c)).toBe(true);expect(engSetupAUQ(fp(c))).toBe(false);expect(planCountQuestionPhase(fp(c),false,engStep0Boundary,engFirstReviewAUQ,engSetupAUQ)).toMatchObject({preReview:false,reviewStarted:true});expect(JSON.stringify(c)).toBe(before);expect(captured.provenance.retrospectivePass).toBe(false); +}); +test('incidental metadata and all offered choices retain substantive review identity',()=>{ + for(const edit of [ + (q:any)=>{q.question=q.question.replace('D5 — Issue 1','D15 — Issue 11');q.header='Arch 11';}, + (q:any)=>{q.question=q.question.replace('PLAN.md:19-20 + :10','docs/plan.md:42');}, + (q:any)=>{q.question=q.question.replace('[P1] (confidence 8/10)','[P2] (confidence 10/10)');}, + (q:any)=>{q.question=q.question.replaceAll('AuthCache','TenantStore').replaceAll('SessionMint','SessionWriter').replaceAll('AuthBroker','AuthReader');q.options=q.options.map((o:any)=>({...o,description:o.description.replaceAll('AuthCache','TenantStore').replaceAll('SessionMint','SessionWriter').replaceAll('AuthBroker','AuthReader')}));}, + (q:any)=>{q.question+='\n"Historical note: This finding is withdrawn."';}, + (q:any)=>{q.options[0].description+='\n"This option is withdrawn."';}, + ])expect(first(change(edit))).toBe(true); + for(const reversed of [false,true])for(let i=0;i<3;i++){const c=fresh(),q=c.questions[0]!;if(reversed)q.options.reverse();c.answers={[q.question]:q.options[i]!.label};expect(first(c)).toBe(true);} +}); +const changes:Array<[string,(q:NativePlanQuestionCall['questions'][number])=>void]>=[ + ['foreign issue header',q=>{q.header='Arch 2';}],['missing issue',q=>{q.question=q.question.replace('Issue 1 ','');}],['missing annotation',q=>{q.question=q.question.replace('[P1] (confidence 8/10) ','');}],['missing source location',q=>{q.question=q.question.replace('PLAN.md:19-20 + :10 — ','');}],['invalid confidence',q=>{q.question=q.question.replace('confidence 8/10','confidence 11/10');}], + ['conditional defect',q=>{q.question=q.question.replace('both mutate','might both mutate');}],['same actor twice',q=>{q.question=q.question.replace('AuthBroker and SessionMint','AuthBroker and AuthBroker');}],['serialized title',q=>{q.question=q.question.replace('does not serialize mutations','serializes mutations');}], + ['source title',q=>{q.question='Source: '+q.question;}],['quoted title',q=>{const lines=q.question.split('\n');lines[0]='"'+lines[0]+'"';q.question=lines.join('\n');}],['source context',q=>{q.question=q.question.replace('Project/branch/task:','Source:');}],['historical context',q=>{q.question=q.question.replace('Project/branch/task:','Project/branch/task: Historical assessment:');}], + ['no own explanation',q=>{q.question=q.question.replace(/^ELI10:.*$/m,'');}],['quoted explanation',q=>{q.question=q.question.replace(/^ELI10: (.*)$/m,'ELI10: "$1"');}],['competing explanation',q=>{q.question+='\nELI10: There is no race.';}],['hypothetical explanation',q=>{q.question=q.question.replace('ELI10:','ELI10: If approved,');}],['missing race consequence',q=>{q.question=q.question.replace('the mint can land after the invalidation and a suspended tenant keeps a live session','the tenant always loses the session');}], + ['repair wrong cache',q=>{q.options[0]!.description=q.options[0]!.description!.replace('AuthCache passed','OtherCache passed');}],['same writer and reader',q=>{q.options[0]!.description=q.options[0]!.description!.replace('AuthBroker reads','SessionMint reads');}],['missing invalidation rejection',q=>{q.options[0]!.description=q.options[0]!.description!.replace('are rejected if the entry was invalidated since read','are accepted even when invalidated');}],['missing owned repair',q=>{q.options[0]!.description='Choose later.';}],['missing opposed risk',q=>{q.options[2]!.description='The race is closed.';}],['opposition now serialized',q=>{q.options[2]!.description+='\nThe writers are now serialized.';}],['reader also writes',q=>{q.options[0]!.description+='\nAuthBroker also writes.';}], +]; +test.each(changes)('%s cannot open review',(_,edit)=>expect(first(change(edit))).toBe(false)); +test('current statuses, framing and conditional approval are enforced on finding and offered outcomes',()=>{ + for(const status of ['withdrawn','no longer current','hypothetical','optional'])for(const [open,close]of [['',''],['"','"'],["'","'"],['“','”'],['‘','’'],['`','`']]){ + for(const owner of ['This finding','D5','Issue 1'])expect(first(change(q=>{q.question+=`\n**${owner}** is ${open}${status}${close}.`;})),`${owner} ${open}${status}`).toBe(false); + for(const i of [0,1,2])expect(first(change(q=>{q.options[i]!.description+=`\n**This option** is ${open}${status}${close}.`;}))).toBe(false); + } + for(const prefix of ['Source:','Historical assessment:','If approved,','Once approved,','Pending approval:'])for(const i of [0,1,2])expect(first(change(q=>{q.options[i]!.description=prefix+'\n'+q.options[i]!.description;})),prefix).toBe(false); +}); +test('native completion, timestamp, exact answer, session and visible menu stay mandatory',()=>{ + const edits:Array<(c:NativePlanQuestionCall)=>void>=[c=>{c.answered=false;},c=>{c.failed=true;},c=>{c.answers={};},c=>{c.answers[c.questions[0]!.question]='not offered';},c=>{c.unansweredQuestionIndices=[0];},c=>{c.answeredAt='invalid';},c=>{c.sessionId='';},c=>{c.toolUseId='';},c=>{c.questions[0]!.multiSelect=true;},c=>{c.questions.push(structuredClone(c.questions[0]!));},c=>{c.questions[0]!.options[1]!.label=c.questions[0]!.options[0]!.label;}]; + for(const edit of edits){const c=fresh();edit(c);expect(first(c)).toBe(false);}const f=fp();expect(engFirstReviewAUQ({...f,signature:'foreign'})).toBe(false);expect(engFirstReviewAUQ({...f,options:f.options.slice().reverse()})).toBe(false);expect(engFirstReviewAUQ({...f,nativeQuestionIndex:1})).toBe(false); +}); +test('regression fixture and control select the engineering finding-count workflow',()=>{ + for(const file of ['test/eng-annotated-cache-au.test.ts','test/fixtures/eng-annotated-cache-au.json'])expect(Object.entries(E2E_TOUCHFILES).filter(([,paths])=>paths.includes(file)).map(([owner])=>owner)).toEqual(['plan-eng-finding-count']); +}); + +test('current approval conditions and same-option effort boundaries cannot hide withdrawals',()=>{ + for(const phrase of ['requires approval','is conditional on approval','is contingent on acceptance']) for(const target of ['finding','option']) expect(first(change(q=>{if(target==='finding')q.question+='\nThis finding '+phrase+'.';else q.options[0]!.description+='\nThis option '+phrase+'.';}))).toBe(false); + for(const status of ['withdrawn','no longer current']) for(const [open,close]of [['',''],['"','"'],["'","'"],['“','”'],['‘','’']]) expect(first(change(q=>{q.options[0]!.description=q.options[0]!.description!.replace(/\.$/,'')+` This option is ${open}${status}${close}.`;}))).toBe(false); + expect(first(change(q=>{q.options[2]!.description+='\nOnly SessionMint writes.';}))).toBe(false); + expect(first(change(q=>{q.options[0]!.description+='\nDo not inject the cache.';}))).toBe(false); +}); + +test('the injection-only alternative must retain its stated unresolved race',()=>{ + for(const text of ['AuthCache is now serialized.','Only SessionMint writes.']) expect(first(change(q=>{q.options[1]!.description+='\n'+text;}))).toBe(false); + for(const text of ['"AuthCache is now serialized."',"'Only SessionMint writes.'",'ArchiveCache is now serialized.']) expect(first(change(q=>{q.options[1]!.description+='\n'+text;}))).toBe(true); +}); diff --git a/test/eng-required-parity-au.test.ts b/test/eng-required-parity-au.test.ts new file mode 100644 index 000000000..5d6792bf5 --- /dev/null +++ b/test/eng-required-parity-au.test.ts @@ -0,0 +1,123 @@ +import { expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { evaluateEngSeedCoverage } from './helpers/eng-seeded-coverage'; +import { E2E_TOUCHFILES } from './helpers/touchfiles-data'; +const report = readFileSync(new URL('./fixtures/eng-required-parity-au.md', import.meta.url), 'utf8'); +const regression = (plan: string) => evaluateEngSeedCoverage({ status: 'ready', calls: [], assistantMessages: [] }, plan, 0, 1).regression; +const required = report.match(/^### Required tests[^\n]+\n[\s\S]*?(?=\n## )/m)![0]; +const baseline = report.match(/^- \[ \] \*\*T4 .*\n(?: .*(?:\n|$))*/m)![0]; +const parity = report.match(/^- \[ \] \*\*T6 .*\n(?: .*(?:\n|$))*/m)![0]; +const compact = '# Current reviewed plan\n\n' + required + '\n## Implementation Tasks\n' + baseline + parity; + +test('the acknowledged required-test oracle owns its unchanged baseline and separate parity task', () => { + expect(createHash('sha256').update(report).digest('hex')).toBe('8c4b2ea61293a0f8a3b78e6e30017065e5b6dc2211cd95262d0a1c63a0947189'); + expect(regression(report)).toBe('plan'); + expect(regression(compact)).toBe('plan'); + for (const text of [compact.replaceAll('T4', 'T14').replaceAll('T6', 'T16'), compact.replaceAll('legacyAuthFlow.regression.test.ts', 'tests/prior-auth.test.js'), compact.replaceAll('auth-parity.test.ts', 'tests/parity.test.js'), compact.replaceAll('Pin current behavior', 'Capture current behavior'), compact.replaceAll('pinning current', 'capturing current'), compact.replaceAll('unmodified legacyAuthFlow()', 'untouched legacyAuthFlow()'), compact.replace(/[`*]/g, '')]) expect(regression(text)).toBe('plan'); +}); +const negatives: Array<[string, (value: string) => string]> = [ + ['declaration missing', t => t.replace(required, '')], + ['mandatory declaration optional', t => t.replace('regression rule, mandatory, no decision needed', 'regression rule, optional')], + ['no protected legacy target', t => t.replace('`legacyAuthFlow()` before any change:', '`otherFlow()` before any change:')], + ['capture after change', t => t.replace('`legacyAuthFlow()` before any change:', '`legacyAuthFlow()` after any change:')], + ['no parity oracle', t => t.replace('This is\nthe oracle for the parity suite', 'This is\nunrelated to the parity suite')], + ['different parity outcomes', t => t.replace('assert identical\noutcome', 'assert different\noutcome')], + ['only one path', t => t.replace('both paths (flag off, flag on)', 'only the new path (flag on)')], + ['baseline task missing', t => t.replace(baseline, '')], + ['baseline task renamed inconsistently', t => t.replace('current legacyAuthFlow() behavior', 'current otherFlow() behavior')], + ['task file mismatch', t => t.replace(' - Files: legacyAuthFlow.regression.test.ts', ' - Files: another.test.ts')], + ['parity task missing', t => t.replace(parity, '')], + ['parity file mismatch', t => t.replace(' - Files: auth-parity.test.ts', ' - Files: another.test.ts')], + ['parity task one path', t => t.replace('through flag-off and flag-on paths', 'through only flag-on path')], + ['parity verification missing', t => t.replace(' - Verify: suite green for every row; becomes the exit criterion for TODO 1', '')], + ['modified baseline', t => t.replace('unmodified legacyAuthFlow()', 'rewritten legacyAuthFlow()')], + ['baseline verification missing', t => t.replace(' - Verify: test passes against unmodified legacyAuthFlow() first', '')], + ['baseline verification neighbor', t => t.replace(' - Verify: test passes', '- [ ] T99 — other — Unrelated test\n - Verify: test passes')], + ['duplicate baseline task', t => t.replace(baseline, baseline + baseline)], + ['duplicate baseline file', t => t.replace(' - Files: legacyAuthFlow.regression.test.ts', ' - Files: legacyAuthFlow.regression.test.ts\n - Files: another.test.ts')], + ['historical ancestor', t => t.replace('# Current reviewed plan', '# Historical reviewed plan')], + ['source owner', t => 'Source:\n' + t.replace('# Current reviewed plan\n', '')], + ['quoted requirements', t => t.replace(required, required.split('\n').map(l => '> ' + l).join('\n'))], + ['fenced requirements', t => t.replace(required, '```\n' + required + '\n```')], + ...['If approved:', 'Once approved:', 'When approved:', 'Pending approval:', 'Source:'].flatMap(prefix => [ + ['conditional baseline ' + prefix, (t: string) => t.replace(baseline, prefix + '\n' + baseline)], + ['conditional verification ' + prefix, (t: string) => t.replace(' - Verify: test passes', ' ' + prefix + '\n - Verify: test passes')], + ['conditional requirement ' + prefix, (t: string) => t.replace('**CRITICAL (', prefix + '\n**CRITICAL (')], + ] as Array<[string, (t: string) => string]>), + ['changed legacy before baseline', t => t + '\n## Current assessment\nlegacyAuthFlow() is rewritten before T4.\n'], + ['current task status row', t => t + '\n## Current assessment\n| T4 | Withdrawn |\n'], +]; +test.each(negatives)('%s does not supply baseline coverage', (_, change) => { + const changed = change(compact); expect(changed).not.toBe(compact); expect(regression(changed)).toBeUndefined(); +}); +test('owned current scalar statuses cancel; whole quoted history and foreign tasks do not', () => { + for (const owner of ['T4', 'T6', 'T4 baseline verification', 'the legacy regression suite']) for (const status of ['withdrawn', 'not current', 'no longer current', 'optional']) for (const [open, close] of [['',''], ['"','"'], ["'","'"], ['“','”'], ['‘','’'], ['`','`']]) { + expect(regression(compact + `\n## Current assessment\n**${owner}** is ${open}${status}${close}.`), `${owner} ${open}${status}${close}`).toBeUndefined(); + } + for (const tail of ['\n## History\n"T4 is withdrawn."', "\n## History\n'T4 is withdrawn.'", '\n## Current assessment\n"T4 is withdrawn."', '\n## Current assessment\nIf T4 is withdrawn, reconsider rollout.', '\n## Current assessment\nT9 is withdrawn.', '\n## Payment regression suite\nThe regression suite is withdrawn.']) expect(regression(compact + tail), tail).toBe('plan'); +}); +test('new controls select only the engineering finding-count workflow', () => { + for (const file of ['test/eng-required-parity-au.test.ts', 'test/fixtures/eng-required-parity-au.md']) expect(Object.entries(E2E_TOUCHFILES).filter(([, paths]) => paths.includes(file)).map(([name]) => name)).toEqual(['plan-eng-finding-count']); +}); + +test('own declaration and baseline verification currentness survives scalar quote normalization', () => { + for (const status of ['withdrawn', 'no longer current', 'proposed']) for (const [open, close] of [['',''], ['"','"'], ["'","'"], ['“','”'], ['‘','’'], ['`','`']]) { + const tail = `This verification is ${open}${status}${close}.`; + for (const boundary of ['\n ', '; ']) expect(regression(compact.replace('unmodified legacyAuthFlow() first', 'unmodified legacyAuthFlow() first' + boundary + tail))).toBeUndefined(); + expect(regression(compact.replace('What\nbreaks without it:', `This requirement is ${open}${status}${close}. What\nbreaks without it:`))).toBeUndefined(); + } +}); + + +test('owned baseline and parity tasks may share a file and assert conditional input behavior', () => { + for (const value of [ + compact.replaceAll('auth-parity.test.ts', 'legacyAuthFlow.regression.test.ts'), + compact.replace('What\nbreaks without it:', 'The regression suite must assert rejection if the token is expired. What\nbreaks without it:'), + compact.replace(' - Files: legacyAuthFlow.regression.test.ts', ' - Cases: Assert rejection if the token is expired.\n - Files: legacyAuthFlow.regression.test.ts'), + compact.replace('What\nbreaks without it:', 'If the token is expired, assert rejection. What\nbreaks without it:'), + ]) expect(regression(value)).toBe('plan'); +}); + +test('mandatory status and owned actions stay affirmative while approval conditions stay unowned', () => { + for (const negation of ['not mandatory', 'never mandatory', 'no longer mandatory']) { + expect(regression(compact.replace('regression rule, mandatory, no decision needed', 'regression rule, ' + negation))).toBeUndefined(); + } + for (const prefix of ['Do not write the ', "Don't write the ", 'Never add the ']) { + expect(regression(compact.replace('— legacyAuthFlow — CRITICAL regression test', '— legacyAuthFlow — ' + prefix + 'CRITICAL regression test'))).toBeUndefined(); + } + for (const prefix of ['Do not implement the ', "Don't implement the ", 'Never add the ']) { + expect(regression(compact.replace('— tests — Table-driven parity suite', '— tests — ' + prefix + 'table-driven parity suite'))).toBeUndefined(); + } + for (const prefix of ['If authorized:', 'Unless approved:', 'Assuming approval:', 'Provided approval:', 'If requested:']) { + expect(regression(compact.replace('**CRITICAL (', prefix + '\n**CRITICAL ('))).toBeUndefined(); + expect(regression(compact.replace(baseline, prefix + '\n' + baseline))).toBeUndefined(); + expect(regression(compact.replace(' - Verify: test passes', ' ' + prefix + '\n - Verify: test passes'))).toBeUndefined(); + } +}); + +test('conditional input acceptance is behavior; approval of the owned work is conditional scope', () => { + for (const condition of [ + 'If the token is accepted, assert the correct tenant.', + 'When the token is authorized, assert its tenant scope.', + 'If the token is rejected, assert the error response.', + ]) expect(regression(compact.replace('What\nbreaks without it:', condition + ' What\nbreaks without it:'))).toBe('plan'); + for (const condition of [ + 'If accepted:', 'Once authorized:', 'Pending acceptance:', + 'If the requirement is accepted:', 'When this work is approved:', 'If the reviewer approves:', + ]) { + expect(regression(compact.replace('**CRITICAL (', condition + '\n**CRITICAL ('))).toBeUndefined(); + expect(regression(compact.replace(baseline, condition + '\n' + baseline))).toBeUndefined(); + } +}); + +test('required capture and parity declarations must affirm their own action after the named file', () => { + for (const command of ['Do not pin', "Don't capture", 'Never record']) { + expect(regression(compact.replace('Pin current behavior', command + ' current behavior'))).toBeUndefined(); + } + for (const command of ['Do not use one', "Don't use one", 'Never use the same']) { + expect(regression(compact.replace('One fixture table', command + ' fixture table'))).toBeUndefined(); + } + expect(regression(compact.replace('What\nbreaks without it:', 'The old review said "Do not pin current behavior." What\nbreaks without it:'))).toBe('plan'); + expect(regression(compact.replace('suite green for every row;', 'suite green for every row; old guidance said "Do not use one fixture table";'))).toBe('plan'); +}); diff --git a/test/eng-retained-corpus-au.test.ts b/test/eng-retained-corpus-au.test.ts new file mode 100644 index 000000000..291748a93 --- /dev/null +++ b/test/eng-retained-corpus-au.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { evaluateEngSeedCoverage } from './helpers/eng-seeded-coverage'; +const report = readFileSync(new URL('./fixtures/eng-retained-corpus-au.md', import.meta.url), 'utf8'); +const regression = (s: string) => evaluateEngSeedCoverage({ status: 'ready', calls: [], assistantMessages: [] }, s, 0, 1).regression; +const required = report.match(/^### CRITICAL regression test[^\n]+\n[\s\S]*?(?=\n### )/m)![0]; +const retained = report.match(/^- `legacyAuthFlow\(\)`: retained[\s\S]*?(?=\n\n)/m)![0]; +const task = report.match(/^- \[ \] \*\*T4 .*\n(?: .*(?:\n|$))*/m)![0]; +const compact = '# Current reviewed plan\n\n' + required + '\n## What already exists\n' + retained + '\n\n## Implementation Tasks\n' + task; +test('a mandatory recorded corpus owns parity against a retained legacy baseline', () => { + expect(regression(report)).toBe('plan'); + expect(regression(compact)).toBe('plan'); + for (const s of [compact.replaceAll('T4', 'T17'), compact.replaceAll('test/auth/legacy-parity.regression.test.ts', 'tests/recorded.test.js'), compact.replace('Record a corpus', 'Capture a corpus'), compact.replace('new `AuthBroker` path', 'new `ReplacementBroker` path'), compact.replace(/[`*]/g, '')]) expect(regression(s)).toBe('plan'); +}); +const cases: Array<[string, (s: string) => string]> = [ + ['missing requirement', s => s.replace(required, '')], + ['optional requirement', s => s.replace('mandatory under', 'optional under')], + ['not mandatory', s => s.replace('mandatory under', 'not mandatory under')], + ['no legacy decisions', s => s.replace('legacy decision for each', 'new broker decision for each')], + ['different outcomes', s => s.replace('assert identical', 'assert different')], + ['partial parity', s => s.replace('for every entry', 'for selected entries')], + ['no persistent oracle', s => s.replace('- This test is also the shadow-mode oracle; it stays after legacy deletion,\n re-pointed at the recorded decisions.', '')], + ['no retained baseline', s => s.replace(retained, '')], + ['different legacy target', s => s.replace('`legacyAuthFlow()`: retained', '`differentFlow()`: retained')], + ['legacy is rewritten', s => s.replace('`: retained behind', '`: rewritten behind')], + ['no task', s => s.replace(task, '')], + ['different task file', s => s.replace(' - Files: test/auth/legacy-parity.regression.test.ts', ' - Files: test/auth/other.test.ts')], + ['missing verification', s => s.replace(' - Verify: 100% decision + reason-code parity across the corpus', '')], + ['partial verification', s => s.replace('100% decision', '50% decision')], + ['neighbor verification', s => s.replace(' - Verify: 100%', '- [ ] T99 — other task\n - Verify: 100%')], + ['duplicate task', s => s.replace(task, task + task)], + ['duplicate Files', s => s.replace(' - Files:', ' - Files: another.test.ts\n - Files:')], + ['do not add', s => s.replace('Add `test/', 'Do not add `test/')], + ['do not record', s => s.replace('- Record a corpus', '- Do not record a corpus')], + ['do not implement task', s => s.replace('CRITICAL: recorded-corpus', 'Do not implement CRITICAL: recorded-corpus')], + ['conditional requirement', s => s.replace('Add `test/', 'If approved:\nAdd `test/')], + ['conditional task', s => s.replace(task, 'Once approved:\n' + task)], + ['quoted requirement', s => s.replace(required, required.split('\n').map(l => '> ' + l).join('\n'))], + ['fenced requirement', s => s.replace(required, '```\n' + required + '\n```')], + ['historical plan', s => s.replace('Current reviewed plan', 'Historical reviewed plan')], + ['quoted owner', s => 'Source:\n' + s.replace('# Current reviewed plan\n', '')], + ['changed baseline before task', s => s + '\n## Current assessment\nlegacyAuthFlow() is rewritten before T4.\n'], + ['withdrawn status row', s => s + '\n## Current assessment\n| T4 | Withdrawn |\n'], +]; +test.each(cases)('%s cannot grant regression coverage', (_, change) => { + const changed = change(compact); expect(changed).not.toBe(compact); expect(regression(changed)).toBeUndefined(); +}); +test('current withdrawals cancel the owned task; historical quotations do not', () => { + for (const owner of ['T4', 'T4 verification', 'the legacy regression suite', 'this corpus']) for (const status of ['withdrawn', 'not current', 'optional']) for (const [a,b] of [['',''], ['"','"'], ["'","'"], ['“','”'], ['‘','’'], ['`','`']]) expect(regression(compact + `\n## Current assessment\n${owner} is ${a}${status}${b}.`)).toBeUndefined(); + for (const tail of ['\n## History\nT4 is withdrawn.', '\n## Current assessment\n"T4 is withdrawn."', '\n## Current assessment\nIf T4 is withdrawn, reassess.', '\n## Current assessment\nT9 is withdrawn.']) expect(regression(compact + tail)).toBe('plan'); +}); +test('approval punctuation does not make the corpus or its verification unconditional', () => { + for (const prefix of ['If approved,', 'Once approved,', 'When approved,', 'Pending approval,']) { + expect(regression(compact.replace('Add `test/', prefix + '\nAdd `test/'))).toBeUndefined(); + expect(regression(compact.replace(task, prefix + '\n' + task))).toBeUndefined(); + expect(regression(compact.replace(' - Verify:', ' ' + prefix + '\n - Verify:'))).toBeUndefined(); + } + expect(regression(compact + '\n## Current assessment\nT4 is conditional on approval.')).toBeUndefined(); +}); +test('local source labels do not assert a required corpus', () => { + for (const prefix of ['Source.', 'Historical assessment:', 'Quoted source.']) expect(regression(compact.replace('Add `test/', prefix + '\nAdd `test/'))).toBeUndefined(); +}); +test('rewriting the oracle before recording its corpus invalidates the baseline', () => { + for (const statement of ['legacyAuthFlow() is rewritten before the corpus is recorded.', 'legacyAuthFlow() is deleted before the regression baseline is captured.', 'The corpus is recorded only after legacyAuthFlow() is rewritten.', 'The legacy regression baseline is rewritten.']) expect(regression(compact + '\n## Current assessment\n' + statement)).toBeUndefined(); +}); +test('an unrelated suite status and a token input condition leave current legacy coverage intact', () => { + expect(regression(compact + '\n## Payment regression suite\nThe regression suite is withdrawn.')).toBe('plan'); + expect(regression(compact.replace('legacy decision for each.', 'legacy decision for each.\n- Also assert rejection if the token is expired.'))).toBe('plan'); +}); + +test('current named legacy suite headings retain ownership of their withdrawals', () => { + for (const title of ['Current legacy regression suite', 'legacyAuthFlow() regression suite', 'Recorded legacy parity test']) expect(regression(compact + `\n## ${title}\nThe regression suite is withdrawn.`)).toBeUndefined(); + for (const title of ['Payment regression suite', 'Current Payment regression suite']) expect(regression(compact + `\n## ${title}\nThe regression suite is withdrawn.`)).toBe('plan'); +}); + +test('a never-mandatory declaration cannot grant mandatory regression coverage', () => { + expect(regression(compact.replace('mandatory under', 'never mandatory under'))).toBeUndefined(); +}); diff --git a/test/eng-scope-entry-ap.test.ts b/test/eng-scope-entry-ap.test.ts index 4cf15a000..2e2dd5dff 100644 --- a/test/eng-scope-entry-ap.test.ts +++ b/test/eng-scope-entry-ap.test.ts @@ -30,7 +30,7 @@ test('every host expands its real bootstrap after the mandatory entry gate', () const preamble = generatePreamble(ctx); const brain = host.suppressedResolvers?.includes('GBRAIN_CONTEXT_LOAD') ? '' : generateGBrainContextLoad(ctx); const expanded = template.replace('{{PREAMBLE}}', preamble).replace('{{GBRAIN_CONTEXT_LOAD}}', brain); - expect(expanded.indexOf(announcement)).toBeLessThan(expanded.indexOf('## Preamble (run first)')); + expect(expanded.indexOf(announcement)).toBeLessThan(expanded.indexOf('## Preamble (after scope gate)')); expect(expanded.indexOf('Reply with A, B, or C. STOP and wait')).toBeLessThan(expanded.indexOf('```bash')); expect(expanded.indexOf('```bash')).toBeLessThan(expanded.indexOf('gstack-skill-start', expanded.indexOf('```bash'))); if (brain) expect(expanded.indexOf(announcement)).toBeLessThan(expanded.indexOf('## Brain Context Load')); diff --git a/test/fixtures/autoplan-command-prefix-au.json b/test/fixtures/autoplan-command-prefix-au.json new file mode 100644 index 000000000..390fe2c6f --- /dev/null +++ b/test/fixtures/autoplan-command-prefix-au.json @@ -0,0 +1,940 @@ +{ + "provenance": { + "source": "AU retry public terminal, pre-tool hook metadata, and acknowledged public same-file Write pair", + "nativeSessionId": "7fcddaa4-44d5-4550-b9cb-ce4e092faebd", + "pendingSource": "pre_tool_use; native use not yet published", + "publicViewportSHA256": "2fae81f74e48f7b76d4f78f962cf17fe1a4d5cceb949edd97ace5198a0e9e2ba", + "hookStateSHA256": "b13288a9c9b733d44732038ed3e92a266550d4a22e4efddd8b776527ebe8f366", + "beforeSHA256": "ee4a915de5edc5955117e32a8ff2e4967da54f9812657c66a5cd2d168ec3c8c1", + "retainedDiagnosisSHA256": "22d4c1d4436ec5e27209131d5ffbae5599e2726ce3c1ee81c6c405a8e550e5ff", + "originalOutcome": "incomplete-permission-stall", + "paidOutcomesReclassified": false, + "limits": [ + "Only two exact public history events are selected here; all 72 were checked in the retained diagnostic replay.", + "Command timestamp bounds the unchanged event filter; exact launcher Date.now is not retained.", + "File restoration preserves the original mtime millisecond floor used by the helper, not filesystem nanosecond precision.", + "The preceding command display has no assigned published/completed/queued Bash status." + ] + }, + "cwd": "/tmp/gstack-paid-shard-a0OkbA/tmp/gstack-autoplan-chain-zmFsqo", + "config": "/tmp/gstack-paid-shard-a0OkbA/tmp/gstack-hermetic-982715-DqIQtk/with-skills/.claude", + "stateRoot": "/tmp/gstack-paid-shard-a0OkbA/tmp/gstack-hermetic-982715-DqIQtk/skill-home-262upM/.gstack", + "commandTimestamp": "2026-09-10T21:44:08.844Z", + "viewportCapturedAt": "2026-09-10T22:05:23.573Z", + "targetStat": { + "size": 7635, + "mtimeNs": "1789077609038803165", + "inode": 65122877, + "device": 65040, + "mode": 420 + }, + "hook": { + "version": 1, + "cwd": "/tmp/gstack-paid-shard-a0OkbA/tmp/gstack-autoplan-chain-zmFsqo", + "config": "/tmp/gstack-paid-shard-a0OkbA/tmp/gstack-hermetic-982715-DqIQtk/with-skills/.claude", + "stateRoot": "/tmp/gstack-paid-shard-a0OkbA/tmp/gstack-hermetic-982715-DqIQtk/skill-home-262upM/.gstack", + "seenIds": [ + "toolu_01BEJjHdBgL3sth2vHKgYSkT" + ], + "pending": { + "source": "pre_tool_use", + "sessionId": "7fcddaa4-44d5-4550-b9cb-ce4e092faebd", + "toolUseId": "toolu_01BEJjHdBgL3sth2vHKgYSkT", + "tool": "Edit", + "file": "/tmp/gstack-paid-shard-a0OkbA/tmp/gstack-hermetic-982715-DqIQtk/skill-home-262upM/.gstack/projects/gstack-autoplan-chain-zmFsqo/ceo-plans/2026-09-10-user-dashboard.md", + "timestamp": "2026-09-10T22:01:27.448Z", + "transcriptPath": "/tmp/gstack-paid-shard-a0OkbA/tmp/gstack-hermetic-982715-DqIQtk/with-skills/.claude/projects/-tmp-gstack-paid-shard-a0OkbA-tmp-gstack-autoplan-chain-zmFsqo/7fcddaa4-44d5-4550-b9cb-ce4e092faebd.jsonl", + "editDigest": { + "version": 1, + "beforeSHA256": "ee4a915de5edc5955117e32a8ff2e4967da54f9812657c66a5cd2d168ec3c8c1", + "requestSHA256": "e174ec4a2640d8fedbe97aa27e3c2787d147b1b8af01d165101105a48b0ac33c", + "oldLineHashes": [ + "c8a17a84891597824117ab580d4cc481bb9698cd8d81a51ace72b54b34a8a53a", + "d874b53ed291fcc57b1ca9a60d1507fbd803fefbad3fd4443211e690e0670b6e", + "d7633393873d912c401a8dd1428a31d42655372451af52e98c6748f8db53212d", + "a40e2f60ffa4f63ff45a7e111fbaa8affb4cd7ffcc2e0b30524a06ed870367a5" + ], + "newLineHashes": [ + "c8a17a84891597824117ab580d4cc481bb9698cd8d81a51ace72b54b34a8a53a", + "1b74a1a2305fe2c2779701953f62918446657b3be114175b4f19e71b1ef78b3e", + "82cc5d4e8fd5a9adbafed900c99e1b6f93fe6fe79c1309d7951b728b27b66f65", + "e0587824320aae5582b9fd213baafc0df133c60b0692a954c6c1e3760394495d", + "6d28976d6a4076a08deb9593e28aefda8b0973ae929e5eb7b157c4b18e99a69c", + "44463911d29525c105445e219ff2d4564fc5e1a802c377a79944ab792271afc6", + "707d97569b65c74a0e3a451908e0c5d81dc598d8168654477e4d4cce9d70c3f9", + "408723e1679758ded7174e1c031098a3f87901175dce9bb0c90fcf35735ecdbd", + "414e1c6c29aaf55f2caeadc97845e40e62b80b8d4caeccad14fb8035132ebe6b", + "c8e02969b2776377290185233fa1e4d3e911fa3652397c012075fe7c7073d315", + "0aa487e1deeb804b33afb0e32842f46bb33fc9e40e4a740be2921e0d986e6a22", + "1855357059e6a2a14a211a1c91000807ecc51e3065187ab67c7d633eaf133eae" + ], + "clippedAdditions": { + "version": 1, + "status": "complete", + "startLine": 96, + "lines": [ + { + "line": 97, + "lineHash": "1b74a1a2305fe2c2779701953f62918446657b3be114175b4f19e71b1ef78b3e", + "nextLineHash": "82cc5d4e8fd5a9adbafed900c99e1b6f93fe6fe79c1309d7951b728b27b66f65", + "suffixHashes": [ + "ab3aec57b44f30c90eb9b79e6119250c7823e58f4c12277e09fe3c13f7dda092", + "80ded7be07c12d5d0facc090a2b1f3397bb251cd49a235f7f03bfed9d2af4924", + "a2754eedf0dfdb0c7329e5a42f8a960b34152efc1bb12ace4fd8cf024d03a9ba", + "327be43ffe414dac4c4a3d67cf0f6d6fd252c6341570c6f152ce29aab80e736e", + "0f109444b10657db12db968ad9b80f2850cda1a071312e4e887240694390beec", + "fc2b7a854eaea0a0b30694eb43f042251227464e3974dc58cd29da50e42beace", + "b6f4682f0420801ed709d499d8e60cdd92fc2d78b59c37f60c77c210385c2cb6", + "022ea3211701d20527ffd74f9b0386412c8b769069fe8c07370414e7c4a0a049", + "537e2c59f3c31f6dbdf4e9a678c409ea48d075d0d1557342b58b2a7ffe180d1c", + "025ef9a895ef97a78437926418e38c8705102daf2b342d8a08fa12a2393a2354", + "698d89b6e9f0d079d6d7c5bd26ee9e0e6a0d0e09076d51e9d9f960dd20551939", + "305293249d1e0f04295631d6bdb3e01ccf02096ed1e64257d5c4ffd6a22e3b90", + "84d2e4e25744bef538847e17f5c9280395743fd65518336a9b795e89ed660857", + "053037ee393365898e3abefde9a560b2cca3f016f985e884549f69dff2c76661", + "955558c32eade14884f631ca4983704745daeb1aa988ac68c4eb6953c762e5a9", + "e79e8105aa87ab4f18b5334e3afd5cecc9e2b12093a150fb317e2002d71bbfa5", + "0b09f4ded28b7e2c7a2b9c027d96f2b04fe0e8aa11260be3403ec5133d14e5c1", + "e64de5b37b0511a5c18e709ae4a9eebe79f2b04cfd65cb05a86348d3a4e43c39", + "06d938cde301761790d32e35f5a25d95fd0b8469b397baf42f3edce6d0218b44", + "a2e5027c014b2ad9b0a9ae0591b2cf56fc155d0fe15100442b72b4b7c87b3432", + "f9fda50eeb722d1db68893516493b6fd0b9e01aac3473f056ecf36f368bd52db", + "62947a4530af56ff4e0511014e6a38c36f80f509475d7d03a9b796512f979088", + "3ee6d1572725f7b7c0a4e868864fc98bc0389bdf31b54d069408f8724d1bee02", + "75a37de01189a0dd3eef5b7ffd0973d8e3c8ed1d05c78d7280a140b37e995469", + "25a78db902fe6997bd99044393dcaa3de718e307c2d4a9ab97ce213ba5ec085e", + "247142dc5ecf73222d3830bfb4b8d1b2781c13626eec2802589c96ce1be0015f", + "743e699f59dcc8e5da83d7bf4caca1bc00e70f31fd24cec117a846991880d13b", + "3fdd4d6e0dc3d64dc6af6ac8323089fef42dc7b146fa54c8062104c82b62e454", + "a51ef9d2c32550f2e19ca7a691f8836df9aeac608ea7f8715ef1707651b628b3", + "326be18b3aecb8a8b8966c11066b576b82de8e807213157a2bf88927754266b4", + "40ffa6740b96d599dce3052a5a73b568c9b76e46f6f556b0f1ec469c72a8f401", + "84650abc3198487cde5bf25a3b87d51422b28d8863436441312296cbf76aefbe", + "4315655d90d32caa18ee24717134880e297fe1b4fae08f4790e4b3fc5023feef", + "c3314b408d73f0a1456168e6b52d2c1761220922400d944dd24bfad9562d9fa0", + "a063b6578179f135c4bbe3e0c3e849a546883776c1549f1aa9b8140d21cb6ac4", + "861c9996d34235ea14bd9a19f6fc672ff26208f82a74ae185fc58ed66c1849eb", + "948d89e150096b1584484a6a4622cddf9935a7ba41c418283650c7afeeab8621", + "005a06583ced56f6c4e234469c4903be6ff859ca497d9c77a17e65ddeb2dc40a", + "f7b4865f548bb062b8e3a16ce8738cf2beae7baeedbaad103155fd648f5d2b27", + "d91e33b7cd563734f264956756132b8c78feba6f2855e7a3ac4493b7da08b9c3", + "c36feb3234527280f5cdf308172293bca801b32aff27d21f8748e34e68494174", + "8f9c6cde8467f8f13900a961d9267807aad3201b9977dc46c18edf79eb85df10", + "1cb109c5ad5584a3c97ffa5e68a325ef284913aae6ca90c56d3f0ca358958b5d", + "162fa7f4aa955c13a09a7f87b3ab053dfc602db53b61a604fd9c58558f5836d6", + "3f02ab71368ed0161315880c7cd0eef75b0930c75fa71df81d8f8a32cc8d6158", + "f5e8bde7e8eaa4a269126be30e7376462e47205af5c8bf40105011fbf231f5c5", + "8f729021aa4af933e925e78c5a181df014b41476f8dbd7fe6ea240c83413d285", + "d2a6fa8b54677bfb7fd2013d48b73e1a5236ec9a2efb20a509e5dd903decb4de", + "080ce00a7997cac3828b8ff82c42f2ec655c7cf9b24b3fa833047f5875dde316", + "365a9ba255b2e32169b8f8f2eb50151280186ef9d68911c0c6528b51e68eaeb2", + "678372c3e10ddb4154f5dd4885ab401c21652015c520440f4be58ee49c0be5c7", + "d5c64bb8d7a475ec0dc64f2ea7ec117c08ce95647d8548fd4e8fec08893f4619", + "8e5db7aa1bf6f6ca826805ad4487463148502cd8271ae18f87cf7e359ce0c570", + "f63c5c6bf3dd061ec0b7a07e975356571d19dafc51d24217df7390dfe6623eb8", + "7344fe5fe7e7179d3dd6fd522eec7b4c9f32abd687dc9a7dd21db148231f5b23", + "a9822cc59b35f9fd5447750ccd3de1887e5847125d819ccc7e401358f828b629", + "202839eef5199545817ad8e2b2be755e9ff129bf8618109a5c42df29437cdf5a", + "d5d0b99eaf66c78d2f54d4610fcb4e3c1f20012215fbe0259f1b47b031f37855", + "5b5a642519de36c2c969b4a0ae53ec529a489e9add5860734dc57e7ffcbd306c", + "3845d1f7322ed1ce36d6c77a87ae1434e25cf23e4cf679963727256ada09c791", + "9b566cf8d005351f09715c4aa64a8ed8d408146b954d708fe9321330ee1dcd35", + "732e0e027561c2045b83cb0431973116960ee0a45ef65af4a1c5091605ffe4f2", + "305dcbcb8478baa0df79070dd696c1aa4f53d34564361177a719e8062bc3292c", + "eacecdcc85482e6cdae4b86ceff4f1954ca58afae4a1fa14c54f6550aef5d9fb", + "f9987a67fc73e6b519645c8fd2ce2745772791807f00c4a83f06e4f65e3654f1", + "6cf1775efe9374b6c58373f968c24d9be5e10903202c9643778db9b6a866763d", + "c891ad6f578822a6bb2a2373d693600888fbfd9a8de347e4097db3757d2b7d5e", + "4936078de2a563088792c0c6b0172aee36b84d51efcbc29ead6e9d108099b9b5", + "ec95d606eadcb4e52bdd72169f20fd1b79ee538f1c3fe5c378f360d947c5a1e3" + ] + }, + { + "line": 98, + "lineHash": "82cc5d4e8fd5a9adbafed900c99e1b6f93fe6fe79c1309d7951b728b27b66f65", + "nextLineHash": "e0587824320aae5582b9fd213baafc0df133c60b0692a954c6c1e3760394495d", + "suffixHashes": [ + "7c575afa225a2df58e6aa37cb5c1f95fe4db6400ac186c2ef5bffffb56ccab1e", + "c38afd434a6b429214dc9ebc912458151e3121e229394aa5a06afa640ed44589", + "5056b992972618ed275c2afd4b5beb675186dc13be1ca1ceda9e8c1234fec157", + "312ecae7149323c4f39aa8c5aedd8052a3bf1fc1ac0d480908f0b938184b4598", + "dc46a89b11e519b958c497cbdf09c83e481337bf267578735ffbe943b77af7a4", + "991b04dfca0cdae3bc03cbda11e659e45c6368df0e1c09d7cc7ed72e3bc49366", + "5e9b7a17f5231423acbffe533a5ecb935a1957fabe213d27e860b39fa72bf06f", + "71c2f5894d5790705addbb699aaec8319218e594b7faf680696b3a3fee858940", + "4d12c035feca49201fd825b1c6d5f0ca37e6a715045a02ecaf4565eb04d89a47", + "e77bd972bbdfb7aeea348a1ac6f219443511bdbe832398f491999101e916e807", + "64c4f39ee34a9b1f8e3d5717abe871b80f1d510f2f50608a933bbc8c815064b1", + "556d61b0db51f078ef26d4940bbc3a8090469c86e6f2b044a3b5768577bb3188", + "2bd4a48170bc63af8069797f252405084a0e11a33eff360186622a8672af4db9", + "af4a9c2334c182595bab8973c02808a758a18428a6675656b95b1d7a89baa358", + "75f348e55423a129a996bde7c1d07bdfaccf5119daae228159073a6cabb4c0cb", + "a36657d35bb9ba715b4b47728fd2a9b2b48f3ca75759b919801f3c8c47aa1eec", + "ed9215c3aae945de363358e5f88fc9b175ef490c0a437c7aa6e1c0f9f103fa11", + "bdece1a20c1e3d6c4cc9a1bfe6c5855dca901faba29d772bfcd7ff69e7dc37c4", + "2c48bf32321ff28cb0cd42af34e7e9499a2780b5a7bcae45f540f06ad047b553", + "4ab3c6c74459d6869a501b043dd4ee832b2f37e31e199ed92a1e453efaf37331", + "f35504e505c0fadac12cb1dd74442d821b97a50dfcbc8f95f7487e9a87514d48", + "741d3368d0590158d2c9137969943d11b5d9f5e9b4de646683b4f55a9f5e519b", + "9fa3615ff0c1c99666aecda8f5f892a837eda867dd6e2e5db58ee364af20358e", + "2b8cf034372bd5b03d3d36df95148df34ed856c00a3a7ee620e0ab9beca8d6bc", + "f8d72202e1f93c25da283724ab9ac685bbe787efbc6653fa2a6f175cea5dc09b", + "c585a838715a235f73eb96dc88a9d055abbc81a1e1ae153520a36f37118ad6b2", + "9bec0e7805f2d013f090d02db4b562f7129e8bc44498feaa633cdddc00f861dc", + "f12c963dda889473b79ec2f6336756775a8cf66007cbdf1085833e6aec8202ff", + "4f6641c764a7555dd6e8515b28e66b6f219b63094a3c2a54afdce35335e34124", + "b33a47d295805d47de774f62743f9ef2bf21967486fce7ffbbefba0fe2ae6e3c", + "b01b08f010e38b76cc863155722c3d36eb7b6a302635105f4c7ec102482398a0", + "c2275c5d50261c3d96bb4da019a127f6e40524ab64a3993c0ef55a811f79dd74", + "217c43c33750be5a961b8034967044cbadb6f40aadfbd1bf34bd8491b7ad60a8", + "540082c83b7ffe382ad1ea41aad6c323f9fe6dd9c46fe10b1d6456ddd55692fb", + "f4802b4949e896d6e1081bd13adfd6408db65ff0f99a6906374aa04299a8be82", + "160919f7cb94979328b199f28a89b775492e189e8bf383b7a589f84e87415c37", + "56ca7bb13d473512f4a3dde2323a8918900aecdc149e1db1aa18f8f15105926d", + "a8159e05453fc7c01afa4b3d568ebbdfdc42008138a9c0aedfc2e936042f03ce", + "8817fab88f99daa810f1af4f45d126cfa5a625c4ab67fd8fe3932b6d1a8baa72", + "8636eb29a21ec37790caf07ddb84da33da76129b7dd954b1f85e0212dd3b4a31", + "62ba62ad32e8df1a776f9b6f1405c475f7ff9b0bf3903f5cd5194702e14ba828", + "b71e83c3e0e19fc695d5cb922811cbeecf7f08aea06f81b0b182a9bf3c9a8cf9", + "26ecd90426a17d0187201d73f653803f292eb70f9eb3a8fc3d3bae2ecbae0bb9", + "e5c86b5cc88947deae60288dbc533deb519c1cad2046c1bc7551a3be520c0a07", + "ca0d119d2c32ef343d2b2b935281b2584f9c041e4df59e215ecd8778c28b530c", + "5cee4753cfd6e0a9847eaefb02b8778c8400de08c316f7944664b199ea3030da", + "2033a7123f81e8d5acd4a13f74493a32896a1f9042fd29f2097d257b7e1f0a34", + "8e5fe3b0f2a5624b958bb934b7ba013bd49973ed806ebe4787524ab58067ca4a", + "ba4fdad44496f0a65ad953643c3907e1fa91e80e453b06925bc4cd61a207e214", + "bb44eb9ebdd1d99253e0bef3930521104c30d1ad261273e7784641cf35f15d24", + "7c4a5410ec44519c112fe035d9141ee297a3ab1a8b7cd0ed3aae89548bb9338c", + "981396a39856b5fe913a1deea98ddbcd621c4551d39428350f59c3008cc919b0", + "f02e967ca8b5c0090834b7a1876276c3029520480fc91c0262309c4a9b550650", + "1f7c4633bc2e733dc8e9ef60182f5872a8109fc94a9451350e31f3c40d63fdf5", + "e386eb3fc2fa79200d02f22dfd537bb57f523ce6a40b7deff82efb3c8a5315ce", + "d1e5084e4b70da5ea643357ba3fec799e2d9fe6ee379137171cee482468dccd9", + "e889bae88b774911ebd9b42680a44edb04f87611e1788e9f7d6be13017300207", + "9532716eb570b102e953c8ae19b1b25a88f315915ce13b40dd0eb7ae536d83e9", + "8f9e86e838c2c4038ccac429b3083250eb5a2c49d0b53083155904f13cd75133", + "cae28e256631534a3bbd0d3d2556ad76642eae4bff1311124df0ba31881fd557", + "64ebb568f05bfde7067822fb3e054fb0883233de88ca78aa6f53d19915eaf6ac", + "b37cee97d40b5c556d203066b4be61256d9e748592ecd35e5cbacb77be9c5e03", + "8312037eeadd15fc82f1bdd2a7b7d64db78e222cace049b0586aaadad9a69c2b", + "7b31ea7ba97e0d074b3930a30b8342c32ebfa06c5d83694aa56ea41091637ab4", + "e09ef282f803c1931478fc26d9a62dd79e7af9c460bd371724f3816aeb434e52", + "431cc07960e321216db7a1f74f33d98134812679466f6817b08585418e00800e", + "b509388a86358e763629ba35d4e2766bb4b1d84c9835a47cb4563c1499e6866d", + "02b1b14ba148abc0d3d24acbad06ca48e72847b3839c4c8f94dbb9a2987ae679", + "11a73d5de71c57e1ce6ae5bfd70255b2f9c65cc8debe1b8d55094791399ea02c", + "949c6b6ea9443b71525292dd20d18c815994b4539c791bcb7beb2a385484b0dc", + "298ab72b6d1defd41b5fbbc409996f2eec7573cad18e09b48629adca45448bb6" + ] + }, + { + "line": 99, + "lineHash": "e0587824320aae5582b9fd213baafc0df133c60b0692a954c6c1e3760394495d", + "nextLineHash": "6d28976d6a4076a08deb9593e28aefda8b0973ae929e5eb7b157c4b18e99a69c", + "suffixHashes": [ + "966ca7571fa950d5367fc8e620edf08b15f99a2ffae6ade95744f120686f59d3", + "df3ea7ffa41c7cd2ef6bde0671f0393f30bce96971fab3c976517bf1aa3a0bbd", + "fe3b8186005bd78d0cf389cfd87848501dd6f478f19f3dbf92d9099aef7ce4b4", + "af38f3435620c7c165543ceb546ff6158de38398d1848e201075c48bbf0685b0", + "d5b0aff48c411b205956eb365e9a21f4cd81da20f9e7ba17940253c2c76422cc", + "5f74816eb63af8839e05dc8a920c91ca39de503d2e046ff89ba8049e62fb21d7", + "260fb7474b8e3b9a21a91d76d6c8705e3d82a28d7f6b7fa7bf1a66380233f7fd", + "404bc57458afb0967fc28049d769f9f9e92128221be521b2c5249c9f58fa0edd", + "99b0adce06c82d57f68c5ced678a7c29b5ac85bbf908a4236c13d32802f17ef5", + "b59bc1f3249df4569266e3c3019d7c02157c2b3c0ccd7c7bd06e5294eb379e33", + "543f299c0a1caecc64002e7e55cec0a4f3f5cd58fa7d302dcf32c63f00be99bf", + "0b14eb81b710603b7a2966413fc6f025ef1abc58d9d5bad59fb9755e76f42550", + "7549ca4572964d046ab8550db7dc6e9269c1f6ab5fca31d594098d35cebc24f8", + "a400e16186cdae2d716634536de44a83f6995d9af8efcf129189649833fcb08e", + "7f7039bfe590869d2d0edcb3184191f4cba9e8ebaa52906d5e600b58bf96b73b", + "bd32d7912aeb98d7c6e37086a34a0698a68260537ce7add17bb111c5e95e0ea0", + "f6f76b3b2b3cd36ffbe24c1e17ba5dce30010b20e0351858417465642fc0d95e", + "5b67495ae5641b98f6d7a04a2f8231229f974b1254666bcc459e26336fd74fac", + "42b8e45dc36d43a99dd9e6544ca895fb1d9b39322d3c5854abf3d29f0d407357", + "5c63da0787e44b5a5255f5816773c56c32f7e34d775d4c3b91fc71261231c851", + "bc9b1a263ba60fb1851be26af50a02052a2afbc89f94396c5cc6c528cb9ff8c7", + "27ab288dbe5bca12658aab73a38bce659ee44b814dfcd32b4928f50796d0cc3c", + "3152876155a54d2ab4bfb95e42e125ca7b0ae6a144e8a5c47b94d56d4819cf19", + "b31b172acfcf7e2fda460a59af893dceafb740b946f4a317d186baf7b9074066", + "b96a080fd2f070e130e750c45692f2f884593f6cc8ff5d927fc55877224ad046", + "e7c2172b01a7c94a5ec8d288b1ce0c366d9ec821dac20401a13e00e9eb8dc0a8", + "daf3ddf203d22e2bc9c8b118caa79da8afb7d85da13de688a6212b34eadc7a41", + "8898bfbcc3fe07ac138c82a7a5d57ebf73ce8dfbcf4d1f253c0f394496cf1dd8", + "2d6644240e6ea1a0dce06ab17bca5c92440b761a3210553a08bef2c7738f178d", + "ecef198d93ddc411835fa479474490e8f5bea3f73b58f2a45d9bb6b2114d3ded", + "cc77ec13b43a60ea50e5b4e77d87799945887ff1371144d6138fd59b7e88bc63", + "44a1f127a813f5c8ddd7f99000a2429397b3c8c10281e22b85c93cf78825998b", + "eba448011aec2dbddb8c0a1bc6e22e9c17906071e0ccea7c3666f97888d86c39", + "5d0debfb905742801a01e7753bfd354fd0073cde7a30b4943972a5930351f363", + "93a38d9aa159f3eafac2c9d8a07721188d939af4a310fef6da396ecb6d0dbecb", + "07f40442e51255de7ac14733bd714e84e79bd1acc1d19ddde6a4f8b5fe7fe05c", + "4d98bdac15ab16f9c1ff78c96bbf9eac6c7164f81d0ade2068076bf72e44a197", + "4bbaf4d2d8c0e76d04af2ae186e04f25b35885f59e4205f38a4f210baeca9601", + "7768fc583a934b75cecf8fc7ad527448f87b3cc8d25f4edbcdcc8b56e21443ad", + "2f2c45ec1d4885bb53dd60eea5ef26a727bbdad7d3ec4c025dc970d6ab8fa955", + "997e887a11bd9eeacef1959214c7acc786000e2569034565efb124f16eeeb0be", + "6c1edff82524b4ee69f6280158a2d4f675dae5d884b751fb6d1454297920d334", + "4c6c7f4624d53ab7f862755b85b8f57cda4cd2c4b225a036ba186dcbd8bce120", + "4c4f7a851a459bb634a560dbbc83576ea5f048a5ffc3449143055bcbd15bb6a6", + "cbc792a30a34520ad24ac021614fd51e6dae82930afdafce746b50f332584ed0", + "201512dd2d6a57f7c05e7839b0cbb64ce7e26b966871c45efe8a1acf070225f4", + "d1c4526b6c1c5a83e3140af5aa629aae0e2985d889c05c2994ecc2bd905265a3", + "327ab28e03653895e2abd81b1f2336fbb9f9de50a4c51a734da688d0597cce45", + "4d5d1b377e633997f48377f4b646b53fde7cce3cf3885127f33ff90d9b4f25b3", + "0883f098174cfcf03dbba6d8abe9a3dcaceb5cc5ad098760da6285a36159eebe", + "8dac9227a4c8c7acfb122037b187a256395865192eff47f3bf91c28451accb06", + "572578cc687b8f4e8af440263b021103ee112d7de09b8feda8ad1d62ca6baf10", + "68394e63ef268a142141f1415eb4a489d4052c92969f098bde0af2d6205bdb93", + "70f631561a87317b526429bc7d19c3cfac0936b1b7d33a58a4623cce2f5fdb0a", + "c69218b23c8069f4b4e192943c6d0bf23f9f168da3508e51f81a594bdd59b4d7", + "2463b6a679f8bcaa0362c27dea31f0c269c4270df6944aec6bde08b42a5c6fac", + "e5a1d9dc88266c8f8b7d2f216ec9ae26a60ed4be7af2e45bd8504dde16d76dcf", + "9407a6db78294a653a59d4816aab78666785784f81ef768c48f6c787a70a25a4", + "2cceabb6fdfd92497e34bfe885e9ec9f2195e17b8ac0e0fd87a17d7d3cc7ad78", + "6573c0fcfdc14a48bfdf55075eb935406ef8a3f4f81f68aeda87268ddeff2fcc", + "418584dd6c852b1c24b8db07e7247f51c812e076690ac7ef152c4570a4ed8964", + "edee6e5d5c239c50143afda5539faa1373c72e77a0d32502b56aa4eae173bcbf", + "40f1b6faa1211d92b2aa34bca7a48de0024801ab37ce3b5102512156e9385b3f", + "0cd9eb90489be3194ea26e4f22fd750740e3e2bd1f6db78cb367e8af6e0d7a97", + "8995418058bf19e7141f91f670c200c1d2e12e518dbbd5d569557e5a3bb9d197", + "519e84d0d21f1d374ff284296199ce5a775079a24ee93c1a09827769fc3d97fa", + "cd29e5b2133b5ae3db783a6064fddb24c7bf6cc0f3de019c937310bc8f394ee8", + "366f301e3423306afa4f25265cac01098d70b197a2c9e159da42a257084a2ef8" + ] + }, + { + "line": 100, + "lineHash": "6d28976d6a4076a08deb9593e28aefda8b0973ae929e5eb7b157c4b18e99a69c", + "nextLineHash": "44463911d29525c105445e219ff2d4564fc5e1a802c377a79944ab792271afc6", + "suffixHashes": [ + "43c304c30bc8917f3b2537ca78374ebe0140d359182a0894a013aa3886333916", + "8fd149d4b31408ce4ee14a73b1f2e63ebef74b04b28783b66b6b89bed9ef4fdd", + "7f16a8b73949cff632f51885179187ce56bad89a77457b3915dbea090c5c27c3", + "99b0b559f6c090e1e9b47f3ebb43090cdcc78ffebc10985545696f9be2ddc7ff", + "e7db2f93f804f71c46356ba3db1fbf64c510445994cdc43b5c5c995b6a1f05bd", + "ae842776a2d34517c03d506061df1e1ebd5b486b67eb42f7ede039c5bb5c92a7", + "e6220ef09ec57ca874482414e1034728a8eddd4b45235e23c0f565362125b787", + "7d7339595fd3e85e1f01b8fb46a6ef936e5e1c79826ae7a415dafacf4e4f671b", + "dc008cb632b9c31d1da491d633bac50544e89cd6dd257409688ea3328153cd1c", + "402a4e4a5830f1af69c3b5e75c26b692ea2a1df6caf133670da64991678b0ff9", + "695e752279fa60618657a8209d4d07b4e492037d311ab49ceca17d3a102122cd", + "592706372914fa25564c17fd36184a03018d10746dfec4e7bafb430fd71301fb", + "2ee3db1dc0ef0080c9ac5a773a38c32e92d7fbb3aa8d09fd07ede6086c8dbfd0", + "c9e44b0226586ff8ed9e3b88dc9366386901fbfc73d7c3370d00c2c48812ae00", + "394e8563b9a43e1d7ea0282c68ae2b0bc0b92c8369eb25edac8e2b7b247d5fb8", + "3f096eef1934a07dbc8b4ea61ed62b00cc4ec15f2299e97757e8aae9494f7b79", + "9ef59822b119ad723c97506f14411a7792bce1f431e09fb945ff4afe477b26ce", + "79c02fa5bf17357e86da20f0b9547c10f8268e0aff1679c5b9bfa00b36491795", + "46ea19c72076da74c921c36f0cc6b3fb7ccecd93b7999c5a67feb759df538a41", + "945e907b44aa9903ce09461d6460c20b4ac99b6337a5861853d8fe44ace5df83", + "ba87173cf2541db8a9b5030a21b46e01cec04487a8cf46746d7deaebaa6f50fb", + "3b7558384be2ed662bcc3f666c4e92d24e7dd63f1232c5fcb25dd42ffa6d1916", + "9ae59ae4988ee9ec533dfee5d029e44105a818460ff8d9a5bbb0d20e542aa21d", + "3a178251a9cec0da3a1dfe02d19cb89c2a558aa7ec01e923c5423b85971306a6", + "d0cb72a68f8dcb6935e6860c4d347626aac280b82eb0ef81e96f4fbeeb20e422", + "f28c605453b93ca904ede9d0d66e4c52d950d8fd03071924e2ebc40a52720190", + "112798ac9a270e620bbaf3629a70c83505899cc1918d8d1989762f00a4af953e", + "f1e6ab0536a6621be02c84211a7723d79fa0a1a90ba6b4cb8bf428110e07ff5a", + "21bb89577a508ab2360016e8f9f0a40bb4ea33e6637e8233cd5f4cd66f665e89", + "7134726d16e82908d374ed0e45f34c13fc73aa0ce3189b20b03cb87924974f38", + "a9de2a023f7025ad271ef1a902584b43d2ad62d5bb35849dfc3e8b4bde3e9821", + "d0b39fcf6a56146575d845099e5b0bfbe24098157f9586b4b1426532cd77779c", + "82a4360912f3a7a07911ef5766208e5e5e3039f9f54ccab5a4b028155164ea40", + "fa26e24a303063ad253516099ade8e1fb0cbf7052b1f5b839327a33845b98397", + "07b2df670a181b4e50462200e411f07d6534c4fb3f18d1ef3d55e47cc71fa779", + "2ee82fb634f2273d4df731e8bc472c174de4af3286b8eeaa428b1cb41347b81c", + "e4941fe644e9f9295a963acf45184c5545fe9c835fdfbbe6276acb7255048a11", + "647c9dedf271d552014c65b12ac0d00e8b0cd8242d5ffe9ce5f68c9fa93c4972", + "45a45d9b1675ed5cac919e6edde7e58a4cf2debfde242ccc21fe85015377ba65", + "08e505f9dd71d30f9b0a0e5227aa516a80d1ac5555ef3fa60229a6d94134bfbe", + "b153422a41f65571e2f73f62270368b09245622c9511f6d6b376f26335369209", + "9a08386eb02016f64f102d6d7e0735c844490e98b456ce4f45a3fe547606ac96", + "bd9d1abb097924ae4d89c9bceb15d20a83c50d17be50eab2e03021019fc757d5", + "8a75dc9dd4e032a7d734f8276a43ba325bef2b9edce671f65fa613a350c166eb", + "4f69608ed57314d37fcb4e75ba4572ed51d82478b155230b2f0da7175ed9c81f", + "e198d11adf756af32b5d86636bd634b3b19aa9364f59b677982b8be3f032266c", + "67c211234a8c3683857d2a8787a8e4448b4b35593f8eba674ed0488ea8128d30", + "8029a300085b6c64e9867630f1afeb671f14f4ad7f6b1ae011f7c94b43f5fd84", + "bbdf38d2f149c37627af9d59506b55d3a074fcf1a888f6e547ee2907fd0dabbd", + "2f7327f7521b6997ac1408dcb4403c47afc1f06e0918db57735b6ebac05e12c3", + "6843ba638bb2e91c7c40ebdddc79c0527b79dbf866163a9268f9505d2727970c", + "99b1c79b342ad3e05dafb6c487ba317114ae736f02be87863b7a2eeb15c75232", + "375383eaefc63a99d6a3701177efbb7b44f398439ee539a1c80d6d38efbf1f56", + "6e301c555c8584c8dd7570c6447717e688b833ec0cbdfe64b84489b8e67d10f8", + "1b8b04c29db9cf700983331017801bd9e363dee741bd66922b3ae2acbe5cc5ca", + "abe86033f2a84f8c1f977c3cd287370261ff00326b85206a058e3f1c94de99cc", + "6f71b0568586fad837513e1004a85dcaf71381b076738b778414555a189110e3", + "5b2badbe28f7f996a1747c607b9f46e9a36d82d39b8fffc685ae0e8915c8c114", + "97afda7198de5b728ba7879151fede0f2915fc497a023d1019f4931834a4f5ec", + "aa8b78004f142dc70a77159c8faec7091eeea8753dac640a985b2e75413a1f84", + "5f7bd19b814a7c51c21400e06571d993f6e1e36cef876e24096f049e54a2f4ed", + "d72d7b218699a8b020aac8e75f6fe0a8f196681aa3de3669df5b914cfc15b3a0", + "9cbb615d9f7966d2a4600d7ab24072afab200b6b0966ca8413e3d9145ac5a891", + "c0154f718e8b3ee0afda6e14e7c2c0984749a9dfd57a7185d05d5c42f0bc2cb3", + "369eca5c2ef887876e7563f10eb37a710f2da9d9a8c348c4c1819d50e6d2890e", + "46178cf09e441efc4694b05949ace93e263adbf39f4fd4042289d318306673c5", + "4c10ead3207e93f950d0c2daaba1d3b4bbaadb1114598b76dc613358c521c190", + "8e95bbf1fb7ae212bc890b4697f22aa764ccc678392885689460a1791cb9e6a4", + "5d02076947487380eceda4e1a64ade3ed17acc9fda296233d43d0baf7a7eacfb", + "66e28c53e3b625503cfe759e0d54efad20943a6889d66fae490f0a82ef3a5f60", + "11b6086bc20be780df63e163fbf63eb905ae266d6fbdca78b40121ed8dff3ad1", + "30ad9c9564d1af93aa248661e81f346be20902e87e5530a2a3d6c3ff377b77f4", + "d0487a6122a284ef5996cd6c842ab571d2c1912ff313b9e706b82e2ad014ad03", + "12b367cdfa648004aadae8f73211bc5db5b3104e31cec3807dd2079a281d561e" + ] + }, + { + "line": 101, + "lineHash": "44463911d29525c105445e219ff2d4564fc5e1a802c377a79944ab792271afc6", + "nextLineHash": "707d97569b65c74a0e3a451908e0c5d81dc598d8168654477e4d4cce9d70c3f9", + "suffixHashes": [ + "baafb2e7cdeecff8bc071ce4087cf8921872a50d14e04deeb1258fdbd011d5e2", + "db4892c21b8014c696f2b332cbe4c00701a292fba30076b84c9ca0e07b5654b8", + "bb7be71b8867a920ed2c0b355bdb4f0e404fca99c4ddc23ad03ee14f6bc781f9", + "68027b4b4494200cc1519f542b40003832f7be9c162522e94511dd4a63ff187b", + "c3b7a4c6e58c964c197fd65334047f2626e8166928c272b3cb497de2cdefb445", + "228c57d1b51d6dc3f124668f84ad7c3da449b4a737374d98813c1d6d3ed41909", + "c832ce36079eaf886b29a5bfc0bf92746918dc99a0c71735b26fe97956244cb9", + "7ff664dfe5ce92b5c1e606d84aac00021682b971ac17c33d7099c1534205085e", + "4bd293c4bc75fa8fdf3fea2c182c7f9dc8035704eb1ecf89a7d54e63b995b01a", + "c6428d80dc6ae235c7c74509bbf638ae26048d6505348ce601fcb11ca350ff79", + "d22868a3a9aaae0c9b04c69800124cc2d5ccd3aed7cfb4f5245ef8179ba4360a", + "69a4efadac6d2476a15e0aa2d1259e5366dc99835622a11011c284abd49dce6c", + "b8bb6f8850c1424a638450d86fecf7b9c00de4e36219bbcf0e51d7444207fe71", + "025c7ae87f632fb255f36cd78804ad5ca45c2217b5bb7e16d26d1e60f547f8e2", + "0d704d8c817b5833d658315e68ade583a5cde9d4373611344a15142a585bcadd", + "71f80d83ba2f6f4d7e526dd4373cc8de3654e01cbf70d54abc65f53fac6cb606", + "a8a5d2b31a1f7aa91232eabea565f5c58f3b651219c7fe4df15b91bfa73773e3", + "c80a445b02cbb70df121d0b96e04b7aedb3467d44b4be47030abe39f1a24aeda", + "5dd436005cc279a994c633faf6e0e7f8e31bb6370ad77167d66321922b792878", + "ba9dd7f0af57622e62a292d8c00be545031398319eda40d779e3f373ce65cb39", + "f756b39f2ac7b9cad65cbfedd6b25a9e84bb061bcd250768acc4129410723e39", + "5d0e903bf2c4ce34640dece30f2da779aca9e03738b07539552a7a6875ab2978", + "e8ff9cd9d0619106ba168b0ebc8c2f284bc7254d6e26ab71a994d5a188bd8e1b", + "ba369239a87e8f49cab51952784dbe764a251d66059cbc23df796fbe19560316", + "4742f69684b4070a00b2eebc584f468b53a6af4ba5691dfff7584e60c5491bb8", + "e8c38ba47584158026edb7bf2eb6b2fbd216b63254ef10e10b8396bfee52c0e0", + "321aa4cbbbcbd2558c3f900eba92dd8f45c76d84bbeccf492d3c897bea589fc2", + "f572f27babc331c60dfaef97a2aaf8128f5d5553efa23bbb67884bc223697052", + "53bb3989a4375119a262914a18640ab2c23c87c21b5051cb9bb12bf37aa07668", + "d0e37aa6787c05fa07319b8572920fba74f860761b12bc9aa3c63ab1f05e32f4", + "3ef2862617e35dde17cc50ccbdda9b5362c33403135bea67207f0f931b03e80d", + "8482fb27aff3f7961c3b2d59a32ce72da21fa786a62abc73f99d12b2b345700b", + "67d02eb162b0a03e1bf7be181ebfa78d8cffc09c4e3c5f5d8bd9432f857cc56b", + "6087ed9b29e9d8372690433e1902b0a90178bf48b67f7926d9fbbb795f920326", + "a149036c65739eeedde122650350877f1a7b41563637a11f7831d70bd782489c", + "079ffe4ec963cc50335b58eec404c08c76ea17f65163f316f1c0ec7abb5d42a8", + "9b6ec87ea8adf954abade2373c40f1ad1e8d41faeeed4f14a9cb590c48cf2055", + "94c627a2da436a71b4bdce89dc6487618790dd0d777a538c4956f3f691765475", + "534c68ee2bf1d4e29e4786e4e13194192ecfd0b19e252d851f95df7eda39c20c", + "82682fab8b827abb92672eb10a54005b208b50bdbe2958c2cc7995af1e615749", + "59ad0d1c27ed4ed69bbea3e671ef669ce8fb11c20cbbd01ccb4632a0428fd3a1", + "f60307f300ceec2d2dc378f285988396a900b59b6305271e35d2dd6d708a0c07", + "377838c9d618b37245fd6289750bb9644d15b27c43baa982be25e5adaf73b517", + "b94afa34e4438d1b44706b3b952182ea41f098f71a27f571ad478d3e474b7920", + "8b5e3c240d762af855c88f82a613ec4dcd456d3557eaa422a02379fead7aef15", + "3977d76b7a3aaefd3af4f61f5072385aea27b52c9c247a372ad8e30bc5191024", + "94528b9e1798811a0de2ddd0a3b383d767b3e6e97cb152422c902b4ce6f95033", + "18ad083a4524d18f480b9580f75354ea20a393df85a70bf888315e85711d239e", + "143cf5c811f8be8fc9a955258f083bb9cc517cee97ad77e74067afbbc3c4d55d", + "72929cbae049e3431a32b7cf7d9bb8121a7f3743a96c58d71c9b9ae2c50691c8", + "2bab9a93935835189cb7eb76f671094126a761d91ebfb0d5e35f14a96fc813a9", + "629045849ee08b633ff6364a61c9c06173e240efc8dfae9af074a0904017f5fd", + "c20262518e001aca2325c6a68c68d25525ef37b686c60a7fd605fd2f4538f3b7", + "b7a132da159a5b51e2bc0408030baa3174af229d3787a65894720b728800c8e6", + "493bbdb7378bdd05593822064176ed8231b30c5f9002e9cc1d40c07f53419d5f", + "0dba9fb9b4969edfb3d7521f8c17f59d6079ccb569213e3376aa499df8293e35", + "bd4ffb6aad05620089e7ab181f9df4458f4b74bd120380c5c4775698e0350b94", + "2e2f8cc03fab7711cec57f961a5e7e52b62653aae725c6fcd2cf7f37d233335d", + "cd06d43e0a8c59cf2d0d61273ab84d90786abac35d29b4dca38d725035f0545a", + "2e3795c897f30aa5fc51e831615457e4363b7e56252246a179b865788ea661cf", + "f0a4fb284332ab8dae13611066fdae0e6b5c943474e4332326d719cca98aeccd", + "23b9f841538d63e9ee24b60a6b50563fe40649af57943f601e2e672c14410758", + "d41599c2414e298f34b30019f5e3f9bd4f5ac4762662b3d3934fe6361f7f05dd", + "4968441aff5025b2e5a169ee8b7df427f9bb482217413db9f270561d3b559d4e", + "4a68b41aa45e02387c32fccc66697255bf4af680fe33c68074c78de1c996f785", + "95497a6f798a07c85d264d6504f87992c8dd4b7785b96cf362682e06049315ef", + "2fed1da01f624473c889a564f474a59943e24ab6d953257e8414fdd45c7b8b31", + "3337679ce341206020da3c01d0be647151ab5f674cd4cbb74a233a8edae71ace", + "647bcc229829b5012ac5a0981869eb54b71b3954bd7cc56bd372eb7a227a3737", + "19abe3b69ec791b67dcd060c0f36437fd24d5c7909d2052e370817b30ae99129", + "84489c7cf279d2479778124c28b6e8c58c178c034e2137965db9d56e94a30624", + "576cc04bd8a87ca46a8eee849ef3abb60d3657684230b069da730a39292c1d6f" + ] + }, + { + "line": 102, + "lineHash": "707d97569b65c74a0e3a451908e0c5d81dc598d8168654477e4d4cce9d70c3f9", + "nextLineHash": "408723e1679758ded7174e1c031098a3f87901175dce9bb0c90fcf35735ecdbd", + "suffixHashes": [ + "e710f5c6f51db5b8f3c245d7aa46985aea78b7cecff9d231fbbfe1332b0e80ba", + "7eb509b681c314e34f37e9a25697caba3e442c484237ef6fe60a0fa964049aff", + "91393935b5dd8fbb577b839c2d1c0e7b48423bb58432c0d6a9d106f9974d1e4b", + "e6b3d6d9987b83dd4b6c0d52f6c2c30b1303996130a2bdd684d22823e12c4692", + "eb24fde8e32b627bce857d97eaeb0eaf565e7f7806078fcf7c6da032c845bb43", + "4dd744997ebd8546cd26c0a0647975589f823603338e8516aa4e8ccbb4a2eb0d", + "00e0cde541774a5ce4906b59f40bea908afb028b07b3ed603e5b4f1525d6461c", + "9f65c0c12a54bcac31c732c5e34b2040ff7a7ff22861a6f5ebeb95c454a05bf3", + "4ad3cc1de8032f1d4b8be385f4b57f7c3699dee252f07f1bfeb7c9c188c7c570", + "4eaf4d0bfca96f7f2da2608dc428d64b33d868aec487a5171aa2f8d510fa4149", + "2e3c35126e864a4093d7b14eeaeb1000a77c83e2ef9c0f6af3ffcee3f7a4c1fe", + "b7d185de8d7d7e97bade519e6e2abd0d56813c70f94541045102f475f9b09c44", + "9e1ff40985655463b42da775eab91fde7d633cc225f80a53e92d104dcdfff293", + "9b35d8ebafe8e653c0c9189486b792d20bed521cc9f049164bcb858512eb79b8", + "8a24ef2dd84e66e19655f7fe49e9e168087ea5ebf2d1be8a36004ab006992288", + "d537f1897783b12892b64acfad829281c7831ef43f1f574cfe63ffb75b7fd9ea", + "9af2ca4f69b43e68e7259d5f74888c65179d8a2efdc74e4a168bc10b8d91a208", + "dfbf7e33026552cb6b35ffd2cb016c1e0537ce630276c7e44575e365d7d61f8d", + "4c1b2a265df0b75a4044bb2f7b2f9a3456792c491d83867fea9e0e809849f8e5", + "5452aa68294a02c0ceb00c4a664d7a958060f73a08d6c6f21395329fdf81624b", + "246c14552664103c48e1d700910d6f524d81e53d9f62630315eb4747c2be0c06", + "efe4b85c60b62fa436461a5af08f044080ecf27f3ffdb317090f15583fdb39ab", + "161052ab76684c34a78b26996829dd0fdc23d3546c71364e64a3fb96e751f2ac", + "894ae1ddd8b17471fe9144af1365686c01fd8403277637b334b7aeb60a2dbe00", + "3f0a375981f0dceae2b8cd2818ce4d934f6c8cf981a08a9896b3291ee288a8a3", + "016ea7624165576e0a0c24771401fe58130bfd6417129e8067ec55272669a8e6", + "6239db28d58e5429bf5fb326b99a3bcc4648de34fc10e6e238e7f4bc48454c9e", + "6e697aedfe57290349db5fa2a4b0c236af64d97635c1e090e792080640592b88", + "4fa69f84f3f3c16745bc0a46b0d8ed2a47013bf468bf8f31ba5bdb3c45db24f0", + "bdfaf6873267d5e8cbb0e4116de503cc2e053ff7c7f6e67f9da32996a3ac8e7e", + "0f786f162b1399d68c44c6179909c0e6abccf3dea0899392d7f69f07a781e255", + "324ba4cbd58b4fd36d751c6ac96da716086c4ff92d49e5e813147624aa728338", + "688e924614d46d08fe7e6ad5989fb8507a1acec15cf90df61f90d085436f0ea0", + "df2aa85e2c718ebca3b3fe98ba89f685ed018b23e015a9050632becc25a46543", + "01ed65170e024dbd6d7ece341f5eb7fea877a28a338545ad638b14269c15db77", + "6bc889a6f490366a3dbc54862bdba1ed1ad0bc4af4e8f9a8a2ddd53a9aef3f58", + "ff9df569f935a6b0b9a1009f47085424237a189418f65e18b80f991dc40c6547", + "c6d23d06a3f69963922ac79ed100e2c9d5d775d93944cf9c598623d79b7cd3e6", + "0737419b4d86dfbcb194586631b139c12532e48da1ddc018ce2b10added94a26", + "ce061869f00dd9c25368da8fbc7fd8c3983f3d56ae4cfdb4cb4e707ffbec01a0", + "3bfe8e808e5c26bd9e4ec5e33f40302836fcd4513d0fdc9502ae75776e75e65f", + "f309ceb054ab2ce421d2fc8a8dcb064c72e837b248eaccbd8278cd1075144e17", + "631ec75c10e4f4c6b4f54013ddf4f6a470e16fd9ff99476ca3a531142612c9d0", + "51e0e7d11f9d2cfda85fefd92dfe234f5cfa53455515bda85eddda79dc3922ba", + "f720a1195ce661774a35bda452406f67dd05cdb7569d0303b5be43140f61a905", + "55b996d4785537c3c017bb24454aad81419db0700927e70a64ba7b442fec338d", + "6fe42d41b0ba6110252a660ca4fdee319de79959c9235a96b52f693eb2bed7d9", + "2521c5df1e3d6150883c167b21f31288cce81b957f14bc62c5f2b46ccf13bd29", + "9e098bc23f6848cddebf38306d87b62f8abb1b2ae2d41e6837f185fad45deb19", + "926ff745847424a2df8fbecfd27218c97651d7881cc0fefc0f2fe2a223c6ca27", + "3c08e314b397307c13c6d0c0f62e1d1e8bedcd4f88f2d60bde3e31359da7d108", + "c9eb0cb251602806a61ec4f31887935438b1a2a0698f220e396ea1ae2a9ff392", + "25b974a8633f0076278d42941b73738655dd33a93d881b14f9d1b6f277452095", + "75c2d7a37e3dab440f4efc0a4c824c2688aace178ce165809523393b3bb69739", + "727d19733e12db2eb5c5e50456133d5990733c94b2b16b243a7fcaeaf9fe27dc", + "f0aad8ee370a8cf208e5a9648b2e4f91c3f0a19d72d58e40f464a42dacfb22f8", + "4fb06b7bb72c84fe60df1300046e69c28b1c8379a7114940add91be4db275d44", + "08e24aff1e3fcb1c80cda3cda1156ec22869cf286c1ce1ae1f4462d85d9bac56", + "b5a3e3590202fc19a4548ce7500ae97f5739e2bd5295297cbea9ae33e5c091d3", + "d5184c58f821c4170f82819688f0ff48811ac143204b4737df9d60e812ff9947", + "f2e576abcd8d43da8d95f262f17001e47c1d193cab02749e858f2fdb0d7c8ebc", + "505a73e5cd93bf5842c7de6d671c39e7cc071c75f279a899eb379aff3a21d560", + "7453ff348400726187d79a0b8458bcc6c991bfefa77af5f98d1d9bdde8bfd6a6", + "1a8683b0b22e1d5accf1565ee2e451ee8a30b60578109544482f027f8bf8dc69", + "3c583e80d62548c098f4bf6664a5857e64d5a54569e0e79f5448bc7aa2acefb6", + "28993d0ac6e5361782cf005e14d10528cad5a4f1f0338e78f148c0c5824a3698", + "72071418cce4fbfd2390d03963fc6c71bcc08ec5e74309bbb3407563e9534680", + "e7ea881a7d32abedf8cea98bdf95f1eac8495263ba3f7282da766ca2b6d5702b", + "b53b4e181071ec6a53a1723d1cdeea3a67c719cec48c36842e825920453357ae", + "2aeb155f653dc02ad3ba58fd08da2bd48695ab8b5e24bd7a5bda0bc006246573" + ] + }, + { + "line": 103, + "lineHash": "408723e1679758ded7174e1c031098a3f87901175dce9bb0c90fcf35735ecdbd", + "nextLineHash": "414e1c6c29aaf55f2caeadc97845e40e62b80b8d4caeccad14fb8035132ebe6b", + "suffixHashes": [ + "05d41e3010babae240c5f2b30cad1656459433adb4c1b8311f117c76cc845f0d", + "7f5342622df16c95f951f980570ea9ba95005f4a029dcdbc0a64136440a25968", + "b9d8c72ceba2c98c6a26306625441ab5df84160179cc25e1c56b72cb54b27841", + "0d3c70c0e98809c50379008f28e8c536d5ab378f2f4537e14ece30288e0bc597", + "3d7247fd94a7684b68d72de422ddd6311c248319df641a90759de1bbb116833c", + "c09c104dd770dec7f3cfc76e24c07fafab1c81f13f751ee73135f55f24df1ee0", + "e66d0009d8323584958700b3f3b4bd58c79b1bcc9f02c309add48dd42cf7c6c7", + "c4e53489fbab9de602c274222832b9d71a81ba5446a22d0f6123480c9ea52bd9", + "707cc1c61b7a1fbc634c7cdfb9766e7c8db02e7c9a4ddc143af608f36c198579", + "70ab1056fad2a5332b54afea9277ac5fff91564e057019fe3c46ac8b2eab2536", + "fb43f8023b0accc7cc6f1f56d6029a69550cdaa63ef84438ce4e33e970839939", + "1c48ca09d46d567f0c324494f5c7d4fda5cb098dd1dd9e4315ac8c023e5f2d6a", + "883178455bed5eb257104950d00c48a517e0e9e1be3e338e70626088acb887c8", + "c58df6fca821c1874c235f9ff75c182be147268664451b624160207c0f9346f8", + "43690dd08fcd25aef50646f2854fa11d6d764159ef36a44d87e3e456157d88aa", + "a7c4279da86094c115670680bd0df406aa841ced8d6272e003e24e38eac2fca5", + "0a6da42da0f38854c14d8461612dd4e12950bcdf487dfbd7f7355f2dde310354", + "32499a21187863609b297a354668981c9be32d04c85c2f21163e8d653b02c339", + "f18b282de1e3a11d68bccdb4f0a1e697ed877f70a1955a0f93d76bdc8aafe49f", + "389b6fafab9332823efaac760eb294ef1f313971042d6a10beb3bae5dce78e73", + "01413100820135f666a03d8ec6311f133d742f85cdeeba9e19f580b4d5fec3c6", + "d24645cd16c071e587e363f0910063b5db0f59f7db6e46c35f6da989fd67da35", + "614bcd0e119d0922c6894c76d0df36ca1b30d2ec03e49c7fb358a4fdbaa14a6b", + "b1ca51ea14e73b80aa0240e68dacf9d1fbcb4b35f517612cc218b16f166f1700", + "6f10e46c8202940af0c87f1f728ff2a3c5836876fc7815a235cf55a3e1406fdd", + "5352b6dded4caf7a42818fc684143eaf6e1921f51cbdf62aae8737392250bf09", + "190bd6113041fcfbaa54655d1af0f521835fea216e7f4fd8cba59e822bee5387", + "f779e0732db8e68c0936e2faa720806705acb97496612341a65eb4310aba53e6", + "3d4f2eb9e7beb85fda4e1dbe603f7a3c7af4fae90835f16d3dc1c79f5dd9fade", + "32b5187abfcb0f8aa9f0bc4b34b0a10076d49600347b2627f3bbe1f4c623d789", + "cd53ec7cb9ba21f8958c2a4079c218f0b82b58ea9ef487367f3ed33948cdfe5b", + "b06ec61b5a06e88042971eaf92676cdd1845f09b2962f7d602a67a6bad438cba", + "2364b6abd1b11efa96ffffd853e3c59b7e4ca773d895f70552735c4eed3a4df9", + "a7835b23233c0e4c008d56d39a61fc0620731013230623cb79f0e67728aeb83e", + "1a1bcc5b4584e678bf8f5c85ba42848132c5cbaf3176049b0cb87b2d6e7e1c90", + "e1d4062f81e0bb010ae80d15a7720c4b89bd31fd78e821af7c0c6da5070178c5", + "12151df33ca326e9f5ef1d360a301de24102b23fb7cd7451edd50065a36c0969", + "87ddd2d00bc831ae97dfcc0af1f1e24127e43f4cd402dfcbf439d47730a7ba70", + "f34596554a9648eb56770f510934d18dda9fd38464006a9756436b429e78ced4", + "5ea6d83a58a39b5e830cf955b4729a889213ad81d58512d99b20fbbd9205c1ea", + "46a6f8156b47161e48e35620271b67c31a714e36adb426bdf1391cc91a216a9a", + "6ea7afcd37a7236f014b254e34ef4bba7dbee52f6d02586b586bb3c80dd7e898", + "cb739e742266be8e25445d44472583c4cbe29fdb00ee8f88b53552084eea2343", + "9ad18fe71ba72a1026b45c447e2c35ca385980faa402307345002ba06cb647fe", + "2b353f3a7ce5b7e0ea8db62c2b407500981a66ddb60a82a41077a19250b4c86d", + "a8dcbb622bea29045435f832e8a19b0b25acd83782a500994bff4baf03f9b33a", + "46938bb8d43fd7eeda551d9c8076937ae9e7772f682e69afe93856dd664e40a3", + "2b2f4ba1372da1b5679dc7c0bbeecc523f3ef82842deed1247821d42110b80ea", + "95e5702fa60f524007f12fbbf608b3fc669792cf4cd02b1bddeb5efc977a0bce", + "2cf7fb14e9fcbb019c6cb9e43181de83644499f852d77e27f80d52e1c68f7ea8", + "1b6aab26218de62ccf5913b5f93d6049a241f7ed258377a34a5c69ef3442dc23", + "625f9ff33a1c1743496fe44fdb7217b71397ae91bb6a50aa24826714f45589c7", + "d21e27cc3a2f11e188771fc3e7da9ea07c621b8f2c99d60d2fbbd42453363dde", + "6aa0c6d1e4fb361f795508d9e7a88c2808c3f42df1e4558cebb1fd9105964ba7", + "39759a35281dd6e7dce12601819fbaef035a9a29c10c8b86d1596584de9f9797", + "7271dd3ec314d111ca65d914ae42822d2d8d6caff4cd7148dcd775b8d01dab1d", + "bb4a422b8b3ff3275b26f6cd3ed89c076cb3945ace9edc734068a23e64900757", + "bd12f32539a73aff9134f60631639276621556b9576cf74503e1afe3915e7be1", + "1aa1ee1b81742d9e055bdff388fabf7bdd8590aa2dd87e6718090896b7394acb", + "e931fb89f18fee5196bbbaa00c5e9f12015b221e9791fe5d0531aefaeff3d427", + "63651283533d495afeedf42f2678c34ca6d71c4300773d7deff6b71e8213981c", + "accb6ac4a7128649e9f83e6a6ba879a319d7f88e10ee63b6460d04315f28229a", + "bd9a25d8ced185547c31837bdbb45390ffe9d766214f0bbbcb0d443250ecda17" + ] + }, + { + "line": 104, + "lineHash": "414e1c6c29aaf55f2caeadc97845e40e62b80b8d4caeccad14fb8035132ebe6b", + "nextLineHash": "c8e02969b2776377290185233fa1e4d3e911fa3652397c012075fe7c7073d315", + "suffixHashes": [ + "0bfc1cf50a098a20219944425701d2d92a8914a65c0699d147464478f7ea5b73", + "75e7efc1641ad642c260cc200a04a0cbd7d420bd6e5d3b9c212e8464d2f6d8c5", + "ec7865d287d41369ec9dd7e1d99269712d5a4712599ee88486c43e18d7261cf9", + "8f1ae363423d019d9585ebdb2d9a86bc6f5937979e3f81cecb04b4f588ff4794", + "b719e254ef5f929d8bf1d2d739e80925493372b802815ead43e1fe01b646a517", + "2205a2648c1be99f3a306182282426676fade42f068a8e99a70cf9190f52e090", + "7b7f8e7b065e13280a559048ec26604b3517a236680ef7561ff2a9b9bf4f6c4b", + "190e01e3310b90396625f66d2a36e99967b03ae52b00455546d305fe0afcb5ed", + "26d928084cf7959db624c3414dfc069edd3d4a8ebb566e045981d970d28bb5fb", + "be6d660ec356767ef246b4425463fadc2e646b79bf960ab3fda00f6533826381", + "a9953506b4dfd486b03957fa12d52ae5c83c0e2c32c442314fc794dd3c1709b3", + "fb984900a290f0841334155c2038eb538b491640cf526984457edf1c4ca1221a", + "970fa9a577cf291cbad4e81cd38909eafcf08dab53cd75d2d370d529e1f0772e", + "7677cba484a7259b7635ca7b6fe98793da031242b2bd02cc43dd032af2215572", + "1a38a70a3643117835c28fed2c5ab6612248ac2f59782a1311d98e23435398d7", + "4b067c810fe4cde0496ab6455a944eb097f4ca9827ae68717591c97b36f877d9", + "172abfc771f7e64e613339bc23265341ea3d792d71e365f51edada4ee3ac9907", + "d32d1cc45b98dd1ca16dcb3dd0bf9acaa6c2cdd285d4c31eebedf93b6ab10af0", + "689304535b02cde3420da3629169a8688d4976cea7979595d1e664763b08d19d", + "3880ac154310e27dfd777212de54b33a4cc9f9a7bcdf9684f3fbebae95b40f42", + "3ca44d5d95862c1cab5069913cc1ee7bb2d041a151d2ee00b7bdd99382f8c8c3", + "e0c951e2b6132ef867ac451080e70e3040fdc53eba9899055828e63b47dc12fc", + "0ed2a12eb40b26d40225b6c37291f6f3b963b4d37815daebbccad1668f3c82c5", + "7d18bc1a385c5d6ed9d25137c54cf6c9a23b37219092b5717b846ced523b00e6", + "f2b684a9ec307669780da98c5ac1b300965956e9ac8910304bdfb5bc10a87f1f", + "b11c36a04b9a33aae61f4767caf8530ffa4ddf674942cd2ce793f67b9a9e79cf", + "5f1806d8645dce77c4c5cc71ba14b9be5a9879eaa4d1298ae23cd0d1bf7f5086", + "9e0e781c64616cac41da72fb6c05c6bf17d6461b0fcff1a60901a899e55170ec", + "ec3cf6dc8a4cddcb3d852b54eb2c2bfb8ecb70d85752e953e8f7762fd26bbc90", + "904d5877d305dcfe9064f04558bfa95fe044235fd8a2d2105057013d3f91d3e5", + "f428b0b372c7285cc0b21b60d0f54eaec39101a43b4d1973f31f9b7ee53b9459", + "75f366059b8b1265c7c5c2b4e539be0551b82f9bfce8e8d97aabe6a1657ba6b8", + "2646eea357e6681949ec6c6346b2600c7ba5622ca73d808ebda7094dc5731fd1", + "2359b2ca34ef52e810e33412d5d79f42790a424eba247068ab4f0a7024684149", + "1313f17e4e13554d5c698b51fc11e317249af3448218540c8a13a2307b2d79c3", + "da300c7201d230d4be77c2133874ed42a2dc63fe6d0e1edac7705a364d5e0655", + "674d05c4dd25486ad9bee019089939697dd8a5c964d9462d82e6ff20004b414b", + "a8e34655b2e50449140a2063a36407d4de587ebb495ec341d2f301535e32b7fa", + "de87c509d08f7b83f0977ead19918251a09f7eff133f6757eb5c52eff66361d6", + "ad47db51d549cd4753e304cc07bcc1f99bf6656169bca04850e716bf63eb4247", + "33e6d463ad653ff0ab6af3b8b03f66bbbc2764aff0aed9361c42c1c3d50edeba", + "e907b7b82d4d0f3fccfad7886c2de6ace6a0be6afb8f9d5b172f9b4fcf1601a0", + "4cf90d63a2674f5b1550edeae719702443dbb5efa510281b8a2f2b080e410224", + "306a9bff17087aabb524e848c9f770bc1fd5c0cf111a9da835c9a2dc75a13e53", + "96e458e73ae7bc40f2191d96ee43c1d6f7b92a6e7f643154845ce3e6f3ec19c9", + "41dbd457831d9d0b652d1ede776e30463aa7351c5860b871440d73083722df96", + "94532b689e00c726c0a9ca5a22447495ef2a0787df230e47c3b6279b70a393a3", + "a8599f50777b6a9cd5a34dee3efca7ed51945e229c7e2a4eebfe348ca7445585", + "00b10de211097f5551da35888d93febef213679b55c49b1963c0dd577119b240", + "e90b02e5248679957a4ec126f252ef5b8fa3d479eea543f8a492adad81634344", + "b86d4805f1b6a2652f4a903f8d708515698147bf7a046977e0d0e8620ecc1452", + "ef69e15775189da3cdaa757a281afd3ff0153ab06de5c48ccdd9666fae9ac287", + "a37c3cd5b8ad49e8814dbaf8ddd99a5959594c2522de18f1847bf48479528586", + "2c367cefb2887e71a7b3afd8baa018c46f78663b664c51f0e3871da4ef9ed795", + "79e798d120216e32b8b600635897e95f2cb8660a1d2fa6f4ef77d11016953c9e", + "578e6e17397e245219dbf33814b07764013af7b8a5e14bee76c5b0394d850dee", + "68e8d70d634cc0aea7510ad935825839bd9ed748eaccb192bc22229f78e30c1e", + "0161ba9509359a20f0cec24057e322cb54f48d53c7f68c30b07ece189ac11ae5", + "b42ac9812a91ccce77ea3e0f1e065e79ac18fd11dd215fc8ac6aa043ce67fcd8", + "04d09832c4e26a8f58cd4f235835b9b93f3022c80d39dd335a6b29c910d2bbeb", + "691e7ab6ad250fc27213e4a1c5b7f0a9b8d96992e67884ed9cda4999ee51b1e3", + "79ec583cb0476613d71d40d22bee625ddac9255392ce90659cb50e9a1d70ecc6", + "f60937710b3475a65bcaf9ceae89a4b64fc5560b65cabb7f3a9b1c2b7465d103", + "acda89e04f3b3075c5a50cd4133fbfa493da5c2e5386e5ab974fb807411b7c71", + "d90c2c6f2b200197ac78e392e3ab7a3b7bf80f1eed9e297d6824ad18573c31ee", + "88db5d79e5f66d3d79d88dc437df719ecbd21fcaecad0706ad30d8abcc59a073" + ] + }, + { + "line": 105, + "lineHash": "c8e02969b2776377290185233fa1e4d3e911fa3652397c012075fe7c7073d315", + "nextLineHash": "0aa487e1deeb804b33afb0e32842f46bb33fc9e40e4a740be2921e0d986e6a22", + "suffixHashes": [ + "a17fd4b5b8687122e23f3a200f36c416c5a9e84eaba6751117252399da4bacb2", + "c549f758c42c4737b0911cbed2d5192b0a93dcad4ee0f82cafec67376b863fa1", + "40558501a2ed5cd3e397ed74cce30d32db3850ffbc0020a341e29de3d7ea7e22", + "fd6d4ed4c3be969c583ab2955a3456ad4cc559a72abb863384c624838131a762", + "1a157ff729f154646e4d4b0edddfce4ca90fe7dd093e733649b1066ce7fd3ca7", + "9c86e6cde16a3b342379a70ef3986dbcbacc8f301976f5ee919ecd09c86ec90a", + "9316978738c04a116e84a39f239f62cd7ef5e0bbe6be8109f750e748d9235fdc", + "7918f666f711e3507624de54d2e7996d42ecd5ab9ba9e866bb6cc3320870d70e", + "2147f126c8e678de9c8d60f70521d39a35ea1f2485b4dc0108dedb10717fe22e", + "e64ca6b46f6b51da7ec288847ba3a68cf71ab52d7fa31de112f08f4d074be342", + "cae3f94535ace37cadd89e0f5fd71982dcbb61133dcdbdd5176c6f7a6d2fc92a", + "566aa7d87276144894bb82d47c2a7833502359856a1cf860bd04faf2357ae41d", + "70b16201b95ffd33ddeeb6ce1e5f191ac2319520b90399b9909f16514a9296c9", + "2fbb348308bc7a960ae1ad851f5a09a8223989d04756226fc1b86007198470a4", + "0499726cf1277133d68648988c6e31059b11b5f2d174405fb16cc878881bd22d", + "3fd499745e637fd23c59a48584017a1f00eb6e67cc50c2e121bea91a4a9d512f", + "7daa35942326e7edec724a4b0d5d4040874497b9d60146c02d87408b8df6d3ab", + "59ea2dd3cf04f7e14c660772dab3b67efe4f83cfa261a5dad3e5bd662313e013", + "6358fea896811b84c7eaa28860a50e7c1ca9fc6015487853f70de424621af044", + "8063ab79cc1fabd4afbb3b6e87d86402fbf1b0cc39aafdba16486158c1a03809", + "a82d436d6bb0ebda2e48a4c525153e24aa12bed75db8318be2af1d35f7866903", + "17b808d20c842a629c11ca616de08513b56a3a4db18f8be615a804ef678dcf8a", + "276472c7be854ed636e458794406ba86cc33d9c739019918383acbf7749bf361", + "237ac6cfcee58a38496d2b520fbee83f29218501e71e9d03ef0c1c2d680681be", + "dfec53b450e4f9da6a43e5b717b7174707f5fc3f7c2dca80fc10e80a684543ff", + "0b7d18ee0cfe9019b2656647fd1bb2da746c1c50b26a8d0032b14ca42b4ab3f2", + "26e63d81df003384d5781f7630cbca99040f32b3fb775b01526b2d4881bd4aea", + "39afe092fbc75076a382120b8a4b85852ae1bb786733981c7a76226164933000", + "9b4e8afc1bf7fd3f65e6a9405bddc0820355d8a65722dd7145342aa205219322", + "54b29e64dfe2b3b8db91c3343d62d3fb63ff54a5ee39a76c3177beda3aaffe4c", + "dcc6a93432348bdefa43287f3861b0032bc0a0210f971d50d8b2c4e9a6a7df4e", + "72c16240af4c5b9ea87c712974d60a5df31a46724ac14aaf4ff800b81ce4fac3", + "99d088ad34905ce3aec6cad5860105b701fc61a2d14a486605ac085e11688b14", + "058511832479579a08e516b4e25b26f614c7451791221ad0755fd5e6e0de1192", + "070f0cd75a7a922330de791c13080e50ab39abb5ec3923712c5dc903f8b65cd0", + "cef533a98fedca39010b92ee8055bcc3836420a4196f5f387c492b8e43a2f0d5", + "2b962927c1d7de12323cb79e9bd675feddbe853f072f4d7bdd3a97d084bec397", + "d647704ab69701512d8187df5ef55f95ff8dcd39795bbb503e4bc264d5478b40", + "5580ed457a5b1411bca143362efd7f6ad7b3a046ee1f242656446b91d3be3233", + "5ed3c9e44905e560c0db2e832c9fa32d90f6fa9ecfb1ab219bb7b706cff99e18", + "b10f8af97a8398e9f7d7f005f985bcde87a5c65329b8fe36c2d88aab63d062cd", + "284f52ba5729387ccea4e6707e84f411f73a27dc0c4f1ac8f72213dbff99c6fd", + "bb0b5415a91ce35170385cb324b962fdb6d5644f163c018d58326a4b56603d0c", + "518ab81098ee0810adc904fca035f3c9ba94f377949aea064a3b132ea65db36f", + "73331b7f39e4c4ada5dd7f0eea2ac314dce6458634de1ea578a77e7ed205e3d4", + "8b1ee0ff7abfccb6c1153f75283acdaa0a9e15c1997f70b8aea67382633bd2f8", + "073f13755c78056b97fab83601ae61ebdbefd1a11fead4e192f93d30a4ee9c48", + "0d889102c75311704ca8c5d088a8c22f6ed38ba5f2991729ca20858ccd1efc5c", + "75c666813405283c170074019a5b416fffd640b6c0ebbb33718115489fbd2778", + "110542b4deb5cd40c8caaf5b6b27504f47a788a4575afbb51eb921a860127dd5", + "adc5f358b3f7316dc6f087579c8c77ef853ea27d5380d0b6fc7afc88ae6f0d28", + "932090a9d9d04096c569d884ef3a143f82d7fffb799650e2bd91fdef86a6736b", + "ca4f5c84f6c2e1532fbb7c8a193f1c52dff862496596d504cc5d670d8e0e44af", + "a6439ebfa2d2d39053d615240efd49915398a0e97f32c22e2fc7f13da045f8ca", + "fdcd51b5989e98fd4ab4b5c5f5ce858f8785dbd1bea1645644e8acdfeb09d9ce", + "1611469f6b13808e079c818a63de4753763b31c0b273f89bbe3dcb628d83c2d6", + "20a8ea133de9c40c8006e343db555eb6eca8c491722577d80320283974b8a099", + "8284c4bd2345c6dd0b56631ddf05f10637df42c8b36e70ddb3966c1acb7dcf9f", + "a5a6ce64f72088d658162a86db6ee926e8db0965714b1d9e320d7b4f705e1ad4", + "cbeba359233b5e10513a47388ec49901b4e9809f7b4ef8f7eddb16e79e404769", + "bf1b695b25bf9b3f6b60494e0417e7e887b47eb4ac6b697479199982b872c6b4", + "f4d196b544233890e90b7544d3c298c4a3b25d445b32e928d511994820313dde", + "29866d13205b9fe1e33bd320b1339165d1aa9775b618d5f2550cce68a545c3d5", + "b774b25e8b9911f922f5883e667909a6ff935297bf8a1a2cc890ad3daf8699fd", + "e571d2a405e2d3cc3579122434f3969eb9b80569379746385c0c6997c6b718e5", + "9025ae6e5b114f55f01be942eb95adbacd85575a9aa1d26f23a13597e886568a", + "0dd61cee2d7f4bbabace2eda140a6d84901fa6af8ca1e7692ed90aa2dba0e1e9", + "6d153ea5c74e2e06df915a925d7c24b5cc837d44f41c1df30234ae787ece18b5", + "013efb4e1eb87cdaa1279ca89e5cec3b18380ff784d6fbc2ac83aa66e415c430", + "5c1f07c87f0ed0ed7ffcec76c7d665ff09bed72ab25176941665b9ea9883174f", + "9a232b47b84126acf2ecb49751c4998f4a18c34ebf0030efcba7faa95833e075" + ] + }, + { + "line": 106, + "lineHash": "0aa487e1deeb804b33afb0e32842f46bb33fc9e40e4a740be2921e0d986e6a22", + "nextLineHash": "1855357059e6a2a14a211a1c91000807ecc51e3065187ab67c7d633eaf133eae", + "suffixHashes": [ + "20f29c90c36e5e53adee15c92c012f0cf2d8c077bbf84c2b095c4d4fabec68d2", + "25723235dee9080dbbb3b8062eee6fd2d812a6ad35c2ffd372bcd6658bf39e19", + "bc74457b0725a76913b146eeda24a981c677c514865d675bd330c9dc4ef5b2c9", + "f44921f40dfa5b3358ee13e55f2051b963f7c64c01623d23dd98bb01457ab2f4", + "0eb424f4fce587b64988b32b472effd0c95d302b51c802e965ee776f07eb75e9", + "d925fae25ea42c65a4fce1e740fc8e3549b9a14bcbaf7fc939ee651ad8180f48", + "f9a86c8252fb17a9344aa0528bee3701ee423749adf9a427ac1b81d5b06cf056", + "43d8db300c2c8476a7651f58b68751a075ace5966eff597b884f1299699104e6", + "4aff2701626d7da09293d618c88348d9a85f6d15f1f6548e1c9d4e83319ff4b3", + "7e4b8ab23aab8a6acb12b7b7e0fc344f4e6bf080f61788cf6dfc3b8370f8ade0", + "2e8f36b67a4631a84c39aa367aeb57084f8fa5b7ef68155a1b75f22b18a9c7ed", + "18f45e21d5f2eeefc771b16d3449526a30d898d8f4c3dbca85f1675be394e295", + "4748423adc6a6ff26b63f9df0d2068af09089b8156d630c3d50f0b2e03a2a2f2", + "6d886d814f9a78a2de49af9fb511b2f6edb4a3ea6dc06ce2f6f95049ac8f3c09", + "4c24c3043fb16eaf41367ed00d20c291efe515241c7dac800d702861f0e5263e", + "579f78303679b2cc554495205decb4a6f1e9bcc9d85cf519d6d5ad2cae44c1e9", + "8f6a35fcf40112fa53d0633d76b0384ac0cefd38bb4c2bc03aa7f1ed7b0617e6", + "0b67dd0c1779c8cb28cb8d058da1de942773f6c0245b1ba08fbaea679501f33c", + "2318e288bdef3c6d117a82cb52fc8237ffe4ac21cae5476152e5478d34154236", + "36bd1f8b413eeb6936eb0985682485944fc325a6607ffe3bef4b1161c2fe007d", + "eb8e529fa86780b1264c105d78b56fc95fc5a1b64095250e95eef2ec21e1211a", + "08ff516360863bdc986947194d61daf53c829347159125a235e1918b37e99338", + "e809d1853b27590bfa06ecb85bce7def13f019bfb63be82efbabb1c7dc92635e", + "9186ddbd04545692048776c8c03ca2fbab702d29d36e6fe008b684e5e993c82b", + "77603f751b73bc21bbe5b773e0d0f6792faa2674e06817e635d4e88bf1d17dd0", + "0a6d0ad5b529d0ac2694ec951d3775f655f97e5685555f751af545aba1050088", + "bf1627c960a8525f99bf6161adf67cde944043e8e30e7e1d96adce1663f5ab4c", + "d8bac0b9657bfcd80fc304869e327e0f2678ef5f14ebcf213c23b812b50fb5e1", + "f0cbe97085c47003dff086067bbbff551297fa89a6c66ebb6ee2f4e3e87982fd", + "4fd42f041a2ccb8aef96142adcb29373c49baae26bffa42163845271531ce6cf", + "cb0b9179855a5077f325960685d46620dc1f8a4d4bad2b598e9090c8aab93528", + "d4fe2b5532d753013e7363f73b5935d385ecf595be34a959bf5f00aa15ae3130", + "524fc0a903140d8c154846ddcd3e9db26a650ee1795396d24f1b0ebe0b9f334f", + "b4ab01e14b68794ecf14a9bffa236b08e49687f4f3ac8a2bb905071afa662bee", + "99e6ddd42ef2512f4bc9afc5fde56913a5f51dd3ac07ef72d8f78e72cb7b16e3", + "8090b5b2c9b7309c309e2e70bf52e33208052083734a197d5e6ae2f583b570b9", + "be3e6b7418a12113a3ebd1f746332aeb68b3fc6c132f1d7361309a02f0efb761", + "8154480245e9b63745921113a00d6bc64a45291a5bbb699834ee72de02d22422", + "b2bd95c8c81ff43b201979a2df77e3b86ad20ae4c07e4c26b850eddf27c7caa3", + "3bd8e5385983d6dcb643a747cd8b225d363c6c0184928b4ed60c95c26f05ce36", + "d04826a660f73e84555281031814fe1d8f50d3c5f94dcebc85ac7e8d7cd38f1c", + "8cd0b12fd5827a41c821f3f240e4e1d79b9defef792d6a6669d9a22d584df0f2", + "926a3af88d09079ff74aad0326c9a01f3496e059dc15e0abe5cb5992fdb5f7af", + "6d43b02b2deee8d329f3b41e30f67e82a4a4d05c2d00b237a1d1b5269cee0506", + "594ecfc56eb85adc7eeb5b1e7c5314218846ef65225a2fae20de5332aa34ecf4", + "ee34d36dc61d0f37d4a39b18c77883dab43b1f2c7ea2346588b305953fb3695f", + "bbbeaf7c9b6d03bab413b8aa0143a3450836b0d477bbcd3c931cf5b0870e8218", + "d17062ce0222d493c375581f72838251e92ac6d8f8957e9fff9826b3452b9edc", + "2c4b908a9bfa9049c2d926447aba5419cf19b80a061a3276fb0ab2aef24e92c5", + "b6431d4b14f93a5262453a9f4bc0d1d22621b876035fe7a1fc0d3a34a5e636ca", + "aef9933e790e45d88e7d5d835b0416711f5c3e5c1ae8ac200dd0f56ced4dd0b6", + "3ebf7f3d750fdddedf6978c02f2050f8daecfad9604897ca50ac5f3f3bf388a7", + "4b1fef1ff474b36bda3aabb7f9c60829a3edd7584f26e6d7af8800caae55fae3", + "36304f8e566cda2660b3badc319df0cc63b4ad2f9f27df08db1712acb5281ddd", + "e996ad53ba0f8987a77d757ddcb428f8202fb9b84fb0f83932015713e4430ea3", + "b8373119066b691bbe24ac3e2a497061fd09945bca72eec166e45ebdd59f35c5", + "4a0d112745dbb87d95d666b0238404549cec6faf9acdf259f069ca7e1d79047b", + "c4d7587698e6f9c3266b39e4fd5c979088a39331b3167d697686102780e9cf2c", + "a0c6924ba9c9db5629822542ef659bcce93dfb656241dabdaca915dd314f0e77", + "0cde86622a0fb406b7f2ccdce46353248637703ac5974a386464fff41dd8097f", + "453954cc34217437e78ddc21997e96bc0addc306f150c3f6cda0adf80d8fdc51", + "b84d0f80dc98a1cc37705b533e76c0dbfae948c36242d160f335f099975557cf", + "545e1e08c9b69ab90fa5444b00e7f9b5dbf6cf2bf04d1a797d345f77a4f201e4", + "0ed757d30d49c57badae21bcb1c893f6b5df42441d87912884f61f0f98673cbb", + "87e8c2d3ad3c3157741e7b44db872cfce41d5f065f38904e8f83b7c74dabe739", + "1ff3fa456559e702759e1be011ce3e2571cd132443021b3fa5eecdb098798915", + "1c059b7c7cee224193e015b41b0505ef876e378e39ed193aebf5bd555d626f9e", + "2183658c581605eb2007811b383b1ac93369f5a5031f48f0e3fb2482d4b3b3be" + ] + }, + { + "line": 107, + "lineHash": "1855357059e6a2a14a211a1c91000807ecc51e3065187ab67c7d633eaf133eae", + "nextLineHash": "fc8b01238e1cc233faaa6bd0f488af535348ee84ccb502ef27c51dac1df6b6e5", + "suffixHashes": [ + "8dda884f89e9234651addabab6eadb3f0298600efcad432f33cfa83ddb2b5cf4", + "44b27de0a8382d60b729e4facc4c4a377975dd624b182815058f11c38fcc0d0c", + "3d4445585160fad6ab4d7946e3e06576d992fd31da717f0249ce5699eda3e93c", + "61888fbb32c77fc9b5364e130765a467e0105184c855375f608b5f3d23afd15c", + "0fc3830863518d0a2b1f4d7f0f55986015ea93028ebb379a7628d11d95c17162", + "b2630d0d816c7dc3d120817488e0472b72ac8c2616fcef07b779a93790037a10", + "b23fd051587a9242eb9203c827f64f72d6d9fc0030cb18997df478e75a1faae7", + "a1c676a1b2784b6a4d94be4e95fc5aa3ad2debb5b9852403364babc5d95f4214", + "408797a5d25c808e6df0de948ab6b1d1ee59e1e1c94a40bd823d573d5054c5a2", + "7898efd258bbee324716e51a3c0a8de583b7bb4b01c3cae5ca8a728ecb873b02", + "9445920d27dda28e76c51bbb1a26e79d6b43d734d138d705c51aa024bd979748", + "5e57dc48e6cc1cffb2a64d2076138d3064e14394cb017916ca87fec51cb79dad", + "2559585806a5147cfec23d94636c194d05e14edf6b13eb7e26bd70a0d5d17863", + "c96a761177594f3e42683b42dba20e702e820f896e3ee60acd9b24e4b0cac376", + "893b2fbc5952dbf6481b78f3bb575f82c858d71dd0aef465ab57eaaddef85e0c", + "796cd2d299c901a5c9a5efacfd8e775c6ce789c0977c7d33d16cd42ab6fb827c", + "b88b9d355263574e1882bb83eb1f7a101a57b8b0d5f2226cbae005e06744cf9e", + "c9c108db3920ce6d73357375bc0ce87e2ad5b0c72f39ccac7e92413252c8dc1e", + "133d2e364d530b164d26bb8020203549df9374a6f06bce59a981c0224031aff5", + "1f1c5c26443b4e713d7cf9250253f4726b718cd7d5426df0c2554c26293d29a2", + "e076c09af83607b4d0c7c58dfa9d68c1a0d46a5e80e76d27646da5e54a597096", + "639fdc32cae9efc918881d1c8e9f30847326563ad60dac12b98b540c766d4866", + "df23ea77e0008a320c504bb9d3e45a5d08409e8f24c42f8104a59406b247dd28", + "f6f42d7fb368dbf43d2415d2907221b9defbe9bcb7ca425309c2d7ce0f854b98", + "91cf08dcf7f28df9bb6fc77f0f3bc0db5015e91ea8450cab2bd4e1d7dc31761c", + "4dedf9ab4d828aeda3e3d5d072e46c44d63609b4ecce1e1b63a7ac00520a356a", + "e47143a8c5bb89ebbf41a9c889e38ae7040e378c90b5f1730b7ad4bae572a321", + "c5aef55f4d7ab1c6ee29a88943f6dd7a63913f098aba3a9a5d412aacb4f79807", + "32a667798522080ff86228409c62992040570956ed8da8c9e0a7939e9226bb9f", + "a7803bb7ccc7ed8d8bf9b6e2b093e23b57ae3a1e385ac4ada48106e827f1b4fd", + "9aad6b9e1982f6ab2572e58bce2b903f6e84244d4ca377db8d3d974b9841a2b4", + "2b0d3d31fc9085671f9b57beca98f2149966f1c68c6f45d160a8d2a053583ada", + "20daa1e88b25ea81bc948c48c391c0fbcc44a0070dd7e6870f51f81b9cc6492b", + "4839e71f43e427e0588f4fc52031321a8a42d0957bbafcda196dc28cbb2ba7fc", + "ce37f12e44a0d49f852e31b8dfb03eb3002a5f24c27c7c0b0d339bd6d6eb7380", + "2c75afb07f2a03f23177a98a02c41f7b1f482223dcb36e6d18c9c8c3a9ddabe6", + "70c4f3918cdc1ce71a1d1bd921d4eb25e9273824e3d91e40ac56b5d4cdb06517", + "5927a27671d497d1d654de47bc70f3854568d96b3af23bb6f5e5c553e0b01376", + "e54713035fb46ce57b8e4e6b6c2bee9bf8f59e126f551ef14d6e1a4eb929ae79", + "c95a618d44903eeba8771c54f5c97d63fab32d270abd00fec92425fdbf83339e", + "43714fed1ed46ebcf9c6be297e023d09aa1aa3655dbc5838a82854a876f3c122", + "6b68b3c1e6dc1e2811e9371004fed449aea8a35fb78a56bb93eccb749b433e3c", + "6cab1e357c51e54a60e19b36e0aea2ad41c3552337621f49e1dd4b1bc812f3fd", + "6ef121ce4a468a03dd323ea4d35278e35963f4333d92332095f564958fae1f5d", + "f4082b12cbea8572148f8b139ef211e398a535a5e98f1faaa1a65e0d23c451a8", + "baf194e9601db92141bd5d6cdebb476d39072535a17662b283e2c472b01e2f0b", + "0d2ca9c74c42e9fd9a03279785e7bc0c179b29c08afb51c1bb78a69147864e58", + "31d56062d2401d58fb712161495de0de869d3ca61d1e95ea7648552963774bdd", + "7b4fb1f3bccc3aff4e58b6ba80a88f5fbe7d18e79e135bc96da147c82192c415", + "ffb88c052ab133c5f080f429c68feb5a0a24c27da3428717b95d5f6054c714b4", + "f7b18259f474ec9599040881410cc3f20c4830449a998ee12a81eeb4b1fa63c8", + "016faea732591170f483820198cb332009a83ccf2cbdc9f03211d16d45815c04", + "104465d75ec50caff3cfa0133d8e0bbb32c903f2663a219edc3d73521e273740", + "0cc3a136cd74f859b3e6c4864c0b47cfc029e1060a3e65409d3ddafb0c69651a", + "ff1cb0bc8a49f80f3a37018fd4d4db3ebed49f1b37ae89d1af52a4cff69c3a3a", + "70091133b10ec4619f3308177691e018310999b4f210e1ff481df37ce0d34b8b", + "47c6a53ccf3f7af384497ef30d47dfabfb896eb83951df6f0b39aa093a3e9568", + "2164b8ae6934bb596c192479bc21a5385703bff12575e5176fc1127d44278d17", + "9c9ddc33f228bfa3ec94fadeadae6211418ab2383a89b301a42022766cd84e03", + "84650882ba841564154e9db8c8436967f53a673df542eee2027bb9c20ab46ec3", + "99ea33b5e20349d64a56fddd4eb54577872ec02c1988d03b5092e3f9e0b9b960", + "6d4c1bdbc19d46751c3fd142fc1a7c4871cda853ff2a18298199e38c92ee283d", + "c414758b17468f82896f5e209ad4d40eab016c4d4e2d4d97d68e04243668891c", + "851e4cca71e86fd179292ff22db8a8eb50608f1475e58ec860c9fcbd2d0190c6" + ] + } + ] + } + } + }, + "sessionId": "7fcddaa4-44d5-4550-b9cb-ce4e092faebd" + }, + "before": "---\nstatus: ACTIVE\n---\n# CEO Plan: User Dashboard Page\nGenerated by /plan-ceo-review (via /autoplan) on 2026-09-10\nBranch: main | Mode: SELECTIVE EXPANSION\nRepo: gstack-autoplan-chain-zmFsqo (no remote)\n\n## Problem\nMembers visit three pages after login to resume work, check alerts, and inspect recent\nchanges. A team walkthrough measured a median of 75s to the next item; the production\nnumber is unknown until the baseline step below runs.\n\nTargets:\n- Ship bar (advance the rollout): median login-to-first-completed-task improves at least\n 20% vs the control cohort.\n- Permanence bar (remove the flag): median at or under 45s absolute, or at least 40% below\n the production baseline if that baseline turns out to be far from 75s, sustained 4 weeks\n at 100%.\n\nGuardrails, measured on the flagged cohort vs control over the same window, evaluated\nonly once the cumulative flagged sample reaches 1000 sessions (a 2-point rate difference\nis noise at 200 sessions):\n- completed-task rate within 2 percentage points of control\n- permission-error rate within 2 percentage points of control\n- dashboard partial-failure rate (any panel failing to load) under 2% of requests\n\n## Vision\n\n### 10x Check\nA landing page that already knows the member's next step. Login, and the first thing on\nscreen is \"Resume: \" as one dominant button, with alerts and changes below.\nNo scanning, no decision. Concrete shape: eligibility-ranked actions from the existing\nregistry, item-level deep links in notifications, activity filtered to \"mine\". Effort for\nthat full version: human ~2 weeks / CC ~3 hours. This plan builds the surface that version\nneeds; multi-action ranking is the separate personalization plan.\n\n## Recommended approach (open taste decision T1)\nA) `GET /api/dashboard` aggregate endpoint returning a per-source result envelope\n(`{ activity, notifications, quickActions }`, each `{ status, data | error }`, notifications\nadding `unreadCount` and a server-issued `snapshot`). Each source returns at most 20\nrecords; the panels link to the existing full pages for more. 200 whenever auth passes;\n`?sources=` allowlist for per-panel retry. Alternatives considered: B) three client calls\nto existing list endpoints (T1 below; under B the envelope, `?sources=`, and server-issued\nsnapshot are replaced by per-call responses and the snapshot comes from the notifications\nlist response); C) post-login smart redirect (deferred as experiment E9).\n\nMark all as read reuses the existing member-scoped bulk-read API, which already takes a\nsnapshot time and marks only notifications at or before it. The `snapshot` in the envelope\nis the server-issued value the client passes to that existing API. No new mutation API is\nintroduced anywhere in this plan; E6 is deferred precisely because it would need one.\n\n## Effort for accepted scope\nBaseline plan plus E1-E5 and the shared primitives: human ~3 days / CC ~1.5 hours\n(the plan file has the hour-by-hour breakdown).\n\n## Scope Decisions\n\n| # | Proposal | Effort | Decision | Reasoning |\n|---|----------|--------|----------|-----------|\n| E1 | Next-up primary CTA when resume-assigned-work is eligible; when not eligible the three actions render as equal buttons | S | ACCEPTED | Directly moves the metric. Uses only the single existing eligibility predicate for that action; no ordering across actions, so this is not ranking. Ranking stays deferred. |\n| E2 | Unread count badge in Notifications header | S | ACCEPTED | Count already needed for Mark-all button state; answers \"is there anything?\" before scanning |\n| E3 | View all links to existing full pages | S | ACCEPTED | Makes the 20-record cap honest; older-page navigation stays where it lives |\n| E4 | Relative timestamps with absolute time in title and datetime | S | ACCEPTED | Does not move the 45s metric directly; low cost, faster scanning, screen readers get the absolute value |\n| E5 | Refresh when the user returns to the tab, at most every 30s | S | ACCEPTED | Does not move the metric directly; low cost, prevents stale-alert confusion; the 30s floor bounds traffic |\n| E6 | Per-notification mark-as-read | M | DEFERRED | Needs a new mutation API, which this plan excludes |\n| E7 | Live notification updates | M-L | DEFERRED | Needs polling or push infrastructure this plan does not touch |\n| E8 | Keyboard shortcuts for actions | S-M | DEFERRED | Global key handling, screen-reader conflicts; needs its own a11y review |\n| E9 | Post-login smart redirect A/B | S | DEFERRED | Login-flow change; needs the production baseline first |\n\n## Accepted Scope (added to this plan)\n- E1 Next-up primary CTA\n- E2 Unread badge\n- E3 View all links\n- E4 Relative timestamps\n- E5 Refresh on tab return (30s floor)\n\n## Engineering commitments inherited from review (not scope decisions)\nThese were required by the review to make the baseline scope shippable; they live in\nthe plan file's Review record with full detail:\n- Complete error and rescue map for every panel and for Mark all as read (shape depends on T1)\n- Action feedback announced through a screen-reader live region (required by the existing\n accessibility policy regardless of T3; T3 only decides whether the toast component is\n built here or in a separate ticket first)\n- Shared panel-state wrapper and a shared server/client contract module\n- Production baseline measurement before rollout; the targets and guardrails above; the\n logs, metrics, alerts, and cohort funnel panel that make them observable\n\n## Rollout\n1. Baseline: compute median and p75 login-to-first-completed-task over the last 30 days\n from existing analytics events and record it in the plan before any member sees the page.\n2. Ramp on `dashboard_landing` (member-cohort flag): staging \u2192 internal cohort \u2192 5% \u2192 25% \u2192 100%.\n3. Exit criteria for staging and internal cohort: functional only (all three panels load,\n a forced partial failure shows one panel error with a working Retry, mark all as read\n completes, keyboard-only pass, zero accessibility violations).\n4. Hold criteria at 5% and 25%: at least 1 week and 200 flagged sessions for the median\n check (ship bar, 20% improvement vs control); guardrails evaluated once the cumulative\n flagged sample reaches 1000 sessions. Roll back one step on a guardrail breach at that\n sample size, or immediately on partial-failure rate over 2%.\n5. Permanence: after 4 weeks at 100% with the permanence bar met and guardrails held,\n remove the flag and make the dashboard the landing page. If the permanence bar is not\n met after 8 weeks at 100%, decide explicitly: keep with the ship-bar gain, iterate, or\n roll back.\n\n## Deferred to TODOS.md\n- E6 per-notification mark-as-read (P3; depends on a new mutation API)\n- E7 live updates (P3; polling or push infrastructure)\n- E8 keyboard shortcuts (P3; needs a11y review)\n- E9 post-login smart redirect A/B (P2; after baseline and cohort data)\n- Remove `dashboard_landing` flag after the permanence bar is met (P2)\n\n## Taste decisions (OPEN \u2014 resolved by the user at the /autoplan Final Gate, due before implementation)\nEach shows the recommended option first. The plan file proceeds on the recommended option\nuntil the user says otherwise.\n- T1 Aggregate endpoint (recommended) vs three client calls. Owner: user at gate.\n- T2 All three panels in v1 behind the flag (recommended) vs QuickActions-first staged v1. Owner: user at gate.\n- T3 Toast built as shared primitive in this plan (recommended) vs separate ticket first. Owner: user at gate.\n- T4 Keep confirmation modal on Mark all as read (recommended; no undo API exists) vs remove modal + undo. Owner: user at gate.\n", + "viewport": " \n \n\u25cf Appending spec-review metrics\n \u23bf $ mkdir -p ~/.gstack/analytics\n echo '{\"skill\":\"plan-ceo-review\",\"ts\":\"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'\",\"iterations\":3,\"issues_found\":20,\"issues_f\n ixed\":20,\"remaining\":0,\"quality_score\":9,\"via\":\"autoplan\"}' >> ~/.gstack/analytics/spec-review.jsonl 2>/dev/null ||\n true\n echo logged\n \n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n Edit file \n \u2026-982715-DqIQtk/skill-home-262upM/.gstack/projects/gstack-autoplan-chain-zmFsqo/ceo-plans/2026-09-10-user-dashboard.md\n\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\n 94 a forced partial failure shows one panel error with a working Retry, mark all as read\n 95 completes, keyboard-only pass, zero accessibility violations).\n 96 4. Hold criteria at 5% and 25%: at least 1 week and 200 flagged sessions for the median\n 97 - check (ship bar, 20% improvement vs control); guardrails evaluated once the cumulative \n 98 - flagged sample reaches 1000 sessions. Roll back one step on a guardrail breach at that \n 99 - sample size, or immediately on partial-failure rate over 2%. \n 97 + check (ship bar, 20% improvement vs control; at 200 sessions this is a directional \n 98 + signal, so also require the confidence interval on the median difference to exclude \n 99 + zero before advancing). Advancing 5% \u2192 25% requires only the median check and the \n 100 + partial-failure rate; the 2-point rate guardrails apply once 1000 cumulative flagged \n 101 + sessions accumulate, at whichever step that occurs, and a breach counts only when the \n 102 + 90% confidence interval on the difference excludes zero (a bare 2-point reading at \n 103 + 1000 sessions fires on noise about one time in three). Roll back one step on a \n 104 + confirmed guardrail breach, or immediately on partial-failure rate over 2%. \n 105 + These exit criteria and guardrail definitions assume the recommended T2 (all three \n 106 + panels in v1); if the user picks the staged option, \"all three panels load\" and the \n 107 + partial-failure definition are re-scoped to the panels actually shipped. \n 108 5. Permanence: after 4 weeks at 100% with the permanence bar met and guardrails held,\n 109 remove the flag and make the dashboard the landing page. If the permanence bar is not\n 110 met after 8 weeks at 100%, decide explicitly: keep with the ship-bar gain, iterate, or\n\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\n Do you want to make this edit to 2026-09-10-user-dashboard.md? \n \u276f 1. Yes \n 2. Yes, and switch to accept edits (auto-approve file edits and common file commands) for this session (shift+tab)\n 3. No\n\n Esc to cancel \u00b7 Tab to amend", + "publicTools": [ + { + "sessionId": "7fcddaa4-44d5-4550-b9cb-ce4e092faebd", + "timestamp": "2026-09-10T22:00:04.615Z", + "toolUseId": "toolu_01Wo4P7AC4zrX774MLdRfHUF", + "kind": "use", + "name": "Write", + "input": { + "file_path": "/tmp/gstack-paid-shard-a0OkbA/tmp/gstack-hermetic-982715-DqIQtk/skill-home-262upM/.gstack/projects/gstack-autoplan-chain-zmFsqo/ceo-plans/2026-09-10-user-dashboard.md", + "content": "---\nstatus: ACTIVE\n---\n# CEO Plan: User Dashboard Page\nGenerated by /plan-ceo-review (via /autoplan) on 2026-09-10\nBranch: main | Mode: SELECTIVE EXPANSION\nRepo: gstack-autoplan-chain-zmFsqo (no remote)\n\n## Problem\nMembers visit three pages after login to resume work, check alerts, and inspect recent\nchanges. A team walkthrough measured a median of 75s to the next item; the production\nnumber is unknown until the baseline step below runs.\n\nTargets:\n- Ship bar (advance the rollout): median login-to-first-completed-task improves at least\n 20% vs the control cohort.\n- Permanence bar (remove the flag): median at or under 45s absolute, or at least 40% below\n the production baseline if that baseline turns out to be far from 75s, sustained 4 weeks\n at 100%.\n\nGuardrails, measured on the flagged cohort vs control over the same window, evaluated\nonly once the cumulative flagged sample reaches 1000 sessions (a 2-point rate difference\nis noise at 200 sessions):\n- completed-task rate within 2 percentage points of control\n- permission-error rate within 2 percentage points of control\n- dashboard partial-failure rate (any panel failing to load) under 2% of requests\n\n## Vision\n\n### 10x Check\nA landing page that already knows the member's next step. Login, and the first thing on\nscreen is \"Resume: \" as one dominant button, with alerts and changes below.\nNo scanning, no decision. Concrete shape: eligibility-ranked actions from the existing\nregistry, item-level deep links in notifications, activity filtered to \"mine\". Effort for\nthat full version: human ~2 weeks / CC ~3 hours. This plan builds the surface that version\nneeds; multi-action ranking is the separate personalization plan.\n\n## Recommended approach (open taste decision T1)\nA) `GET /api/dashboard` aggregate endpoint returning a per-source result envelope\n(`{ activity, notifications, quickActions }`, each `{ status, data | error }`, notifications\nadding `unreadCount` and a server-issued `snapshot`). Each source returns at most 20\nrecords; the panels link to the existing full pages for more. 200 whenever auth passes;\n`?sources=` allowlist for per-panel retry. Alternatives considered: B) three client calls\nto existing list endpoints (T1 below; under B the envelope, `?sources=`, and server-issued\nsnapshot are replaced by per-call responses and the snapshot comes from the notifications\nlist response); C) post-login smart redirect (deferred as experiment E9).\n\nMark all as read reuses the existing member-scoped bulk-read API, which already takes a\nsnapshot time and marks only notifications at or before it. The `snapshot` in the envelope\nis the server-issued value the client passes to that existing API. No new mutation API is\nintroduced anywhere in this plan; E6 is deferred precisely because it would need one.\n\n## Effort for accepted scope\nBaseline plan plus E1-E5 and the shared primitives: human ~3 days / CC ~1.5 hours\n(the plan file has the hour-by-hour breakdown).\n\n## Scope Decisions\n\n| # | Proposal | Effort | Decision | Reasoning |\n|---|----------|--------|----------|-----------|\n| E1 | Next-up primary CTA when resume-assigned-work is eligible; when not eligible the three actions render as equal buttons | S | ACCEPTED | Directly moves the metric. Uses only the single existing eligibility predicate for that action; no ordering across actions, so this is not ranking. Ranking stays deferred. |\n| E2 | Unread count badge in Notifications header | S | ACCEPTED | Count already needed for Mark-all button state; answers \"is there anything?\" before scanning |\n| E3 | View all links to existing full pages | S | ACCEPTED | Makes the 20-record cap honest; older-page navigation stays where it lives |\n| E4 | Relative timestamps with absolute time in title and datetime | S | ACCEPTED | Does not move the 45s metric directly; low cost, faster scanning, screen readers get the absolute value |\n| E5 | Refresh when the user returns to the tab, at most every 30s | S | ACCEPTED | Does not move the metric directly; low cost, prevents stale-alert confusion; the 30s floor bounds traffic |\n| E6 | Per-notification mark-as-read | M | DEFERRED | Needs a new mutation API, which this plan excludes |\n| E7 | Live notification updates | M-L | DEFERRED | Needs polling or push infrastructure this plan does not touch |\n| E8 | Keyboard shortcuts for actions | S-M | DEFERRED | Global key handling, screen-reader conflicts; needs its own a11y review |\n| E9 | Post-login smart redirect A/B | S | DEFERRED | Login-flow change; needs the production baseline first |\n\n## Accepted Scope (added to this plan)\n- E1 Next-up primary CTA\n- E2 Unread badge\n- E3 View all links\n- E4 Relative timestamps\n- E5 Refresh on tab return (30s floor)\n\n## Engineering commitments inherited from review (not scope decisions)\nThese were required by the review to make the baseline scope shippable; they live in\nthe plan file's Review record with full detail:\n- Complete error and rescue map for every panel and for Mark all as read (shape depends on T1)\n- Action feedback announced through a screen-reader live region (required by the existing\n accessibility policy regardless of T3; T3 only decides whether the toast component is\n built here or in a separate ticket first)\n- Shared panel-state wrapper and a shared server/client contract module\n- Production baseline measurement before rollout; the targets and guardrails above; the\n logs, metrics, alerts, and cohort funnel panel that make them observable\n\n## Rollout\n1. Baseline: compute median and p75 login-to-first-completed-task over the last 30 days\n from existing analytics events and record it in the plan before any member sees the page.\n2. Ramp on `dashboard_landing` (member-cohort flag): staging \u2192 internal cohort \u2192 5% \u2192 25% \u2192 100%.\n3. Exit criteria for staging and internal cohort: functional only (all three panels load,\n a forced partial failure shows one panel error with a working Retry, mark all as read\n completes, keyboard-only pass, zero accessibility violations).\n4. Hold criteria at 5% and 25%: at least 1 week and 200 flagged sessions for the median\n check (ship bar, 20% improvement vs control); guardrails evaluated once the cumulative\n flagged sample reaches 1000 sessions. Roll back one step on a guardrail breach at that\n sample size, or immediately on partial-failure rate over 2%.\n5. Permanence: after 4 weeks at 100% with the permanence bar met and guardrails held,\n remove the flag and make the dashboard the landing page. If the permanence bar is not\n met after 8 weeks at 100%, decide explicitly: keep with the ship-bar gain, iterate, or\n roll back.\n\n## Deferred to TODOS.md\n- E6 per-notification mark-as-read (P3; depends on a new mutation API)\n- E7 live updates (P3; polling or push infrastructure)\n- E8 keyboard shortcuts (P3; needs a11y review)\n- E9 post-login smart redirect A/B (P2; after baseline and cohort data)\n- Remove `dashboard_landing` flag after the permanence bar is met (P2)\n\n## Taste decisions (OPEN \u2014 resolved by the user at the /autoplan Final Gate, due before implementation)\nEach shows the recommended option first. The plan file proceeds on the recommended option\nuntil the user says otherwise.\n- T1 Aggregate endpoint (recommended) vs three client calls. Owner: user at gate.\n- T2 All three panels in v1 behind the flag (recommended) vs QuickActions-first staged v1. Owner: user at gate.\n- T3 Toast built as shared primitive in this plan (recommended) vs separate ticket first. Owner: user at gate.\n- T4 Keep confirmation modal on Mark all as read (recommended; no undo API exists) vs remove modal + undo. Owner: user at gate.\n" + }, + "messageId": "msg_011CevWWAzJYxLfW7H13BcgU", + "requestId": "req_011CevWW9bU8yzBJMoTJ8Z3g" + }, + { + "sessionId": "7fcddaa4-44d5-4550-b9cb-ce4e092faebd", + "timestamp": "2026-09-10T22:00:09.083Z", + "toolUseId": "toolu_01Wo4P7AC4zrX774MLdRfHUF", + "kind": "result", + "content": "The file /tmp/gstack-paid-shard-a0OkbA/tmp/gstack-hermetic-982715-DqIQtk/skill-home-262upM/.gstack/projects/gstack-autoplan-chain-zmFsqo/ceo-plans/2026-09-10-user-dashboard.md has been updated successfully. (file state is current in your context \u2014 no need to Read it back)", + "isError": false + } + ] +} diff --git a/test/fixtures/autoplan-with-result-au.json b/test/fixtures/autoplan-with-result-au.json new file mode 100644 index 000000000..9e279c2e9 --- /dev/null +++ b/test/fixtures/autoplan-with-result-au.json @@ -0,0 +1,11 @@ +{ + "sessionId": "78ce9c42-e5f7-4595-81ea-7d9bb8b4345c", + "text": "Phase 2.5 wrapped up with DX improved to 8/10 and TTHW down to under 5 min; the Claude subagent review found 22 issues (6 high-severity), all accepted, while Codex remained disabled. Moving to Phase 3 (Eng Review), starting by loading the eng phase section and its methodology pointer.\n\n", + "timestamp": "2026-09-10T21:35:46.209Z", + "provenance": { + "kind": "Claude Code public narration summary", + "cliVersion": "2.1.263", + "publicTerminalSHA256": "79b01906d51fbf87451004e2fb10a191ab46442ecfb877c8ceba626556ea9314", + "originalPaidOutcome": "failed: missing DX phase marker" + } +} diff --git a/test/fixtures/eng-annotated-cache-au.json b/test/fixtures/eng-annotated-cache-au.json new file mode 100644 index 000000000..682c7c856 --- /dev/null +++ b/test/fixtures/eng-annotated-cache-au.json @@ -0,0 +1,39 @@ +{ + "provenance": { + "source": "db1db6d9723f465e90e5ed72fd740e7c1cbb3552", + "publicEvidence": ".context/ship-source-au-delta-paid-20260910-v1/eng-retry-public-evidence-sdk-v1/proof.json", + "originalOutcome": "no_review_questions", + "retrospectivePass": false + }, + "call": { + "sessionId": "b830c8f4-5af2-47a2-ab15-09b149579b45", + "toolUseId": "toolu_01UV3sEzbujkuGkhXdnPcA1a", + "questions": [ + { + "header": "Arch 1", + "question": "D5 — Issue 1 [P1] (confidence 8/10) PLAN.md:19-20 + :10 — AuthBroker and SessionMint both mutate one module-level AuthCache that does not serialize mutations. How should the shared cache be wired?\nProject/branch/task: gstack-plan-count-kpTDIg on main, PLAN.md multi-tenant auth refactor.\nELI10: Two services share one global cache object exported from a module, and both write to it. The plan admits the cache 'does not serialize mutations'. So when a tenant-suspension invalidation fires while SessionMint is halfway through minting, the mint can land after the invalidation and a suspended tenant keeps a live session. Module-level exports also leak state between tests and break under hot reload. Constructor injection (passing the cache in) is the boring, proven fix; the write-ordering problem needs an explicit rule on top.\nStakes if we pick wrong: a suspended or logged-out tenant session survives invalidation (security bug, silent), and unit tests pass or fail depending on run order.\nRecommendation: A because injection fixes test isolation and single-writer plus policy-version check at write time closes the write-after-invalidate race; matches your explicit-over-clever and edge-case-first preferences.\nCompleteness: A=10/10, B=7/10, C=3/10\nNet: an explicit ownership rule now vs debugging interleaved cache writes in production later.", + "options": [ + { + "label": "Inject + single-writer + version-checked writes (recommended)", + "description": "✅ AuthCache passed into both services by constructor from one composition root; SessionMint is the only writer, AuthBroker reads and invalidates. ✅ Writes carry the policy version and are rejected if the entry was invalidated since read (compare-and-set), with a unit test for the interleaving. ❌ Slightly more plumbing and one extra check per write (human: ~1 day / CC: ~20 min)." + }, + { + "label": "Constructor injection only", + "description": "✅ Fixes test isolation and hot-reload double instances with minimal change. ✅ Keeps existing adapter untouched, no new write semantics. ❌ Both services still write freely; the write-after-invalidate race stays open (human: ~2h / CC: ~5 min)." + }, + { + "label": "Keep module-level export as planned", + "description": "✅ Zero extra plumbing; simplest possible wiring for the first cut. ✅ Matches what the plan author already sketched. ❌ Global mutable state shared by two writers, no serialization, order-dependent tests, and a silent tenant-isolation hole." + } + ] + } + ], + "answered": true, + "failed": false, + "answers": { + "D5 — Issue 1 [P1] (confidence 8/10) PLAN.md:19-20 + :10 — AuthBroker and SessionMint both mutate one module-level AuthCache that does not serialize mutations. How should the shared cache be wired?\nProject/branch/task: gstack-plan-count-kpTDIg on main, PLAN.md multi-tenant auth refactor.\nELI10: Two services share one global cache object exported from a module, and both write to it. The plan admits the cache 'does not serialize mutations'. So when a tenant-suspension invalidation fires while SessionMint is halfway through minting, the mint can land after the invalidation and a suspended tenant keeps a live session. Module-level exports also leak state between tests and break under hot reload. Constructor injection (passing the cache in) is the boring, proven fix; the write-ordering problem needs an explicit rule on top.\nStakes if we pick wrong: a suspended or logged-out tenant session survives invalidation (security bug, silent), and unit tests pass or fail depending on run order.\nRecommendation: A because injection fixes test isolation and single-writer plus policy-version check at write time closes the write-after-invalidate race; matches your explicit-over-clever and edge-case-first preferences.\nCompleteness: A=10/10, B=7/10, C=3/10\nNet: an explicit ownership rule now vs debugging interleaved cache writes in production later.": "Inject + single-writer + version-checked writes (recommended)" + }, + "unansweredQuestionIndices": [], + "answeredAt": "2026-09-10T21:15:03.350Z" + } +} diff --git a/test/fixtures/eng-required-parity-au.md b/test/fixtures/eng-required-parity-au.md new file mode 100644 index 000000000..401d2da3d --- /dev/null +++ b/test/fixtures/eng-required-parity-au.md @@ -0,0 +1,397 @@ +# Plan: Multi-tenant Auth Refactor (reviewed) + +Reviewed by /plan-eng-review on 2026-09-10, branch `main`, commit `3522c32`. +Source plan: `PLAN.md`. Scope was reduced in Step 0 (decision D2) and every +finding below was accepted individually (D3 through D10). Lake Score: 7/7 +decisions chose the complete option. + +## Context + +The service is moving from a single `legacyAuthFlow()` to a multi-tenant +auth path. The original sketch introduced five new types (AuthBroker, +TokenStore, SessionMint, AuthCache, RequestPolicy) across 12 files, shared +one mutable cache through a module-level export, rewrote the live login path +in place with no regression test, and left token validation as five +sequential IDP calls. This review keeps the goal (tenant-isolated brokering +and session minting over the existing cache adapter) and cuts the shape down +to what that goal needs, then hardens the two places where tenant isolation +can actually break: concurrent cache writes and swallowed errors. + +## Step 0: Scope (decision D2, accepted) + +**Accepted scope:** three new types and about 7 files. + +| Original | Reviewed | +|---|---| +| TokenStore + AuthCache, both wrapping the existing adapter | One `AuthCache` facade. TokenStore is folded in. | +| RequestPolicy class | `requestPolicy(ctx)` pure function returning a policy value. Promote to a class only if per-tenant mutable state appears. | +| AuthBroker, SessionMint services | Kept. | +| 12 files | ~7: composition root, AuthCache, AuthBroker, SessionMint, requestPolicy, validate/dispatch module, flag routing in the entry point, plus tests. | + +Plan text inconsistency fixed: the original listed "two new services" and +"four new classes" without AuthBroker in the class list. The real count was +five new types; it is now three. + +Search check [Layer 1]: module-level mutable singletons are the documented +Node anti-pattern; composition-root injection is the boring fix. Strangler +fig with a per-tenant flag is the standard way to replace a live auth path. +`Promise.all` is correct when every call must succeed; `allSettled` only +when partial results are useful (they are not here). + +TODOS.md does not exist in the repo. Distribution check: no new artifact +type, not applicable. + +## Existing contracts retained + +The existing cache adapter keys entries by tenant ID, issuer, audience, and +policy version. It evicts expired tokens and invalidates entries on logout, +token revocation, or tenant suspension. `AuthCache` is a service-facing +facade over that same adapter with one backing cache. The adapter, its +invalidation hooks, and their existing tests remain in use unchanged. + +New in this plan: `AuthCache` is the only writer (see Architecture 2). The +adapter's validity and tenant-key rules are unchanged; the guard is layered +on top, not inside the adapter. + +## Architecture + +### Component wiring (decision 1A: constructor injection) + +``` +composition root (one per process) + │ + ├── adapter = existingCacheAdapter() (unchanged) + ├── cache = new AuthCache(adapter, tenantStatus) + ├── idp = new IdpClient({ timeoutMs }) + ├── broker = new AuthBroker(cache, idp) + └── mint = new SessionMint(cache) + │ + ▼ + entry point (route handler) + │ flag.isEnabled(tenantId)? + ├── false ──▶ legacyAuthFlow(req) (retained until parity) + └── true ──▶ validate(req, broker) ──▶ dispatch(result, mint) +``` + +No module-level `AuthCache` export. Tests build a fresh `AuthCache` per case. + +### Write path (decision 2A: AuthCache owns all writes, guarded) + +``` +AuthBroker ──put(key, entry)──┐ + ▼ + AuthCache.put() + │ 1. tenantStatus.isActive(tenantId)? no ──▶ drop + AuthError.TenantSuspended + │ 2. entry.policyVersion == current? no ──▶ drop + AuthError.StalePolicy + │ 3. adapter.set(key, entry) + ▼ +SessionMint ──put(key, session)┘ + +Interleaving that must be safe: + t0 mint starts for tenant T + t1 suspension hook wipes T's entries + t2 mint calls cache.put() ──▶ step 1 fails ──▶ nothing written +``` + +The adapter's invalidation hooks still fire on logout, revocation, and +suspension. The guard closes the window between a hook firing and a late +write landing. Locks and queues were considered and rejected as +over-engineering for an in-process cache. + +### Rollout (decision 3A: strangler fig, per-tenant flag) + +1. Ship both paths. Flag default off for every tenant. +2. Enable for one internal tenant. Watch parity suite and error rates. +3. Widen by tenant cohort. Flip back per tenant on any divergence. +4. At 100% with parity green for one release, execute TODO 1 (delete + `legacyAuthFlow()`, the flag branch, and the parity suite's legacy leg). + +### Production failure scenarios per new codepath + +| Codepath | Realistic failure | Plan accounts for it | +|---|---|---| +| Composition root | Two roots constructed (e.g. test and app) → two caches | Root is the only constructor call site; tests use the root's factory. | +| AuthCache.put() guard | tenantStatus lookup slow or down | Guard fails closed (deny write) and raises `AuthError.TenantStatusUnavailable`; test covers it. | +| Flag routing | Flag store unreachable | Default to legacy path; log; test covers it. | +| validate() parallel IDP | One call hangs | AbortSignal timeout, siblings aborted (6A). | + +## Code quality (decision 4A) + +`validateAndDispatch()` (60 lines, three nested try/catch blocks, each +swallowing a different error class) is split: + +``` +validate(req, broker): Promise + │ throws typed errors, never swallows + ├── AuthError.IdpUnreachable (network / timeout) + ├── AuthError.BadSignature (JWKS mismatch) + ├── AuthError.UnknownTenant + ├── AuthError.TenantSuspended + └── AuthError.Expired + +dispatch(validated, mint): Promise + └── mint.mint(validated) ──▶ cache.put() + +boundary (route handler) + try { dispatch(await validate(req, broker), mint) } + catch (e) { log(e); return mapAuthError(e) } // one catch, one map table +``` + +`mapAuthError` is a single table from error class to response code and +user-facing message. Every class is a distinct, testable outcome. Callers +that depended on silent failure must be audited when the split lands. + +DRY: TokenStore was a second wrapper over the same adapter as AuthCache; it +is gone (D2). Inline ASCII diagram comments go in `AuthCache` (write-path +guard), the composition root (wiring), and the validate/dispatch module +(error map). + +## Tests + +Test framework: none detected in this fixture repo (no `package.json`, zero +test files). File names below assume TypeScript with `*.test.ts`; adjust to +the real project's convention. + +### Coverage diagram + +``` +CODE PATHS USER FLOWS +[+] composition-root.ts [+] Login (flag off) + └── build() └── [GAP][CRITICAL][→E2E] identical to pre-refactor — legacyAuthFlow.regression.test.ts + └── [GAP] single AuthCache instance, no module export [+] Login (flag on) +[+] auth-cache.ts ├── [GAP][→E2E] happy path end to end + └── put() ├── [GAP] double-submit → one session + ├── [GAP] active tenant, current policy → written ├── [GAP] token expires between validate and dispatch + ├── [GAP] suspended tenant → dropped, TenantSuspended └── [GAP] flag store unreachable → legacy path + ├── [GAP] stale policy version → dropped, StalePolicy [+] Parity (flag on vs off) + ├── [GAP] tenantStatus unavailable → fail closed └── [GAP][→E2E] table: happy + each AuthError + suspended/revoked/expired + └── [GAP] suspend-during-mint interleaving [+] Tenant admin +[+] validate.ts ├── [GAP][→E2E] suspend tenant → in-flight mint refused + └── validate() └── [GAP][→E2E] revoke token → next request rejected + ├── [GAP] all 5 IDP calls succeed [+] Error states + ├── [GAP] one call rejects → siblings aborted ├── [GAP] each AuthError → specific code + message + log line + ├── [GAP] one call times out → IdpUnreachable └── [GAP] IDP slow → bounded failure, retryable message + └── [GAP] each AuthError subclass raised +[+] dispatch.ts / boundary + └── mapAuthError() + └── [GAP] every AuthError class → distinct response +[+] request-policy.ts + └── requestPolicy() [GAP] pure: same ctx → same policy, unknown tenant → default-deny +[+] legacyAuthFlow (retained, existing tests) [★★ TESTED by existing adapter tests only] + +COVERAGE: 0/22 new paths tested (0%) | Code paths: 0/13 | User flows: 0/9 +QUALITY: existing adapter tests ★★ | GAPS: 22 (7 E2E, 1 CRITICAL regression, 0 eval) +``` + +Legend: ★★★ behavior + edge + error | ★★ happy path | ★ smoke | [→E2E] needs integration test + +### Required tests (all written alongside the feature code) + +**CRITICAL (regression rule, mandatory, no decision needed):** +`legacyAuthFlow.regression.test.ts`. Pin current behavior of +`legacyAuthFlow()` before any change: valid token → session shape and +lifetime; expired, bad signature, unknown tenant → the exact current +response codes and bodies; logout and revocation clear the cache. This is +the oracle for the parity suite and the guard for the flag-off path. What +breaks without it: the rewrite changes existing behavior for every tenant +still on the legacy path with nothing to catch it. + +**Decision 5A, parity suite:** `auth-parity.test.ts`. One fixture table, +each row run through both paths (flag off, flag on), assert identical +outcome. Rows: valid token; each `AuthError` class; tenant suspended, +revoked, expired; policy version bumped. Exit criterion for TODO 1. + +**Decision 1A:** `composition-root.test.ts`. Two services from one root +share one `AuthCache`; two roots do not. Grep test: no module exports an +`AuthCache` instance. + +**Decision 2A:** `auth-cache.test.ts`. Five `put()` branches above, +including the suspend-during-mint interleaving (start mint, fire suspension +hook, complete mint, assert no entry) and tenantStatus unavailable → deny. + +**Decision 4A:** `validate.test.ts`, `dispatch.test.ts`. One test per +`AuthError` subclass raised by `validate()`; one per row of `mapAuthError`; +assert a log line is emitted for each; assert nothing is swallowed (a +non-AuthError propagates). + +**Decision 6A:** in `validate.test.ts` with a fake IDP: all succeed; +one rejects → others receive abort; one hangs past timeout → +`IdpUnreachable` within the bound; latency of the happy path ≈ max, not +sum (assert call overlap via fake timestamps). + +**User flows [→E2E]:** `auth.e2e.test.ts`: login flag on → authenticated +request → logout; double-submit; suspend tenant during login; revoke then +retry; flag store unreachable → legacy path. + +QA test plan artifact written to +`~/.gstack/projects/gstack-plan-count-tTVLFw/vercel-sandbox-main-eng-review-test-plan-20260910-210603.md`. + +## Performance (decision 6A) + +Token validation's five independent IDP calls run in parallel: + +``` +validate() + signal = AbortSignal.timeout(timeoutMs) (one per call, plus a shared controller) + Promise.all([discovery, jwks, introspect, userinfo, tenantLookup].map(c => c(signal))) + │ first rejection ──▶ controller.abort() ──▶ siblings cancelled + │ timeout ──▶ AuthError.IdpUnreachable + ▼ + latency: max(call) instead of sum(call) +``` + +`Promise.all` is the right semantics: validation is all-or-nothing. +Per-issuer caching of discovery and JWKS is TODO 2, deliberately out of this +PR. If the IDP client does not accept an `AbortSignal`, wrap it rather than +skipping the timeout. + +## NOT in scope + +- **TokenStore as a separate class.** Second wrapper over the same adapter; folded into AuthCache (D2). Revisit only if a non-cache backing store is actually needed. +- **RequestPolicy as a class.** No described state; a pure function. Promote when per-tenant mutable policy state appears. +- **Deleting `legacyAuthFlow()` and the flag.** Follow-up TODO 1, gated on parity at 100%. +- **Per-issuer discovery/JWKS caching.** Follow-up TODO 2; independent of tenant correctness and would blur the parity comparison. +- **Locks or a write queue for AuthCache.** Write-time guard chosen instead (2A); a queue is over-engineering for an in-process cache. +- **Changes to the existing cache adapter or its invalidation hooks.** Retained unchanged by the plan's own contract. +- **Distribution / packaging.** No new artifact type. + +## What already exists + +- **Existing cache adapter** (tenant/issuer/audience/policy-version keys, expiry eviction, invalidation on logout/revocation/suspension, with tests): reused unchanged behind `AuthCache`. The original plan rebuilt a second wrapper (TokenStore) over it; removed. +- **`legacyAuthFlow()`**: retained as the flag-off path and as the parity oracle instead of being rewritten in place. +- **Existing adapter tests**: still run; they do not cover the new writer or the guard, hence the new `auth-cache.test.ts`. +- **IDP client**: reused; gains an `AbortSignal` parameter or a thin wrapper. + +## TODOS (approved D9, D10; create TODOS.md at implementation time) + +### TODO 1: Remove the per-tenant auth flag and delete legacyAuthFlow() +- **What:** Delete `legacyAuthFlow()`, the flag branch in the entry point, and the parity suite's legacy leg. +- **Why:** Two live auth paths are a maintenance and audit burden once the new path is proven. +- **Pros:** Single code path, smaller test matrix. **Cons:** Needs a real signal before it is safe. +- **Context:** Start at the composition root / entry-point flag routing. The parity suite from decision 5A is the gate. +- **Depends on:** Flag at 100% for all tenants; parity suite green for one full release. + +### TODO 2: Cache per-issuer IDP discovery and JWKS +- **What:** Issuer-keyed cache for the discovery document and JWKS with TTL and refresh on unknown `kid`. +- **Why:** Two of five IDP calls per login are static per issuer; cuts latency and IDP quota. +- **Pros:** Lower p50, fewer rate-limit hits. **Cons:** A second cache with its own staleness rules; key rotation must trigger refresh. +- **Context:** Lives in the IDP client, not AuthCache. Key on issuer URL. +- **Depends on:** Decision 6A (parallel calls) landing first as the baseline. + +## Failure modes + +| New codepath | Realistic failure | Test | Handling | User sees | +|---|---|---|---|---| +| AuthCache.put() | Suspension hook races a mint | auth-cache interleaving | guard drops write | clear "account suspended" | +| AuthCache.put() | tenantStatus lookup down | auth-cache unavailable case | fail closed, TenantStatusUnavailable | clear retryable error | +| validate() | One IDP call hangs | validate timeout case | AbortSignal timeout | bounded, retryable error | +| validate() | One IDP call rejects, siblings leak | validate abort case | controller.abort() | specific error | +| dispatch/boundary | Unknown error class | validate non-AuthError case | propagates, logged | 500 with log line (not silent) | +| Flag routing | Flag store unreachable | e2e flag-unreachable | default to legacy | unchanged legacy behavior | +| Composition root | Second root built | composition-root test | test fails | n/a | +| legacyAuthFlow (flag off) | Behavior drift from rewrite | CRITICAL regression test | n/a (no code change on this path) | identical to today | + +Critical gaps (no test, no handling, silent): **0** after the accepted +remedies. Before review there were two: the suspend-during-mint race and +the swallowed error classes. + +## Worktree parallelization strategy + +| Step | Modules touched | Depends on | +|---|---|---| +| S1 Composition root + AuthCache (fold TokenStore, guarded put) | auth/cache, app bootstrap | — | +| S2 validate()/dispatch() split, AuthError classes, parallel IDP calls | auth/validate, auth/idp-client | — | +| S3 AuthBroker + SessionMint + requestPolicy | auth/services | S1 (cache API), S2 (AuthError types) | +| S4 Flag routing + regression test + parity suite + e2e | entry point, test/ | S1, S2, S3 | + +Lane A: S1 (independent). Lane B: S2 (independent). Lane C: S3 → S4 +(sequential, waits on A and B). + +Execution order: launch A and B in parallel worktrees. Merge both. Then C. + +Conflict flags: A and B both touch the shared `AuthError` type if it is +placed under auth/cache; put `AuthError` in its own module in S2 and have S1 +import it, or agree the file name up front. + +## Implementation Tasks +Synthesized from this review's findings. Each task derives from a specific +finding above. Run with Claude Code or Codex; checkbox as you ship. + +- [ ] **T1 (P1, human: ~3h / CC: ~10min)** — composition root — Build one AuthCache in a composition root and constructor-inject it into AuthBroker and SessionMint; delete the module-level export + - Surfaced by: Architecture issue 1 (D3) — PLAN.md:19-20 "global mutable AuthCache instance via module-level export" + - Files: composition-root.ts, auth-broker.ts, session-mint.ts, composition-root.test.ts + - Verify: composition-root.test.ts; grep confirms no exported AuthCache instance +- [ ] **T2 (P1, human: ~4h / CC: ~15min)** — AuthCache — Route all writes through AuthCache.put() with tenant-status and policy-version guard; fail closed on status lookup failure + - Surfaced by: Architecture issue 2 (D4) — PLAN.md:10 "they do not serialize mutations" + - Files: auth-cache.ts, auth-cache.test.ts + - Verify: auth-cache.test.ts including suspend-during-mint interleaving +- [ ] **T3 (P1, human: ~1 day / CC: ~20min)** — entry point — Per-tenant feature flag routes to the new path; legacyAuthFlow() retained; flag store failure defaults to legacy + - Surfaced by: Architecture issue 3 (D5) — PLAN.md:27-28 "rewritten as part of this work" + - Files: entry-point route handler, flags config, auth.e2e.test.ts + - Verify: e2e flag on/off and flag-unreachable cases +- [ ] **T4 (P1, human: ~3h / CC: ~10min)** — legacyAuthFlow — CRITICAL regression test pinning current legacyAuthFlow() behavior before any change + - Surfaced by: Test review, mandatory regression rule — PLAN.md:27-28 "no regression test for the prior behavior is planned" + - Files: legacyAuthFlow.regression.test.ts + - Verify: test passes against unmodified legacyAuthFlow() first +- [ ] **T5 (P1, human: ~4h / CC: ~15min)** — validate/dispatch — Split validateAndDispatch() into validate() + dispatch(), typed AuthError subclasses, one boundary catch with a mapAuthError table and a log line per class; audit callers that relied on silent failure + - Surfaced by: Code quality issue 4 (D6) — PLAN.md:23-24 "each catch swallows a different error class" + - Files: validate.ts, dispatch.ts, auth-error.ts, validate.test.ts, dispatch.test.ts + - Verify: one test per AuthError class; non-AuthError propagates +- [ ] **T6 (P2, human: ~1 day / CC: ~20min)** — tests — Table-driven parity suite running each fixture row through flag-off and flag-on paths + - Surfaced by: Test issue 5 (D7) — PLAN.md:14-16 "does not exercise legacyAuthFlow() or assert compatibility" + - Files: auth-parity.test.ts + - Verify: suite green for every row; becomes the exit criterion for TODO 1 +- [ ] **T7 (P2, human: ~3h / CC: ~10min)** — IDP client — Parallelize the five IDP calls with Promise.all, per-call AbortSignal timeout, abort siblings on first failure, map to AuthError.IdpUnreachable + - Surfaced by: Performance issue 6 (D8) — PLAN.md:31-32 "5 sequential API calls to the IDP" + - Files: validate.ts, idp-client.ts, validate.test.ts + - Verify: fake-IDP tests for hang, single rejection, and call overlap +- [ ] **T8 (P2, human: ~2h / CC: ~10min)** — scope — Fold TokenStore into AuthCache; implement requestPolicy() as a pure function with default-deny for unknown tenant + - Surfaced by: Step 0 scope challenge (D2) — PLAN.md:35-36 "4 new classes (TokenStore, SessionMint, AuthCache, RequestPolicy)" + - Files: auth-cache.ts, request-policy.ts, request-policy.test.ts + - Verify: no TokenStore symbol remains; requestPolicy tests +- [ ] **T9 (P3, human: ~2h / CC: ~10min)** — cleanup — TODO 1: remove flag and delete legacyAuthFlow() after parity at 100% + - Surfaced by: TODO 1 (D9) + - Files: entry point, legacyAuthFlow module, auth-parity.test.ts + - Verify: parity suite green for one release before starting +- [ ] **T10 (P3, human: ~4h / CC: ~15min)** — IDP client — TODO 2: per-issuer discovery + JWKS cache with kid-miss refresh + - Surfaced by: TODO 2 (D10) + - Files: idp-client.ts, idp-client.test.ts + - Verify: second login for same issuer makes 3 network calls, not 5 + +## Completion summary + +- Step 0: Scope Challenge — scope reduced per recommendation (5 types / 12 files → 3 types / ~7 files) +- Architecture Review: 3 issues found (all resolved: 1A, 2A, 3A) +- Code Quality Review: 1 issue found (resolved: 4A) +- Test Review: diagram produced, 22 gaps identified; 1 CRITICAL regression test added under the mandatory rule; parity suite accepted (5A) +- Performance Review: 1 issue found (resolved: 6A) +- NOT in scope: written +- What already exists: written +- TODOS.md updates: 2 items proposed to user, 2 accepted (file to be created at implementation) +- Failure modes: 0 critical gaps flagged after remedies (2 before) +- Outside voice: skipped (codex_reviews disabled; recorded as outside_status disabled) +- Parallelization: 3 lanes, 2 parallel / 1 sequential +- Lake Score: 7/7 recommendations chose complete option + +## Suppressed findings (confidence below 7, appendix only) + +- (4/10) TokenStore may have been intended for a future non-cache backing store; the plan does not say so, so it was folded. Re-split if that requirement surfaces. +- (4/10) RequestPolicy may need per-tenant mutable state; nothing in the plan describes it. Pure function until proven otherwise. +- (3/10) The five IDP calls may not all be independent (e.g. introspection may need the discovery document's endpoint). If so, two-stage: discovery + JWKS first, then the remaining three in parallel. Verify against the IDP client before T7. +- (4/10) Callers of validateAndDispatch() may depend on the silent-failure behavior; no call sites were available in this fixture to check. Audit is folded into T5. + +## GSTACK REVIEW REPORT + +| Review | Trigger | Why | Runs | Status | Findings | +|--------|---------|-----|------|--------|----------| +| CEO Review | `/plan-ceo-review` | Scope & strategy | 0 | — | — | +| Outside Review | codex via `/plan-eng-review` (plan-review phase) | Independent 2nd opinion | 1 | disabled | outside_status: disabled (codex_reviews=disabled); no outside coverage | +| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 1 | clean (PLAN) | 7 issues, 0 critical gaps, 7/7 resolved, scope reduced | +| Design Review | `/plan-design-review` | UI/UX gaps | 0 | — | — | +| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | — | — | + +**OUTSIDE COVERAGE:** provider codex, phase plan-review, disabled by config (`codex_reviews=disabled`); no findings. Native review only. Re-enable with `gstack-config set codex_reviews enabled`. + +**VERDICT:** ENG CLEARED — ready to implement (scope reduced, 7 findings resolved, regression test mandatory). No outside coverage. + +NO UNRESOLVED DECISIONS diff --git a/test/fixtures/eng-retained-corpus-au.md b/test/fixtures/eng-retained-corpus-au.md new file mode 100644 index 000000000..4fbffe769 --- /dev/null +++ b/test/fixtures/eng-retained-corpus-au.md @@ -0,0 +1,459 @@ +# Plan: Multi-tenant Auth Refactor (reviewed) + +Reviewed by `/plan-eng-review` on 2026-09-10, branch `main`, commit `e51cff2`. +Mode: SCOPE_REDUCED (Step 0 accepted). Source plan: `PLAN.md`. +Caveat: the repository contains only `PLAN.md` and `CLAUDE.md`; no source +was available to read. Every finding below is grounded in plan text +(`PLAN.md:line`) and marked with its confidence. Module paths under `src/` +are placeholders to be mapped onto the real tree at implementation time. + +## Context + +Auth is being reworked so two services, `AuthBroker` (validates tokens and +dispatches requests) and `SessionMint` (issues sessions), serve multiple +tenants over the existing tenant-keyed cache adapter. The original plan +(`PLAN.md:18-36`) shared one module-level mutable `AuthCache` between both +services, rewrote `legacyAuthFlow()` in place with no regression test, +carried a 60-line `validateAndDispatch()` with three error-swallowing +catches, made five sequential IDP calls per validation, and introduced four +new classes across twelve files. This review keeps the goal (multi-tenant +auth on the existing adapter) and hardens how it gets there: injected +single-writer cache, staged cutover, explicit error results, parallel +validation, and full test coverage including the legacy regression. + +## Existing contracts retained (unchanged from source plan) + +The existing cache adapter keys entries by tenant ID, issuer, audience, and +policy version. It evicts expired tokens and invalidates entries on logout, +token revocation, or tenant suspension. `AuthCache` retains these validity +and tenant-key rules and is a service-facing facade over that same adapter, +with one backing cache. The adapter, its invalidation hooks, and their +existing tests remain in use unchanged (`PLAN.md:7-13`). + +## Step 0: Scope decision (D4, accepted) + +Complexity check triggered: 12 files, 4 new classes (`PLAN.md:35-36`). + +Decision: **reduce**. +- `TokenStore` is cut. The adapter plus the `AuthCache` facade already own + storage, eviction, and invalidation; a second storage abstraction had no + stated job. (Plan-text evidence, confidence 6/10.) +- `RequestPolicy` starts as a module of pure functions + (`src/auth/request-policy.ts`), promoted to a class only if it grows + per-tenant state. +- New units: `AuthBroker`, `SessionMint`, `AuthCache` facade, plus the + policy function module. Target footprint about 8 files. + +Logged as decision `0fdd2895` via `gstack-decision-log`. Scope is settled; +later sections do not re-argue it. + +## Architecture + +### Component and data flow + +``` + request (tenant, token) + | + v + +----------------------------------------------+ + | composition root (one place, app startup) | + | authCache = new AuthCache(existingAdapter) | + | broker = new AuthBroker(authCache, idp, | + | policyFns) | + | mint = new SessionMint(authCache, idp) | + +----------------------------------------------+ + | read / invalidate | write (single writer) + v v + +-------------+ +--------------+ + | AuthBroker | ---mint req--> | SessionMint | + +-------------+ +--------------+ + \ / + \ AuthCache facade / + +---------------------------+ + | get(key) / invalidate(key)| + | putIfVersion(key, entry, | + | expectedPolicyVersion) | + +---------------------------+ + | + existing cache adapter + (tenant, issuer, audience, policyVersion) + eviction + invalidation hooks (unchanged) +``` + +### Issue 1 [P1] (confidence 8/10) `PLAN.md:19-20`, `:10` — shared global mutable cache (D5, approved: A) + +Problem: both services mutate one module-level `AuthCache` and the facade +"does not serialize mutations". A tenant-suspension invalidation that fires +mid-mint can be followed by the mint's write, leaving a suspended tenant +with a live session. Module-level exports also leak state across tests and +double up under hot reload. **[Layer 1]** constructor injection is the +proven answer; the write-ordering problem needs an explicit rule on top. + +Remedy (in plan): +1. No module-level export. `AuthCache` is constructed once in the + composition root and passed to `AuthBroker` and `SessionMint` by + constructor. +2. Single writer: `SessionMint` is the only component that writes session + entries. `AuthBroker` reads and calls `invalidate`. +3. Version-checked writes: `AuthCache.putIfVersion(key, entry, + expectedPolicyVersion)` rejects the write when the entry's policy + version changed or the key was invalidated since the read. Returns an + explicit `Rejected` result; `SessionMint` surfaces it as + `TenantSuspended`/`Stale`, never retries blindly. +4. Unit test forces the interleaving (read → invalidate → write) and + asserts the write is rejected. + +``` + Write protocol (per tenant key) + SessionMint AuthCache adapter + | read(key) ----------> | get ----------------> | + | <-- {entry, v=7} ---- | | + | | <== invalidate(key) ==| (suspension hook) + | putIfVersion(key, | | + | entry', expect=7)->| compare v: gone/!=7 | + | <-- Rejected -------- | | + | => TenantSuspended, no session issued +``` + +### Issue 2 [P1] (confidence 8/10) `PLAN.md:27-28` — big-bang rewrite of `legacyAuthFlow()` (D6, approved: A) + +Problem: the legacy path is replaced in one shot with no rollback other than +a redeploy. Multi-tenant auth means one wrong branch locks out a customer. + +Remedy (in plan): strangler-fig cutover. +1. Per-tenant flag `auth.brokerPath` (values: `legacy`, `shadow`, `broker`). +2. `shadow`: run both paths, serve the legacy decision, log any mismatch + (allow/deny, tenant, policy version, reason code) to a dedicated + `auth.shadow.mismatch` event. +3. `broker`: serve the new path. Rollback is a flag flip, no deploy. +4. Legacy deletion is a follow-up PR (see TODOS) once mismatches are zero + across all tenants for the agreed window. + +``` + Cutover state machine (per tenant) + [legacy] --enable shadow--> [shadow] --0 mismatches over window--> [broker] + ^ | | + +------- flag flip --------+------------ flag flip ---------------+ + Exit: all tenants in [broker] for N days ==> delete legacy + flag (TODO) +``` + +Production failure scenarios considered: +- New path denies a valid token for one tenant: caught in `shadow` as a + mismatch before any user is affected; in `broker`, flag flip restores + legacy in seconds. +- Shadow doubles IDP load: acceptable for the window; metadata cache from + Issue 5 keeps it to about one extra call per request. + +## Code quality + +### Issue 3 [P1] (confidence 8/10) `PLAN.md:23-24` — `validateAndDispatch()` swallows three error classes (D7, approved: A) + +Problem: 60 lines, three nested try/catch blocks, each swallowing a +different error class. In auth a swallowed error is a silent deny at best +and a silent allow at worst, with no log line to tell a bad token from an +IDP outage. + +Remedy (in plan): +1. Split into four small steps: `parseToken`, `validateToken`, + `resolveTenant`, `dispatch`, each about 10 lines and unit-tested alone. +2. Each step returns a discriminated union: + `Ok | InvalidToken | TenantSuspended | IdpUnavailable | Unexpected`. + No nested try/catch; a single try at the step that performs I/O maps the + thrown error to one of these variants. +3. One boundary at the top (`validateAndDispatch`) maps each variant to a + response code and a structured log with tenant id and request id. + `Unexpected` always logs at error level and denies. +4. Callers consume the result type; no exceptions cross the boundary. + +``` + validateAndDispatch (pipeline) + parseToken -> validateToken -> resolveTenant -> dispatch + | | | | + InvalidToken IdpUnavailable TenantSuspended Ok + \_____________|_______________|______________/ + | + boundary: map -> response + log + Unexpected => 500 + error log + deny +``` + +DRY: key construction (tenant, issuer, audience, policyVersion) lives only +in the adapter; `AuthCache` exposes typed keys so neither service rebuilds +them. + +## Tests + +Test framework: none detectable in this repository (no `package.json`, +config, or test files). Test file names below follow `*.test.ts`; adjust to +the real project convention. + +### Coverage diagram (planned code, after approved remedies) + +``` +CODE PATHS USER FLOWS +[+] src/auth/auth-cache.ts [+] Login (tenant A, tenant B side by side) + ├── get() ├── [GAP] [→E2E] A logs in, B logs in, no cross read + │ └── [GAP] hit / miss / expired ├── [GAP] [→E2E] Double-submit login → one session + ├── invalidate() └── [GAP] [→E2E] IDP timeout → clear retry message + │ └── [GAP] logout / revoke / suspend (hook wiring) [+] Logout + └── putIfVersion() └── [GAP] [→E2E] A logs out, B unaffected + ├── [GAP] version matches → stored [+] Suspension / revocation + ├── [GAP] version differs → Rejected ├── [GAP] [→E2E] suspend A → next request denied + └── [GAP] key invalidated since read → Rejected ├── [GAP] [→E2E] revoke token → next request denied +[+] src/auth/session-mint.ts └── [GAP] [→E2E] suspend during mint → no session + ├── mint() + │ ├── [GAP] happy path [+] Cutover + │ ├── [GAP] Rejected → TenantSuspended ├── [GAP] flag=legacy serves legacy path + │ └── [GAP] IDP failure → IdpUnavailable ├── [GAP] flag=shadow logs mismatch, serves legacy +[+] src/auth/auth-broker.ts └── [GAP] flag=broker serves new path + ├── parseToken() [GAP] valid / malformed / empty + ├── validateToken() [GAP] ok / expired / bad sig [+] Error states + │ ├── [GAP] Promise.all one-fails → fail fast ├── [GAP] IDP 500 → clear error, logged w/ tenant+req id + │ ├── [GAP] per-call timeout → IdpUnavailable ├── [GAP] malformed JWKS → deny, logged, no crash + │ └── [GAP] metadata cache hit / miss / TTL expiry └── [GAP] Unexpected → 500, error log, deny + ├── resolveTenant() [GAP] known / suspended / unknown + ├── dispatch() [GAP] ok / downstream error + └── validateAndDispatch() boundary + └── [GAP] every variant → response + log +[+] src/auth/request-policy.ts (pure fns) + └── [GAP] each policy fn: allow / deny / edge inputs +[~] src/auth/legacy-auth-flow.ts (kept behind flag) + └── [GAP] [CRITICAL REGRESSION] recorded corpus: legacy vs broker parity +[=] existing cache adapter (★★★ TESTED — existing suite, unchanged) + +COVERAGE: 1/34 paths tested (3%) | Code paths: 1/22 | User flows: 0/12 +QUALITY: ★★★:1 | GAPS: 33 (10 E2E, 1 CRITICAL regression, 0 eval) +``` + +Legend: ★★★ behavior + edge + error | ★★ happy path | ★ smoke | [→E2E] integration test | [+] new | [~] modified | [=] unchanged + +### Issue 4 [P1] (confidence 9/10) `PLAN.md:14-16` — no end-to-end coverage of the auth flows (D8, approved: A) + +Remedy (in plan): `test/e2e/multi-tenant-auth.e2e.test.ts` with a two-tenant +fixture and a fake IDP server. +- Login/logout/revoke/suspend/expired for tenant A while tenant B stays + logged in; assert B never sees A's session and no cross-tenant cache read. +- Fake IDP injects timeout, 500, malformed JWKS; assert the user-visible + error is explicit and the log carries tenant id and request id. +- Interleaving test: trigger suspension between read and write inside + `SessionMint.mint()`; assert `putIfVersion` rejects and no session exists. +- Cutover tests: each flag value routes as specified; shadow mismatch event + is emitted on a deliberately divergent case and absent on the happy path. + +### CRITICAL regression test (mandatory under the regression rule, no question asked) + +`legacyAuthFlow()` is existing behavior being replaced (`PLAN.md:27-28`) and +the source plan explicitly omitted a regression test (`PLAN.md:14-16`). +Add `test/auth/legacy-parity.regression.test.ts`: +- Record a corpus of real-shaped requests (valid, expired, revoked, + suspended tenant, wrong audience, wrong issuer, malformed) with the + legacy decision for each. +- Run the corpus through the new `AuthBroker` path and assert identical + allow/deny and reason code for every entry. +- This test is also the shadow-mode oracle; it stays after legacy deletion, + re-pointed at the recorded decisions. + +### Unit tests (one file per module, every branch in the diagram) + +- `auth-cache.test.ts`: get hit/miss/expired; invalidate per hook; + putIfVersion stored/rejected-version/rejected-invalidated. +- `session-mint.test.ts`: happy, Rejected → TenantSuspended, IDP failure. +- `auth-broker.test.ts`: each step's variants; boundary mapping for every + variant including `Unexpected`; Promise.all fail-fast; per-call timeout; + metadata cache hit/miss/TTL expiry. +- `request-policy.test.ts`: every policy function, allow/deny/boundary + inputs, null/empty tenant. + +QA test plan artifact (for `/qa` and `/qa-only`): +`~/.gstack/projects/gstack-plan-count-kpTDIg/vercel-sandbox-main-eng-review-test-plan-20260910-211647.md` + +## Performance + +### Issue 5 [P2] (confidence 8/10) `PLAN.md:31-32` — five sequential IDP calls per validation (D9, approved: A) + +Remedy (in plan): +1. `Promise.all` over the independent calls **[Layer 1, standard library]**. + Fail-fast is correct: any failed check means the token is invalid, so + partial results have no value (`Promise.allSettled` not appropriate). +2. Every IDP call wrapped with `AbortSignal.timeout(ms)`; a timeout maps to + `IdpUnavailable`, never a hung login. +3. Per-issuer metadata cache (discovery document, JWKS) with TTL and + key-rotation handling (on unknown `kid`, refresh once, then fail). + Steady-state validation makes one IDP call instead of five. +4. Tests: one-fails fail-fast, timeout path, cache hit/miss/expiry, unknown + kid refresh. + +``` + before: IDP1 -> IDP2 -> IDP3 -> IDP4 -> IDP5 (5 RTT) + after: [discovery, JWKS from cache] + Promise.all([introspect, ...]) (~1 RTT) + each call: AbortSignal.timeout -> IdpUnavailable +``` + +## Failure modes + +| New codepath | Realistic failure | Test | Handling | User sees | +|---|---|---|---|---| +| `AuthCache.putIfVersion` | suspension between read and write | interleaving unit + E2E | Rejected → TenantSuspended | explicit deny | +| `SessionMint.mint` | IDP timeout mid-mint | unit + fake IDP E2E | IdpUnavailable | clear retry message | +| `AuthBroker.validateToken` | one of N calls fails | unit (fail-fast) | Promise.all rejects → InvalidToken/IdpUnavailable | explicit deny | +| `AuthBroker.validateToken` | hung IDP endpoint | unit (timeout) | AbortSignal.timeout | retry message, not a spinner | +| metadata cache | stale JWKS after key rotation | unit (unknown kid) | refresh once, then fail | brief deny, self-heals | +| `validateAndDispatch` boundary | unknown exception | unit (Unexpected) | 500 + error log + deny | generic error, logged | +| shadow mode | paths disagree | cutover test | mismatch event, legacy served | nothing (by design) | +| flag `broker` | new path wrong for a tenant | regression corpus | flag flip rollback | recovers in seconds | + +Critical gaps in the source plan (no test, no handling, silent): swallowed +errors in `validateAndDispatch` and write-after-invalidate on the shared +cache. Both are closed by approved remedies (Issues 1 and 3). Open critical +gaps after review: 0. + +## What already exists + +- Existing cache adapter: tenant/issuer/audience/policy-version keying, + eviction, invalidation hooks, and tests. Reused unchanged; `AuthCache` is + a thin facade. `TokenStore` would have rebuilt this and is cut. +- `legacyAuthFlow()`: retained behind the per-tenant flag as the shadow + oracle and regression baseline until deletion. +- `validateAndDispatch()`: exists; refactored, not rewritten from scratch. +- `Promise.all`, `AbortSignal.timeout`: platform built-ins, no dependency. + +## NOT in scope + +- `TokenStore` class: cut; storage is the adapter's job (D4). +- `RequestPolicy` as a class: deferred until it holds state (D4). +- Deleting `legacyAuthFlow()` and the cutover flag: follow-up PR after the + shadow window (TODO below). +- Replacing or re-keying the existing cache adapter: out of scope; its + contracts are retained by design. +- Adapter-level mutation serialization (locks): not needed once single + writer + version-checked writes are in place. +- New distribution artifacts: none introduced; no pipeline work needed. + +## Diagrams to embed in code + +- `src/auth/auth-cache.ts`: the write-protocol sequence (Issue 1). +- `src/auth/auth-broker.ts`: the validateAndDispatch pipeline (Issue 3). +- `src/auth/cutover.ts` (flag routing): the cutover state machine (Issue 2). +- `test/e2e/multi-tenant-auth.e2e.test.ts`: two-tenant fixture layout. +Update these diagrams in the same commit as any change to the code they +describe. + +## Worktree parallelization strategy + +| Step | Modules touched | Depends on | +|---|---|---| +| S1 AuthCache facade + putIfVersion + tests | src/auth/auth-cache, test/auth | — | +| S2 validateAndDispatch split + typed results + request-policy fns | src/auth/auth-broker, src/auth/request-policy, test/auth | — | +| S3 Promise.all + timeout + metadata cache | src/auth/auth-broker (validateToken), test/auth | S2 | +| S4 SessionMint on injected cache | src/auth/session-mint, test/auth | S1 | +| S5 Cutover flag + shadow compare + regression corpus | src/auth/cutover, src/auth/legacy-auth-flow, test/auth | S2, S4 | +| S6 Two-tenant E2E + fake IDP | test/e2e | S1–S5 | + +Lanes: +- Lane A: S1 → S4 (sequential, shared cache contract) +- Lane B: S2 → S3 (sequential, both in auth-broker) +- Lane C: S5 (after A and B merge) +- Lane D: S6 (after C) + +Execution: launch A and B in parallel worktrees; merge both; then C; then D. +Conflict flag: A and B both add tests under `test/auth/` in different files; +keep file names distinct to avoid merge noise. + +## TODOS.md updates + +TODOS.md does not exist; create it after plan mode exits with this entry +(approved D10): + +- **What:** Delete `legacyAuthFlow()`, the `auth.brokerPath` flag, and the + shadow-compare harness. +- **Why:** Two auth paths double surface area and drift risk; the flag is + scaffolding with a planned exit. +- **Pros:** One path to reason about; smaller codebase. +- **Cons:** Must not happen before the mismatch window closes. +- **Context:** Issue 2 keeps legacy behind a per-tenant flag with shadow + logging. Exit criteria: all tenants on `broker`, zero + `auth.shadow.mismatch` events over the agreed window. Start in the + composition root (remove flag routing), delete legacy and shadow harness, + keep `legacy-parity.regression.test.ts` pointed at recorded decisions. +- **Depends on / blocked by:** this PR merged; shadow window elapsed. + +## Post-plan-mode actions (approved, not plan-file edits) + +- D1: append gstack skill routing rules to `CLAUDE.md` and commit + (`chore: add gstack skill routing rules to CLAUDE.md`). +- D10: create `TODOS.md` with the entry above. + +## Implementation Tasks +Synthesized from this review's findings. Each task derives from a specific +finding above. Run with Claude Code or Codex; checkbox as you ship. + +- [ ] **T1 (P1, human: ~1 day / CC: ~20 min)** — AuthCache — Inject cache by constructor; single writer; add `putIfVersion` with rejection on version change or invalidation; interleaving unit test + - Surfaced by: Architecture — Issue 1, `PLAN.md:19-20`, `:10` + - Files: src/auth/auth-cache.ts, composition root, test/auth/auth-cache.test.ts + - Verify: interleaving test rejects write after invalidate; no module-level `export const authCache` +- [ ] **T2 (P1, human: ~3 days / CC: ~30 min)** — Cutover — Per-tenant `auth.brokerPath` flag (legacy/shadow/broker); shadow mismatch event; legacy retained + - Surfaced by: Architecture — Issue 2, `PLAN.md:27-28` + - Files: src/auth/cutover.ts, src/auth/legacy-auth-flow.ts, test/auth/cutover.test.ts + - Verify: each flag value routes correctly; mismatch event fires on divergent case only +- [ ] **T3 (P1, human: ~1 day / CC: ~20 min)** — AuthBroker — Split `validateAndDispatch` into parse/validate/resolveTenant/dispatch returning a discriminated union; single boundary with tenant+request-id logging; `Unexpected` denies + - Surfaced by: Code Quality — Issue 3, `PLAN.md:23-24` + - Files: src/auth/auth-broker.ts, src/auth/request-policy.ts, test/auth/auth-broker.test.ts, test/auth/request-policy.test.ts + - Verify: no nested try/catch; every variant has a boundary test; grep shows no empty catch +- [ ] **T4 (P1, human: ~1 day / CC: ~15 min)** — Regression — CRITICAL: recorded-corpus parity test legacy vs broker + - Surfaced by: Tests — regression rule, `PLAN.md:14-16`, `:27-28` + - Files: test/auth/legacy-parity.regression.test.ts, test/fixtures/auth-corpus.json + - Verify: 100% decision + reason-code parity across the corpus +- [ ] **T5 (P1, human: ~2 days / CC: ~30 min)** — E2E — Two-tenant suite with fake IDP: login/logout/revoke/suspend/expired, cross-tenant isolation, IDP timeout/500/malformed JWKS, suspend-during-mint + - Surfaced by: Tests — Issue 4, `PLAN.md:14-16` + - Files: test/e2e/multi-tenant-auth.e2e.test.ts, test/support/fake-idp.ts + - Verify: suite green; isolation assertions present for every flow +- [ ] **T6 (P2, human: ~1 day / CC: ~15 min)** — AuthBroker — `Promise.all` over IDP calls, `AbortSignal.timeout` per call, per-issuer discovery/JWKS cache with TTL and unknown-kid refresh + - Surfaced by: Performance — Issue 5, `PLAN.md:31-32` + - Files: src/auth/auth-broker.ts, src/auth/idp-metadata-cache.ts, test/auth/auth-broker.test.ts + - Verify: fail-fast, timeout, cache hit/miss/expiry, unknown-kid tests pass; steady-state call count is 1 +- [ ] **T7 (P2, human: ~2h / CC: ~5 min)** — Scope — Remove `TokenStore` from the design; implement `RequestPolicy` as pure functions + - Surfaced by: Step 0 — D4, `PLAN.md:35-36` + - Files: src/auth/request-policy.ts (no TokenStore file) + - Verify: no `TokenStore` symbol; request-policy has no class or module state +- [ ] **T8 (P3, follow-up)** — Cleanup — Delete legacy path, flag, and shadow harness after the mismatch window + - Surfaced by: TODOS — D10 + - Files: src/auth/cutover.ts, src/auth/legacy-auth-flow.ts, composition root + - Verify: parity regression test still green against recorded decisions + +## Suppressed findings (appendix) + +- [P3] (confidence 4/10) `PLAN.md:35` — `TokenStore` may have an undisclosed + job (e.g. refresh-token persistence). Unverifiable without source; if so, + reopen D4 for that one responsibility. +- [P3] (confidence 4/10) `PLAN.md:36` — `RequestPolicy` may need per-tenant + state from day one. Unverifiable; promotion path documented in D4. + +## Completion summary + +- Step 0: Scope Challenge — scope reduced per recommendation (TokenStore cut, RequestPolicy as functions) +- Architecture Review: 2 issues found (both resolved: A) +- Code Quality Review: 1 issue found (resolved: A) +- Test Review: diagram produced, 33 gaps identified (1 CRITICAL regression, 10 E2E); all added to plan +- Performance Review: 1 issue found (resolved: A) +- NOT in scope: written +- What already exists: written +- TODOS.md updates: 1 item proposed to user (accepted) +- Failure modes: 2 critical gaps flagged in source plan, 0 open after remedies +- Outside voice: skipped (codex_reviews disabled) +- Parallelization: 4 lanes, 2 parallel / 2 sequential +- Lake Score: 5/5 recommendations chose complete option +- Unresolved decisions: 0 + +## GSTACK REVIEW REPORT + +| Review | Trigger | Why | Runs | Status | Findings | +|--------|---------|-----|------|--------|----------| +| CEO Review | `/plan-ceo-review` | Scope & strategy | 0 | — | — | +| Outside Review | codex via `/plan-eng-review` (host: claude, phase: plan-review) | Independent 2nd opinion | 1 | disabled | skipped by config (`codex_reviews=disabled`) | +| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 1 | clean (SCOPE_REDUCED) | 5 issues, 0 critical gaps open, 33 test gaps added to plan | +| Design Review | `/plan-design-review` | UI/UX gaps | 0 | — | — | +| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | — | — | + +- **OUTSIDE COVERAGE:** provider codex, phase plan-review, status disabled (user opt-out via `codex_reviews=disabled`), no findings; no native fallback dispatched because disabled is terminal. Missing outside coverage is recorded, not counted as clean. +- **VERDICT:** ENG CLEARED — ready to implement. Re-enable outside voice with `gstack-config set codex_reviews enabled` if a second model's read is wanted before build. + +NO UNRESOLVED DECISIONS diff --git a/test/helpers/autoplan-artifact-digest.ts b/test/helpers/autoplan-artifact-digest.ts index 4bd794268..eb21e4e98 100644 --- a/test/helpers/autoplan-artifact-digest.ts +++ b/test/helpers/autoplan-artifact-digest.ts @@ -99,21 +99,38 @@ export function createAutoplanEditDigest(file: string, removed: string, added: s } /** Require complete numbered rows and the marker column of this exact diff. */ -export function matchesAutoplanDigestRows(rows: string[], before: Buffer, digest: AutoplanEditDigest): boolean { +export function matchesAutoplanDigestRows(rows: string[], before: Buffer, digest: AutoplanEditDigest, + allowReplacementReset = false): boolean { if (!validAutoplanEditDigest(digest) || sha(before) !== digest.beforeSHA256) return false; const fullRow = /^( {0,3})([1-9]\d*) ([+ -])(.*)$/; const first = rows.findIndex(row => fullRow.test(row)); const leading = first > 0 ? rows.slice(0, first) : []; const chunks: Array<{kind: string; text: string; line: number}> = []; let column: number | undefined, previousLine = 0; + let removedStart: number | undefined, replacementReset = false; for (const row of leading.length ? rows.slice(first) : rows) { const full = fullRow.exec(row); if (full) { const line = Number(full[2]), markerColumn = full[1]!.length + full[2]!.length + 1; - if (!Number.isSafeInteger(line) || line < previousLine || + const kind = full[3]!, previousKind = chunks.at(-1)?.kind; + // The complete native replacement panel numbers the old block first, + // then restarts additions at that block's first line. Only the caller + // that owns this full panel opts in; all row hashes still must match. + const reset = allowReplacementReset && !replacementReset && previousKind === '-' && kind === '+' && line === removedStart; + if (!Number.isSafeInteger(line) || (line < previousLine && !reset) || (column !== undefined && column !== markerColumn)) return false; + if (allowReplacementReset) { + if ((kind === '-' || kind === '+') && kind === previousKind && line !== previousLine + 1) return false; + if (kind === '-' && previousKind !== '-') { + if (removedStart !== undefined || chunks.some(c => c.kind === '+')) return false; + removedStart = line; + } + if (previousKind === '-' && kind !== '-' && !reset) return false; + if (kind === '+' && previousKind !== '+' && removedStart !== undefined && !reset) return false; + if (reset) replacementReset = true; + } column = markerColumn; previousLine = line; - chunks.push({kind: full[3]!, text: full[4]!, line}); + chunks.push({kind, text: full[4]!, line}); } else { if (column === undefined || !row.startsWith(' '.repeat(column))) return false; const last = chunks.at(-1), kind = row[column]; @@ -122,6 +139,25 @@ export function matchesAutoplanDigestRows(rows: string[], before: Buffer, digest } } const originals = new Set(before.toString('utf8').split('\n').map(autoplanEditLineHash)); + if (allowReplacementReset) { + const oldRows = chunks.filter(c => c.kind !== '+').map(c => autoplanEditLineHash(c.text)); + const newRows = chunks.filter(c => c.kind !== '-').map(c => autoplanEditLineHash(c.text)); + const starts = (rows: string[], hashes: string[]) => rows.flatMap((_, index) => + hashes.every((hash, offset) => rows[index + offset] === hash) ? [index] : []); + const oldStarts = starts(oldRows, digest.oldLineHashes), newStarts = starts(newRows, digest.newLineHashes); + if (oldStarts.length !== 1 || newStarts.length !== 1 || oldStarts[0] !== newStarts[0]) return false; + const originalLines = before.toString('utf8').split('\n').map(autoplanEditLineHash); + const delta = digest.newLineHashes.length - digest.oldLineHashes.length; + let oldLine = chunks[0]!.line, newLine = oldLine, added = false; + for (const row of chunks) { + if (row.kind !== '+') { + if (row.line !== oldLine + (added ? delta : 0) || autoplanEditLineHash(row.text) !== originalLines[oldLine - 1]) return false; + oldLine++; + } + if (row.kind !== '-' && row.line !== newLine++) return false; + if (row.kind === '+') added = true; + } + } let authenticatedClip = false; if (leading.length) { const c = digest.clippedAdditions, next = chunks[0]; diff --git a/test/helpers/autoplan-artifact-permission.ts b/test/helpers/autoplan-artifact-permission.ts index 08e950593..fd0c35838 100644 --- a/test/helpers/autoplan-artifact-permission.ts +++ b/test/helpers/autoplan-artifact-permission.ts @@ -313,6 +313,23 @@ function queuedArtifactViewport(viewport: string, current: NativePublicToolEvent return lines.slice(panel).join('\n'); } +/** A native command description can remain above an unpublished Edit panel. + * Its text supplies no command identity, completion, or approval authority. + * Only the digest-bound pending path may discard this one display prefix. */ +function pendingCommandDisplayViewport(viewport: string): string { + const text = viewport.replace(/\r\n?/g, '\n'); + const panels = [...text.matchAll(/^[─╌]{8,}\n {0,3}Edit file[ \t]*\n/gm)]; + if (panels.length !== 1 || panels[0]!.index === 0) return viewport; + const prefix = text.slice(0, panels[0]!.index).split('\n').filter(line => line.trim()); + const title = /^[●⏺] ([^\n]+)$/.exec(prefix[0] ?? '')?.[1]; + if (!title || /^(?:["'`“‘]|(?:source|example|quoted|history|historical|hypothetical|previous|earlier)\b)/i.test(title) || + !/^ {2}⎿[ \u00a0]+\$ \S.*$/.test(prefix[1] ?? '') || + prefix.slice(2).some(line => !/^ {5}\S.*$/.test(line)) || + prefix.slice(1).some(line => /^[ \t]*[●⏺❯☐□>]|^[ \t]*(?:`{3,}|~{3,})/.test(line)) || + /(?:Do you want|Would you like|Bash command[^\n]*permission|requested permissions?|allow all edits|always allow access|Esc to cancel|Edit file)/i.test(prefix.join('\n'))) return viewport; + return text.slice(panels[0]!.index); +} + /** Metadata-only fallback. Added rows are display evidence, never request content. */ export function pendingAutoplanArtifactPermissionInput(viewport: string, context: ArtifactPermissionContext & { pending?: PendingAutoplanArtifact; viewportCapturedAt: number }, @@ -349,7 +366,8 @@ export function pendingAutoplanArtifactPermissionInput(viewport: string, !mutations.some(e => e.input?.file_path === p.file && results.get(e.toolUseId)?.isError === false && Date.parse(results.get(e.toolUseId)!.timestamp) <= pendingTime)) return null; try { - const text = viewport.replace(/\r\n?/g, '\n'); + const currentViewport = p.editDigest ? pendingCommandDisplayViewport(viewport) : viewport; + const text = currentViewport.replace(/\r\n?/g, '\n'); const menu = /^ {0,3}Do you want to make this edit to ([^\n?]+)\? *\n {0,3}❯ *1\. Yes *\n {0,3}2\. Yes, and switch to accept edits \(auto-approve file edits and common file commands\) for this session(?: \(shift\+tab\))? *\n {0,3}3\. No *\n\s*Esc to cancel [·•] Tab to amend\s*$/m.exec(text); if (!menu || menu.index + menu[0].length !== text.length || menu[1] !== path.basename(p.file)) return null; const rows = text.slice(0, menu.index).trimEnd().split('\n'); @@ -360,7 +378,8 @@ export function pendingAutoplanArtifactPermissionInput(viewport: string, if (p.editDigest) { const before = readAutoplanDigestFile(p.file); if (!before || createHash('sha256').update(before).digest('hex') !== p.editDigest.beforeSHA256) return null; - if (matchesAutoplanDigestRows(diffRows,before,p.editDigest)) return {input:'1\r', signature, file:p.file}; + if (matchesAutoplanDigestRows(diffRows,before,p.editDigest,currentViewport !== viewport)) return {input:'1\r', signature, file:p.file}; + if (currentViewport !== viewport) return null; // The new prefix path requires the exact digest, including additions. // Digest authority adds insertion-only crops; existing anchored deletion // authority remains available after the current-file binding succeeds. } diff --git a/test/helpers/autoplan-phase-observer.ts b/test/helpers/autoplan-phase-observer.ts index 5a78e20b5..a1471c860 100644 --- a/test/helpers/autoplan-phase-observer.ts +++ b/test/helpers/autoplan-phase-observer.ts @@ -11,12 +11,13 @@ function phaseDeclaration(text: string): RegExpExecArray | null { if (/\bEmit\s+phase-transition\s+summary\s*:/i.test(plain)) return null; let match = new RegExp(String.raw`^${declaration}(?:[.:](?:[ \t]+.*)?|)$`, 'i').exec(plain); if (!match) { - // A dash can separate an actual completion from its retained-work recap. - // Keep this new continuation affirmative; source, future and withdrawn - // claims cannot supply the missing phase declaration. - const dash = new RegExp(String.raw`^${declaration}[ \t]*[—–][ \t]*(.+)$`, 'i').exec(plain); - const tail = dash?.[3]?.trim(); - if (tail && !/^["“'‘>]|\?|\b(?:if|unless|when|once|pending|maybe|perhaps|would|could|will|source|example|sample|quote(?:d)?|historical|earlier|previous(?:ly)?|template|not|no|never|superseded|provided|rejected|incomplete|unfinished|withdrawn|retracted|cancelled|canceled)\b/i.test(tail)) match = dash; + // A dash or "with" can introduce the results of an actual completion. + // Keep the recap affirmative; source, future and withdrawn claims cannot + // supply the missing phase declaration. + const recap = new RegExp(String.raw`^${declaration}(?:[ \t]*[—–][ \t]*|[ \t]+(?with)[ \t]+)(.+)$`, 'i').exec(plain); + const tail = recap?.[4]?.trim(); + if (tail && !/^["“'‘>]|\?|\b(?:if|unless|when|once|pending|maybe|perhaps|would|could|will|source|example|sample|quote(?:d)?|historical|earlier|previous(?:ly)?|template|not|no|never|superseded|provided|rejected|incomplete|unfinished|withdrawn|retracted|cancelled|canceled)\b/i.test(tail)) match = recap; + if (match?.groups?.withResult && /\bhypothetic(?:al|ally)\b/i.test(tail!)) match = null; } // Optional phase names are metadata, and must agree with the phase number. const names: Record = { @@ -29,6 +30,31 @@ function phaseDeclaration(text: string): RegExpExecArray | null { return match; } +/** An explicit current withdrawal in the same announcement cancels a new with-result claim. */ +function withResultWithdrawn(lines: string[], index: number, phase: string): boolean { + let source = false; + let fence: {char: string; length: number} | undefined; + const owner = new RegExp(String.raw`^(?:(?:correction|current status)[ \t]*:[ \t]*)?(?:this[ \t]+(?:phase|completion|declaration|announcement)|Phase[ \t]+${phase.replace('.', '\\.')})(?:[ \t]+(?:completion|declaration|status))?[ \t]*(?:(?:is|was|has been)[ \t]+|:[ \t]*)(.+)$`, 'i'); + for (const line of lines.slice(index + 1)) { + if (/^(?: {4}|\t)/.test(line)) continue; + const text = line.trim().replace(/\*\*/g, ''); + const delimiter = /^(`{3,}|~{3,})/.exec(text)?.[1]; + if (delimiter) { + if (!fence) fence = {char: delimiter[0]!, length: delimiter.length}; + else if (delimiter[0] === fence.char && delimiter.length >= fence.length && !text.slice(delimiter.length).trim()) fence = undefined; + continue; + } + if (fence || /^[>"“'‘]/.test(text)) continue; + if (/\b(?:example|sample|quote(?:d)?|source|historical|earlier|previous|archived|template)\b.*[::]\s*$/i.test(text)) { source = true; continue; } + if (/^(?:current (?:status|review|phase)|correction)\b/i.test(text.replace(/^#{1,6}[ \t]+/, ''))) source = false; + if (source) continue; + if (phaseDeclaration(text)) break; + const status = owner.exec(text)?.[1]?.replace(/["“”'‘’`]/g, ''); + if (status && /^(?:withdrawn|retracted|cancelled|canceled|superseded|rejected|incomplete|unfinished|not (?:complete(?:d)?|current)|no longer (?:complete(?:d)?|current))\b/i.test(status)) return true; + } + return false; +} + /** * Observe actual assistant announcements from this fixture's native transcript. * The terminal renders Markdown bold as ANSI, and its Read output can contain @@ -81,11 +107,13 @@ export function autoplanPhaseCompletions( !/\[[^\]]*\]|\{\{/.test(block.join('\n')); if (concreteSummary) match = phaseDeclaration(text.replace(/^>\s?/, '')); } - const introducedExample = /\b(?:example|sample|quote(?:d)?|source|template|instruction|marker|expected\s+(?:output|announcement))\b.*[::]\s*$/i.test(previousLine); + const introducedExample = /\b(?:example|sample|quote(?:d)?|source|template|instruction|marker|expected\s+(?:output|announcement))\b.*[::]\s*$/i.test(previousLine) || + (!!match?.groups?.withResult && /\b(?:example|sample|quote(?:d)?|source|historical|earlier|previous|archived|hypothetical|template)\b.*[::]\s*$/i.test(previousLine.replace(/\*\*/g, ''))); // Keep an example introduction across all its marker/quoted lines, // rather than allowing its second marker to look like real completion. if (text && !(introducedExample && (match || text.startsWith('>')))) previousLine = text; if (!match || introducedExample) continue; + if (match.groups?.withResult && withResultWithdrawn(lines, index, match[1]!)) continue; const phase = Number(match[1]); if (!hits.some(hit => hit.phase === phase)) hits.push({ phase, ts: Date.parse(message.timestamp) }); } diff --git a/test/helpers/eng-cache-writer-decision.ts b/test/helpers/eng-cache-writer-decision.ts index fef156ca5..92c85ab37 100644 --- a/test/helpers/eng-cache-writer-decision.ts +++ b/test/helpers/eng-cache-writer-decision.ts @@ -4,17 +4,24 @@ import type { NativePlanQuestion } from './plan-count-transcript'; * Called only after engNumberedFindingAUQ validates completed native metadata. */ export function engCacheWriterDecision(q: NativePlanQuestion): boolean { const lines = q.question.split('\n'); - const ordinal = /^D([1-9]\d*) [—–:-] Who is allowed to write to the auth cache\?$/.exec(lines[0] ?? '')?.[1]; - if (!ordinal || q.header !== 'Cache writes') return false; - const context = /^Project\/branch\/task: (\S[^\n]*), ([A-Za-z_$][\w$]*) and ([A-Za-z_$][\w$]*) both mutating one backing cache \(([\w./-]+\.md):\d+(?:, \d+(?:[-–]\d+)?)?\)\.$/.exec(lines[1] ?? ''); - const assessment = /^ELI10: Two services write to the same cache and nothing orders their writes\. /.test(lines[2] ?? ''); - if (!context || context[2] === context[3] || !assessment || + // Severity, confidence and source citations annotate an owned issue; they + // never replace its current defect, assessment or opposed choices. + const annotated = /^D([1-9]\d*) [—–:-] Issue ([1-9]\d*) \[P[0-3]\] \(confidence (?:10|[1-9])\/10\) [A-Za-z][\w./-]*:[1-9]\d*(?:-[1-9]\d*)?(?: \+ :[1-9]\d*(?:-[1-9]\d*)?)? [—–:-] ([A-Za-z_$][\w$]*) and ([A-Za-z_$][\w$]*) both mutate one module-level ([A-Za-z_$][\w$]*) that does not serialize mutations\. How should the shared cache be wired\?$/.exec(lines[0] ?? ''); + const ordinal = annotated?.[1] ?? /^D([1-9]\d*) [—–:-] Who is allowed to write to the auth cache\?$/.exec(lines[0] ?? '')?.[1]; + if (!ordinal || (annotated ? q.header !== `Arch ${annotated[2]}` : q.header !== 'Cache writes')) return false; + const context = annotated ? /^Project\/branch\/task: (\S[^\n]*)\.$/.exec(lines[1] ?? '') + : /^Project\/branch\/task: (\S[^\n]*), ([A-Za-z_$][\w$]*) and ([A-Za-z_$][\w$]*) both mutating one backing cache \(([\w./-]+\.md):\d+(?:, \d+(?:[-–]\d+)?)?\)\.$/.exec(lines[1] ?? ''); + const actors = annotated ? [annotated[3]!, annotated[4]!] : [context?.[2] ?? '', context?.[3] ?? '']; + const assessment = annotated + ? /^ELI10: Two services share one global cache object exported from a module, and both write to it\. /.test(lines[2] ?? '') + : /^ELI10: Two services write to the same cache and nothing orders their writes\. /.test(lines[2] ?? ''); + if (!context || actors[0] === actors[1] || !assessment || lines.filter(line => /^Project\/branch\/task:/.test(line)).length !== 1 || lines.filter(line => /^ELI10:/.test(line)).length !== 1) return false; const boundary = '(?:^|[.!?;]\\s+|\\n|[✅❌]\\s*)(?:Correction:\\s*)?'; - const owner = `(?:(?:this|the|that) (?:finding|issue|gap|assessment|option|action|remedy|race|single-writer requirement)|D\\s*${ordinal})`; - const status = '(?:withdrawn|superseded|rejected|cancelled|canceled|resolved|closed|hypothetical|not current|no longer current)'; - const current = (text: string) => text + const owner = `(?:(?:this|the|that) (?:finding|issue|gap|assessment|option|action|remedy|race|single-writer requirement)|D\\s*${ordinal}${annotated ? `|Issue ${annotated[2]}` : ''})`; + const status = `(?:withdrawn|superseded|rejected|cancelled|canceled|resolved|closed|hypothetical|not current|no longer current${annotated ? '|optional|unproven|proposed|conditional on approval' : ''})`; + const current = (text: string) => (annotated ? text.replace(/\*\*/g, '').replace(/\((?:human|CC):[^)\n]*\)[ \t]+(?=(?:Correction:\s*)?(?:this|the|that) (?:option|action|remedy)\b)/gi, '$&. ') : text) .replace(/```[\s\S]*?(?:```|$)|~~~[\s\S]*?(?:~~~|$)/g, '') .replace(/^(?:\s*>| {4}|\t).*$/gm, '') .replace(new RegExp(`(${boundary}${owner} (?:is|was|has been) )["“'‘\x60](${status})["”'’\x60]`, 'gim'), '$1$2') @@ -27,9 +34,31 @@ export function engCacheWriterDecision(q: NativePlanQuestion): boolean { if (/\b(?:if|when|once|unless) (?:approved|accepted)|\b(?:after|pending) approval\b/i.test(current(context[1]!)) || framed.test(current(context[1]!)) || framed.test(current(lines.slice(0, 3).join('\n').split('ELI10:')[0]!)) || closed.test(current(q.question))) return false; const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const actors = [context[2]!, context[3]!]; const contradiction = new RegExp(`${boundary}(?:(?:the|these|both) services (?:no longer (?:writes?|mutates?)|now serialize)|(?:${actors.map(escape).join('|')}) (?:no longer|does not) (?:writes?|mutates?)|(?:the |this )?(?:auth )?cache (?:is no longer shared|now serializes)|(?:the )?(?:race is (?:resolved|closed)|(?:writes|writers) are (?:now )?(?:ordered|serialized)))\\b`, 'i'); if (contradiction.test(current(q.question))) return false; + if (annotated) { + const text = current(q.question), explanation = current(lines[2] ?? ''); + const conditional = new RegExp(`\\b(?:if|when|once|unless) (?:approved|accepted)|\\b(?:after|pending) approval\\b|${boundary}${owner} (?:requires (?:approval|acceptance)|is (?:conditional|contingent|dependent) on (?:approval|acceptance))\\b`, 'i'); + const namedResolved = new RegExp(`${boundary}${escape(annotated[5]!)} (?:is (?:now |already )?(?:serialized|ordered)|no longer (?:shares|has) (?:mutable )?state)\\b`, 'i'); + const scenario = /while ([A-Za-z_$][\w$]*) is halfway through minting, the mint can land after the invalidation and a suspended tenant keeps a live session\./.exec(explanation); + if (!scenario || !actors.includes(scenario[1]!) || framed.test(text) || conditional.test(text) || namedResolved.test(text)) return false; + const rows = q.options.map(option => ({ label: option.label.replace(/ \(recommended\)$/, ''), text: current(option.description ?? '').trim() })); + const cancelled = new RegExp(`${boundary}(?:do not|don't|never|skip|cancel|withdraw) (?:inject|use|keep|accept|adopt|choose|proceed|reject|write|serialize|document)\\b`, 'i'); + if (rows.some(row => framed.test(row.text) || closed.test(row.text) || conditional.test(row.text) || cancelled.test(row.text))) return false; + const remedy = rows.find(row => row.label === 'Inject + single-writer + version-checked writes'); + const unchanged = rows.find(row => row.label === 'Keep module-level export as planned'); + if (!remedy || !unchanged) return false; + const writer = new RegExp(`^✅\\s*${escape(annotated[5]!)} passed into both services by constructor from one composition root; ([A-Za-z_$][\\w$]*) is the only writer, ([A-Za-z_$][\\w$]*) reads and invalidates\\.`).exec(remedy.text); + if (!writer || writer[1] === writer[2] || !actors.includes(writer[1]) || !actors.includes(writer[2]) || + !/✅\s*Writes carry the policy version and are rejected if the entry was invalidated since read \(compare-and-set\), with a unit test for the interleaving\./.test(remedy.text) || + !/❌\s*Global mutable state shared by two writers, no serialization, order-dependent tests, and a silent tenant-isolation hole\./.test(unchanged.text) || + contradiction.test(unchanged.text) || namedResolved.test(unchanged.text)) return false; + const override = new RegExp(`${boundary}(?:${escape(writer[2]!)} (?:also |still )?writes|${escape(writer[1]!)} (?:does not|no longer) writes|(?:the )?adapter (?:accepts stale writes|does not reject stale writes)|(?:the )?(?:version check|single-writer requirement) is (?:removed|disabled|optional))\\b`, 'i'); + if (override.test(remedy.text) || new RegExp(`${boundary}only (?:${actors.map(escape).join('|')}) writes\\b`, 'i').test(unchanged.text)) return false; + return rows.every(row => row === remedy || row === unchanged || row.label === 'Constructor injection only' && + /❌\s*Both services still write freely; the write-after-invalidate race stays open\b/.test(row.text) && !contradiction.test(row.text) && + !namedResolved.test(row.text) && !new RegExp(`${boundary}only (?:${actors.map(escape).join('|')}) writes\\b`, 'i').test(row.text)); + } const rows = q.options.map(option => ({ id: new RegExp(`^${ordinal}([A-D]) (.+?)(?: \\(recommended\\))?$`).exec(option.label), text: current(option.description ?? '').trim(), diff --git a/test/helpers/eng-retained-corpus.ts b/test/helpers/eng-retained-corpus.ts new file mode 100644 index 000000000..f0d80e66d --- /dev/null +++ b/test/helpers/eng-retained-corpus.ts @@ -0,0 +1,67 @@ +/** A recorded legacy oracle can survive removal of the implementation it sampled. */ +export function hasRetainedLegacyCorpus( + current: ReadonlyArray<{ title: string; body: string[] }>, snapshot: string, +): boolean { + const flat = (s: string) => s.replace(/\s+/g, ' ').trim(); + const quoted = (s: string) => s.replace(/"[^"\n]*"|“[^”\n]*”|(? /(?:^|\n)\s*(?:source(?: excerpt| material)?|quoted(?: source)?|historical(?: example| assessment)?|if approved|once approved|when approved|pending approval|assuming approval|provided approval)\s*[,.:—-]/i.test(quoted(s)); + const status = '(?:withdrawn|rejected|declined|cancelled|canceled|superseded|deferred|optional|proposed|not current|no longer current|not required|no longer required|conditional on approval)'; + const owner = '(?:(?:this|the) (?:(?:legacy|recorded|baseline) )?(?:(?:regression|parity) )?(?:suite|test|requirement|verification|oracle|corpus))'; + const scalar = (s: string, id: string) => quoted(s.replace(new RegExp( + `((?:^|[.!?;]\\s+|\\n)\\s*(?:Correction:\\s*)?(?:${id}(?: verification)?|${owner}) (?:is|are|was|were|has been|have been) )["“'‘](${status})["”'’]`, 'gim'), '$1$2')); + const inactive = (s: string, id: string) => source(s) || new RegExp( + `\\b(?:${id}(?: verification)?|${owner}) (?:is|are|was|were|has been|have been) ${status}\\b|\\b(?:only if|unless|pending) (?:user )?approv`, 'i').test(scalar(s, id)); + const negated = (s: string) => /\b(?:do not|don't|never|skip|omit|defer|cancel) (?:add|write|implement|record|capture|pin|run|assert|retain|keep)\b/i.test(quoted(s)); + const owned = (s: string) => !source(s) && !negated(s) && snapshot.includes(flat(s)); + + for (const declaration of current) { + if (!/^CRITICAL regression test \([^)]*\bmandatory\b[^)]*\)$/i.test(declaration.title) + || /\b(?:not|never|no longer) mandatory\b/i.test(declaration.title)) continue; + const body = declaration.body.join('\n').trim(); + const add = /^Add ([A-Za-z][\w/.-]*\.test\.[jt]s):$/m.exec(body); + if (!add || !owned(body) || inactive(body, 'T[1-9]\\d*')) continue; + const bullets = body.split(/\n(?=- )/).slice(1).map(flat); + const capture = bullets.findIndex(s => /^- (?:Record|Capture|Pin) a corpus of .+ with the legacy decision for each\.$/i.test(s)); + const parity = bullets.findIndex(s => /^- Run the (?:same )?corpus through the new [A-Za-z][\w]* path and assert identical allow\/deny and reason code for every entry\.$/i.test(s)); + if (capture < 0 || parity <= capture || !bullets.some(s => /^- This test is also the shadow-mode oracle; it stays after legacy deletion, re-pointed at the recorded decisions\.$/i.test(s))) continue; + // Retention makes the old behavior the oracle. Building independent new + // modules before recording it does not itself rewrite that old behavior. + const retention = current.filter(s => s.title === 'What already exists').flatMap(s => s.body.join('\n').split(/\n(?=- )/)) + .find(s => /^- legacyAuthFlow\(\): retained behind the per-tenant flag as the shadow oracle and regression baseline until deletion\.$/i.test(flat(s)) && owned(s) && !inactive(s, 'T[1-9]\\d*')); + if (!retention) continue; + for (const section of current.filter(s => s.title === 'Implementation Tasks')) { + const taskBody = section.body.join('\n').trim(); + const tasks = taskBody.split(/\n(?=- )/); + const rows = tasks.map(body => ({ body, match: /^- (?:\[[ xX]\] )?(T[1-9]\d*)(?: \([^\n)]*\))? [—–-] (.+)(?:\n|$)/.exec(body) })).filter(row => row.match); + if (rows.length !== new Set(rows.map(row => row.match![1])).size) continue; + for (const row of rows) { + const id = row.match![1]!, title = row.match![2]!; + const action = row.body.replace(/^ - Surfaced by:.*$/gm, ''); + const preceding = taskBody.slice(0, taskBody.indexOf(row.body)).trim().split('\n').at(-1) ?? ''; + const files = [...row.body.matchAll(/^ - Files: ([^\n]+)$/gm)]; + const verifies = [...row.body.matchAll(/^ - Verify: ([^\n]+)$/gm)]; + if (!/^[A-Za-z][\w -]* [—–-] CRITICAL: recorded-corpus parity test legacy vs [A-Za-z][\w]*$/i.test(title) + || files.length !== 1 || verifies.length !== 1 || !snapshot.includes(flat(row.body)) || source(action) || negated(action) || source(preceding) + || inactive(action, id) || !files[0]![1]!.split(',').map(s => s.trim()).includes(add[1]!)) continue; + if (!/^100% decision \+ reason-code parity across the corpus$/i.test(verifies[0]![1]!)) continue; + const cancelled = current.some(s => { + if (/\b(?:history|historical|source|quoted|example)\b/i.test(s.title)) return false; + const raw = s.body.join('\n'), value = scalar(raw, id); + const named = /^(.*?)\b(?:regression|characterization|parity)\s+(?:suite|test)/i.exec(s.title)?.[1]?.trim(); + const foreign = Boolean(named && !/^(?:(?:critical|recorded|required|current|final|updated)\s*)*(?:legacy(?:AuthFlow\(\))?)?[\s:—-]*$/i.test(named)); + if (new RegExp(`^\\s*\\|\\s*${id}\\s*\\|\\s*["“'‘]?${status}["”'’]?\\s*\\|`, 'im').test(raw)) return true; + return value.split(/\n|[.!?;]\s+/).some(line => { + if (source(line) || /^\s*(?:if|unless|assuming|provided)\b/i.test(line)) return false; + if (foreign && !new RegExp(`\\b${id}\\b|legacyAuthFlow|\\blegacy (?:regression|parity) (?:suite|test|corpus|baseline)\\b`, 'i').test(line)) return false; + return inactive(line, id) || new RegExp(`^\\s*(?:Correction:\\s*)?legacyAuthFlow\\(\\) (?:is|was|has been|will be) (?:modified|changed|rewritten|refactored|removed|deleted) before ${id}\\b`, 'i').test(line) + || /^\s*(?:Correction:\s*)?legacyAuthFlow\(\) (?:is|was|has been|will be) (?:modified|changed|rewritten|refactored|removed|deleted) before (?:the )?(?:(?:legacy|recorded|regression) )?(?:corpus|baseline)(?: is)? (?:recorded|captured|pinned)\b/i.test(line) + || /^\s*(?:the|this) (?:(?:legacy|regression) )?(?:corpus|baseline) is (?:recorded|captured|pinned) (?:only )?after legacyAuthFlow\(\) is (?:modified|changed|rewritten|refactored|removed|deleted)\b/i.test(line) + || /\b(?:the|this) legacy (?:regression baseline|shadow oracle) (?:is|was|has been) (?:changed|rewritten|removed|deleted|replaced)\b/i.test(line); + }); + }); + if (!cancelled) return true; + } + } + } + return false; +} diff --git a/test/helpers/eng-seeded-coverage.ts b/test/helpers/eng-seeded-coverage.ts index 84b578154..5f9047f2d 100644 --- a/test/helpers/eng-seeded-coverage.ts +++ b/test/helpers/eng-seeded-coverage.ts @@ -1,4 +1,5 @@ import type { NativePlanQuestionCall, PlanCountTranscript } from './plan-count-transcript'; +import { hasRetainedLegacyCorpus } from './eng-retained-corpus'; /** Evidence for this fixture's four decision seeds; regression coverage is auto-added by the skill. */ export const ENG_DECISION_SEEDS = ['complexity', 'shared-cache', 'swallowed-errors', 'sequential-idp'] as const; @@ -796,7 +797,82 @@ function declaredLegacyCharacterization(text: string): boolean { } } - return false; + // A required-test item may own the named legacy oracle while a separate + // parity item and task preserve its compatibility obligation. Bind these + // small obligations structurally; unrelated prose cannot complete the chain. + for (const section of current.filter(s => /^Required tests(?: \([^\n]*\))?$/i.test(s.title))) { + const blocks = section.body.join('\n').trim().split(/\n\s*\n/); + for (const block of blocks) { + const claim = block.replace(/\s+/g, ' ').trim(); + const rule = /^CRITICAL\s*\([^)]*\bmandatory\b[^)]*\):\s*([A-Za-z][\w/.-]*\.test\.[jt]s)\./i.exec(claim); + if (!rule || /\b(?:not|never|no longer)\s+mandatory\b/i.test(rule[0]) || !/^\s*(?:Pin|Capture|Record) current behavior of legacyAuthFlow\(\) before any (?:change|rewrite|refactor):/i.test(claim.slice(rule[0].length)) + || !/\bThis is the oracle for the parity suite\b/i.test(claim) || !snapshotSource.includes(claim)) continue; + const quoteFree = (value: string) => value.replace(/"[^"\n]*"|“[^”\n]*”|(? quoteFree(value.replace(new RegExp( + `((?:^|[.!?;]\\s+|\\n)[\\t ]*(?:Correction:\\s*)?(?:${ids}|${subject}) (?:is|are|was|were|has been|have been) )["“'‘](${inactiveWords})["”'’]`, 'gim'), '$1$2')); + // Input conditions describe asserted behavior, including accepted tokens. + // An implicit approval or an explicit work/approver subject instead + // governs whether this work exists. + const conditionalApproval = /(?:^|[.!?;]\s+|\n)[\t ]*(?:if|unless|assuming|provided|once|when|pending|after)\s+(?:(?:approv\w*|authoriz\w*|consent|confirm\w*|accept\w*|desired|requested|needed)\b|(?:(?:this|the|our) )?(?:work|plan|review|task|baseline|parity|regression|characterization|suite|tests?|requirement|verification|proposal|implementation|T[1-9]\d*|we|you|they|reviewer|owner|user)\b[^.!?;,\n]{0,80}\b(?:approv\w*|authoriz\w*|consent|confirm\w*|accept\w*|desired|requested|needed|proceed)\b)/i; + const unowned = (value: string) => extractionSourceOwner(value) || /\b(?:proposed|optional|hypothetical|unproven|maybe|might|could)\b/i.test(quoteFree(value)) || conditionalApproval.test(quoteFree(value)); + const inactive = (value: string, ids: string) => unowned(value) || new RegExp( + `\\b(?:${ids}|${subject}) (?:is|are|was|were|has been|have been) ${inactiveWords}\\b|\\b(?:skip|defer|omit) (?:the |this )?(?:baseline|regression|characterization|parity) (?:test|suite|verification)`, 'i').test(currentText(value, ids)); + if (inactive(block, 'T[1-9]\\d*')) continue; + for (const parityBlock of blocks) { + const parity = parityBlock.replace(/\s+/g, ' ').trim(); + const comparison = /^Decision [1-9]\d*[A-D], parity suite:\s*([A-Za-z][\w/.-]*\.test\.[jt]s)\./i.exec(parity); + if (!comparison || inactive(parityBlock, 'T[1-9]\\d*') || !snapshotSource.includes(parity) + || !/^\s*(?:One|The same) fixture table, each row run through both paths \(flag off, flag on\), assert identical outcomes?\b/i.test(parity.slice(comparison[0].length))) continue; + for (const tasks of current.filter(s => s.title === 'Implementation Tasks')) { + const taskBody = tasks.body.join('\n').trim(), taskBlocks = taskBody.split(/\n(?=-\s)/); + const rows = taskBlocks.map(body => ({ body, match: /^- (?:\[[ xX]\] )?(T[1-9]\d*)(?: \([^\n)]*\))? [—–-] ([^\n]+)(?:\n|$)/.exec(body) })).filter(row => row.match); + if (rows.length !== new Set(rows.map(row => row.match![1])).size) continue; + const field = (body: string, name: string) => { + const found = [...body.matchAll(new RegExp(`^ - ${name}: ([^\\n]+)$`, 'gm'))]; + return found.length === 1 ? found[0]![1] : undefined; + }; + // Match the asserted action after its component label. A later + // positive test phrase cannot override a leading "Do not write". + const taskAction = (title: string) => /^[^\n]*?\s+[—–-]\s+(.+)$/.exec(title)?.[1] ?? title; + const ownedTask = (body: string) => { + // Source quotations identify the finding but cannot grant or revoke + // its separately asserted action or verification. + const action = body.replace(/^ - Surfaced by:.*$/gm, ''); + const preceding = taskBody.slice(0, taskBody.indexOf(body)).trim().split('\n').at(-1) ?? ''; + return !unowned(action) && !unowned(preceding) && snapshotSource.includes(body.replace(/\s+/g, ' ').trim()); + }; + for (const baseline of rows) { + const title = baseline.match![2]!, id = baseline.match![1]!; + if (!/^(?:(?:Write|Add|Create) (?:the )?)?CRITICAL (?:regression|characterization) tests? (?:pinning|capturing|recording) current legacyAuthFlow\(\) behavior before any (?:change|rewrite|refactor)\b/i.test(taskAction(title)) + || field(baseline.body, 'Files') !== rule[1] || !ownedTask(baseline.body) + || !/^(?:test|suite) passes against (?:unmodified|unchanged|untouched) legacyAuthFlow\(\) (?:first|before any (?:change|rewrite|refactor))$/i.test(field(baseline.body, 'Verify') ?? '')) continue; + for (const next of rows) { + const nextId = next.match![1]!; + if (nextId === id || field(next.body, 'Files') !== comparison[1] || !ownedTask(next.body) + || !/^(?:(?:Write|Add|Implement) (?:the )?)?(?:Table-driven )?parity suite running each fixture row through flag-off and flag-on paths\b/i.test(taskAction(next.match![2]!)) + || !/^suite green for every row\b/i.test(field(next.body, 'Verify') ?? '')) continue; + const ids = `(?:${id}|${nextId})(?: (?:baseline requirement|baseline verification|verification|rerun))?`; + if ([block, parityBlock, baseline.body, next.body].some(value => inactive(value.replace(/^ - Surfaced by:.*$/gm, ''), ids))) continue; + const cancelled = new RegExp(`\\b${ids} (?:is|was|has been) ${inactiveWords}\\b`, 'i'); + const changedFirst = new RegExp(`^(?:Correction:\\s*)?legacyAuthFlow\\(\\) (?:is|was|has been|will be) (?:modified|changed|rewritten|refactored) before ${id}\\b`, 'i'); + const withdrawn = current.some(s => { + if (/\b(?:history|historical|source|quoted|example)\b/i.test(s.title)) return false; + const body = s.body.join('\n'); + if (new RegExp(`^\\s*\\|\\s*(?:${id}|${nextId})\\s*\\|\\s*["“'‘]?${inactiveWords}["”'’]?\\s*\\|`, 'im').test(body)) return true; + return currentText(body, ids).split(/\n|[.!?;]\s+/).some(line => !extractionSourceOwner(line) && !/\b(?:if|unless|assuming|provided)\b|\b(?:once|when|pending|after)\b[^.!?\n]{0,50}\bapprov/i.test(line) && (cancelled.test(line) || changedFirst.test(line.trim()) + || new RegExp(`\\b(?:the|this) legacy (?:regression|characterization) (?:suite|tests?|requirement) (?:is|are|was|were|has been|have been) ${inactiveWords}\\b`, 'i').test(line))); + }); + if (!withdrawn) return true; + } + } + } + } + } + } + + return hasRetainedLegacyCorpus(current, snapshotSource); } function regressionEvidence(text: string): boolean { diff --git a/test/helpers/skill-fixture.ts b/test/helpers/skill-fixture.ts index 26f975c54..529110751 100644 --- a/test/helpers/skill-fixture.ts +++ b/test/helpers/skill-fixture.ts @@ -13,7 +13,8 @@ * order given. For tests that exercise specific workflow steps. * - extractSkillBody(skillDir) * frontmatter + intro + everything AFTER the shared generated preamble - * ("## Preamble (run first)" .. end of "## Plan Status Footer"). + * ("## Preamble (run first)" or "## Preamble (after scope gate)" + * .. end of "## Plan Status Footer"). * For tests that exercise the skill's ENTIRE specific flow but never * touch the ~780-line shared preamble. * - extractSkillHead(skillDir, bodyLineCount) @@ -101,7 +102,7 @@ export const CODEX_REVIEW_E2E_SECTIONS = [ /** First/last H2 headings of the shared preamble block that gen-skill-docs * emits into every tier >= 2 skill. extractSkillBody drops this range. */ -const SHARED_PREAMBLE_FIRST = 'Preamble (run first)'; +const SHARED_PREAMBLE_FIRST = ['Preamble (run first)', 'Preamble (after scope gate)']; const SHARED_PREAMBLE_LAST = 'Plan Status Footer'; interface H2Section { @@ -220,15 +221,25 @@ export function extractSkillSections(skillDir: string, sections: string[]): stri } /** - * Frontmatter + intro (everything before "## Preamble (run first)") + the + * Frontmatter + intro/scope gate (everything before either exact preamble heading) + the * full skill-specific body (everything after the "## Plan Status Footer" * section). Use when a test exercises the whole skill flow: this drops the * ~780-line shared generated preamble and nothing else. */ export function extractSkillBody(skillDir: string): string { const { file, frontmatter, bodyLines, sections: all } = loadSkill(skillDir); - const first = findSection(all, SHARED_PREAMBLE_FIRST, file); - const last = findSection(all, SHARED_PREAMBLE_LAST, file); + const boundary = (names: string[]): H2Section => { + const matches = all.filter(section => names.includes(section.heading)); + const label = names.map(name => `"## ${name}"`).join(' or '); + if (!matches.length) throw new Error(`skill-fixture: section ${label} not found in ${file}.`); + if (matches.length !== 1) throw new Error(`skill-fixture: ambiguous section ${label} in ${file}.`); + return matches[0]; + }; + const first = boundary(SHARED_PREAMBLE_FIRST); + const last = boundary([SHARED_PREAMBLE_LAST]); + if (first.start >= last.start) { + throw new Error(`skill-fixture: "## ${SHARED_PREAMBLE_LAST}" precedes the preamble in ${file}.`); + } const intro = bodyLines.slice(0, first.start).join('\n').trimEnd(); const tail = bodyLines.slice(last.end).join('\n').trimEnd(); if (!tail) { diff --git a/test/helpers/touchfiles-data.ts b/test/helpers/touchfiles-data.ts index be2077459..0377bfc5a 100644 --- a/test/helpers/touchfiles-data.ts +++ b/test/helpers/touchfiles-data.ts @@ -89,9 +89,15 @@ export const E2E_TOUCHFILES: Record = { 'plan-ceo-review-selective': ['plan-ceo-review/**', 'test/skill-e2e-plan.test.ts'], 'plan-ceo-review-benefits': ['plan-ceo-review/**', 'scripts/gen-skill-docs.ts', 'test/skill-e2e-plan.test.ts'], 'plan-ceo-review-expansion-energy': ['plan-ceo-review/**', 'scripts/resolvers/preamble.ts', 'test/fixtures/mode-posture/**', 'test/helpers/llm-judge.ts', 'test/skill-e2e-plan.test.ts'], - 'plan-eng-review': ['test/eng-scope-entry-ap.test.ts', 'plan-eng-review/**', 'test/skill-e2e-plan.test.ts'], - 'plan-eng-review-artifact': ['test/eng-scope-entry-ap.test.ts', 'plan-eng-review/**', 'test/skill-e2e-plan.test.ts'], - 'plan-review-report': ['test/eng-scope-entry-ap.test.ts', 'plan-eng-review/**', 'scripts/gen-skill-docs.ts', 'test/skill-e2e-plan.test.ts'], + 'plan-eng-review': ['test/eng-scope-entry-ap.test.ts', 'plan-eng-review/**', 'test/skill-e2e-plan.test.ts', + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts", + ], + 'plan-eng-review-artifact': ['test/eng-scope-entry-ap.test.ts', 'plan-eng-review/**', 'test/skill-e2e-plan.test.ts', + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts", + ], + 'plan-review-report': ['test/eng-scope-entry-ap.test.ts', 'plan-eng-review/**', 'scripts/gen-skill-docs.ts', 'test/skill-e2e-plan.test.ts', + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts", + ], // Plan-mode smoke tests — gate-tier safety regression tests. Each test file // contains TWO test cases as of v1.21: the baseline plan-mode case and the @@ -115,6 +121,8 @@ export const E2E_TOUCHFILES: Record = { 'test/fixtures/design-scope-declaration-ak.json', "test/eng-option-b-scope-al.test.ts", "test/fixtures/eng-option-b-scope-al.json", + + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", ], 'plan-design-review-plan-mode': [ "test/fixtures/design-scope-checkpoint-at.json",'test/pty-screen-unicode-ap.test.ts', @@ -125,6 +133,8 @@ export const E2E_TOUCHFILES: Record = { "test/eng-option-b-scope-al.test.ts", "test/fixtures/eng-option-b-scope-al.json", "test/design-scope-entry-aq.test.ts", + + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", ], 'plan-devex-review-plan-mode': ['test/pty-screen-unicode-ap.test.ts', 'test/auto-decide-saved-ai.test.ts', 'test/fixtures/auto-decide-saved-ai.json', 'test/fixtures/auto-decide-retry-ai.json','bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-devex-review/**', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/question-tuning.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/review.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/skill-e2e-plan-devex-plan-mode.test.ts', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/helpers/plan-scope-selection.ts', 'test/plan-scope-selection.test.ts', 'test/helpers/native-auto-decide.ts', 'test/native-auto-decide.test.ts', 'test/native-auto-decide-pty.test.ts', 'test/fixtures/native-auto-decide-ag.json', 'test/eng-seeded-completion-ai.test.ts', 'test/fixtures/eng-seeded-completion-ai.json', 'test/helpers/plan-count-pending-exit.ts', 'test/plan-count-pending-exit.test.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**', @@ -149,6 +159,8 @@ export const E2E_TOUCHFILES: Record = { "test/eng-option-b-scope-al.test.ts", "test/fixtures/eng-option-b-scope-al.json", "test/design-scope-entry-aq.test.ts", + + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", ], // v1.21+ AskUserQuestion-blocked regression tests — Conductor launches @@ -177,7 +189,9 @@ export const E2E_TOUCHFILES: Record = { // the Conductor rule, the preamble signal, the hook, and the detection helper. 'conductor-prose': ['test/pty-screen-unicode-ap.test.ts', 'test/eng-scope-entry-ap.test.ts', 'test/conductor-prose-observation-ao.test.ts', 'test/fixtures/conductor-prose-ao.json', - 'test/auto-decide-saved-ai.test.ts', 'test/fixtures/auto-decide-saved-ai.json', 'test/fixtures/auto-decide-retry-ai.json','bin/gstack-skill-start', 'bin/gstack-skill-end', 'bin/gstack-session-kind', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble.ts', 'plan-eng-review/**', 'hosts/claude/hooks/question-preference-hook.ts', 'hosts/claude/hooks/spawned-directive.ts', 'lib/is-conductor.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/skill-e2e-conductor-prose.test.ts', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/helpers/native-auto-decide.ts', 'test/native-auto-decide.test.ts', 'test/native-auto-decide-pty.test.ts', 'test/fixtures/native-auto-decide-ag.json', 'test/eng-seeded-completion-ai.test.ts', 'test/fixtures/eng-seeded-completion-ai.json', 'test/helpers/plan-count-pending-exit.ts', 'test/plan-count-pending-exit.test.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**'], + 'test/auto-decide-saved-ai.test.ts', 'test/fixtures/auto-decide-saved-ai.json', 'test/fixtures/auto-decide-retry-ai.json','bin/gstack-skill-start', 'bin/gstack-skill-end', 'bin/gstack-session-kind', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble.ts', 'plan-eng-review/**', 'hosts/claude/hooks/question-preference-hook.ts', 'hosts/claude/hooks/spawned-directive.ts', 'lib/is-conductor.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/skill-e2e-conductor-prose.test.ts', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/helpers/native-auto-decide.ts', 'test/native-auto-decide.test.ts', 'test/native-auto-decide-pty.test.ts', 'test/fixtures/native-auto-decide-ag.json', 'test/eng-seeded-completion-ai.test.ts', 'test/fixtures/eng-seeded-completion-ai.json', 'test/helpers/plan-count-pending-exit.ts', 'test/plan-count-pending-exit.test.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**', + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-completion-status.ts", + ], // Real-PTY E2E batch (#6 new tests on the harness). // Each one tests behavior the SDK harness can't observe (rendered TTY, @@ -190,6 +204,8 @@ export const E2E_TOUCHFILES: Record = { 'plan-design-with-ui-scope': [ "test/fixtures/design-scope-checkpoint-at.json",'plan-design-review/**', 'test/fixtures/plans/ui-heavy-feature.md', 'test/helpers/claude-pty-runner.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/skill-e2e-plan-design-with-ui.test.ts', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', "test/design-scope-entry-aq.test.ts", + + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts", ], 'ship-idempotency-pty': ['ship/**', 'bin/gstack-next-version', 'bin/gstack-version-bump', 'scripts/resolvers/sections.ts', 'lib/worktree.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/skill-e2e-ship-idempotency.test.ts', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt'], 'tpa-present': ['scripts/resolvers/third-party-actions.ts', 'ship/SKILL.md.tmpl', 'ship/sections/apple-release.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/helpers/session-runner.ts', 'test/skill-e2e-third-party-actions.test.ts', 'test/helpers/third-party-actions.ts'], @@ -208,6 +224,8 @@ export const E2E_TOUCHFILES: Record = { 'carve-section-loading': [ "test/fixtures/design-scope-checkpoint-at.json",'test/eng-scope-entry-ap.test.ts', 'scripts/resolvers/composition.ts', 'test/autoplan-review-discovery.test.ts', 'test/autoplan-phase-order.test.ts', 'design-html/**', 'design-shotgun/**', 'qa/**', 'browse/**', 'retro/**', 'autoplan/**', 'spec/**', 'setup-gbrain/**', 'review/**', 'codex/**', 'land-and-deploy/**', 'plan-eng-review/**', 'plan-design-review/**', 'plan-devex-review/**', 'office-hours/**', 'document-release/**', 'design-consultation/**', 'cso/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'test/session-runner-tools.test.ts', 'test/carve-section-loading.test.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/autoplan-snapshot.test.ts', 'test/autoplan-init.test.ts', 'test/autoplan-obligations.test.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', "test/design-scope-entry-aq.test.ts", + + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts", ], 'autoplan-chain-pty': [ "test/fixtures/design-scope-checkpoint-at.json", @@ -226,6 +244,8 @@ export const E2E_TOUCHFILES: Record = { "test/autoplan-clipped-suffix-aq.test.ts", "test/fixtures/autoplan-clipped-suffix-aq.json", "test/design-scope-entry-aq.test.ts", "test/helpers/autoplan-preconfigured-fixture.ts", "test/autoplan-preconfigured-onboarding-ar.test.ts", "test/autoplan-artifact-stall-as.test.ts", "test/fixtures/autoplan-artifact-stall-as.json", + + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts", "test/autoplan-with-result-au.test.ts", "test/fixtures/autoplan-with-result-au.json", "test/autoplan-command-prefix-au.test.ts", "test/fixtures/autoplan-command-prefix-au.json", ], // Per-finding AskUserQuestion count + review-report-at-bottom assertion. @@ -279,6 +299,8 @@ export const E2E_TOUCHFILES: Record = { "test/eng-injected-export-aq.test.ts", "test/fixtures/eng-injected-export-aq.json", "test/eng-staged-regression-aq.test.ts", "test/fixtures/eng-staged-regression-aq.md", "test/eng-library-hooks-aq.test.ts", "test/fixtures/eng-library-hooks-aq.json", "test/eng-before-rewrite-ar.test.ts", "test/fixtures/eng-before-rewrite-ar.md", "test/eng-declarative-as.test.ts", "test/fixtures/eng-declarative-as.json", "test/eng-mandatory-baseline-as.test.ts", "test/fixtures/eng-mandatory-baseline-as.md", "test/helpers/eng-cache-writer-decision.ts", "test/eng-cache-writes-as.test.ts", "test/fixtures/eng-cache-writes-as.json", "test/eng-retry-coverage-as.test.ts", "test/fixtures/eng-retry-coverage-as.json", "test/fixtures/eng-retry-baseline-as.md", + + "test/eng-required-parity-au.test.ts", "test/fixtures/eng-required-parity-au.md", "test/helpers/eng-retained-corpus.ts", "test/eng-retained-corpus-au.test.ts", "test/fixtures/eng-retained-corpus-au.md", "test/eng-annotated-cache-au.test.ts", "test/fixtures/eng-annotated-cache-au.json", "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", ], 'plan-design-finding-count': [ "test/fixtures/design-scope-checkpoint-at.json",'test/pty-screen-unicode-ap.test.ts', 'test/design-crop-gutter-ap.test.ts', 'test/fixtures/design-crop-gutter-ap.json', 'test/helpers/dx-selected-navigation.ts', 'test/dx-selected-navigation-ap.test.ts', 'test/fixtures/dx-selected-navigation-ap.json', 'test/dx-manual-handoff-ao.test.ts', 'test/fixtures/dx-manual-handoff-ao.json', 'bin/gstack-config', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-design-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/helpers/claude-pty-runner.ts', 'test/plan-count-native-input.test.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/plan-count-completion.test.ts', 'test/plan-count-dx-handoff.test.ts', 'test/fixtures/devex-handoff-n-call.json', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/autoplan-public-narration.test.ts', 'test/fixtures/autoplan-public-narration-ad.json', 'test/helpers/plan-count-pending-exit.ts', 'test/plan-count-pending-exit.test.ts', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', 'test/helpers/eval-store.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/plan-count-timeout.test.ts', 'test/plan-count-navigation-r.test.ts', 'test/plan-count-prerequisite-n.test.ts', 'test/fixtures/ceo-prerequisite-n-call.json', 'test/helpers/design-count-review.ts', 'test/design-count-review.test.ts', 'test/fixtures/design-review-j-calls.json', 'test/helpers/design-count-outside.ts', 'test/design-count-outside.test.ts', 'test/skill-e2e-plan-design-finding-count.test.ts', 'test/fixtures/design-review-l-calls.json', 'test/design-completion-handoff.test.ts', 'test/fixtures/design-handoff-l-calls.json', 'test/fixtures/devex-review-l-calls.json', 'test/plan-count-checkbox.test.ts', 'test/fixtures/ceo-checkbox-l.screen.txt', 'test/fixtures/design-review-n-calls.json', 'test/design-completion-handoff-scored.test.ts', 'test/fixtures/design-handoff-n-calls.json', 'test/fixtures/design-handoff-q-calls.json', 'test/plan-count-empty-review.test.ts', 'test/fixtures/ceo-count-s-distinct.json', 'test/fixtures/plan-count-design-questionless-report.md', 'test/plan-count-dx-handoff-o.test.ts', 'test/fixtures/devex-handoff-o-call.json', 'test/helpers/plan-count-file-permission.ts', 'test/plan-count-file-permission.test.ts', 'test/fixtures/plan-count-edit-permission-t.json', 'test/plan-count-preview-footer.test.ts', 'test/fixtures/ceo-preview-u-call.json', 'test/fixtures/ceo-preview-u-screen.txt', 'test/design-completion-handoff-u.test.ts', 'test/fixtures/design-handoff-u-calls.json', 'test/fixtures/design-preview-v-screen.txt', 'test/plan-count-owned-permission.test.ts', 'test/fixtures/plan-count-owned-permission-v.json', 'test/fixtures/ceo-questionless-w-native.json', 'test/helpers/design-artifact-question.ts', 'test/design-artifact-question.test.ts', 'test/fixtures/design-artifacts-w-calls.json', 'test/fixtures/design-outside-y-calls.json', 'test/fixtures/design-boundaries-y-calls.json', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/fixtures/design-gap-z-calls.json', 'test/plan-count-permission-ac.test.ts', 'test/fixtures/plan-count-permission-ac.json', 'test/fixtures/plan-count-permission-ad.json', 'test/fixtures/plan-count-permission-ae.json', 'test/fixtures/plan-count-permission-target-ad-v2.json', 'test/design-count-ad-v2.test.ts', 'test/fixtures/design-count-ad-v2.json', 'test/design-first-decision-af.test.ts', 'test/fixtures/design-first-decision-af.json', 'test/fixtures/design-first-decision-af-retry.json', 'test/fixtures/plan-count-permission-ah.json', 'test/design-first-issue-ai.test.ts', 'test/fixtures/design-first-issue-ai.json', 'test/design-primary-action-aj.test.ts', 'test/fixtures/design-primary-action-aj.json', 'test/fixtures/design-future-todo-aj.json', @@ -301,6 +323,8 @@ export const E2E_TOUCHFILES: Record = { "test/fixtures/design-primary-assignment-ao.json", "test/design-primary-header-aq.test.ts", "test/fixtures/design-primary-header-aq.json", "test/design-scope-entry-aq.test.ts", "test/design-primary-group-as.test.ts", "test/fixtures/design-primary-group-as-calls.json", + + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", ], 'plan-devex-finding-count': [ "test/dx-journey-field-at.test.ts", "test/fixtures/dx-journey-field-at.json",'test/pty-screen-unicode-ap.test.ts', 'test/design-crop-gutter-ap.test.ts', 'test/fixtures/design-crop-gutter-ap.json', 'test/helpers/dx-selected-navigation.ts', 'test/dx-selected-navigation-ap.test.ts', 'test/fixtures/dx-selected-navigation-ap.json', 'test/dx-manual-handoff-ao.test.ts', 'test/fixtures/dx-manual-handoff-ao.json', 'bin/gstack-config', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-devex-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/helpers/claude-pty-runner.ts', 'test/plan-count-native-input.test.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/plan-count-completion.test.ts', 'test/plan-count-dx-handoff.test.ts', 'test/fixtures/devex-handoff-n-call.json', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/autoplan-public-narration.test.ts', 'test/fixtures/autoplan-public-narration-ad.json', 'test/helpers/plan-count-pending-exit.ts', 'test/plan-count-pending-exit.test.ts', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', 'test/helpers/eval-store.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/plan-count-timeout.test.ts', 'test/plan-count-navigation-r.test.ts', 'test/plan-count-prerequisite-n.test.ts', 'test/fixtures/ceo-prerequisite-n-call.json', 'test/helpers/devex-count-fixture.ts', 'test/devex-count-fixture.test.ts', 'test/skill-e2e-plan-devex-finding-count.test.ts', 'test/fixtures/dx-prerequisite-r-call.json', 'test/fixtures/devex-review-l-calls.json', 'test/plan-count-checkbox.test.ts', 'test/fixtures/ceo-checkbox-l.screen.txt', 'test/fixtures/devex-review-n-calls.json', 'test/plan-count-empty-review.test.ts', 'test/fixtures/ceo-count-s-distinct.json', 'test/fixtures/plan-count-design-questionless-report.md', 'test/devex-output-o.test.ts', 'test/fixtures/devex-review-o-calls.json', 'test/fixtures/devex-output-o-retry-call.json', 'test/devex-setup-remedy-o.test.ts', 'test/fixtures/devex-review-o-retry-calls.json', 'test/plan-count-dx-handoff-o.test.ts', 'test/fixtures/devex-handoff-o-call.json', 'test/eng-devex-s-count.test.ts', 'test/fixtures/eng-devex-s-first-calls.json', 'test/fixtures/eng-devex-s-retry-calls.json', 'test/fixtures/devex-review-t-calls.json', 'test/helpers/plan-count-file-permission.ts', 'test/plan-count-file-permission.test.ts', 'test/fixtures/plan-count-edit-permission-t.json', 'test/plan-count-preview-footer.test.ts', 'test/fixtures/ceo-preview-u-call.json', 'test/fixtures/ceo-preview-u-screen.txt', 'test/fixtures/devex-count-u-calls.json', 'test/fixtures/devex-count-u-retry-calls.json', 'test/fixtures/devex-empathy-v-calls.json', 'test/fixtures/devex-handoff-v-call.json', 'test/fixtures/design-preview-v-screen.txt', 'test/plan-count-owned-permission.test.ts', 'test/fixtures/plan-count-owned-permission-v.json', 'test/fixtures/ceo-questionless-w-native.json', 'test/fixtures/devex-count-y-calls.json', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/fixtures/devex-count-z-calls.json', 'test/fixtures/devex-handoff-z-call.json', 'test/review-handoffs-aa.test.ts', 'test/fixtures/review-handoff-aa-ceo.json', 'test/fixtures/review-handoff-aa-dx.json', 'test/devex-empathy-ab.test.ts', 'test/fixtures/devex-empathy-ab-calls.json', 'test/devex-ac-accounting.test.ts', 'test/fixtures/devex-ac-first-attempt-calls.json', 'test/plan-count-permission-ac.test.ts', 'test/fixtures/plan-count-permission-ac.json', 'test/fixtures/plan-count-permission-ad.json', 'test/fixtures/plan-count-permission-ae.json', 'test/fixtures/plan-count-permission-target-ad-v2.json', 'test/devex-reconfirmation-ad-v2.test.ts', 'test/fixtures/devex-reconfirmation-ad-v2.json', 'test/plan-count-history.test.ts', 'test/helpers/devex-seed-coverage.ts', 'test/devex-seed-coverage.test.ts', 'test/fixtures/devex-seed-coverage-ad-v3.json', 'test/fixtures/plan-count-permission-ah.json', @@ -322,6 +346,8 @@ export const E2E_TOUCHFILES: Record = { // numbered-option render. ~1-3 min typical wall time per test, ~$2-6 total. 'plan-eng-finding-floor': ['test/eng-scope-entry-ap.test.ts', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-eng-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/review.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/fixtures/forcing-finding-seeds.ts', 'test/skill-e2e-plan-eng-finding-floor.test.ts', 'test/eng-first-review-t.test.ts', 'test/fixtures/eng-batching-t-calls.json', 'test/eng-scope-y.test.ts', 'test/fixtures/eng-scope-y-calls.json', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/eng-binding-z.test.ts', 'test/fixtures/eng-binding-z-calls.json', 'test/eng-binding-retry-z.test.ts', 'test/fixtures/eng-binding-retry-z-calls.json', 'test/helpers/plan-floor-target.ts', 'test/plan-floor-target.test.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', "test/eng-injected-export-aq.test.ts", "test/fixtures/eng-injected-export-aq.json", "test/eng-library-hooks-aq.test.ts", "test/fixtures/eng-library-hooks-aq.json", + + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", ], 'plan-ceo-finding-floor': ['bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-ceo-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/review.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/fixtures/forcing-finding-seeds.ts', 'test/skill-e2e-plan-ceo-finding-floor.test.ts', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/helpers/plan-floor-target.ts', 'test/plan-floor-target.test.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', "test/ceo-section-ordering-aq.test.ts", "test/fixtures/ceo-section-ordering-aq.json", @@ -330,6 +356,8 @@ export const E2E_TOUCHFILES: Record = { 'plan-design-finding-floor': [ "test/fixtures/design-scope-checkpoint-at.json",'bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-design-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/review.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/fixtures/forcing-finding-seeds.ts', 'test/skill-e2e-plan-design-finding-floor.test.ts', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/helpers/plan-floor-target.ts', 'test/plan-floor-target.test.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', "test/design-scope-entry-aq.test.ts", + + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", ], 'plan-devex-finding-floor': ['bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-devex-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/review.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/fixtures/forcing-finding-seeds.ts', 'test/skill-e2e-plan-devex-finding-floor.test.ts', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/helpers/plan-floor-target.ts', 'test/plan-floor-target.test.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts'], @@ -347,6 +375,8 @@ export const E2E_TOUCHFILES: Record = { 'test/fixtures/plan-count-quoted-frame-ak.json', "test/eng-injected-export-aq.test.ts", "test/fixtures/eng-injected-export-aq.json", "test/eng-library-hooks-aq.test.ts", "test/fixtures/eng-library-hooks-aq.json", "test/eng-declarative-as.test.ts", "test/fixtures/eng-declarative-as.json", "test/helpers/eng-cache-writer-decision.ts", "test/eng-cache-writes-as.test.ts", "test/fixtures/eng-cache-writes-as.json", + + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", ], 'plan-ceo-split-overflow': ['test/pty-screen-unicode-ap.test.ts', 'test/design-crop-gutter-ap.test.ts', 'test/fixtures/design-crop-gutter-ap.json', 'bin/gstack-config', 'plan-ceo-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'bin/gstack-question-preference', 'test/helpers/claude-pty-runner.ts', 'test/plan-count-native-input.test.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/autoplan-public-narration.test.ts', 'test/fixtures/autoplan-public-narration-ad.json', 'test/helpers/plan-count-pending-exit.ts', 'test/plan-count-pending-exit.test.ts', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', 'test/helpers/eval-store.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/plan-count-timeout.test.ts', 'test/plan-count-navigation-r.test.ts', 'test/plan-count-prerequisite-n.test.ts', 'test/fixtures/ceo-prerequisite-n-call.json', 'test/fixtures/forcing-finding-seeds.ts', 'test/skill-e2e-plan-ceo-split-overflow.test.ts', 'test/plan-count-checkbox.test.ts', 'test/fixtures/ceo-checkbox-l.screen.txt', 'test/helpers/plan-count-file-permission.ts', 'test/plan-count-file-permission.test.ts', 'test/fixtures/plan-count-edit-permission-t.json', 'test/plan-count-owned-permission.test.ts', 'test/fixtures/plan-count-owned-permission-v.json', 'test/fixtures/ceo-questionless-w-native.json', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/plan-count-permission-ac.test.ts', 'test/fixtures/plan-count-permission-ac.json', 'test/fixtures/plan-count-permission-ad.json', 'test/fixtures/plan-count-permission-ae.json', 'test/fixtures/plan-count-permission-target-ad-v2.json', 'test/fixtures/plan-count-permission-ah.json', 'test/plan-count-crop-ak.test.ts', @@ -372,8 +402,12 @@ export const E2E_TOUCHFILES: Record = { // Fires when either template OR the two preamble resolvers change. 'plan-ceo-review-format-mode': ['plan-ceo-review/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completeness-section.ts', 'scripts/resolvers/preamble.ts', 'model-overlays/opus-4-7.md', 'test/helpers/llm-judge.ts', 'test/skill-e2e-plan-format.test.ts'], 'plan-ceo-review-format-approach': ['plan-ceo-review/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completeness-section.ts', 'scripts/resolvers/preamble.ts', 'model-overlays/opus-4-7.md', 'test/helpers/llm-judge.ts', 'test/skill-e2e-plan-format.test.ts'], - 'plan-eng-review-format-coverage': ['test/eng-scope-entry-ap.test.ts', 'plan-eng-review/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completeness-section.ts', 'scripts/resolvers/preamble.ts', 'model-overlays/opus-4-7.md', 'test/helpers/llm-judge.ts', 'test/skill-e2e-plan-format.test.ts'], - 'plan-eng-review-format-kind': ['test/eng-scope-entry-ap.test.ts', 'plan-eng-review/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completeness-section.ts', 'scripts/resolvers/preamble.ts', 'model-overlays/opus-4-7.md', 'test/helpers/llm-judge.ts', 'test/skill-e2e-plan-format.test.ts'], + 'plan-eng-review-format-coverage': ['test/eng-scope-entry-ap.test.ts', 'plan-eng-review/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completeness-section.ts', 'scripts/resolvers/preamble.ts', 'model-overlays/opus-4-7.md', 'test/helpers/llm-judge.ts', 'test/skill-e2e-plan-format.test.ts', + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts", + ], + 'plan-eng-review-format-kind': ['test/eng-scope-entry-ap.test.ts', 'plan-eng-review/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completeness-section.ts', 'scripts/resolvers/preamble.ts', 'model-overlays/opus-4-7.md', 'test/helpers/llm-judge.ts', 'test/skill-e2e-plan-format.test.ts', + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts", + ], // v1.7.0.0 Pros/Cons format cadence + format + negative-escape evals. // Dependencies: same as format-mode + the 4 plan-review templates + overlay. @@ -381,10 +415,14 @@ export const E2E_TOUCHFILES: Record = { 'plan-ceo-review-prosons-cadence': [ "test/fixtures/design-scope-checkpoint-at.json",'test/eng-scope-entry-ap.test.ts', 'plan-ceo-review/**', 'plan-eng-review/**', 'plan-design-review/**', 'plan-devex-review/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'model-overlays/opus-4-7.md', 'test/skill-e2e-plan-prosons.test.ts', "test/design-scope-entry-aq.test.ts", + + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts", ], 'plan-review-prosons-format': [ "test/fixtures/design-scope-checkpoint-at.json",'test/eng-scope-entry-ap.test.ts', 'plan-ceo-review/**', 'plan-eng-review/**', 'plan-design-review/**', 'plan-devex-review/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'model-overlays/opus-4-7.md', 'test/skill-e2e-plan-prosons.test.ts', "test/design-scope-entry-aq.test.ts", + + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts", ], 'plan-review-prosons-hardstop-neg': ['plan-ceo-review/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'model-overlays/opus-4-7.md', 'test/skill-e2e-plan-prosons.test.ts'], 'plan-review-prosons-neutral-neg': ['plan-ceo-review/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'model-overlays/opus-4-7.md', 'test/skill-e2e-plan-prosons.test.ts'], @@ -407,8 +445,12 @@ export const E2E_TOUCHFILES: Record = { 'codex-offered-design-review': [ "test/fixtures/design-scope-checkpoint-at.json",'plan-design-review/**', 'scripts/gen-skill-docs.ts', 'test/skill-e2e-plan.test.ts', "test/design-scope-entry-aq.test.ts", + + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts", + ], + 'codex-offered-eng-review': ['test/eng-scope-entry-ap.test.ts', 'plan-eng-review/**', 'scripts/gen-skill-docs.ts', 'test/skill-e2e-plan.test.ts', + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts", ], - 'codex-offered-eng-review': ['test/eng-scope-entry-ap.test.ts', 'plan-eng-review/**', 'scripts/gen-skill-docs.ts', 'test/skill-e2e-plan.test.ts'], // Ship 'ship-base-branch': ['ship/**', 'bin/gstack-repo-mode', 'test/skill-e2e-review-attribution.test.ts'], @@ -486,6 +528,8 @@ export const E2E_TOUCHFILES: Record = { 'test/helpers/disabled-plan-review-fixture.ts', 'test/disabled-plan-review-evidence.test.ts', 'test/skill-e2e-outside-plan-disabled.test.ts', 'test/fixtures/disabled-plan-attribution-ad-v2.json', "test/fixtures/disabled-historical-line-aq.json", + + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts", ], // GPT-5.6 Sol scope-termination E2E (Codex CLI, full generated investigate skill) @@ -511,6 +555,8 @@ export const E2E_TOUCHFILES: Record = { "test/coverage-audit-shell-legend-at.test.ts", "test/fixtures/coverage-audit-shell-legend-at.json",'test/eng-scope-entry-ap.test.ts', 'plan-eng-review/**', 'test/fixtures/coverage-audit-fixture.ts', 'test/skill-e2e-coverage-audit.test.ts', 'test/helpers/coverage-audit-evidence.ts', 'test/coverage-audit-evidence.test.ts', 'test/fixtures/coverage-audit-ae.json', 'test/coverage-audit-af.test.ts', 'test/fixtures/coverage-audit-af.json', "test/coverage-shell-display-aq.test.ts", "test/fixtures/coverage-shell-display-aq.json", "test/coverage-diagram-legend-as.test.ts", "test/fixtures/coverage-diagram-legend-as.json", + + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts", ], 'ship-triage': ['ship/**', 'bin/gstack-repo-mode', 'test/skill-e2e-triage.test.ts'], 'ship-docsync': ['ship/**', 'document-release/**', 'scripts/gen-skill-docs.ts', 'scripts/resolvers/sections.ts', 'test/skill-e2e-ship-docsync.test.ts'], @@ -538,6 +584,8 @@ export const E2E_TOUCHFILES: Record = { 'plan-design-review-no-ui-scope': [ "test/fixtures/design-scope-checkpoint-at.json",'plan-design-review/**', 'lib/design-catalog.ts', 'scripts/gen-skill-docs.ts', 'test/skill-e2e-design.test.ts', "test/design-scope-entry-aq.test.ts", + + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts", ], 'design-review-fix': ['design-review/**', 'scripts/resolvers/aside.ts', 'scripts/resolvers/design.ts', 'lib/design-catalog.ts', 'browse/src/**', 'scripts/gen-skill-docs.ts', 'test/skill-e2e-design.test.ts'], // Design detector (user-installed impeccable engine) through the fake engine shim: source mode on a diff and DOM mode on a served page. @@ -604,42 +652,62 @@ export const E2E_TOUCHFILES: Record = { 'journey-ideation': [ "test/fixtures/design-scope-checkpoint-at.json",'test/eng-scope-entry-ap.test.ts', '*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/skill-routing-e2e.test.ts', "test/design-scope-entry-aq.test.ts", + + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts", ], 'journey-plan-eng': [ "test/fixtures/design-scope-checkpoint-at.json",'test/eng-scope-entry-ap.test.ts', '*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/skill-routing-e2e.test.ts', "test/design-scope-entry-aq.test.ts", + + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts", ], 'journey-debug': [ "test/fixtures/design-scope-checkpoint-at.json",'test/eng-scope-entry-ap.test.ts', '*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/skill-routing-e2e.test.ts', "test/design-scope-entry-aq.test.ts", + + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts", ], 'journey-qa': [ "test/fixtures/design-scope-checkpoint-at.json",'test/eng-scope-entry-ap.test.ts', '*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/skill-routing-e2e.test.ts', "test/design-scope-entry-aq.test.ts", + + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts", ], 'journey-code-review': [ "test/fixtures/design-scope-checkpoint-at.json",'test/eng-scope-entry-ap.test.ts', '*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/skill-routing-e2e.test.ts', "test/design-scope-entry-aq.test.ts", + + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts", ], 'journey-ship': [ "test/fixtures/design-scope-checkpoint-at.json",'test/eng-scope-entry-ap.test.ts', '*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/skill-routing-e2e.test.ts', "test/design-scope-entry-aq.test.ts", + + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts", ], 'journey-docs': [ "test/fixtures/design-scope-checkpoint-at.json",'test/eng-scope-entry-ap.test.ts', '*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/skill-routing-e2e.test.ts', "test/design-scope-entry-aq.test.ts", + + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts", ], 'journey-retro': [ "test/fixtures/design-scope-checkpoint-at.json",'test/eng-scope-entry-ap.test.ts', '*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/skill-routing-e2e.test.ts', "test/design-scope-entry-aq.test.ts", + + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts", ], 'journey-design-system': [ "test/fixtures/design-scope-checkpoint-at.json",'test/eng-scope-entry-ap.test.ts', '*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/skill-routing-e2e.test.ts', "test/design-scope-entry-aq.test.ts", + + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts", ], 'journey-visual-qa': [ "test/fixtures/design-scope-checkpoint-at.json",'test/eng-scope-entry-ap.test.ts', '*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/skill-routing-e2e.test.ts', "test/design-scope-entry-aq.test.ts", + + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts", ], // Opus 4.7 behavior evals — keys match testName: values in the test file. @@ -1106,12 +1174,16 @@ export const LLM_JUDGE_TOUCHFILES: Record = { // Plan Reviews 'plan-ceo-review/SKILL.md modes': ['plan-ceo-review/SKILL.md', 'plan-ceo-review/SKILL.md.tmpl', 'test/skill-llm-eval.test.ts', 'test/helpers/workflow-judge-input.ts', 'test/workflow-judge-input.test.ts', 'test/helpers/workflow-excerpt.ts'], - 'plan-eng-review/SKILL.md sections': ['test/eng-scope-entry-ap.test.ts', 'plan-eng-review/SKILL.md', 'plan-eng-review/SKILL.md.tmpl', 'test/skill-llm-eval.test.ts', 'test/helpers/workflow-judge-input.ts', 'test/workflow-judge-input.test.ts', 'test/helpers/workflow-excerpt.ts'], + 'plan-eng-review/SKILL.md sections': ['test/eng-scope-entry-ap.test.ts', 'plan-eng-review/SKILL.md', 'plan-eng-review/SKILL.md.tmpl', 'test/skill-llm-eval.test.ts', 'test/helpers/workflow-judge-input.ts', 'test/workflow-judge-input.test.ts', 'test/helpers/workflow-excerpt.ts', + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts", + ], // /spec authored-spec quality (paid LLM-judge — periodic-tier). 'plan-design-review/SKILL.md passes': [ "test/fixtures/design-scope-checkpoint-at.json",'plan-design-review/SKILL.md', 'plan-design-review/SKILL.md.tmpl', 'test/skill-llm-eval.test.ts', 'test/helpers/workflow-judge-input.ts', 'test/workflow-judge-input.test.ts', 'test/helpers/workflow-excerpt.ts', "test/design-scope-entry-aq.test.ts", + + "test/review-entry-and-design-clarity-au.test.ts", "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts", ], // Design skills diff --git a/test/review-entry-and-design-clarity-au.test.ts b/test/review-entry-and-design-clarity-au.test.ts new file mode 100644 index 000000000..1093f345b --- /dev/null +++ b/test/review-entry-and-design-clarity-au.test.ts @@ -0,0 +1,114 @@ +import {expect, test} from 'bun:test'; +import fs from 'node:fs'; +import path from 'node:path'; +import {ALL_HOST_CONFIGS} from '../hosts'; +import {HOST_PATHS, type TemplateContext} from '../scripts/resolvers/types'; +import {generatePreamble} from '../scripts/resolvers/preamble'; +import {generatePreambleBash} from '../scripts/resolvers/preamble/generate-preamble-bash'; +import {generatePlanModeInfo} from '../scripts/resolvers/preamble/generate-completion-status'; +import {generateDesignHardRules} from '../scripts/resolvers/design'; +import {E2E_TOUCHFILES, LLM_JUDGE_TOUCHFILES, selectTests} from './helpers/touchfiles'; + +const root = path.resolve(import.meta.dir, '..'); +const announcement = 'Scope gate: plan mode — auto-selected B (reviewing ).'; +const section = fs.readFileSync(path.join(root, 'plan-design-review/sections/review-sections.md.tmpl'), 'utf8'); +function context(skillName: string, host: typeof ALL_HOST_CONFIGS[number]): TemplateContext { + return {skillName, tmplPath: `${skillName}/SKILL.md.tmpl`, host: host.name, + paths: HOST_PATHS[host.name]!, preambleTier: 3, interactive: true}; +} +function assertScopedEntry(preamble: string) { + const command = preamble.indexOf('```bash'); + expect(command).toBeGreaterThan(0); + const entry = preamble.slice(0, command); + expect(entry).toContain('## Preamble (after scope gate)'); + expect(entry).toContain(announcement); + expect(entry).toContain('actual selected plan title or path'); + expect(entry).toContain('after this skill loads'); + expect(entry).toContain('a pre-invocation introduction does not count'); + expect(entry).toContain('If the gate asks a question, wait for its answer'); + expect(preamble).not.toContain('## Preamble (run first)'); + expect(preamble).toContain('starting from the Scope gate'); + expect(preamble).not.toContain('starting from Step 0;'); +} + +test('Design and Eng resolve the existing gate at the first executable preamble in every host', () => { + for (const host of ALL_HOST_CONFIGS) for (const skill of ['plan-design-review', 'plan-eng-review']) { + const ctx = context(skill, host); + assertScopedEntry(generatePreamble(ctx)); + const tmpl = fs.readFileSync(path.join(root, `${skill}/SKILL.md.tmpl`), 'utf8'); + expect(tmpl.indexOf(announcement)).toBeLessThan(tmpl.indexOf('{{PREAMBLE}}')); + expect(tmpl).toContain('If the user explicitly named a DIFFERENT target'); + expect(tmpl).toContain('If plan mode is indicated but no plan exists yet, ask as normal'); + } +}); + +test('missing local checkpoint and competing Step 0 entry instructions fail the generation contract', () => { + const text = generatePreamble(context('plan-design-review', ALL_HOST_CONFIGS[0]!)); + expect(() => assertScopedEntry(text.replace(announcement, ''))).toThrow(); + expect(() => assertScopedEntry(text.replace('## Preamble (after scope gate)', '## Preamble (run first)'))).toThrow(); + expect(() => assertScopedEntry(text.replace('starting from the Scope gate', 'starting from Step 0; then the Scope gate'))).toThrow(); +}); + +test('other skills keep their existing preamble entry and plan-mode starting point', () => { + for (const host of ALL_HOST_CONFIGS) for (const skill of ['plan-ceo-review', 'plan-devex-review', 'ship', 'review']) { + const ctx = context(skill, host); + expect(generatePreambleBash(ctx).startsWith('## Preamble (run first)\n\n```bash')).toBe(true); + expect(generatePlanModeInfo(ctx)).toContain('starting from Step 0;'); + expect(generatePreambleBash(ctx)).not.toContain('Before the command below'); + } +}); + +test('hard rules remain inside the scored fourth pass without changing other skills heading levels', () => { + const host = ALL_HOST_CONFIGS[0]!; + const rules = generateDesignHardRules(context('plan-design-review', host)); + const rendered = section.replace('{{DESIGN_HARD_RULES}}', rules); + const start = rendered.indexOf('### Pass 4:'); + const end = rendered.indexOf('### Pass 5:'); + const pass = rendered.slice(start, end); + expect(pass.indexOf('**Pass 4 evaluation:**')).toBeLessThan(pass.indexOf('#### Design Hard Rules')); + expect(pass.match(/^### /gm)).toHaveLength(1); + expect(pass).toContain('An unresolved hard rejection caps this pass below 8'); + expect(pass).toContain('Litmus answers support findings, not a separate numeric score.'); + for (const skill of ['design-review', 'design-consultation', 'design-html']) { + expect(generateDesignHardRules(context(skill, host)).startsWith('### Design Hard Rules\n')).toBe(true); + } +}); + +test('the decision table preserves first approval, inherited approvals, artifact and navigation boundaries', () => { + const cases = section.slice(section.indexOf('| Situation |'), section.indexOf('{{LEARNINGS_SEARCH}}')); + expect(cases).toContain('exact fix already has an individual user decision or a preamble-authorized per-issue auto-decision'); + expect(cases).toContain('do not ask again'); + expect(cases).toContain('copied unchanged into a required artifact'); + expect(cases).toContain('it approves no new remedy'); + expect(cases).toContain('no individual decision has approved its fix'); + expect(cases).toContain('even if the input names the gap or DESIGN.md prescribes the exact token'); + expect(cases).toContain('Keep the proposed remedy pending'); + expect(cases).toContain('new tradeoff'); + expect(section).toContain('Scope, focus, setup, and next-step choices approve no remedies.'); + expect(section).toContain('Never edit first and ask afterward.'); + expect(section).toContain('An unapproved violation still needs its first individual decision.'); +}); + +test('scoring and section dependencies are explicit while existing clean and pass thresholds remain', () => { + expect(section).toContain('use the lowest of the six rated pass scores (1-6)'); + expect(section).toContain('separately before and after approved fixes'); + expect(section).toContain('Pass 7 is unscored'); + expect(section).toContain('If all passes 8+'); + expect(section).toContain('"clean" if overall score 8+ AND 0 unresolved'); + expect(section).toContain('If DESIGN.md is absent, rate the plan\'s explicit token and component specifications'); + expect(section).toContain('do not skip the score or assume alignment'); + expect(section).toContain('Read `~/.claude/skills/gstack/plan-design-review/SKILL.md`'); + expect(section).toContain('Use their existing results; do not restart the review.'); + expect(section).toContain('After Pass 7: offer the mockup update below when applicable, resolve deferred TODO proposals, reconcile approvals, then synthesize tasks and the Completion Summary.'); +}); + +test('new regression and changed preamble sources select all Design and Eng owners', () => { + for (const map of [E2E_TOUCHFILES, LLM_JUDGE_TOUCHFILES]) { + const affected = selectTests(['plan-design-review/SKILL.md.tmpl', 'plan-eng-review/SKILL.md.tmpl'], map, []).selected; + expect(selectTests(['test/review-entry-and-design-clarity-au.test.ts'], map, []).selected).toEqual(affected); + for (const source of ['scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts']) { + const selected = selectTests([source], map, []).selected; + for (const owner of affected) expect(selected).toContain(owner); + } + } +}); diff --git a/test/skill-fixture.test.ts b/test/skill-fixture.test.ts index 006845394..6c23630bf 100644 --- a/test/skill-fixture.test.ts +++ b/test/skill-fixture.test.ts @@ -27,6 +27,7 @@ import { RETRO_E2E_SECTIONS, CODEX_REVIEW_E2E_SECTIONS, } from './helpers/skill-fixture'; +import { E2E_TOUCHFILES, GLOBAL_TOUCHFILES, selectTests } from './helpers/touchfiles'; const ROOT = path.resolve(import.meta.dir, '..'); @@ -126,6 +127,64 @@ describe('extractSkillBody (synthetic)', () => { fs.writeFileSync(path.join(bare, 'SKILL.md'), '---\nname: bare\n---\n## Only Section\nbody\n'); expect(() => extractSkillBody(bare)).toThrow(/Preamble \(run first\)/); }); + + test('both exact preamble headings preserve the complete intro/scope gate and tail', () => { + for (const heading of ['Preamble (run first)', 'Preamble (after scope gate)']) { + const file = path.join(tmpDir, 'scoped-body.md'); + const scope = '## Scope gate\nAnnounce the actual selected plan before any tools.\n\n'; + const input = SYNTHETIC_SKILL.replace('## Preamble (run first)', scope + `## ${heading}`); + fs.writeFileSync(file, input); + const out = extractSkillBody(file); + const expected = extractSkillBody(skillDir).replace('Invoke text.\n\n', 'Invoke text.\n\n' + scope); + expect(out).toBe(expected); + expect(out).toContain(scope.trimEnd()); + expect(out).not.toContain(`## ${heading}`); + } + }); + + test('unknown, missing, duplicated and reversed boundary sections fail loudly', () => { + const cases: Array<[string, string, RegExp]> = [ + ['unknown start', SYNTHETIC_SKILL.replace('Preamble (run first)', 'Preamble (after setup)'), /Preamble/], + ['suffixed scoped start', SYNTHETIC_SKILL.replace('Preamble (run first)', 'Preamble (after scope gate) extra'), /Preamble/], + ['suffixed old start', SYNTHETIC_SKILL.replace('Preamble (run first)', 'Preamble (run first) extra'), /Preamble/], + ['missing footer', SYNTHETIC_SKILL.replace('## Plan Status Footer', '## Renamed Footer'), /Plan Status Footer/], + ['suffixed footer', SYNTHETIC_SKILL.replace('## Plan Status Footer', '## Plan Status Footer extra'), /Plan Status Footer/], + ['both starts', SYNTHETIC_SKILL.replace('## AskUserQuestion Format', '## Preamble (after scope gate)'), /ambiguous/], + ['duplicate start', SYNTHETIC_SKILL.replace('## AskUserQuestion Format', '## Preamble (run first)'), /ambiguous/], + ['duplicate footer', SYNTHETIC_SKILL.replace('## Step 2 — Other', '## Plan Status Footer'), /ambiguous/], + ['footer before start', SYNTHETIC_SKILL.replace('## Preamble (run first)', '## Plan Status Footer').replace('## Plan Status Footer\nfooter junk', '## Preamble (after scope gate)\nfooter junk'), /precedes/], + ['missing tail', SYNTHETIC_SKILL.slice(0, SYNTHETIC_SKILL.indexOf('## Step 1 — Do the thing')), /no content after/], + ]; + for (const [name, input, error] of cases) { + const file = path.join(tmpDir, 'invalid-boundaries.md'); fs.writeFileSync(file, input); + expect(() => extractSkillBody(file), name).toThrow(error); + expect(() => extractSkillBody(file), name).toThrow(/invalid-boundaries\.md/); + } + }); + + test('quoted and fenced boundary examples do not become live section markers', () => { + for (const [open, close] of [['```markdown\n', '```\n'], ['~~~markdown\n', '~~~\n']]) { + const file = path.join(tmpDir, 'fenced-boundaries.md'); + const examples = `${open}## Preamble (after scope gate)\n## Plan Status Footer\n${close}> ## Preamble (after scope gate)\n\n`; + fs.writeFileSync(file, SYNTHETIC_SKILL.replace('Intro line before any section.', examples + 'Intro line before any section.')); + expect(extractSkillBody(file)).toContain(examples.trimEnd()); + fs.writeFileSync(file, SYNTHETIC_SKILL.replace('## Preamble (run first)\npreamble junk', `${open}## Preamble (after scope gate)\n${close}preamble junk`)); + expect(() => extractSkillBody(file)).toThrow(/Preamble/); + } + }); + + test('other section extraction keeps its existing prefix matching', () => { + const file = path.join(tmpDir, 'section-prefix.md'); + fs.writeFileSync(file, SYNTHETIC_SKILL.replace('Preamble (run first)', 'Preamble (after scope gate)')); + expect(extractSkillSections(file, ['Preamble (after'])).toContain('preamble junk'); + expect(extractSkillSections(file, ['Step 1'])).toContain('step one continues after the fence'); + }); + + test('the existing global source dependency selects every affected workflow fixture', () => { + expect(GLOBAL_TOUCHFILES).toContain('test/helpers/skill-fixture.ts'); + const selection = selectTests(['test/helpers/skill-fixture.ts'], E2E_TOUCHFILES, GLOBAL_TOUCHFILES); + expect(selection.selected.sort()).toEqual(Object.keys(E2E_TOUCHFILES).sort()); + }); }); describe('extractSkillHead (synthetic)', () => { @@ -236,6 +295,7 @@ describe('real-skill pins: body/head extraction used by E2E fixtures', () => { test(`extractSkillBody(${skill}) drops the shared preamble, keeps the flow`, () => { const out = extractSkillBody(path.join(ROOT, skill)); expect(out).not.toContain('## Preamble (run first)'); + expect(out).not.toContain('## Preamble (after scope gate)'); expect(out).not.toContain('## Telemetry (run last)'); const full = fs.readFileSync(path.join(ROOT, skill, 'SKILL.md'), 'utf-8'); expect(out.length).toBeLessThan(full.length * 0.75); @@ -250,6 +310,15 @@ describe('real-skill pins: body/head extraction used by E2E fixtures', () => { expect(extractSkillBody(path.join(ROOT, 'context-restore'))).toContain('## If no saved contexts exist'); }); + test('the scoped Eng render retains its original scope gate before the workflow', () => { + const file = path.join(ROOT, 'plan-eng-review', 'SKILL.md'); + const full = fs.readFileSync(file, 'utf-8'); + const scopeStart = full.indexOf('## Scope gate'); + const preamble = full.indexOf('## Preamble (after scope gate)'); + expect(scopeStart).toBeGreaterThan(0); expect(preamble).toBeGreaterThan(scopeStart); + expect(extractSkillBody(file)).toContain(full.slice(scopeStart, preamble).trimEnd()); + }); + // The union of skills installed by the routing + opus-47 discovery fixtures. const HEAD_EXTRACTED_SKILLS = [ '', 'qa', 'qa-only', 'ship', 'review', 'plan-ceo-review', 'plan-eng-review',